diff --git a/config/packages/kimai.yaml b/config/packages/kimai.yaml index df256f50..3145037a 100644 --- a/config/packages/kimai.yaml +++ b/config/packages/kimai.yaml @@ -1,10 +1,12 @@ kimai: + # -------------------------------------------------------------------------------- # Settings for the user management and login forms user: registration: true password_reset: true + # -------------------------------------------------------------------------------- # All configs related to timesheet and record management timesheet: @@ -33,6 +35,7 @@ kimai: # days: ['saturday','sunday'] # factor: 1.5 + # -------------------------------------------------------------------------------- # Invoice management #invoice: # # all files in these directories will be used as invoice documents (if supported by a renderer) @@ -40,6 +43,45 @@ kimai: # - 'var/invoices/' # - 'templates/invoice/renderer/' + # -------------------------------------------------------------------------------- + # Default settings used to populate forms + #defaults: + # customer: + # timezone: Europe/Berlin + # country: DE + # currency: EUR + + # -------------------------------------------------------------------------------- + # Free configurable permission system, see var/docs/permissions.md + permissions: + # mapping complex rulesets of single permissions to named "sets" ("set name" = [array of "permissions"]) + sets: + ACTIVITIES: [view_activity,create_activity,edit_activity,delete_activity] + PROJECTS: [view_project,create_project,edit_project,delete_project] + CUSTOMERS: [view_customer,create_customer,edit_customer,delete_customer] + INVOICE: [view_invoice,create_invoice] + INVOICE_TEMPLATE: [view_invoice_template,create_invoice_template,edit_invoice_template,delete_invoice_template] + TIMESHEET: [view_own_timesheet,start_own_timesheet,stop_own_timesheet,create_own_timesheet,edit_own_timesheet,export_own_timesheet,delete_own_timesheet] + TIMESHEET_OTHER: [view_other_timesheet,start_other_timesheet,stop_other_timesheet,create_other_timesheet,edit_other_timesheet,delete_other_timesheet] + PROFILE: [view_own_profile,edit_own_profile,password_own_profile,preferences_own_profile,api-token_own_profile] + PROFILE_OTHER: [view_other_profile,edit_other_profile,delete_other_profile,password_other_profile,roles_other_profile,preferences_other_profile,api-token_other_profile] + USER: [view_user,create_user,delete_user] + RATE: [view_rate_own_timesheet,edit_rate_own_timesheet] + RATE_OTHER: [view_rate_other_timesheet,edit_rate_other_timesheet] + # mapping "sets" or permissions to user roles ("role name" = [array of "set names"]) + maps: + ROLE_USER: [TIMESHEET,PROFILE] + ROLE_TEAMLEAD: [INVOICE,TIMESHEET,TIMESHEET_OTHER,PROFILE] + ROLE_ADMIN: [ACTIVITIES,PROJECTS,CUSTOMERS,INVOICE,INVOICE_TEMPLATE,TIMESHEET,TIMESHEET_OTHER,PROFILE,RATE,RATE_OTHER] + ROLE_SUPER_ADMIN: [ACTIVITIES,PROJECTS,CUSTOMERS,INVOICE,INVOICE_TEMPLATE,TIMESHEET,TIMESHEET_OTHER,PROFILE,PROFILE_OTHER,USER,RATE,RATE_OTHER] + # adding single permissions to user roles, extending the definition from "sets" ("role name" = [array of "permissions"]) + roles: + ROLE_USER: [] + ROLE_TEAMLEAD: [view_invoice_template,create_invoice_template,edit_invoice_template,view_rate_own_timesheet,view_rate_other_timesheet,hourly-rate_own_profile] + ROLE_ADMIN: [hourly-rate_own_profile] + ROLE_SUPER_ADMIN: [hourly-rate_own_profile,hourly-rate_other_profile,delete_own_profile,roles_own_profile] + + # -------------------------------------------------------------------------------- # Language specific settings, like the date formats languages: de: @@ -75,6 +117,7 @@ kimai: duration: '%%h:%%m óra' duration_short: '%%h:%%m h' + # -------------------------------------------------------------------------------- # timesheet calendar configuration calendar: week_numbers: true @@ -93,6 +136,7 @@ kimai: # id: 'de.german#holiday@group.v.calendar.google.com' # color: '#ccc' + # -------------------------------------------------------------------------------- # theme related settings, will be available as twig globals at "kimai_context.*" # please see documentation at var/docs/theme.md theme: @@ -105,6 +149,7 @@ kimai: # possible options: blue, black, purple, yellow, red, green box_color: 'green' + # -------------------------------------------------------------------------------- # Dashboard widget sections, please see documentation at var/docs/dashboard.md dashboard: user_duration: @@ -133,6 +178,7 @@ kimai: permission: ROLE_ADMIN widgets: [amountToday, amountWeek, amountMonth, amountYear] + # -------------------------------------------------------------------------------- # All available widgets, please see documentation at var/docs/dashboard.md widgets: userDurationToday: { title: stats.durationToday, query: duration, user: true, begin: '00:00:00', end: '23:59:59', icon: duration, color: green } @@ -161,10 +207,3 @@ kimai: activeUsersYear: { title: stats.userActiveYear, query: users, begin: '01 january this year 00:00:00', end: '31 december this year 23:59:59', icon: user, color: yellow } activeUsersTotal: { title: stats.userActiveTotal, query: users, icon: user, color: red } activeRecordings: { title: stats.activeRecordings, query: active, icon: duration, color: red } - - # Default settings used to populate forms - #defaults: - # customer: - # timezone: Europe/Berlin - # country: DE - # currency: EUR \ No newline at end of file diff --git a/config/packages/security.yaml b/config/packages/security.yaml index a936332a..c5ee3f24 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -47,5 +47,5 @@ security: - { path: '^/(%app_locales%)/login', role: IS_AUTHENTICATED_ANONYMOUSLY } - { path: '^/(%app_locales%)/register', role: IS_AUTHENTICATED_ANONYMOUSLY } - { path: '^/(%app_locales%)/resetting', role: IS_AUTHENTICATED_ANONYMOUSLY } - - { path: '^/(%app_locales%)/admin', roles: ROLE_ADMIN } + - { path: '^/(%app_locales%)/', roles: ROLE_USER } - { path: '^/api', roles: IS_AUTHENTICATED_REMEMBERED } diff --git a/config/services.yaml b/config/services.yaml index 50d0c8ba..f5865be7 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -118,6 +118,13 @@ services: App\Timesheet\Calculator\RateCalculator: arguments: ["%kimai.timesheet.rates%"] + # ================================================================================ + # SECURITY & VOTER + # ================================================================================ + + App\Security\RolePermissionManager: + arguments: ["%kimai.permissions%"] + # ================================================================================ # REPOSITORIES # ================================================================================ diff --git a/src/API/TimesheetController.php b/src/API/TimesheetController.php index 0f710af4..f782e905 100644 --- a/src/API/TimesheetController.php +++ b/src/API/TimesheetController.php @@ -28,8 +28,6 @@ use Symfony\Component\HttpFoundation\Response; /** * @RouteResource("Timesheet") - * - * @Security("is_granted('ROLE_USER')") */ class TimesheetController extends BaseApiController { @@ -67,6 +65,8 @@ class TimesheetController extends BaseApiController * @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order (allowed values: 'ASC', 'DESC')") * @Rest\QueryParam(name="orderBy", requirements="id|begin|end|rate", strict=true, nullable=true, description="The field by which results will be ordered (allowed values: 'id', 'begin', 'end', 'rate')") * + * @Security("is_granted('view_own_timesheet')") + * * @return Response */ public function cgetAction(ParamFetcherInterface $paramFetcher) @@ -120,6 +120,8 @@ class TimesheetController extends BaseApiController * @SWG\Schema(ref="#/definitions/TimesheetEntity") * ) * + * @Security("is_granted('view_own_timesheet')") + * * @param int $id * @return Response */ @@ -146,6 +148,8 @@ class TimesheetController extends BaseApiController * ) * ) * + * @Security("is_granted('create_own_timesheet')") + * * @param Request $request * @return Response */ diff --git a/src/API/UserController.php b/src/API/UserController.php index c8342ee0..b3b93b36 100644 --- a/src/API/UserController.php +++ b/src/API/UserController.php @@ -22,9 +22,6 @@ use Symfony\Component\HttpFoundation\Response; /** * @RouteResource("User") - * - * @Security("is_granted('ROLE_SUPER_ADMIN')") - * @Security("is_granted('IS_AUTHENTICATED_FULLY')") */ class UserController extends BaseApiController { @@ -55,6 +52,8 @@ class UserController extends BaseApiController * @SWG\Schema(ref="#/definitions/UserCollection"), * ) * + * @Security("is_granted('view_user')") + * * @return Response */ public function cgetAction() @@ -73,6 +72,8 @@ class UserController extends BaseApiController * @SWG\Schema(ref="#/definitions/UserEntity"), * ) * + * @Security("is_granted('view_user')") + * * @param int $id * @return Response */ diff --git a/src/Controller/Admin/ActivityController.php b/src/Controller/Admin/ActivityController.php index d21c1323..1fd40a57 100644 --- a/src/Controller/Admin/ActivityController.php +++ b/src/Controller/Admin/ActivityController.php @@ -27,8 +27,7 @@ use Symfony\Component\Routing\Annotation\Route; * Controller used to manage activities in the admin part of the site. * * @Route(path="/admin/activity") - * @Security("is_granted('ROLE_ADMIN')") - * @Security("is_granted('IS_AUTHENTICATED_FULLY')") + * @Security("is_granted('view_activity')") */ class ActivityController extends AbstractController { @@ -44,6 +43,11 @@ class ActivityController extends AbstractController * @Route(path="/", defaults={"page": 1}, name="admin_activity", methods={"GET"}) * @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_activity_paginated", methods={"GET"}) * @Cache(smaxage="10") + * @Security("is_granted('view_activity')") + * + * @param int $page + * @param Request $request + * @return \Symfony\Component\HttpFoundation\Response */ public function indexAction($page, Request $request) { @@ -71,6 +75,7 @@ class ActivityController extends AbstractController /** * @Route(path="/create", name="admin_activity_create", methods={"GET", "POST"}) + * @Security("is_granted('create_activity')") * * @param Request $request * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response @@ -94,8 +99,6 @@ class ActivityController extends AbstractController } /** - * The route to delete an existing entry. - * * @Route(path="/{id}/delete", name="admin_activity_delete", methods={"GET", "POST"}) * @Security("is_granted('delete', activity)") * diff --git a/src/Controller/Admin/CustomerController.php b/src/Controller/Admin/CustomerController.php index 7774a896..a134d97e 100644 --- a/src/Controller/Admin/CustomerController.php +++ b/src/Controller/Admin/CustomerController.php @@ -26,8 +26,7 @@ use Symfony\Component\Routing\Annotation\Route; * Controller used to manage activities in the admin part of the site. * * @Route(path="/admin/customer") - * @Security("is_granted('ROLE_ADMIN')") - * @Security("is_granted('IS_AUTHENTICATED_FULLY')") + * @Security("is_granted('view_customer')") */ class CustomerController extends AbstractController { @@ -55,6 +54,11 @@ class CustomerController extends AbstractController /** * @Route(path="/", defaults={"page": 1}, name="admin_customer", methods={"GET"}) * @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_customer_paginated", methods={"GET"}) + * @Security("is_granted('view_customer')") + * + * @param int $page + * @param Request $request + * @return \Symfony\Component\HttpFoundation\Response */ public function indexAction($page, Request $request) { @@ -81,6 +85,10 @@ class CustomerController extends AbstractController /** * @Route(path="/create", name="admin_customer_create", methods={"GET", "POST"}) + * @Security("is_granted('create_customer')") + * + * @param Request $request + * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response */ public function createAction(Request $request) { @@ -95,6 +103,10 @@ class CustomerController extends AbstractController /** * @Route(path="/{id}/edit", name="admin_customer_edit", methods={"GET", "POST"}) * @Security("is_granted('edit', customer)") + * + * @param Customer $customer + * @param Request $request + * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response */ public function editAction(Customer $customer, Request $request) { @@ -102,35 +114,6 @@ class CustomerController extends AbstractController } /** - * @param Customer $customer - * @param Request $request - * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response - */ - protected function renderCustomerForm(Customer $customer, Request $request) - { - $editForm = $this->createEditForm($customer); - - $editForm->handleRequest($request); - - if ($editForm->isSubmitted() && $editForm->isValid()) { - $entityManager = $this->getDoctrine()->getManager(); - $entityManager->persist($customer); - $entityManager->flush(); - - $this->flashSuccess('action.update.success'); - - return $this->redirectToRoute('admin_customer'); - } - - return $this->render('admin/customer_edit.html.twig', [ - 'customer' => $customer, - 'form' => $editForm->createView() - ]); - } - - /** - * The route to delete an existing entry. - * * @Route(path="/{id}/delete", name="admin_customer_delete", methods={"GET", "POST"}) * @Security("is_granted('delete', customer)") * @@ -179,6 +162,33 @@ class CustomerController extends AbstractController ]); } + /** + * @param Customer $customer + * @param Request $request + * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response + */ + protected function renderCustomerForm(Customer $customer, Request $request) + { + $editForm = $this->createEditForm($customer); + + $editForm->handleRequest($request); + + if ($editForm->isSubmitted() && $editForm->isValid()) { + $entityManager = $this->getDoctrine()->getManager(); + $entityManager->persist($customer); + $entityManager->flush(); + + $this->flashSuccess('action.update.success'); + + return $this->redirectToRoute('admin_customer'); + } + + return $this->render('admin/customer_edit.html.twig', [ + 'customer' => $customer, + 'form' => $editForm->createView() + ]); + } + /** * @param CustomerQuery $query * @return \Symfony\Component\Form\FormInterface diff --git a/src/Controller/Admin/ProjectController.php b/src/Controller/Admin/ProjectController.php index ccfb6824..01e84b88 100644 --- a/src/Controller/Admin/ProjectController.php +++ b/src/Controller/Admin/ProjectController.php @@ -28,8 +28,7 @@ use Symfony\Component\Routing\Annotation\Route; * Controller used to manage projects in the admin part of the site. * * @Route(path="/admin/project") - * @Security("is_granted('ROLE_ADMIN')") - * @Security("is_granted('IS_AUTHENTICATED_FULLY')") + * @Security("is_granted('view_project')") */ class ProjectController extends AbstractController { @@ -45,6 +44,11 @@ class ProjectController extends AbstractController * @Route(path="/", defaults={"page": 1}, name="admin_project", methods={"GET"}) * @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_project_paginated", methods={"GET"}) * @Cache(smaxage="10") + * @Security("is_granted('view_project')") + * + * @param int $page + * @param Request $request + * @return \Symfony\Component\HttpFoundation\Response */ public function indexAction($page, Request $request) { @@ -72,6 +76,10 @@ class ProjectController extends AbstractController /** * @Route(path="/create", name="admin_project_create", methods={"GET", "POST"}) + * @Security("is_granted('create_project')") + * + * @param Request $request + * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response */ public function createAction(Request $request) { @@ -81,6 +89,10 @@ class ProjectController extends AbstractController /** * @Route(path="/{id}/edit", name="admin_project_edit", methods={"GET", "POST"}) * @Security("is_granted('edit', project)") + * + * @param Project $project + * @param Request $request + * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response */ public function editAction(Project $project, Request $request) { @@ -88,8 +100,6 @@ class ProjectController extends AbstractController } /** - * The route to delete an existing entry. - * * @Route(path="/{id}/delete", name="admin_project_delete", methods={"GET", "POST"}) * @Security("is_granted('delete', project)") * diff --git a/src/Controller/Admin/TimesheetController.php b/src/Controller/Admin/TimesheetController.php index 6a0137d2..b35ab296 100644 --- a/src/Controller/Admin/TimesheetController.php +++ b/src/Controller/Admin/TimesheetController.php @@ -24,8 +24,7 @@ use Symfony\Component\Routing\Annotation\Route; * Controller used for manage timesheet entries in the admin part of the site. * * @Route(path="/team/timesheet") - * @Security("is_granted('ROLE_TEAMLEAD')") - * @Security("is_granted('IS_AUTHENTICATED_FULLY')") + * @Security("is_granted('view_other_timesheet')") */ class TimesheetController extends AbstractController { @@ -41,10 +40,9 @@ class TimesheetController extends AbstractController } /** - * This route shows all users timesheet entries. - * * @Route(path="/", defaults={"page": 1}, name="admin_timesheet", methods={"GET"}) * @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_timesheet_paginated", methods={"GET"}) + * @Security("is_granted('view_other_timesheet')") * * @param $page * @param Request $request @@ -81,8 +79,6 @@ class TimesheetController extends AbstractController } /** - * The route to stop a running entry. - * * @Route(path="/{id}/stop", name="admin_timesheet_stop", methods={"GET"}) * @Security("is_granted('stop', entry)") * @@ -95,8 +91,6 @@ class TimesheetController extends AbstractController } /** - * The route to edit an existing entry. - * * @Route(path="/{id}/edit", name="admin_timesheet_edit", methods={"GET", "POST"}) * @Security("is_granted('edit', entry)") * @@ -110,9 +104,8 @@ class TimesheetController extends AbstractController } /** - * The route to create a new entry by form. - * * @Route(path="/create", name="admin_timesheet_create", methods={"GET", "POST"}) + * @Security("is_granted('create_other_timesheet')") * * @param Request $request * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response @@ -123,8 +116,6 @@ class TimesheetController extends AbstractController } /** - * The route to delete an existing entry. - * * @Route(path="/{id}/delete", defaults={"page": 1}, name="admin_timesheet_delete", methods={"GET", "POST"}) * @Security("is_granted('delete', entry)") * @@ -157,7 +148,8 @@ class TimesheetController extends AbstractController 'action' => $this->generateUrl('admin_timesheet_create'), 'method' => 'POST', 'duration_only' => $this->isDurationOnlyMode(), - 'include_user' => true + 'include_rate' => $this->isGranted('edit_rate', $entry), + 'include_user' => true, ]); } @@ -175,7 +167,8 @@ class TimesheetController extends AbstractController ]), 'method' => 'POST', 'duration_only' => $this->isDurationOnlyMode(), - 'include_user' => true + 'include_rate' => $this->isGranted('edit_rate', $entry), + 'include_user' => true, ]); } diff --git a/src/Controller/Admin/UserController.php b/src/Controller/Admin/UserController.php index 8eb428e6..36adf7f4 100644 --- a/src/Controller/Admin/UserController.php +++ b/src/Controller/Admin/UserController.php @@ -25,8 +25,7 @@ use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; * Controller used to manage users in the admin part of the site. * * @Route(path="/admin/user") - * @Security("is_granted('ROLE_SUPER_ADMIN')") - * @Security("is_granted('IS_AUTHENTICATED_FULLY')") + * @Security("is_granted('view_user')") */ class UserController extends AbstractController { @@ -54,7 +53,8 @@ class UserController extends AbstractController /** * @Route(path="/", defaults={"page": 1}, name="admin_user", methods={"GET"}) * @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_user_paginated", methods={"GET"}) - + * @Security("is_granted('view_user')") + * * @param int $page * @param Request $request * @return \Symfony\Component\HttpFoundation\Response @@ -84,7 +84,10 @@ class UserController extends AbstractController /** * @Route(path="/create", name="admin_user_create", methods={"GET", "POST"}) - * @Security("is_granted('create', user)") + * @Security("is_granted('create_user')") + * + * @param Request $request + * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response */ public function createAction(Request $request) { @@ -124,8 +127,6 @@ class UserController extends AbstractController } /** - * The route to delete an existing user. - * * @Route(path="/{id}/delete", name="admin_user_delete", methods={"GET", "POST"}) * @Security("is_granted('delete', userToDelete)") * @@ -136,6 +137,7 @@ class UserController extends AbstractController */ public function deleteAction(User $userToDelete, Request $request) { + // $userToDelete MUST not be called $user, as $user is always the current user! $stats = $this->getDoctrine()->getRepository(Timesheet::class)->getUserStatistics($userToDelete); $deleteForm = $this->createFormBuilder() diff --git a/src/Controller/InvoiceController.php b/src/Controller/InvoiceController.php index 01518c84..2dbfc338 100644 --- a/src/Controller/InvoiceController.php +++ b/src/Controller/InvoiceController.php @@ -28,7 +28,7 @@ use Symfony\Component\Routing\Annotation\Route; * Controller used to manage invoices. * * @Route(path="/invoice") - * @Security("is_granted('ROLE_TEAMLEAD')") + * @Security("is_granted('view_invoice') or is_granted('view_invoice_template')") */ class InvoiceController extends AbstractController { @@ -77,7 +77,7 @@ class InvoiceController extends AbstractController /** * @Route(path="/", name="invoice", methods={"GET", "POST"}) - * @Security("is_granted('view', 'invoice')") + * @Security("is_granted('view_invoice')") * * @param Request $request * @return \Symfony\Component\HttpFoundation\Response @@ -111,7 +111,7 @@ class InvoiceController extends AbstractController /** * @Route(path="/print", name="invoice_print", methods={"GET", "POST"}) - * @Security("is_granted('create', 'invoice')") + * @Security("is_granted('create_invoice')") * * @param Request $request * @return \Symfony\Component\HttpFoundation\Response @@ -212,7 +212,7 @@ class InvoiceController extends AbstractController /** * @Route(path="/template", defaults={"page": 1}, name="admin_invoice_template", methods={"GET", "POST"}) * @Route(path="/template/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_invoice_template_paginated", methods={"GET", "POST"}) - * @Security("is_granted('view', 'invoice_template')") + * @Security("is_granted('view_invoice_template')") * * @param $page * @return \Symfony\Component\HttpFoundation\Response @@ -244,7 +244,7 @@ class InvoiceController extends AbstractController /** * @Route(path="/template/create", name="admin_invoice_template_create", methods={"GET", "POST"}) * @Route(path="/template/create/{id}", name="admin_invoice_template_copy", methods={"GET", "POST"}) - * @Security("is_granted('create', 'invoice_template')") + * @Security("is_granted('create_invoice_template')") * * @param Request $request * @param InvoiceTemplate|null $template diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php index 44e5cf9d..f68fccb4 100644 --- a/src/Controller/ProfileController.php +++ b/src/Controller/ProfileController.php @@ -11,6 +11,7 @@ namespace App\Controller; use App\Entity\Timesheet; use App\Entity\User; +use App\Event\PrepareUserEvent; use App\Form\UserApiTokenType; use App\Form\UserEditType; use App\Form\UserPasswordType; @@ -19,6 +20,7 @@ use App\Form\UserRolesType; use App\Repository\TimesheetRepository; use App\Voter\UserVoter; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Form\Form; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Annotation\Route; @@ -28,10 +30,15 @@ use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; * User profile controller * * @Route(path="/profile") - * @Security("is_granted('ROLE_USER')") + * @Security("is_granted('view_own_profile') or is_granted('view_other_profile')") */ class ProfileController extends AbstractController { + /** + * @var EventDispatcherInterface + */ + protected $dispatcher; + /** * @var UserPasswordEncoderInterface */ @@ -40,9 +47,10 @@ class ProfileController extends AbstractController /** * @param UserPasswordEncoderInterface $encoder */ - public function __construct(UserPasswordEncoderInterface $encoder) + public function __construct(UserPasswordEncoderInterface $encoder, EventDispatcherInterface $dispatcher) { $this->encoder = $encoder; + $this->dispatcher = $dispatcher; } /** @@ -162,6 +170,10 @@ class ProfileController extends AbstractController */ public function savePreferencesAction(User $profile, Request $request) { + // we need to prepare the user preferences, which is done via an EventSubscriber + $event = new PrepareUserEvent($profile); + $this->dispatcher->dispatch(PrepareUserEvent::PREPARE, $event); + $original = []; foreach ($profile->getPreferences() as $preference) { $original[$preference->getName()] = $preference; @@ -194,8 +206,11 @@ class ProfileController extends AbstractController $this->flashSuccess('action.update.success'); - // switch locale if neccessary - $locale = $profile->getPreferenceValue('language', $request->getLocale()); + // switch locale ONLY if updated profile is the current user + $locale = $request->getLocale(); + if ($this->getUser()->getId() === $profile->getId()) { + $locale = $profile->getPreferenceValue('language', $locale); + } return $this->redirectToRoute('user_profile_preferences', [ '_locale' => $locale, @@ -269,6 +284,10 @@ class ProfileController extends AbstractController */ private function createPreferencesForm(User $user) { + // we need to prepare the user preferences, which is done via an EventSubscriber + $event = new PrepareUserEvent($user); + $this->dispatcher->dispatch(PrepareUserEvent::PREPARE, $event); + return $this->createForm( UserPreferencesForm::class, $user, diff --git a/src/Controller/TimesheetController.php b/src/Controller/TimesheetController.php index b0421f8d..f7674a77 100644 --- a/src/Controller/TimesheetController.php +++ b/src/Controller/TimesheetController.php @@ -14,7 +14,6 @@ use App\Form\TimesheetEditForm; use App\Form\Toolbar\TimesheetToolbarForm; use App\Repository\Query\TimesheetQuery; use Pagerfanta\Pagerfanta; -use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Annotation\Route; @@ -24,7 +23,7 @@ use Symfony\Component\Validator\Validator\ValidatorInterface; * Controller used to manage timesheets. * * @Route(path="/timesheet") - * @Security("is_granted('ROLE_USER')") + * @Security("is_granted('view_own_timesheet')") */ class TimesheetController extends AbstractController { @@ -41,7 +40,7 @@ class TimesheetController extends AbstractController /** * @Route(path="/", defaults={"page": 1}, name="timesheet", methods={"GET"}) * @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="timesheet_paginated", methods={"GET"}) - * @Cache(smaxage="10") + * @Security("is_granted('view_own_timesheet')") * * @param int $page * @param Request $request @@ -81,6 +80,7 @@ class TimesheetController extends AbstractController /** * @Route(path="/export", name="timesheet_export", methods={"GET"}) + * @Security("is_granted('export_own_timesheet')") * * @param Request $request * @return \Symfony\Component\HttpFoundation\Response @@ -114,8 +114,6 @@ class TimesheetController extends AbstractController } /** - * The "main button and fly-out" for displaying (and stopping) active entries. - * * @return \Symfony\Component\HttpFoundation\Response */ public function activeEntriesAction() @@ -144,8 +142,6 @@ class TimesheetController extends AbstractController } /** - * The route to re-start a timesheet entry. - * * @Route(path="/start/{id}", name="timesheet_start", requirements={"id" = "\d+"}, methods={"GET", "POST"}) * @Security("is_granted('start', timesheet)") * @@ -182,8 +178,6 @@ class TimesheetController extends AbstractController } /** - * The route to edit an existing entry. - * * @Route(path="/{id}/edit", name="timesheet_edit", methods={"GET", "POST"}) * @Security("is_granted('edit', entry)") * @@ -201,9 +195,8 @@ class TimesheetController extends AbstractController } /** - * The route to create a new entry by form. - * * @Route(path="/create", name="timesheet_create", methods={"GET", "POST"}) + * @Security("is_granted('create_own_timesheet')") * * @param Request $request * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response @@ -214,8 +207,6 @@ class TimesheetController extends AbstractController } /** - * The route to delete an existing entry. - * * @Route(path="/{id}/delete", defaults={"page": 1}, name="timesheet_delete", methods={"GET", "POST"}) * @Security("is_granted('delete', entry)") * @@ -247,6 +238,7 @@ class TimesheetController extends AbstractController return $this->createForm(TimesheetEditForm::class, $entry, [ 'action' => $this->generateUrl('timesheet_create'), 'method' => 'POST', + 'include_rate' => $this->isGranted('edit_rate', $entry), 'duration_only' => $this->isDurationOnlyMode(), ]); } @@ -264,6 +256,7 @@ class TimesheetController extends AbstractController 'page' => $page ]), 'method' => 'POST', + 'include_rate' => $this->isGranted('edit_rate', $entry), 'duration_only' => $this->isDurationOnlyMode(), ]); } diff --git a/src/DataFixtures/UserFixtures.php b/src/DataFixtures/UserFixtures.php index 2ba74e4a..ab9e9be9 100644 --- a/src/DataFixtures/UserFixtures.php +++ b/src/DataFixtures/UserFixtures.php @@ -47,7 +47,6 @@ class UserFixtures extends Fixture private $encoder; /** - * AppFixtures constructor. * @param UserPasswordEncoderInterface $encoder */ public function __construct(UserPasswordEncoderInterface $encoder) diff --git a/src/DependencyInjection/AppExtension.php b/src/DependencyInjection/AppExtension.php index 6bfb37d7..1ed31399 100644 --- a/src/DependencyInjection/AppExtension.php +++ b/src/DependencyInjection/AppExtension.php @@ -40,11 +40,43 @@ class AppExtension extends Extension implements PrependExtensionInterface $container->setParameter('kimai.invoice.documents', $config['invoice']['documents']); $container->setParameter('kimai.defaults', $config['defaults']); + $this->createPermissionParameter($config['permissions'], $container); $this->createThemeParameter($config['theme'], $container); $this->createUserParameter($config['user'], $container); $this->createTimesheetParameter($config['timesheet'], $container); } + /** + * Performs some pre-compilation on the configured permissions from kimai.yaml + * to save us from constant array lookups from during runtime. + * + * @param array $config + * @param ContainerBuilder $container + */ + protected function createPermissionParameter(array $config, ContainerBuilder $container) + { + foreach ($config['maps'] as $role => $sets) { + if (!isset($config['roles'][$role])) { + $exception = new InvalidConfigurationException( + 'Configured permission set includes unknown role "' . $role . '"' + ); + $exception->setPath('kimai.permissions.maps.' . $role); + throw $exception; + } + foreach ($sets as $set) { + if (!isset($config['sets'][$set])) { + $exception = new InvalidConfigurationException( + 'Configured permission set "' . $set . '" for role "' . $role . '" is unknown' + ); + $exception->setPath('kimai.permissions.maps.' . $role); + throw $exception; + } + $config['roles'][$role] = array_unique(array_merge($config['roles'][$role], $config['sets'][$set])); + } + } + $container->setParameter('kimai.permissions', $config['roles']); + } + /** * @param array $config * @param ContainerBuilder $container diff --git a/src/DependencyInjection/Compiler/DoctrineCompilerPass.php b/src/DependencyInjection/Compiler/DoctrineCompilerPass.php index d4a5feaa..b148eddd 100644 --- a/src/DependencyInjection/Compiler/DoctrineCompilerPass.php +++ b/src/DependencyInjection/Compiler/DoctrineCompilerPass.php @@ -23,8 +23,6 @@ class DoctrineCompilerPass implements CompilerPassInterface */ protected $allowedEngines = [ 'mysql', - 'oracle', - 'postgres', 'sqlite' ]; @@ -37,15 +35,6 @@ class DoctrineCompilerPass implements CompilerPassInterface { $engine = null; - // TODO - this does return the wrong connection. it used to be mysql, even if - // TODO - getenv('DATABASE_URL') returned an sqlite:// connection string - /* - $dbConfig = $container->getExtensionConfig('doctrine'); - if (isset($dbConfig[0]['dbal']['driver'])) { - $engine = str_replace('pdo_', '', $dbConfig[0]['dbal']['driver']); - } - */ - if (null === $engine) { $dbConfig = explode('://', getenv('DATABASE_URL')); $engine = $dbConfig['0'] ?: null; diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 91034b95..f9e4bc8f 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -40,6 +40,7 @@ class Configuration implements ConfigurationInterface ->append($this->getDashboardNode()) ->append($this->getWidgetsNode()) ->append($this->getDefaultsNode()) + ->append($this->getPermissionsNode()) ->end() ->end(); @@ -329,4 +330,51 @@ class Configuration implements ConfigurationInterface return $node; } + + protected function getPermissionsNode() + { + $builder = new TreeBuilder(); + $node = $builder->root('permissions'); + + $node + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('sets') + ->requiresAtLeastOneElement() + ->useAttributeAsKey('key') + ->performNoDeepMerging() + ->arrayPrototype() + ->useAttributeAsKey('key') + ->isRequired() + ->prototype('scalar')->end() + ->defaultValue([]) + ->end() + ->end() + ->arrayNode('maps') + ->requiresAtLeastOneElement() + ->useAttributeAsKey('key') + ->performNoDeepMerging() + ->arrayPrototype() + ->useAttributeAsKey('key') + ->isRequired() + ->prototype('scalar')->end() + ->defaultValue([]) + ->end() + ->end() + ->arrayNode('roles') + ->requiresAtLeastOneElement() + ->useAttributeAsKey('key') + ->performNoDeepMerging() + ->arrayPrototype() + ->useAttributeAsKey('key') + ->isRequired() + ->prototype('scalar')->end() + ->defaultValue([]) + ->end() + ->end() + ->end() + ; + + return $node; + } } diff --git a/src/Entity/UserPreference.php b/src/Entity/UserPreference.php index 685fdcc0..bffe1f31 100644 --- a/src/Entity/UserPreference.php +++ b/src/Entity/UserPreference.php @@ -69,6 +69,11 @@ class UserPreference */ protected $type; + /** + * @var bool + */ + protected $enabled = true; + /** * @var Constraint[] */ @@ -181,6 +186,25 @@ class UserPreference return $this->type; } + /** + * @return bool + */ + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * @param bool $enabled + * @return UserPreference + */ + public function setEnabled(bool $enabled) + { + $this->enabled = $enabled; + + return $this; + } + /** * Set the constraints which are used for validation of the value. * diff --git a/src/Event/PrepareUserEvent.php b/src/Event/PrepareUserEvent.php new file mode 100644 index 00000000..11dc12a7 --- /dev/null +++ b/src/Event/PrepareUserEvent.php @@ -0,0 +1,42 @@ +user = $user; + } + + /** + * @return User + */ + public function getUser() + { + return $this->user; + } +} diff --git a/src/Event/UserPreferenceEvent.php b/src/Event/UserPreferenceEvent.php index d6141fc1..33bd2bb8 100644 --- a/src/Event/UserPreferenceEvent.php +++ b/src/Event/UserPreferenceEvent.php @@ -14,7 +14,7 @@ use App\Entity\UserPreference; use Symfony\Component\EventDispatcher\Event; /** - * Class UserPreferenceEvent + * This event should be used, if further user preferences should added dynamically */ class UserPreferenceEvent extends Event { @@ -30,7 +30,6 @@ class UserPreferenceEvent extends Event protected $preferences; /** - * UserPreferenceEvent constructor. * @param User $user * @param UserPreference[] $preferences */ @@ -41,6 +40,7 @@ class UserPreferenceEvent extends Event } /** + * Do not set the preferences directly to the user object, but ONLY via addUserPreference() * @return User */ public function getUser() diff --git a/src/EventSubscriber/MenuSubscriber.php b/src/EventSubscriber/MenuSubscriber.php index 8f20641e..fc712100 100644 --- a/src/EventSubscriber/MenuSubscriber.php +++ b/src/EventSubscriber/MenuSubscriber.php @@ -53,26 +53,23 @@ class MenuSubscriber implements EventSubscriberInterface { $auth = $this->security; - $isLoggedIn = $auth->isGranted('IS_AUTHENTICATED_REMEMBERED'); - $isUser = $isLoggedIn && $auth->isGranted('ROLE_USER'); - $isTeamlead = $isLoggedIn && $auth->isGranted('ROLE_TEAMLEAD'); - - if (!$isLoggedIn || !$isUser) { + if (!$auth->isGranted('IS_AUTHENTICATED_REMEMBERED')) { return; } $menu = $event->getMenu(); - $menu->addItem( - new MenuItemModel('timesheet', 'menu.timesheet', 'timesheet', [], 'far fa-clock') - ); - if (!$isTeamlead) { - return; + if ($auth->isGranted('view_own_timesheet')) { + $menu->addItem( + new MenuItemModel('timesheet', 'menu.timesheet', 'timesheet', [], 'far fa-clock') + ); } - $menu->addItem( - new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], 'fas fa-file-invoice') - ); + if ($auth->isGranted('view_invoice')) { + $menu->addItem( + new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], 'fas fa-file-invoice') + ); + } } /** @@ -80,33 +77,42 @@ class MenuSubscriber implements EventSubscriberInterface */ public function onAdminMenuConfigure(ConfigureAdminMenuEvent $event) { - $menu = $event->getAdminMenu(); $auth = $this->security; - if (!$auth->isGranted('IS_AUTHENTICATED_REMEMBERED') || !$auth->isGranted('ROLE_TEAMLEAD')) { + if (!$auth->isGranted('IS_AUTHENTICATED_REMEMBERED')) { return; } - $menu->addChild( - new MenuItemModel('timesheet_admin', 'menu.admin_timesheet', 'admin_timesheet', [], 'far fa-clock') - ); + $menu = $event->getAdminMenu(); - if (!$auth->isGranted('ROLE_ADMIN')) { - return; + if ($auth->isGranted('view_other_timesheet')) { + $menu->addChild( + new MenuItemModel('timesheet_admin', 'menu.admin_timesheet', 'admin_timesheet', [], 'far fa-clock') + ); } - if ($auth->isGranted('ROLE_SUPER_ADMIN')) { + if ($auth->isGranted('view_user')) { $menu->addChild( new MenuItemModel('user_admin', 'menu.admin_user', 'admin_user', [], 'fas fa-user') ); } - $menu->addChild( - new MenuItemModel('customer_admin', 'menu.admin_customer', 'admin_customer', [], 'fas fa-users') - )->addChild( - new MenuItemModel('project_admin', 'menu.admin_project', 'admin_project', [], 'fas fa-project-diagram') - )->addChild( - new MenuItemModel('activity_admin', 'menu.admin_activity', 'admin_activity', [], 'fas fa-tasks') - ); + if ($auth->isGranted('view_customer')) { + $menu->addChild( + new MenuItemModel('customer_admin', 'menu.admin_customer', 'admin_customer', [], 'fas fa-users') + ); + } + + if ($auth->isGranted('view_project')) { + $menu->addChild( + new MenuItemModel('project_admin', 'menu.admin_project', 'admin_project', [], 'fas fa-project-diagram') + ); + } + + if ($auth->isGranted('view_activity')) { + $menu->addChild( + new MenuItemModel('activity_admin', 'menu.admin_activity', 'admin_activity', [], 'fas fa-tasks') + ); + } } } diff --git a/src/EventSubscriber/UserPreferenceSubscriber.php b/src/EventSubscriber/UserPreferenceSubscriber.php index 88d63aa3..b6a8e879 100644 --- a/src/EventSubscriber/UserPreferenceSubscriber.php +++ b/src/EventSubscriber/UserPreferenceSubscriber.php @@ -11,6 +11,7 @@ namespace App\EventSubscriber; use App\Entity\User; use App\Entity\UserPreference; +use App\Event\PrepareUserEvent; use App\Event\UserPreferenceEvent; use App\Form\Type\CalendarViewType; use App\Form\Type\LanguageType; @@ -19,15 +20,10 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\IntegerType; -use Symfony\Component\Form\Extension\Core\Type\TimezoneType; -use Symfony\Component\HttpKernel\Event\KernelEvent; -use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Validator\Constraints\Range; -/** - * Class UserPreferenceSubscriber - */ class UserPreferenceSubscriber implements EventSubscriberInterface { /** @@ -35,20 +31,26 @@ class UserPreferenceSubscriber implements EventSubscriberInterface */ protected $eventDispatcher; + /** + * @var AuthorizationCheckerInterface + */ + protected $voter; + /** * @var TokenStorageInterface */ protected $storage; /** - * UserPreferenceSubscriber constructor. * @param EventDispatcherInterface $dispatcher * @param TokenStorageInterface $storage + * @param AuthorizationCheckerInterface $voter */ - public function __construct(EventDispatcherInterface $dispatcher, TokenStorageInterface $storage) + public function __construct(EventDispatcherInterface $dispatcher, TokenStorageInterface $storage, AuthorizationCheckerInterface $voter) { $this->eventDispatcher = $dispatcher; $this->storage = $storage; + $this->voter = $voter; } /** @@ -57,27 +59,37 @@ class UserPreferenceSubscriber implements EventSubscriberInterface public static function getSubscribedEvents(): array { return [ - KernelEvents::CONTROLLER => ['loadUserPreferences', 200] + PrepareUserEvent::PREPARE => ['loadUserPreferences', 200] ]; } /** + * @param User $user * @return UserPreference[] */ - public function getDefaultPreferences() + public function getDefaultPreferences(User $user) { + $enableHourlyRate = false; + + if ($this->voter->isGranted('hourly-rate', $user)) { + $enableHourlyRate = true; + } + + /* + (new UserPreference()) + ->setName('timezone') + ->setValue(date_default_timezone_get()) + ->setType(TimezoneType::class), + */ + return [ (new UserPreference()) ->setName(UserPreference::HOURLY_RATE) ->setValue(0) ->setType(IntegerType::class) + ->setEnabled($enableHourlyRate) ->addConstraint(new Range(['min' => 0])), -/* - (new UserPreference()) - ->setName('timezone') - ->setValue(date_default_timezone_get()) - ->setType(TimezoneType::class), -*/ + (new UserPreference()) ->setName('language') ->setValue('en') // TODO fetch from services.yaml @@ -116,31 +128,32 @@ class UserPreferenceSubscriber implements EventSubscriberInterface } /** - * @param KernelEvent $event + * @param PrepareUserEvent $event */ - public function loadUserPreferences(KernelEvent $event) + public function loadUserPreferences(PrepareUserEvent $event) { if (!$this->canHandleEvent($event)) { return; } - /** @var User $user */ - $user = $this->storage->getToken()->getUser(); + $user = $event->getUser(); $prefs = []; foreach ($user->getPreferences() as $preference) { $prefs[$preference->getName()] = $preference; } - $event = new UserPreferenceEvent($user, $this->getDefaultPreferences()); + $event = new UserPreferenceEvent($user, $this->getDefaultPreferences($user)); $this->eventDispatcher->dispatch(UserPreferenceEvent::CONFIGURE, $event); foreach ($event->getPreferences() as $preference) { + /* @var UserPreference[] $prefs */ if (isset($prefs[$preference->getName()])) { /* @var UserPreference $pref */ $prefs[$preference->getName()] ->setType($preference->getType()) ->setConstraints($preference->getConstraints()) + ->setEnabled($preference->isEnabled()) ; } else { $prefs[$preference->getName()] = $preference; @@ -151,24 +164,15 @@ class UserPreferenceSubscriber implements EventSubscriberInterface } /** - * @param KernelEvent $event + * @param PrepareUserEvent $event * @return bool */ - protected function canHandleEvent(KernelEvent $event): bool + protected function canHandleEvent(PrepareUserEvent $event): bool { - // Ignore sub-requests - if (!$event->isMasterRequest()) { + if (null === ($user = $event->getUser())) { return false; } - // ignore events like the toolbar where we do not have a token - if (null === $this->storage->getToken()) { - return false; - } - - /** @var User $user */ - $user = $this->storage->getToken()->getUser(); - return ($user instanceof User); } } diff --git a/src/EventSubscriber/UserProfileSubscriber.php b/src/EventSubscriber/UserProfileSubscriber.php new file mode 100644 index 00000000..3b7d2ba3 --- /dev/null +++ b/src/EventSubscriber/UserProfileSubscriber.php @@ -0,0 +1,89 @@ +eventDispatcher = $dispatcher; + $this->storage = $storage; + } + + /** + * @return array + */ + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::CONTROLLER => ['prepareUserProfile', 200] + ]; + } + + /** + * @param KernelEvent $event + */ + public function prepareUserProfile(KernelEvent $event) + { + if (!$this->canHandleEvent($event)) { + return; + } + + /** @var User $user */ + $user = $this->storage->getToken()->getUser(); + + $event = new PrepareUserEvent($user); + $this->eventDispatcher->dispatch(PrepareUserEvent::PREPARE, $event); + } + + /** + * @param KernelEvent $event + * @return bool + */ + protected function canHandleEvent(KernelEvent $event): bool + { + // Ignore sub-requests + if (!$event->isMasterRequest()) { + return false; + } + + // ignore events like the toolbar where we do not have a token + if (null === $this->storage->getToken()) { + return false; + } + + /** @var User $user */ + $user = $this->storage->getToken()->getUser(); + + return ($user instanceof User); + } +} diff --git a/src/Form/TimesheetEditForm.php b/src/Form/TimesheetEditForm.php index c04e7195..94d20158 100644 --- a/src/Form/TimesheetEditForm.php +++ b/src/Form/TimesheetEditForm.php @@ -133,15 +133,20 @@ class TimesheetEditForm extends AbstractType 'label' => 'label.description', 'required' => false, ]) - ->add('fixedRate', NumberType::class, [ - 'label' => 'label.fixed_rate', - 'required' => false, - ]) - ->add('hourlyRate', NumberType::class, [ - 'label' => 'label.hourly_rate', - 'required' => false, - ]) ; + + if ($options['include_rate']) { + $builder + ->add('fixedRate', NumberType::class, [ + 'label' => 'label.fixed_rate', + 'required' => false, + ]) + ->add('hourlyRate', NumberType::class, [ + 'label' => 'label.hourly_rate', + 'required' => false, + ]); + } + /* $builder->get('customer')->addEventListener( FormEvents::POST_SUBMIT, @@ -162,6 +167,7 @@ class TimesheetEditForm extends AbstractType } ); */ + $builder->get('project')->addEventListener( FormEvents::POST_SUBMIT, function (FormEvent $event) { @@ -192,6 +198,7 @@ class TimesheetEditForm extends AbstractType 'csrf_token_id' => 'timesheet_edit', 'duration_only' => false, 'include_user' => false, + 'include_rate' => true, 'docu_chapter' => 'timesheet', ]); } diff --git a/src/Form/Type/UserPreferenceType.php b/src/Form/Type/UserPreferenceType.php index ce7fee72..3390a80a 100644 --- a/src/Form/Type/UserPreferenceType.php +++ b/src/Form/Type/UserPreferenceType.php @@ -35,23 +35,31 @@ class UserPreferenceType extends AbstractType /** @var UserPreference $preference */ $preference = $event->getData(); - if ($preference instanceof UserPreference) { - // prevents unconfigured values from showing up in the form - if ($preference->getType() === null) { - return; - } - - $required = true; - if (CheckboxType::class == $preference->getType()) { - $required = false; - } - - $event->getForm()->add('value', $preference->getType(), [ - 'label' => 'label.' . $preference->getName(), - 'constraints' => $preference->getConstraints(), - 'required' => $required, - ]); + if (!($preference instanceof UserPreference)) { + return; } + + // prevents unconfigured values from showing up in the form + if ($preference->getType() === null) { + return; + } + + $required = true; + if (CheckboxType::class == $preference->getType()) { + $required = false; + } + + $type = $preference->getType(); + if (!$preference->isEnabled()) { + $type = HiddenType::class; + } + + $event->getForm()->add('value', $type, [ + 'label' => 'label.' . $preference->getName(), + 'constraints' => $preference->getConstraints(), + 'required' => $required, + 'disabled' => !$preference->isEnabled(), + ]); } ); $builder->add('name', HiddenType::class); diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 7592bc8d..191014ea 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -27,6 +27,18 @@ class UserRepository extends AbstractRepository implements UserLoaderInterface return $this->find($id); } + /** + * Overwritten to fetch preferences when using the Profile controller actions. + * Depends on the query, some magic mechanisms like the ParamConverter will use this method to fetch the user. + */ + public function findOneBy(array $criteria, array $orderBy = null) + { + if (count($criteria) == 1 && isset($criteria['username'])) { + return $this->loadUserByUsername($criteria['username']); + } + + return parent::findOneBy($criteria, $orderBy); + } /** * @return int */ @@ -43,7 +55,6 @@ class UserRepository extends AbstractRepository implements UserLoaderInterface { $qb = $this->getEntityManager()->createQueryBuilder(); - // if we join activities, the maxperpage limit will limit the list to the amount or projects + activties $qb->select('u') ->from(User::class, 'u') ->orderBy('u.' . $query->getOrderBy(), $query->getOrder()); @@ -77,6 +88,8 @@ class UserRepository extends AbstractRepository implements UserLoaderInterface public function loadUserByUsername($username) { return $this->createQueryBuilder('u') + ->select('u', 'p') + ->leftJoin('u.preferences', 'p') ->where('u.username = :username') ->orWhere('u.email = :username') ->setParameter('username', $username) diff --git a/src/Security/RolePermissionManager.php b/src/Security/RolePermissionManager.php new file mode 100644 index 00000000..38c2fb2a --- /dev/null +++ b/src/Security/RolePermissionManager.php @@ -0,0 +1,67 @@ +permissions = $permissions; + + foreach ($permissions as $role => $perms) { + $this->knownPermissions = array_merge($this->knownPermissions, $perms); + } + $this->knownPermissions = array_unique($this->knownPermissions); + } + + /** + * @param string $permission + * @return bool + */ + public function isRegisteredPermission($permission) + { + return in_array($permission, $this->knownPermissions); + } + + /** + * @param string $role + * @return bool + */ + public function roleHasPermission($role) + { + return isset($this->permissions[$role]); + } + + /** + * @param string $role + * @param string $permission + * @return bool + */ + public function hasPermission($role, $permission) + { + if (!isset($this->permissions[$role])) { + return false; + } + + return in_array($permission, $this->permissions[$role]); + } +} diff --git a/src/Voter/AbstractVoter.php b/src/Voter/AbstractVoter.php index b9eda969..12039ba7 100644 --- a/src/Voter/AbstractVoter.php +++ b/src/Voter/AbstractVoter.php @@ -9,12 +9,14 @@ namespace App\Voter; +use App\Entity\User; use App\Security\AclDecisionManager; +use App\Security\RolePermissionManager; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Voter; /** - * Abstract voter to help with checking user roles. + * Abstract voter to help with checking user permissions. */ abstract class AbstractVoter extends Voter { @@ -22,14 +24,19 @@ abstract class AbstractVoter extends Voter * @var AclDecisionManager */ protected $decisionManager; + /** + * @var RolePermissionManager + */ + protected $roleManager; /** - * AbstractVoter constructor. * @param AclDecisionManager $decisionManager + * @param RolePermissionManager $roleManager */ - public function __construct(AclDecisionManager $decisionManager) + public function __construct(AclDecisionManager $decisionManager, RolePermissionManager $roleManager) { $this->decisionManager = $decisionManager; + $this->roleManager = $roleManager; } /** @@ -50,4 +57,39 @@ abstract class AbstractVoter extends Voter { return $this->decisionManager->hasRole($token, [$role]); } + + /** + * @param string $role + * @param string $permission + * @return bool + */ + protected function hasPermission($role, $permission) + { + return $this->roleManager->hasPermission($role, $permission); + } + + /** + * @param User $user + * @param string $permission + * @return bool + */ + protected function hasRolePermission(User $user, $permission) + { + foreach ($user->getRoles() as $role) { + if ($this->hasPermission($role, $permission)) { + return true; + } + } + + return false; + } + + /** + * @param string $permission + * @return bool + */ + public function isRegisteredPermission($permission) + { + return $this->roleManager->isRegisteredPermission($permission); + } } diff --git a/src/Voter/ActivityVoter.php b/src/Voter/ActivityVoter.php index e1d37b95..91d099ba 100644 --- a/src/Voter/ActivityVoter.php +++ b/src/Voter/ActivityVoter.php @@ -22,6 +22,9 @@ class ActivityVoter extends AbstractVoter public const EDIT = 'edit'; public const DELETE = 'delete'; + /** + * support rules based on the given $subject (here: Activity) + */ public const ALLOWED_ATTRIBUTES = [ self::VIEW, self::EDIT, @@ -30,16 +33,16 @@ class ActivityVoter extends AbstractVoter /** * @param string $attribute - * @param mixed $subject + * @param Activity $subject * @return bool */ protected function supports($attribute, $subject) { - if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) { + if (!$subject instanceof Activity) { return false; } - if (!$subject instanceof Activity) { + if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) { return false; } @@ -60,54 +63,10 @@ class ActivityVoter extends AbstractVoter return false; } - switch ($attribute) { - case self::VIEW: - return $this->canView($subject, $user, $token); - case self::EDIT: - return $this->canEdit($subject, $user, $token); - case self::DELETE: - return $this->canDelete($token); + if ($subject instanceof Activity) { + return $this->hasRolePermission($user, $attribute . '_activity'); } return false; } - - /** - * @param Activity $activity - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canView(Activity $activity, User $user, TokenInterface $token) - { - if ($this->canEdit($activity, $user, $token)) { - return true; - } - - return false; - } - - /** - * @param Activity $activity - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canEdit(Activity $activity, User $user, TokenInterface $token) - { - if ($this->canDelete($token)) { - return true; - } - - return false; - } - - /** - * @param TokenInterface $token - * @return bool - */ - protected function canDelete(TokenInterface $token) - { - return $this->isFullyAuthenticated($token) && $this->hasRole('ROLE_ADMIN', $token); - } } diff --git a/src/Voter/CustomerVoter.php b/src/Voter/CustomerVoter.php index 667eca70..5b3c6402 100644 --- a/src/Voter/CustomerVoter.php +++ b/src/Voter/CustomerVoter.php @@ -22,6 +22,9 @@ class CustomerVoter extends AbstractVoter public const EDIT = 'edit'; public const DELETE = 'delete'; + /** + * support rules based on the given $subject (here: Customer) + */ public const ALLOWED_ATTRIBUTES = [ self::VIEW, self::EDIT, @@ -30,16 +33,16 @@ class CustomerVoter extends AbstractVoter /** * @param string $attribute - * @param mixed $subject + * @param Customer $subject * @return bool */ protected function supports($attribute, $subject) { - if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) { + if (!$subject instanceof Customer) { return false; } - if (!$subject instanceof Customer) { + if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) { return false; } @@ -60,54 +63,10 @@ class CustomerVoter extends AbstractVoter return false; } - switch ($attribute) { - case self::VIEW: - return $this->canView($subject, $user, $token); - case self::EDIT: - return $this->canEdit($subject, $user, $token); - case self::DELETE: - return $this->canDelete($token); + if ($subject instanceof Customer) { + return $this->hasRolePermission($user, $attribute . '_customer'); } return false; } - - /** - * @param Customer $customer - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canView(Customer $customer, User $user, TokenInterface $token) - { - if ($this->canEdit($customer, $user, $token)) { - return true; - } - - return false; - } - - /** - * @param Customer $customer - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canEdit(Customer $customer, User $user, TokenInterface $token) - { - if ($this->canDelete($token)) { - return true; - } - - return false; - } - - /** - * @param TokenInterface $token - * @return bool - */ - protected function canDelete(TokenInterface $token) - { - return $this->isFullyAuthenticated($token) && $this->hasRole('ROLE_ADMIN', $token); - } } diff --git a/src/Voter/InvoiceTemplateVoter.php b/src/Voter/InvoiceTemplateVoter.php new file mode 100644 index 00000000..44a36414 --- /dev/null +++ b/src/Voter/InvoiceTemplateVoter.php @@ -0,0 +1,72 @@ +getUser(); + + if (!$user instanceof User) { + return false; + } + + if ($subject instanceof InvoiceTemplate) { + return $this->hasRolePermission($user, $attribute . '_invoice_template'); + } + + return false; + } +} diff --git a/src/Voter/InvoiceVoter.php b/src/Voter/InvoiceVoter.php deleted file mode 100644 index e3357f18..00000000 --- a/src/Voter/InvoiceVoter.php +++ /dev/null @@ -1,136 +0,0 @@ -getUser(); - - if (!$user instanceof User) { - return false; - } - - switch ($attribute) { - case self::VIEW: - return $this->canView($user, $token); - case self::CREATE: - return $this->canCreate($user, $token); - case self::EDIT: - return $this->canEdit($user, $token); - case self::DELETE: - return $this->canDelete($token); - } - - return false; - } - - /** - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canView(User $user, TokenInterface $token) - { - if ($this->canEdit($user, $token)) { - return true; - } - - return false; - } - - /** - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canCreate(User $user, TokenInterface $token) - { - if ($this->canDelete($token)) { - return true; - } - - return false; - } - - /** - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canEdit(User $user, TokenInterface $token) - { - if ($this->canDelete($token)) { - return true; - } - - return false; - } - - /** - * @param TokenInterface $token - * @return bool - */ - protected function canDelete(TokenInterface $token) - { - return $this->isFullyAuthenticated($token) && $this->hasRole('ROLE_TEAMLEAD', $token); - } -} diff --git a/src/Voter/ProjectVoter.php b/src/Voter/ProjectVoter.php index e8bfb5c2..4b26e6da 100644 --- a/src/Voter/ProjectVoter.php +++ b/src/Voter/ProjectVoter.php @@ -22,6 +22,9 @@ class ProjectVoter extends AbstractVoter public const EDIT = 'edit'; public const DELETE = 'delete'; + /** + * support rules based on the given $subject (here: Project) + */ public const ALLOWED_ATTRIBUTES = [ self::VIEW, self::EDIT, @@ -30,16 +33,16 @@ class ProjectVoter extends AbstractVoter /** * @param string $attribute - * @param mixed $subject + * @param Project $subject * @return bool */ protected function supports($attribute, $subject) { - if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) { + if (!$subject instanceof Project) { return false; } - if (!$subject instanceof Project) { + if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) { return false; } @@ -60,54 +63,10 @@ class ProjectVoter extends AbstractVoter return false; } - switch ($attribute) { - case self::VIEW: - return $this->canView($subject, $user, $token); - case self::EDIT: - return $this->canEdit($subject, $user, $token); - case self::DELETE: - return $this->canDelete($token); + if ($subject instanceof Project) { + return $this->hasRolePermission($user, $attribute . '_project'); } return false; } - - /** - * @param Project $project - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canView(Project $project, User $user, TokenInterface $token) - { - if ($this->canEdit($project, $user, $token)) { - return true; - } - - return false; - } - - /** - * @param Project $project - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canEdit(Project $project, User $user, TokenInterface $token) - { - if ($this->canDelete($token)) { - return true; - } - - return false; - } - - /** - * @param TokenInterface $token - * @return bool - */ - protected function canDelete(TokenInterface $token) - { - return $this->isFullyAuthenticated($token) && $this->hasRole('ROLE_ADMIN', $token); - } } diff --git a/src/Voter/RolePermissionVoter.php b/src/Voter/RolePermissionVoter.php new file mode 100644 index 00000000..8d65d58d --- /dev/null +++ b/src/Voter/RolePermissionVoter.php @@ -0,0 +1,64 @@ +isRegisteredPermission($attribute); + } + + return false; + } + + /** + * @param string $attribute + * @param Activity $subject + * @param TokenInterface $token + * @return bool + */ + protected function voteOnAttribute($attribute, $subject, TokenInterface $token) + { + $user = $token->getUser(); + + if (!($user instanceof User)) { + return false; + } + + foreach ($user->getRoles() as $role) { + if ($this->hasPermission($role, $attribute)) { + return true; + } + } + + return false; + } +} diff --git a/src/Voter/TimesheetVoter.php b/src/Voter/TimesheetVoter.php index efc6c6e8..efa21201 100644 --- a/src/Voter/TimesheetVoter.php +++ b/src/Voter/TimesheetVoter.php @@ -21,16 +21,21 @@ class TimesheetVoter extends AbstractVoter { public const START = 'start'; public const STOP = 'stop'; - public const VIEW = 'view'; public const EDIT = 'edit'; public const DELETE = 'delete'; + public const VIEW_RATE = 'view_rate'; + public const EDIT_RATE = 'edit_rate'; + /** + * support rules based on the given $subject (here: Timesheet) + */ public const ALLOWED_ATTRIBUTES = [ self::START, self::STOP, - self::VIEW, self::EDIT, - self::DELETE + self::DELETE, + self::VIEW_RATE, + self::EDIT_RATE, ]; /** @@ -40,11 +45,11 @@ class TimesheetVoter extends AbstractVoter */ protected function supports($attribute, $subject) { - if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) { + if (!$subject instanceof Timesheet) { return false; } - if (!$subject instanceof Timesheet) { + if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) { return false; } @@ -61,40 +66,48 @@ class TimesheetVoter extends AbstractVoter { $user = $token->getUser(); - if (!$user instanceof User) { + if (!($user instanceof User)) { return false; } - switch ($attribute) { - case self::STOP: - return $this->canStop($subject, $user, $token); - - case self::START: - return $this->canStart($subject, $user, $token); - - case self::VIEW: - return $this->canView($subject, $user, $token); - - case self::EDIT: - return $this->canEdit($subject, $user, $token); - - case self::DELETE: - return $this->canDelete($subject, $user, $token); + if (!($subject instanceof Timesheet)) { + return false; } - return false; - } + $permission = ''; - /** - * @param Timesheet $timesheet - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canStop(Timesheet $timesheet, User $user, TokenInterface $token) - { - // if a teamlead stops an entry for another user, check that this user is part of his team - return $this->isOwnOrTeamlead($timesheet, $user, $token); + switch ($attribute) { + case self::START: + if (!$this->canStart($subject, $user, $token)) { + return false; + } + $permission .= $attribute; + break; + + case self::VIEW_RATE: + case self::EDIT_RATE: + case self::STOP: + case self::EDIT: + case self::DELETE: + $permission .= $attribute; + break; + + default: + return false; + } + + $permission .= '_'; + + // extend me for "team" support later on + if ($subject->getUser()->getId() == $user->getId()) { + $permission .= 'own'; + } else { + $permission .= 'other'; + } + + $permission .= '_timesheet'; + + return $this->hasRolePermission($user, $permission); } /** @@ -118,65 +131,4 @@ class TimesheetVoter extends AbstractVoter return true; } - - /** - * @param Timesheet $timesheet - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canView(Timesheet $timesheet, User $user, TokenInterface $token) - { - return $this->isOwnOrTeamlead($timesheet, $user, $token); - } - - /** - * @param Timesheet $timesheet - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canEdit(Timesheet $timesheet, User $user, TokenInterface $token) - { - return $this->isOwnOrTeamlead($timesheet, $user, $token); - } - - /** - * @param TokenInterface $token - * @return bool - */ - protected function canDelete(Timesheet $timesheet, User $user, TokenInterface $token) - { - if (!$this->isFullyAuthenticated($token)) { - return false; - } - - return $this->isOwnOrAdmin($timesheet, $user, $token); - } - - /** - * @param TokenInterface $token - * @return bool - */ - protected function isOwnOrTeamlead(Timesheet $timesheet, User $user, TokenInterface $token) - { - if ($timesheet->getUser()->getId() == $user->getId()) { - return true; - } - - return $this->hasRole('ROLE_TEAMLEAD', $token); - } - - /** - * @param TokenInterface $token - * @return bool - */ - protected function isOwnOrAdmin(Timesheet $timesheet, User $user, TokenInterface $token) - { - if ($timesheet->getUser()->getId() == $user->getId()) { - return true; - } - - return $this->hasRole('ROLE_ADMIN', $token); - } } diff --git a/src/Voter/UserVoter.php b/src/Voter/UserVoter.php index bda84e76..be8650bf 100644 --- a/src/Voter/UserVoter.php +++ b/src/Voter/UserVoter.php @@ -19,22 +19,22 @@ class UserVoter extends AbstractVoter { public const VIEW = 'view'; public const EDIT = 'edit'; - public const CREATE = 'create'; public const DELETE = 'delete'; public const PASSWORD = 'password'; public const ROLES = 'roles'; public const PREFERENCES = 'preferences'; public const API_TOKEN = 'api-token'; + public const HOURLY_RATE = 'hourly-rate'; public const ALLOWED_ATTRIBUTES = [ self::VIEW, self::EDIT, - self::CREATE, self::ROLES, self::PASSWORD, self::DELETE, self::PREFERENCES, self::API_TOKEN, + self::HOURLY_RATE, ]; /** @@ -48,7 +48,7 @@ class UserVoter extends AbstractVoter return false; } - if (!$subject instanceof User) { + if (!($subject instanceof User)) { return false; } @@ -65,66 +65,48 @@ class UserVoter extends AbstractVoter { $user = $token->getUser(); - if (!$user instanceof User) { + if (!($user instanceof User)) { return false; } + $permission = ''; + switch ($attribute) { + // special case for the UserController + case self::DELETE: + if (!$this->canDelete($subject, $user, $token)) { + return false; + } + + return $this->hasRolePermission($user, 'delete_user'); + + // used in templates and ProfileController case self::VIEW: - return $this->canView($subject, $user, $token); case self::EDIT: case self::API_TOKEN: case self::PASSWORD: - return $this->canEdit($subject, $user, $token); - case self::DELETE: - return $this->canDelete($subject, $user, $token); - case self::CREATE: // create actually passes in the current user as $subject, not the new one case self::ROLES: - return $this->canAdminUsers($token); case self::PREFERENCES: - return $this->canEditPreferences($subject, $user, $token); + case self::HOURLY_RATE: + $permission .= $attribute; + break; + + default: + return false; } - return false; - } + $permission .= '_'; - /** - * @param User $profile - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canEditPreferences(User $profile, User $user, TokenInterface $token) - { - return $profile->getId() === $user->getId(); - } - - /** - * @param User $profile - * @param User $user - * @return bool - */ - protected function canView(User $profile, User $user, TokenInterface $token) - { - if ($this->canEdit($profile, $user, $token)) { - return true; + // extend me for "team" support later on + if ($subject->getId() == $user->getId()) { + $permission .= 'own'; + } else { + $permission .= 'other'; } - return $profile->getId() === $user->getId(); - } + $permission .= '_profile'; - /** - * @param User $profile - * @param User $user - * @return bool - */ - protected function canEdit(User $profile, User $user, TokenInterface $token) - { - if ($this->canAdminUsers($token)) { - return true; - } - - return $profile->getId() === $user->getId(); + return $this->hasRolePermission($user, $permission); } /** @@ -134,19 +116,6 @@ class UserVoter extends AbstractVoter */ protected function canDelete(User $profile, User $user, TokenInterface $token) { - if (!$this->canAdminUsers($token)) { - return false; - } - return $profile->getId() !== $user->getId(); } - - /** - * @param TokenInterface $token - * @return bool - */ - protected function canAdminUsers(TokenInterface $token) - { - return $this->isFullyAuthenticated($token) && $this->hasRole(User::ROLE_SUPER_ADMIN, $token); - } } diff --git a/templates/admin/activity.html.twig b/templates/admin/activity.html.twig index a20ecb04..404d885b 100644 --- a/templates/admin/activity.html.twig +++ b/templates/admin/activity.html.twig @@ -5,7 +5,13 @@ {% block page_title %}{{ 'admin_activity.title'|trans }}{% endblock %} {% block page_subtitle %}{{ 'admin_activity.subtitle'|trans }} {{ 'subtitle.amount'|trans({'%count%': entries.count}) }}{% endblock %} -{% block page_actions %}{{ widgets.page_actions({'filter': '#collapseActivityAdmin', 'visibility': '#modal_activity_admin', 'create': path('admin_activity_create')}) }}{% endblock %} +{% block page_actions %} + {% set actions = {'filter': '#collapseActivityAdmin', 'visibility': '#modal_activity_admin'} %} + {% if is_granted('create_activity') %} + {% set actions = actions|merge({'create': path('admin_activity_create')}) %} + {% endif %} + {{ widgets.page_actions(actions) }} +{% endblock %} {% block main_before %} {{ toolbar.toolbar(toolbarForm, 'collapseActivityAdmin', showFilter) }} diff --git a/templates/admin/customer.html.twig b/templates/admin/customer.html.twig index 3ffb98d0..a76d6fe3 100644 --- a/templates/admin/customer.html.twig +++ b/templates/admin/customer.html.twig @@ -5,7 +5,13 @@ {% block page_title %}{{ 'admin_customer.title'|trans }}{% endblock %} {% block page_subtitle %}{{ 'admin_customer.subtitle'|trans }} {{ 'subtitle.amount'|trans({'%count%': entries.count}) }}{% endblock %} -{% block page_actions %}{{ widgets.page_actions({'filter': '#collapseCustomerAdmin', 'visibility': '#modal_customer_admin', 'create': path('admin_customer_create')}) }}{% endblock %} +{% block page_actions %} + {% set actions = {'filter': '#collapseCustomerAdmin', 'visibility': '#modal_customer_admin'} %} + {% if is_granted('create_customer') %} + {% set actions = actions|merge({'create': path('admin_customer_create')}) %} + {% endif %} + {{ widgets.page_actions(actions) }} +{% endblock %} {% block main_before %} {{ toolbar.toolbar(toolbarForm, 'collapseCustomerAdmin', showFilter) }} diff --git a/templates/admin/project.html.twig b/templates/admin/project.html.twig index f32e8fdd..e3dd9c26 100644 --- a/templates/admin/project.html.twig +++ b/templates/admin/project.html.twig @@ -5,7 +5,13 @@ {% block page_title %}{{ 'admin_project.title'|trans }}{% endblock %} {% block page_subtitle %}{{ 'admin_project.subtitle'|trans }} {{ 'subtitle.amount'|trans({'%count%': entries.count}) }}{% endblock %} -{% block page_actions %}{{ widgets.page_actions({'filter': '#collapseProjectAdmin', 'visibility': '#modal_project_admin', 'create': path('admin_project_create')}) }}{% endblock %} +{% block page_actions %} + {% set actions = {'filter': '#collapseProjectAdmin', 'visibility': '#modal_project_admin'} %} + {% if is_granted('create_project') %} + {% set actions = actions|merge({'create': path('admin_project_create')}) %} + {% endif %} + {{ widgets.page_actions(actions) }} +{% endblock %} {% block main_before %} {{ toolbar.toolbar(toolbarForm, 'collapseProjectAdmin', showFilter) }} diff --git a/templates/admin/timesheet.html.twig b/templates/admin/timesheet.html.twig index c1478d00..de8b42d4 100644 --- a/templates/admin/timesheet.html.twig +++ b/templates/admin/timesheet.html.twig @@ -5,7 +5,13 @@ {% block page_title %}{{ 'admin_timesheet.title'|trans }}{% endblock %} {% block page_subtitle %}{{ 'admin_timesheet.subtitle'|trans }}{% endblock %} -{% block page_actions %}{{ widgets.page_actions({'filter': '#collapseTimesheetAdmin', 'visibility': '#modal_timesheet_admin', 'create': path('admin_timesheet_create')}) }}{% endblock %} +{% block page_actions %} + {% set actions = {'filter': '#collapseTimesheetAdmin', 'visibility': '#modal_timesheet_admin'} %} + {% if is_granted('create_other_timesheet') %} + {% set actions = actions|merge({'create': path('admin_timesheet_create')}) %} + {% endif %} + {{ widgets.page_actions(actions) }} +{% endblock %} {% block main_before %} {{ toolbar.toolbar(toolbarForm, 'collapseTimesheetAdmin', showFilter) }} @@ -50,15 +56,19 @@