added team permissions (#996)

This commit is contained in:
Kevin Papst
2019-08-16 01:22:33 +02:00
committed by GitHub
parent 9611646bc6
commit 561ec3b1e9
124 changed files with 4122 additions and 362 deletions

View File

@@ -68,6 +68,7 @@ class CustomerController extends BaseApiController
public function cgetAction(ParamFetcherInterface $paramFetcher)
{
$query = new CustomerQuery();
$query->setCurrentUser($this->getUser());
if (null !== ($order = $paramFetcher->get('order'))) {
$query->setOrder($order);

View File

@@ -70,6 +70,7 @@ class ProjectController extends BaseApiController
public function cgetAction(ParamFetcherInterface $paramFetcher)
{
$query = new ProjectQuery();
$query->setCurrentUser($this->getUser());
if (null !== ($order = $paramFetcher->get('order'))) {
$query->setOrder($order);

View File

@@ -104,9 +104,7 @@ class TagController extends BaseApiController
throw new NotFoundException();
}
$entityManager = $this->getDoctrine()->getManager();
$entityManager->remove($tag);
$entityManager->flush();
$this->repository->deleteTag($tag);
$view = new View(null, Response::HTTP_NO_CONTENT);

133
src/API/TeamController.php Normal file
View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
/*
* 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\API;
use App\Entity\Team;
use App\Repository\TeamRepository;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Component\HttpFoundation\Response;
/**
* @RouteResource("Team")
*/
class TeamController extends BaseApiController
{
/**
* @var TeamRepository
*/
protected $repository;
/**
* @var ViewHandlerInterface
*/
protected $viewHandler;
public function __construct(ViewHandlerInterface $viewHandler, TeamRepository $repository)
{
$this->viewHandler = $viewHandler;
$this->repository = $repository;
}
/**
* Fetch all existing teams
*
* @SWG\Response(
* response=200,
* description="Returns the collection of all existing teams",
* @SWG\Schema(
* type="array",
* @SWG\Items(ref="#/definitions/TeamCollection")
* )
* )
*
* @Security("is_granted('view_team')")
*
* @return Response
*/
public function cgetAction(ParamFetcherInterface $paramFetcher)
{
$data = $this->repository->findAll();
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Collection', 'Team']);
return $this->viewHandler->handle($view);
}
/**
* Returns one team
*
* @SWG\Response(
* response=200,
* description="Returns one team entity",
* @SWG\Schema(ref="#/definitions/TeamEntity"),
* )
*
* @param int $id
* @return Response
*/
public function getAction($id)
{
/** @var Team $data */
$data = $this->repository->find($id);
if (null === $data) {
throw new NotFoundException();
}
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Team']);
return $this->viewHandler->handle($view);
}
/**
* Delete a team
*
* @SWG\Delete(
* @SWG\Response(
* response=204,
* description="Delete one team"
* ),
* )
* @SWG\Parameter(
* name="id",
* in="path",
* type="integer",
* description="Team ID to delete",
* required=true,
* )
*
* @Security("is_granted('delete_team')")
*
* @param int $id
* @return Response
*/
public function deleteAction($id)
{
$team = $this->repository->find($id);
if (null === $team) {
throw new NotFoundException();
}
$this->repository->deleteTeam($team);
$view = new View(null, Response::HTTP_NO_CONTENT);
return $this->viewHandler->handle($view);
}
}

View File

@@ -47,4 +47,9 @@ class FormConfiguration implements SystemBundleConfiguration
{
return $this->find('user.language');
}
public function getUserDefaultCurrency(): string
{
return $this->find('user.currency');
}
}

View File

@@ -9,6 +9,7 @@
namespace App\Controller;
use App\Configuration\FormConfiguration;
use App\Entity\Activity;
use App\Entity\Project;
use App\Event\ActivityMetaDefinitionEvent;
@@ -40,14 +41,19 @@ class ActivityController extends AbstractController
* @var ActivityRepository
*/
private $repository;
/**
* @var FormConfiguration
*/
private $configuration;
/**
* @var EventDispatcherInterface
*/
protected $dispatcher;
public function __construct(ActivityRepository $repository, EventDispatcherInterface $dispatcher)
public function __construct(ActivityRepository $repository, FormConfiguration $configuration, EventDispatcherInterface $dispatcher)
{
$this->repository = $repository;
$this->configuration = $configuration;
$this->dispatcher = $dispatcher;
}
@@ -68,6 +74,7 @@ class ActivityController extends AbstractController
public function indexAction($page, Request $request)
{
$query = new ActivityQuery();
$query->setCurrentUser($this->getUser());
$query->setPage($page);
$form = $this->getToolbarForm($query);
@@ -250,15 +257,20 @@ class ActivityController extends AbstractController
*/
private function createEditForm(Activity $activity)
{
if ($activity->getId() === null) {
$url = $this->generateUrl('admin_activity_create');
} else {
$currency = $this->configuration->getCustomerDefaultCurrency();
$url = $this->generateUrl('admin_activity_create');
if ($activity->getId() !== null) {
$url = $this->generateUrl('admin_activity_edit', ['id' => $activity->getId()]);
if (null !== $activity->getProject()) {
$currency = $activity->getProject()->getCustomer()->getCurrency();
}
}
return $this->createForm(ActivityEditForm::class, $activity, [
'action' => $url,
'method' => 'POST',
'currency' => $currency,
'create_more' => true,
'customer' => true,
'include_budget' => $this->isGranted('budget', $activity)

View File

@@ -13,6 +13,7 @@ use App\Configuration\FormConfiguration;
use App\Entity\Customer;
use App\Event\CustomerMetaDefinitionEvent;
use App\Form\CustomerEditForm;
use App\Form\CustomerTeamPermissionForm;
use App\Form\Toolbar\CustomerToolbarForm;
use App\Form\Type\CustomerType;
use App\Repository\CustomerRepository;
@@ -71,14 +72,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 Response
*/
public function indexAction($page, Request $request)
{
$query = new CustomerQuery();
$query->setCurrentUser($this->getUser());
$query->setPage($page);
$form = $this->getToolbarForm($query);
@@ -98,9 +96,6 @@ class CustomerController extends AbstractController
/**
* @Route(path="/create", name="admin_customer_create", methods={"GET", "POST"})
* @Security("is_granted('create_customer')")
*
* @param Request $request
* @return RedirectResponse|Response
*/
public function createAction(Request $request)
{
@@ -117,12 +112,39 @@ class CustomerController extends AbstractController
return $this->renderCustomerForm($customer, $request);
}
/**
* @Route(path="/{id}/permissions", name="admin_customer_permissions", methods={"GET", "POST"})
* @Security("is_granted('permissions', customer)")
*/
public function teamPermissions(Customer $customer, Request $request)
{
$form = $this->createForm(CustomerTeamPermissionForm::class, $customer, [
'action' => $this->generateUrl('admin_customer_permissions', ['id' => $customer->getId()]),
'method' => 'POST',
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->getRepository()->saveCustomer($customer);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_customer');
} catch (ORMException $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}
return $this->render('customer/permissions.html.twig', [
'customer' => $customer,
'form' => $form->createView()
]);
}
/**
* @Route(path="/{id}/budget", name="admin_customer_budget", methods={"GET"})
* @Security("is_granted('budget', customer)")
*
* @param Customer $customer
* @return Response
*/
public function budgetAction(Customer $customer)
{
@@ -135,10 +157,6 @@ 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 RedirectResponse|Response
*/
public function editAction(Customer $customer, Request $request)
{
@@ -148,10 +166,6 @@ class CustomerController extends AbstractController
/**
* @Route(path="/{id}/delete", name="admin_customer_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', customer)")
*
* @param Customer $customer
* @param Request $request
* @return RedirectResponse|Response
*/
public function deleteAction(Customer $customer, Request $request)
{
@@ -169,6 +183,7 @@ class CustomerController extends AbstractController
'query_builder' => function (CustomerRepository $repo) use ($customer) {
$query = new CustomerFormTypeQuery();
$query->setCustomerToIgnore($customer);
$query->setUser($this->getUser());
return $repo->getQueryBuilderForFormType($query);
},
@@ -229,11 +244,7 @@ class CustomerController extends AbstractController
]);
}
/**
* @param CustomerQuery $query
* @return FormInterface
*/
protected function getToolbarForm(CustomerQuery $query)
protected function getToolbarForm(CustomerQuery $query): FormInterface
{
return $this->createForm(CustomerToolbarForm::class, $query, [
'action' => $this->generateUrl('admin_customer', [
@@ -243,11 +254,7 @@ class CustomerController extends AbstractController
]);
}
/**
* @param Customer $customer
* @return FormInterface
*/
private function createEditForm(Customer $customer)
private function createEditForm(Customer $customer): FormInterface
{
if ($customer->getId() === null) {
$url = $this->generateUrl('admin_customer_create');

View File

@@ -69,6 +69,7 @@ class ExportController extends AbstractController
$query->setEnd($end);
$query->setState(ExportQuery::STATE_STOPPED);
$query->setExported(ExportQuery::STATE_NOT_EXPORTED);
$query->setCurrentUser($this->getUser());
return $query;
}

View File

@@ -71,6 +71,7 @@ class InvoiceController extends AbstractController
$query->setBegin($begin);
$query->setEnd($end);
$query->setState(InvoiceQuery::STATE_STOPPED);
$query->setCurrentUser($this->getUser());
return $query;
}

View File

@@ -16,12 +16,14 @@ use App\Form\UserEditType;
use App\Form\UserPasswordType;
use App\Form\UserPreferencesForm;
use App\Form\UserRolesType;
use App\Form\UserTeamsType;
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\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
@@ -175,6 +177,28 @@ class ProfileController extends AbstractController
return $this->getProfileView($profile, 'roles', null, null, $form);
}
/**
* @Route(path="/{username}/teams", name="user_profile_teams", methods={"GET", "POST"})
* @Security("is_granted('teams', profile)")
*/
public function teamsAction(User $profile, Request $request)
{
$form = $this->createTeamsForm($profile);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($profile);
$entityManager->flush();
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('user_profile_teams', ['username' => $profile->getUsername()]);
}
return $this->getProfileView($profile, 'teams', null, null, null, null, $form);
}
/**
* @Route(path="/{username}/prefs", name="user_profile_preferences", methods={"GET", "POST"})
* @Security("is_granted('preferences', profile)")
@@ -236,24 +260,15 @@ class ProfileController extends AbstractController
]);
}
/**
* @param User $user
* @param string $tab
* @param Form|null $editForm
* @param Form|null $pwdForm
* @param Form|null $rolesForm
* @param Form|null $apiTokenForm
* @return \Symfony\Component\HttpFoundation\Response
* @throws \Doctrine\ORM\NonUniqueResultException
*/
protected function getProfileView(
User $user,
string $tab,
Form $editForm = null,
Form $pwdForm = null,
Form $rolesForm = null,
Form $apiTokenForm = null
) {
FormInterface $editForm = null,
FormInterface $pwdForm = null,
FormInterface $rolesForm = null,
FormInterface $apiTokenForm = null,
FormInterface $teamsForm = null
): Response {
$forms = [];
if ($this->isGranted(UserVoter::EDIT, $user)) {
@@ -268,6 +283,10 @@ class ProfileController extends AbstractController
$apiTokenForm = $apiTokenForm ?: $this->createApiTokenForm($user);
$forms['api-token'] = $apiTokenForm->createView();
}
if ($this->isGranted(UserVoter::TEAMS, $user)) {
$teamsForm = $teamsForm ?: $this->createTeamsForm($user);
$forms['teams'] = $teamsForm->createView();
}
if ($this->isGranted(UserVoter::ROLES, $user)) {
$rolesForm = $rolesForm ?: $this->createRolesForm($user);
$forms['roles'] = $rolesForm->createView();
@@ -280,11 +299,7 @@ class ProfileController extends AbstractController
]);
}
/**
* @param User $user
* @return \Symfony\Component\Form\FormInterface
*/
private function createPreferencesForm(User $user)
private function createPreferencesForm(User $user): FormInterface
{
// we need to prepare the user preferences, which is done via an EventSubscriber
$event = new PrepareUserEvent($user);
@@ -300,11 +315,7 @@ class ProfileController extends AbstractController
);
}
/**
* @param User $user
* @return \Symfony\Component\Form\FormInterface
*/
private function createEditForm(User $user)
private function createEditForm(User $user): FormInterface
{
return $this->createForm(
UserEditType::class,
@@ -317,11 +328,7 @@ class ProfileController extends AbstractController
);
}
/**
* @param User $user
* @return \Symfony\Component\Form\FormInterface
*/
private function createRolesForm(User $user)
private function createRolesForm(User $user): FormInterface
{
return $this->createForm(
UserRolesType::class,
@@ -333,11 +340,19 @@ class ProfileController extends AbstractController
);
}
/**
* @param User $user
* @return \Symfony\Component\Form\FormInterface
*/
private function createPasswordForm(User $user)
private function createTeamsForm(User $user): FormInterface
{
return $this->createForm(
UserTeamsType::class,
$user,
[
'action' => $this->generateUrl('user_profile_teams', ['username' => $user->getUsername()]),
'method' => 'POST',
]
);
}
private function createPasswordForm(User $user): FormInterface
{
return $this->createForm(
UserPasswordType::class,
@@ -350,11 +365,7 @@ class ProfileController extends AbstractController
);
}
/**
* @param User $user
* @return \Symfony\Component\Form\FormInterface
*/
private function createApiTokenForm(User $user)
private function createApiTokenForm(User $user): FormInterface
{
return $this->createForm(
UserApiTokenType::class,

View File

@@ -9,10 +9,12 @@
namespace App\Controller;
use App\Configuration\FormConfiguration;
use App\Entity\Customer;
use App\Entity\Project;
use App\Event\ProjectMetaDefinitionEvent;
use App\Form\ProjectEditForm;
use App\Form\ProjectTeamPermissionForm;
use App\Form\Toolbar\ProjectToolbarForm;
use App\Form\Type\ProjectType;
use App\Repository\ProjectRepository;
@@ -40,14 +42,19 @@ class ProjectController extends AbstractController
* @var ProjectRepository
*/
private $repository;
/**
* @var FormConfiguration
*/
private $configuration;
/**
* @var EventDispatcherInterface
*/
protected $dispatcher;
public function __construct(ProjectRepository $repository, EventDispatcherInterface $dispatcher)
public function __construct(ProjectRepository $repository, FormConfiguration $configuration, EventDispatcherInterface $dispatcher)
{
$this->repository = $repository;
$this->configuration = $configuration;
$this->dispatcher = $dispatcher;
}
@@ -60,14 +67,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"})
* @Security("is_granted('view_project')")
*
* @param int $page
* @param Request $request
* @return Response
*/
public function indexAction($page, Request $request)
{
$query = new ProjectQuery();
$query->setCurrentUser($this->getUser());
$query->setPage($page);
$form = $this->getToolbarForm($query);
@@ -85,14 +89,40 @@ class ProjectController extends AbstractController
]);
}
/**
* @Route(path="/{id}/permissions", name="admin_project_permissions", methods={"GET", "POST"})
* @Security("is_granted('permissions', project)")
*/
public function teamPermissions(Project $project, Request $request)
{
$form = $this->createForm(ProjectTeamPermissionForm::class, $project, [
'action' => $this->generateUrl('admin_project_permissions', ['id' => $project->getId()]),
'method' => 'POST',
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->getRepository()->saveProject($project);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_project');
} catch (ORMException $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}
return $this->render('project/permissions.html.twig', [
'project' => $project,
'form' => $form->createView()
]);
}
/**
* @Route(path="/create", name="admin_project_create", methods={"GET", "POST"})
* @Route(path="/create/{customer}", name="admin_project_create_with_customer", methods={"GET", "POST"})
* @Security("is_granted('create_project')")
*
* @param Request $request
* @param Customer|null $customer
* @return RedirectResponse|Response
*/
public function createAction(Request $request, ?Customer $customer = null)
{
@@ -108,9 +138,6 @@ class ProjectController extends AbstractController
/**
* @Route(path="/{id}/budget", name="admin_project_budget", methods={"GET"})
* @Security("is_granted('budget', project)")
*
* @param Project $project
* @return Response
*/
public function budgetAction(Project $project)
{
@@ -123,10 +150,6 @@ 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 RedirectResponse|Response
*/
public function editAction(Project $project, Request $request)
{
@@ -136,10 +159,6 @@ class ProjectController extends AbstractController
/**
* @Route(path="/{id}/delete", name="admin_project_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', project)")
*
* @param Project $project
* @param Request $request
* @return RedirectResponse|Response
*/
public function deleteAction(Project $project, Request $request)
{
@@ -158,6 +177,7 @@ class ProjectController extends AbstractController
$query = new ProjectFormTypeQuery();
$query->setCustomer($project->getCustomer());
$query->setProjectToIgnore($project);
$query->setUser($this->getUser());
return $repo->getQueryBuilderForFormType($query);
},
@@ -225,11 +245,7 @@ class ProjectController extends AbstractController
]);
}
/**
* @param ProjectQuery $query
* @return FormInterface
*/
protected function getToolbarForm(ProjectQuery $query)
protected function getToolbarForm(ProjectQuery $query): FormInterface
{
return $this->createForm(ProjectToolbarForm::class, $query, [
'action' => $this->generateUrl('admin_project', [
@@ -239,16 +255,12 @@ class ProjectController extends AbstractController
]);
}
/**
* @param Project $project
* @return FormInterface
*/
private function createEditForm(Project $project)
private function createEditForm(Project $project): FormInterface
{
if ($project->getId() === null) {
$url = $this->generateUrl('admin_project_create');
$currency = Customer::DEFAULT_CURRENCY;
} else {
$currency = $this->configuration->getCustomerDefaultCurrency();
$url = $this->generateUrl('admin_project_create');
if ($project->getId() !== null) {
$url = $this->generateUrl('admin_project_edit', ['id' => $project->getId()]);
$currency = $project->getCustomer()->getCurrency();
}

View File

@@ -248,6 +248,10 @@ class SystemConfigurationController extends AbstractController
->setName('defaults.user.theme')
->setLabel('skin')
->setType(SkinType::class),
(new Configuration())
->setName('defaults.user.currency')
->setLabel('currency')
->setType(CurrencyType::class),
]),
(new SystemConfigurationModel())
->setSection(SystemConfigurationModel::SECTION_THEME)

View File

@@ -0,0 +1,185 @@
<?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\Controller;
use App\Entity\Team;
use App\Form\TeamCustomerForm;
use App\Form\TeamEditForm;
use App\Form\TeamProjectForm;
use App\Form\Toolbar\TeamToolbarForm;
use App\Repository\Query\TeamQuery;
use App\Repository\TeamRepository;
use Doctrine\ORM\ORMException;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route(path="/admin/teams")
* @Security("is_granted('view_team')")
*/
class TeamController extends AbstractController
{
/**
* @var TeamRepository
*/
private $repository;
public function __construct(TeamRepository $repository)
{
$this->repository = $repository;
}
/**
* @Route(path="/", defaults={"page": 1}, name="admin_team", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_team_paginated", methods={"GET"})
*
* @param TeamRepository $repository
* @param Request $request
* @param int $page
* @return Response
*/
public function listTeams(TeamRepository $repository, Request $request, $page)
{
$query = new TeamQuery();
$query->setPage($page);
$query->setOrderBy('name');
$form = $this->getToolbarForm($query);
$form->setData($query);
$form->submit($request->query->all(), false);
$teams = $repository->getPagerfantaForQuery($query);
return $this->render('team/index.html.twig', [
'teams' => $teams,
'query' => $query,
'showFilter' => $query->isDirty(),
'toolbarForm' => $form->createView(),
]);
}
/**
* @Route(path="/create", name="admin_team_create", methods={"GET", "POST"})
* @Security("is_granted('create_team')")
*
* @param Request $request
* @return RedirectResponse|Response
*/
public function createTeam(Request $request)
{
return $this->renderEditScreen(new Team(), $request);
}
/**
* @Route(path="/{id}/edit", name="admin_team_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', team)")
*/
public function editAction(Team $team, Request $request)
{
return $this->renderEditScreen($team, $request);
}
private function renderEditScreen(Team $team, Request $request): Response
{
$customerForm = null;
$projectForm = null;
if ($team->getId() === null) {
$url = $this->generateUrl('admin_team_create');
} else {
$url = $this->generateUrl('admin_team_edit', ['id' => $team->getId()]);
}
$editForm = $this->createForm(TeamEditForm::class, $team, [
'action' => $url,
'method' => 'POST',
]);
if ($request->isMethod('POST') && (null !== ($editFormValues = $request->get($editForm->getName())))) {
$editForm->submit($editFormValues, true);
if ($editForm->isValid()) {
try {
// make sure that the teamlead is always part of the team, otherwise permission checks
// and filtering might not work as expected!
$team->addUser($team->getTeamLead());
$this->repository->saveTeam($team);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]);
} catch (ORMException $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}
}
if (null !== $team->getId()) {
$customerForm = $this->createForm(TeamCustomerForm::class, $team, [
'method' => 'POST',
]);
if ($request->isMethod('POST') && (null !== ($customerFormValues = $request->get($customerForm->getName())))) {
$customerForm->submit($customerFormValues, true);
if ($customerForm->isValid()) {
try {
$this->repository->saveTeam($team);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]);
} catch (ORMException $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}
}
$projectForm = $this->createForm(TeamProjectForm::class, $team, [
'method' => 'POST',
]);
if ($request->isMethod('POST') && (null !== ($projectFormValues = $request->get($projectForm->getName())))) {
$projectForm->submit($projectFormValues, true);
if ($projectForm->isValid()) {
try {
$this->repository->saveTeam($team);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]);
} catch (ORMException $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}
}
}
return $this->render('team/edit.html.twig', [
'team' => $team,
'form' => $editForm->createView(),
'customerForm' => $customerForm ? $customerForm->createView() : null,
'projectForm' => $projectForm ? $projectForm->createView() : null,
]);
}
private function getToolbarForm(TeamQuery $query): FormInterface
{
return $this->createForm(TeamToolbarForm::class, $query, [
'action' => $this->generateUrl('admin_team', [
'page' => $query->getPage(),
]),
'method' => 'GET',
]);
}
}

View File

@@ -112,9 +112,7 @@ abstract class TimesheetAbstractController extends AbstractController
$dirtyQuery = $query->isDirty();
if (!$this->includeUserInForms()) {
$query->setUser($this->getUser());
}
$this->prepareQuery($query);
$pager = $this->getRepository()->getPagerfantaForQuery($query);
@@ -253,9 +251,7 @@ abstract class TimesheetAbstractController extends AbstractController
}
$query->getEnd()->setTime(23, 59, 59);
if (!$this->includeUserInForms()) {
$query->setUser($this->getUser());
}
$this->prepareQuery($query);
$entries = $this->getRepository()->getTimesheetsForQuery($query);
@@ -265,6 +261,11 @@ abstract class TimesheetAbstractController extends AbstractController
]);
}
protected function prepareQuery(TimesheetQuery $query)
{
$query->setUser($this->getUser());
}
protected function getCreateForm(Timesheet $entry, TrackingModeInterface $mode): FormInterface
{
return $this->createForm($this->getCreateFormClassName(), $entry, [

View File

@@ -13,6 +13,7 @@ use App\Entity\Timesheet;
use App\Form\TimesheetAdminEditForm;
use App\Repository\ActivityRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TagRepository;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
@@ -76,6 +77,11 @@ class TimesheetTeamController extends TimesheetAbstractController
return $this->create($request, 'timesheet-team/edit.html.twig', $projectRepository, $activityRepository, $tagRepository);
}
protected function prepareQuery(TimesheetQuery $query)
{
$query->setCurrentUser($this->getUser());
}
protected function getCreateFormClassName(): string
{
return TimesheetAdminEditForm::class;

View File

@@ -9,6 +9,7 @@
namespace App\DependencyInjection;
use App\Entity\Customer;
use App\Entity\User;
use App\Timesheet\Rounding\RoundingInterface;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
@@ -439,7 +440,7 @@ class Configuration implements ConfigurationInterface
->children()
->scalarNode('timezone')->defaultNull()->end()
->scalarNode('country')->defaultValue('DE')->end()
->scalarNode('currency')->defaultValue('EUR')->end()
->scalarNode('currency')->defaultValue(Customer::DEFAULT_CURRENCY)->end()
->end()
->end()
->arrayNode('user')
@@ -448,9 +449,9 @@ class Configuration implements ConfigurationInterface
->scalarNode('timezone')->defaultNull()->end()
->scalarNode('language')->defaultValue(User::DEFAULT_LANGUAGE)->end()
->scalarNode('theme')->defaultNull()->end()
->scalarNode('currency')->defaultValue(Customer::DEFAULT_CURRENCY)->end()
->end()
->end()
->end()
;

View File

@@ -160,9 +160,26 @@ class Customer implements EntityWithMetaFields
*/
private $meta;
/**
* @var Team[]|ArrayCollection
*
* @ORM\ManyToMany(targetEntity="Team", cascade={"remove", "persist"}, inversedBy="customers")
* @ORM\JoinTable(
* name="kimai2_customers_teams",
* joinColumns={
* @ORM\JoinColumn(name="customer_id", referencedColumnName="id", onDelete="CASCADE")
* },
* inverseJoinColumns={
* @ORM\JoinColumn(name="team_id", referencedColumnName="id", onDelete="CASCADE")
* }
* )
*/
private $teams;
public function __construct()
{
$this->meta = new ArrayCollection();
$this->teams = new ArrayCollection();
}
public function getId(): ?int
@@ -399,6 +416,33 @@ class Customer implements EntityWithMetaFields
return $this;
}
public function addTeam(Team $team)
{
if ($this->teams->contains($team)) {
return $this;
}
$this->teams->add($team);
$team->addCustomer($this);
}
public function removeTeam(Team $team)
{
if (!$this->teams->contains($team)) {
return;
}
$this->teams->removeElement($team);
$team->removeCustomer($this);
}
/**
* @return Collection<Team>
*/
public function getTeams(): Collection
{
return $this->teams;
}
/**
* @return string
*/

View File

@@ -90,9 +90,26 @@ class Project implements EntityWithMetaFields
*/
private $meta;
/**
* @var Team[]|ArrayCollection
*
* @ORM\ManyToMany(targetEntity="Team", cascade={"remove", "persist"}, inversedBy="projects")
* @ORM\JoinTable(
* name="kimai2_projects_teams",
* joinColumns={
* @ORM\JoinColumn(name="project_id", referencedColumnName="id", onDelete="CASCADE")
* },
* inverseJoinColumns={
* @ORM\JoinColumn(name="team_id", referencedColumnName="id", onDelete="CASCADE")
* }
* )
*/
private $teams;
public function __construct()
{
$this->meta = new ArrayCollection();
$this->teams = new ArrayCollection();
}
public function getId(): ?int
@@ -223,6 +240,33 @@ class Project implements EntityWithMetaFields
return $this;
}
public function addTeam(Team $team)
{
if ($this->teams->contains($team)) {
return;
}
$this->teams->add($team);
$team->addProject($this);
}
public function removeTeam(Team $team)
{
if (!$this->teams->contains($team)) {
return;
}
$this->teams->removeElement($team);
$team->removeProject($this);
}
/**
* @return Collection<Team>
*/
public function getTeams(): Collection
{
return $this->teams;
}
/**
* @return string
*/

210
src/Entity/Team.php Normal file
View File

@@ -0,0 +1,210 @@
<?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\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Table(name="kimai2_teams",
* uniqueConstraints={
* @ORM\UniqueConstraint(columns={"name"})
* }
* )
* @ORM\Entity(repositoryClass="App\Repository\TeamRepository")
* @UniqueEntity("name")
*/
class Team
{
/**
* @var int
*
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
*/
private $id;
/**
* @var string
*
* @ORM\Column(name="name", type="string", length=100, nullable=false)
* @Assert\NotBlank()
* @Assert\Length(min=2, max=100)
*/
private $name;
/**
* @var User
*
* @ORM\ManyToOne(targetEntity="App\Entity\User")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $teamlead;
/**
* @var User[]|ArrayCollection
*
* @ORM\ManyToMany(targetEntity="User", mappedBy="teams", fetch="EXTRA_LAZY")
*/
private $users;
/**
* @var Customer[]|ArrayCollection
*
* @ORM\ManyToMany(targetEntity="Customer", mappedBy="teams", fetch="EXTRA_LAZY")
*/
private $customers;
/**
* @var Project[]|ArrayCollection
*
* @ORM\ManyToMany(targetEntity="Project", mappedBy="teams", fetch="EXTRA_LAZY")
*/
private $projects;
public function __construct()
{
$this->users = new ArrayCollection();
$this->customers = new ArrayCollection();
$this->projects = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function setName(string $name): Team
{
$this->name = $name;
return $this;
}
public function getName(): ?string
{
return $this->name;
}
public function getTeamLead(): ?User
{
return $this->teamlead;
}
public function isTeamlead(User $user): bool
{
return $this->teamlead === $user;
}
public function setTeamLead(User $teamlead): Team
{
$this->teamlead = $teamlead;
$this->addUser($teamlead);
return $this;
}
public function hasUser(User $user): bool
{
return $this->users->contains($user);
}
public function addUser(User $user)
{
if ($this->users->contains($user)) {
return;
}
$this->users->add($user);
$user->addTeam($this);
}
public function removeUser(User $user)
{
if (!$this->users->contains($user)) {
return;
}
$this->users->removeElement($user);
$user->removeTeam($this);
}
/**
* @return Collection<User>
*/
public function getUsers(): iterable
{
return $this->users;
}
public function addCustomer(Customer $customer)
{
if ($this->customers->contains($customer)) {
return;
}
$this->customers->add($customer);
$customer->addTeam($this);
}
public function removeCustomer(Customer $customer)
{
if (!$this->customers->contains($customer)) {
return;
}
$this->customers->removeElement($customer);
$customer->removeTeam($this);
}
/**
* @return Collection<Customer>
*/
public function getCustomers(): iterable
{
return $this->customers;
}
public function addProject(Project $project)
{
if ($this->projects->contains($project)) {
return;
}
$this->projects->add($project);
$project->addTeam($this);
}
public function removeProject(Project $project)
{
if (!$this->projects->contains($project)) {
return;
}
$this->projects->removeElement($project);
$project->removeTeam($this);
}
/**
* @return Collection<Project>
*/
public function getProjects(): iterable
{
return $this->projects;
}
/**
* @return string
*/
public function __toString()
{
return $this->getName();
}
}

View File

@@ -140,14 +140,14 @@ class Timesheet implements EntityWithMetaFields
/**
* @var Tag[]|ArrayCollection
*
* @ORM\ManyToMany(targetEntity="Tag", inversedBy="timesheets", cascade={"persist"})
* @ORM\ManyToMany(targetEntity="Tag", inversedBy="timesheets", cascade={"remove", "persist"})
* @ORM\JoinTable(
* name="kimai2_timesheet_tags",
* joinColumns={
* @ORM\JoinColumn(name="timesheet_id", referencedColumnName="id")
* @ORM\JoinColumn(name="timesheet_id", referencedColumnName="id", onDelete="CASCADE")
* },
* inverseJoinColumns={
* @ORM\JoinColumn(name="tag_id", referencedColumnName="id")
* @ORM\JoinColumn(name="tag_id", referencedColumnName="id", onDelete="CASCADE")
* }
* )
*/

View File

@@ -95,6 +95,22 @@ class User extends BaseUser implements UserInterface
*/
private $preferences;
/**
* @var Team[]|ArrayCollection
*
* @ORM\ManyToMany(targetEntity="Team", inversedBy="users", cascade={"remove", "persist"})
* @ORM\JoinTable(
* name="kimai2_users_teams",
* joinColumns={
* @ORM\JoinColumn(name="user_id", referencedColumnName="id", onDelete="CASCADE")
* },
* inverseJoinColumns={
* @ORM\JoinColumn(name="team_id", referencedColumnName="id", onDelete="CASCADE")
* }
* )
*/
private $teams;
/**
* User constructor.
*/
@@ -103,6 +119,7 @@ class User extends BaseUser implements UserInterface
parent::__construct();
$this->registeredAt = new \DateTime();
$this->preferences = new ArrayCollection();
$this->teams = new ArrayCollection();
}
public function getId(): ?int
@@ -274,6 +291,55 @@ class User extends BaseUser implements UserInterface
return $this;
}
public function addTeam(Team $team): User
{
if ($this->teams->contains($team)) {
return $this;
}
$this->teams->add($team);
$team->addUser($this);
return $this;
}
public function removeTeam(Team $team)
{
if (!$this->teams->contains($team)) {
return;
}
$this->teams->removeElement($team);
$team->removeUser($this);
}
/**
* @return Collection<Team>
*/
public function getTeams(): Collection
{
return $this->teams;
}
public function isInTeam(Team $team): bool
{
return $this->teams->contains($team);
}
public function isTeamleadOf(Team $team): bool
{
return $team->getTeamLead() === $this;
}
public function isTeamlead(): bool
{
return $this->hasRole(static::ROLE_TEAMLEAD);
}
public function isAdmin(): bool
{
return $this->hasRole(static::ROLE_ADMIN);
}
/**
* @return string
*/

View File

@@ -38,7 +38,6 @@ class UserPreference
* @ORM\Column(name="id", type="integer")
*/
private $id;
/**
* @var User
*
@@ -47,7 +46,6 @@ class UserPreference
* @Assert\NotNull()
*/
private $user;
/**
* @var string
*
@@ -55,28 +53,29 @@ class UserPreference
* @Assert\Length(min=2, max=50)
*/
private $name;
/**
* @var string
*
* @ORM\Column(name="value", type="string", length=255, nullable=true)
*/
private $value;
/**
* @var string
*/
protected $type;
private $type;
/**
* @var bool
*/
protected $enabled = true;
private $enabled = true;
/**
* @var Constraint[]
*/
protected $constraints = [];
private $constraints = [];
/**
* An array of options for the form element
* @var array
*/
private $options = [];
/**
* @return int
@@ -237,4 +236,27 @@ class UserPreference
{
return $this->constraints;
}
/**
* Set an array of options for the FormType.
*
* @param array $options
* @return UserPreference
*/
public function setOptions(array $options): UserPreference
{
$this->options = $options;
return $this;
}
/**
* Returns an array with options for the FormType.
*
* @return array
*/
public function getOptions(): array
{
return $this->options;
}
}

View File

@@ -142,6 +142,12 @@ final class MenuSubscriber implements EventSubscriberInterface
);
}
if ($auth->isGranted('view_team')) {
$menu->addChild(
new MenuItemModel('user_team', 'menu.admin_team', 'admin_team', [], $this->getIcon('team'))
);
}
if ($auth->isGranted('plugins')) {
$menu->addChild(
new MenuItemModel('plugins', 'menu.plugin', 'plugins', [], $this->getIcon('plugin'))

View File

@@ -66,6 +66,11 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
return $this->formConfig->getUserDefaultTheme();
}
private function getDefaultCurrency(): ?string
{
return $this->formConfig->getUserDefaultCurrency();
}
private function getDefaultLanguage(): string
{
return $this->formConfig->getUserDefaultLanguage();
@@ -88,9 +93,11 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
public function getDefaultPreferences(User $user)
{
$enableHourlyRate = false;
$hourlyRateOptions = [];
if ($this->voter->isGranted('hourly-rate', $user)) {
$enableHourlyRate = true;
$hourlyRateOptions = ['currency' => $this->getDefaultCurrency()];
}
return [
@@ -99,6 +106,7 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
->setValue(0)
->setType(MoneyType::class)
->setEnabled($enableHourlyRate)
->setOptions($hourlyRateOptions)
->addConstraint(new Range(['min' => 0])),
(new UserPreference())
@@ -165,6 +173,7 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
->setType($preference->getType())
->setConstraints($preference->getConstraints())
->setEnabled($preference->isEnabled())
->setOptions($preference->getOptions())
;
} else {
$prefs[$preference->getName()] = $preference;

View File

@@ -67,8 +67,11 @@ class ActivityEditForm extends AbstractType
if ($options['customer']) {
$builder
->add('customer', CustomerType::class, [
'query_builder' => function (CustomerRepository $repo) use ($customer) {
return $repo->getQueryBuilderForFormType(new CustomerFormTypeQuery($customer));
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
$query = new CustomerFormTypeQuery($customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
'data' => $customer ? $customer : null,
'required' => false,
@@ -80,15 +83,18 @@ class ActivityEditForm extends AbstractType
$builder
->add('project', ProjectType::class, [
'required' => false,
'query_builder' => function (ProjectRepository $repo) use ($project, $customer) {
return $repo->getQueryBuilderForFormType(new ProjectFormTypeQuery($project, $customer));
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer) {
$query = new ProjectFormTypeQuery($project, $customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
]);
// replaces the project select after submission, to make sure only projects for the selected customer are displayed
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($project) {
function (FormEvent $event) use ($builder, $project) {
$data = $event->getData();
if (!isset($data['customer']) || empty($data['customer'])) {
return;
@@ -96,8 +102,11 @@ class ActivityEditForm extends AbstractType
$event->getForm()->add('project', ProjectType::class, [
'group_by' => null,
'query_builder' => function (ProjectRepository $repo) use ($data, $project) {
return $repo->getQueryBuilderForFormType(new ProjectFormTypeQuery($project, $data['customer']));
'query_builder' => function (ProjectRepository $repo) use ($builder, $data, $project) {
$query = new ProjectFormTypeQuery($project, $data['customer']);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
]);
}

View File

@@ -0,0 +1,51 @@
<?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;
use App\Entity\Customer;
use App\Form\Type\TeamType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class CustomerTeamPermissionForm extends AbstractType
{
use EntityFormTrait;
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('teams', TeamType::class, [
'multiple' => true,
'expanded' => true,
'by_reference' => false,
])
;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Customer::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'admin_customer_teams_edit',
'attr' => [
'data-form-event' => 'kimai.customerTeamUpdate'
],
]);
}
}

View File

@@ -0,0 +1,45 @@
<?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\Extension;
use App\Entity\User;
use App\Security\CurrentUser;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class UserExtension extends AbstractTypeExtension
{
/**
* @var CurrentUser
*/
private $user;
public function __construct(CurrentUser $user)
{
$this->user = $user;
}
public static function getExtendedTypes(): iterable
{
return [FormType::class];
}
/**
* @param OptionsResolver $resolver
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefined(['user']);
// null needs to be allowed, as there is no user for anonymoud forms (like "forgot password" and "registration")
$resolver->setAllowedTypes('user', [User::class, 'null']);
$resolver->setDefault('user', $this->user->getUser());
}
}

View File

@@ -59,8 +59,11 @@ class ProjectEditForm extends AbstractType
'required' => false,
])
->add('customer', CustomerType::class, [
'query_builder' => function (CustomerRepository $repo) use ($customer) {
return $repo->getQueryBuilderForFormType(new CustomerFormTypeQuery($customer));
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
$query = new CustomerFormTypeQuery($customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
]);

View File

@@ -0,0 +1,51 @@
<?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;
use App\Entity\Project;
use App\Form\Type\TeamType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ProjectTeamPermissionForm extends AbstractType
{
use EntityFormTrait;
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('teams', TeamType::class, [
'multiple' => true,
'expanded' => true,
'by_reference' => false,
])
;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Project::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'admin_project_teams_edit',
'attr' => [
'data-form-event' => 'kimai.projectTeamUpdate'
],
]);
}
}

View File

@@ -0,0 +1,51 @@
<?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;
use App\Entity\Team;
use App\Form\Type\CustomerType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TeamCustomerForm extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('customers', CustomerType::class, [
'multiple' => true,
'expanded' => true,
'by_reference' => false,
'query_builder_for_user' => false,
'required' => false,
])
;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Team::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'admin_team_customer',
'attr' => [
'data-form-event' => 'kimai.teamUpdate'
],
]);
}
}

61
src/Form/TeamEditForm.php Normal file
View File

@@ -0,0 +1,61 @@
<?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;
use App\Entity\Team;
use App\Form\Type\UserType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TeamEditForm extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('name', TextType::class, [
'label' => 'label.name',
'attr' => [
'autofocus' => 'autofocus'
],
])
->add('teamlead', UserType::class, [
'label' => 'label.teamlead',
'multiple' => false,
'expanded' => false,
])
->add('users', UserType::class, [
'multiple' => true,
'expanded' => true,
'by_reference' => false,
])
;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Team::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'admin_team_edit',
'attr' => [
'data-form-event' => 'kimai.teamUpdate'
],
]);
}
}

View File

@@ -0,0 +1,52 @@
<?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;
use App\Entity\Team;
use App\Form\Type\ProjectType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TeamProjectForm extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('projects', ProjectType::class, [
'multiple' => true,
'expanded' => false,
'by_reference' => false,
'attr' => ['size' => '20'],
'query_builder_for_user' => false,
'required' => false,
])
;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Team::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'admin_team_project',
'attr' => [
'data-form-event' => 'kimai.teamUpdate'
],
]);
}
}

View File

@@ -163,8 +163,11 @@ class TimesheetEditForm extends AbstractType
{
$builder
->add('customer', CustomerType::class, [
'query_builder' => function (CustomerRepository $repo) use ($customer) {
return $repo->getQueryBuilderForFormType(new CustomerFormTypeQuery($customer));
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
$query = new CustomerFormTypeQuery($customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
'data' => $customer ? $customer : '',
'required' => false,
@@ -189,8 +192,11 @@ class TimesheetEditForm extends AbstractType
array_merge($projectOptions, [
'placeholder' => '',
'activity_enabled' => true,
'query_builder' => function (ProjectRepository $repo) use ($project, $customer) {
return $repo->getQueryBuilderForFormType(new ProjectFormTypeQuery($project, $customer));
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer) {
$query = new ProjectFormTypeQuery($project, $customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
])
);
@@ -198,7 +204,7 @@ class TimesheetEditForm extends AbstractType
// replaces the project select after submission, to make sure only projects for the selected customer are displayed
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($project, $customer, $isNew) {
function (FormEvent $event) use ($builder, $project, $customer, $isNew) {
$data = $event->getData();
$customer = isset($data['customer']) && !empty($data['customer']) ? $data['customer'] : null;
$project = isset($data['project']) && !empty($data['project']) ? $data['project'] : $project;
@@ -207,7 +213,7 @@ class TimesheetEditForm extends AbstractType
'placeholder' => '',
'activity_enabled' => true,
'group_by' => null,
'query_builder' => function (ProjectRepository $repo) use ($project, $customer, $isNew) {
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer, $isNew) {
// is there a better wa to prevent starting a record with a hidden project ?
if ($isNew && !is_object($project)) {
/** @var Project $project */
@@ -221,8 +227,10 @@ class TimesheetEditForm extends AbstractType
}
}
}
$query = new ProjectFormTypeQuery($project, $customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType(new ProjectFormTypeQuery($project, $customer));
return $repo->getQueryBuilderForFormType($query);
},
]);
}

View File

@@ -66,13 +66,14 @@ abstract class AbstractToolbarForm extends AbstractType
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) {
function (FormEvent $event) use ($builder) {
$data = $event->getData();
$event->getForm()->add('customer', CustomerType::class, [
'required' => false,
'project_enabled' => true,
'query_builder' => function (CustomerRepository $repo) use ($data) {
'query_builder' => function (CustomerRepository $repo) use ($builder, $data) {
$query = new CustomerFormTypeQuery();
$query->setUser($builder->getOption('user'));
if (isset($data['customer']) && !empty($data['customer'])) {
$query->setCustomer($data['customer']);
}
@@ -138,13 +139,14 @@ abstract class AbstractToolbarForm extends AbstractType
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) {
function (FormEvent $event) use ($builder) {
$data = $event->getData();
$event->getForm()->add('project', ProjectType::class, [
'required' => false,
'activity_enabled' => true,
'query_builder' => function (ProjectRepository $repo) use ($data) {
'query_builder' => function (ProjectRepository $repo) use ($builder, $data) {
$query = new ProjectFormTypeQuery();
$query->setUser($builder->getOption('user'));
if (isset($data['customer']) && !empty($data['customer'])) {
$query->setCustomer($data['customer']);

View File

@@ -0,0 +1,37 @@
<?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\Toolbar;
use App\Repository\Query\TeamQuery;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TeamToolbarForm extends AbstractToolbarForm
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$this->addPageSizeChoice($builder);
$this->addHiddenPagination($builder);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => TeamQuery::class,
'csrf_protection' => false,
]);
}
}

View File

@@ -37,13 +37,22 @@ class CustomerType extends AbstractType
'label' => 'label.customer',
'class' => Customer::class,
'choice_label' => 'name',
'query_builder' => function (CustomerRepository $repo) {
return $repo->getQueryBuilderForFormType(new CustomerFormTypeQuery());
},
'query_builder_for_user' => true,
'project_enabled' => false,
'project_visibility' => ProjectQuery::SHOW_VISIBLE,
]);
$resolver->setDefault('query_builder', function (Options $options) {
return function (CustomerRepository $repo) use ($options) {
$query = new CustomerFormTypeQuery();
if (true === $options['query_builder_for_user']) {
$query->setUser($options['user']);
}
return $repo->getQueryBuilderForFormType($query);
};
});
$resolver->setDefault('api_data', function (Options $options) {
if (true === $options['project_enabled']) {
return [

View File

@@ -62,13 +62,22 @@ class ProjectType extends AbstractType
'group_by' => function (Project $project, $key, $index) {
return $project->getCustomer()->getName();
},
'query_builder' => function (ProjectRepository $repo) {
return $repo->getQueryBuilderForFormType(new ProjectFormTypeQuery());
},
'query_builder_for_user' => true,
'activity_enabled' => false,
'activity_visibility' => ActivityQuery::SHOW_VISIBLE,
]);
$resolver->setDefault('query_builder', function (Options $options) {
return function (ProjectRepository $repo) use ($options) {
$query = new ProjectFormTypeQuery();
if (true === $options['query_builder_for_user']) {
$query->setUser($options['user']);
}
return $repo->getQueryBuilderForFormType($query);
};
});
$resolver->setDefault('api_data', function (Options $options) {
if (true === $options['activity_enabled']) {
return [

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Type;
use App\Entity\Team;
use App\Repository\TeamRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TeamType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'class' => Team::class,
'label' => 'label.team',
'query_builder' => function (TeamRepository $repo) {
return $repo->createQueryBuilder('t')->orderBy('t.name', 'ASC');
},
'choice_label' => function (Team $team) {
return $team->getName();
},
]);
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return EntityType::class;
}
}

View File

@@ -17,12 +17,23 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Custom form field type to edit a user preference.
*/
class UserPreferenceType extends AbstractType
{
/**
* @var TranslatorInterface
*/
private $translate;
public function __construct(TranslatorInterface $translator)
{
$this->translate = $translator;
}
/**
* @param FormBuilderInterface $builder
* @param array $options
@@ -54,12 +65,22 @@ class UserPreferenceType extends AbstractType
$type = HiddenType::class;
}
$event->getForm()->add('value', $type, [
'label' => 'label.' . $preference->getName(),
'constraints' => $preference->getConstraints(),
'required' => $required,
'disabled' => !$preference->isEnabled(),
]);
$transId = 'label.' . $preference->getName();
if ($this->translate->trans($transId) === $transId) {
$transId = $preference->getName();
}
$options = array_merge(
[
'label' => $transId,
'constraints' => $preference->getConstraints(),
'required' => $required,
'disabled' => !$preference->isEnabled(),
],
$preference->getOptions()
);
$event->getForm()->add('value', $type, $options);
}
);
$builder->add('name', HiddenType::class);

View File

@@ -10,8 +10,11 @@
namespace App\Form\Type;
use App\Entity\User;
use App\Repository\Query\UserFormTypeQuery;
use App\Repository\UserRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
@@ -35,6 +38,15 @@ class UserType extends AbstractType
return $user->getUsername();
},
]);
$resolver->setDefault('query_builder', function (Options $options) {
return function (UserRepository $repo) use ($options) {
$query = new UserFormTypeQuery();
$query->setUser($options['user']);
return $repo->getQueryBuilderForFormType($query);
};
});
}
/**

View File

@@ -0,0 +1,49 @@
<?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;
use App\Entity\User;
use App\Form\Type\TeamType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Defines the form used to assign a User to teams.
*/
class UserTeamsType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('teams', TeamType::class, [
'label' => 'label.team',
'multiple' => true,
'expanded' => true,
])
;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => User::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'edit_user_teams',
]);
}
}

View File

@@ -39,13 +39,6 @@ class Version20190510205245 extends AbstractMigration
public function down(Schema $schema): void
{
$tags = $schema->getTable('kimai2_tags');
$tags->dropIndex('UNIQ_27CAF54C5E237E06');
$timesheetTags = $schema->getTable('kimai2_timesheet_tags');
$timesheetTags->dropIndex('IDX_E3284EFEABDD46BE');
$timesheetTags->dropIndex('IDX_E3284EFEBAD26311');
$schema->dropTable('kimai2_timesheet_tags');
$schema->dropTable('kimai2_tags');
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/*
* 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 DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Creates user team tables.
*
* @version 1.2
*/
final class Version20190729162655 extends AbstractMigration
{
public function getDescription(): string
{
return 'Fixes foreign keys on tag table';
}
public function up(Schema $schema): void
{
$timesheetTags = $schema->getTable('kimai2_timesheet_tags');
if (!$timesheetTags->hasForeignKey('FK_732EECA9ABDD46BE')) {
$timesheetTags->addForeignKeyConstraint('kimai2_timesheet', ['timesheet_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_732EECA9ABDD46BE');
}
if (!$timesheetTags->hasForeignKey('FK_732EECA9BAD26311')) {
$timesheetTags->addForeignKeyConstraint('kimai2_tags', ['tag_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_732EECA9BAD26311');
}
}
public function down(Schema $schema): void
{
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/*
* 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 DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Fixes foreign keys on tag table.
*
* @version 1.2
*/
final class Version20190730123324 extends AbstractMigration
{
public function getDescription(): string
{
return 'Creates user team and permission tables';
}
public function up(Schema $schema): void
{
$teams = $schema->createTable('kimai2_teams');
$teams->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
$teams->addColumn('name', 'string', ['notnull' => true, 'length' => 100]);
$teams->addColumn('teamlead_id', 'integer', ['length' => 11, 'notnull' => true]);
$teams->setPrimaryKey(['id']);
$teams->addUniqueIndex(['name'], 'UNIQ_3BEDDC7F5E237E06');
$teams->addForeignKeyConstraint('kimai2_users', ['teamlead_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_3BEDDC7F8F7DE5D7');
$userTeams = $schema->createTable('kimai2_users_teams');
$userTeams->addColumn('user_id', 'integer', ['length' => 11, 'notnull' => true]);
$userTeams->addColumn('team_id', 'integer', ['length' => 11, 'notnull' => true]);
$userTeams->addForeignKeyConstraint('kimai2_users', ['user_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_B5E92CF8A76ED395');
$userTeams->addForeignKeyConstraint('kimai2_teams', ['team_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_B5E92CF8296CD8AE');
$userTeams->setPrimaryKey(['user_id', 'team_id']);
$customerTeams = $schema->createTable('kimai2_customers_teams');
$customerTeams->addColumn('customer_id', 'integer', ['length' => 11, 'notnull' => true]);
$customerTeams->addColumn('team_id', 'integer', ['length' => 11, 'notnull' => true]);
$customerTeams->addForeignKeyConstraint('kimai2_customers', ['customer_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_50BD83889395C3F3');
$customerTeams->addForeignKeyConstraint('kimai2_teams', ['team_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_50BD8388296CD8AE');
$customerTeams->setPrimaryKey(['customer_id', 'team_id']);
$projectTeams = $schema->createTable('kimai2_projects_teams');
$projectTeams->addColumn('project_id', 'integer', ['length' => 11, 'notnull' => true]);
$projectTeams->addColumn('team_id', 'integer', ['length' => 11, 'notnull' => true]);
$projectTeams->addForeignKeyConstraint('kimai2_projects', ['project_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_9345D431166D1F9C');
$projectTeams->addForeignKeyConstraint('kimai2_teams', ['team_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_9345D431296CD8AE');
$projectTeams->setPrimaryKey(['project_id', 'team_id']);
}
public function down(Schema $schema): void
{
$schema->dropTable('kimai2_projects_teams');
$schema->dropTable('kimai2_customers_teams');
$schema->dropTable('kimai2_users_teams');
$schema->dropTable('kimai2_teams');
}
}

View File

@@ -11,6 +11,7 @@ namespace App\Repository;
use App\Entity\Activity;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\ActivityStatistic;
use App\Repository\Loader\ActivityLoader;
use App\Repository\Paginator\LoaderPaginator;
@@ -81,6 +82,47 @@ class ActivityRepository extends EntityRepository
return $stats;
}
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
{
// make sure that all queries without a user see all projects
if (null === $user && empty($teams)) {
return;
}
// make sure that admins see all activities
if (null !== $user && ($user->isSuperAdmin() || $user->isAdmin())) {
return;
}
if (null !== $user) {
$teams = array_merge($teams, $user->getTeams()->toArray());
}
$qb->leftJoin('p.teams', 'teams')
->leftJoin('c.teams', 'c_teams');
if (empty($teams)) {
$qb->andWhere($qb->expr()->isNull('c_teams'));
$qb->andWhere($qb->expr()->isNull('teams'));
return;
}
$orProject = $qb->expr()->orX(
$qb->expr()->isNull('teams'),
$qb->expr()->isMemberOf(':teams', 'p.teams')
);
$qb->andWhere($orProject);
$orCustomer = $qb->expr()->orX(
$qb->expr()->isNull('c_teams'),
$qb->expr()->isMemberOf(':teams', 'c.teams')
);
$qb->andWhere($orCustomer);
$qb->setParameter('teams', $teams);
}
/**
* @deprecated since 1.1
*/
@@ -181,16 +223,11 @@ class ActivityRepository extends EntityRepository
$qb
->select('a')
->from(Activity::class, 'a')
->leftJoin('a.project', 'p')
->leftJoin('p.customer', 'c')
->addOrderBy('a.' . $query->getOrderBy(), $query->getOrder())
;
if (!$query->isGlobalsOnly()) {
$qb
->leftJoin('a.project', 'p')
->leftJoin('p.customer', 'c')
;
}
$where = $qb->expr()->andX();
if (in_array($query->getVisibility(), [ActivityQuery::SHOW_VISIBLE, ActivityQuery::SHOW_HIDDEN])) {
@@ -239,6 +276,8 @@ class ActivityRepository extends EntityRepository
$qb->andWhere($where);
}
$this->addPermissionCriteria($qb, $query->getCurrentUser());
return $qb;
}

View File

@@ -13,6 +13,7 @@ use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\CustomerStatistic;
use App\Repository\Loader\CustomerLoader;
use App\Repository\Paginator\LoaderPaginator;
@@ -107,8 +108,41 @@ class CustomerRepository extends EntityRepository
return $stats;
}
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
{
// make sure that all queries without a user see all customers
if (null === $user && empty($teams)) {
return;
}
// make sure that admins see all customers
if (null !== $user && ($user->isSuperAdmin() || $user->isAdmin())) {
return;
}
if (null !== $user) {
$teams = array_merge($teams, $user->getTeams()->toArray());
}
$qb->leftJoin('c.teams', 'teams');
if (empty($teams)) {
$qb->andWhere($qb->expr()->isNull('teams'));
return;
}
$or = $qb->expr()->orX(
$qb->expr()->isNull('teams'),
$qb->expr()->isMemberOf(':teams', 'c.teams')
);
$qb->andWhere($or);
$qb->setParameter('teams', $teams);
}
/**
* @deprecated since 1.1
* @deprecated since 1.1 - don't use this method, it ignores team permission checks
*/
public function builderForEntityType($customer)
{
@@ -145,6 +179,8 @@ class CustomerRepository extends EntityRepository
$qb->setParameter('ignored', $query->getCustomerToIgnore());
}
$this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams());
return $qb;
}
@@ -152,7 +188,7 @@ class CustomerRepository extends EntityRepository
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('c', 'meta')
$qb->select('c')
->from(Customer::class, 'c')
->leftJoin('c.meta', 'meta')
->orderBy('c.' . $query->getOrderBy(), $query->getOrder());
@@ -165,6 +201,8 @@ class CustomerRepository extends EntityRepository
$qb->setParameter('visible', false, \PDO::PARAM_BOOL);
}
$this->addPermissionCriteria($qb, $query->getCurrentUser(), $query->getTeams());
return $qb;
}

View File

@@ -69,6 +69,27 @@ final class ActivityIdLoader implements LoaderInterface
->andWhere($qb->expr()->in('p.id', $projectIds))
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL a.{id}', 'PARTIAL project.{id}', 'teams', 'teamlead')
->from(Activity::class, 'a')
->leftJoin('a.project', 'project')
->leftJoin('project.teams', 'teams')
->leftJoin('teams.teamlead', 'teamlead')
->andWhere($qb->expr()->in('a.id', $ids))
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL a.{id}', 'PARTIAL project.{id}', 'PARTIAL customer.{id}', 'teams', 'teamlead')
->from(Activity::class, 'a')
->leftJoin('a.project', 'project')
->leftJoin('project.customer', 'customer')
->leftJoin('customer.teams', 'teams')
->leftJoin('teams.teamlead', 'teamlead')
->andWhere($qb->expr()->in('a.id', $ids))
->getQuery()
->execute();
}
}
}

View File

@@ -42,5 +42,14 @@ final class CustomerIdLoader implements LoaderInterface
->andWhere($qb->expr()->in('c.id', $ids))
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL c.{id}', 'teams', 'teamlead')
->from(Customer::class, 'c')
->leftJoin('c.teams', 'teams')
->leftJoin('teams.teamlead', 'teamlead')
->andWhere($qb->expr()->in('c.id', $ids))
->getQuery()
->execute();
}
}

View File

@@ -50,5 +50,24 @@ final class ProjectIdLoader implements LoaderInterface
->andWhere($qb->expr()->in('p.id', $ids))
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL p.{id}', 'teams', 'teamlead')
->from(Project::class, 'p')
->leftJoin('p.teams', 'teams')
->leftJoin('teams.teamlead', 'teamlead')
->andWhere($qb->expr()->in('p.id', $ids))
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL p.{id}', 'PARTIAL customer.{id}', 'teams', 'teamlead')
->from(Project::class, 'p')
->leftJoin('p.customer', 'customer')
->leftJoin('customer.teams', 'teams')
->leftJoin('teams.teamlead', 'teamlead')
->andWhere($qb->expr()->in('p.id', $ids))
->getQuery()
->execute();
}
}

View File

@@ -0,0 +1,46 @@
<?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\Repository\Loader;
use App\Entity\Team;
use Doctrine\ORM\EntityManagerInterface;
final class TeamIdLoader implements LoaderInterface
{
/**
* @var EntityManagerInterface
*/
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
/**
* @param int[] $ids
*/
public function loadResults(array $ids): void
{
if (empty($ids)) {
return;
}
$em = $this->entityManager;
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL t.{id}', 'users')
->from(Team::class, 't')
->leftJoin('t.users', 'users')
->andWhere($qb->expr()->in('t.id', $ids))
->getQuery()
->execute();
}
}

View File

@@ -0,0 +1,38 @@
<?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\Repository\Loader;
use App\Entity\Team;
use Doctrine\ORM\EntityManagerInterface;
final class TeamLoader implements LoaderInterface
{
/**
* @var TeamIdLoader
*/
private $loader;
public function __construct(EntityManagerInterface $entityManager)
{
$this->loader = new TeamIdLoader($entityManager);
}
/**
* @param Team[] $teams
*/
public function loadResults(array $teams): void
{
$ids = array_map(function (Team $team) {
return $team->getId();
}, $teams);
$this->loader->loadResults($ids);
}
}

View File

@@ -12,6 +12,7 @@ namespace App\Repository;
use App\Entity\Activity;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\ProjectStatistic;
use App\Repository\Loader\ProjectLoader;
use App\Repository\Paginator\LoaderPaginator;
@@ -91,8 +92,49 @@ class ProjectRepository extends EntityRepository
return $stats;
}
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
{
// make sure that all queries without a user see all projects
if (null === $user && empty($teams)) {
return;
}
// make sure that admins see all projects
if (null !== $user && ($user->isSuperAdmin() || $user->isAdmin())) {
return;
}
if (null !== $user) {
$teams = array_merge($teams, $user->getTeams()->toArray());
}
$qb->leftJoin('p.teams', 'teams')
->leftJoin('c.teams', 'c_teams');
if (empty($teams)) {
$qb->andWhere($qb->expr()->isNull('c_teams'));
$qb->andWhere($qb->expr()->isNull('teams'));
return;
}
$orProject = $qb->expr()->orX(
$qb->expr()->isNull('teams'),
$qb->expr()->isMemberOf(':teams', 'p.teams')
);
$qb->andWhere($orProject);
$orCustomer = $qb->expr()->orX(
$qb->expr()->isNull('c_teams'),
$qb->expr()->isMemberOf(':teams', 'c.teams')
);
$qb->andWhere($orCustomer);
$qb->setParameter('teams', $teams);
}
/**
* @deprecated since 1.1
* @deprecated since 1.1 - don't use this method, it ignores team permission checks
*/
public function builderForEntityType($project, $customer)
{
@@ -114,7 +156,7 @@ class ProjectRepository extends EntityRepository
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->select('p', 'c')
->select('p')
->from(Project::class, 'p')
->leftJoin('p.customer', 'c')
->addOrderBy('c.name', 'ASC')
@@ -140,6 +182,8 @@ class ProjectRepository extends EntityRepository
$qb->setParameter('ignored', $query->getProjectToIgnore());
}
$this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams());
return $qb;
}
@@ -150,11 +194,11 @@ class ProjectRepository extends EntityRepository
$qb
->select('p')
->from(Project::class, 'p')
->leftJoin('p.customer', 'c')
;
if (in_array($query->getVisibility(), [ProjectQuery::SHOW_VISIBLE, ProjectQuery::SHOW_HIDDEN])) {
$qb
->leftJoin('p.customer', 'c')
->andWhere($qb->expr()->eq('p.visible', ':visible'))
->andWhere($qb->expr()->eq('c.visible', ':customer_visible'))
;
@@ -173,6 +217,8 @@ class ProjectRepository extends EntityRepository
->setParameter('customer', $query->getCustomer());
}
$this->addPermissionCriteria($qb, $query->getCurrentUser());
$qb->orderBy('p.' . $query->getOrderBy(), $query->getOrder());
return $qb;

View File

@@ -9,6 +9,9 @@
namespace App\Repository\Query;
use App\Entity\Team;
use App\Entity\User;
/**
* Base class for advanced Repository queries.
*/
@@ -44,6 +47,45 @@ class BaseQuery
* @var string
*/
private $resultType = self::RESULT_TYPE_PAGER;
/**
* @var User
*/
private $user;
/**
* @var Team[]
*/
private $teams = [];
public function addTeam(Team $team): self
{
$this->teams[$team->getId()] = $team;
return $this;
}
/**
* @return Team[]
*/
public function getTeams(): array
{
return array_values($this->teams);
}
public function getCurrentUser(): ?User
{
return $this->user;
}
/**
* @param User $user
* @return $this
*/
public function setCurrentUser(User $user)
{
$this->user = $user;
return $this;
}
/**
* @return int

View File

@@ -10,6 +10,8 @@
namespace App\Repository\Query;
use App\Entity\Customer;
use App\Entity\Team;
use App\Entity\User;
/**
* Can be used for advanced queries with the: CustomerRepository
@@ -24,6 +26,14 @@ final class CustomerFormTypeQuery
* @var Customer|null
*/
private $customerToIgnore;
/**
* @var User
*/
private $user;
/**
* @var array<Team>
*/
private $teams = [];
/**
* @param Customer|int|null $customer
@@ -33,6 +43,33 @@ final class CustomerFormTypeQuery
$this->customer = $customer;
}
public function addTeam(Team $team): CustomerFormTypeQuery
{
$this->teams[$team->getId()] = $team;
return $this;
}
/**
* @return Team[]
*/
public function getTeams(): array
{
return array_values($this->teams);
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(User $user): CustomerFormTypeQuery
{
$this->user = $user;
return $this;
}
/**
* @return Customer|int|null
*/

View File

@@ -11,6 +11,8 @@ namespace App\Repository\Query;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Team;
use App\Entity\User;
final class ProjectFormTypeQuery
{
@@ -26,6 +28,14 @@ final class ProjectFormTypeQuery
* @var Project|null
*/
private $projectToIgnore;
/**
* @var User
*/
private $user;
/**
* @var array<Team>
*/
private $teams = [];
/**
* @param Project|int|null $project
@@ -37,6 +47,33 @@ final class ProjectFormTypeQuery
$this->customer = $customer;
}
public function addTeam(Team $team): ProjectFormTypeQuery
{
$this->teams[$team->getId()] = $team;
return $this;
}
/**
* @return Team[]
*/
public function getTeams(): array
{
return array_values($this->teams);
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(User $user): ProjectFormTypeQuery
{
$this->user = $user;
return $this;
}
/**
* @return Customer|int|null
*/

View File

@@ -11,4 +11,8 @@ namespace App\Repository\Query;
class TagQuery extends BaseQuery
{
public function __construct()
{
$this->setOrderBy('name');
}
}

View File

@@ -0,0 +1,18 @@
<?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\Repository\Query;
class TeamQuery extends BaseQuery
{
public function __construct()
{
$this->setOrderBy('name');
}
}

View File

@@ -28,7 +28,7 @@ class TimesheetQuery extends ActivityQuery
/**
* @var User|null
*/
protected $user;
protected $timesheetUser;
/**
* @var Activity|null
*/
@@ -63,7 +63,7 @@ class TimesheetQuery extends ActivityQuery
*/
public function getUser()
{
return $this->user;
return $this->timesheetUser;
}
/**
@@ -72,7 +72,7 @@ class TimesheetQuery extends ActivityQuery
*/
public function setUser($user = null)
{
$this->user = $user;
$this->timesheetUser = $user;
return $this;
}
@@ -256,7 +256,7 @@ class TimesheetQuery extends ActivityQuery
return true;
}
if ($this->user !== null) {
if ($this->timesheetUser !== null) {
return true;
}

View File

@@ -0,0 +1,55 @@
<?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\Repository\Query;
use App\Entity\Team;
use App\Entity\User;
/**
* Can be used for pre-filling form types with the: UserRepository
*/
final class UserFormTypeQuery
{
/**
* @var User
*/
private $user;
/**
* @var array<Team>
*/
private $teams = [];
public function addTeam(Team $team): UserFormTypeQuery
{
$this->teams[$team->getId()] = $team;
return $this;
}
/**
* @return Team[]
*/
public function getTeams(): array
{
return array_values($this->teams);
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(User $user): UserFormTypeQuery
{
$this->user = $user;
return $this;
}
}

View File

@@ -9,12 +9,26 @@
namespace App\Repository;
use App\Entity\Tag;
use App\Repository\Query\TagQuery;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
use Pagerfanta\Adapter\DoctrineORMAdapter;
use Pagerfanta\Pagerfanta;
class TagRepository extends EntityRepository
{
use RepositoryTrait;
/**
* @param Tag $tag
* @throws ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function deleteTag(Tag $tag)
{
$entityManager = $this->getEntityManager();
$entityManager->remove($tag);
$entityManager->flush();
}
/**
* Find ids of the given tagNames separated by comma
@@ -68,7 +82,7 @@ class TagRepository extends EntityRepository
* - amount
*
* @param TagQuery $query
* @return array|\Doctrine\ORM\QueryBuilder|\Pagerfanta\Pagerfanta
* @return Pagerfanta
*/
public function getTagCount(TagQuery $query)
{
@@ -79,9 +93,12 @@ class TagRepository extends EntityRepository
->leftJoin('tag.timesheets', 'timesheets')
->addGroupBy('tag.id')
->addGroupBy('tag.name')
->orderBy('tag.name')
;
->orderBy('tag.' . $query->getOrderBy(), $query->getOrder());
return $this->getBaseQueryResult($qb, $query);
$paginator = new Pagerfanta(new DoctrineORMAdapter($qb->getQuery(), false));
$paginator->setMaxPerPage($query->getPageSize());
$paginator->setCurrentPage($query->getPage());
return $paginator;
}
}

View File

@@ -0,0 +1,99 @@
<?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\Repository;
use App\Entity\Team;
use App\Entity\Timesheet;
use App\Repository\Loader\TeamLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\TeamQuery;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Pagerfanta;
class TeamRepository extends EntityRepository
{
/**
* @param Team $team
* @throws ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function saveTeam(Team $team)
{
$entityManager = $this->getEntityManager();
$entityManager->persist($team);
$entityManager->flush();
}
/**
* @param Team $team
* @throws ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function deleteTeam(Team $team)
{
$entityManager = $this->getEntityManager();
$entityManager->remove($team);
$entityManager->flush();
}
public function getPagerfantaForQuery(TeamQuery $query): Pagerfanta
{
$paginator = new Pagerfanta($this->getPaginatorForQuery($query));
$paginator->setMaxPerPage($query->getPageSize());
$paginator->setCurrentPage($query->getPage());
return $paginator;
}
protected function getPaginatorForQuery(TeamQuery $query): PaginatorInterface
{
$qb = $this->getQueryBuilderForQuery($query);
$qb
->resetDQLPart('select')
->resetDQLPart('orderBy')
->select($qb->expr()->countDistinct('t.id'))
;
$counter = (int) $qb->getQuery()->getSingleScalarResult();
$qb = $this->getQueryBuilderForQuery($query);
return new LoaderPaginator(new TeamLoader($qb->getEntityManager()), $qb, $counter);
}
/**
* @param TeamQuery $query
* @return Timesheet[]
*/
public function getTeamsForQuery(TeamQuery $query): iterable
{
// this is using the paginator internally, as it will load all joined entities into the working unit
// do not "optimize" to use the query directly, as it would results in hundreds of additional lazy queries
$paginator = $this->getPaginatorForQuery($query);
return $paginator->getAll();
}
private function getQueryBuilderForQuery(TeamQuery $query): QueryBuilder
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->select('t')
->from(Team::class, 't')
;
$qb->orderBy('t.' . $query->getOrderBy(), $query->getOrder());
return $qb;
}
}

View File

@@ -456,6 +456,36 @@ class TimesheetRepository extends EntityRepository
return $counter;
}
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
{
// make sure that all queries without a user see all projects
if (null === $user && empty($teams)) {
return;
}
if (empty($teams)) {
return;
}
// make sure that admins see all timesheet records
if (null !== $user && ($user->isSuperAdmin() || $user->isAdmin())) {
return;
}
$qb
->leftJoin('p.customer', 'c')
->leftJoin('p.teams', 'teams')
->leftJoin('c.teams', 'c_teams');
$orTeam = $qb->expr()->orX(
$qb->expr()->isMemberOf(':teams', 'p.teams'),
$qb->expr()->isMemberOf(':teams', 'c.teams')
);
$qb->andWhere($orTeam);
$qb->setParameter('teams', $teams);
}
public function getPagerfantaForQuery(TimesheetQuery $query): Pagerfanta
{
$paginator = new Pagerfanta($this->getPaginatorForQuery($query));
@@ -500,11 +530,43 @@ class TimesheetRepository extends EntityRepository
$qb
->select('t')
->from(Timesheet::class, 't')
->leftJoin('t.project', 'p')
;
$user = [];
if (null !== $query->getUser()) {
$qb->andWhere('t.user = :user')
->setParameter('user', $query->getUser());
$user[] = $query->getUser();
}
if (null === $query->getUser() && null !== $query->getCurrentUser()) {
$currentUser = $query->getCurrentUser();
if (!$currentUser->isSuperAdmin() && !$currentUser->isAdmin()) {
foreach ($currentUser->getTeams() as $team) {
if ($currentUser->isTeamleadOf($team)) {
$query->addTeam($team);
}
}
}
}
if (!empty($query->getTeams())) {
foreach ($query->getTeams() as $team) {
$user = array_merge($user, $team->getUsers()->toArray());
}
}
$user = array_map(function ($user) {
if ($user instanceof User) {
return $user->getId();
}
return $user;
}, $user);
$user = array_unique($user);
if (!empty($user)) {
$qb->andWhere($qb->expr()->in('t.user', $user));
}
if (null !== $query->getBegin()) {
@@ -539,7 +601,6 @@ class TimesheetRepository extends EntityRepository
$qb->andWhere('t.project = :project')
->setParameter('project', $query->getProject());
} elseif (null !== $query->getCustomer()) {
$qb->join('t.project', 'p');
$qb->andWhere('p.customer = :customer')
->setParameter('customer', $query->getCustomer());
}
@@ -551,6 +612,8 @@ class TimesheetRepository extends EntityRepository
->setParameter('tags', $query->getTags());
}
$this->addPermissionCriteria($qb, $query->getCurrentUser(), $query->getTeams());
$qb->orderBy('t.' . $query->getOrderBy(), $query->getOrder());
return $qb;

View File

@@ -10,8 +10,10 @@
namespace App\Repository;
use App\Entity\User;
use App\Repository\Query\UserFormTypeQuery;
use App\Repository\Query\UserQuery;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface;
class UserRepository extends EntityRepository implements UserLoaderInterface
@@ -26,12 +28,28 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
}
/**
* Used to fetch the currently logged-in user.
*
* @param int $id
* @return null|User
*/
public function getUserById($id): ?User
{
return $this->find($id);
try {
return $this->createQueryBuilder('u')
->select('u', 'p', 't', 'tu', 'tl')
->leftJoin('u.preferences', 'p')
->leftJoin('u.teams', 't')
->leftJoin('t.users', 'tu')
->leftJoin('t.teamlead', 'tl')
->where('u.id = :id')
->setParameter('id', $id)
->getQuery()
->getSingleResult();
} catch (\Exception $ex) {
}
return null;
}
/**
@@ -103,12 +121,49 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
public function loadUserByUsername($username)
{
return $this->createQueryBuilder('u')
->select('u', 'p')
->select('u', 'p', 't', 'tu', 'tl')
->leftJoin('u.preferences', 'p')
->leftJoin('u.teams', 't')
->leftJoin('t.users', 'tu')
->leftJoin('t.teamlead', 'tl')
->where('u.username = :username')
->orWhere('u.email = :username')
->setParameter('username', $username)
->getQuery()
->getSingleResult();
}
public function getQueryBuilderForFormType(UserFormTypeQuery $query): QueryBuilder
{
$qb = $this->createQueryBuilder('u');
$qb->andWhere($qb->expr()->eq('u.enabled', ':enabled'));
$qb->setParameter('enabled', true, \PDO::PARAM_BOOL);
$qb->orderBy('u.username', 'ASC');
$this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams());
return $qb;
}
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
{
// make sure that all queries without a user see all user
if (null === $user && empty($teams)) {
return;
}
// make sure that admins see all user
if (null !== $user && ($user->isSuperAdmin() || $user->isAdmin())) {
return;
}
if (null !== $user) {
$qb->leftJoin('u.teams', 'teams')
->leftJoin('teams.users', 'users')
->andWhere('teams.teamlead = :id')
->setParameter('id', $user);
}
}
}

View File

@@ -13,16 +13,20 @@ use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
class CurrentUser
final class CurrentUser
{
/**
* @var TokenStorageInterface
*/
protected $storage;
private $storage;
/**
* @var UserRepository
*/
protected $repository;
private $repository;
/**
* @var User|null
*/
private $user;
/**
* @param TokenStorageInterface $storage
@@ -43,6 +47,11 @@ class CurrentUser
return null;
}
// some inline caching to prevent multiple DB lookups
if (null !== $this->user) {
return $this->user;
}
/** @var User $user */
$user = $this->storage->getToken()->getUser();
@@ -50,6 +59,8 @@ class CurrentUser
return null;
}
return $this->repository->getUserById($user->getId());
$this->user = $this->repository->getUserById($user->getId());
return $this->user;
}
}

View File

@@ -45,7 +45,8 @@ final class IconExtension extends AbstractExtension
'stop-small' => 'far fa-stop-circle',
'timesheet' => 'fas fa-clock',
'trash' => 'far fa-trash-alt',
'user' => 'fas fa-users',
'team' => 'fas fa-users',
'user' => 'fas fa-user-friends',
'visibility' => 'far fa-eye',
'settings' => 'fas fa-cog',
'export' => 'fas fa-file-export',
@@ -64,6 +65,7 @@ final class IconExtension extends AbstractExtension
'profile' => 'fas fa-user-edit',
'warning' => 'fas fa-exclamation-triangle',
'permissions' => 'fas fa-user-lock',
'unlocked' => 'fas fa-unlock-alt',
'back' => 'fas fa-long-arrow-alt-left',
'tag' => 'fas fa-tags',
'avatar' => 'fas fa-user',

View File

@@ -10,6 +10,7 @@
namespace App\Voter;
use App\Entity\Activity;
use App\Entity\Team;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
@@ -65,6 +66,44 @@ class ActivityVoter extends AbstractVoter
return false;
}
return $this->hasRolePermission($user, $attribute . '_activity');
if ($this->hasRolePermission($user, $attribute . '_activity')) {
return true;
}
$project = $subject->getProject();
if (null === $project) {
return false;
}
$hasTeamleadPermission = $this->hasRolePermission($user, $attribute . '_teamlead_activity');
$hasTeamPermission = $this->hasRolePermission($user, $attribute . '_team_activity');
if (!$hasTeamleadPermission && !$hasTeamPermission) {
return false;
}
/** @var Team $team */
foreach ($project->getTeams() as $team) {
if ($hasTeamleadPermission && $user->isTeamleadOf($team)) {
return true;
}
if ($hasTeamPermission && $user->isInTeam($team)) {
return true;
}
}
/** @var Team $team */
foreach ($project->getCustomer()->getTeams() as $team) {
if ($hasTeamleadPermission && $user->isTeamleadOf($team)) {
return true;
}
if ($hasTeamPermission && $user->isInTeam($team)) {
return true;
}
}
return false;
}
}

View File

@@ -10,6 +10,7 @@
namespace App\Voter;
use App\Entity\Customer;
use App\Entity\Team;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
@@ -22,6 +23,7 @@ class CustomerVoter extends AbstractVoter
public const EDIT = 'edit';
public const BUDGET = 'budget';
public const DELETE = 'delete';
public const PERMISSIONS = 'permissions';
/**
* support rules based on the given $subject (here: Customer)
@@ -31,6 +33,7 @@ class CustomerVoter extends AbstractVoter
self::EDIT,
self::BUDGET,
self::DELETE,
self::PERMISSIONS,
];
/**
@@ -65,6 +68,28 @@ class CustomerVoter extends AbstractVoter
return false;
}
return $this->hasRolePermission($user, $attribute . '_customer');
if ($this->hasRolePermission($user, $attribute . '_customer')) {
return true;
}
$hasTeamleadPermission = $this->hasRolePermission($user, $attribute . '_teamlead_customer');
$hasTeamPermission = $this->hasRolePermission($user, $attribute . '_team_customer');
if (!$hasTeamleadPermission && !$hasTeamPermission) {
return false;
}
/** @var Team $team */
foreach ($subject->getTeams() as $team) {
if ($hasTeamleadPermission && $user->isTeamleadOf($team)) {
return true;
}
if ($hasTeamPermission && $user->isInTeam($team)) {
return true;
}
}
return false;
}
}

