Release 2.17 (#4836)

see https://github.com/kimai/kimai/pull/4836
This commit is contained in:
Kevin Papst
2024-05-19 17:42:03 +02:00
committed by GitHub
parent 5f7114e008
commit 0c445d1bc4
41 changed files with 318 additions and 307 deletions

View File

@@ -16,30 +16,19 @@ final class ApiRequestMatcher implements RequestMatcherInterface
{
public function matches(Request $request): bool
{
// we do not want to handle URLs that
// we do not want to handle URLs that are not in the API scope
if (!str_starts_with($request->getRequestUri(), '/api/')) {
return false;
}
// API documentation is only available to registered users
// API documentation is only available to registered and logged-in users
if (str_starts_with($request->getRequestUri(), '/api/doc')) {
return false;
}
// let's use this firewall if a Bearer token is set in the header
// other cases like "bearer" are rejected earlier
if (($auth = $request->headers->get('Authorization')) !== null && str_starts_with($auth, 'Bearer ')) {
return true;
}
// let's use this firewall if the deprecated username & token combination is available
if ($request->headers->has(TokenAuthenticator::HEADER_USERNAME) &&
$request->headers->has(TokenAuthenticator::HEADER_TOKEN)) {
return true;
}
// checking for a previous session allows us to skip the API firewall and token access handler
// we simply re-use the existing session when doing API calls from the frontend
// we simply re-use the existing session when doing API calls from the frontend.
// it is not necessary to check headers. if there is no valid session, we should always use this firewall
return !$request->hasPreviousSession();
}
}

View File

@@ -56,5 +56,5 @@ abstract class AbstractRoleCommand extends Command
return Command::SUCCESS;
}
abstract protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role): void;
abstract protected function executeRoleCommand(UserService $userService, SymfonyStyle $output, User $user, bool $super, $role): void;
}

View File

