Release 2.57 (#5929)
This commit is contained in:
@@ -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')])]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()]));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()]));
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
44
src/Form/API/CommentApiForm.php
Normal file
44
src/Form/API/CommentApiForm.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ final class UserType extends AbstractType
|
||||
return $a->getDisplayName() <=> $b->getDisplayName();
|
||||
});
|
||||
|
||||
return array_values($userById);
|
||||
return $userById;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
29
src/Invoice/InvoicePeriod.php
Normal file
29
src/Invoice/InvoicePeriod.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -287,7 +287,7 @@ final class LocaleFormatter
|
||||
try {
|
||||
$date = new \DateTimeImmutable($date);
|
||||
} catch (Exception $ex) {
|
||||
return $date;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
29
src/Validator/Constraints/TimesheetTeamAccess.php
Normal file
29
src/Validator/Constraints/TimesheetTeamAccess.php
Normal 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;
|
||||
}
|
||||
}
|
||||
124
src/Validator/Constraints/TimesheetTeamAccessValidator.php
Normal file
124
src/Validator/Constraints/TimesheetTeamAccessValidator.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user