View File

@@ -10,6 +10,7 @@
namespace App\Voter;
use App\Entity\Project;
use App\Entity\Team;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
@@ -22,6 +23,7 @@ class ProjectVoter extends AbstractVoter
public const EDIT = 'edit';
public const BUDGET = 'budget';
public const DELETE = 'delete';
public const PERMISSIONS = 'permissions';
/**
* support rules based on the given $subject (here: Project)
@@ -31,6 +33,7 @@ class ProjectVoter extends AbstractVoter
self::EDIT,
self::BUDGET,
self::DELETE,
self::PERMISSIONS,
];
/**
@@ -65,6 +68,39 @@ class ProjectVoter extends AbstractVoter
return false;
}
return $this->hasRolePermission($user, $attribute . '_project');
if ($this->hasRolePermission($user, $attribute . '_project')) {
return true;
}
$hasTeamleadPermission = $this->hasRolePermission($user, $attribute . '_teamlead_project');
$hasTeamPermission = $this->hasRolePermission($user, $attribute . '_team_project');
if (!$hasTeamleadPermission && !$hasTeamPermission) {
return false;
}
/** @var Team $team */
foreach ($subject->getTeams() as $team) {
if ($hasTeamleadPermission && $user->isTeamleadOf($team)) {
return true;
}
if ($hasTeamPermission && $user->isInTeam($team)) {
return true;
}
}
/** @var Team $team */
foreach ($subject->getCustomer()->getTeams() as $team) {
if ($hasTeamleadPermission && $user->isTeamleadOf($team)) {
return true;
}
if ($hasTeamPermission && $user->isInTeam($team)) {
return true;
}
}
return false;
}
}