@@ -33,13 +33,13 @@ final class DemoteUserCommand extends AbstractRoleCommand
);
}
protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role): void
protected function executeRoleCommand(UserService $userService, SymfonyStyle $output, User $user, bool $super, $role): void
{
$username = $user->getUserIdentifier();
if ($super) {
if ($user->isSuperAdmin()) {
$user->setSuperAdmin(false);
$manipulator->saveUser($user);
$userService->saveUser($user);
$output->success(sprintf('Super administrator role has been removed from the user "%s".', $username));
} else {
$output->warning(sprintf('User "%s" doesn\'t have the super administrator role.', $username));
@@ -47,7 +47,7 @@ final class DemoteUserCommand extends AbstractRoleCommand
} else {
if ($user->hasRole($role)) {
$user->removeRole($role);
$manipulator->saveUser($user);
$userService->saveUser($user);
$output->success(sprintf('Role "%s" has been removed from user "%s".', $role, $username));
} else {
$output->warning(sprintf('User "%s" didn\'t have "%s" role.', $username, $role));

View File

@@ -33,13 +33,13 @@ final class PromoteUserCommand extends AbstractRoleCommand
);
}
protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role): void
protected function executeRoleCommand(UserService $userService, SymfonyStyle $output, User $user, bool $super, $role): void
{
$username = $user->getUserIdentifier();
if ($super) {
if (!$user->isSuperAdmin()) {
$user->setSuperAdmin(true);
$manipulator->saveUser($user);
$userService->saveUser($user);
$output->success(sprintf('User "%s" has been promoted as a super administrator.', $username));
} else {
$output->warning(sprintf('User "%s" does already have the super administrator role.', $username));
@@ -47,7 +47,7 @@ final class PromoteUserCommand extends AbstractRoleCommand
} else {
if (!$user->hasRole($role)) {
$user->addRole($role);
$manipulator->saveUser($user);
$userService->saveUser($user);
$output->success(sprintf('Role "%s" has been added to user "%s".', $role, $username));
} else {
$output->warning(sprintf('User "%s" did already have "%s" role.', $username, $role));

View File

@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '2.16.1';
public const VERSION = '2.17.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 21601;
public const VERSION_ID = 21700;
/**
* The software name
*/

View File

@@ -31,7 +31,7 @@ final class ContractController extends AbstractController
{
$currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory($currentUser);
$canChangeUser = $this->isGranted('contract_other_profile');
$canChangeUser = $this->isGranted('hours_other_profile');
$defaultDate = $dateTimeFactory->createStartOfYear();
$now = $dateTimeFactory->createDateTime();

View File

@@ -58,7 +58,12 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/admin/project')]
final class ProjectController extends AbstractController
{
public function __construct(private ProjectRepository $repository, private SystemConfiguration $configuration, private EventDispatcherInterface $dispatcher, private ProjectService $projectService)
public function __construct(
private readonly ProjectRepository $repository,
private readonly SystemConfiguration $configuration,
private readonly EventDispatcherInterface $dispatcher,
private readonly ProjectService $projectService
)
{
}
@@ -480,11 +485,17 @@ final class ProjectController extends AbstractController
$csrfTokenManager->refreshToken('project.duplicate');
$newProject = $projectDuplicationService->duplicate($project, $project->getName() . ' [COPY]');
try {
$newProject = $projectDuplicationService->duplicate($project, $project->getName() . ' [COPY]');
$this->flashSuccess('action.update.success');
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('project_details', ['id' => $newProject->getId()]);
} catch (\Exception $ex) {
$this->logException($ex);
$this->flashError('action.update.error', 'Failed to copy project: ' . $ex->getMessage());
}
return $this->redirectToRoute('project_details', ['id' => $newProject->getId()]);
return $this->redirectToRoute('admin_project');
}
#[Route(path: '/{id}/delete', name: 'admin_project_delete', methods: ['GET', 'POST'])]

View File

@@ -9,7 +9,6 @@
namespace App\Controller;
use App\Configuration\SystemConfiguration;
use App\Entity\User;
use App\Event\PrepareUserEvent;
use App\Event\UserPreferenceDisplayEvent;
@@ -29,7 +28,6 @@ use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -41,7 +39,10 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('view_user')]
final class UserController extends AbstractController
{
public function __construct(private UserPasswordHasherInterface $passwordHasher, private UserRepository $repository, private EventDispatcherInterface $dispatcher)
public function __construct(
private readonly UserRepository $repository,
private readonly EventDispatcherInterface $dispatcher
)
{
}
@@ -100,37 +101,23 @@ final class UserController extends AbstractController
]);
}
private function createNewDefaultUser(SystemConfiguration $config): User
{
$user = new User();
$user->setEnabled(true);
$user->setRoles([User::DEFAULT_ROLE]);
$user->setTimezone($config->getUserDefaultTimezone());
$user->setLanguage($config->getUserDefaultLanguage());
return $user;
}
#[Route(path: '/create', name: 'admin_user_create', methods: ['GET', 'POST'])]
#[IsGranted('create_user')]
public function createAction(Request $request, SystemConfiguration $config, UserRepository $userRepository, EventDispatcherInterface $dispatcher): Response
public function createAction(Request $request, UserService $userService, EventDispatcherInterface $dispatcher): Response
{
$user = $this->createNewDefaultUser($config);
$user = $userService->createNewUser();
$editForm = $this->getCreateUserForm($user);
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
$password = $this->passwordHasher->hashPassword($user, $user->getPlainPassword());
$user->setPassword($password);
$userRepository->saveUser($user);
$userService->saveUser($user);
$this->flashSuccess('action.update.success');
try {
$event = new PrepareUserEvent($user, false);
$dispatcher->dispatch($event);
$userRepository->saveUser($user);
$this->repository->saveUser($user);
} catch (\Exception $ex) {
// it should be no problem, if creating default user preferences fails
}

View File

@@ -481,6 +481,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget
$this->addTeam($team);
}
$this->number = null;
$currentMeta = $this->meta;
$this->meta = new ArrayCollection();
/** @var ProjectMeta $meta */

View File

@@ -71,7 +71,7 @@ class Tag
public function setName(?string $tagName): Tag
{
$this->name = $tagName;
$this->name = $tagName !== null ? trim($tagName) : $tagName;
return $this;
}

View File

@@ -154,6 +154,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
private ?string $description = null;
#[ORM\Column(name: 'rate', type: 'float', nullable: false)]
#[Assert\GreaterThanOrEqual(0)]
#[Assert\NotNull]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
private float $rate = 0.00;
@@ -224,8 +225,6 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
/**
* Get entry id, returns null for new entities which were not persisted.
*
* @return int|null
*/
public function getId(): ?int
{
@@ -282,10 +281,6 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
return $this->end === null;
}
/**
* @param DateTime $end
* @return Timesheet
*/
public function setEnd(?DateTime $end): Timesheet
{
$this->end = $end;
@@ -300,10 +295,6 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
return $this;
}
/**
* @param int|null $duration
* @return Timesheet
*/
public function setDuration(?int $duration): Timesheet
{
$this->duration = $duration;
@@ -313,9 +304,6 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
/**
* Do not rely on the results of this method for running records.
*
* @param bool $calculate
* @return int|null
*/
public function getDuration(bool $calculate = true): ?int
{
@@ -384,11 +372,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
return $this->description;
}
/**
* @param float $rate
* @return Timesheet
*/
public function setRate($rate): Timesheet
public function setRate(float $rate): Timesheet
{
$this->rate = $rate;
@@ -454,18 +438,11 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
);
}
/**
* @return bool
*/
public function isExported(): bool
{
return $this->exported;
}
/**
* @param bool $exported
* @return Timesheet
*/
public function setExported(bool $exported): Timesheet
{
$this->exported = $exported;
@@ -473,9 +450,6 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
return $this;
}
/**
* @return string
*/
public function getTimezone(): ?string
{
return $this->timezone;
@@ -486,8 +460,6 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
* It is reserved for some very rare use-cases.
*
* @internal
* @param string $timezone
* @return Timesheet
*/
public function setTimezone(string $timezone): Timesheet
{
@@ -498,8 +470,6 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
/**
* This method returns ALWAYS: "timesheet"
*
* @return string
*/
public function getType(): string
{
@@ -635,6 +605,10 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
public function setMetaField(MetaTableTypeInterface $meta): EntityWithMetaFields
{
// this needs to be done, otherwise doctrine will not see the item as changed
// and the calculators will not run
$this->modifiedAt = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
if (null === ($current = $this->getMetaField($meta->getName()))) {
$meta->setEntity($this);
$this->meta->add($meta);

View File

@@ -141,6 +141,11 @@ class PageActionsEvent extends ThemeEvent
$this->payload['actions'][$key] = null;
}
public function addQuickImport(string $url): void
{
$this->addAction('import', ['url' => $url, 'class' => 'toolbar-action', 'title' => 'import', 'icon' => 'upload', 'translation_domain' => 'actions']);
}
public function addQuickExport(string $url): void
{
$this->addAction('download', ['url' => $url, 'class' => 'toolbar-action', 'title' => 'export']);

View File

@@ -94,7 +94,7 @@ final class MenuSubscriber implements EventSubscriberInterface
}
$contract = new MenuItemModel('contract', 'work_contract', null, [], 'contract');
if ($user->hasContractSettings() || $auth->isGranted('contract_other_profile')) {
if ($user->hasContractSettings() || $auth->isGranted('hours_other_profile')) {
$contract->addChild(new MenuItemModel('contract_status', 'work_times', 'user_contract', [], 'work_times'));
}

View File

@@ -45,29 +45,47 @@ final class TagArrayToStringTransformer implements DataTransformerInterface
*
* @see \Symfony\Bridge\Doctrine\Form\DataTransformer\CollectionToArrayTransformer::reverseTransform()
*
* @param string|null $value
* @param array<string>|string|null $value
* @return Tag[]
* @throws TransformationFailedException
*/
public function reverseTransform(mixed $value): mixed
public function reverseTransform(mixed $value): array
{
// check for empty tag list
if ('' === $value || null === $value) {
return [];
}
$names = array_filter(array_unique(array_map('trim', explode(',', $value))));
if (!\is_array($value)) {
$names = array_filter(array_unique(array_map('trim', explode(',', $value))));
} else {
$names = $value;
}
// get the current tags and find the new ones that should be created
$tags = $this->tagRepository->findBy(['name' => $names]);
if ($this->create) {
// works, because of the implicit case: (string) $tag
$newNames = array_diff($names, $tags);
$tags = [];
foreach ($names as $tagName) {
if ($tagName === null || $tagName === '') {
continue;
}
foreach ($newNames as $name) {
$tagName = trim($tagName);
$tag = null;
if (is_numeric($tagName)) {
$tag = $this->tagRepository->find($tagName);
}
if ($tag === null) {
$tag = $this->tagRepository->findTagByName($tagName);
}
// get the current tags and find the new ones that should be created
if ($this->create && $tag === null) {
$tag = new Tag();
$tag->setName(mb_substr($name, 0, 100));
$tag->setName(mb_substr($tagName, 0, 100));
$this->tagRepository->saveTag($tag);
}
if ($tag !== null) {
$tags[] = $tag;
}
}

View File

@@ -122,7 +122,7 @@ trait FormTrait
return;
}
$options['projects'] = $data['project'];
$options['projects'] = \is_string($data['project']) ? (int) $data['project'] : $data['project'];
$event->getForm()->add('activity', ActivityType::class, $options);
}

View File

@@ -139,7 +139,7 @@ trait ToolbarFormTrait
protected function addPageSizeChoice(FormBuilderInterface $builder): void
{
$builder->add('pageSize', PageSizeType::class);
$builder->add('size', PageSizeType::class);
}
protected function addUserRoleChoice(FormBuilderInterface $builder): void

View File

@@ -36,13 +36,15 @@ final class TagsSelectType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
if (!$options['allow_create']) {
return;
}
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) {
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($options) {
/** @var array<string> $tagIds */
$tagIds = $event->getData();
// this is mainly here, because the link from tags index page uses the non-array syntax
if (\is_string($tagIds) || \is_int($tagIds)) {
$tagIds = array_filter(array_unique(array_map('trim', explode(',', $tagIds))));
}
if (!\is_array($tagIds)) {
return;
}
@@ -59,13 +61,15 @@ final class TagsSelectType extends AbstractType
$tag = $this->tagRepository->findTagByName($tagId);
}
if ($tag === null) {
if ($options['allow_create'] && $tag === null) {
$tag = new Tag();
$tag->setName(mb_substr($tagId, 0, 100));
$tag->setName($tagId);
$this->tagRepository->saveTag($tag);
}
$tags[] = $tag->getId();
if ($tag !== null) {
$tags[] = $tag->getId();
}
}
$event->setData($tags);
@@ -79,6 +83,9 @@ final class TagsSelectType extends AbstractType
'class' => Tag::class,
'label' => 'tag',
'allow_create' => false,
'choice_value' => function (Tag $tag) {
return $tag->getId();
},
'choice_attr' => function (Tag $tag) {
$color = $tag->getColor();
if ($color === null) {

View File

@@ -60,7 +60,7 @@ class Kernel extends BaseKernel
}
}
if ($this->environment === 'test' && getenv('TEST_WITH_BUNDLES') === false) {
if ($this->environment === 'test') {
return;
}
@@ -145,7 +145,7 @@ class Kernel extends BaseKernel
$loader->load($file->getPathname());
}
if (is_file($confDir . '/packages/local.yaml')) {
if ($this->environment !== 'test' && is_file($confDir . '/packages/local.yaml')) {
$loader->load($confDir . '/packages/local.yaml');
}
$loader->load($confDir . '/services' . self::CONFIG_EXTS, 'glob');

View File

@@ -9,9 +9,7 @@
namespace App\Project;
use App\Entity\ActivityRate;
use App\Entity\Project;
use App\Entity\ProjectRate;
use App\Repository\ActivityRateRepository;
use App\Repository\ActivityRepository;
use App\Repository\ProjectRateRepository;
@@ -19,10 +17,10 @@ use App\Repository\ProjectRateRepository;
final class ProjectDuplicationService
{
public function __construct(
private ProjectService $projectService,
private ActivityRepository $activityRepository,
private ProjectRateRepository $projectRateRepository,
private ActivityRateRepository $activityRateRepository
private readonly ProjectService $projectService,
private readonly ActivityRepository $activityRepository,
private readonly ProjectRateRepository $projectRateRepository,
private readonly ActivityRateRepository $activityRateRepository
) {
}
@@ -30,6 +28,7 @@ final class ProjectDuplicationService
{
$newProject = clone $project;
$newProject->setName($newName);
$newProject->setNumber($this->projectService->calculateNextProjectNumber());
foreach ($project->getTeams() as $team) {
$newProject->addTeam($team);
@@ -49,7 +48,6 @@ final class ProjectDuplicationService
$this->projectService->saveNewProject($newProject);
foreach ($this->projectRateRepository->getRatesForProject($project) as $rate) {
/** @var ProjectRate $newRate */
$newRate = clone $rate;
$newRate->setProject($newProject);
$this->projectRateRepository->saveRate($newRate);
@@ -68,7 +66,6 @@ final class ProjectDuplicationService
$this->activityRepository->saveActivity($newActivity);
foreach ($this->activityRateRepository->getRatesForActivity($activity) as $rate) {
/** @var ActivityRate $newRate */
$newRate = clone $rate;
$newRate->setActivity($newActivity);
$this->activityRateRepository->saveRate($newRate);

View File

@@ -112,7 +112,7 @@ final class ProjectService
return $this->repository->findOneBy(['number' => $number]);
}
private function calculateNextProjectNumber(): ?string
public function calculateNextProjectNumber(): ?string
{
$format = $this->configuration->find('project.number_format');
if (empty($format) || !\is_string($format)) {

View File

@@ -30,7 +30,7 @@ class BaseQuery
/** @var array<string, string|int|null|bool|array<mixed>|DateRange> */
private array $defaults = [
'page' => 1,
'pageSize' => self::DEFAULT_PAGESIZE,
'size' => self::DEFAULT_PAGESIZE,
'orderBy' => 'id',
'order' => self::ORDER_ASC,
'searchTerm' => null,
@@ -137,6 +137,16 @@ class BaseQuery
return $this;
}
public function getSize(): int
{
return $this->getPageSize();
}
public function setSize(?int $size): void
{
$this->setPageSize($size);
}
public function getOrderBy(): string
{
return $this->orderBy;

View File

@@ -53,6 +53,8 @@ final class SessionHandler extends PdoSessionHandler
if (false === $limit->isAccepted()) {
throw new BadRequestHttpException('Too many requests with invalid Session ID. Prediction attack?');
}
usleep(250000); // slow down potential attacks
}
return $result;

View File

@@ -60,6 +60,7 @@ class UserService
{
$user = new User();
$user->setEnabled(true);
$user->setRoles([User::DEFAULT_ROLE]);
$user->setTimezone($this->configuration->getUserDefaultTimezone());
$user->setLanguage($this->configuration->getUserDefaultLanguage());
$user->setPreferenceValue(UserPreference::SKIN, $this->configuration->getUserDefaultTheme());
@@ -92,6 +93,7 @@ class UserService
$this->hashPassword($user);
$this->hashApiToken($user);
$user->eraseCredentials();
$this->dispatcher->dispatch(new UserCreatePreEvent($user)); // @CloudRequired
$this->repository->saveUser($user);
@@ -120,6 +122,7 @@ class UserService
$this->hashPassword($user);
$this->hashApiToken($user);
$user->eraseCredentials();
$this->dispatcher->dispatch(new UserUpdatePreEvent($user));
$this->repository->saveUser($user);
@@ -179,7 +182,6 @@ class UserService
$password = $this->passwordHasher->hashPassword($user, $plain);
$user->setPassword($password);
$user->eraseCredentials();
}
private function hashApiToken(User $user): void
@@ -192,7 +194,6 @@ class UserService
$password = $this->passwordHasher->hashPassword($user, $plain);
$user->setApiToken($password);
$user->eraseCredentials();
}
public function deleteUser(User $delete, ?User $replace = null): void

View File

@@ -35,6 +35,7 @@ final class UserVoter extends Voter
'hourly-rate',
'view_team_member',
'contract',
'hours',
'supervisor',
];
@@ -73,6 +74,10 @@ final class UserVoter extends Voter
return $this->permissionManager->hasRolePermission($user, 'contract_other_profile');
}
if ($attribute === 'hours') {
return $this->permissionManager->hasRolePermission($user, 'hours_other_profile');
}
if ($attribute === 'access_user') {
return $user->canSeeUser($subject);
}