Release 2.57 (#5929)

This commit is contained in:
Kevin Papst
2026-05-21 22:14:10 +02:00
committed by GitHub
parent f0ce1c7bd6
commit 976d38e8a4
101 changed files with 4226 additions and 1119 deletions

View File

@@ -191,7 +191,7 @@ final class ActivityController extends BaseApiController
* Delete activity
*
* [DANGER] This will also delete ALL linked timesheets.
* Do you want to use `PATCH` instead and mark it as inactive with `{visible: false}` instead?
* Do you want to use `PATCH` instead and mark it as inactive with `{visible: false}`?
*/
#[IsGranted('delete', 'activity')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one activity')])]

View File

@@ -11,8 +11,10 @@ namespace App\API;
use App\Customer\CustomerService;
use App\Entity\Customer;
use App\Entity\CustomerComment;
use App\Entity\CustomerRate;
use App\Entity\User;
use App\Form\API\CommentApiForm;
use App\Form\API\CustomerApiEditForm;
use App\Form\API\CustomerRateApiForm;
use App\Repository\CustomerRateRepository;
@@ -35,6 +37,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Customer')]
final class CustomerController extends BaseApiController
{
private const GROUPS_COMMENT = ['Default', 'Not_Expanded'];
public const GROUPS_ENTITY = ['Default', 'Entity', 'Customer', 'Customer_Entity'];
public const GROUPS_COLLECTION = ['Default', 'Collection', 'Customer'];
public const GROUPS_RATE = ['Default', 'Entity', 'Customer_Rate'];
@@ -182,7 +185,7 @@ final class CustomerController extends BaseApiController
* Delete customer
*
* [DANGER] This will also delete ALL linked projects, project activities and timesheets.
* Do you want to use `PATCH` instead and mark it as inactive with `{visible: false}` instead?
* Do you want to use `PATCH` instead and mark it as inactive with `{visible: false}`?
*/
#[IsGranted('delete', 'customer')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one customer')])]
@@ -299,4 +302,103 @@ final class CustomerController extends BaseApiController
return $this->viewHandler->handle($view);
}
/**
* Fetch comments for customer
*/
#[IsGranted('view', 'customer')]
#[IsGranted('comments', 'customer')]
#[OA\Response(response: 200, description: 'Returns a collection of customer comments', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Comment')))]
#[OA\Parameter(name: 'id', description: 'The customer whose comments will be returned', in: 'path', required: true)]
#[Route(path: '/{id}/comments', name: 'get_customer_comments', requirements: ['id' => '\d+'], methods: ['GET'])]
public function getCommentsAction(#[MapEntity(mapping: ['id' => 'id'])] Customer $customer): Response
{
$comments = $this->repository->getComments($customer);
$view = new View($comments, 200);
$view->getContext()->setGroups(self::GROUPS_COMMENT);
return $this->viewHandler->handle($view);
}
/**
* Add comment for customer
*/
#[IsGranted('view', 'customer')]
#[IsGranted('comments', 'customer')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the newly created customer comment', content: new OA\JsonContent(ref: '#/components/schemas/Comment'))])]
#[OA\Parameter(name: 'id', description: 'The customer to add the comment for', in: 'path', required: true)]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/CommentForm'))]
#[Route(path: '/{id}/comments', name: 'post_customer_comment', requirements: ['id' => '\d+'], methods: ['POST'])]
public function postCommentAction(#[MapEntity(mapping: ['id' => 'id'])] Customer $customer, Request $request): Response
{
$comment = new CustomerComment($customer);
$comment->setCreatedBy($this->getUser());
$form = $this->createForm(CommentApiForm::class, $comment, [
'method' => 'POST',
]);
$form->setData($comment);
$form->submit($request->request->all(), false);
if (false === $form->isValid()) {
return $this->viewHandler->handle(new View($form, Response::HTTP_BAD_REQUEST));
}
$this->repository->saveComment($comment);
$view = new View($comment, 200);
$view->getContext()->setGroups(self::GROUPS_COMMENT);
return $this->viewHandler->handle($view);
}
/**
* Pin customer comment
*
* This toggles the `pinned` status of the given comment.
*/
#[IsGranted('view', 'customer')]
#[IsGranted('edit', 'customer')]
#[IsGranted('comments', 'customer')]
#[OA\Patch(responses: [new OA\Response(response: 200, description: 'Returns the updated customer comment', content: new OA\JsonContent(ref: '#/components/schemas/Comment'))])]
#[OA\Parameter(name: 'id', description: 'The customer whose comment will be pinned or unpinned', in: 'path', required: true)]
#[OA\Parameter(name: 'comment', description: 'The comment whose pinned status will be toggled', in: 'path', required: true)]
#[Route(path: '/{id}/comments/{comment}/pin', name: 'toggle_customer_comment_pin', requirements: ['id' => '\d+', 'comment' => '\d+'], methods: ['PATCH'])]
public function toggleCommentPin(#[MapEntity(mapping: ['id' => 'id'])] Customer $customer, #[MapEntity(mapping: ['comment' => 'id'])] CustomerComment $comment): Response
{
if ($comment->getCustomer() !== $customer) {
throw $this->createAccessDeniedException(\sprintf('Comment %s does not belong to customer %s', $comment->getId(), $customer->getId()));
}
$comment->setPinned(!$comment->isPinned());
$this->repository->saveComment($comment);
$view = new View($comment, 200);
$view->getContext()->setGroups(self::GROUPS_COMMENT);
return $this->viewHandler->handle($view);
}
/**
* Delete customer comment
*/
#[IsGranted('view', 'customer')]
#[IsGranted('edit', 'customer')]
#[IsGranted('comments', 'customer')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Returns no content: 204 on successful delete')])]
#[OA\Parameter(name: 'id', description: 'The customer whose comment will be removed', in: 'path', required: true)]
#[OA\Parameter(name: 'comment', description: 'The comment to remove', in: 'path', required: true)]
#[Route(path: '/{id}/comments/{comment}', name: 'delete_customer_comment', requirements: ['id' => '\d+', 'comment' => '\d+'], methods: ['DELETE'])]
public function deleteCommentAction(#[MapEntity(mapping: ['id' => 'id'])] Customer $customer, #[MapEntity(mapping: ['comment' => 'id'])] CustomerComment $comment): Response
{
if ($comment->getCustomer() !== $customer) {
throw $this->createAccessDeniedException(\sprintf('Comment %s does not belong to customer %s', $comment->getId(), $customer->getId()));
}
$this->repository->deleteComment($comment);
return $this->viewHandler->handle(new View(null, Response::HTTP_NO_CONTENT));
}
}

View File

@@ -10,8 +10,10 @@
namespace App\API;
use App\Entity\Project;
use App\Entity\ProjectComment;
use App\Entity\ProjectRate;
use App\Entity\User;
use App\Form\API\CommentApiForm;
use App\Form\API\ProjectApiEditForm;
use App\Form\API\ProjectRateApiForm;
use App\Project\ProjectService;
@@ -37,6 +39,7 @@ use Symfony\Component\Validator\Constraints;
#[OA\Tag(name: 'Project')]
final class ProjectController extends BaseApiController
{
private const GROUPS_COMMENT = ['Default', 'Not_Expanded'];
public const GROUPS_ENTITY = ['Default', 'Entity', 'Project', 'Project_Entity'];
public const GROUPS_COLLECTION = ['Default', 'Collection', 'Project'];
public const GROUPS_RATE = ['Default', 'Entity', 'Project_Rate'];
@@ -238,7 +241,7 @@ final class ProjectController extends BaseApiController
* Delete project
*
* [DANGER] This will also delete ALL linked activities and timesheets.
* Do you want to use `PATCH` instead and mark it as inactive with `{visible: false}` instead?
* Do you want to use `PATCH` instead and mark it as inactive with `{visible: false}`?
*/
#[IsGranted('delete', 'project')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one project')])]
@@ -355,4 +358,103 @@ final class ProjectController extends BaseApiController
return $this->viewHandler->handle($view);
}
/**
* Fetch comments for project
*/
#[IsGranted('view', 'project')]
#[IsGranted('comments', 'project')]
#[OA\Response(response: 200, description: 'Returns a collection of project comments', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Comment')))]
#[OA\Parameter(name: 'id', description: 'The project whose comments will be returned', in: 'path', required: true)]
#[Route(path: '/{id}/comments', name: 'get_project_comments', requirements: ['id' => '\d+'], methods: ['GET'])]
public function getCommentsAction(#[MapEntity(mapping: ['id' => 'id'])] Project $project): Response
{
$comments = $this->repository->getComments($project);
$view = new View($comments, 200);
$view->getContext()->setGroups(self::GROUPS_COMMENT);
return $this->viewHandler->handle($view);
}
/**
* Add comment for project
*/
#[IsGranted('view', 'project')]
#[IsGranted('comments', 'project')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the newly created project comment', content: new OA\JsonContent(ref: '#/components/schemas/Comment'))])]
#[OA\Parameter(name: 'id', description: 'The project to add the comment for', in: 'path', required: true)]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/CommentForm'))]
#[Route(path: '/{id}/comments', name: 'post_project_comment', requirements: ['id' => '\d+'], methods: ['POST'])]
public function postCommentAction(#[MapEntity(mapping: ['id' => 'id'])] Project $project, Request $request): Response
{
$comment = new ProjectComment($project);
$comment->setCreatedBy($this->getUser());
$form = $this->createForm(CommentApiForm::class, $comment, [
'method' => 'POST',
]);
$form->setData($comment);
$form->submit($request->request->all(), false);
if (false === $form->isValid()) {
return $this->viewHandler->handle(new View($form, Response::HTTP_BAD_REQUEST));
}
$this->repository->saveComment($comment);
$view = new View($comment, 200);
$view->getContext()->setGroups(self::GROUPS_COMMENT);
return $this->viewHandler->handle($view);
}
/**
* Pin project comment
*
* This toggles the `pinned` status of the given comment.
*/
#[IsGranted('view', 'project')]
#[IsGranted('edit', 'project')]
#[IsGranted('comments', 'project')]
#[OA\Patch(responses: [new OA\Response(response: 200, description: 'Returns the updated project comment', content: new OA\JsonContent(ref: '#/components/schemas/Comment'))])]
#[OA\Parameter(name: 'id', description: 'The project whose comment will be pinned or unpinned', in: 'path', required: true)]
#[OA\Parameter(name: 'comment', description: 'The comment whose pinned status will be toggled', in: 'path', required: true)]
#[Route(path: '/{id}/comments/{comment}/pin', name: 'toggle_project_comment_pin', requirements: ['id' => '\d+', 'comment' => '\d+'], methods: ['PATCH'])]
public function toggleCommentPin(#[MapEntity(mapping: ['id' => 'id'])] Project $project, #[MapEntity(mapping: ['comment' => 'id'])] ProjectComment $comment): Response
{
if ($comment->getProject() !== $project) {
throw $this->createAccessDeniedException(\sprintf('Comment %s does not belong to project %s', $comment->getId(), $project->getId()));
}
$comment->setPinned(!$comment->isPinned());
$this->repository->saveComment($comment);
$view = new View($comment, 200);
$view->getContext()->setGroups(self::GROUPS_COMMENT);
return $this->viewHandler->handle($view);
}
/**
* Delete project comment
*/
#[IsGranted('view', 'project')]
#[IsGranted('edit', 'project')]
#[IsGranted('comments', 'project')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Returns no content: 204 on successful delete')])]
#[OA\Parameter(name: 'id', description: 'The project whose comment will be removed', in: 'path', required: true)]
#[OA\Parameter(name: 'comment', description: 'The comment to remove', in: 'path', required: true)]
#[Route(path: '/{id}/comments/{comment}', name: 'delete_project_comment', requirements: ['id' => '\d+', 'comment' => '\d+'], methods: ['DELETE'])]
public function deleteCommentAction(#[MapEntity(mapping: ['id' => 'id'])] Project $project, #[MapEntity(mapping: ['comment' => 'id'])] ProjectComment $comment): Response
{
if ($comment->getProject() !== $project) {
throw $this->createAccessDeniedException(\sprintf('Comment %s does not belong to project %s', $comment->getId(), $project->getId()));
}
$this->repository->deleteComment($comment);
return $this->viewHandler->handle(new View(null, Response::HTTP_NO_CONTENT));
}
}

View File

@@ -113,6 +113,9 @@ final class TimesheetController extends BaseApiController
if (!$seeAll) {
foreach ($userRepository->findByIds($users) as $user) {
if (!$this->isGranted('access_user', $user)) {
throw $this->createAccessDeniedException('Cannot access user: ' . $user->getId());
}
$query->addUser($user);
}
}

View File

@@ -175,6 +175,11 @@ final class SystemConfiguration
return (bool) $this->find('user.registration');
}
public function getAuthenticationTheme(): string
{
return $this->getString('user.theme', 'auto');
}
public function getPasswordResetTokenLifetime(): int
{
return (int) $this->find('user.password_reset_token_ttl');

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.56.0';
public const VERSION = '2.57.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 25600;
public const VERSION_ID = 25700;
/**
* The software name
*/

View File

@@ -189,6 +189,10 @@ final class ActivityController extends AbstractController
#[IsGranted('edit', 'activity')]
public function editRateAction(Activity $activity, ActivityRate $rate, Request $request, ActivityRateRepository $repository): Response
{
if ($rate->getActivity() !== $activity) {
throw $this->createAccessDeniedException('Trying to edit rate and activity that do not belong together.');
}
return $this->rateFormAction($activity, $rate, $request, $repository, $this->generateUrl('admin_activity_rate_edit', ['id' => $activity->getId(), 'rate' => $rate->getId()]));
}
@@ -231,6 +235,7 @@ final class ActivityController extends AbstractController
#[Route(path: '/create/{project}', name: 'admin_activity_create_with_project', methods: ['GET', 'POST'])]
#[IsGranted('create_activity')]
#[IsGranted('edit', 'project')]
public function createWithProjectAction(Project $project, Request $request, ActivityService $activityService, SystemConfiguration $configuration): Response
{
return $this->createActivity($request, $activityService, $configuration, $project);

View File

@@ -44,8 +44,6 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
@@ -170,29 +168,6 @@ final class CustomerController extends AbstractController
]);
}
#[Route(path: '/{id}/comment_delete/{token}', name: 'customer_comment_delete', methods: ['GET'])]
#[IsGranted(new Expression("is_granted('edit', subject.getCustomer()) and is_granted('comments', subject.getCustomer())"), 'comment')]
public function deleteCommentAction(CustomerComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
{
$customerId = $comment->getCustomer()->getId();
if (!$csrfTokenManager->isTokenValid(new CsrfToken('comment.delete', $token))) {
$this->flashError('action.csrf.error');
return $this->redirectToRoute('customer_details', ['id' => $customerId]);
}
$csrfTokenManager->refreshToken('comment.delete');
try {
$this->repository->deleteComment($comment);
} catch (\Exception $ex) {
$this->flashDeleteException($ex);
}
return $this->redirectToRoute('customer_details', ['id' => $customerId]);
}
#[Route(path: '/{id}/comment_add', name: 'customer_comment_add', methods: ['POST'])]
#[IsGranted('comments', 'customer')]
public function addCommentAction(Customer $customer, Request $request): Response
@@ -213,30 +188,6 @@ final class CustomerController extends AbstractController
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
}
#[Route(path: '/{id}/comment_pin/{token}', name: 'customer_comment_pin', methods: ['GET'])]
#[IsGranted(new Expression("is_granted('edit', subject.getCustomer()) and is_granted('comments', subject.getCustomer())"), 'comment')]
public function pinCommentAction(CustomerComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
{
$customerId = $comment->getCustomer()->getId();
if (!$csrfTokenManager->isTokenValid(new CsrfToken('comment.pin', $token))) {
$this->flashError('action.csrf.error');
return $this->redirectToRoute('customer_details', ['id' => $customerId]);
}
$csrfTokenManager->refreshToken('comment.pin');
$comment->setPinned(!$comment->isPinned());
try {
$this->repository->saveComment($comment);
} catch (\Exception $ex) {
$this->flashUpdateException($ex);
}
return $this->redirectToRoute('customer_details', ['id' => $customerId]);
}
#[Route(path: '/{id}/create_team', name: 'customer_team_create', methods: ['GET'])]
#[IsGranted('create_team')]
#[IsGranted('permissions', 'customer')]
@@ -371,6 +322,10 @@ final class CustomerController extends AbstractController
#[IsGranted('edit', 'customer')]
public function editRateAction(Customer $customer, CustomerRate $rate, Request $request, CustomerRateRepository $repository): Response
{
if ($rate->getCustomer() !== $customer) {
throw $this->createAccessDeniedException('Trying to edit rate and customer that do not belong together.');
}
return $this->rateFormAction($customer, $rate, $request, $repository, $this->generateUrl('admin_customer_rate_edit', ['id' => $customer->getId(), 'rate' => $rate->getId()]));
}

View File

@@ -28,6 +28,7 @@ final class FavoriteController extends AbstractController
#[Route(path: '/timesheet/add/{id}', name: 'favorites_timesheets_add', methods: ['GET'])]
#[IsGranted('start_own_timesheet')]
#[IsGranted('is_owner', 'timesheet')]
public function add(Timesheet $timesheet, FavoriteRecordService $favoriteRecordService): Response
{
$favoriteRecordService->addFavorite($timesheet);
@@ -37,6 +38,7 @@ final class FavoriteController extends AbstractController
#[Route(path: '/timesheet/remove/{id}', name: 'favorites_timesheets_remove', methods: ['GET'])]
#[IsGranted('start_own_timesheet')]
#[IsGranted('is_owner', 'timesheet')]
public function remove(Timesheet $timesheet, FavoriteRecordService $favoriteRecordService): Response
{
$favoriteRecordService->removeFavorite($timesheet);

View File

@@ -161,6 +161,7 @@ final class ProjectController extends AbstractController
#[Route(path: '/create/{customer}', name: 'admin_project_create_with_customer', methods: ['GET', 'POST'])]
#[IsGranted('create_project')]
#[IsGranted('edit', 'customer')]
public function createWithCustomerAction(Request $request, Customer $customer, ProjectService $projectService, SystemConfiguration $configuration): Response
{
return $this->createProject($request, $projectService, $configuration, $customer);
@@ -198,29 +199,6 @@ final class ProjectController extends AbstractController
]);
}
#[Route(path: '/{id}/comment_delete/{token}', name: 'project_comment_delete', methods: ['GET'])]
#[IsGranted(new Expression("is_granted('edit', subject.getProject()) and is_granted('comments', subject.getProject())"), 'comment')]
public function deleteCommentAction(ProjectComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
{
$projectId = $comment->getProject()->getId();
if (!$csrfTokenManager->isTokenValid(new CsrfToken('comment.delete', $token))) {
$this->flashError('action.csrf.error');
return $this->redirectToRoute('project_details', ['id' => $projectId]);
}
$csrfTokenManager->refreshToken('comment.delete');
try {
$this->repository->deleteComment($comment);
} catch (\Exception $ex) {
$this->flashDeleteException($ex);
}
return $this->redirectToRoute('project_details', ['id' => $projectId]);
}
#[Route(path: '/{id}/comment_add', name: 'project_comment_add', methods: ['POST'])]
#[IsGranted('comments', 'project')]
public function addCommentAction(Project $project, Request $request): Response
@@ -241,30 +219,6 @@ final class ProjectController extends AbstractController
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
}
#[Route(path: '/{id}/comment_pin/{token}', name: 'project_comment_pin', methods: ['GET'])]
#[IsGranted(new Expression("is_granted('edit', subject.getProject()) and is_granted('comments', subject.getProject())"), 'comment')]
public function pinCommentAction(ProjectComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
{
$projectId = $comment->getProject()->getId();
if (!$csrfTokenManager->isTokenValid(new CsrfToken('comment.pin', $token))) {
$this->flashError('action.csrf.error');
return $this->redirectToRoute('project_details', ['id' => $projectId]);
}
$csrfTokenManager->refreshToken('comment.pin');
$comment->setPinned(!$comment->isPinned());
try {
$this->repository->saveComment($comment);
} catch (\Exception $ex) {
$this->flashUpdateException($ex);
}
return $this->redirectToRoute('project_details', ['id' => $projectId]);
}
#[Route(path: '/{id}/create_team', name: 'project_team_create', methods: ['GET'])]
#[IsGranted('create_team')]
#[IsGranted('permissions', 'project')]
@@ -394,6 +348,10 @@ final class ProjectController extends AbstractController
#[IsGranted('edit', 'project')]
public function editRateAction(Project $project, ProjectRate $rate, Request $request, ProjectRateRepository $repository): Response
{
if ($rate->getProject() !== $project) {
throw $this->createAccessDeniedException('Trying to edit rate and project that do not belong together.');
}
return $this->rateFormAction($project, $rate, $request, $repository, $this->generateUrl('admin_project_rate_edit', ['id' => $project->getId(), 'rate' => $rate->getId()]));
}

View File

@@ -277,6 +277,9 @@ final class SystemConfigurationController extends AbstractController
->setLabel('user_auth_password_reset_token_ttl')
->setConstraints([new NotNull(), new GreaterThanOrEqual(['value' => 60])])
->setType(IntegerType::class),
(new Configuration('user.theme'))
->setLabel('skin')
->setType(SkinType::class),
]);
$allowRegistration = $this->systemConfiguration->find('features.user_registration');

View File

@@ -556,6 +556,15 @@ final class Configuration implements ConfigurationInterface
->booleanNode('login')
->defaultTrue()
->end()
->scalarNode('theme')
->defaultValue('auto')
->validate()
->ifTrue(static function ($v) {
return (!\in_array($v, ['auto', 'default', 'dark']));
})
->thenInvalid('The theme must be one of: "auto", "default", "dark"')
->end()
->end()
->booleanNode('registration')
->defaultFalse()
->end()
@@ -625,7 +634,15 @@ final class Configuration implements ConfigurationInterface
->children()
->scalarNode('timezone')->defaultNull()->end()
->scalarNode('language')->defaultValue(User::DEFAULT_LANGUAGE)->end()
->scalarNode('theme')->defaultValue('auto')->end()
->scalarNode('theme')
->defaultValue('auto')
->validate()
->ifTrue(static function ($v) {
return (!\in_array($v, ['auto', 'default', 'dark']));
})
->thenInvalid('The theme must be one of: "auto", "default", "dark"')
->end()
->end()
->end()
->end()

View File

@@ -11,6 +11,7 @@ namespace App\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;
trait CommentTableTypeTrait
@@ -18,19 +19,29 @@ trait CommentTableTypeTrait
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(name: 'id', type: Types::INTEGER)]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
private ?int $id = null;
#[ORM\Column(name: 'message', type: Types::TEXT, nullable: false)]
#[Assert\NotNull]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
private ?string $message = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
private ?User $createdBy = null;
#[ORM\Column(name: 'created_at', type: Types::DATETIME_MUTABLE, nullable: false)]
#[Assert\NotNull]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
private ?\DateTime $createdAt = null;
#[ORM\Column(name: 'pinned', type: Types::BOOLEAN, nullable: false, options: ['default' => false])]
#[Assert\NotNull]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
private bool $pinned = false;
public function getId(): ?int

View File

@@ -10,12 +10,14 @@
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_customers_comments')]
#[ORM\Index(columns: ['customer_id'])]
#[ORM\Entity]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[Serializer\ExclusionPolicy('all')]
class CustomerComment implements CommentInterface
{
use CommentTableTypeTrait;

View File

@@ -10,12 +10,14 @@
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_projects_comments')]
#[ORM\Index(columns: ['project_id'])]
#[ORM\Entity]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[Serializer\ExclusionPolicy('all')]
class ProjectComment implements CommentInterface
{
use CommentTableTypeTrait;

View File

@@ -113,11 +113,13 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
private ?string $avatar = null;
/**
* API token (password) for this user
* @deprecated since 2.55
*/
#[ORM\Column(name: 'api_token', type: Types::STRING, length: 255, nullable: true)]
private ?string $apiToken = null;
/**
* @internal to be set via form, must not be persisted
* @deprecated since 2.55
*/
#[Assert\NotBlank(groups: ['ApiTokenUpdate'])]
#[Assert\Length(min: 8, max: 60, groups: ['ApiTokenUpdate'])]
@@ -298,11 +300,17 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
return $this;
}
/**
* @deprecated since 2.57
*/
public function getApiToken(): ?string
{
return $this->apiToken;
}
/**
* @deprecated since 2.57
*/
public function setApiToken(?string $apiToken): User
{
$this->apiToken = $apiToken;
@@ -316,16 +324,23 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
#[Serializer\VirtualProperty]
#[Serializer\SerializedName('apiToken')]
#[Serializer\Groups(['Default'])]
#[OA\Property(description: 'DEPRECATED - switch to API tokens instead', deprecated: true)]
public function hasApiToken(): bool
{
return $this->apiToken !== null;
}
/**
* @deprecated since 2.57
*/
public function getPlainApiToken(): ?string
{
return $this->plainApiToken;
}
/**
* @deprecated since 2.57
*/
public function setPlainApiToken(?string $plainApiToken): User
{
$this->plainApiToken = $plainApiToken;
@@ -644,6 +659,8 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
/**
* Use this function to check if the current user can read data from the given user.
*
* @deprecated since 2.57 use RolePermissionManager::checkUserAccess() or is_granted('access_user', user)
*/
public function canSeeUser(User $user): bool
{
@@ -651,7 +668,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
return true;
}
if ($this->canSeeAllData()) {
if ($this->isSuperAdmin() || $this->canSeeAllData()) {
return true;
}
@@ -667,9 +684,21 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
return true;
}
// special case: the requested user is in no team and the current user is a teamlead.
// this configuration is likely in new installations with small teams, and
// it is allowed for teamleads to see other users data by definition
if ($this->hasTeamleadRole() && $user->isRegularUserOnly()) {
return \count($user->getTeams()) === 0;
}
return false;
}
public function isRegularUserOnly(): bool
{
return $this->getRoles() === [static::DEFAULT_ROLE];
}
/**
* List of all teams, this user is part of
*
@@ -837,7 +866,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
public function eraseCredentials(): void
{
$this->plainPassword = null;
$this->plainApiToken = null;
$this->plainApiToken = null; // @phpstan-ignore property.deprecated
}
public function hasUsername(): bool

View File

@@ -10,6 +10,7 @@
namespace App\EventSubscriber;
use App\Configuration\LocaleService;
use App\Configuration\SystemConfiguration;
use App\Entity\User;
use KevinPapst\TablerBundle\Helper\ContextHelper;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
@@ -25,7 +26,8 @@ final class ThemeOptionsSubscriber implements EventSubscriberInterface
public function __construct(
private readonly TokenStorageInterface $storage,
private readonly ContextHelper $helper,
private readonly LocaleService $localeService
private readonly LocaleService $localeService,
private readonly SystemConfiguration $systemConfiguration,
)
{
}
@@ -48,18 +50,13 @@ final class ThemeOptionsSubscriber implements EventSubscriberInterface
$this->helper->setIsRightToLeft(true);
}
// ignore events like the toolbar where we do not have a token
if (null === $this->storage->getToken()) {
return;
$skin = $this->systemConfiguration->getAuthenticationTheme();
$user = $this->storage->getToken()?->getUser();
if ($user instanceof User) {
$skin = $user->getSkin();
}
$user = $this->storage->getToken()->getUser();
if (!($user instanceof User)) {
return;
}
$skin = $user->getSkin();
if ($skin === 'dark') {
$this->helper->setIsDarkMode(true);
$this->helper->setThemeAuto(false);

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\API;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class CommentApiForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('pinned', CheckboxType::class, [
'required' => false,
'documentation' => [
'default' => false,
'description' => 'Pinned comments always appear first'
],
]);
$builder->add('message', TextareaType::class, [
'label' => false,
'documentation' => [
'description' => 'The actual comment (markdown is supported)'
],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'csrf_protection' => false,
]);
}
}

View File

@@ -13,6 +13,9 @@ use App\Utils\SearchTerm;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
/**
* @implements DataTransformerInterface<SearchTerm, string>
*/
final class SearchTermTransformer implements DataTransformerInterface
{
/**

View File

@@ -62,31 +62,21 @@ trait FormTrait
/** @var array<string, mixed> $data */
$data = $event->getData();
$customer = \array_key_exists('customer', $data) && $data['customer'] !== '' ? $data['customer'] : null;
$project = \array_key_exists('project', $data) && $data['project'] !== '' ? $data['project'] : $project;
$event->getForm()->add('project', ProjectType::class, array_merge($options, [
'group_by' => null,
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer, $isNew) {
// is there a better way to prevent starting a record with a hidden project ?
$project = \is_string($project) ? (int) $project : $project;
$customer = \is_string($customer) ? (int) $customer : $customer;
if ($isNew && \is_int($project)) {
/** @var Project $project */
$project = $repo->find($project);
if ($project !== null) {
if (!$project->getCustomer()->isVisible()) {
$customer = null;
$project = null;
} elseif (!$project->isVisible()) {
$project = null;
}
if ($isNew && $project instanceof Project) {
if (!$project->getCustomer()->isVisible()) {
$customer = null;
$project = null;
} elseif (!$project->isVisible()) {
$project = null;
}
}
if ($project !== null && !\is_int($project) && !($project instanceof Project)) {
throw new \InvalidArgumentException('Project type needs a project object or an ID');
}
if ($customer !== null && !\is_int($customer) && !($customer instanceof Customer)) {
throw new \InvalidArgumentException('Project type needs a customer object or an ID');
}

View File

@@ -17,11 +17,14 @@ use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @template T of object
*/
final class MultiUpdateTable extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
/** @var EntityRepository $repository */
/** @var EntityRepository<T> $repository */
$repository = $options['repository'];
/** @var MultiUpdateTableDTO $dto */
$dto = $options['data'];

View File

@@ -19,6 +19,9 @@ use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @implements DataTransformerInterface<string, string>
*/
final class ColorChoiceType extends AbstractType implements DataTransformerInterface
{
public function __construct(private readonly SystemConfiguration $systemConfiguration)

View File

@@ -16,6 +16,9 @@ use Symfony\Component\Form\Extension\Core\Type\ColorType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @implements DataTransformerInterface<string, string>
*/
final class ColorPickerType extends AbstractType implements DataTransformerInterface
{
public const DEFAULT_COLOR = Constants::DEFAULT_COLOR;
@@ -37,15 +40,21 @@ final class ColorPickerType extends AbstractType implements DataTransformerInter
]);
}
/**
* @return string
*/
public function transform(mixed $data): mixed
{
if (empty($data)) {
if (!\is_string($data) || $data === '') {
return self::DEFAULT_COLOR;
}
return $data;
}
/**
* @return string
*/
public function reverseTransform(mixed $value): mixed
{
return null === $value ? self::DEFAULT_COLOR : $value;

View File

@@ -90,7 +90,11 @@ final class QuickEntryWeekType extends AbstractType
}
}
$event->getForm()->add('activity', ActivityType::class, $activityOptions);
// exported entries cause the dropdown to be deactivated
// we need to make sure to fetch the info before the field is replaced
// see https://github.com/kimai/kimai/issues/5642
$disabled = $event->getForm()->get('activity')->isDisabled();
$event->getForm()->add('activity', ActivityType::class, array_merge(['disabled' => $disabled], $activityOptions));
};
$builder->addEventListener(FormEvents::PRE_SUBMIT, $activityPreSubmitFunction);

View File

@@ -135,7 +135,7 @@ final class UserType extends AbstractType
return $a->getDisplayName() <=> $b->getDisplayName();
});
return array_values($userById);
return $userById;
});
}

View File

@@ -97,11 +97,6 @@ class UserEditType extends AbstractType
]);
}
$builder->add('systemAccount', YesNoType::class, [
'label' => 'system_account',
'help' => 'system_account.help',
]);
if ($options['include_supervisor']) {
$builder->add('supervisor', UserType::class, [
'required' => false,
@@ -111,6 +106,11 @@ class UserEditType extends AbstractType
}
if ($options['include_password_reset']) {
$builder->add('systemAccount', YesNoType::class, [
'label' => 'system_account',
'help' => 'system_account.help',
]);
$builder->add('requiresPasswordReset', YesNoType::class, [
'label' => 'force_password_change',
'help' => 'force_password_change_help',

View File

@@ -174,29 +174,19 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
}
}
$entries = $model->getEntries();
$min = null;
$max = null;
$period = $model->getInvoicePeriod();
$min = $period->getStart();
$max = $period->getEnd();
foreach ($entries as $entry) {
if ($min === null || $min->getBegin() > $entry->getBegin()) {
$min = $entry;
}
if ($max === null || $max->getBegin() < $entry->getBegin()) {
$max = $entry;
}
}
if ($min !== null && $max !== null) {
$values = array_merge($values, [
'invoice.first' => $formatter->getFormattedDateTime($min->getBegin()),
'invoice.first_process' => $min->getBegin()?->format(self::DATE_PROCESS_FORMAT), // since 2.14
'invoice.last' => $formatter->getFormattedDateTime($max->getEnd()),
'invoice.last_process' => $max->getEnd()?->format(self::DATE_PROCESS_FORMAT), // since 2.14
]);
}
return $values;
return array_merge($values, [
'invoice.first' => $formatter->getFormattedDateTime($min),
'invoice.first_process' => $min->format(self::DATE_PROCESS_FORMAT), // since 2.14
'invoice.first_month' => $formatter->getFormattedMonthName($min), // since 2.57
'invoice.first_year' => $min->format('Y'), // since 2.57
'invoice.last' => $formatter->getFormattedDateTime($max),
'invoice.last_process' => $max->format(self::DATE_PROCESS_FORMAT), // since 2.14
'invoice.last_month' => $formatter->getFormattedMonthName($max), // since 2.57
'invoice.last_year' => $max->format('Y'), // since 2.57
]);
}
}

View File

@@ -37,13 +37,18 @@ final class InvoiceModelProjectHydrator implements InvoiceModelHydrator
}
}
if (\count($projects) === 0) {
return [];
$counter = \count($projects);
$values = [
'project._counter' => $counter,
];
if ($counter === 0) {
return $values;
}
$projects = array_values($projects);
$values = [];
$i = 0;
foreach ($projects as $project) {

View File

@@ -100,6 +100,35 @@ final class InvoiceModel
return $this->entries;
}
public function getInvoicePeriod(): InvoicePeriod
{
$min = null;
$max = null;
foreach ($this->getEntries() as $entry) {
if ($min === null || $min > $entry->getBegin()) {
$min = $entry->getBegin();
}
if ($max === null || $max < $entry->getEnd()) {
$max = $entry->getEnd();
}
}
if ($min === null) {
$min = $this->getQuery()?->getBegin() ?? $this->invoiceDate;
}
if ($max === null) {
$max = $this->getQuery()?->getEnd() ?? $this->invoiceDate;
}
return new InvoicePeriod(
\DateTimeImmutable::createFromInterface($min),
\DateTimeImmutable::createFromInterface($max)
);
}
/**
* @param ExportableItem[] $entries
* @return InvoiceModel

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice;
use DateTimeInterface;
final readonly class InvoicePeriod
{
public function __construct(private DateTimeInterface $start, private DateTimeInterface $end)
{
}
public function getStart(): DateTimeInterface
{
return $this->start;
}
public function getEnd(): DateTimeInterface
{
return $this->end;
}
}

View File

@@ -91,7 +91,7 @@ abstract class AbstractSpreadsheetRenderer extends AbstractRenderer
continue;
}
// we ONLY check if the given replacer content contains a formula character
if (\is_string($content) && \in_array($content[0], ['=', '-', '+', '@', "\t", "\r"])) {
if (\is_string($content) && $content !== '' && \in_array($content[0], ['=', '-', '+', '@', "\t", "\r"])) {
$contentLooksLikeFormula = true;
}
$value = str_replace($searchKey, $content ?? '', $value);

View File

@@ -73,10 +73,10 @@ class QuickEntryWeek
$result = 0;
} elseif ($aName === null && $bName !== null) {
$result = 1;
} elseif ($aName !== null && $bName === null) {
} elseif ($aName !== null && $bName === null) { // @phpstan-ignore notIdentical.alwaysTrue
$result = -1;
} else {
$result = strcmp((string) $aName, (string) $bName);
$result = strcmp($aName, $bName);
}
return $result < 0 ? -1 : 1;

View File

@@ -198,4 +198,36 @@ final class RolePermissionManager
return $this->checkTeamLeadAccess($timesheet->getUser()?->getTeams() ?? [], $user);
}
public function checkUserAccess(User $subject, User $user): bool
{
if ($subject->getId() === $user->getId()) {
return true;
}
if ($user->isSuperAdmin() || $user->canSeeAllData()) {
return true;
}
if (!$subject->isEnabled()) {
return false;
}
if (!$user->isSystemAccount() && $subject->isSystemAccount()) {
return false;
}
if ($user->isTeamleadOfUser($subject)) {
return true;
}
// special case: the requested user is in no team and the current user is a teamlead.
// this configuration is likely in new installations with small teams, and
// it is allowed for teamleads to see other users data by definition
if (($user->hasTeamleadRole() || $user->isAdmin()) && $subject->isRegularUserOnly()) {
return \count($subject->getTeams()) === 0;
}
return false;
}
}

View File

@@ -12,6 +12,7 @@ namespace App\Twig\Runtime;
use Psr\Container\ContainerInterface;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
use Twig\Error\RuntimeError;
use Twig\Extension\RuntimeExtensionInterface;
final class EncoreExtension implements RuntimeExtensionInterface, ServiceSubscriberInterface
@@ -32,12 +33,19 @@ final class EncoreExtension implements RuntimeExtensionInterface, ServiceSubscri
public function getEncoreEntryCssSource(string $packageName): string
{
if (!\in_array($packageName, ['invoice', 'invoice-pdf', 'export-pdf'])) {
throw new RuntimeError('Unknown CSS package requested: ' . $packageName);
}
$lookup = $this->container->get(EntrypointLookupInterface::class);
$files = $lookup->getCssFiles($packageName);
$source = '';
foreach ($files as $file) {
if (!str_ends_with($file, '.css') || str_contains($file, '..')) {
continue;
}
$source .= file_get_contents($this->projectDirectory . '/public/' . $file);
}

View File

@@ -287,7 +287,7 @@ final class LocaleFormatter
try {
$date = new \DateTimeImmutable($date);
} catch (Exception $ex) {
return $date;
return null;
}
}

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Validator\Constraints;
#[\Attribute(\Attribute::TARGET_CLASS)]
final class TimesheetTeamAccess extends TimesheetConstraint
{
public const PROJECT_ACCESS_ERROR = 'kimai-timesheet-team-project';
public const ACTIVITY_ACCESS_ERROR = 'kimai-timesheet-team-activity';
protected const ERROR_NAMES = [
self::PROJECT_ACCESS_ERROR => 'You are not allowed to use this project.',
self::ACTIVITY_ACCESS_ERROR => 'You are not allowed to use this activity.',
];
public string $message = 'This timesheet has invalid settings.';
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
}

View File

@@ -0,0 +1,124 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Validator\Constraints;
use App\Entity\Activity;
use App\Entity\Project;
use App\Entity\Timesheet as TimesheetEntity;
use App\Entity\User;
use App\Security\RolePermissionManager;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class TimesheetTeamAccessValidator extends ConstraintValidator
{
public function __construct(
private readonly Security $security,
private readonly RolePermissionManager $permissionManager,
private readonly ManagerRegistry $registry,
)
{
}
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof TimesheetTeamAccess)) {
throw new UnexpectedTypeException($constraint, TimesheetTeamAccess::class);
}
if (!\is_object($value) || !($value instanceof TimesheetEntity)) {
throw new UnexpectedTypeException($value, TimesheetEntity::class);
}
$user = $this->security->getUser();
if (!($user instanceof User) || $user->canSeeAllData()) {
return;
}
$originalData = $this->getOriginalData($value);
$project = $value->getProject();
if ($project !== null && $this->hasAssociationChanged($value, 'project', $project, $originalData)) {
if (!$this->permissionManager->checkTeamAccessProject($project, $user)) {
$this->context->buildViolation(TimesheetTeamAccess::getErrorName(TimesheetTeamAccess::PROJECT_ACCESS_ERROR))
->atPath('project')
->setTranslationDomain('validators')
->setCode(TimesheetTeamAccess::PROJECT_ACCESS_ERROR)
->addViolation();
}
}
$activity = $value->getActivity();
if ($activity !== null && $this->hasAssociationChanged($value, 'activity', $activity, $originalData)) {
if (!$this->permissionManager->checkTeamAccessActivity($activity, $user)) {
$this->context->buildViolation(TimesheetTeamAccess::getErrorName(TimesheetTeamAccess::ACTIVITY_ACCESS_ERROR))
->atPath('activity')
->setTranslationDomain('validators')
->setCode(TimesheetTeamAccess::ACTIVITY_ACCESS_ERROR)
->addViolation();
}
}
}
/**
* @return array<string, mixed>
*/
private function getOriginalData(TimesheetEntity $timesheet): array
{
if ($timesheet->getId() === null) {
return [];
}
$manager = $this->registry->getManagerForClass(TimesheetEntity::class);
if (!($manager instanceof EntityManagerInterface)) {
return [];
}
return $manager->getUnitOfWork()->getOriginalEntityData($timesheet);
}
/**
* @param array<string, mixed> $originalData
*/
private function hasAssociationChanged(TimesheetEntity $timesheet, string $field, Project|Activity $current, array $originalData): bool
{
if ($timesheet->getId() === null) {
return true;
}
if (!\array_key_exists($field, $originalData)) {
return true;
}
$original = $originalData[$field];
if ($original === null) {
return true;
}
if ($original === $current) {
return false;
}
if (!\is_object($original) || !method_exists($original, 'getId')) {
return true;
}
if ($original->getId() === null || $current->getId() === null) {
return true;
}
return $original->getId() !== $current->getId();
}
}

View File

@@ -48,7 +48,8 @@ final class TimesheetVoter extends Voter
self::EDIT_RATE,
self::EDIT_EXPORT,
'edit_billable',
'duplicate'
'duplicate',
'is_owner',
];
private ?bool $lockdownGrace = null;
@@ -89,6 +90,9 @@ final class TimesheetVoter extends Voter
$permission = '';
switch ($attribute) {
case 'is_owner':
return (!$subject instanceof MultiUserTimesheet) && $user === $subject->getUser();
case self::START:
if (!$this->canStart($subject)) {
return false;

View File

@@ -75,7 +75,7 @@ final class UserVoter extends Voter
}
if ($attribute === 'access_user') {
return $user->canSeeUser($subject);
return $this->permissionManager->checkUserAccess($subject, $user);
}
if ($attribute === 'view_team_member') {