65
src/Voter/TeamVoter.php Normal file
View File

@@ -0,0 +1,65 @@
<?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\Voter;
use App\Entity\Team;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
class TeamVoter extends AbstractVoter
{
public const VIEW = 'view';
public const EDIT = 'edit';
public const DELETE = 'delete';
/**
* support rules based on the given $subject (here: Team)
*/
public const ALLOWED_ATTRIBUTES = [
self::VIEW,
self::EDIT,
self::DELETE,
];
/**
* @param string $attribute
* @param Team $subject
* @return bool
*/
protected function supports($attribute, $subject)
{
if (!($subject instanceof Team)) {
return false;
}
if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
return false;
}
return true;
}
/**
* @param string $attribute
* @param Team $subject
* @param TokenInterface $token
* @return bool
*/
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
return $this->hasRolePermission($user, $attribute . '_team');
}
}

View File

@@ -22,6 +22,7 @@ class UserVoter extends AbstractVoter
public const DELETE = 'delete';
public const PASSWORD = 'password';
public const ROLES = 'roles';
public const TEAMS = 'teams';
public const PREFERENCES = 'preferences';
public const API_TOKEN = 'api-token';
public const HOURLY_RATE = 'hourly-rate';
@@ -30,6 +31,7 @@ class UserVoter extends AbstractVoter
self::VIEW,
self::EDIT,
self::ROLES,
self::TEAMS,
self::PASSWORD,
self::DELETE,
self::PREFERENCES,
@@ -93,6 +95,7 @@ class UserVoter extends AbstractVoter
case self::PASSWORD:
case self::API_TOKEN:
case self::ROLES:
case self::TEAMS:
case self::HOURLY_RATE:
$permission .= $attribute;
break;