Next major version 2 with PHP 8.1, Symfony 6, Tabler UI, 2FA ... (#2902)

This commit is contained in:
Kevin Papst
2022-12-31 21:19:55 +01:00
committed by GitHub
parent 95e06746bd
commit 90a0fd8a22
2164 changed files with 83700 additions and 86426 deletions

View File

@@ -10,30 +10,18 @@
namespace App\Controller;
use App\Constants;
use App\Utils\PageSetup;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route(path="/about")
*/
class AboutController extends AbstractController
#[Route(path: '/about')]
final class AboutController extends AbstractController
{
/**
* @var string
*/
protected $projectDirectory;
/**
* @param string $projectDirectory
*/
public function __construct(string $projectDirectory)
public function __construct(private string $projectDirectory)
{
$this->projectDirectory = $projectDirectory;
}
/**
* @Route(path="", name="about", methods={"GET"})
*/
#[Route(path: '', name: 'about', methods: ['GET'])]
public function license(): Response
{
$filename = $this->projectDirectory . '/LICENSE';
@@ -47,10 +35,11 @@ class AboutController extends AbstractController
if (false === $license) {
$license = 'Failed reading license file: ' . $filename . '. ' .
'Check this instead: ' . Constants::GITHUB . 'blob/master/LICENSE';
'Check this instead: ' . Constants::GITHUB . 'blob/main/LICENSE';
}
return $this->render('about/license.html.twig', [
'page_setup' => new PageSetup('about.title'),
'license' => $license
]);
}

View File

@@ -9,86 +9,112 @@
namespace App\Controller;
use App\Configuration\LanguageFormattings;
use App\Entity\Bookmark;
use App\Entity\User;
use App\Repository\BookmarkRepository;
use App\Repository\Query\BaseQuery;
use App\Timesheet\DateTimeFactory;
use App\Utils\LocaleFormats;
use App\Validator\ValidationFailedException;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as BaseAbstractController;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* The abstract base controller.
* @method null|User getUser()
*/
abstract class AbstractController extends BaseAbstractController implements ServiceSubscriberInterface
{
/**
* @deprecated since 1.6, will be removed with 2.0
*/
public const ROLE_ADMIN = User::ROLE_ADMIN;
protected function getUser(): User
{
$user = parent::getUser();
if ($user === null) {
throw $this->createAccessDeniedException('Missing user');
}
protected function getTranslator(): TranslatorInterface
if (!($user instanceof User)) {
throw $this->createAccessDeniedException('Expected Kimai user, received unknown type');
}
return $user;
}
private function getTranslator(): TranslatorInterface
{
return $this->container->get('translator');
}
public function createFormForGetRequest(string $type = FormType::class, $data = null, array $options = []): FormInterface
protected function createSearchForm(string $type = FormType::class, $data = null, array $options = []): FormInterface
{
return $this->createFormForGetRequest($type, $data, $options);
}
protected function createFormForGetRequest(string $type = FormType::class, $data = null, array $options = []): FormInterface
{
return $this->container
->get('form.factory')
->createNamed('', $type, $data, $options);
->createNamed('', $type, $data, array_merge(['method' => 'GET'], $options));
}
private function getLogger(): LoggerInterface
protected function createFormWithName(string $name, string $type, mixed $data = null, array $options = []): FormInterface
{
return $this->container->get('logger');
return $this->container->get('form.factory')->createNamed($name, $type, $data, $options);
}
/**
* Returns a RedirectResponse to the given route with the given parameters.
*
* This needs to be a 201 code and NOT 302 (as usual for redirects) because 302 cannot be handled on
* javascript side, as the fetch() API will auto-redirect these responses without access to the header.
*/
protected function redirectToRouteAfterCreate(string $route, array $parameters = []): RedirectResponse
{
$url = $this->generateUrl($route, $parameters);
$response = new RedirectResponse($url, 201);
$response->headers->set('x-modal-redirect', $url);
return $response;
}
/**
* Adds a "successful" flash message to the stack.
*
* @param string $translationKey
* @param array $parameter
*/
protected function flashSuccess(string $translationKey, array $parameter = []): void
protected function flashSuccess(string $translationKey): void
{
$this->addFlashTranslated('success', $translationKey, $parameter);
$this->addFlashTranslated('success', $translationKey);
}
/**
* Adds a "warning" flash message to the stack.
*
* @param string $translationKey
* @param array $parameter
*/
protected function flashWarning(string $translationKey, array $parameter = []): void
protected function flashWarning(string $translationKey): void
{
$this->addFlashTranslated('warning', $translationKey, $parameter);
$this->addFlashTranslated('warning', $translationKey);
}
/**
* Adds a "error" flash message to the stack.
* Adds an "error" flash message to the stack.
*
* @param string $translationKey
* @param array $parameter
* @param array<string, string>|string $reason passing an array is deprecated
* @return void
* @throws \Exception
*/
protected function flashError(string $translationKey, array $parameter = []): void
protected function flashError(string $translationKey, array|string $reason = ''): void
{
$this->addFlashTranslated('error', $translationKey, $parameter);
if (\is_array($reason)) {
@trigger_error('Calling "flashError" with an array $reason is deprecated and will be removed soon. Refactor and pass a string instead.', E_USER_DEPRECATED);
$reason = \array_key_exists('%reason%', $reason) ? $reason['%reason%'] : '';
}
$this->addFlashTranslated('error', $translationKey, ['%reason%' => $reason]);
}
/**
* Adds an exception flash message for failed update/create actions.
*
* @param \Exception $exception
*/
protected function flashUpdateException(\Exception $exception): void
{
@@ -97,8 +123,6 @@ abstract class AbstractController extends BaseAbstractController implements Serv
/**
* Adds an exception flash message for failed delete actions.
*
* @param \Exception $exception
*/
protected function flashDeleteException(\Exception $exception): void
{
@@ -106,21 +130,13 @@ abstract class AbstractController extends BaseAbstractController implements Serv
}
/**
* Adds a "error" flash message and logs the Exception.
*
* @param \Exception $exception
* @param string $translationKey
* @param array $parameter
* Adds an "error" flash message and logs the Exception.
*/
protected function flashException(\Exception $exception, string $translationKey, array $parameter = []): void
protected function flashException(\Exception $exception, string $translationKey): void
{
$this->logException($exception);
if (!\array_key_exists('%reason%', $parameter)) {
$parameter['%reason%'] = $exception->getMessage();
}
$this->addFlashTranslated('error', $translationKey, $parameter);
$this->addFlashTranslated('error', $translationKey, ['%reason%' => $exception->getMessage()]);
}
/**
@@ -128,9 +144,11 @@ abstract class AbstractController extends BaseAbstractController implements Serv
*
* @param string $type
* @param string $message
* @param array $parameter
* @param array<string, string> $parameter
* @return void
* @throws \Exception
*/
protected function addFlashTranslated(string $type, string $message, array $parameter = []): void
private function addFlashTranslated(string $type, string $message, array $parameter = []): void
{
if (!empty($parameter)) {
foreach ($parameter as $key => $value) {
@@ -146,17 +164,39 @@ abstract class AbstractController extends BaseAbstractController implements Serv
$this->addFlash($type, $message);
}
protected function logException(\Exception $ex): void
/**
* Handles exception flash messages for failed update/create actions.
*/
protected function handleFormUpdateException(\Exception $exception, FormInterface $form): void
{
$this->getLogger()->critical($ex->getMessage());
if (!($exception instanceof ValidationFailedException)) {
$this->flashUpdateException($exception);
return;
}
$msg = $this->getTranslator()->trans($exception->getMessage(), [], 'validators');
if ($exception->getViolations()->count() > 0) {
for ($i = 0; $i < $exception->getViolations()->count(); $i++) {
$violation = $exception->getViolations()->get($i);
$form->addError(new FormError($violation->getMessage()));
}
} else {
$form->addError(new FormError($msg));
}
}
public static function getSubscribedServices()
protected function logException(\Exception $ex): void
{
$this->container->get('logger')->critical($ex->getMessage());
}
public static function getSubscribedServices(): array
{
return array_merge(parent::getSubscribedServices(), [
'translator' => TranslatorInterface::class,
'logger' => LoggerInterface::class,
LanguageFormattings::class => LanguageFormattings::class,
BookmarkRepository::class => BookmarkRepository::class,
]);
}
@@ -169,28 +209,30 @@ abstract class AbstractController extends BaseAbstractController implements Serv
return DateTimeFactory::createByUser($user);
}
protected function getLocaleFormats(string $locale): LocaleFormats
// ================================ SEARCH AND BOOKMARKS ================================
private function getBookmark(): BookmarkRepository
{
return new LocaleFormats($this->container->get(LanguageFormattings::class), $locale);
return $this->container->get(BookmarkRepository::class);
}
private function getLastSearch(BaseQuery $query): ?array
private function getLastSearch(SessionInterface $session, BaseQuery $query): ?array
{
$name = 'search_' . $this->getSearchName($query);
if (!$this->get('session')->has($name)) {
if (!$session->has($name)) {
return null;
}
return $this->get('session')->get($name);
return $session->get($name);
}
private function removeLastSearch(BaseQuery $query): void
private function removeLastSearch(SessionInterface $session, BaseQuery $query): void
{
$name = 'search_' . $this->getSearchName($query);
if ($this->get('session')->has($name)) {
$this->get('session')->remove($name);
if ($session->has($name)) {
$session->remove($name);
}
}
@@ -199,15 +241,6 @@ abstract class AbstractController extends BaseAbstractController implements Serv
return substr($query->getName(), 0, 50);
}
/**
* @param Request $request
* @internal
*/
protected function ignorePersistedSearch(Request $request): void
{
$request->query->set('performSearch', true);
}
protected function handleSearch(FormInterface $form, Request $request): bool
{
$data = $form->getData();
@@ -230,21 +263,26 @@ abstract class AbstractController extends BaseAbstractController implements Serv
if ($request->query->has('resetSearchFilter')) {
$data->resetFilter();
$this->removeLastSearch($data);
$this->removeLastSearch($request->getSession(), $data);
return true;
}
$submitData = $request->query->all();
// allow to use forms with block-prefix
$queryKey = null;
if (!empty($formName = $form->getConfig()->getName()) && $request->request->has($formName)) {
$submitData = $request->request->get($formName);
// allow using forms with block-prefix
$queryKey = $formName;
}
if ($request->isMethod(Request::METHOD_POST)) {
$submitData = $request->request->all($queryKey);
} else {
$submitData = $request->query->all($queryKey);
}
$searchName = $this->getSearchName($data);
/** @var BookmarkRepository $bookmarkRepo */
$bookmarkRepo = $this->getDoctrine()->getRepository(Bookmark::class);
$bookmarkRepo = $this->getBookmark();
$bookmark = $bookmarkRepo->getSearchDefaultOptions($this->getUser(), $searchName);
if ($bookmark !== null) {
@@ -260,12 +298,23 @@ abstract class AbstractController extends BaseAbstractController implements Serv
// apply persisted search data ONLY if search form was not submitted manually
if (!$request->query->has('performSearch')) {
$sessionSearch = $this->getLastSearch($data);
$sessionSearch = $this->getLastSearch($request->getSession(), $data);
if ($sessionSearch !== null) {
$submitData = array_merge($sessionSearch, $submitData);
} elseif ($bookmark !== null && !$request->query->has('setDefaultQuery')) {
$submitData = array_merge($bookmark->getContent(), $submitData);
$data->flagAsBookmarkSearch();
$bookContent = $bookmark->getContent();
$isBookmarkSearch = true;
foreach ($submitData as $key => $value) {
if (!\array_key_exists($key, $bookContent) || $value !== $bookContent[$key]) {
$isBookmarkSearch = false;
break;
}
}
if ($isBookmarkSearch) {
$data->flagAsBookmarkSearch();
}
$submitData = array_merge($bookContent, $submitData);
}
}
@@ -298,7 +347,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
}
if ($request->query->has('performSearch')) {
$this->get('session')->set('search_' . $searchName, $params);
$request->getSession()->set('search_' . $searchName, $params);
}
// filter stuff, that does not belong in a bookmark
@@ -310,7 +359,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
}
if ($request->query->has('setDefaultQuery')) {
$this->removeLastSearch($data);
$this->removeLastSearch($request->getSession(), $data);
if ($bookmark === null) {
$bookmark = new Bookmark();
$bookmark->setType(Bookmark::SEARCH_DEFAULT);

View File

@@ -32,50 +32,29 @@ use App\Repository\ActivityRateRepository;
use App\Repository\ActivityRepository;
use App\Repository\Query\ActivityQuery;
use App\Repository\TeamRepository;
use App\Utils\DataTable;
use App\Utils\PageSetup;
use Exception;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* Controller used to manage activities in the admin part of the site.
*
* @Route(path="/admin/activity")
* @Security("is_granted('view_activity') or is_granted('view_teamlead_activity') or is_granted('view_team_activity')")
* Controller used to manage activities.
*/
#[Route(path: '/admin/activity')]
#[Security("is_granted('view_activity') or is_granted('view_teamlead_activity') or is_granted('view_team_activity')")]
final class ActivityController extends AbstractController
{
/**
* @var ActivityRepository
*/
private $repository;
/**
* @var SystemConfiguration
*/
private $configuration;
/**
* @var EventDispatcherInterface
*/
private $dispatcher;
/**
* @var ActivityService
*/
private $activityService;
public function __construct(ActivityRepository $repository, SystemConfiguration $configuration, EventDispatcherInterface $dispatcher, ActivityService $activityService)
public function __construct(private ActivityRepository $repository, private SystemConfiguration $configuration, private EventDispatcherInterface $dispatcher, private ActivityService $activityService)
{
$this->repository = $repository;
$this->configuration = $configuration;
$this->dispatcher = $dispatcher;
$this->activityService = $activityService;
}
/**
* @Route(path="/", defaults={"page": 1}, name="admin_activity", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_activity_paginated", methods={"GET"})
*/
#[Route(path: '/', defaults: ['page' => 1], name: 'admin_activity', methods: ['GET'])]
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_activity_paginated', methods: ['GET'])]
public function indexAction($page, Request $request)
{
$query = new ActivityQuery();
@@ -88,12 +67,43 @@ final class ActivityController extends AbstractController
}
$entries = $this->repository->getPagerfantaForQuery($query);
$metaColumns = $this->findMetaColumns($query);
$table = new DataTable('activity_admin', $query);
$table->setPagination($entries);
$table->setSearchForm($form);
$table->setPaginationRoute('admin_activity_paginated');
$table->setReloadEvents('kimai.activityUpdate kimai.activityDelete kimai.activityTeamUpdate');
$table->addColumn('name', ['class' => 'alwaysVisible']);
$table->addColumn('project', ['class' => 'd-none']);
$table->addColumn('comment', ['class' => 'd-none', 'title' => 'description']);
foreach ($metaColumns as $metaColumn) {
$table->addColumn('mf_' . $metaColumn->getName(), ['title' => $metaColumn->getLabel(), 'class' => 'd-none', 'orderBy' => false, 'data' => $metaColumn]);
}
if ($this->isGranted('budget_money', 'activity')) {
$table->addColumn('budget', ['class' => 'd-none text-end w-min', 'title' => 'budget']);
}
if ($this->isGranted('budget_time', 'activity')) {
$table->addColumn('timeBudget', ['class' => 'd-none text-end w-min', 'title' => 'timeBudget']);
}
$table->addColumn('billable', ['class' => 'd-none text-center w-min', 'orderBy' => false]);
$table->addColumn('team', ['class' => 'text-center w-min', 'orderBy' => false]);
$table->addColumn('visible', ['class' => 'd-none text-center w-min']);
$table->addColumn('actions', ['class' => 'actions']);
$page = $this->createPageSetup();
$page->setDataTable($table);
$page->setActionName('activities');
return $this->render('activity/index.html.twig', [
'entries' => $entries,
'query' => $query,
'toolbarForm' => $form->createView(),
'metaColumns' => $this->findMetaColumns($query),
'page_setup' => $page,
'dataTable' => $table,
'metaColumns' => $metaColumns,
'defaultCurrency' => $this->configuration->getCustomerDefaultCurrency(),
'now' => $this->getDateTimeFactory()->createDateTime(),
]);
@@ -103,7 +113,7 @@ final class ActivityController extends AbstractController
* @param ActivityQuery $query
* @return MetaTableTypeInterface[]
*/
protected function findMetaColumns(ActivityQuery $query): array
private function findMetaColumns(ActivityQuery $query): array
{
$event = new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::ACTIVITY);
$this->dispatcher->dispatch($event);
@@ -111,10 +121,8 @@ final class ActivityController extends AbstractController
return $event->getFields();
}
/**
* @Route(path="/{id}/details", name="activity_details", methods={"GET", "POST"})
* @Security("is_granted('view', activity)")
*/
#[Route(path: '/{id}/details', name: 'activity_details', methods: ['GET', 'POST'])]
#[Security("is_granted('view', activity)")]
public function detailsAction(Activity $activity, TeamRepository $teamRepository, ActivityRateRepository $rateRepository, ActivityStatisticService $statisticService)
{
$event = new ActivityMetaDefinitionEvent($activity);
@@ -146,7 +154,13 @@ final class ActivityController extends AbstractController
$this->dispatcher->dispatch($event);
$boxes = $event->getController();
$page = $this->createPageSetup();
$page->setActionName('activity');
$page->setActionView('activity_details');
$page->setActionPayload(['activity' => $activity]);
return $this->render('activity/details.html.twig', [
'page_setup' => $page,
'activity' => $activity,
'stats' => $stats,
'rates' => $rates,
@@ -157,17 +171,27 @@ final class ActivityController extends AbstractController
]);
}
/**
* @Route(path="/{id}/rate", name="admin_activity_rate_add", methods={"GET", "POST"})
* @Security("is_granted('edit', activity)")
*/
public function addRateAction(Activity $activity, Request $request, ActivityRateRepository $repository)
#[Route(path: '/{id}/rate/{rate}', name: 'admin_activity_rate_edit', methods: ['GET', 'POST'])]
#[Security("is_granted('edit', activity)")]
public function editRateAction(Activity $activity, ActivityRate $rate, Request $request, ActivityRateRepository $repository): Response
{
return $this->rateFormAction($activity, $rate, $request, $repository, $this->generateUrl('admin_activity_rate_edit', ['id' => $activity->getId(), 'rate' => $rate->getId()]));
}
#[Route(path: '/{id}/rate', name: 'admin_activity_rate_add', methods: ['GET', 'POST'])]
#[Security("is_granted('edit', activity)")]
public function addRateAction(Activity $activity, Request $request, ActivityRateRepository $repository): Response
{
$rate = new ActivityRate();
$rate->setActivity($activity);
return $this->rateFormAction($activity, $rate, $request, $repository, $this->generateUrl('admin_activity_rate_add', ['id' => $activity->getId()]));
}
private function rateFormAction(Activity $activity, ActivityRate $rate, Request $request, ActivityRateRepository $repository, string $formUrl): Response
{
$form = $this->createForm(ActivityRateForm::class, $rate, [
'action' => $this->generateUrl('admin_activity_rate_add', ['id' => $activity->getId()]),
'action' => $formUrl,
'method' => 'POST',
]);
@@ -185,17 +209,27 @@ final class ActivityController extends AbstractController
}
return $this->render('activity/rates.html.twig', [
'page_setup' => $this->createPageSetup(),
'activity' => $activity,
'form' => $form->createView()
]);
}
/**
* @Route(path="/create", name="admin_activity_create", methods={"GET", "POST"})
* @Route(path="/create/{project}", name="admin_activity_create_with_project", methods={"GET", "POST"})
* @Security("is_granted('create_activity')")
*/
public function createAction(Request $request, ?Project $project = null)
#[Route(path: '/create/{project}', name: 'admin_activity_create_with_project', methods: ['GET', 'POST'])]
#[Security("is_granted('create_activity')")]
public function createWithProjectAction(Request $request, Project $project): Response
{
return $this->createActivity($request, $project);
}
#[Route(path: '/create', name: 'admin_activity_create', methods: ['GET', 'POST'])]
#[Security("is_granted('create_activity')")]
public function createAction(Request $request): Response
{
return $this->createActivity($request, null);
}
private function createActivity(Request $request, ?Project $project = null): Response
{
$activity = $this->activityService->createNewActivity($project);
@@ -210,23 +244,22 @@ final class ActivityController extends AbstractController
$this->activityService->saveNewActivity($activity);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_activity');
return $this->redirectToRouteAfterCreate('activity_details', ['id' => $activity->getId()]);
} catch (Exception $ex) {
$this->flashUpdateException($ex);
$this->handleFormUpdateException($ex, $editForm);
}
}
return $this->render('activity/edit.html.twig', [
'page_setup' => $this->createPageSetup(),
'activity' => $activity,
'form' => $editForm->createView()
]);
}
/**
* @Route(path="/{id}/permissions", name="admin_activity_permissions", methods={"GET", "POST"})
* @Security("is_granted('permissions', activity)")
*/
public function teamPermissionsAction(Activity $activity, Request $request)
#[Route(path: '/{id}/permissions', name: 'admin_activity_permissions', methods: ['GET', 'POST'])]
#[Security("is_granted('permissions', activity)")]
public function teamPermissionsAction(Activity $activity, Request $request): Response
{
$form = $this->createForm(ActivityTeamPermissionForm::class, $activity, [
'action' => $this->generateUrl('admin_activity_permissions', ['id' => $activity->getId()]),
@@ -251,26 +284,24 @@ final class ActivityController extends AbstractController
}
return $this->render('activity/permissions.html.twig', [
'page_setup' => $this->createPageSetup(),
'activity' => $activity,
'form' => $form->createView()
]);
}
/**
* @Route(path="/{id}/create_team", name="activity_team_create", methods={"GET"})
* @Security("is_granted('create_team') and is_granted('permissions', activity)")
*/
public function createDefaultTeamAction(Activity $activity, TeamRepository $teamRepository)
#[Route(path: '/{id}/create_team', name: 'activity_team_create', methods: ['GET'])]
#[Security("is_granted('create_team') and is_granted('permissions', activity)")]
public function createDefaultTeamAction(Activity $activity, TeamRepository $teamRepository): Response
{
$defaultTeam = $teamRepository->findOneBy(['name' => $activity->getName()]);
if (null !== $defaultTeam) {
$this->flashError('action.update.error', ['%reason%' => 'Team already existing']);
$this->flashError('action.update.error', 'Team already existing');
return $this->redirectToRoute('activity_details', ['id' => $activity->getId()]);
}
$defaultTeam = new Team();
$defaultTeam->setName($activity->getName());
$defaultTeam = new Team($activity->getName());
$defaultTeam->addTeamlead($this->getUser());
$defaultTeam->addActivity($activity);
@@ -283,11 +314,9 @@ final class ActivityController extends AbstractController
return $this->redirectToRoute('activity_details', ['id' => $activity->getId()]);
}
/**
* @Route(path="/{id}/edit", name="admin_activity_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', activity)")
*/
public function editAction(Activity $activity, Request $request)
#[Route(path: '/{id}/edit', name: 'admin_activity_edit', methods: ['GET', 'POST'])]
#[Security("is_granted('edit', activity)")]
public function editAction(Activity $activity, Request $request): Response
{
$event = new ActivityMetaDefinitionEvent($activity);
$this->dispatcher->dispatch($event);
@@ -307,16 +336,15 @@ final class ActivityController extends AbstractController
}
return $this->render('activity/edit.html.twig', [
'page_setup' => $this->createPageSetup(),
'activity' => $activity,
'form' => $editForm->createView()
]);
}
/**
* @Route(path="/{id}/delete", name="admin_activity_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', activity)")
*/
public function deleteAction(Activity $activity, Request $request, ActivityStatisticService $statisticService)
#[Route(path: '/{id}/delete', name: 'admin_activity_delete', methods: ['GET', 'POST'])]
#[Security("is_granted('delete', activity)")]
public function deleteAction(Activity $activity, Request $request, ActivityStatisticService $statisticService): Response
{
$stats = $statisticService->getActivityStatistics($activity);
@@ -352,20 +380,16 @@ final class ActivityController extends AbstractController
return $this->redirectToRoute('admin_activity');
}
return $this->render(
'activity/delete.html.twig',
[
'activity' => $activity,
'stats' => $stats,
'form' => $deleteForm->createView(),
]
);
return $this->render('activity/delete.html.twig', [
'page_setup' => $this->createPageSetup(),
'activity' => $activity,
'stats' => $stats,
'form' => $deleteForm->createView(),
]);
}
/**
* @Route(path="/export", name="activity_export", methods={"GET"})
*/
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter)
#[Route(path: '/export', name: 'activity_export', methods: ['GET'])]
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter): Response
{
$query = new ActivityQuery();
$query->setCurrentUser($this->getUser());
@@ -390,25 +414,16 @@ final class ActivityController extends AbstractController
return $writer->getFileResponse($spreadsheet);
}
/**
* @param ActivityQuery $query
* @return FormInterface
*/
protected function getToolbarForm(ActivityQuery $query)
private function getToolbarForm(ActivityQuery $query): FormInterface
{
return $this->createForm(ActivityToolbarForm::class, $query, [
return $this->createSearchForm(ActivityToolbarForm::class, $query, [
'action' => $this->generateUrl('admin_activity', [
'page' => $query->getPage(),
]),
'method' => 'GET',
])
]);
}
/**
* @param Activity $activity
* @return FormInterface
*/
private function createEditForm(Activity $activity)
private function createEditForm(Activity $activity): FormInterface
{
$currency = $this->configuration->getCustomerDefaultCurrency();
$url = $this->generateUrl('admin_activity_create');
@@ -428,4 +443,12 @@ final class ActivityController extends AbstractController
'include_time' => $this->isGranted('time', $activity),
]);
}
private function createPageSetup(): PageSetup
{
$page = new PageSetup('activities');
$page->setHelp('activity.html');
return $page;
}
}

View File

@@ -17,23 +17,14 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
/**
* @Route(path="/saml")
*/
#[Route(path: '/saml')]
final class SamlController extends AbstractController
{
private $authFactory;
private $samlConfiguration;
public function __construct(SamlAuthFactory $authFactory, SamlConfigurationInterface $samlConfiguration)
public function __construct(private SamlAuthFactory $authFactory, private SamlConfigurationInterface $samlConfiguration)
{
$this->authFactory = $authFactory;
$this->samlConfiguration = $samlConfiguration;
}
/**
* @Route(path="/login", name="saml_login")
*/
#[Route(path: '/login', name: 'saml_login')]
public function loginAction(Request $request)
{
if (!$this->samlConfiguration->isActivated()) {
@@ -47,7 +38,7 @@ final class SamlController extends AbstractController
if ($request->attributes->has($authErrorKey)) {
$error = $request->attributes->get($authErrorKey);
} elseif (null !== $session && $session->has($authErrorKey)) {
} elseif ($session->has($authErrorKey)) {
$error = $session->get($authErrorKey);
$session->remove($authErrorKey);
}
@@ -62,9 +53,7 @@ final class SamlController extends AbstractController
$this->authFactory->create()->login($session->get('_security.main.target_path'));
}
/**
* @Route(path="/metadata", name="saml_metadata")
*/
#[Route(path: '/metadata', name: 'saml_metadata')]
public function metadataAction()
{
if (!$this->samlConfiguration->isActivated()) {
@@ -79,9 +68,7 @@ final class SamlController extends AbstractController
return $response;
}
/**
* @Route(path="/acs", name="saml_acs")
*/
#[Route(path: '/acs', name: 'saml_acs')]
public function assertionConsumerServiceAction()
{
if (!$this->samlConfiguration->isActivated()) {
@@ -91,9 +78,7 @@ final class SamlController extends AbstractController
throw new \RuntimeException('You must configure the check path in your firewall.');
}
/**
* @Route(path="/logout", name="saml_logout")
*/
#[Route(path: '/logout', name: 'saml_logout')]
public function logoutAction()
{
if (!$this->samlConfiguration->isActivated()) {

View File

@@ -0,0 +1,140 @@
<?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\Bookmark;
use App\Repository\BookmarkRepository;
use App\Utils\ProfileManager;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Exception\RuntimeException;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
/**
* This does not go into the API, because it is ONLY related to the Web UI.
*/
#[Route(path: '/bookmark')]
final class BookmarkController extends AbstractController
{
public const DATATABLE_TOKEN = 'datatable_update';
public const PARAM_TOKEN_NAME = 'datatable_token';
public const PARAM_DATATABLE = 'datatable_name';
public const PARAM_PROFILE = 'datatable_profile';
public function __construct(private BookmarkRepository $bookmarkRepository, private ProfileManager $profileManager)
{
}
#[Route(path: '/datatable/profile', name: 'bookmark_profile', methods: ['POST'])]
public function datatableProfile(Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$request->request->has(self::PARAM_TOKEN_NAME) || !$request->request->has(self::PARAM_PROFILE)) {
throw $this->createNotFoundException('Missing CSRF Token');
}
if (!$csrfTokenManager->isTokenValid(new CsrfToken(self::DATATABLE_TOKEN, $request->request->get(self::PARAM_TOKEN_NAME)))) {
throw $this->createAccessDeniedException('Invalid CSRF Token');
}
$profile = $request->request->get(self::PARAM_PROFILE);
if (!$this->profileManager->isValidProfile($profile)) {
throw $this->createNotFoundException('Invalid profile given');
}
$this->profileManager->setProfile($request->getSession(), $profile);
$csrfTokenManager->refreshToken(self::DATATABLE_TOKEN);
return new Response();
}
#[Route(path: '/datatable/save', name: 'bookmark_save_datatable', methods: ['POST'])]
public function datatableSave(Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$request->request->has(self::PARAM_TOKEN_NAME) || !$request->request->has(self::PARAM_DATATABLE) || !$request->request->has(self::PARAM_PROFILE)) {
throw $this->createNotFoundException('Missing data: csrf token, datatable name or profile');
}
if (!$csrfTokenManager->isTokenValid(new CsrfToken(self::DATATABLE_TOKEN, $request->request->get(self::PARAM_TOKEN_NAME)))) {
throw $this->createAccessDeniedException('Invalid CSRF Token');
}
$profile = $request->request->get(self::PARAM_PROFILE);
if (!$this->profileManager->isValidProfile($profile)) {
throw $this->createNotFoundException('Invalid profile given');
}
$datatableName = $request->request->get(self::PARAM_DATATABLE);
$datatableName = $this->profileManager->getDatatableName($datatableName, $profile);
if (empty($datatableName) || mb_strlen($datatableName) > 50) {
throw new RuntimeException('Invalid datatable name');
}
$enabled = [];
foreach ($request->request->all() as $name => $value) {
if ($value !== 'on' || mb_strlen($name) > 30) {
continue;
}
$enabled[$name] = true;
}
if (\count($enabled) > 50) {
throw new RuntimeException(sprintf('Too many columns provided, expected maximum 50, received %s.', \count($enabled)));
}
$user = $this->getUser();
$bookmark = $this->bookmarkRepository->findBookmark($user, Bookmark::COLUMN_VISIBILITY, $datatableName);
if ($bookmark === null) {
$bookmark = new Bookmark();
$bookmark->setUser($user);
$bookmark->setType(Bookmark::COLUMN_VISIBILITY);
$bookmark->setName($datatableName);
}
$bookmark->setContent($enabled);
$this->bookmarkRepository->saveBookmark($bookmark);
$this->profileManager->setProfile($request->getSession(), $profile);
$csrfTokenManager->refreshToken(self::DATATABLE_TOKEN);
return new Response();
}
#[Route(path: '/datatable/delete', name: 'bookmark_delete', methods: ['POST'])]
public function datatableDelete(Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$request->request->has(self::PARAM_TOKEN_NAME) || !$request->request->has(self::PARAM_DATATABLE) || !$request->request->has(self::PARAM_PROFILE)) {
throw $this->createNotFoundException('Missing data: csrf token, datatable name or profile');
}
if (!$csrfTokenManager->isTokenValid(new CsrfToken(self::DATATABLE_TOKEN, $request->request->get(self::PARAM_TOKEN_NAME)))) {
throw $this->createAccessDeniedException('Invalid CSRF Token');
}
$profile = $request->request->get(self::PARAM_PROFILE);
if (!$this->profileManager->isValidProfile($profile)) {
throw $this->createNotFoundException('Invalid profile given');
}
$datatableName = $request->request->get(self::PARAM_DATATABLE);
$datatableName = $this->profileManager->getDatatableName($datatableName, $profile);
$bookmark = $this->bookmarkRepository->findBookmark($this->getUser(), Bookmark::COLUMN_VISIBILITY, $datatableName);
if ($bookmark !== null) {
$this->bookmarkRepository->deleteBookmark($bookmark);
}
$csrfTokenManager->refreshToken(self::DATATABLE_TOKEN);
return new Response();
}
}

View File

@@ -14,6 +14,7 @@ use App\Configuration\SystemConfiguration;
use App\Entity\User;
use App\Form\CalendarForm;
use App\Timesheet\TrackingModeService;
use App\Utils\PageSetup;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -21,27 +22,17 @@ use Symfony\Component\Routing\Annotation\Route;
/**
* Controller used to display calendars.
*
* @Route(path="/calendar")
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
*/
class CalendarController extends AbstractController
#[Route(path: '/calendar')]
#[Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")]
final class CalendarController extends AbstractController
{
private $calendarService;
private $configuration;
private $service;
public function __construct(CalendarService $calendarService, SystemConfiguration $configuration, TrackingModeService $service)
public function __construct(private CalendarService $calendarService, private SystemConfiguration $configuration, private TrackingModeService $service)
{
$this->calendarService = $calendarService;
$this->configuration = $configuration;
$this->service = $service;
}
/**
* @Route(path="/", name="calendar", methods={"GET"})
* @Route(path="/{profile}", name="calendar_user", methods={"GET"})
*/
#[Route(path: '/', name: 'calendar', methods: ['GET'])]
#[Route(path: '/{profile}', name: 'calendar_user', methods: ['GET'])]
public function userCalendar(Request $request): Response
{
$form = null;
@@ -72,7 +63,13 @@ class CalendarController extends AbstractController
$mode = $this->service->getActiveMode();
$factory = $this->getDateTimeFactory();
$defaultStart = $factory->createDateTime($this->configuration->getTimesheetDefaultBeginTime());
// if now is default time, we do not pass it on, so it can be re-calculated for each new entry
$defaultStart = null;
if ($this->configuration->getTimesheetDefaultBeginTime() !== 'now') {
$defaultStart = $factory->createDateTime($this->configuration->getTimesheetDefaultBeginTime());
$defaultStart = $defaultStart->format('h:i:s');
}
$config = $this->calendarService->getConfiguration();
@@ -87,14 +84,18 @@ class CalendarController extends AbstractController
}
}
$page = new PageSetup('calendar');
$page->setHelp('calendar.html');
return $this->render('calendar/user.html.twig', [
'page_setup' => $page,
'form' => $form,
'user' => $profile,
'config' => $config,
'dragAndDrop' => $dragAndDrop,
'google' => $this->calendarService->getGoogleSources($profile),
'now' => $factory->createDateTime(),
'defaultStartTime' => $defaultStart->format('h:i:s'),
'defaultStartTime' => $defaultStart,
'is_punch_mode' => $isPunchMode,
'can_edit_begin' => $mode->canEditBegin(),
'can_edit_end' => $mode->canEditBegin(),

View File

@@ -9,7 +9,7 @@
namespace App\Controller;
use App\Configuration\SystemConfiguration;
use App\Customer\CustomerService;
use App\Customer\CustomerStatisticService;
use App\Entity\Customer;
use App\Entity\CustomerComment;
@@ -34,44 +34,34 @@ use App\Repository\ProjectRepository;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\TeamRepository;
use Pagerfanta\Pagerfanta;
use App\Utils\DataTable;
use App\Utils\FileHelper;
use App\Utils\PageSetup;
use JeroenDesloovere\VCard\VCard;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Intl\Countries;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
/**
* Controller used to manage customer in the admin part of the site.
*
* @Route(path="/admin/customer")
* @Security("is_granted('view_customer') or is_granted('view_teamlead_customer') or is_granted('view_team_customer')")
*/
#[Route(path: '/admin/customer')]
#[Security("is_granted('view_customer') or is_granted('view_teamlead_customer') or is_granted('view_team_customer')")]
final class CustomerController extends AbstractController
{
/**
* @var CustomerRepository
*/
private $repository;
/**
* @var EventDispatcherInterface
*/
private $dispatcher;
public function __construct(CustomerRepository $repository, EventDispatcherInterface $dispatcher)
public function __construct(private CustomerRepository $repository, private EventDispatcherInterface $dispatcher)
{
$this->repository = $repository;
$this->dispatcher = $dispatcher;
}
/**
* @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"})
*/
#[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'])]
public function indexAction($page, Request $request)
{
$query = new CustomerQuery();
@@ -84,12 +74,54 @@ final class CustomerController extends AbstractController
}
$entries = $this->repository->getPagerfantaForQuery($query);
$metaColumns = $this->findMetaColumns($query);
$table = new DataTable('customer_admin', $query);
$table->setPagination($entries);
$table->setSearchForm($form);
$table->setPaginationRoute('admin_customer_paginated');
$table->setReloadEvents('kimai.customerUpdate kimai.customerDelete kimai.customerTeamUpdate');
$table->addColumn('name', ['class' => 'alwaysVisible']);
$table->addColumn('comment', ['class' => 'd-none', 'title' => 'description']);
$table->addColumn('number', ['class' => 'd-none w-min']);
$table->addColumn('company', ['class' => 'd-none']);
$table->addColumn('vat_id', ['class' => 'd-none w-min']);
$table->addColumn('contact', ['class' => 'd-none']);
$table->addColumn('address', ['class' => 'd-none']);
$table->addColumn('country', ['class' => 'd-none w-min']);
$table->addColumn('currency', ['class' => 'd-none w-min']);
$table->addColumn('phone', ['class' => 'd-none']);
$table->addColumn('fax', ['class' => 'd-none']);
$table->addColumn('mobile', ['class' => 'd-none']);
$table->addColumn('email', ['class' => 'd-none']);
$table->addColumn('homepage', ['class' => 'd-none']);
foreach ($metaColumns as $metaColumn) {
$table->addColumn('mf_' . $metaColumn->getName(), ['title' => $metaColumn->getLabel(), 'class' => 'd-none', 'orderBy' => false, 'data' => $metaColumn]);
}
if ($this->isGranted('budget_money', 'customer')) {
$table->addColumn('budget', ['class' => 'd-none text-end w-min', 'title' => 'budget']);
}
if ($this->isGranted('budget_time', 'customer')) {
$table->addColumn('timeBudget', ['class' => 'd-none text-end w-min', 'title' => 'timeBudget']);
}
$table->addColumn('billable', ['class' => 'd-none text-center w-min', 'orderBy' => false]);
$table->addColumn('team', ['class' => 'text-center w-min', 'orderBy' => false]);
$table->addColumn('visible', ['class' => 'd-none text-center w-min']);
$table->addColumn('actions', ['class' => 'actions']);
$page = $this->createPageSetup();
$page->setDataTable($table);
$page->setActionName('customers');
return $this->render('customer/index.html.twig', [
'entries' => $entries,
'query' => $query,
'toolbarForm' => $form->createView(),
'metaColumns' => $this->findMetaColumns($query),
'page_setup' => $page,
'dataTable' => $table,
'metaColumns' => $metaColumns,
'now' => $this->getDateTimeFactory()->createDateTime(),
]);
}
@@ -106,29 +138,17 @@ final class CustomerController extends AbstractController
return $event->getFields();
}
/**
* @Route(path="/create", name="admin_customer_create", methods={"GET", "POST"})
* @Security("is_granted('create_customer')")
*/
public function createAction(Request $request, SystemConfiguration $configuration)
#[Route(path: '/create', name: 'admin_customer_create', methods: ['GET', 'POST'])]
#[Security("is_granted('create_customer')")]
public function createAction(Request $request, CustomerService $customerService)
{
$timezone = date_default_timezone_get();
if (null !== $configuration->getCustomerDefaultTimezone()) {
$timezone = $configuration->getCustomerDefaultTimezone();
}
$customer = $customerService->createNewCustomer('');
$customer = new Customer();
$customer->setCountry($configuration->getCustomerDefaultCountry());
$customer->setCurrency($configuration->getCustomerDefaultCurrency());
$customer->setTimezone($timezone);
return $this->renderCustomerForm($customer, $request);
return $this->renderCustomerForm($customer, $request, true);
}
/**
* @Route(path="/{id}/permissions", name="admin_customer_permissions", methods={"GET", "POST"})
* @Security("is_granted('permissions', customer)")
*/
#[Route(path: '/{id}/permissions', name: 'admin_customer_permissions', methods: ['GET', 'POST'])]
#[Security("is_granted('permissions', customer)")]
public function teamPermissionsAction(Customer $customer, Request $request)
{
$form = $this->createForm(CustomerTeamPermissionForm::class, $customer, [
@@ -154,15 +174,14 @@ final class CustomerController extends AbstractController
}
return $this->render('customer/permissions.html.twig', [
'page_setup' => $this->createPageSetup(),
'customer' => $customer,
'form' => $form->createView()
]);
}
/**
* @Route(path="/{id}/comment_delete/{token}", name="customer_comment_delete", methods={"GET"})
* @Security("is_granted('edit', comment.getCustomer()) and is_granted('comments', comment.getCustomer())")
*/
#[Route(path: '/{id}/comment_delete/{token}', name: 'customer_comment_delete', methods: ['GET'])]
#[Security("is_granted('edit', comment.getCustomer()) and is_granted('comments', comment.getCustomer())")]
public function deleteCommentAction(CustomerComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager)
{
$customerId = $comment->getCustomer()->getId();
@@ -184,14 +203,12 @@ final class CustomerController extends AbstractController
return $this->redirectToRoute('customer_details', ['id' => $customerId]);
}
/**
* @Route(path="/{id}/comment_add", name="customer_comment_add", methods={"POST"})
* @Security("is_granted('comments_create', customer)")
*/
#[Route(path: '/{id}/comment_add', name: 'customer_comment_add', methods: ['POST'])]
#[Security("is_granted('comments', customer)")]
public function addCommentAction(Customer $customer, Request $request)
{
$comment = new CustomerComment();
$form = $this->getCommentForm($customer, $comment);
$comment = new CustomerComment($customer);
$form = $this->getCommentForm($comment);
$form->handleRequest($request);
@@ -206,10 +223,8 @@ final class CustomerController extends AbstractController
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
}
/**
* @Route(path="/{id}/comment_pin/{token}", name="customer_comment_pin", methods={"GET"})
* @Security("is_granted('edit', comment.getCustomer()) and is_granted('comments', comment.getCustomer())")
*/
#[Route(path: '/{id}/comment_pin/{token}', name: 'customer_comment_pin', methods: ['GET'])]
#[Security("is_granted('edit', comment.getCustomer()) and is_granted('comments', comment.getCustomer())")]
public function pinCommentAction(CustomerComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager)
{
$customerId = $comment->getCustomer()->getId();
@@ -232,21 +247,18 @@ final class CustomerController extends AbstractController
return $this->redirectToRoute('customer_details', ['id' => $customerId]);
}
/**
* @Route(path="/{id}/create_team", name="customer_team_create", methods={"GET"})
* @Security("is_granted('create_team') and is_granted('permissions', customer)")
*/
#[Route(path: '/{id}/create_team', name: 'customer_team_create', methods: ['GET'])]
#[Security("is_granted('create_team') and is_granted('permissions', customer)")]
public function createDefaultTeamAction(Customer $customer, TeamRepository $teamRepository)
{
$defaultTeam = $teamRepository->findOneBy(['name' => $customer->getName()]);
if (null !== $defaultTeam) {
$this->flashError('action.update.error', ['%reason%' => 'Team already existing']);
$this->flashError('action.update.error', 'Team already existing');
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
}
$defaultTeam = new Team();
$defaultTeam->setName($customer->getName());
$defaultTeam = new Team($customer->getName());
$defaultTeam->addTeamlead($this->getUser());
$defaultTeam->addCustomer($customer);
@@ -259,10 +271,8 @@ final class CustomerController extends AbstractController
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
}
/**
* @Route(path="/{id}/projects/{page}", defaults={"page": 1}, name="customer_projects", methods={"GET", "POST"})
* @Security("is_granted('view', customer)")
*/
#[Route(path: '/{id}/projects/{page}', defaults: ['page' => 1], name: 'customer_projects', methods: ['GET', 'POST'])]
#[Security("is_granted('view', customer)")]
public function projectsAction(Customer $customer, int $page, ProjectRepository $projectRepository)
{
$query = new ProjectQuery();
@@ -274,7 +284,6 @@ final class CustomerController extends AbstractController
$query->addOrderGroup('visible', ProjectQuery::ORDER_DESC);
$query->addOrderGroup('name', ProjectQuery::ORDER_ASC);
/* @var $entries Pagerfanta */
$entries = $projectRepository->getPagerfantaForQuery($query);
return $this->render('customer/embed_projects.html.twig', [
@@ -285,10 +294,8 @@ final class CustomerController extends AbstractController
]);
}
/**
* @Route(path="/{id}/details", name="customer_details", methods={"GET", "POST"})
* @Security("is_granted('view', customer)")
*/
#[Route(path: '/{id}/details', name: 'customer_details', methods: ['GET', 'POST'])]
#[Security("is_granted('view', customer)")]
public function detailsAction(Customer $customer, TeamRepository $teamRepository, CustomerRateRepository $rateRepository, CustomerStatisticService $statisticService)
{
$event = new CustomerMetaDefinitionEvent($customer);
@@ -321,10 +328,7 @@ final class CustomerController extends AbstractController
if ($this->isGranted('comments', $customer)) {
$comments = $this->repository->getComments($customer);
}
if ($this->isGranted('comments_create', $customer)) {
$commentForm = $this->getCommentForm($customer, new CustomerComment())->createView();
$commentForm = $this->getCommentForm(new CustomerComment($customer))->createView();
}
if ($this->isGranted('permissions', $customer) || $this->isGranted('details', $customer) || $this->isGranted('view_team')) {
@@ -336,7 +340,13 @@ final class CustomerController extends AbstractController
$this->dispatcher->dispatch($event);
$boxes = $event->getController();
$page = $this->createPageSetup();
$page->setActionName('customer');
$page->setActionView('customer_details');
$page->setActionPayload(['customer' => $customer]);
return $this->render('customer/details.html.twig', [
'page_setup' => $page,
'customer' => $customer,
'comments' => $comments,
'commentForm' => $commentForm,
@@ -351,17 +361,80 @@ final class CustomerController extends AbstractController
]);
}
/**
* @Route(path="/{id}/rate", name="admin_customer_rate_add", methods={"GET", "POST"})
* @Security("is_granted('edit', customer)")
*/
public function addRateAction(Customer $customer, Request $request, CustomerRateRepository $repository)
#[Route(path: '/{id}/vcard', name: 'customer_vcard', methods: ['GET'])]
#[Security("is_granted('view', customer)")]
public function downloadVCard(Customer $customer): Response
{
$vcard = new VCard();
$contact = $customer->getContact() ?? $customer->getName();
$contact = explode(' ', $contact);
$lastname = array_pop($contact);
$firstname = \count($contact) > 0 ? $contact[0] : $lastname;
$note = $customer->getComment();
if ($note !== null) {
$note .= PHP_EOL;
}
$vcard->addName($lastname, $firstname);
$vcard->addNote($note . $customer->getAddress());
$vcard->addAddress(null, null, null, null, null, null, Countries::getName($customer->getCountry()));
$vcard->addCompany($customer->getCompany() ?? $customer->getName());
$vcard->addEmail($customer->getEmail());
$hasPref = false;
if ($customer->getPhone() !== null) {
$hasPref = true;
$vcard->addPhoneNumber($customer->getPhone(), 'PREF;WORK');
}
if ($customer->getMobile() !== null) {
$type = $hasPref ? 'CELL' : 'PREF;CELL';
$vcard->addPhoneNumber($customer->getMobile(), $type);
}
if ($customer->getFax() !== null) {
$vcard->addPhoneNumber($customer->getFax(), 'FAX');
}
if ($customer->getHomepage() !== null) {
$vcard->addURL($customer->getHomepage(), 'WORK');
}
$response = new Response($vcard->getOutput());
$disposition = $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
FileHelper::convertToAsciiFilename($customer->getName()) . '.vcf'
);
$response->headers->set('Content-Disposition', $disposition);
return $response;
}
#[Route(path: '/{id}/rate/{rate}', name: 'admin_customer_rate_edit', methods: ['GET', 'POST'])]
#[Security("is_granted('edit', customer)")]
public function editRateAction(Customer $customer, CustomerRate $rate, Request $request, CustomerRateRepository $repository): Response
{
return $this->rateFormAction($customer, $rate, $request, $repository, $this->generateUrl('admin_customer_rate_edit', ['id' => $customer->getId(), 'rate' => $rate->getId()]));
}
#[Route(path: '/{id}/rate', name: 'admin_customer_rate_add', methods: ['GET', 'POST'])]
#[Security("is_granted('edit', customer)")]
public function addRateAction(Customer $customer, Request $request, CustomerRateRepository $repository): Response
{
$rate = new CustomerRate();
$rate->setCustomer($customer);
return $this->rateFormAction($customer, $rate, $request, $repository, $this->generateUrl('admin_customer_rate_add', ['id' => $customer->getId()]));
}
private function rateFormAction(Customer $customer, CustomerRate $rate, Request $request, CustomerRateRepository $repository, string $formUrl): Response
{
$form = $this->createForm(CustomerRateForm::class, $rate, [
'action' => $this->generateUrl('admin_customer_rate_add', ['id' => $customer->getId()]),
'action' => $formUrl,
'method' => 'POST',
]);
@@ -379,24 +452,21 @@ final class CustomerController extends AbstractController
}
return $this->render('customer/rates.html.twig', [
'page_setup' => $this->createPageSetup(),
'customer' => $customer,
'form' => $form->createView()
]);
}
/**
* @Route(path="/{id}/edit", name="admin_customer_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', customer)")
*/
#[Route(path: '/{id}/edit', name: 'admin_customer_edit', methods: ['GET', 'POST'])]
#[Security("is_granted('edit', customer)")]
public function editAction(Customer $customer, Request $request)
{
return $this->renderCustomerForm($customer, $request);
}
/**
* @Route(path="/{id}/delete", name="admin_customer_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', customer)")
*/
#[Route(path: '/{id}/delete', name: 'admin_customer_delete', methods: ['GET', 'POST'])]
#[Security("is_granted('delete', customer)")]
public function deleteAction(Customer $customer, Request $request, CustomerStatisticService $statisticService)
{
$stats = $statisticService->getCustomerStatistics($customer);
@@ -431,15 +501,14 @@ final class CustomerController extends AbstractController
}
return $this->render('customer/delete.html.twig', [
'page_setup' => $this->createPageSetup(),
'customer' => $customer,
'stats' => $stats,
'form' => $deleteForm->createView(),
]);
}
/**
* @Route(path="/export", name="customer_export", methods={"GET"})
*/
#[Route(path: '/export', name: 'customer_export', methods: ['GET'])]
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter)
{
$query = new CustomerQuery();
@@ -465,12 +534,7 @@ final class CustomerController extends AbstractController
return $writer->getFileResponse($spreadsheet);
}
/**
* @param Customer $customer
* @param Request $request
* @return RedirectResponse|Response
*/
private function renderCustomerForm(Customer $customer, Request $request)
private function renderCustomerForm(Customer $customer, Request $request, bool $create = false): Response
{
$editForm = $this->createEditForm($customer);
@@ -481,13 +545,18 @@ final class CustomerController extends AbstractController
$this->repository->saveCustomer($customer);
$this->flashSuccess('action.update.success');
if ($create) {
return $this->redirectToRouteAfterCreate('customer_details', ['id' => $customer->getId()]);
}
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
} catch (\Exception $ex) {
$this->flashUpdateException($ex);
$this->handleFormUpdateException($ex, $editForm);
}
}
return $this->render('customer/edit.html.twig', [
'page_setup' => $this->createPageSetup(),
'customer' => $customer,
'form' => $editForm->createView()
]);
@@ -495,23 +564,21 @@ final class CustomerController extends AbstractController
private function getToolbarForm(CustomerQuery $query): FormInterface
{
return $this->createForm(CustomerToolbarForm::class, $query, [
return $this->createSearchForm(CustomerToolbarForm::class, $query, [
'action' => $this->generateUrl('admin_customer', [
'page' => $query->getPage(),
]),
'method' => 'GET',
])
]);
}
private function getCommentForm(Customer $customer, CustomerComment $comment): FormInterface
private function getCommentForm(CustomerComment $comment): FormInterface
{
if (null === $comment->getId()) {
$comment->setCustomer($customer);
$comment->setCreatedBy($this->getUser());
}
return $this->createForm(CustomerCommentForm::class, $comment, [
'action' => $this->generateUrl('customer_comment_add', ['id' => $customer->getId()]),
'action' => $this->generateUrl('customer_comment_add', ['id' => $comment->getCustomer()->getId()]),
'method' => 'POST',
]);
}
@@ -534,4 +601,12 @@ final class CustomerController extends AbstractController
'include_time' => $this->isGranted('time', $customer),
]);
}
private function createPageSetup(): PageSetup
{
$page = new PageSetup('customers');
$page->setHelp('customer.html');
return $page;
}
}

View File

@@ -9,145 +9,292 @@
namespace App\Controller;
use App\Entity\Bookmark;
use App\Entity\User;
use App\Event\DashboardEvent;
use App\Widget\Type\AbstractContainer;
use App\Widget\Type\AuthorizedWidget;
use App\Widget\Type\CompoundRow;
use App\Widget\Type\UserWidget;
use App\Widget\WidgetContainerInterface;
use App\Widget\WidgetException;
use App\Repository\BookmarkRepository;
use App\Utils\PageSetup;
use App\Widget\WidgetInterface;
use App\Widget\WidgetService;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* Dashboard controller for the admin area.
*
* @Route(path="/dashboard")
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
*/
class DashboardController extends AbstractController
#[Route(path: '/dashboard')]
#[Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")]
final class DashboardController extends AbstractController
{
public const BOOKMARK_TYPE = 'dashboard';
public const BOOKMARK_NAME = 'default';
/**
* @var EventDispatcherInterface
* @var WidgetInterface[]|null
*/
private $eventDispatcher;
/**
* @var WidgetService
*/
private $widgets;
/**
* @var array
*/
private $dashboard;
private ?array $widgets = null;
/**
* @param EventDispatcherInterface $dispatcher
* @param WidgetService $service
* @param array $dashboard
*/
public function __construct(EventDispatcherInterface $dispatcher, WidgetService $service, array $dashboard)
public function __construct(private EventDispatcherInterface $eventDispatcher, private WidgetService $service, private BookmarkRepository $repository)
{
$this->eventDispatcher = $dispatcher;
$this->widgets = $service;
$this->dashboard = $dashboard;
}
/**
* @Route(path="/", defaults={}, name="dashboard", methods={"GET"})
* @param User $user
* @return array<WidgetInterface>
* @throws \Exception
*/
public function indexAction()
private function getAllAvailableWidgets(User $user): array
{
$user = $this->getUser();
if ($this->widgets === null) {
$all = [];
foreach ($this->service->getAllWidgets() as $widget) {
$widget->setUser($user);
$event = new DashboardEvent($user);
foreach ($this->dashboard as $widgetRow) {
if (empty($widgetRow['widgets'])) {
continue;
}
if (null !== $widgetRow['permission'] && !$this->isGranted($widgetRow['permission'])) {
continue;
}
if (!isset($widgetRow['type'])) {
$widgetRow['type'] = CompoundRow::class;
}
if (!class_exists($widgetRow['type'])) {
throw new WidgetException(sprintf('Unknown widget type "%s"', $widgetRow['type']));
}
$row = new $widgetRow['type']();
if (!($row instanceof AbstractContainer)) {
throw new WidgetException(
sprintf(
'Expected widget type to be an instanceof "%s", but found "%s"',
AbstractContainer::class,
$widgetRow['type']
)
);
}
$row->setTitle($widgetRow['title'] ?? '');
$row->setOrder($widgetRow['order']);
foreach ($widgetRow['widgets'] as $widgetName) {
if (!$this->widgets->hasWidget($widgetName)) {
throw new \Exception(sprintf('Unknown widget "%s"', $widgetName));
}
$widget = $this->widgets->getWidget($widgetName);
$add = true;
if ($widget instanceof AuthorizedWidget) {
$tmp = false;
foreach ($widget->getPermissions() as $perm) {
$permissions = $widget->getPermissions();
if (\count($permissions) > 0) {
$add = false;
foreach ($permissions as $perm) {
if ($this->isGranted($perm)) {
$tmp = true;
$add = true;
break;
}
}
$add = $tmp;
}
if ($widget instanceof UserWidget) {
$widget->setUser($user);
}
if ($add) {
$row->addWidget($widget);
if (!$add) {
continue;
}
}
$all[] = $widget;
}
$this->widgets = $all;
}
$event->addSection($row);
return $this->widgets;
}
private function getBookmark(User $user): ?Bookmark
{
return $this->repository->findBookmark($user, self::BOOKMARK_TYPE, self::BOOKMARK_NAME);
}
private function getDefaultConfig(): array
{
$event = new DashboardEvent($this->getUser());
// default widgets
$dashboard = [
'PaginatedWorkingTimeChart',
//'UserAmountToday',
//'UserAmountWeek',
//'UserAmountMonth',
//'UserAmountYear',
//'UserTeams',
//'UserTeamProjects',
'DurationToday',
'DurationWeek',
'DurationMonth',
'DurationYear',
//'ActiveUsersToday',
//'ActiveUsersWeek',
//'ActiveUsersMonth',
//'ActiveUsersYear',
//'AmountToday',
//'AmountWeek',
//'AmountMonth',
//'AmountYear',
//'TotalsUser',
//'TotalsCustomer',
//'TotalsProject',
//'TotalsActivity',
];
foreach ($dashboard as $widgetName) {
$event->addWidget($widgetName);
}
$this->eventDispatcher->dispatch($event);
$sections = $event->getSections();
$clearedSections = [];
/** @var WidgetContainerInterface $section */
foreach ($sections as $key => $section) {
if (!empty($section->getWidgets())) {
$clearedSections[] = $section;
return $event->getWidgets();
}
/**
* Returns the list of widgets names and options for a user.
*
* @param User $user
* @return array<int, array<string, mixed>>
*/
private function getUserConfig(User $user): array
{
$bookmark = $this->getBookmark($user);
if ($bookmark !== null) {
return $bookmark->getContent();
}
$widgets = [];
foreach ($this->getDefaultConfig() as $name) {
$widgets[] = ['id' => $name, 'options' => []];
}
return $widgets;
}
/**
* @param array<WidgetInterface> $widgets
* @param User $user
* @return array<WidgetInterface>
*/
private function filterWidgets(array $widgets, User $user): array
{
$filteredWidgets = [];
foreach ($this->getUserConfig($user) as $setting) {
$id = $setting['id'];
$options = $setting['options'];
foreach ($widgets as $widget) {
if ($widget->getId() === $id) {
$tmpWidget = clone $widget;
foreach ($options as $key => $value) {
$tmpWidget->setOption($key, $value);
}
$filteredWidgets[] = $tmpWidget;
break;
}
}
}
uasort(
$clearedSections,
function (WidgetContainerInterface $a, WidgetContainerInterface $b) {
if ($a->getOrder() == $b->getOrder()) {
return 0;
}
return $filteredWidgets;
}
return ($a->getOrder() < $b->getOrder()) ? -1 : 1;
}
);
#[Route(path: '/', defaults: [], name: 'dashboard', methods: ['GET'])]
public function index(): Response
{
$user = $this->getUser();
$available = $this->getAllAvailableWidgets($user);
$widgets = $this->filterWidgets($available, $user);
$page = new PageSetup('dashboard.title');
$page->setHelp('dashboard.html');
$page->setActionName('dashboard');
$page->setActionPayload(['widgets' => $widgets, 'available' => $available]);
return $this->render('dashboard/index.html.twig', [
'widgets' => $clearedSections
'page_setup' => $page,
'widgets' => $widgets,
'available' => $available,
]);
}
#[Route(path: '/reset/', defaults: [], name: 'dashboard_reset', methods: ['GET', 'POST'])]
public function reset(): RedirectResponse
{
$bookmark = $this->getBookmark($this->getUser());
if ($bookmark !== null) {
$this->repository->deleteBookmark($bookmark);
}
return $this->redirectToRoute('dashboard');
}
#[Route(path: '/add-widget/{widget}', defaults: [], name: 'dashboard_add', methods: ['GET'])]
public function add(string $widget): Response
{
$user = $this->getUser();
$widgets = $this->getUserConfig($user);
// prevent to add the same widget multiple times
foreach ($widgets as $id => $setting) {
if ($setting['id'] === $widget) {
return $this->redirectToRoute('dashboard_edit');
}
}
$widgets[] = ['id' => $widget, 'options' => []];
$this->saveBookmark($user, $widgets);
return $this->redirectToRoute('dashboard_edit');
}
private function saveBookmark(User $user, array $widgets): void
{
$bookmark = $this->getBookmark($user);
if ($bookmark === null) {
$bookmark = new Bookmark();
$bookmark->setUser($user);
$bookmark->setType(self::BOOKMARK_TYPE);
$bookmark->setName(self::BOOKMARK_NAME);
}
$bookmark->setContent($widgets);
$this->repository->saveBookmark($bookmark);
}
#[Route(path: '/edit/', defaults: [], name: 'dashboard_edit', methods: ['GET', 'POST'])]
public function edit(Request $request): Response
{
$user = $this->getUser();
$available = $this->getAllAvailableWidgets($user);
$widgets = $this->filterWidgets($available, $user);
$choices = [];
foreach ($available as $widget) {
if (empty($widget->getTitle())) {
continue;
}
$choices[$widget->getId()] = $widget->getId();
}
$form = $this->createFormBuilder(null, [])
->add('widgets', ChoiceType::class, ['choices' => $choices, 'multiple' => true])
->setAction($this->generateUrl('dashboard_edit'))
->setMethod('POST')
->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$userWidgets = $this->getUserConfig($user);
$saveWidgets = [];
foreach ($form->getData()['widgets'] as $widgetId) {
$options = [];
foreach ($userWidgets as $setting) {
if ($setting['id'] === $widgetId) {
$options = $setting['options'];
}
}
$saveWidgets[] = ['id' => $widgetId, 'options' => $options];
}
$this->saveBookmark($user, $saveWidgets);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('dashboard');
} catch (\Exception $ex) {
$this->flashDeleteException($ex);
}
}
$page = new PageSetup('dashboard.title');
$page->setHelp('dashboard.html');
$page->setActionName('dashboard');
$page->setActionView('edit');
$page->setActionPayload(['widgets' => $widgets, 'available' => $available]);
return $this->render('dashboard/grid.html.twig', [
'page_setup' => $page,
'widgets' => $widgets,
'available' => $available,
'form' => $form->createView(),
]);
}
}

View File

@@ -10,26 +10,25 @@
namespace App\Controller;
use App\Utils\FileHelper;
use App\Utils\PageSetup;
use App\Utils\ReleaseVersion;
use Composer\InstalledVersions;
use PackageVersions\Versions;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* @Route(path="/doctor")
* @Security("is_granted('system_information')")
*/
class DoctorController extends AbstractController
#[Route(path: '/doctor')]
#[Security("is_granted('system_information')")]
final class DoctorController extends AbstractController
{
/**
* PHP extensions which Kimai needs for runtime.
* Some are not a hard requiremenet, but some functions might not work as expected.
* Required PHP extensions for Kimai.
*/
public const REQUIRED_EXTENSIONS = [
'gd',
'intl',
'json',
'mbstring',
@@ -47,21 +46,12 @@ class DoctorController extends AbstractController
'var/log/',
];
private $projectDirectory;
private $environment;
private $fileHelper;
public function __construct(string $projectDirectory, string $kernelEnvironment, FileHelper $fileHelper)
public function __construct(private string $projectDirectory, private string $kernelEnvironment, private FileHelper $fileHelper, private CacheInterface $cache)
{
$this->projectDirectory = $projectDirectory;
$this->environment = $kernelEnvironment;
$this->fileHelper = $fileHelper;
}
/**
* @Route(path="/flush-log/{token}", name="doctor_flush_log", methods={"GET"})
* @Security("is_granted('system_configuration')")
*/
#[Route(path: '/flush-log/{token}', name: 'doctor_flush_log', methods: ['GET'])]
#[Security("is_granted('system_configuration')")]
public function deleteLogfileAction(string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$csrfTokenManager->isTokenValid(new CsrfToken('doctor.flush_log', $token))) {
@@ -76,10 +66,10 @@ class DoctorController extends AbstractController
if (file_exists($logfile)) {
if (!is_writable($logfile)) {
$this->flashError('action.delete.error', ['%reason%' => 'Logfile cannot be written']);
$this->flashError('action.delete.error', 'Logfile cannot be written');
} else {
if (false === file_put_contents($logfile, '')) {
$this->flashError('action.delete.error', ['%reason%' => 'Failed writing to logfile']);
$this->flashError('action.delete.error', 'Failed writing to logfile');
} else {
$this->flashSuccess('action.delete.success');
}
@@ -89,32 +79,34 @@ class DoctorController extends AbstractController
return $this->redirectToRoute('doctor');
}
/**
* @Route(path="", name="doctor", methods={"GET"})
*/
#[Route(path: '', name: 'doctor', methods: ['GET'])]
public function index(): Response
{
$logLines = 100;
$canDeleteLogfile = $this->isGranted('system_configuration') && is_writable($this->getLogFilename());
$page = new PageSetup('Doctor');
$page->setHelp('doctor.html');
return $this->render('doctor/index.html.twig', array_merge(
[
'modules' => get_loaded_extensions(),
'environment' => $this->environment,
'info' => $this->getPhpInfo(),
'settings' => $this->getIniSettings(),
'extensions' => $this->getLoadedExtensions(),
'directories' => $this->getFilePermissions(),
'log_delete' => $canDeleteLogfile,
'logs' => $this->getLog(),
'logLines' => $logLines,
'logSize' => $this->getLogSize(),
'composer' => $this->getComposerPackages(),
]
));
return $this->render('doctor/index.html.twig', [
'page_setup' => $page,
'modules' => get_loaded_extensions(),
'environment' => $this->kernelEnvironment,
'info' => $this->getPhpInfo(),
'settings' => $this->getIniSettings(),
'extensions' => $this->getLoadedExtensions(),
'directories' => $this->getFilePermissions(),
'log_delete' => $canDeleteLogfile,
'logs' => $this->getLog(),
'logLines' => $logLines,
'logSize' => $this->getLogSize(),
'composer' => $this->getComposerPackages(),
'release' => $this->getNextUpdateVersion()
]);
}
/**
* @return array<string, string>
*/
private function getComposerPackages(): array
{
$versions = [];
@@ -124,35 +116,30 @@ class DoctorController extends AbstractController
foreach (InstalledVersions::getInstalledPackages() as $package) {
$versions[$package] = InstalledVersions::getPrettyVersion($package);
}
} else {
@trigger_error('Please upgrade your Composer to 2.x', E_USER_DEPRECATED);
// @deprecated since 1.14, will be removed with 2.0
$rootPackage = Versions::rootPackageName();
foreach (Versions::VERSIONS as $name => $version) {
$versions[$name] = explode('@', $version)[0];
}
// remove kimai from the package list
$versions = array_filter($versions, function ($version, $name) use ($rootPackage): bool {
if ($name === $rootPackage) {
return false;
}
if ($version === null || $version === '*') {
return false;
}
return true;
}, ARRAY_FILTER_USE_BOTH);
ksort($versions);
}
// remove kimai from the package list
$versions = array_filter($versions, function ($version, $name) use ($rootPackage) {
if ($name === $rootPackage) {
return false;
}
if ($version === null || $version === '*') {
return false;
}
return true;
}, ARRAY_FILTER_USE_BOTH);
ksort($versions);
return $versions;
}
private function getLoadedExtensions()
/**
* @return array<string, bool>
*/
private function getLoadedExtensions(): array
{
$results = [];
@@ -175,7 +162,7 @@ class DoctorController extends AbstractController
private function getLogFilename(): string
{
$logfileName = 'var/log/' . $this->environment . '.log';
$logfileName = 'var/log/' . $this->kernelEnvironment . '.log';
return $this->projectDirectory . '/' . $logfileName;
}
@@ -218,7 +205,7 @@ class DoctorController extends AbstractController
return $result;
}
private function getFilePermissions()
private function getFilePermissions(): array
{
$testPaths = [];
$baseDir = $this->projectDirectory . DIRECTORY_SEPARATOR;
@@ -241,7 +228,7 @@ class DoctorController extends AbstractController
foreach ($testPaths as $fullUri) {
$fullUri = rtrim($fullUri, DIRECTORY_SEPARATOR);
$tmp = str_replace($baseDir, '', $fullUri) . DIRECTORY_SEPARATOR;
if ($fullUri !== false && is_readable($fullUri) && is_writable($fullUri)) {
if (is_readable($fullUri) && is_writable($fullUri)) {
$results[$tmp] = true;
} else {
$results[$tmp] = false;
@@ -251,9 +238,13 @@ class DoctorController extends AbstractController
return $results;
}
private function getIniSettings()
private function getIniSettings(): array
{
$ini = [
'memory_limit',
'session.gc_maxlifetime',
'max_execution_time',
'date.timezone',
'allow_url_fopen',
'allow_url_include',
'default_charset',
@@ -262,8 +253,6 @@ class DoctorController extends AbstractController
'error_log',
'error_reporting',
'log_errors',
'max_execution_time',
'memory_limit',
'open_basedir',
'post_max_size',
'sys_temp_dir',
@@ -274,7 +263,7 @@ class DoctorController extends AbstractController
$settings = [];
foreach ($ini as $name) {
try {
$settings[$name] = ini_get($name);
$settings[$name] = \ini_get($name);
} catch (\Exception $ex) {
$settings[$name] = "Couldn't load ini setting: " . $ex->getMessage();
}
@@ -287,9 +276,9 @@ class DoctorController extends AbstractController
* @author https://php.net/manual/en/function.phpinfo.php#117961
* @return array
*/
private function getPhpInfo()
private function getPhpInfo(): array
{
$plainText = function ($input) {
$plainText = function ($input): string {
return trim(html_entity_decode(strip_tags($input)));
};
@@ -323,4 +312,24 @@ class DoctorController extends AbstractController
return $phpInfo;
}
private function getNextUpdateVersion(): ?array
{
return $this->cache->get('kimai.update_release', function (ItemInterface $item) {
// we cache the result, no matter if the call failed: at the end, this is "just"
// an update note but an expensive call
$item->expiresAfter(86400); // one day
try {
$version = new ReleaseVersion();
return $version->getLatestReleaseFromGithub(true);
} catch (\Exception $ex) {
// something failed, retry tomorrow
}
return null;
});
}
}

View File

@@ -9,12 +9,13 @@
namespace App\Controller;
use App\Entity\ExportableItem;
use App\Export\Base\DispositionInlineInterface;
use App\Export\ExportItemInterface;
use App\Export\ServiceExport;
use App\Export\TooManyItemsExportException;
use App\Form\Toolbar\ExportToolbarForm;
use App\Repository\Query\ExportQuery;
use App\Utils\PageSetup;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
@@ -23,25 +24,16 @@ use Symfony\Component\Routing\Annotation\Route;
/**
* Controller used to export timesheet data.
*
* @Route(path="/export")
* @Security("is_granted('create_export')")
*/
class ExportController extends AbstractController
#[Route(path: '/export')]
#[Security("is_granted('create_export')")]
final class ExportController extends AbstractController
{
/**
* @var ServiceExport
*/
private $export;
public function __construct(ServiceExport $export)
public function __construct(private ServiceExport $export)
{
$this->export = $export;
}
/**
* @Route(path="/", name="export", methods={"GET"})
*/
#[Route(path: '/', name: 'export', methods: ['GET'])]
public function indexAction(Request $request): Response
{
$query = $this->getDefaultQuery();
@@ -84,7 +76,11 @@ class ExportController extends AbstractController
}
}
$page = new PageSetup('export');
$page->setHelp('export.html');
return $this->render('export/index.html.twig', [
'page_setup' => $page,
'too_many' => $tooManyResults,
'by_customer' => $byCustomer,
'query' => $query,
@@ -97,9 +93,7 @@ class ExportController extends AbstractController
]);
}
/**
* @Route(path="/data", name="export_data", methods={"POST"})
*/
#[Route(path: '/data', name: 'export_data', methods: ['POST'])]
public function export(Request $request): Response
{
$query = $this->getDefaultQuery();
@@ -133,7 +127,7 @@ class ExportController extends AbstractController
return $response;
}
protected function getDefaultQuery(): ExportQuery
private function getDefaultQuery(): ExportQuery
{
$begin = $this->getDateTimeFactory()->getStartOfMonth();
$end = $this->getDateTimeFactory()->getEndOfMonth();
@@ -148,10 +142,10 @@ class ExportController extends AbstractController
/**
* @param ExportQuery $query
* @return ExportItemInterface[]
* @return ExportableItem[]
* @throws TooManyItemsExportException
*/
protected function getEntries(ExportQuery $query): array
private function getEntries(ExportQuery $query): array
{
if (null !== $query->getBegin()) {
$query->getBegin()->setTime(0, 0, 0);
@@ -163,9 +157,9 @@ class ExportController extends AbstractController
return $this->export->getExportItems($query);
}
protected function getToolbarForm(ExportQuery $query, string $method): FormInterface
private function getToolbarForm(ExportQuery $query, string $method): FormInterface
{
return $this->createForm(ExportToolbarForm::class, $query, [
return $this->createSearchForm(ExportToolbarForm::class, $query, [
'action' => $this->generateUrl('export', []),
'include_user' => $this->isGranted('view_other_timesheet'),
'include_export' => $this->isGranted('edit_export_other_timesheet'),

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\Controller;
use App\Entity\Timesheet;
use App\Timesheet\FavoriteRecordService;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
#[Route(path: '/favorite')]
#[Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")]
final class FavoriteController extends AbstractController
{
#[Route(path: '/timesheet/', name: 'favorites_timesheets', methods: ['GET'])]
#[Security("is_granted('view_own_timesheet')")]
public function favoriteAction(): Response
{
return $this->render('partials/recent-activities.html.twig');
}
#[Route(path: '/timesheet/add/{id}', name: 'favorites_timesheets_add', methods: ['GET'])]
#[Security("is_granted('view_own_timesheet')")]
public function add(Timesheet $timesheet, FavoriteRecordService $favoriteRecordService): Response
{
$favoriteRecordService->addFavorite($timesheet);
return $this->render('partials/recent-activities.html.twig');
}
#[Route(path: '/timesheet/remove/{id}', name: 'favorites_timesheets_remove', methods: ['GET'])]
#[Security("is_granted('view_own_timesheet')")]
public function remove(Timesheet $timesheet, FavoriteRecordService $favoriteRecordService): Response
{
$favoriteRecordService->removeFavorite($timesheet);
return $this->render('partials/recent-activities.html.twig');
}
}

View File

@@ -9,9 +9,8 @@
namespace App\Controller;
use App\Configuration\LocaleService;
use App\Entity\User;
use App\Form\Type\InitialViewType;
use App\Utils\LanguageService;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -19,20 +18,19 @@ use Symfony\Component\Routing\Annotation\Route;
/**
* Homepage controller is a redirect controller with user specific logic.
*
* @Route(path="/homepage")
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
*/
class HomepageController extends AbstractController
#[Route(path: '/homepage')]
#[Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")]
final class HomepageController extends AbstractController
{
/**
* @Route(path="", defaults={}, name="homepage", methods={"GET"})
*/
public function indexAction(Request $request, LanguageService $service): Response
public const DEFAULT_ROUTE = 'timesheet';
#[Route(path: '', defaults: [], name: 'homepage', methods: ['GET'])]
public function indexAction(Request $request, LocaleService $service): Response
{
/** @var User $user */
$user = $this->getUser();
$userRoute = $user->getPreferenceValue('login.initial_view', InitialViewType::DEFAULT_VIEW, false);
$userRoute = $user->getPreferenceValue('login_initial_view', self::DEFAULT_ROUTE, false);
$userLanguage = $user->getLanguage();
$requestLanguage = $request->getLocale();
@@ -46,16 +44,16 @@ class HomepageController extends AbstractController
// if a user somehow managed to get a wrong locale into hos account (eg. an imported user from Kimai 1)
// make sure that he will still see a beautiful page and not a 404
if (!$service->isKnownLanguage($userLanguage)) {
$userLanguage = $service->getDefaultLanguage();
if (!$service->isKnownLocale($userLanguage)) {
$userLanguage = $service->getDefaultLocale();
}
$routes = [
[$userRoute, $userLanguage],
[$userRoute, $requestLanguage],
[$userRoute, User::DEFAULT_LANGUAGE],
[InitialViewType::DEFAULT_VIEW, $userLanguage],
[InitialViewType::DEFAULT_VIEW, $requestLanguage],
[self::DEFAULT_ROUTE, $userLanguage],
[self::DEFAULT_ROUTE, $requestLanguage],
];
foreach ($routes as $routeSettings) {
@@ -69,6 +67,6 @@ class HomepageController extends AbstractController
}
}
return $this->redirectToRoute(InitialViewType::DEFAULT_VIEW, ['_locale' => User::DEFAULT_LANGUAGE]);
return $this->redirectToRoute(self::DEFAULT_ROUTE, ['_locale' => User::DEFAULT_LANGUAGE]);
}
}

View File

@@ -9,12 +9,10 @@
namespace App\Controller;
use App\Configuration\SystemConfiguration;
use App\Entity\Customer;
use App\Entity\Invoice;
use App\Entity\InvoiceTemplate;
use App\Entity\MetaTableTypeInterface;
use App\Event\InvoiceCreatedMultipleEvent;
use App\Event\InvoiceDocumentsEvent;
use App\Event\InvoiceMetaDefinitionEvent;
use App\Event\InvoiceMetaDisplayEvent;
@@ -26,16 +24,21 @@ use App\Form\InvoiceEditForm;
use App\Form\InvoiceTemplateForm;
use App\Form\Toolbar\InvoiceArchiveForm;
use App\Form\Toolbar\InvoiceToolbarForm;
use App\Form\Toolbar\InvoiceToolbarSimpleForm;
use App\Form\Type\DatePickerType;
use App\Form\Type\InvoiceTemplateType;
use App\Invoice\ServiceInvoice;
use App\Repository\CustomerRepository;
use App\Repository\InvoiceDocumentRepository;
use App\Repository\InvoiceRepository;
use App\Repository\InvoiceTemplateRepository;
use App\Repository\Query\BaseQuery;
use App\Repository\Query\InvoiceArchiveQuery;
use App\Repository\Query\InvoiceQuery;
use App\Utils\DataTable;
use App\Utils\PageSetup;
use Exception;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
@@ -47,30 +50,22 @@ use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* Controller used to create invoices and manage invoice templates.
*
* @Route(path="/invoice")
* @Security("is_granted('view_invoice')")
*/
#[Route(path: '/invoice')]
#[Security("is_granted('IS_AUTHENTICATED_FULLY') and is_granted('view_invoice')")]
final class InvoiceController extends AbstractController
{
private $service;
private $templateRepository;
private $invoiceRepository;
private $dispatcher;
public function __construct(ServiceInvoice $service, InvoiceTemplateRepository $templateRepository, InvoiceRepository $invoiceRepository, EventDispatcherInterface $dispatcher)
{
$this->service = $service;
$this->templateRepository = $templateRepository;
$this->invoiceRepository = $invoiceRepository;
$this->dispatcher = $dispatcher;
public function __construct(
private ServiceInvoice $service,
private InvoiceTemplateRepository $templateRepository,
private InvoiceRepository $invoiceRepository,
private EventDispatcherInterface $dispatcher
) {
}
/**
* @Route(path="/", name="invoice", methods={"GET", "POST"})
* @Security("is_granted('view_invoice')")
*/
public function indexAction(Request $request, SystemConfiguration $configuration, CsrfTokenManagerInterface $csrfTokenManager): Response
#[Route(path: '/', name: 'invoice', methods: ['GET', 'POST'])]
#[Security("is_granted('create_invoice')")]
public function indexAction(Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$this->templateRepository->hasTemplate()) {
if ($this->isGranted('manage_invoice_template')) {
@@ -81,13 +76,7 @@ final class InvoiceController extends AbstractController
$query = $this->getDefaultQuery();
$token = null;
if ($request->query->has('token')) {
$token = $request->query->get('token');
$request->query->remove('token');
}
$form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form'));
$form = $this->getToolbarForm($query);
if ($this->handleSearch($form, $request)) {
return $this->redirectToRoute('invoice');
}
@@ -96,53 +85,50 @@ final class InvoiceController extends AbstractController
$total = 0;
$searched = false;
if ($form->isValid() && $this->isGranted('create_invoice')) {
if ($request->query->has('createInvoice')) {
if (!$this->isCsrfTokenValid('invoice.create', $token)) {
$this->flashError('action.csrf.error');
return $this->redirectToRoute('invoice');
}
$csrfTokenManager->refreshToken('invoice.create');
try {
return $this->renderInvoice($query, $request);
} catch (Exception $ex) {
$this->logException($ex);
$this->flashError('action.update.error', ['%reason%' => 'check doctor/logs']);
}
}
if ($form->get('template')->getData() !== null) {
try {
$models = $this->service->createModels($query);
$searched = true;
} catch (Exception $ex) {
$this->logException($ex);
$this->flashError($ex->getMessage());
}
if ($form->isValid() && $query->getTemplate() !== null) {
try {
$models = $this->service->createModels($query);
$searched = true;
} catch (Exception $ex) {
$this->flashUpdateException($ex);
}
}
$forms = [];
foreach ($models as $model) {
$customer = $model->getCustomer();
$customerTpl = $model->getTemplate();
$total += \count($model->getCalculator()->getEntries());
$values = [
'invoiceDate' => $query->getInvoiceDate(),
'template' => $customerTpl
];
$forms[] = $this->createFormWithName('customer_' . $customer->getId(), FormType::class, $values, [
'csrf_protection' => false,
])
->add('template', InvoiceTemplateType::class)
->add('invoiceDate', DatePickerType::class, [
'required' => true,
])
->createView();
}
return $this->render('invoice/index.html.twig', [
'page_setup' => $this->createPageSetup(),
'models' => $models,
'forms' => $forms,
'form' => $form->createView(),
'limit_preview' => ($total > 500),
'searched' => $searched,
]);
}
/**
* @Route(path="/preview/{customer}/{token}", name="invoice_preview", methods={"GET"})
* @Security("is_granted('access', customer)")
* @Security("is_granted('create_invoice')")
*/
public function previewAction(Customer $customer, string $token, Request $request, SystemConfiguration $configuration): Response
#[Route(path: '/preview/{customer}/{token}', name: 'invoice_preview', methods: ['GET'])]
#[Security("is_granted('access', customer) and is_granted('create_invoice')")]
public function previewAction(Customer $customer, string $token, Request $request): Response
{
if (!$this->templateRepository->hasTemplate()) {
return $this->redirectToRoute('invoice');
@@ -158,7 +144,8 @@ final class InvoiceController extends AbstractController
// so the new token would not be loaded
$query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form'));
$query->setAllowTemplateOverwrite(false);
$form = $this->getToolbarForm($query);
if ($this->handleSearch($form, $request)) {
return $this->redirectToRoute('invoice');
}
@@ -168,10 +155,9 @@ final class InvoiceController extends AbstractController
$query->setCustomers([$customer]);
$model = $this->service->createModel($query);
return $this->service->renderInvoiceWithModel($model, $this->dispatcher, true);
return $this->service->renderInvoice($model, $this->dispatcher, true);
} catch (Exception $ex) {
$this->logException($ex);
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
$this->flashUpdateException($ex);
}
} else {
$this->flashFormError($form);
@@ -180,12 +166,9 @@ final class InvoiceController extends AbstractController
return $this->redirectToRoute('invoice');
}
/**
* @Route(path="/save-invoice/{customer}/{template}/{token}", name="invoice_create", methods={"GET"})
* @Security("is_granted('access', customer)")
* @Security("is_granted('create_invoice')")
*/
public function createInvoiceAction(Customer $customer, InvoiceTemplate $template, string $token, Request $request, SystemConfiguration $configuration, CsrfTokenManagerInterface $csrfTokenManager): Response
#[Route(path: '/save-invoice/{customer}/{token}', name: 'invoice_create', methods: ['GET'])]
#[Security("is_granted('access', customer) and is_granted('create_invoice')")]
public function createInvoiceAction(Customer $customer, string $token, Request $request, CustomerRepository $customerRepository): Response
{
if (!$this->templateRepository->hasTemplate()) {
return $this->redirectToRoute('invoice');
@@ -198,28 +181,40 @@ final class InvoiceController extends AbstractController
}
$query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form'));
$query->setAllowTemplateOverwrite(false);
$form = $this->getToolbarForm($query);
if ($this->handleSearch($form, $request)) {
return $this->redirectToRoute('invoice');
}
if ($form->isValid()) {
$query->setTemplate($template);
$query->setCustomers([$customer]);
try {
$query->setCustomers([$customer]);
$model = $this->service->createModel($query);
return $this->renderInvoice($query, $request);
// save default template for customer if not yet set
if ($customer->getInvoiceTemplate() === null) {
$customer->setInvoiceTemplate($query->getTemplate());
$customerRepository->saveCustomer($customer);
}
$invoice = $this->service->createInvoice($model, $this->dispatcher);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_invoice_list', ['id' => $invoice->getId()]);
} catch (Exception $ex) {
$this->flashUpdateException($ex);
}
} else {
$this->flashFormError($form);
}
$this->flashFormError($form);
return $this->redirectToRoute('invoice');
}
/**
* @Route(path="/change-status/{id}/{status}/{token}", name="admin_invoice_status", methods={"GET", "POST"})
* @Security("is_granted('access', invoice.getCustomer())")
* @Security("is_granted('create_invoice')")
*/
#[Route(path: '/change-status/{id}/{status}/{token}', name: 'admin_invoice_status', methods: ['GET', 'POST'])]
#[Security("is_granted('access', invoice.getCustomer()) and is_granted('create_invoice')")]
public function changeStatusAction(Invoice $invoice, string $status, string $token, Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
@@ -238,6 +233,7 @@ final class InvoiceController extends AbstractController
$form->handleRequest($request);
return $this->render('invoice/invoice_edit.html.twig', [
'page_setup' => $this->createPageSetup(),
'invoice' => $invoice,
'form' => $form->createView()
]);
@@ -253,11 +249,8 @@ final class InvoiceController extends AbstractController
return $this->redirectToRoute('admin_invoice_list');
}
/**
* @Route(path="/edit/{id}", name="admin_invoice_edit", methods={"GET", "POST"})
* @Security("is_granted('access', invoice.getCustomer())")
* @Security("is_granted('create_invoice')")
*/
#[Route(path: '/edit/{id}', name: 'admin_invoice_edit', methods: ['GET', 'POST'])]
#[Security("is_granted('access', invoice.getCustomer()) and is_granted('create_invoice')")]
public function editAction(Invoice $invoice, Request $request): Response
{
$form = $this->createInvoiceEditForm($invoice);
@@ -267,24 +260,22 @@ final class InvoiceController extends AbstractController
try {
$this->invoiceRepository->saveInvoice($invoice);
$this->flashSuccess('action.update.success');
} catch (Exception $ex) {
$this->flashUpdateException($ex);
}
return $this->redirectToRoute('admin_invoice_list');
return $this->redirectToRoute('admin_invoice_list');
} catch (Exception $ex) {
$this->handleFormUpdateException($ex, $form);
}
}
return $this->render('invoice/invoice_edit.html.twig', [
'page_setup' => $this->createPageSetup(),
'invoice' => $invoice,
'form' => $form->createView()
]);
}
/**
* @Route(path="/delete/{id}/{token}", name="admin_invoice_delete", methods={"GET"})
* @Security("is_granted('access', invoice.getCustomer())")
* @Security("is_granted('delete_invoice')")
*/
#[Route(path: '/delete/{id}/{token}', name: 'admin_invoice_delete', methods: ['GET'])]
#[Security("is_granted('access', invoice.getCustomer()) and is_granted('delete_invoice')")]
public function deleteInvoiceAction(Invoice $invoice, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
@@ -305,11 +296,8 @@ final class InvoiceController extends AbstractController
return $this->redirectToRoute('admin_invoice_list');
}
/**
* @Route(path="/download/{id}", name="admin_invoice_download", methods={"GET"})
* @Security("is_granted('access', invoice.getCustomer())")
* @Security("is_granted('create_invoice')")
*/
#[Route(path: '/download/{id}', name: 'admin_invoice_download', methods: ['GET'])]
#[Security("is_granted('access', invoice.getCustomer()) and is_granted('view_invoice')")]
public function downloadAction(Invoice $invoice): Response
{
$file = $this->service->getInvoiceFile($invoice);
@@ -323,10 +311,8 @@ final class InvoiceController extends AbstractController
return $this->file($file->getRealPath(), $file->getBasename());
}
/**
* @Route(path="/show/{page}", defaults={"page": 1}, requirements={"page": "[1-9]\d*"}, name="admin_invoice_list", methods={"GET"})
* @Security("is_granted('view_invoice')")
*/
#[Route(path: '/show/{page}', defaults: ['page' => 1], requirements: ['page' => '[1-9]\d*'], name: 'admin_invoice_list', methods: ['GET'])]
#[Security("is_granted('view_invoice')")]
public function showInvoicesAction(Request $request, int $page): Response
{
$invoice = null;
@@ -344,21 +330,48 @@ final class InvoiceController extends AbstractController
return $this->redirectToRoute('admin_invoice_list');
}
$invoices = $this->invoiceRepository->getPagerfantaForQuery($query);
$entries = $this->invoiceRepository->getPagerfantaForQuery($query);
$metaColumns = $this->findMetaColumns($query);
$table = new DataTable('invoices', $query);
$table->setPagination($entries);
$table->setSearchForm($form);
$table->setPaginationRoute('admin_invoice_list');
$table->setReloadEvents('kimai.invoiceUpdate');
$table->addColumn('avatar', ['class' => 'text-nowrap w-avatar d-none d-md-table-cell', 'title' => false, 'orderBy' => false]);
$table->addColumn('date', ['class' => 'd-none d-sm-table-cell text-nowrap w-min']);
$table->addColumn('user', ['class' => 'd-none text-nowrap w-min', 'orderBy' => false]);
$table->addColumn('customer', ['class' => 'alwaysVisible text-nowrap', 'orderBy' => false]);
$table->addColumn('comment', ['class' => 'd-none', 'title' => 'description']);
foreach ($metaColumns as $metaColumn) {
$table->addColumn('mf_' . $metaColumn->getName(), ['title' => $metaColumn->getLabel(), 'class' => 'd-none', 'orderBy' => false]);
}
$table->addColumn('invoice_number', ['class' => 'd-none d-md-table-cell w-min', 'title' => 'invoice.number', 'orderBy' => false]);
$table->addColumn('due_date', ['class' => 'd-none w-min', 'title' => 'invoice.due_days', 'orderBy' => false]);
$table->addColumn('payment_date', ['class' => 'd-none w-min', 'title' => 'invoice.payment_date', 'orderBy' => false]);
$table->addColumn('status', ['class' => 'd-none d-sm-table-cell w-min', 'orderBy' => false]);
$table->addColumn('subtotal', ['class' => 'd-none text-end w-min', 'title' => 'invoice.subtotal', 'orderBy' => false]);
$table->addColumn('tax', ['class' => 'd-none text-end w-min', 'title' => 'invoice.tax']);
$table->addColumn('total_rate', ['class' => 'd-none d-md-table-cell text-end w-min']);
$table->addColumn('actions', ['class' => 'actions']);
$page = $this->createPageSetup('all_invoices');
$page->setDataTable($table);
$page->setActionName('invoice_archive');
return $this->render('invoice/listing.html.twig', [
'entries' => $invoices,
'query' => $query,
'toolbarForm' => $form->createView(),
'page_setup' => $page,
'dataTable' => $table,
'download' => $invoice,
'metaColumns' => $this->findMetaColumns($query),
'metaColumns' => $metaColumns,
]);
}
/**
* @Route(path="/export", name="invoice_export", methods={"GET"})
* @Security("is_granted('view_invoice')")
*/
#[Route(path: '/export', name: 'invoice_export', methods: ['GET'])]
#[Security("is_granted('view_invoice')")]
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter)
{
$query = new InvoiceArchiveQuery();
@@ -380,35 +393,52 @@ final class InvoiceController extends AbstractController
return $writer->getFileResponse($spreadsheet);
}
/**
* @Route(path="/template/{page}", requirements={"page": "[1-9]\d*"}, defaults={"page": 1}, name="admin_invoice_template", methods={"GET", "POST"})
* @Security("is_granted('manage_invoice_template')")
*/
#[Route(path: '/template/{page}', requirements: ['page' => '[1-9]\d*'], defaults: ['page' => 1], name: 'admin_invoice_template', methods: ['GET', 'POST'])]
#[Security("is_granted('manage_invoice_template')")]
public function listTemplateAction(int $page): Response
{
$query = new BaseQuery();
$query->setPage($page);
$templates = $this->templateRepository->getPagerfantaForQuery($query);
$entries = $this->templateRepository->getPagerfantaForQuery($query);
$table = new DataTable('invoice_template', $query);
$table->setPagination($entries);
$table->setPaginationRoute('admin_invoice_template');
$table->setReloadEvents('kimai.invoiceTemplateUpdate');
$table->addColumn('name', ['class' => 'alwaysVisible', 'orderBy' => false]);
$table->addColumn('title', ['class' => 'd-none text-nowrap', 'orderBy' => false]);
$table->addColumn('company', ['class' => 'd-none', 'orderBy' => false]);
$table->addColumn('vat_id', ['class' => 'd-none text-nowrap', 'orderBy' => false]);
$table->addColumn('tax_rate', ['class' => 'd-none text-nowrap', 'orderBy' => false]);
$table->addColumn('due_days', ['class' => 'd-none text-nowrap', 'orderBy' => false]);
$table->addColumn('address', ['class' => 'd-none', 'orderBy' => false]);
$table->addColumn('contact', ['class' => 'd-none', 'orderBy' => false]);
$table->addColumn('calculator', ['class' => 'd-none', 'orderBy' => false, 'title' => 'invoice_calculator', 'translation_domain' => 'invoice-calculator']);
$table->addColumn('renderer', ['class' => 'd-none', 'orderBy' => false, 'title' => 'invoice_renderer', 'translation_domain' => 'invoice-renderer']);
$table->addColumn('language', ['class' => 'd-none text-nowrap', 'orderBy' => false]);
$table->addColumn('actions', ['class' => 'actions', 'orderBy' => false]);
$page = $this->createPageSetup('admin_invoice_template.title');
$page->setDataTable($table);
$page->setActionName('invoice_templates');
return $this->render('invoice/templates.html.twig', [
'entries' => $templates,
'page_setup' => $page,
'dataTable' => $table,
]);
}
/**
* @Route(path="/template/{id}/edit", name="admin_invoice_template_edit", methods={"GET", "POST"})
* @Security("is_granted('manage_invoice_template')")
*/
#[Route(path: '/template/{id}/edit', name: 'admin_invoice_template_edit', methods: ['GET', 'POST'])]
#[Security("is_granted('manage_invoice_template')")]
public function editTemplateAction(InvoiceTemplate $template, Request $request): Response
{
return $this->renderTemplateForm($template, $request);
}
/**
* @Route(path="/document_upload", name="admin_invoice_document_upload", methods={"GET", "POST"})
* @Security("is_granted('upload_invoice_template')")
*/
#[Route(path: '/document_upload', name: 'admin_invoice_document_upload', methods: ['GET', 'POST'])]
#[Security("is_granted('upload_invoice_template')")]
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository)
{
$dir = $documentRepository->getUploadDirectory();
@@ -450,7 +480,7 @@ final class InvoiceController extends AbstractController
}
if (!file_exists($invoiceDir)) {
@mkdir($invoiceDir, 0777);
@mkdir($invoiceDir, 0o777);
}
if (!is_dir($invoiceDir)) {
@@ -494,7 +524,10 @@ final class InvoiceController extends AbstractController
}
}
$page = $this->createPageSetup('admin_invoice_template.title');
return $this->render('invoice/document_upload.html.twig', [
'page_setup' => $page,
'error_replacer' => ['%max%' => $event->getMaximumAllowedDocuments(), '%dir%' => $dir],
'upload_error' => $uploadError,
'can_upload' => $canUpload,
@@ -504,10 +537,8 @@ final class InvoiceController extends AbstractController
]);
}
/**
* @Route(path="/document/{id}/delete/{token}", name="invoice_document_delete", methods={"GET", "POST"})
* @Security("is_granted('manage_invoice_template')")
*/
#[Route(path: '/document/{id}/delete/{token}', name: 'invoice_document_delete', methods: ['GET', 'POST'])]
#[Security("is_granted('manage_invoice_template')")]
public function deleteDocument(string $id, string $token, CsrfTokenManagerInterface $csrfTokenManager, InvoiceDocumentRepository $documentRepository): Response
{
$document = $documentRepository->findByName($id);
@@ -549,30 +580,38 @@ final class InvoiceController extends AbstractController
return $this->redirectToRoute('admin_invoice_document_upload');
}
/**
* @Route(path="/template/create", name="admin_invoice_template_create", methods={"GET", "POST"})
* @Route(path="/template/create/{id}", name="admin_invoice_template_copy", methods={"GET", "POST"})
* @Security("is_granted('manage_invoice_template')")
*/
public function createTemplateAction(Request $request, ?InvoiceTemplate $copyFrom): Response
#[Route(path: '/template/create/{id}', name: 'admin_invoice_template_copy', methods: ['GET', 'POST'])]
#[Security("is_granted('manage_invoice_template')")]
public function copyTemplateAction(Request $request, InvoiceTemplate $copyFrom): Response
{
return $this->createTemplate($request, $copyFrom);
}
#[Route(path: '/template/create', name: 'admin_invoice_template_create', methods: ['GET', 'POST'])]
#[Security("is_granted('manage_invoice_template')")]
public function createTemplateAction(Request $request): Response
{
return $this->createTemplate($request, null);
}
private function createTemplate(Request $request, ?InvoiceTemplate $copyFrom = null): Response
{
$template = new InvoiceTemplate();
$template->setLanguage($request->getLocale());
if (null !== $copyFrom) {
$template = clone $copyFrom;
$template->setName('Copy of ' . $copyFrom->getName());
$template->setName($copyFrom->getName() . ' (1)');
}
return $this->renderTemplateForm($template, $request);
}
/**
* @Route(path="/template/{id}/delete/{token}", name="admin_invoice_template_delete", methods={"GET", "POST"})
* @Security("is_granted('manage_invoice_template')")
*/
public function deleteTemplate(InvoiceTemplate $template, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
#[Route(path: '/template/{id}/delete/{csrfToken}', name: 'admin_invoice_template_delete', methods: ['GET', 'POST'])]
#[Security("is_granted('manage_invoice_template')")]
public function deleteTemplate(InvoiceTemplate $template, string $csrfToken, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.delete_template', $token))) {
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.delete_template', $csrfToken))) {
$this->flashError('action.csrf.error');
return $this->redirectToRoute('admin_invoice_template');
@@ -599,6 +638,7 @@ final class InvoiceController extends AbstractController
$query = new InvoiceQuery();
$query->setBegin($begin);
$query->setEnd($end);
$query->setInvoiceDate($factory->createDateTime());
// limit access to data from teams
$query->setCurrentUser($this->getUser());
@@ -610,32 +650,6 @@ final class InvoiceController extends AbstractController
return $query;
}
private function renderInvoice(InvoiceQuery $query, Request $request)
{
// use the current request locale as fallback, if no translation was configured
if (null !== $query->getTemplate() && null === $query->getTemplate()->getLanguage()) {
$query->getTemplate()->setLanguage($request->getLocale());
}
try {
$invoices = $this->service->createInvoices($query, $this->dispatcher);
$this->flashSuccess('action.update.success');
if (\count($invoices) === 1) {
return $this->redirectToRoute('admin_invoice_list', ['id' => $invoices[0]->getId()]);
} elseif (\count($invoices) > 1) {
$this->dispatcher->dispatch(new InvoiceCreatedMultipleEvent($invoices));
}
return $this->redirectToRoute('admin_invoice_list');
} catch (Exception $ex) {
$this->flashUpdateException($ex);
}
return $this->redirectToRoute('invoice');
}
private function flashFormError(FormInterface $form): void
{
$err = '';
@@ -643,7 +657,7 @@ final class InvoiceController extends AbstractController
$err .= PHP_EOL . '[' . $error->getOrigin()->getName() . '] ' . $error->getMessage();
}
$this->flashError('action.update.error', ['%reason%' => $err]);
$this->flashError('action.update.error', $err);
}
private function renderTemplateForm(InvoiceTemplate $template, Request $request): Response
@@ -659,25 +673,24 @@ final class InvoiceController extends AbstractController
return $this->redirectToRoute('admin_invoice_template');
} catch (Exception $ex) {
$this->flashUpdateException($ex);
$this->handleFormUpdateException($ex, $editForm);
}
}
$page = $this->createPageSetup('admin_invoice_template.title');
return $this->render('invoice/template_edit.html.twig', [
'page_setup' => $page,
'template' => $template,
'form' => $editForm->createView()
]);
}
private function getToolbarForm(InvoiceQuery $query, bool $simple): FormInterface
private function getToolbarForm(InvoiceQuery $query): FormInterface
{
$form = $simple ? InvoiceToolbarSimpleForm::class : InvoiceToolbarForm::class;
return $this->createForm($form, $query, [
return $this->createSearchForm(InvoiceToolbarForm::class, $query, [
'action' => $this->generateUrl('invoice', []),
'method' => 'GET',
'include_user' => $this->isGranted('view_other_timesheet'),
'include_export' => $this->isGranted('edit_export_other_timesheet'),
'timezone' => $this->getDateTimeFactory()->getTimezone()->getName(),
'attr' => [
'id' => 'invoice-print-form'
@@ -687,9 +700,8 @@ final class InvoiceController extends AbstractController
private function getArchiveToolbarForm(InvoiceArchiveQuery $query): FormInterface
{
return $this->createForm(InvoiceArchiveForm::class, $query, [
return $this->createSearchForm(InvoiceArchiveForm::class, $query, [
'action' => $this->generateUrl('admin_invoice_list', []),
'method' => 'GET',
'timezone' => $this->getDateTimeFactory()->getTimezone()->getName(),
'attr' => [
'id' => 'invoice-archive-form'
@@ -734,4 +746,12 @@ final class InvoiceController extends AbstractController
'timezone' => $this->getDateTimeFactory()->getTimezone()->getName(),
]);
}
private function createPageSetup(string $title = 'invoices'): PageSetup
{
$page = new PageSetup($title);
$page->setHelp('invoices.html');
return $page;
}
}

View File

@@ -21,6 +21,7 @@ use App\Repository\RoleRepository;
use App\Repository\UserRepository;
use App\Security\RolePermissionManager;
use App\Security\RoleService;
use App\Utils\PageSetup;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
@@ -31,38 +32,20 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
/**
* Controller used to manage user roles and role permissions.
*
* @Route(path="/admin/permissions")
* @Security("is_granted('role_permissions')")
*/
#[Route(path: '/admin/permissions')]
#[Security("is_granted('IS_AUTHENTICATED_FULLY') and is_granted('role_permissions')")]
final class PermissionController extends AbstractController
{
public const TOKEN_NAME = 'user_role_permissions';
/**
* @var RoleService
*/
private $roleService;
/**
* @var RolePermissionManager
*/
private $manager;
/**
* @var RoleRepository
*/
private $roleRepository;
public function __construct(RoleService $roleService, RolePermissionManager $manager, RoleRepository $roleRepository)
public function __construct(private RolePermissionManager $manager, private RoleRepository $roleRepository)
{
$this->roleService = $roleService;
$this->manager = $manager;
$this->roleRepository = $roleRepository;
}
/**
* @Route(path="", name="admin_user_permissions", methods={"GET", "POST"})
* @Security("is_granted('role_permissions')")
*/
public function permissions(EventDispatcherInterface $dispatcher, CsrfTokenManagerInterface $csrfTokenManager)
#[Route(path: '', name: 'admin_user_permissions', methods: ['GET', 'POST'])]
#[Security("is_granted('role_permissions')")]
public function permissions(EventDispatcherInterface $dispatcher, CsrfTokenManagerInterface $csrfTokenManager, RoleService $roleService)
{
$all = $this->roleRepository->findAll();
$existing = [];
@@ -74,8 +57,7 @@ final class PermissionController extends AbstractController
$existing = array_map('strtoupper', $existing);
// automatically import all hard coded (default) roles into the database table
foreach ($this->roleService->getAvailableNames() as $roleName) {
$roleName = strtoupper($roleName);
foreach ($roleService->getAvailableNames() as $roleName) {
if (!\in_array($roleName, $existing)) {
$role = new Role();
$role->setName($roleName);
@@ -152,6 +134,9 @@ final class PermissionController extends AbstractController
foreach ($all as $role) {
$roles[$role->getName()] = $role;
}
$default = $roles['ROLE_USER'];
unset($roles['ROLE_USER']);
$roles['ROLE_USER'] = $default;
$event = new PermissionsEvent();
foreach ($permissionSorted as $title => $permissions) {
@@ -160,20 +145,23 @@ final class PermissionController extends AbstractController
$dispatcher->dispatch($event);
$page = new PageSetup('profile.roles');
$page->setHelp('permissions.html');
$page->setActionName('user_permissions');
return $this->render('permission/permissions.html.twig', [
'page_setup' => $page,
'token' => $csrfTokenManager->refreshToken(self::TOKEN_NAME)->getValue(),
'roles' => array_values($roles),
'sorted' => $event->getPermissions(),
'manager' => $this->manager,
'system_roles' => $this->roleService->getSystemRoles(),
'always_apply_superadmin' => RolePermissionManager::SUPER_ADMIN_PERMISSIONS,
'system_roles' => $roleService->getSystemRoles(),
'always_apply_superadmin' => array_keys(RolePermissionManager::SUPER_ADMIN_PERMISSIONS),
]);
}
/**
* @Route(path="/roles/create", name="admin_user_roles", methods={"GET", "POST"})
* @Security("is_granted('role_permissions')")
*/
#[Route(path: '/roles/create', name: 'admin_user_roles', methods: ['GET', 'POST'])]
#[Security("is_granted('role_permissions')")]
public function createRole(Request $request): Response
{
$role = new Role();
@@ -196,16 +184,18 @@ final class PermissionController extends AbstractController
return $this->redirectToRoute('admin_user_permissions');
}
$page = new PageSetup('profile.roles');
$page->setHelp('permissions.html');
return $this->render('permission/edit_role.html.twig', [
'page_setup' => $page,
'form' => $form->createView(),
'role' => $role,
]);
}
/**
* @Route(path="/roles/{id}/delete/{csrfToken}", name="admin_user_role_delete", methods={"GET", "POST"})
* @Security("is_granted('role_permissions')")
*/
#[Route(path: '/roles/{id}/delete/{csrfToken}', name: 'admin_user_role_delete', methods: ['GET', 'POST'])]
#[Security("is_granted('role_permissions')")]
public function deleteRole(Role $role, string $csrfToken, UserRepository $userRepository, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$this->isCsrfTokenValid(self::TOKEN_NAME, $csrfToken)) {
@@ -234,10 +224,8 @@ final class PermissionController extends AbstractController
return $this->redirectToRoute('admin_user_permissions');
}
/**
* @Route(path="/roles/{id}/{name}/{value}/{csrfToken}", name="admin_user_permission_save", methods={"POST"})
* @Security("is_granted('role_permissions')")
*/
#[Route(path: '/roles/{id}/{name}/{value}/{csrfToken}', name: 'admin_user_permission_save', methods: ['POST'])]
#[Security("is_granted('role_permissions')")]
public function savePermission(Role $role, string $name, bool $value, string $csrfToken, RolePermissionRepository $rolePermissionRepository, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$this->isCsrfTokenValid(self::TOKEN_NAME, $csrfToken)) {
@@ -248,8 +236,8 @@ final class PermissionController extends AbstractController
throw $this->createNotFoundException('Unknown permission: ' . $name);
}
if (false === $value && $role->getName() === User::ROLE_SUPER_ADMIN && \in_array($name, RolePermissionManager::SUPER_ADMIN_PERMISSIONS)) {
throw $this->createAccessDeniedException(sprintf('Permission "%s" cannot be deactivated for role "%s"', $name, $role->getName()));
if (false === $value && $role->getName() === User::ROLE_SUPER_ADMIN && \array_key_exists($name, RolePermissionManager::SUPER_ADMIN_PERMISSIONS)) {
throw new BadRequestHttpException(sprintf('Permission "%s" cannot be deactivated for role "%s"', $name, $role->getName()));
}
try {
@@ -259,7 +247,7 @@ final class PermissionController extends AbstractController
$permission->setRole($role);
$permission->setPermission($name);
}
$permission->setAllowed((bool) $value);
$permission->setAllowed($value);
$rolePermissionRepository->saveRolePermission($permission);

View File

@@ -10,42 +10,57 @@
namespace App\Controller;
use App\Plugin\PluginManager;
use App\Utils\PageSetup;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* @Route(path="/admin/plugins")
* @Security("is_granted('plugins')")
*/
class PluginController extends AbstractController
#[Route(path: '/admin/plugins')]
#[Security("is_granted('plugins')")]
final class PluginController extends AbstractController
{
/**
* @var PluginManager
*/
protected $plugins;
/**
* @param PluginManager $manager
*/
public function __construct(PluginManager $manager)
#[Route(path: '/', name: 'plugins', methods: ['GET'])]
public function indexAction(PluginManager $manager, HttpClientInterface $client, CacheInterface $cache): Response
{
$this->plugins = $manager;
}
/**
* @Route(path="/", name="plugins", methods={"GET"})
*
* @return \Symfony\Component\HttpFoundation\Response
*/
public function indexAction()
{
$plugins = $this->plugins->getPlugins();
foreach ($this->plugins->getPlugins() as $plugin) {
$this->plugins->loadMetadata($plugin);
$installed = [];
$plugins = $manager->getPlugins();
foreach ($plugins as $plugin) {
$manager->loadMetadata($plugin);
$installed[] = $plugin->getId();
}
$page = new PageSetup('menu.plugin');
$page->setHelp('plugins.html');
return $this->render('plugin/index.html.twig', [
'page_setup' => $page,
'plugins' => $plugins,
'installed' => $installed,
'extensions' => $this->getPluginInformation($client, $cache)
]);
}
private function getPluginInformation(HttpClientInterface $client, CacheInterface $cache): array
{
return $cache->get('kimai.marketplace_extensions', function (ItemInterface $item) use ($client) {
$response = $client->request('GET', 'https://www.kimai.org/plugins.json');
if ($response->getStatusCode() !== 200) {
return [];
}
$json = json_decode($response->getContent(), true);
if ($json === null) {
return [];
}
$item->expiresAfter(86400); // one day
return $response->toArray();
});
}
}

View File

@@ -12,18 +12,26 @@ namespace App\Controller;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Event\PrepareUserEvent;
use App\Form\Model\TotpActivation;
use App\Form\UserApiTokenType;
use App\Form\UserEditType;
use App\Form\UserPasswordType;
use App\Form\UserPreferencesForm;
use App\Form\UserRolesType;
use App\Form\UserTeamsType;
use App\Form\UserTwoFactorType;
use App\Repository\TeamRepository;
use App\Repository\TimesheetRepository;
use App\Repository\UserRepository;
use App\Timesheet\TimesheetStatisticService;
use App\User\UserService;
use Doctrine\Common\Collections\ArrayCollection;
use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\Encoding\Encoding;
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh;
use Endroid\QrCode\RoundBlockSizeMode\RoundBlockSizeModeMargin;
use Endroid\QrCode\Writer\PngWriter;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormInterface;
@@ -33,28 +41,23 @@ use Symfony\Component\Routing\Annotation\Route;
/**
* User profile controller
*
* @Route(path="/profile")
* @Security("is_granted('view_own_profile') or is_granted('view_other_profile')")
*/
#[Route(path: '/profile')]
#[Security("(is_granted('view_own_profile') or is_granted('view_other_profile'))")]
final class ProfileController extends AbstractController
{
/**
* @Route(path="/", name="my_profile", methods={"GET"})
*/
#[Route(path: '/', name: 'my_profile', methods: ['GET'])]
public function profileAction(): Response
{
return $this->redirectToRoute('user_profile', ['username' => $this->getUser()->getUsername()]);
return $this->redirectToRoute('user_profile', ['username' => $this->getUser()->getUserIdentifier()]);
}
/**
* @Route(path="/{username}", name="user_profile", methods={"GET"})
* @Security("is_granted('view', profile)")
*/
#[Route(path: '/{username}', name: 'user_profile', methods: ['GET'])]
#[Security("is_granted('view', profile)")]
public function indexAction(User $profile, TimesheetRepository $repository, TimesheetStatisticService $statisticService): Response
{
$dateFactory = $this->getDateTimeFactory();
$userStats = $repository->getUserStatistics($profile, false);
$userStats = $repository->getUserStatistics($profile);
$firstEntry = $statisticService->findFirstRecordDate($profile);
$begin = $firstEntry ?? $dateFactory->getStartOfMonth();
@@ -75,10 +78,8 @@ final class ProfileController extends AbstractController
return $this->render('user/stats.html.twig', $viewVars);
}
/**
* @Route(path="/{username}/edit", name="user_profile_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', profile)")
*/
#[Route(path: '/{username}/edit', name: 'user_profile_edit', methods: ['GET', 'POST'])]
#[Security("is_granted('edit', profile)")]
public function editAction(User $profile, Request $request, UserRepository $userRepository): Response
{
$form = $this->createEditForm($profile);
@@ -89,20 +90,18 @@ final class ProfileController extends AbstractController
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('user_profile_edit', ['username' => $profile->getUsername()]);
return $this->redirectToRoute('user_profile_edit', ['username' => $profile->getUserIdentifier()]);
}
return $this->render('user/profile.html.twig', [
'tab' => 'settings',
'tab' => 'edit',
'user' => $profile,
'form' => $form->createView(),
]);
}
/**
* @Route(path="/{username}/password", name="user_profile_password", methods={"GET", "POST"})
* @Security("is_granted('password', profile)")
*/
#[Route(path: '/{username}/password', name: 'user_profile_password', methods: ['GET', 'POST'])]
#[Security("is_granted('IS_AUTHENTICATED_FULLY') and is_granted('password', profile)")]
public function passwordAction(User $profile, Request $request, UserService $userService): Response
{
$form = $this->createPasswordForm($profile);
@@ -113,7 +112,7 @@ final class ProfileController extends AbstractController
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('user_profile_password', ['username' => $profile->getUsername()]);
return $this->redirectToRoute('user_profile_password', ['username' => $profile->getUserIdentifier()]);
}
return $this->render('user/form.html.twig', [
@@ -123,10 +122,8 @@ final class ProfileController extends AbstractController
]);
}
/**
* @Route(path="/{username}/api-token", name="user_profile_api_token", methods={"GET", "POST"})
* @Security("is_granted('api-token', profile)")
*/
#[Route(path: '/{username}/api-token', name: 'user_profile_api_token', methods: ['GET', 'POST'])]
#[Security("is_granted('IS_AUTHENTICATED_FULLY') and is_granted('api-token', profile)")]
public function apiTokenAction(User $profile, Request $request, UserService $userService): Response
{
$form = $this->createApiTokenForm($profile);
@@ -137,7 +134,7 @@ final class ProfileController extends AbstractController
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('user_profile_api_token', ['username' => $profile->getUsername()]);
return $this->redirectToRoute('user_profile_api_token', ['username' => $profile->getUserIdentifier()]);
}
return $this->render('user/api-token.html.twig', [
@@ -147,10 +144,8 @@ final class ProfileController extends AbstractController
]);
}
/**
* @Route(path="/{username}/roles", name="user_profile_roles", methods={"GET", "POST"})
* @Security("is_granted('roles', profile)")
*/
#[Route(path: '/{username}/roles', name: 'user_profile_roles', methods: ['GET', 'POST'])]
#[Security("is_granted('IS_AUTHENTICATED_FULLY') and is_granted('roles', profile)")]
public function rolesAction(User $profile, Request $request, UserRepository $userRepository): Response
{
$isSuperAdmin = $profile->isSuperAdmin();
@@ -169,7 +164,7 @@ final class ProfileController extends AbstractController
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('user_profile_roles', ['username' => $profile->getUsername()]);
return $this->redirectToRoute('user_profile_roles', ['username' => $profile->getUserIdentifier()]);
}
return $this->render('user/form.html.twig', [
@@ -179,10 +174,8 @@ final class ProfileController extends AbstractController
]);
}
/**
* @Route(path="/{username}/teams", name="user_profile_teams", methods={"GET", "POST"})
* @Security("is_granted('teams', profile)")
*/
#[Route(path: '/{username}/teams', name: 'user_profile_teams', methods: ['GET', 'POST'])]
#[Security("is_granted('teams', profile)")]
public function teamsAction(User $profile, Request $request, UserRepository $userRepository, TeamRepository $teamRepository): Response
{
$originalMembers = new ArrayCollection();
@@ -206,7 +199,7 @@ final class ProfileController extends AbstractController
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('user_profile_teams', ['username' => $profile->getUsername()]);
return $this->redirectToRoute('user_profile_teams', ['username' => $profile->getUserIdentifier()]);
}
return $this->render('user/form.html.twig', [
@@ -216,62 +209,32 @@ final class ProfileController extends AbstractController
]);
}
/**
* @Route(path="/{username}/prefs", name="user_profile_preferences", methods={"GET", "POST"})
* @Security("is_granted('preferences', profile)")
*/
public function preferencesAction(User $profile, Request $request, EventDispatcherInterface $dispatcher): Response
#[Route(path: '/{username}/prefs', name: 'user_profile_preferences', methods: ['GET', 'POST'])]
#[Security("is_granted('preferences', profile)")]
public function preferencesAction(User $profile, Request $request, EventDispatcherInterface $dispatcher, UserRepository $userRepository): Response
{
// we need to prepare the user preferences, which is done via an EventSubscriber
$event = new PrepareUserEvent($profile);
$dispatcher->dispatch($event);
$original = [];
foreach ($profile->getPreferences() as $preference) {
$original[$preference->getName()] = $preference;
}
$form = $this->createPreferencesForm($profile);
$form->handleRequest($request);
if ($form->isSubmitted()) {
if ($form->isValid()) {
$entityManager = $this->getDoctrine()->getManager();
$preferences = $profile->getPreferences();
if ($form->isSubmitted() && $form->isValid()) {
$userRepository->saveUser($profile);
// do not allow to add unknown preferences
foreach ($preferences as $preference) {
if (!isset($original[$preference->getName()])) {
$preferences->removeElement($preference);
}
}
$this->flashSuccess('action.update.success');
// but allow to delete already saved settings
foreach ($original as $name => $preference) {
if (false === $profile->getPreferences()->contains($preference)) {
$entityManager->remove($preference);
}
}
$profile->setPreferences($preferences);
$entityManager->persist($profile);
$entityManager->flush();
$this->flashSuccess('action.update.success');
// switch locale ONLY if updated profile is the current user
$locale = $request->getLocale();
if ($this->getUser()->getId() === $profile->getId()) {
$locale = $profile->getPreferenceValue('language', $locale, false);
}
return $this->redirectToRoute('user_profile_preferences', [
'_locale' => $locale,
'username' => $profile->getUsername()
]);
} else {
$this->flashError('action.update.error', ['%reason%' => 'Validation failed']);
// switch locale ONLY if updated profile is the current user
$locale = $request->getLocale();
if ($this->getUser()->getId() === $profile->getId()) {
$locale = $profile->getPreferenceValue('language', $locale, false);
}
return $this->redirectToRoute('user_profile_preferences', [
'_locale' => $locale,
'username' => $profile->getUserIdentifier()
]);
}
// prepare ordered preferences
@@ -304,7 +267,7 @@ final class ProfileController extends AbstractController
UserPreferencesForm::class,
$user,
[
'action' => $this->generateUrl('user_profile_preferences', ['username' => $user->getUsername()]),
'action' => $this->generateUrl('user_profile_preferences', ['username' => $user->getUserIdentifier()]),
'method' => 'POST'
]
);
@@ -316,7 +279,7 @@ final class ProfileController extends AbstractController
UserEditType::class,
$user,
[
'action' => $this->generateUrl('user_profile_edit', ['username' => $user->getUsername()]),
'action' => $this->generateUrl('user_profile_edit', ['username' => $user->getUserIdentifier()]),
'method' => 'POST',
'include_active_flag' => ($user->getId() !== $this->getUser()->getId()),
'include_preferences' => false,
@@ -330,7 +293,7 @@ final class ProfileController extends AbstractController
UserRolesType::class,
$user,
[
'action' => $this->generateUrl('user_profile_roles', ['username' => $user->getUsername()]),
'action' => $this->generateUrl('user_profile_roles', ['username' => $user->getUserIdentifier()]),
'method' => 'POST',
]
);
@@ -342,7 +305,7 @@ final class ProfileController extends AbstractController
UserTeamsType::class,
$user,
[
'action' => $this->generateUrl('user_profile_teams', ['username' => $user->getUsername()]),
'action' => $this->generateUrl('user_profile_teams', ['username' => $user->getUserIdentifier()]),
'method' => 'POST',
]
);
@@ -354,7 +317,7 @@ final class ProfileController extends AbstractController
UserPasswordType::class,
$user,
[
'action' => $this->generateUrl('user_profile_password', ['username' => $user->getUsername()]),
'action' => $this->generateUrl('user_profile_password', ['username' => $user->getUserIdentifier()]),
'method' => 'POST'
]
);
@@ -366,9 +329,98 @@ final class ProfileController extends AbstractController
UserApiTokenType::class,
$user,
[
'action' => $this->generateUrl('user_profile_api_token', ['username' => $user->getUsername()]),
'action' => $this->generateUrl('user_profile_api_token', ['username' => $user->getUserIdentifier()]),
'method' => 'POST'
]
);
}
#[Route(path: '/{username}/2fa', name: 'user_profile_2fa', methods: ['GET', 'POST'])]
#[Security("is_granted('IS_AUTHENTICATED_FULLY') and is_granted('2fa', profile)")]
public function twoFactorAction(User $profile, Request $request, UserService $userService, TotpAuthenticatorInterface $totpAuthenticator): Response
{
if (!$profile->hasTotpSecret()) {
$profile->setTotpSecret($totpAuthenticator->generateSecret());
$userService->updateUser($profile);
}
$data = new TotpActivation($profile);
$form = $this->createForm(UserTwoFactorType::class, $data, [
'action' => $this->generateUrl('user_profile_2fa', ['username' => $profile->getUserIdentifier()]),
'method' => 'POST'
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$profile->enableTotpAuthentication();
$userService->updateUser($profile);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('user_profile_2fa', ['username' => $profile->getUserIdentifier()]);
}
return $this->render('user/2fa.html.twig', [
'tab' => '2fa',
'user' => $profile,
'form' => $form->createView(),
'deactivate' => $this->getTwoFactorDeactivationForm($profile)->createView(),
]);
}
private function getTwoFactorDeactivationForm(User $user): FormInterface
{
return $this->createFormBuilder(
[],
[
'action' => $this->generateUrl('user_profile_2fa_deactivate', ['username' => $user->getUserIdentifier()]),
'method' => 'POST'
]
)->getForm();
}
#[Route(path: '/{username}/2fa_deactivate', name: 'user_profile_2fa_deactivate', methods: ['POST'])]
#[Security("is_granted('IS_AUTHENTICATED_FULLY') and is_granted('2fa', profile)")]
public function deactivateTwoFactorAction(User $profile, Request $request, UserService $userService, TotpAuthenticatorInterface $totpAuthenticator): Response
{
if ($profile->hasTotpSecret()) {
$form = $this->getTwoFactorDeactivationForm($profile);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$profile->disableTotpAuthentication();
$userService->updateUser($profile);
$this->flashSuccess('action.update.success');
}
}
return $this->redirectToRoute('user_profile_2fa', ['username' => $profile->getUserIdentifier()]);
}
#[Route(path: '/{username}/totp.png', name: 'user_profile_2fa_image', methods: ['GET'])]
#[Security("is_granted('2fa', profile)")]
public function displayTotpQrCode(User $profile, TotpAuthenticatorInterface $totpAuthenticator): Response
{
if (!$profile->hasTotpSecret()) {
throw $this->createNotFoundException('User has no TOTP secret.');
}
$qrCodeContent = $totpAuthenticator->getQRContent($profile);
$result = Builder::create()
->writer(new PngWriter())
->writerOptions([])
->data($qrCodeContent)
->encoding(new Encoding('UTF-8'))
->errorCorrectionLevel(new ErrorCorrectionLevelHigh())
->size(200)
->margin(0)
->roundBlockSizeMode(new RoundBlockSizeModeMargin())
->build();
return new Response($result->getString(), 200, ['Content-Type' => 'image/png']);
}
}

View File

@@ -15,7 +15,6 @@ use App\Entity\MetaTableTypeInterface;
use App\Entity\Project;
use App\Entity\ProjectComment;
use App\Entity\ProjectRate;
use App\Entity\Rate;
use App\Entity\Team;
use App\Event\ProjectDetailControllerEvent;
use App\Event\ProjectMetaDefinitionEvent;
@@ -39,52 +38,30 @@ use App\Repository\Query\ActivityQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\TeamRepository;
use App\Utils\Context;
use Pagerfanta\Pagerfanta;
use App\Utils\DataTable;
use App\Utils\PageSetup;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
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\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
/**
* Controller used to manage projects.
*
* @Route(path="/admin/project")
* @Security("is_granted('view_project') or is_granted('view_teamlead_project') or is_granted('view_team_project')")
*/
#[Route(path: '/admin/project')]
#[Security("is_granted('view_project') or is_granted('view_teamlead_project') or is_granted('view_team_project')")]
final class ProjectController extends AbstractController
{
/**
* @var ProjectRepository
*/
private $repository;
/**
* @var SystemConfiguration
*/
private $configuration;
/**
* @var EventDispatcherInterface
*/
private $dispatcher;
/**
* @var ProjectService
*/
private $projectService;
public function __construct(ProjectRepository $repository, SystemConfiguration $configuration, EventDispatcherInterface $dispatcher, ProjectService $projectService)
public function __construct(private ProjectRepository $repository, private SystemConfiguration $configuration, private EventDispatcherInterface $dispatcher, private ProjectService $projectService)
{
$this->repository = $repository;
$this->configuration = $configuration;
$this->dispatcher = $dispatcher;
$this->projectService = $projectService;
}
/**
* @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"})
*/
#[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'])]
public function indexAction($page, Request $request)
{
$query = new ProjectQuery();
@@ -97,12 +74,47 @@ final class ProjectController extends AbstractController
}
$entries = $this->repository->getPagerfantaForQuery($query);
$metaColumns = $this->findMetaColumns($query);
$table = new DataTable('project_admin', $query);
$table->setPagination($entries);
$table->setSearchForm($form);
$table->setPaginationRoute('admin_project_paginated');
$table->setReloadEvents('kimai.projectUpdate kimai.projectDelete kimai.projectTeamUpdate');
$table->addColumn('name', ['class' => 'alwaysVisible']);
$table->addColumn('customer', ['class' => 'd-none']);
$table->addColumn('comment', ['class' => 'd-none', 'title' => 'description']);
$table->addColumn('orderNumber', ['class' => 'd-none']);
$table->addColumn('orderDate', ['class' => 'd-none']);
$table->addColumn('project_start', ['class' => 'd-none']);
$table->addColumn('project_end', ['class' => 'd-none']);
foreach ($metaColumns as $metaColumn) {
$table->addColumn('mf_' . $metaColumn->getName(), ['title' => $metaColumn->getLabel(), 'class' => 'd-none', 'orderBy' => false, 'data' => $metaColumn]);
}
if ($this->isGranted('budget_money', 'project')) {
$table->addColumn('budget', ['class' => 'd-none text-end w-min', 'title' => 'budget']);
}
if ($this->isGranted('budget_time', 'project')) {
$table->addColumn('timeBudget', ['class' => 'd-none text-end w-min', 'title' => 'timeBudget']);
}
$table->addColumn('billable', ['class' => 'd-none text-center w-min', 'orderBy' => false]);
$table->addColumn('team', ['class' => 'text-center w-min', 'orderBy' => false]);
$table->addColumn('visible', ['class' => 'd-none text-center w-min']);
$table->addColumn('actions', ['class' => 'actions']);
$page = $this->createPageSetup();
$page->setDataTable($table);
$page->setActionName('projects');
return $this->render('project/index.html.twig', [
'entries' => $entries,
'query' => $query,
'toolbarForm' => $form->createView(),
'metaColumns' => $this->findMetaColumns($query),
'page_setup' => $page,
'dataTable' => $table,
'metaColumns' => $metaColumns,
'now' => $this->getDateTimeFactory()->createDateTime(),
]);
}
@@ -111,7 +123,7 @@ final class ProjectController extends AbstractController
* @param ProjectQuery $query
* @return MetaTableTypeInterface[]
*/
protected function findMetaColumns(ProjectQuery $query): array
private function findMetaColumns(ProjectQuery $query): array
{
$event = new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::PROJECT);
$this->dispatcher->dispatch($event);
@@ -119,10 +131,8 @@ final class ProjectController extends AbstractController
return $event->getFields();
}
/**
* @Route(path="/{id}/permissions", name="admin_project_permissions", methods={"GET", "POST"})
* @Security("is_granted('permissions', project)")
*/
#[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, [
@@ -148,17 +158,27 @@ final class ProjectController extends AbstractController
}
return $this->render('project/permissions.html.twig', [
'page_setup' => $this->createPageSetup(),
'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')")
*/
public function createAction(Request $request, ?Customer $customer = null)
#[Route(path: '/create/{customer}', name: 'admin_project_create_with_customer', methods: ['GET', 'POST'])]
#[Security("is_granted('create_project')")]
public function createWithCustomerAction(Request $request, Customer $customer)
{
return $this->createProject($request, $customer);
}
#[Route(path: '/create', name: 'admin_project_create', methods: ['GET', 'POST'])]
#[Security("is_granted('create_project')")]
public function createAction(Request $request)
{
return $this->createProject($request, null);
}
private function createProject(Request $request, ?Customer $customer = null)
{
$project = $this->projectService->createNewProject($customer);
@@ -170,22 +190,21 @@ final class ProjectController extends AbstractController
$this->projectService->saveNewProject($project, new Context($this->getUser()));
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
return $this->redirectToRouteAfterCreate('project_details', ['id' => $project->getId()]);
} catch (\Exception $ex) {
$this->flashUpdateException($ex);
$this->handleFormUpdateException($ex, $editForm);
}
}
return $this->render('project/edit.html.twig', [
'page_setup' => $this->createPageSetup(),
'project' => $project,
'form' => $editForm->createView()
]);
}
/**
* @Route(path="/{id}/comment_delete/{token}", name="project_comment_delete", methods={"GET"})
* @Security("is_granted('edit', comment.getProject()) and is_granted('comments', comment.getProject())")
*/
#[Route(path: '/{id}/comment_delete/{token}', name: 'project_comment_delete', methods: ['GET'])]
#[Security("is_granted('edit', comment.getProject()) and is_granted('comments', comment.getProject())")]
public function deleteCommentAction(ProjectComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager)
{
$projectId = $comment->getProject()->getId();
@@ -207,14 +226,12 @@ final class ProjectController extends AbstractController
return $this->redirectToRoute('project_details', ['id' => $projectId]);
}
/**
* @Route(path="/{id}/comment_add", name="project_comment_add", methods={"POST"})
* @Security("is_granted('comments_create', project)")
*/
#[Route(path: '/{id}/comment_add', name: 'project_comment_add', methods: ['POST'])]
#[Security("is_granted('comments', project)")]
public function addCommentAction(Project $project, Request $request)
{
$comment = new ProjectComment();
$form = $this->getCommentForm($project, $comment);
$comment = new ProjectComment($project);
$form = $this->getCommentForm($comment);
$form->handleRequest($request);
@@ -229,10 +246,8 @@ final class ProjectController extends AbstractController
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
}
/**
* @Route(path="/{id}/comment_pin/{token}", name="project_comment_pin", methods={"GET"})
* @Security("is_granted('edit', comment.getProject()) and is_granted('comments', comment.getProject())")
*/
#[Route(path: '/{id}/comment_pin/{token}', name: 'project_comment_pin', methods: ['GET'])]
#[Security("is_granted('edit', comment.getProject()) and is_granted('comments', comment.getProject())")]
public function pinCommentAction(ProjectComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager)
{
$projectId = $comment->getProject()->getId();
@@ -255,21 +270,18 @@ final class ProjectController extends AbstractController
return $this->redirectToRoute('project_details', ['id' => $projectId]);
}
/**
* @Route(path="/{id}/create_team", name="project_team_create", methods={"GET"})
* @Security("is_granted('create_team') and is_granted('edit', project)")
*/
#[Route(path: '/{id}/create_team', name: 'project_team_create', methods: ['GET'])]
#[Security("is_granted('create_team') and is_granted('permissions', project)")]
public function createDefaultTeamAction(Project $project, TeamRepository $teamRepository)
{
$defaultTeam = $teamRepository->findOneBy(['name' => $project->getName()]);
if (null !== $defaultTeam) {
$this->flashError('action.update.error', ['%reason%' => 'Team already existing']);
$this->flashError('action.update.error', 'Team already existing');
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
}
$defaultTeam = new Team();
$defaultTeam->setName($project->getName());
$defaultTeam = new Team($project->getName());
$defaultTeam->addTeamlead($this->getUser());
$defaultTeam->addProject($project);
@@ -282,10 +294,8 @@ final class ProjectController extends AbstractController
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
}
/**
* @Route(path="/{id}/activities/{page}", defaults={"page": 1}, name="project_activities", methods={"GET", "POST"})
* @Security("is_granted('view', project)")
*/
#[Route(path: '/{id}/activities/{page}', defaults: ['page' => 1], name: 'project_activities', methods: ['GET', 'POST'])]
#[Security("is_granted('view', project)")]
public function activitiesAction(Project $project, int $page, ActivityRepository $activityRepository)
{
$query = new ActivityQuery();
@@ -298,7 +308,6 @@ final class ProjectController extends AbstractController
$query->addOrderGroup('visible', ActivityQuery::ORDER_DESC);
$query->addOrderGroup('name', ActivityQuery::ORDER_ASC);
/* @var $entries Pagerfanta */
$entries = $activityRepository->getPagerfantaForQuery($query);
return $this->render('project/embed_activities.html.twig', [
@@ -309,11 +318,9 @@ final class ProjectController extends AbstractController
]);
}
/**
* @Route(path="/{id}/details", name="project_details", methods={"GET", "POST"})
* @Security("is_granted('view', project)")
*/
public function detailsAction(Project $project, TeamRepository $teamRepository, ProjectRateRepository $rateRepository, ProjectStatisticService $statisticService)
#[Route(path: '/{id}/details', name: 'project_details', methods: ['GET', 'POST'])]
#[Security("is_granted('view', project)")]
public function detailsAction(Project $project, TeamRepository $teamRepository, ProjectRateRepository $rateRepository, ProjectStatisticService $statisticService, CsrfTokenManagerInterface $csrfTokenManager)
{
$event = new ProjectMetaDefinitionEvent($project);
$this->dispatcher->dispatch($event);
@@ -340,10 +347,7 @@ final class ProjectController extends AbstractController
if ($this->isGranted('comments', $project)) {
$comments = $this->repository->getComments($project);
}
if ($this->isGranted('comments_create', $project)) {
$commentForm = $this->getCommentForm($project, new ProjectComment())->createView();
$commentForm = $this->getCommentForm(new ProjectComment($project))->createView();
}
if ($this->isGranted('permissions', $project) || $this->isGranted('details', $project) || $this->isGranted('view_team')) {
@@ -355,7 +359,13 @@ final class ProjectController extends AbstractController
$this->dispatcher->dispatch($event);
$boxes = $event->getController();
$page = $this->createPageSetup();
$page->setActionName('project');
$page->setActionView('project_details');
$page->setActionPayload(['project' => $project, 'token' => $csrfTokenManager->getToken('project.duplicate')]);
return $this->render('project/details.html.twig', [
'page_setup' => $page,
'project' => $project,
'comments' => $comments,
'commentForm' => $commentForm,
@@ -369,17 +379,27 @@ final class ProjectController extends AbstractController
]);
}
/**
* @Route(path="/{id}/rate", name="admin_project_rate_add", methods={"GET", "POST"})
* @Security("is_granted('edit', project)")
*/
public function addRateAction(Project $project, Request $request, ProjectRateRepository $repository)
#[Route(path: '/{id}/rate/{rate}', name: 'admin_project_rate_edit', methods: ['GET', 'POST'])]
#[Security("is_granted('edit', project)")]
public function editRateAction(Project $project, ProjectRate $rate, Request $request, ProjectRateRepository $repository): Response
{
return $this->rateFormAction($project, $rate, $request, $repository, $this->generateUrl('admin_project_rate_edit', ['id' => $project->getId(), 'rate' => $rate->getId()]));
}
#[Route(path: '/{id}/rate', name: 'admin_project_rate_add', methods: ['GET', 'POST'])]
#[Security("is_granted('edit', project)")]
public function addRateAction(Project $project, Request $request, ProjectRateRepository $repository): Response
{
$rate = new ProjectRate();
$rate->setProject($project);
return $this->rateFormAction($project, $rate, $request, $repository, $this->generateUrl('admin_project_rate_add', ['id' => $project->getId()]));
}
private function rateFormAction(Project $project, ProjectRate $rate, Request $request, ProjectRateRepository $repository, string $formUrl): Response
{
$form = $this->createForm(ProjectRateForm::class, $rate, [
'action' => $this->generateUrl('admin_project_rate_add', ['id' => $project->getId()]),
'action' => $formUrl,
'method' => 'POST',
]);
@@ -397,15 +417,14 @@ final class ProjectController extends AbstractController
}
return $this->render('project/rates.html.twig', [
'page_setup' => $this->createPageSetup(),
'project' => $project,
'form' => $form->createView()
]);
}
/**
* @Route(path="/{id}/edit", name="admin_project_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', project)")
*/
#[Route(path: '/{id}/edit', name: 'admin_project_edit', methods: ['GET', 'POST'])]
#[Security("is_granted('edit', project)")]
public function editAction(Project $project, Request $request)
{
$editForm = $this->createEditForm($project);
@@ -423,15 +442,14 @@ final class ProjectController extends AbstractController
}
return $this->render('project/edit.html.twig', [
'page_setup' => $this->createPageSetup(),
'project' => $project,
'form' => $editForm->createView()
]);
}
/**
* @Route(path="/{id}/duplicate/{token}", name="admin_project_duplicate", methods={"GET", "POST"})
* @Security("is_granted('edit', project)")
*/
#[Route(path: '/{id}/duplicate/{token}', name: 'admin_project_duplicate', methods: ['GET', 'POST'])]
#[Security("is_granted('edit', project)")]
public function duplicateAction(Project $project, string $token, ProjectDuplicationService $projectDuplicationService, CsrfTokenManagerInterface $csrfTokenManager)
{
if (!$csrfTokenManager->isTokenValid(new CsrfToken('project.duplicate', $token))) {
@@ -449,10 +467,8 @@ final class ProjectController extends AbstractController
return $this->redirectToRoute('project_details', ['id' => $newProject->getId()]);
}
/**
* @Route(path="/{id}/delete", name="admin_project_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', project)")
*/
#[Route(path: '/{id}/delete', name: 'admin_project_delete', methods: ['GET', 'POST'])]
#[Security("is_granted('delete', project)")]
public function deleteAction(Project $project, Request $request, ProjectStatisticService $statisticService)
{
$stats = $statisticService->getProjectStatistics($project);
@@ -488,15 +504,14 @@ final class ProjectController extends AbstractController
}
return $this->render('project/delete.html.twig', [
'page_setup' => $this->createPageSetup(),
'project' => $project,
'stats' => $stats,
'form' => $deleteForm->createView(),
]);
}
/**
* @Route(path="/export", name="project_export", methods={"GET"})
*/
#[Route(path: '/export', name: 'project_export', methods: ['GET'])]
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter)
{
$query = new ProjectQuery();
@@ -522,25 +537,23 @@ final class ProjectController extends AbstractController
return $writer->getFileResponse($spreadsheet);
}
protected function getToolbarForm(ProjectQuery $query): FormInterface
private function getToolbarForm(ProjectQuery $query): FormInterface
{
return $this->createForm(ProjectToolbarForm::class, $query, [
return $this->createSearchForm(ProjectToolbarForm::class, $query, [
'action' => $this->generateUrl('admin_project', [
'page' => $query->getPage(),
]),
'method' => 'GET',
]);
}
private function getCommentForm(Project $project, ProjectComment $comment): FormInterface
private function getCommentForm(ProjectComment $comment): FormInterface
{
if (null === $comment->getId()) {
$comment->setProject($project);
$comment->setCreatedBy($this->getUser());
}
return $this->createForm(ProjectCommentForm::class, $comment, [
'action' => $this->generateUrl('project_comment_add', ['id' => $project->getId()]),
'action' => $this->generateUrl('project_comment_add', ['id' => $comment->getProject()->getId()]),
'method' => 'POST',
]);
}
@@ -565,7 +578,14 @@ final class ProjectController extends AbstractController
'timezone' => $this->getDateTimeFactory()->getTimezone()->getName(),
'include_budget' => $this->isGranted('budget', $project),
'include_time' => $this->isGranted('time', $project),
'time_increment' => 15,
]);
}
private function createPageSetup(): PageSetup
{
$page = new PageSetup('projects');
$page->setHelp('project.html');
return $page;
}
}

View File

@@ -16,32 +16,23 @@ use App\Model\QuickEntryWeek;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TimesheetRepository;
use App\Timesheet\TimesheetService;
use App\Utils\PageSetup;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
/**
* Controller used to enter times in weekly form.
*
* @Route(path="/quick_entry")
* @Security("is_granted('quick-entry')")
*/
class QuickEntryController extends AbstractController
#[Route(path: '/quick_entry')]
#[Security("is_granted('quick-entry')")]
final class QuickEntryController extends AbstractController
{
private $configuration;
private $timesheetService;
private $repository;
public function __construct(SystemConfiguration $configuration, TimesheetService $timesheetService, TimesheetRepository $repository)
public function __construct(private SystemConfiguration $configuration, private TimesheetService $timesheetService, private TimesheetRepository $repository)
{
$this->configuration = $configuration;
$this->timesheetService = $timesheetService;
$this->repository = $repository;
}
/**
* @Route(path="/{begin}", name="quick_entry", methods={"GET", "POST"})
*/
#[Route(path: '/{begin}', name: 'quick_entry', methods: ['GET', 'POST'])]
public function quickEntry(Request $request, ?string $begin = null)
{
$factory = $this->getDateTimeFactory();
@@ -67,7 +58,7 @@ class QuickEntryController extends AbstractController
$query->setBegin($startWeek);
$query->setEnd($endWeek);
$query->setName('quickEntryForm');
$query->setUser($this->getUser());
$query->setUser($user);
$result = $this->repository->getTimesheetResult($query);
@@ -104,7 +95,7 @@ class QuickEntryController extends AbstractController
$startFrom = clone $startWeek;
$startFrom->modify(sprintf('-%s weeks', $takeOverWeeks));
}
$timesheets = $this->repository->getRecentActivities($this->getUser(), $startFrom, $amount);
$timesheets = $this->repository->getRecentActivities($user, $startFrom, $amount);
foreach ($timesheets as $timesheet) {
$id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId();
if (\array_key_exists($id, $rows)) {
@@ -112,7 +103,7 @@ class QuickEntryController extends AbstractController
}
// there is an edge case possible with a project that starts and ends between the start and end date
// user could still select it from the dropdown, but it is better to hide a row than displaying already ended projects
if (!$timesheet->getProject()->isVisibleAtDate($startWeek) && !$timesheet->getProject()->isVisibleAtDate($endWeek)) {
if ($timesheet->getProject() !== null && (!$timesheet->getProject()->isVisibleAtDate($startWeek) && !$timesheet->getProject()->isVisibleAtDate($endWeek))) {
continue;
}
$rows[$id] = [
@@ -225,12 +216,15 @@ class QuickEntryController extends AbstractController
return $this->redirectToRoute('quick_entry', ['begin' => $begin->format('Y-m-d')]);
}
} catch (\Exception $ex) {
$this->flashError('action.update.error');
$this->logException($ex);
$this->flashUpdateException($ex);
}
}
$page = new PageSetup('quick_entry.title');
$page->setHelp('weekly-times.html');
return $this->render('quick-entry/index.html.twig', [
'page_setup' => $page,
'days' => $week,
'form' => $form->createView(),
]);

View File

@@ -21,15 +21,8 @@ use DateTime;
abstract class AbstractUserReportController extends AbstractController
{
protected $statisticService;
private $projectRepository;
private $activityRepository;
public function __construct(TimesheetStatisticService $statisticService, ProjectRepository $projectRepository, ActivityRepository $activityRepository)
public function __construct(protected TimesheetStatisticService $statisticService, private ProjectRepository $projectRepository, private ActivityRepository $activityRepository)
{
$this->statisticService = $statisticService;
$this->projectRepository = $projectRepository;
$this->activityRepository = $activityRepository;
}
protected function canSelectUser(): bool

View File

@@ -14,38 +14,32 @@ use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
use App\Export\Spreadsheet\Writer\XlsxWriter;
use App\Reporting\CustomerMonthlyProjects\CustomerMonthlyProjects;
use App\Reporting\CustomerMonthlyProjects\CustomerMonthlyProjectsForm;
use App\Reporting\CustomerMonthlyProjects\CustomerMonthlyProjectsRepository;
use App\Repository\Query\UserQuery;
use App\Repository\UserRepository;
use App\Timesheet\TimesheetStatisticService;
use PhpOffice\PhpSpreadsheet\Reader\Html;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route(path="/reporting/customer/monthly_projects")
* @Security("is_granted('view_reporting') and is_granted('view_other_reporting') and is_granted('view_other_timesheet')")
*/
#[Route(path: '/reporting/customer/monthly_projects')]
#[Security("is_granted('report:customer') and is_granted('report:other')")]
final class CustomerMonthlyProjectsController extends AbstractController
{
/**
* @Route(path="/view", name="report_customer_monthly_projects", methods={"GET","POST"})
*/
public function report(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
#[Route(path: '/view', name: 'report_customer_monthly_projects', methods: ['GET', 'POST'])]
public function report(Request $request, CustomerMonthlyProjectsRepository $repository, UserRepository $userRepository): Response
{
return $this->render(
'reporting/customer/monthly_projects.html.twig',
$this->getData($request, $statisticService, $userRepository)
$this->getData($request, $repository, $userRepository)
);
}
/**
* @Route(path="/export", name="report_customer_monthly_projects_export", methods={"GET","POST"})
*/
public function export(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
#[Route(path: '/export', name: 'report_customer_monthly_projects_export', methods: ['GET', 'POST'])]
public function export(Request $request, CustomerMonthlyProjectsRepository $repository, UserRepository $userRepository): Response
{
$data = $this->getData($request, $statisticService, $userRepository);
$data = $this->getData($request, $repository, $userRepository);
$content = $this->render('reporting/customer/monthly_projects_export.html.twig', $data)->getContent();
@@ -57,12 +51,13 @@ final class CustomerMonthlyProjectsController extends AbstractController
return $writer->getFileResponse($spreadsheet);
}
private function getData(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): array
private function getData(Request $request, CustomerMonthlyProjectsRepository $repository, UserRepository $userRepository): array
{
$currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory();
$query = new UserQuery();
$query->setSystemAccount(false);
$query->setCurrentUser($currentUser);
$allUsers = $userRepository->getUsersForQuery($query);
@@ -94,7 +89,7 @@ final class CustomerMonthlyProjectsController extends AbstractController
$next = clone $start;
$next->modify('+1 month');
$stats = $statisticService->getGroupedByCustomerProjectActivityUser($start, $end, $allUsers);
$stats = $repository->getGroupedByCustomerProjectActivityUser($start, $end, $allUsers, $values->getCustomer());
return [
'dataType' => $values->getSumType(),

View File

@@ -20,20 +20,18 @@ use Symfony\Component\Routing\Annotation\Route;
final class ProjectDateRangeController extends AbstractController
{
/**
* @Route(path="/reporting/project_daterange", name="report_project_daterange", methods={"GET","POST"})
* @Security("is_granted('view_reporting') and is_granted('budget_any', 'project')")
*/
#[Route(path: '/reporting/project_daterange', name: 'report_project_daterange', methods: ['GET', 'POST'])]
#[Security("is_granted('report:project') and is_granted('budget_any', 'project')")]
public function __invoke(Request $request, ProjectStatisticService $service)
{
$dateFactory = $this->getDateTimeFactory();
$user = $this->getUser();
$query = new ProjectDaterangeQuery($dateFactory->getStartOfMonth(), $user);
$form = $this->createForm(ProjectDateRangeForm::class, $query, [
$form = $this->createFormForGetRequest(ProjectDateRangeForm::class, $query, [
'timezone' => $user->getTimezone()
]);
$form->handleRequest($request);
$form->submit($request->query->all(), false);
$dateRange = new DateRange(true);
$dateRange->setBegin($query->getMonth());
@@ -52,6 +50,7 @@ final class ProjectDateRangeController extends AbstractController
}
return $this->render('reporting/project_daterange.html.twig', [
'report_title' => 'report_project_daterange',
'entries' => $byCustomer,
'form' => $form->createView(),
'queryEnd' => $dateRange->getEnd(),

View File

@@ -10,40 +10,50 @@
namespace App\Controller\Reporting;
use App\Controller\AbstractController;
use App\Entity\Project;
use App\Project\ProjectStatisticService;
use App\Reporting\ProjectDetails\ProjectDetailsForm;
use App\Reporting\ProjectDetails\ProjectDetailsQuery;
use App\Utils\PageSetup;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
final class ProjectDetailsController extends AbstractController
{
/**
* @Route(path="/reporting/project_details", name="report_project_details", methods={"GET"})
* @Security("is_granted('view_reporting') and is_granted('details', 'project')")
*/
#[Route(path: '/reporting/project_details', name: 'report_project_details', methods: ['GET'])]
#[Security("is_granted('report:project') and is_granted('details', 'project')")]
public function __invoke(Request $request, ProjectStatisticService $service)
{
$dateFactory = $this->getDateTimeFactory();
$user = $this->getUser();
$query = new ProjectDetailsQuery($dateFactory->createDateTime(), $user);
$form = $this->createForm(ProjectDetailsForm::class, $query);
$form = $this->createFormForGetRequest(ProjectDetailsForm::class, $query);
$form->submit($request->query->all(), false);
$projectView = null;
$projectDetails = null;
$project = $query->getProject();
if ($query->getProject() !== null && $this->isGranted('details', $query->getProject())) {
$projectViews = $service->getProjectView($user, [$query->getProject()], $query->getToday());
if ($project !== null && $this->isGranted('details', $project)) {
$projectViews = $service->getProjectView($user, [$project], $query->getToday());
$projectView = $projectViews[0];
$projectDetails = $service->getProjectsDetails($query);
}
$page = new PageSetup('projects');
$page->setHelp('project.html');
if ($project !== null) {
$page->setActionName('project');
$page->setActionView('project_details_report');
$page->setActionPayload(['project' => $project]);
}
return $this->render('reporting/project_details.html.twig', [
'project' => $query->getProject(),
'page_setup' => $page,
'report_title' => 'report_project_details',
'project' => $project,
'project_view' => $projectView,
'project_details' => $projectDetails,
'form' => $form->createView(),

View File

@@ -19,10 +19,8 @@ use Symfony\Component\Routing\Annotation\Route;
final class ProjectInactiveController extends AbstractController
{
/**
* @Route(path="/reporting/project_inactive", name="report_project_inactive", methods={"GET","POST"})
* @Security("is_granted('view_reporting') and is_granted('budget_any', 'project')")
*/
#[Route(path: '/reporting/project_inactive', name: 'report_project_inactive', methods: ['GET', 'POST'])]
#[Security("is_granted('report:project') and is_granted('budget_any', 'project')")]
public function __invoke(Request $request, ProjectStatisticService $service)
{
$dateFactory = $this->getDateTimeFactory();
@@ -30,7 +28,7 @@ final class ProjectInactiveController extends AbstractController
$now = $dateFactory->createDateTime();
$query = new ProjectInactiveQuery($dateFactory->createDateTime('-1 year'), $user);
$form = $this->createForm(ProjectInactiveForm::class, $query, [
$form = $this->createFormForGetRequest(ProjectInactiveForm::class, $query, [
'timezone' => $user->getTimezone()
]);
$form->submit($request->query->all(), false);
@@ -47,10 +45,10 @@ final class ProjectInactiveController extends AbstractController
$byCustomer[$customer->getId()]['projects'][] = $entry;
}
return $this->render('reporting/project_view.html.twig', [
return $this->render('reporting/project_inactive.html.twig', [
'entries' => $byCustomer,
'form' => $form->createView(),
'title' => 'report_inactive_project',
'report_title' => 'report_inactive_project',
'tableName' => 'inactive_project_reporting',
'now' => $now,
'skipColumns' => ['today', 'week', 'month', 'projectStart', 'projectEnd', 'comment'],

View File

@@ -19,17 +19,15 @@ use Symfony\Component\Routing\Annotation\Route;
final class ProjectViewController extends AbstractController
{
/**
* @Route(path="/reporting/project_view", name="report_project_view", methods={"GET","POST"})
* @Security("is_granted('view_reporting') and is_granted('budget_any', 'project')")
*/
#[Route(path: '/reporting/project_view', name: 'report_project_view', methods: ['GET', 'POST'])]
#[Security("is_granted('report:project') and is_granted('budget_any', 'project')")]
public function __invoke(Request $request, ProjectStatisticService $service)
{
$dateFactory = $this->getDateTimeFactory();
$user = $this->getUser();
$query = new ProjectViewQuery($dateFactory->createDateTime(), $user);
$form = $this->createForm(ProjectViewForm::class, $query);
$form = $this->createFormForGetRequest(ProjectViewForm::class, $query);
$form->submit($request->query->all(), false);
$projects = $service->findProjectsForView($query);
@@ -47,7 +45,7 @@ final class ProjectViewController extends AbstractController
return $this->render('reporting/project_view.html.twig', [
'entries' => $byCustomer,
'form' => $form->createView(),
'title' => 'report_project_view',
'report_title' => 'report_project_view',
'tableName' => 'project_view_reporting',
'now' => $dateFactory->createDateTime(),
]);

View File

@@ -13,8 +13,8 @@ use App\Controller\AbstractController;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
use App\Export\Spreadsheet\Writer\XlsxWriter;
use App\Model\DailyStatistic;
use App\Reporting\MonthlyUserList;
use App\Reporting\MonthlyUserListForm;
use App\Reporting\MonthlyUserList\MonthlyUserList;
use App\Reporting\MonthlyUserList\MonthlyUserListForm;
use App\Repository\Query\UserQuery;
use App\Repository\UserRepository;
use App\Timesheet\TimesheetStatisticService;
@@ -24,15 +24,11 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route(path="/reporting/users")
* @Security("is_granted('view_reporting') and is_granted('view_other_reporting') and is_granted('view_other_timesheet')")
*/
#[Route(path: '/reporting/users')]
#[Security("is_granted('report:other')")]
final class ReportUsersMonthController extends AbstractController
{
/**
* @Route(path="/month", name="report_monthly_users", methods={"GET","POST"})
*/
#[Route(path: '/month', name: 'report_monthly_users', methods: ['GET', 'POST'])]
public function report(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
{
return $this->render(
@@ -41,9 +37,7 @@ final class ReportUsersMonthController extends AbstractController
);
}
/**
* @Route(path="/month_export", name="report_monthly_users_export", methods={"GET","POST"})
*/
#[Route(path: '/month_export', name: 'report_monthly_users_export', methods: ['GET', 'POST'])]
public function export(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
{
$data = $this->getData($request, $statisticService, $userRepository);
@@ -66,7 +60,7 @@ final class ReportUsersMonthController extends AbstractController
$values = new MonthlyUserList();
$values->setDate($dateTimeFactory->getStartOfMonth());
$form = $this->createForm(MonthlyUserListForm::class, $values, [
$form = $this->createFormForGetRequest(MonthlyUserListForm::class, $values, [
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),
]);
@@ -74,6 +68,7 @@ final class ReportUsersMonthController extends AbstractController
$form->submit($request->query->all(), false);
$query = new UserQuery();
$query->setSystemAccount(false);
$query->setCurrentUser($currentUser);
if ($form->isSubmitted()) {

View File

@@ -13,8 +13,8 @@ use App\Controller\AbstractController;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
use App\Export\Spreadsheet\Writer\XlsxWriter;
use App\Model\DailyStatistic;
use App\Reporting\WeeklyUserList;
use App\Reporting\WeeklyUserListForm;
use App\Reporting\WeeklyUserList\WeeklyUserList;
use App\Reporting\WeeklyUserList\WeeklyUserListForm;
use App\Repository\Query\UserQuery;
use App\Repository\UserRepository;
use App\Timesheet\TimesheetStatisticService;
@@ -24,15 +24,11 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route(path="/reporting/users")
* @Security("is_granted('view_reporting') and is_granted('view_other_reporting') and is_granted('view_other_timesheet')")
*/
#[Route(path: '/reporting/users')]
#[Security("is_granted('report:other')")]
final class ReportUsersWeekController extends AbstractController
{
/**
* @Route(path="/week", name="report_weekly_users", methods={"GET","POST"})
*/
#[Route(path: '/week', name: 'report_weekly_users', methods: ['GET', 'POST'])]
public function report(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
{
return $this->render(
@@ -41,9 +37,7 @@ final class ReportUsersWeekController extends AbstractController
);
}
/**
* @Route(path="/week_export", name="report_weekly_users_export", methods={"GET","POST"})
*/
#[Route(path: '/week_export', name: 'report_weekly_users_export', methods: ['GET', 'POST'])]
public function export(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
{
$data = $this->getData($request, $statisticService, $userRepository);
@@ -66,7 +60,7 @@ final class ReportUsersWeekController extends AbstractController
$values = new WeeklyUserList();
$values->setDate($dateTimeFactory->getStartOfWeek());
$form = $this->createForm(WeeklyUserListForm::class, $values, [
$form = $this->createFormForGetRequest(WeeklyUserListForm::class, $values, [
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),
]);
@@ -74,6 +68,7 @@ final class ReportUsersWeekController extends AbstractController
$form->submit($request->query->all(), false);
$query = new UserQuery();
$query->setSystemAccount(false);
$query->setCurrentUser($currentUser);
if ($form->isSubmitted()) {

View File

@@ -14,8 +14,8 @@ use App\Controller\AbstractController;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
use App\Export\Spreadsheet\Writer\XlsxWriter;
use App\Model\MonthlyStatistic;
use App\Reporting\YearlyUserList;
use App\Reporting\YearlyUserListForm;
use App\Reporting\YearlyUserList\YearlyUserList;
use App\Reporting\YearlyUserList\YearlyUserListForm;
use App\Repository\Query\UserQuery;
use App\Repository\UserRepository;
use App\Timesheet\TimesheetStatisticService;
@@ -26,19 +26,16 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route(path="/reporting/users")
* @Security("is_granted('view_reporting') and is_granted('view_other_reporting') and is_granted('view_other_timesheet')")
*/
#[Route(path: '/reporting/users')]
#[Security("is_granted('report:other')")]
final class ReportUsersYearController extends AbstractController
{
/**
* @Route(path="/year", name="report_yearly_users", methods={"GET","POST"})
*
* @param Request $request
* @return Response
* @throws Exception
*/
#[Route(path: '/year', name: 'report_yearly_users', methods: ['GET', 'POST'])]
public function report(Request $request, SystemConfiguration $systemConfiguration, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
{
return $this->render(
@@ -48,12 +45,11 @@ final class ReportUsersYearController extends AbstractController
}
/**
* @Route(path="/year_export", name="report_yearly_users_export", methods={"GET","POST"})
*
* @param Request $request
* @return Response
* @throws Exception
*/
#[Route(path: '/year_export', name: 'report_yearly_users_export', methods: ['GET', 'POST'])]
public function export(Request $request, SystemConfiguration $systemConfiguration, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
{
$data = $this->getData($request, $systemConfiguration, $statisticService, $userRepository);
@@ -82,7 +78,7 @@ final class ReportUsersYearController extends AbstractController
$values = new YearlyUserList();
$values->setDate(clone $defaultDate);
$form = $this->createForm(YearlyUserListForm::class, $values, [
$form = $this->createFormForGetRequest(YearlyUserListForm::class, $values, [
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),
]);
@@ -90,6 +86,7 @@ final class ReportUsersYearController extends AbstractController
$form->submit($request->query->all(), false);
$query = new UserQuery();
$query->setSystemAccount(false);
$query->setCurrentUser($currentUser);
if ($form->isSubmitted()) {
@@ -126,7 +123,7 @@ final class ReportUsersYearController extends AbstractController
}
return [
'query' => $values,
'subReportDate' => $values->getDate(),
'period_attribute' => 'months',
'dataType' => $values->getSumType(),
'report_title' => 'report_yearly_users',

View File

@@ -9,10 +9,9 @@
namespace App\Controller\Reporting;
use App\Entity\User;
use App\Model\DailyStatistic;
use App\Reporting\MonthByUser;
use App\Reporting\MonthByUserForm;
use App\Reporting\MonthByUser\MonthByUser;
use App\Reporting\MonthByUser\MonthByUserForm;
use Exception;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
@@ -20,19 +19,16 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
/**
* @Route(path="/reporting/user")
* @Security("is_granted('view_reporting')")
*/
#[Route(path: '/reporting/user')]
#[Security("is_granted('report:user')")]
final class UserMonthController extends AbstractUserReportController
{
/**
* @Route(path="/month", name="report_user_month", methods={"GET","POST"})
*
* @param Request $request
* @return Response
* @throws Exception
*/
#[Route(path: '/month', name: 'report_user_month', methods: ['GET', 'POST'])]
public function monthByUser(Request $request): Response
{
return $this->render('reporting/report_by_user.html.twig', $this->getData($request));
@@ -48,7 +44,7 @@ final class UserMonthController extends AbstractUserReportController
$values->setUser($currentUser);
$values->setDate($dateTimeFactory->getStartOfMonth());
$form = $this->createForm(MonthByUserForm::class, $values, [
$form = $this->createFormForGetRequest(MonthByUserForm::class, $values, [
'include_user' => $canChangeUser,
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),

View File

@@ -9,10 +9,9 @@
namespace App\Controller\Reporting;
use App\Entity\User;
use App\Model\DailyStatistic;
use App\Reporting\WeekByUser;
use App\Reporting\WeekByUserForm;
use App\Reporting\WeekByUser\WeekByUser;
use App\Reporting\WeekByUser\WeekByUserForm;
use Exception;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
@@ -20,19 +19,16 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
/**
* @Route(path="/reporting/user")
* @Security("is_granted('view_reporting')")
*/
#[Route(path: '/reporting/user')]
#[Security("is_granted('report:user')")]
final class UserWeekController extends AbstractUserReportController
{
/**
* @Route(path="/week", name="report_user_week", methods={"GET","POST"})
*
* @param Request $request
* @return Response
* @throws Exception
*/
#[Route(path: '/week', name: 'report_user_week', methods: ['GET', 'POST'])]
public function weekByUser(Request $request): Response
{
return $this->render('reporting/report_by_user.html.twig', $this->getData($request));
@@ -48,7 +44,7 @@ final class UserWeekController extends AbstractUserReportController
$values->setUser($currentUser);
$values->setDate($dateTimeFactory->getStartOfWeek());
$form = $this->createForm(WeekByUserForm::class, $values, [
$form = $this->createFormForGetRequest(WeekByUserForm::class, $values, [
'include_user' => $canChangeUser,
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),

View File

@@ -13,8 +13,8 @@ use App\Configuration\SystemConfiguration;
use App\Entity\User;
use App\Model\DateStatisticInterface;
use App\Model\MonthlyStatistic;
use App\Reporting\YearByUser;
use App\Reporting\YearByUserForm;
use App\Reporting\YearByUser\YearByUser;
use App\Reporting\YearByUser\YearByUserForm;
use DateTime;
use Exception;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
@@ -23,19 +23,16 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
/**
* @Route(path="/reporting/user")
* @Security("is_granted('view_reporting')")
*/
#[Route(path: '/reporting/user')]
#[Security("is_granted('report:user')")]
final class UserYearController extends AbstractUserReportController
{
/**
* @Route(path="/year", name="report_user_year", methods={"GET","POST"})
*
* @param Request $request
* @return Response
* @throws Exception
*/
#[Route(path: '/year', name: 'report_user_year', methods: ['GET', 'POST'])]
public function yearByUser(Request $request, SystemConfiguration $systemConfiguration): Response
{
return $this->render('reporting/report_by_user_year.html.twig', $this->getData($request, $systemConfiguration));
@@ -58,7 +55,7 @@ final class UserYearController extends AbstractUserReportController
$values->setDate(clone $defaultDate);
$form = $this->createForm(YearByUserForm::class, $values, [
$form = $this->createFormForGetRequest(YearByUserForm::class, $values, [
'include_user' => $canChangeUser,
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),

View File

@@ -10,49 +10,27 @@
namespace App\Controller;
use App\Reporting\ReportingService;
use App\Utils\PageSetup;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* Controller used to render reports.
*
* @Route(path="/reporting")
* @Security("is_granted('view_reporting')")
*/
#[Route(path: '/reporting')]
#[Security("is_granted('view_reporting')")]
final class ReportingController extends AbstractController
{
/**
* @Route(path="/", name="reporting", methods={"GET"})
*
* @return Response
*/
#[Route(path: '/', name: 'reporting', methods: ['GET'])]
public function defaultReport(ReportingService $reportingService): Response
{
$user = $this->getUser();
$route = null;
$page = new PageSetup('menu.reporting');
$page->setHelp('reporting.html');
$defaultReport = $user->getPreferenceValue('reporting.initial_view', ReportingService::DEFAULT_VIEW, false);
$allReports = $reportingService->getAvailableReports($user);
foreach ($allReports as $report) {
if ($report->getId() === $defaultReport) {
$route = $report->getRoute();
break;
}
}
// fallback, if the configured report could not be found
// e.g. when it was deleted or replaced by an enhanced version with a new id
if ($route === null && \count($allReports) > 0) {
$report = $allReports[array_keys($allReports)[0]];
$route = $report->getRoute();
}
if ($route === null) {
throw $this->createNotFoundException('Unknown default report');
}
return $this->redirectToRoute($route);
return $this->render('reporting/index.html.twig', [
'page_setup' => $page,
'reports' => $reportingService->getAvailableReports($this->getUser()),
]);
}
}

View File

@@ -27,28 +27,22 @@ use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @Route(path="/resetting")
*/
#[Route(path: '/resetting')]
final class PasswordResetController extends AbstractController
{
private $eventDispatcher;
private $userService;
private $configuration;
public function __construct(EventDispatcherInterface $eventDispatcher, UserService $userService, SystemConfiguration $configuration)
{
$this->eventDispatcher = $eventDispatcher;
$this->userService = $userService;
$this->configuration = $configuration;
public function __construct(
private EventDispatcherInterface $eventDispatcher,
private UserService $userService,
private SystemConfiguration $configuration
) {
}
/**
* Request reset user password: show form.
*
* @Route(path="/request", name="fos_user_resetting_request", methods={"GET"})
*/
#[Route(path: '/request', name: 'resetting_request', methods: ['GET'])]
public function requestAction(): Response
{
if (!$this->configuration->isPasswordResetActive()) {
@@ -60,10 +54,9 @@ final class PasswordResetController extends AbstractController
/**
* Request reset user password: submit form and send email.
*
* @Route(path="/send-email", name="fos_user_resetting_send_email", methods={"POST"})
*/
public function sendEmailAction(Request $request): Response
#[Route(path: '/send-email', name: 'resetting_send_email', methods: ['POST'])]
public function sendEmailAction(Request $request, TranslatorInterface $translator): Response
{
if (!$this->configuration->isPasswordResetActive()) {
throw $this->createNotFoundException();
@@ -75,7 +68,7 @@ final class PasswordResetController extends AbstractController
if (null !== $user && !$user->isPasswordRequestNonExpired($this->configuration->getPasswordResetRetryLifetime())) {
if (!$user->isInternalUser()) {
throw $this->createAccessDeniedException(
sprintf('The user "%s" tried to reset the password, but it is registered as "%s" auth-type.', $user->getUsername(), $user->getAuth())
sprintf('The user "%s" tried to reset the password, but it is registered as "%s" auth-type.', $user->getUserIdentifier(), $user->getAuth())
);
}
@@ -83,7 +76,7 @@ final class PasswordResetController extends AbstractController
$user->setConfirmationToken($this->userService->generateSecurityToken());
}
$mail = $this->generateResettingEmailMessage($user);
$mail = $this->generateResettingEmailMessage($user, $translator);
$event = new EmailPasswordResetEvent($user, $mail);
$this->eventDispatcher->dispatch($event);
@@ -94,14 +87,13 @@ final class PasswordResetController extends AbstractController
$this->userService->updateUser($user);
}
return $this->redirectToRoute('fos_user_resetting_check_email', ['username' => $username]);
return $this->redirectToRoute('resetting_check_email', ['username' => $username]);
}
/**
* Tell the user to check his email provider.
*
* @Route(path="/check-email", name="fos_user_resetting_check_email", methods={"GET"})
*/
#[Route(path: '/check-email', name: 'resetting_check_email', methods: ['GET'])]
public function checkEmailAction(Request $request): Response
{
if (!$this->configuration->isPasswordResetActive()) {
@@ -112,7 +104,7 @@ final class PasswordResetController extends AbstractController
if (empty($username)) {
// the user does not come from the sendEmail action
return $this->redirectToRoute('fos_user_resetting_request');
return $this->redirectToRoute('resetting_request');
}
return $this->render('security/password-reset/check_email.html.twig', [
@@ -122,9 +114,8 @@ final class PasswordResetController extends AbstractController
/**
* Reset user password.
*
* @Route(path="/reset/{token}", name="fos_user_resetting_reset", methods={"GET", "POST"})
*/
#[Route(path: '/reset/{token}', name: 'resetting_reset', methods: ['GET', 'POST'])]
public function resetAction(Request $request, LoginManager $loginManager, ?string $token): Response
{
if (!$this->configuration->isPasswordResetActive()) {
@@ -134,11 +125,11 @@ final class PasswordResetController extends AbstractController
$user = $this->userService->findUserByConfirmationToken($token);
if (null === $user) {
return $this->redirectToRoute('fos_user_security_login');
return $this->redirectToRoute('login');
}
if (!$user->isPasswordRequestNonExpired($this->configuration->getPasswordResetTokenLifetime())) {
return $this->redirectToRoute('fos_user_resetting_request');
return $this->redirectToRoute('resetting_request');
}
$form = $this->createResetForm();
@@ -169,20 +160,20 @@ final class PasswordResetController extends AbstractController
{
$options = ['validation_groups' => ['ResetPassword', 'Default']];
return $this->createFormBuilder()->create('fos_user_resetting_form', PasswordResetForm::class, $options)->getForm();
return $this->createFormBuilder()->create('resetting_form', PasswordResetForm::class, $options)->getForm();
}
private function generateResettingEmailMessage(User $user): Email
private function generateResettingEmailMessage(User $user, TranslatorInterface $translator): Email
{
$username = $user->getDisplayName();
$language = $user->getLanguage();
$url = $this->generateUrl('fos_user_resetting_reset', ['token' => $user->getConfirmationToken()], UrlGeneratorInterface::ABSOLUTE_URL);
$url = $this->generateUrl('resetting_reset', ['token' => $user->getConfirmationToken()], UrlGeneratorInterface::ABSOLUTE_URL);
return (new TemplatedEmail())
->to(new Address($user->getEmail()))
->subject(
$this->getTranslator()->trans('reset.subject', ['%username%' => $username], 'email', $language)
$translator->trans('reset.subject', ['%username%' => $username], 'email', $language)
)
->htmlTemplate('emails/password-reset.html.twig')
->context([

View File

@@ -10,62 +10,36 @@
namespace App\Controller\Security;
use App\Configuration\SamlConfigurationInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use App\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
final class SecurityController extends AbstractController
{
private $tokenManager;
private $samlConfiguration;
public function __construct(CsrfTokenManagerInterface $tokenManager, SamlConfigurationInterface $samlConfiguration)
public function __construct(private CsrfTokenManagerInterface $tokenManager, private SamlConfigurationInterface $samlConfiguration)
{
$this->tokenManager = $tokenManager;
$this->samlConfiguration = $samlConfiguration;
}
/**
* @Route(path="/login", name="fos_user_security_login", methods={"GET", "POST"})
*/
public function loginAction(Request $request): Response
#[Route(path: '/login', name: 'login', methods: ['GET', 'POST'])]
public function loginAction(AuthenticationUtils $authenticationUtils): Response
{
if ($this->isGranted('IS_AUTHENTICATED_FULLY')) {
return $this->redirectToRoute('homepage');
}
/** @var SessionInterface $session */
$session = $request->getSession();
$authErrorKey = Security::AUTHENTICATION_ERROR;
$lastUsernameKey = Security::LAST_USERNAME;
// get the error if any (works with forward and redirect -- see below)
if ($request->attributes->has($authErrorKey)) {
$error = $request->attributes->get($authErrorKey);
} elseif (null !== $session && $session->has($authErrorKey)) {
$error = $session->get($authErrorKey);
$session->remove($authErrorKey);
} else {
$error = null;
}
if (!$error instanceof AuthenticationException) {
$error = null; // The value does not come from the security component.
}
$lastUsername = '';
if ($request->hasSession()) {
$lastUsername = $session->get($lastUsernameKey);
}
$error = $authenticationUtils->getLastAuthenticationError();
$lastUsername = $authenticationUtils->getLastUsername();
$csrfToken = $this->tokenManager->getToken('authenticate')->getValue();
if ($this->isGranted('IS_AUTHENTICATED_REMEMBERED') && $this->getUser()->isInternalUser()) {
return $this->render('security/unlock.html.twig', [
'error' => $error,
'csrf_token' => $csrfToken,
]);
}
return $this->render('security/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
@@ -74,17 +48,13 @@ final class SecurityController extends AbstractController
]);
}
/**
* @Route(path="/login_check", name="fos_user_security_check", methods={"POST"})
*/
#[Route(path: '/login_check', name: 'security_check', methods: ['POST'])]
public function checkAction()
{
throw new \RuntimeException('You must configure the check path to be handled by the firewall using form_login in your security firewall configuration.');
}
/**
* @Route(path="/logout", name="fos_user_security_logout", methods={"GET", "POST"})
*/
#[Route(path: '/logout', name: 'logout', methods: ['GET', 'POST'])]
public function logoutAction()
{
throw new \RuntimeException('You must activate the logout in your security firewall configuration.');

View File

@@ -28,29 +28,21 @@ use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @Route(path="/register")
*/
class SelfRegistrationController extends AbstractController
#[Route(path: '/register')]
final class SelfRegistrationController extends AbstractController
{
private $eventDispatcher;
private $userService;
private $tokenStorage;
private $configuration;
public function __construct(EventDispatcherInterface $eventDispatcher, UserService $userService, TokenStorageInterface $tokenStorage, SystemConfiguration $configuration)
{
$this->eventDispatcher = $eventDispatcher;
$this->userService = $userService;
$this->tokenStorage = $tokenStorage;
$this->configuration = $configuration;
public function __construct(
private EventDispatcherInterface $eventDispatcher,
private UserService $userService,
private TokenStorageInterface $tokenStorage,
private SystemConfiguration $configuration
) {
}
/**
* @Route(path="/", name="fos_user_registration_register", methods={"GET", "POST"})
*/
public function registerAction(Request $request): Response
#[Route(path: '/', name: 'registration_register', methods: ['GET', 'POST'])]
public function registerAction(Request $request, TranslatorInterface $translator): Response
{
if (!$this->configuration->isSelfRegistrationActive()) {
throw $this->createNotFoundException();
@@ -68,18 +60,18 @@ class SelfRegistrationController extends AbstractController
$user->setEnabled(false);
$user->setConfirmationToken($this->userService->generateSecurityToken());
$mail = $this->generateConfirmationEmail($user);
$mail = $this->generateConfirmationEmail($user, $translator);
$event = new EmailSelfRegistrationEvent($user, $mail);
$this->eventDispatcher->dispatch($event);
// this will finally send the email
$this->eventDispatcher->dispatch(new EmailEvent($event->getEmail()));
$request->getSession()->set('fos_user_send_confirmation_email/email', $user->getEmail());
$request->getSession()->set('confirmation_email_address', $user->getEmail());
$this->userService->saveNewUser($user);
return $this->redirectToRoute('fos_user_registration_check_email');
return $this->redirectToRoute('user_registration_check_email');
}
return $this->render('security/self-registration/register.html.twig', [
@@ -89,26 +81,25 @@ class SelfRegistrationController extends AbstractController
/**
* Tell the user to check their email provider.
*
* @Route(path="/check-email", name="fos_user_registration_check_email", methods={"GET"})
*/
#[Route(path: '/check-email', name: 'user_registration_check_email', methods: ['GET'])]
public function checkEmailAction(Request $request): Response
{
if (!$this->configuration->isSelfRegistrationActive()) {
throw $this->createNotFoundException();
}
$email = $request->getSession()->get('fos_user_send_confirmation_email/email');
$email = $request->getSession()->get('confirmation_email_address');
if (empty($email)) {
return $this->redirectToRoute('fos_user_registration_register');
return $this->redirectToRoute('registration_register');
}
$request->getSession()->remove('fos_user_send_confirmation_email/email');
$request->getSession()->remove('confirmation_email_address');
$user = $this->userService->findUserByEmail($email);
if (null === $user) {
return $this->redirectToRoute('fos_user_security_login');
return $this->redirectToRoute('login');
}
return $this->render('security/self-registration/check_email.html.twig', [
@@ -118,9 +109,8 @@ class SelfRegistrationController extends AbstractController
/**
* Receive the confirmation token from user email provider, login the user.
*
* @Route(path="/confirm/{token}", name="fos_user_registration_confirm", methods={"GET"})
*/
#[Route(path: '/confirm/{token}', name: 'registration_confirm', methods: ['GET'])]
public function confirmAction(LoginManager $loginManager, ?string $token): Response
{
if (!$this->configuration->isSelfRegistrationActive()) {
@@ -130,7 +120,7 @@ class SelfRegistrationController extends AbstractController
$user = $this->userService->findUserByConfirmationToken($token);
if (null === $user) {
return $this->redirectToRoute('fos_user_security_login');
return $this->redirectToRoute('login');
}
$user->setConfirmationToken(null);
@@ -138,7 +128,7 @@ class SelfRegistrationController extends AbstractController
$this->userService->updateUser($user);
$response = $this->redirectToRoute('fos_user_registration_confirmed');
$response = $this->redirectToRoute('registration_confirmed');
$loginManager->logInUser($user, $response);
return $response;
@@ -146,22 +136,16 @@ class SelfRegistrationController extends AbstractController
/**
* Tell the user his account is now confirmed.
*
* @Route(path="/confirmed", name="fos_user_registration_confirmed", methods={"GET"})
*/
#[Route(path: '/confirmed', name: 'registration_confirmed', methods: ['GET'])]
public function confirmedAction(Request $request): Response
{
if (!$this->configuration->isSelfRegistrationActive()) {
throw $this->createNotFoundException();
}
$user = $this->getUser();
if ($user === null) {
throw $this->createAccessDeniedException('This user does not have access to this section.');
}
return $this->render('security/self-registration/confirmed.html.twig', [
'user' => $user,
'user' => $this->getUser(),
'targetUrl' => $this->getTargetUrlFromSession($request->getSession()),
]);
}
@@ -170,13 +154,13 @@ class SelfRegistrationController extends AbstractController
{
$options = ['validation_groups' => ['Registration', 'Default']];
return $this->createFormBuilder()->create('fos_user_registration_form', SelfRegistrationForm::class, $options)->getForm();
return $this->createFormBuilder()->create('user_registration_form', SelfRegistrationForm::class, $options)->getForm();
}
private function getTargetUrlFromSession(SessionInterface $session): ?string
{
$token = $this->tokenStorage->getToken();
if (!method_exists($token, 'getProviderKey')) {
if ($token === null || !method_exists($token, 'getProviderKey')) {
return null;
}
@@ -189,17 +173,17 @@ class SelfRegistrationController extends AbstractController
return null;
}
private function generateConfirmationEmail(User $user): Email
private function generateConfirmationEmail(User $user, TranslatorInterface $translator): Email
{
$username = $user->getDisplayName();
$language = $user->getLanguage();
$url = $this->generateUrl('fos_user_registration_confirm', ['token' => $user->getConfirmationToken()], UrlGeneratorInterface::ABSOLUTE_URL);
$url = $this->generateUrl('registration_confirm', ['token' => $user->getConfirmationToken()], UrlGeneratorInterface::ABSOLUTE_URL);
return (new TemplatedEmail())
->to(new Address($user->getEmail()))
->subject(
$this->getTranslator()->trans('registration.subject', ['%username%' => $username], 'email', $language)
$translator->trans('registration.subject', ['%username%' => $username], 'email', $language)
)
->htmlTemplate('emails/confirmation.html.twig')
->context([

View File

@@ -31,18 +31,19 @@ use App\Form\Type\TrackingModeType;
use App\Form\Type\WeekDaysType;
use App\Form\Type\YesNoType;
use App\Repository\ConfigurationRepository;
use App\Validator\Constraints\AllowedHtmlTags;
use App\Timesheet\LockdownService;
use App\Utils\PageSetup;
use App\Validator\Constraints\ColorChoices;
use App\Validator\Constraints\DateTimeFormat;
use App\Validator\Constraints\TimeFormat;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Form\Extension\Core\Type\CurrencyType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
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;
@@ -54,35 +55,16 @@ use Symfony\Component\Validator\Constraints\Regex;
/**
* Controller used for executing system relevant tasks.
*
* @Route(path="/admin/system-config")
* @Security("is_granted('system_configuration')")
*/
#[Route(path: '/admin/system-config')]
#[Security("is_granted('IS_AUTHENTICATED_FULLY') and is_granted('system_configuration')")]
final class SystemConfigurationController extends AbstractController
{
/**
* @var EventDispatcherInterface
*/
private $eventDispatcher;
/**
* @var SystemConfiguration
*/
private $configurations;
/**
* @var ConfigurationRepository
*/
private $repository;
public function __construct(EventDispatcherInterface $dispatcher, ConfigurationRepository $repository, SystemConfiguration $config)
public function __construct(private EventDispatcherInterface $eventDispatcher, private ConfigurationRepository $repository, private SystemConfiguration $systemConfiguration, private LockdownService $lockdownService)
{
$this->eventDispatcher = $dispatcher;
$this->repository = $repository;
$this->configurations = $config;
}
/**
* @Route(path="/", name="system_configuration", methods={"GET"})
*/
#[Route(path: '/', name: 'system_configuration', methods: ['GET'])]
public function indexAction(): Response
{
$configSettings = $this->getInitializedConfigurations();
@@ -95,14 +77,16 @@ final class SystemConfigurationController extends AbstractController
];
}
$page = new PageSetup('menu.system_configuration');
$page->setHelp('configurations.html');
return $this->render('system-configuration/index.html.twig', [
'page_setup' => $page,
'sections' => $configurations,
]);
}
/**
* @Route(path="/edit/{section}", name="system_configuration_section", methods={"GET"})
*/
#[Route(path: '/edit/{section}', name: 'system_configuration_section', methods: ['GET'])]
public function sectionAction(string $section): Response
{
$configSettings = $this->getInitializedConfigurations();
@@ -119,27 +103,31 @@ final class SystemConfigurationController extends AbstractController
];
}
return $this->render('system-configuration/index.html.twig', [
$page = new PageSetup('menu.system_configuration');
$page->setHelp('configurations.html');
return $this->render('system-configuration/section.html.twig', [
'page_setup' => $page,
'sections' => $configurations,
]);
}
/**
* @Route(path="/update/{section}/{single}", defaults={"single": "0"}, name="system_configuration_update", methods={"POST"})
*
* @internal do not link directly to this route
* @param Request $request
* @param string $section
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
* @return RedirectResponse|Response
*/
public function configUpdate(Request $request, string $section, string $single)
#[Route(path: '/update/{section}/{single}', defaults: ['single' => 0], name: 'system_configuration_update', methods: ['POST'])]
public function configUpdate(Request $request, string $section, string $single): Response
{
$single = (bool) $single;
$configModel = null;
$configSettings = $this->getInitializedConfigurations();
foreach ($configSettings as $configModel) {
if ($configModel->getSection() === $section) {
foreach ($configSettings as $model) {
if ($model->getSection() === $section) {
$configModel = $model;
break;
}
}
@@ -151,40 +139,36 @@ final class SystemConfigurationController extends AbstractController
$form = $this->createConfigurationsForm($configModel, $single);
$form->handleRequest($request);
if ($form->isSubmitted()) {
if ($form->isValid()) {
try {
$this->repository->saveSystemConfiguration($form->getData());
$this->flashSuccess('action.update.success');
} catch (\Exception $ex) {
$this->flashUpdateException($ex);
}
if ($single) {
return $this->redirectToRoute('system_configuration_section', ['section' => $section]);
}
return $this->redirectToRoute('system_configuration');
} else {
$this->flashError('action.update.error', ['%reason%' => 'Validation problem']);
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->repository->saveSystemConfiguration($form->getData());
$this->flashSuccess('action.update.success');
} catch (\Exception $ex) {
$this->handleFormUpdateException($ex, $form);
}
if ($single) {
return $this->redirectToRoute('system_configuration_section', ['section' => $section]);
}
return $this->redirectToRoute('system_configuration');
}
$configSettings = $this->getInitializedConfigurations();
$configurations = [];
foreach ($configSettings as $configModel) {
if ($single && $section !== $configModel->getSection()) {
foreach ($configSettings as $model) {
if ($single && $section !== $model->getSection()) {
continue;
}
if ($section !== $configModel->getSection()) {
$form2 = $this->createConfigurationsForm($configModel, $single);
if ($section !== $model->getSection()) {
$form2 = $this->createConfigurationsForm($model, $single);
} else {
$form2 = $form;
}
$configurations[] = [
'model' => $configModel,
'model' => $model,
'form' => $form2->createView(),
];
}
@@ -210,7 +194,7 @@ final class SystemConfigurationController extends AbstractController
/**
* @return SystemConfigurationModel[]
*/
protected function getInitializedConfigurations()
private function getInitializedConfigurations(): array
{
$types = $this->getConfigurationTypes();
@@ -219,11 +203,11 @@ final class SystemConfigurationController extends AbstractController
foreach ($event->getConfigurations() as $configs) {
foreach ($configs->getConfiguration() as $config) {
if (!$this->configurations->has($config->getName())) {
if (!$this->systemConfiguration->has($config->getName())) {
continue;
}
$configValue = $this->configurations->find($config->getName());
$configValue = $this->systemConfiguration->find($config->getName());
if (null !== $configValue) {
$config->setValue($configValue);
}
@@ -236,41 +220,29 @@ final class SystemConfigurationController extends AbstractController
/**
* @return SystemConfigurationModel[]
*/
protected function getConfigurationTypes()
private function getConfigurationTypes(): array
{
$user = $this->getUser();
$lockdownStartHelp = null;
$lockdownEndHelp = null;
$lockdownGraceHelp = null;
$dateFormat = 'D, d M Y H:i:s';
if ($this->configurations->isTimesheetLockdownActive()) {
$userTimezone = $this->getDateTimeFactory()->getTimezone();
$timezone = $this->configurations->getTimesheetLockdownTimeZone();
if ($timezone !== null) {
$timezone = new \DateTimeZone($timezone);
}
if ($timezone === null) {
$timezone = $userTimezone;
}
if ($this->lockdownService->isLockdownActive()) {
try {
if (!empty($this->configurations->getTimesheetLockdownPeriodStart())) {
$lockdownStartHelp = new \DateTime($this->configurations->getTimesheetLockdownPeriodStart(), $timezone);
$lockdownStartHelp->setTimezone($userTimezone);
$lockdownStartHelp = $lockdownStartHelp->format($dateFormat);
$start = $this->lockdownService->getLockdownStart($user);
if ($start !== null) {
$lockdownStartHelp = $start->format($dateFormat);
}
if (!empty($this->configurations->getTimesheetLockdownPeriodEnd())) {
$lockdownEndHelp = new \DateTime($this->configurations->getTimesheetLockdownPeriodEnd(), $timezone);
if (!empty($this->configurations->getTimesheetLockdownGracePeriod())) {
$lockdownGraceHelp = clone $lockdownEndHelp;
$lockdownGraceHelp->modify($this->configurations->getTimesheetLockdownGracePeriod());
$lockdownGraceHelp->setTimezone($userTimezone);
$lockdownGraceHelp = $lockdownGraceHelp->format($dateFormat);
}
$lockdownEndHelp->setTimezone($userTimezone);
$lockdownEndHelp = $lockdownEndHelp->format($dateFormat);
$end = $this->lockdownService->getLockdownEnd($user);
if ($end !== null) {
$lockdownEndHelp = $end->format($dateFormat);
}
$grace = $this->lockdownService->getLockdownGrace($user);
if ($grace !== null) {
$lockdownGraceHelp = $grace->format($dateFormat);
}
} catch (\Exception $ex) {
$lockdownStartHelp = 'invalid';
@@ -279,52 +251,35 @@ final class SystemConfigurationController extends AbstractController
$authentication = (new SystemConfigurationModel('authentication'))
->setConfiguration([
(new Configuration())
->setName('user.login')
(new Configuration('user.login'))
->setLabel('user_auth_login')
->setTranslationDomain('system-configuration')
->setType(YesNoType::class),
(new Configuration())
->setName('user.registration')
(new Configuration('user.registration'))
->setLabel('user_auth_registration')
->setTranslationDomain('system-configuration')
->setType(YesNoType::class),
(new Configuration())
->setName('user.password_reset')
(new Configuration('user.password_reset'))
->setTranslationDomain('system-configuration')
->setLabel('user_auth_password_reset')
->setType(YesNoType::class),
(new Configuration())
->setName('user.password_reset_retry_ttl')
(new Configuration('user.password_reset_retry_ttl'))
->setTranslationDomain('system-configuration')
->setLabel('user_auth_password_reset_retry_ttl')
->setConstraints([new NotNull(), new GreaterThanOrEqual(['value' => 60])])
->setType(IntegerType::class),
(new Configuration())
->setName('user.password_reset_token_ttl')
(new Configuration('user.password_reset_token_ttl'))
->setTranslationDomain('system-configuration')
->setLabel('user_auth_password_reset_token_ttl')
->setConstraints([new NotNull(), new GreaterThanOrEqual(['value' => 60])])
->setType(IntegerType::class),
/*
(new Configuration())
->setName('ldap.activate')
->setLabel('ldap_activate')
->setTranslationDomain('system-configuration')
->setType(YesNoType::class),
(new Configuration())
->setName('saml.activate')
->setLabel('saml_activate')
->setTranslationDomain('system-configuration')
->setType(YesNoType::class),
*/
]);
if (!$this->configurations->isSamlActive()) {
if (!$this->systemConfiguration->isSamlActive()) {
$authentication->getConfigurationByName('user.login')->setEnabled(false);
}
if (!$this->configurations->isPasswordResetActive()) {
if (!$this->systemConfiguration->isPasswordResetActive()) {
$authentication->getConfigurationByName('user.password_reset_retry_ttl')->setEnabled(false);
$authentication->getConfigurationByName('user.password_reset_token_ttl')->setEnabled(false);
}
@@ -332,64 +287,52 @@ final class SystemConfigurationController extends AbstractController
return [
(new SystemConfigurationModel('timesheet'))
->setConfiguration([
(new Configuration())
->setName('timesheet.mode')
(new Configuration('timesheet.mode'))
->setType(TrackingModeType::class)
->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('timesheet.default_begin')
(new Configuration('timesheet.default_begin'))
->setType(DateTimeTextType::class)
->setConstraints([new DateTimeFormat(), new NotNull()])
->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('timesheet.rules.allow_future_times')
->setType(CheckboxType::class)
(new Configuration('timesheet.rules.allow_future_times'))
->setType(YesNoType::class)
->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('timesheet.rules.allow_zero_duration')
->setType(CheckboxType::class)
(new Configuration('timesheet.rules.allow_zero_duration'))
->setType(YesNoType::class)
->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('timesheet.rules.allow_overlapping_records')
->setType(CheckboxType::class)
(new Configuration('timesheet.rules.allow_overlapping_records'))
->setType(YesNoType::class)
->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('timesheet.rules.allow_overbooking_budget')
->setType(CheckboxType::class)
(new Configuration('timesheet.rules.allow_overbooking_budget'))
->setType(YesNoType::class)
->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('timesheet.active_entries.hard_limit')
(new Configuration('timesheet.active_entries.hard_limit'))
->setType(IntegerType::class)
->setTranslationDomain('system-configuration')
->setConstraints([
new GreaterThanOrEqual(['value' => 1])
]),
(new Configuration())
->setName('timesheet.time_increment')
(new Configuration('timesheet.time_increment'))
->setType(MinuteIncrementType::class)
->setOptions(['deactivate' => false, 'max_one_hour' => true])
->setTranslationDomain('system-configuration')
->setConstraints([
new Range(['min' => 1, 'max' => 60])
new Range(['min' => 0, 'max' => 60])
]),
(new Configuration())
->setName('timesheet.duration_increment')
(new Configuration('timesheet.duration_increment'))
->setType(MinuteIncrementType::class)
->setTranslationDomain('system-configuration')
->setConstraints([
new GreaterThanOrEqual(['value' => 0])
]),
/*
(new Configuration())
->setName('timesheet.rules.break_warning_duration')
(new Configuration('timesheet.rules.break_warning_duration'))
->setType(IntegerType::class)
->setTranslationDomain('system-configuration')
->setConstraints([
new GreaterThanOrEqual(['value' => 0])
]),
*/
(new Configuration())
->setName('timesheet.rules.long_running_duration')
(new Configuration('timesheet.rules.long_running_duration'))
->setType(IntegerType::class)
->setTranslationDomain('system-configuration')
->setConstraints([
@@ -400,24 +343,21 @@ final class SystemConfigurationController extends AbstractController
->setTranslation('quick_entry.title')
->setTranslationDomain('messages')
->setConfiguration([
(new Configuration())
->setName('quick_entry.recent_activities')
(new Configuration('quick_entry.recent_activities'))
->setType(IntegerType::class)
->setTranslationDomain('system-configuration')
->setRequired(false)
->setConstraints([
new Range(['min' => 0, 'max' => 20]),
]),
(new Configuration())
->setName('quick_entry.recent_activity_weeks')
(new Configuration('quick_entry.recent_activity_weeks'))
->setType(IntegerType::class)
->setTranslationDomain('system-configuration')
->setRequired(false)
->setConstraints([
new Range(['min' => 0, 'max' => 20]),
]),
(new Configuration())
->setName('quick_entry.minimum_rows')
(new Configuration('quick_entry.minimum_rows'))
->setType(IntegerType::class)
->setTranslationDomain('system-configuration')
->setConstraints([
@@ -426,27 +366,23 @@ final class SystemConfigurationController extends AbstractController
]),
(new SystemConfigurationModel('lockdown_period'))
->setConfiguration([
(new Configuration())
->setName('timesheet.rules.lockdown_period_start')
(new Configuration('timesheet.rules.lockdown_period_start'))
->setOptions(['help' => $lockdownStartHelp])
->setType(TextType::class)
->setRequired(false)
->setConstraints([new DateTimeFormat()])
->setConstraints([new DateTimeFormat(['separator' => ','])])
->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('timesheet.rules.lockdown_period_end')
(new Configuration('timesheet.rules.lockdown_period_end'))
->setOptions(['help' => $lockdownEndHelp])
->setType(TextType::class)
->setRequired(false)
->setConstraints([new DateTimeFormat()])
->setConstraints([new DateTimeFormat(['separator' => ','])])
->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('timesheet.rules.lockdown_period_timezone')
(new Configuration('timesheet.rules.lockdown_period_timezone'))
->setType(TimezoneType::class)
->setRequired(false)
->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('timesheet.rules.lockdown_grace_period')
(new Configuration('timesheet.rules.lockdown_grace_period'))
->setOptions(['help' => $lockdownGraceHelp])
->setType(TextType::class)
->setRequired(false)
@@ -455,33 +391,28 @@ final class SystemConfigurationController extends AbstractController
]),
(new SystemConfigurationModel('rounding'))
->setConfiguration([
(new Configuration())
->setName('timesheet.rounding.default.mode')
(new Configuration('timesheet.rounding.default.mode'))
->setType(RoundingModeType::class)
->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('timesheet.rounding.default.begin')
(new Configuration('timesheet.rounding.default.begin'))
->setType(IntegerType::class)
->setTranslationDomain('system-configuration')
->setConstraints([
new GreaterThanOrEqual(['value' => 0])
]),
(new Configuration())
->setName('timesheet.rounding.default.end')
(new Configuration('timesheet.rounding.default.end'))
->setType(IntegerType::class)
->setTranslationDomain('system-configuration')
->setConstraints([
new GreaterThanOrEqual(['value' => 0])
]),
(new Configuration())
->setName('timesheet.rounding.default.duration')
(new Configuration('timesheet.rounding.default.duration'))
->setType(IntegerType::class)
->setTranslationDomain('system-configuration')
->setConstraints([
new GreaterThanOrEqual(['value' => 0])
]),
(new Configuration())
->setName('timesheet.rounding.default.days')
(new Configuration('timesheet.rounding.default.days'))
->setType(WeekDaysType::class)
->setTranslationDomain('system-configuration'),
]),
@@ -490,210 +421,160 @@ final class SystemConfigurationController extends AbstractController
->setTranslationDomain('messages')
->setConfiguration([
// TODO that should be a custom type with validation
(new Configuration())
->setName('invoice.number_format')
(new Configuration('invoice.number_format'))
->setLabel('invoice.number_format')
->setOptions([
'help' => 'allowed_replacer',
'help_translation_parameters' => [
'%replacer%' => '{Y}, {y}, {M}, {m}, {D}, {d}, {date}, {cc}, {ccy}, {ccm}, {ccd}, {cu}, {cuy}, {cum}, {cud}, {ustaff}, {uid}, {c}, {cy}, {cm}, {cd}, {cname}, {cnumber}'
]
])
->setRequired(true)
->setType(TextType::class)
->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('invoice.simple_form')
->setLabel('simple_form')
->setRequired(false)
->setType(YesNoType::class)
->setTranslationDomain('system-configuration'),
]),
$authentication,
(new SystemConfigurationModel('customer'))
->setConfiguration([
(new Configuration())
->setName('defaults.customer.timezone')
(new Configuration('defaults.customer.timezone'))
->setLabel('timezone')
->setType(TimezoneType::class)
->setValue(date_default_timezone_get())
->setOptions(['help' => 'default_value_new']),
(new Configuration())
->setName('defaults.customer.country')
(new Configuration('defaults.customer.country'))
->setLabel('country')
->setType(CountryType::class)
->setOptions(['help' => 'default_value_new']),
(new Configuration())
->setName('defaults.customer.currency')
(new Configuration('defaults.customer.currency'))
->setLabel('currency')
->setType(CurrencyType::class)
->setOptions(['help' => 'default_value_new']),
(new Configuration())
->setName('customer.choice_pattern')
(new Configuration('customer.choice_pattern'))
->setLabel('choice_pattern')
->setType(CustomerTypePatternType::class),
(new Configuration('customer.number_format'))
->setLabel('customer.number_format')
->setOptions(['help' => 'allowed_replacer', 'help_translation_parameters' => ['%replacer%' => '{cc}']])
->setRequired(true)
->setType(TextType::class)
->setTranslationDomain('system-configuration'),
]),
(new SystemConfigurationModel('project'))
->setConfiguration([
(new Configuration())
->setName('project.choice_pattern')
(new Configuration('project.choice_pattern'))
->setLabel('choice_pattern')
->setType(ProjectTypePatternType::class),
(new Configuration())
->setName('project.copy_teams_on_create')
(new Configuration('project.copy_teams_on_create'))
->setLabel('copy_teams_on_create')
->setType(YesNoType::class)
->setTranslationDomain('system-configuration'),
]),
(new SystemConfigurationModel('activity'))
->setConfiguration([
(new Configuration())
->setName('activity.choice_pattern')
(new Configuration('activity.choice_pattern'))
->setLabel('choice_pattern')
->setType(ActivityTypePatternType::class),
// TODO see DependencyInjection/Configuration::getActivityNode()
/*
(new Configuration('activity.allow_inline_create'))
->setLabel('activity.allow_inline_create')
->setType(YesNoType::class)
->setTranslationDomain('system-configuration'), */
]),
(new SystemConfigurationModel('user'))
->setConfiguration([
(new Configuration())
->setName('defaults.user.timezone')
(new Configuration('defaults.user.timezone'))
->setLabel('timezone')
->setType(TimezoneType::class)
->setValue(date_default_timezone_get())
->setOptions(['help' => 'default_value_new']),
(new Configuration())
->setName('defaults.user.language')
(new Configuration('defaults.user.language'))
->setLabel('language')
->setType(LanguageType::class)
->setOptions(['help' => 'default_value_new']),
(new Configuration())
->setName('defaults.user.theme')
(new Configuration('defaults.user.theme'))
->setLabel('skin')
->setType(SkinType::class)
->setOptions(['help' => 'default_value_new']),
(new Configuration())
->setName('defaults.user.currency')
(new Configuration('defaults.user.currency'))
->setLabel('currency')
->setType(CurrencyType::class),
(new Configuration())
->setName('theme.avatar_url')
(new Configuration('theme.avatar_url'))
->setRequired(false)
->setLabel('theme.avatar_url')
->setType(CheckboxType::class)
->setType(YesNoType::class)
->setTranslationDomain('system-configuration'),
]),
(new SystemConfigurationModel('theme'))
->setConfiguration([
(new Configuration())
->setName('theme.autocomplete_chars')
->setLabel('theme.autocomplete_chars')
->setType(IntegerType::class)
->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('timesheet.markdown_content')
(new Configuration('timesheet.markdown_content'))
->setLabel('theme.markdown_content')
->setType(CheckboxType::class)
->setType(YesNoType::class)
->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('theme.tags_create')
->setLabel('theme.tags_create')
->setType(CheckboxType::class)
->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('theme.colors_limited')
->setLabel('theme.colors_limited')
->setType(CheckboxType::class)
->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('theme.color_choices')
(new Configuration('theme.color_choices'))
->setRequired(false)
->setLabel('theme.color_choices')
->setType(ArrayToCommaStringType::class)
->setOptions(['help' => 'help.theme.color_choices'])
->setConstraints([new ColorChoices()])
->setTranslationDomain('system-configuration'),
// random colors as fallback
(new Configuration())
->setName('theme.random_colors')
->setRequired(false)
->setLabel('theme.random_colors')
->setType(CheckboxType::class)
->setTranslationDomain('system-configuration'),
]),
(new SystemConfigurationModel('calendar'))
->setTranslation('calendar')
->setTranslationDomain('messages')
->setConfiguration([
(new Configuration())
->setName('calendar.week_numbers')
(new Configuration('calendar.week_numbers'))
->setTranslationDomain('system-configuration')
->setType(CheckboxType::class),
(new Configuration())
->setName('calendar.weekends')
->setType(YesNoType::class),
(new Configuration('calendar.weekends'))
->setTranslationDomain('system-configuration')
->setType(CheckboxType::class),
(new Configuration())
->setName('calendar.businessHours.begin')
->setType(YesNoType::class),
(new Configuration('calendar.businessHours.begin'))
->setTranslationDomain('system-configuration')
->setType(DayTimeType::class)
->setConstraints([new NotBlank(), new TimeFormat()]),
(new Configuration())
->setName('calendar.businessHours.end')
(new Configuration('calendar.businessHours.end'))
->setTranslationDomain('system-configuration')
->setType(DayTimeType::class)
->setConstraints([new NotBlank(), new TimeFormat()]),
(new Configuration())
->setName('calendar.visibleHours.begin')
(new Configuration('calendar.visibleHours.begin'))
->setTranslationDomain('system-configuration')
->setType(DayTimeType::class)
->setConstraints([new NotBlank(), new TimeFormat()]),
(new Configuration())
->setName('calendar.visibleHours.end')
(new Configuration('calendar.visibleHours.end'))
->setTranslationDomain('system-configuration')
->setType(DayTimeType::class)
->setConstraints([new NotBlank(), new TimeFormat()]),
(new Configuration())
->setName('calendar.slot_duration')
(new Configuration('calendar.slot_duration'))
->setTranslationDomain('system-configuration')
->setType(TextType::class)
->setConstraints([new Regex(['pattern' => '/[0-2]{1}[0-9]{1}:[0-9]{2}:[0-9]{2}/']), new NotNull()]),
(new Configuration())
->setName('calendar.dragdrop_amount')
(new Configuration('calendar.dragdrop_amount'))
->setTranslationDomain('system-configuration')
->setType(IntegerType::class)
->setConstraints([new Range(['min' => 0, 'max' => 20]), new NotNull()]),
(new Configuration())
->setName('calendar.dragdrop_data')
(new Configuration('calendar.dragdrop_data'))
->setTranslationDomain('system-configuration')
->setType(CheckboxType::class),
(new Configuration())
->setName('calendar.title_pattern')
->setType(YesNoType::class),
(new Configuration('calendar.title_pattern'))
->setTranslationDomain('system-configuration')
->setType(CalendarTitlePatternType::class),
]),
(new SystemConfigurationModel('branding'))
->setConfiguration([
(new Configuration())
->setName('theme.branding.logo')
(new Configuration('theme.branding.logo'))
->setTranslationDomain('system-configuration')
->setRequired(false)
->setType(TextType::class),
(new Configuration())
->setName('theme.branding.company')
->setTranslationDomain('system-configuration')
->setRequired(false)
->setType(TextType::class)
->setConstraints([new AllowedHtmlTags(['tags' => '<b><i><u><strong><em><img><svg>'])]),
(new Configuration())
->setName('theme.branding.mini')
->setTranslationDomain('system-configuration')
->setRequired(false)
->setType(TextType::class)
->setConstraints([new AllowedHtmlTags(['tags' => '<b><i><u><strong><em><img><svg>'])]),
(new Configuration())
->setName('theme.branding.title')
(new Configuration('theme.branding.company'))
->setTranslationDomain('system-configuration')
->setRequired(false)
->setType(TextType::class),
(new Configuration())
->setName('company.financial_year')
(new Configuration('company.financial_year'))
->setTranslationDomain('system-configuration')
->setRequired(false)
->setType(DatePickerType::class)
->setOptions(['input' => 'string']),
->setOptions(['input' => 'string']),
]),
];
}

View File

@@ -16,28 +16,27 @@ use App\Form\TagEditForm;
use App\Form\Toolbar\TagToolbarForm;
use App\Repository\Query\TagQuery;
use App\Repository\TagRepository;
use App\Utils\DataTable;
use App\Utils\PageSetup;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route(path="/admin/tags")
* @Security("is_granted('view_tag')")
*/
class TagController extends AbstractController
#[Route(path: '/admin/tags')]
#[Security("is_granted('view_tag')")]
final class TagController extends AbstractController
{
/**
* @Route(path="/", defaults={"page": 1}, name="tags", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="tags_paginated", methods={"GET"})
*
* @param TagRepository $repository
* @param Request $request
* @param int $page
* @return Response
*/
public function listTags(TagRepository $repository, Request $request, $page)
#[Route(path: '/', defaults: ['page' => 1], name: 'tags', methods: ['GET'])]
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'tags_paginated', methods: ['GET'])]
public function listTags(TagRepository $repository, Request $request, $page): Response
{
$query = new TagQuery();
$query->setPage($page);
@@ -47,24 +46,37 @@ class TagController extends AbstractController
return $this->redirectToRoute('tags');
}
$tags = $repository->getTagCount($query);
$entries = $repository->getTagCount($query);
$multiUpdateForm = $this->getMultiUpdateForm($repository);
$table = new DataTable('admin_tags', $query);
$table->setSearchForm($form);
$table->setPagination($entries);
$table->setPaginationRoute('tags_paginated');
$table->setReloadEvents('kimai.tagUpdate');
$table->setBatchForm($multiUpdateForm);
if ($multiUpdateForm !== null) {
$multiUpdateForm = $multiUpdateForm->createView();
$table->addColumn('id', ['class' => 'alwaysVisible multiCheckbox', 'orderBy' => false, 'title' => false, 'batchUpdate' => true]);
}
$table->addColumn('name', ['class' => 'alwaysVisible']);
$table->addColumn('amount', ['class' => 'text-center w-min']);
$table->addColumn('actions', ['class' => 'actions']);
$page = new PageSetup('tags');
$page->setActionName('tags');
$page->setHelp('tags.html');
$page->setDataTable($table);
return $this->render('tags/index.html.twig', [
'tags' => $tags,
'query' => $query,
'toolbarForm' => $form->createView(),
'multiUpdateForm' => $multiUpdateForm,
'page_setup' => $page,
'dataTable' => $table,
]);
}
/**
* @Route(path="/{id}/edit", name="tags_edit", methods={"GET", "POST"})
* @Security("is_granted('manage_tag')")
*/
#[Route(path: '/{id}/edit', name: 'tags_edit', methods: ['GET', 'POST'])]
#[Security("is_granted('manage_tag')")]
public function editAction(Tag $tag, TagRepository $repository, Request $request)
{
$editForm = $this->createForm(TagEditForm::class, $tag, [
@@ -81,20 +93,22 @@ class TagController extends AbstractController
return $this->redirectToRoute('tags');
} catch (\Exception $ex) {
$this->flashUpdateException($ex);
$this->handleFormUpdateException($ex, $editForm);
}
}
$page = new PageSetup('tags');
$page->setHelp('tags.html');
return $this->render('tags/edit.html.twig', [
'page_setup' => $page,
'tag' => $tag,
'form' => $editForm->createView()
]);
}
/**
* @Route(path="/create", name="tags_create", methods={"GET", "POST"})
* @Security("is_granted('manage_tag')")
*/
#[Route(path: '/create', name: 'tags_create', methods: ['GET', 'POST'])]
#[Security("is_granted('manage_tag')")]
public function createAction(TagRepository $repository, Request $request)
{
$tag = new Tag();
@@ -113,20 +127,22 @@ class TagController extends AbstractController
return $this->redirectToRoute('tags');
} catch (\Exception $ex) {
$this->flashUpdateException($ex);
$this->handleFormUpdateException($ex, $editForm);
}
}
$page = new PageSetup('tags');
$page->setHelp('tags.html');
return $this->render('tags/edit.html.twig', [
'page_setup' => $page,
'tag' => $tag,
'form' => $editForm->createView()
]);
}
/**
* @Route(path="/multi-delete", name="tags_multi_delete", methods={"POST"})
* @Security("is_granted('delete_tag')")
*/
#[Route(path: '/multi-delete', name: 'tags_multi_delete', methods: ['POST'])]
#[Security("is_granted('delete_tag')")]
public function multiDelete(TagRepository $repository, Request $request)
{
$form = $this->getMultiUpdateForm($repository);
@@ -146,7 +162,7 @@ class TagController extends AbstractController
return $this->redirectToRoute('tags');
}
protected function getMultiUpdateForm(TagRepository $repository): ?FormInterface
private function getMultiUpdateForm(TagRepository $repository): ?FormInterface
{
$dto = new MultiUpdateTableDTO();
if ($this->isGranted('delete_tag')) {
@@ -164,17 +180,12 @@ class TagController extends AbstractController
]);
}
/**
* @param TagQuery $query
* @return \Symfony\Component\Form\FormInterface
*/
protected function getToolbarForm(TagQuery $query)
private function getToolbarForm(TagQuery $query): FormInterface
{
return $this->createForm(TagToolbarForm::class, $query, [
return $this->createSearchForm(TagToolbarForm::class, $query, [
'action' => $this->generateUrl('tags', [
'page' => $query->getPage(),
]),
'method' => 'GET',
]);
}
}

View File

@@ -16,41 +16,31 @@ use App\Form\TeamProjectForm;
use App\Form\Toolbar\TeamToolbarForm;
use App\Repository\Query\TeamQuery;
use App\Repository\TeamRepository;
use App\Utils\DataTable;
use App\Utils\PageSetup;
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;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
/**
* @Route(path="/admin/teams")
* @Security("is_granted('view_team')")
*/
#[Route(path: '/admin/teams')]
#[Security("is_granted('view_team')")]
final class TeamController extends AbstractController
{
/**
* @var TeamRepository
*/
private $repository;
public function __construct(TeamRepository $repository)
public function __construct(private 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)
#[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'])]
public function listTeams(TeamRepository $repository, Request $request, $page): Response
{
$query = new TeamQuery();
$query->setPage($page);
@@ -61,69 +51,66 @@ final class TeamController extends AbstractController
return $this->redirectToRoute('admin_team');
}
$teams = $repository->getPagerfantaForQuery($query);
$entries = $repository->getPagerfantaForQuery($query);
$table = new DataTable('admin_teams', $query);
$table->setPagination($entries);
$table->setSearchForm($form);
$table->setPaginationRoute('admin_team_paginated');
$table->setReloadEvents('kimai.teamUpdate');
$table->addColumn('name', ['class' => 'alwaysVisible']);
$table->addColumn('teamlead', ['class' => 'd-none badges', 'orderBy' => false]);
$table->addColumn('teamlead_avatar', ['title' => 'team.member', 'translation_domain' => 'teams', 'class' => 'd-none d-lg-table-cell avatars avatar-list avatar-list-stacked', 'orderBy' => false]);
$table->addColumn('user', ['class' => 'd-none badges', 'orderBy' => false, 'title' => 'user']);
$table->addColumn('actions', ['class' => 'actions']);
$page = new PageSetup('teams');
$page->setActionName('teams');
$page->setHelp('teams.html');
$page->setDataTable($table);
return $this->render('team/index.html.twig', [
'teams' => $teams,
'query' => $query,
'toolbarForm' => $form->createView(),
'page_setup' => $page,
'dataTable' => $table,
]);
}
/**
* @Route(path="/create", name="admin_team_create", methods={"GET", "POST"})
* @Security("is_granted('create_team')")
*
* @param Request $request
* @return RedirectResponse|Response
* @return Response
*/
public function createTeam(Request $request)
#[Route(path: '/create', name: 'admin_team_create', methods: ['GET', 'POST'])]
#[Security("is_granted('create_team')")]
public function createTeam(Request $request): Response
{
return $this->renderEditScreen(new Team(), $request);
return $this->renderEditScreen(new Team(''), $request, true);
}
/**
* @Route(path="/{id}/duplicate/{token}", name="team_duplicate", methods={"GET", "POST"})
* @Security("is_granted('edit', team) and is_granted('create_team')")
*/
public function duplicateTeam(Team $team, string $token, CsrfTokenManagerInterface $csrfTokenManager)
#[Route(path: '/{id}/duplicate', name: 'team_duplicate', methods: ['GET', 'POST'])]
#[Security("is_granted('edit', team) and is_granted('create_team')")]
public function duplicateTeam(Team $team, Request $request)
{
if (!$csrfTokenManager->isTokenValid(new CsrfToken('team.duplicate', $token))) {
$this->flashError('action.csrf.error');
return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]);
}
$csrfTokenManager->refreshToken('team.duplicate');
$newTeam = clone $team;
$newTeam->setName($team->getName() . ' [COPY]');
try {
$this->repository->saveTeam($newTeam);
$this->flashSuccess('action.update.success');
$i = 1;
do {
$newName = sprintf('%s (%s)', $team->getName(), $i++);
} while ($this->repository->count(['name' => $newName]) > 0 && $i < 10);
$newTeam->setName($newName);
return $this->redirectToRoute('admin_team_edit', ['id' => $newTeam->getId()]);
} catch (\Exception $ex) {
$this->flashUpdateException($ex);
}
return $this->redirectToRoute('admin_team');
return $this->renderEditScreen($newTeam, $request, true);
}
/**
* @Route(path="/{id}/edit", name="admin_team_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', team)")
*/
#[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);
}
/**
* @Route(path="/{id}/edit_member", name="admin_team_member", methods={"GET", "POST"})
* @Security("is_granted('edit', team)")
*/
#[Route(path: '/{id}/edit_member', name: 'admin_team_member', methods: ['GET', 'POST'])]
#[Security("is_granted('edit', team)")]
public function editMemberAction(Team $team, Request $request)
{
$editForm = $this->createForm(TeamEditForm::class, $team, [
@@ -144,13 +131,17 @@ final class TeamController extends AbstractController
}
}
$page = new PageSetup('teams');
$page->setHelp('teams.html');
return $this->render('team/edit_member.html.twig', [
'page_setup' => $page,
'team' => $team,
'form' => $editForm->createView(),
]);
}
private function renderEditScreen(Team $team, Request $request): Response
private function renderEditScreen(Team $team, Request $request, bool $create = false): Response
{
$customerForm = null;
$projectForm = null;
@@ -173,9 +164,13 @@ final class TeamController extends AbstractController
$this->repository->saveTeam($team);
$this->flashSuccess('action.update.success');
if ($create) {
return $this->redirectToRouteAfterCreate('admin_team_edit', ['id' => $team->getId()]);
}
return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]);
} catch (\Exception $ex) {
$this->flashUpdateException($ex);
$this->handleFormUpdateException($ex, $editForm);
}
}
@@ -213,21 +208,24 @@ final class TeamController extends AbstractController
}
}
$page = new PageSetup('teams');
$page->setHelp('teams.html');
return $this->render('team/edit.html.twig', [
'page_setup' => $page,
'team' => $team,
'form' => $editForm->createView(),
'customerForm' => $customerForm ? $customerForm->createView() : null,
'projectForm' => $projectForm ? $projectForm->createView() : null,
'customerForm' => $customerForm?->createView(),
'projectForm' => $projectForm?->createView(),
]);
}
private function getToolbarForm(TeamQuery $query): FormInterface
{
return $this->createForm(TeamToolbarForm::class, $query, [
return $this->createSearchForm(TeamToolbarForm::class, $query, [
'action' => $this->generateUrl('admin_team', [
'page' => $query->getPage(),
]),
'method' => 'GET',
]);
}
}

View File

@@ -11,7 +11,6 @@ namespace App\Controller;
use App\Configuration\SystemConfiguration;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Tag;
use App\Entity\Timesheet;
use App\Event\TimesheetDuplicatePostEvent;
use App\Event\TimesheetDuplicatePreEvent;
@@ -26,12 +25,14 @@ use App\Form\TimesheetEditForm;
use App\Form\TimesheetPreCreateForm;
use App\Form\Toolbar\TimesheetExportToolbarForm;
use App\Form\Toolbar\TimesheetToolbarForm;
use App\Repository\Query\BaseQuery;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TagRepository;
use App\Repository\TimesheetRepository;
use App\Timesheet\TimesheetService;
use App\Timesheet\TrackingMode\TrackingModeInterface;
use Doctrine\Common\Collections\ArrayCollection;
use App\Utils\DataTable;
use App\Utils\PageSetup;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
@@ -40,33 +41,13 @@ use Symfony\Component\HttpFoundation\Response;
abstract class TimesheetAbstractController extends AbstractController
{
/**
* @var TimesheetRepository
*/
protected $repository;
/**
* @var EventDispatcherInterface
*/
protected $dispatcher;
/**
* @var TimesheetService
*/
protected $service;
/**
* @var SystemConfiguration
*/
protected $configuration;
public function __construct(
TimesheetRepository $repository,
EventDispatcherInterface $dispatcher,
TimesheetService $timesheetService,
SystemConfiguration $configuration
protected TimesheetRepository $repository,
protected EventDispatcherInterface $dispatcher,
protected TimesheetService $service,
protected SystemConfiguration $configuration,
protected TagRepository $tagRepository
) {
$this->repository = $repository;
$this->dispatcher = $dispatcher;
$this->service = $timesheetService;
$this->configuration = $configuration;
}
protected function getTrackingMode(): TrackingModeInterface
@@ -74,37 +55,75 @@ abstract class TimesheetAbstractController extends AbstractController
return $this->service->getActiveTrackingMode();
}
protected function index(TimesheetQuery $query, Request $request, string $route, string $renderTemplate, string $location): Response
protected function index(TimesheetQuery $query, Request $request, string $route, string $paginationRoute, string $location): Response
{
$form = $this->getToolbarForm($query);
if ($this->handleSearch($form, $request)) {
return $this->redirectToRoute($route);
}
$tags = $query->getTags(true);
if (!empty($tags)) {
/** @var TagRepository $tagRepo */
$tagRepo = $this->getDoctrine()->getRepository(Tag::class);
$query->setTags(
new ArrayCollection(
$tagRepo->findIdsByTagNameList(implode(',', $tags))
)
);
}
$canSeeRate = $this->canSeeRate();
$canSeeUsername = $this->canSeeUsername();
$this->prepareQuery($query);
$pager = $this->repository->getPagerfantaForQuery($query);
$result = $this->repository->getTimesheetResult($query);
$metaColumns = $this->findMetaColumns($query, $location);
return $this->render($renderTemplate, [
'entries' => $pager,
'page' => $query->getPage(),
'query' => $query,
'toolbarForm' => $form->createView(),
'multiUpdateForm' => $this->getMultiUpdateActionForm()->createView(),
$table = new DataTable($this->getTableName(), $query);
$table->setPagination($result->getPagerfanta());
$table->setSearchForm($form);
$table->setBatchForm($this->getMultiUpdateActionForm());
$table->setPaginationRoute($paginationRoute);
$table->setReloadEvents('kimai.timesheetUpdate kimai.timesheetDelete');
$table->addColumn('date', ['class' => 'alwaysVisible', 'orderBy' => 'begin']);
if ($this->canSeeStartEndTime()) {
$table->addColumn('starttime', ['class' => 'd-none d-sm-table-cell text-center', 'orderBy' => 'begin']);
$table->addColumn('endtime', ['class' => 'd-none d-sm-table-cell text-center', 'orderBy' => 'end']);
}
$table->addColumn('duration', ['class' => 'text-end text-nowrap']);
if ($canSeeRate) {
$table->addColumn('hourlyRate', ['class' => 'text-end d-none']);
$table->addColumn('rate', ['class' => 'text-end']);
}
$table->addColumn('customer', ['class' => 'd-none d-md-table-cell']);
$table->addColumn('project', ['class' => 'd-none d-lg-table-cell']);
$table->addColumn('activity', ['class' => 'd-none d-xl-table-cell']);
$table->addColumn('description', ['class' => 'd-none']);
$table->addColumn('tags', ['class' => 'd-none badges', 'orderBy' => false]);
foreach ($metaColumns as $metaColumn) {
$table->addColumn('mf_' . $metaColumn->getName(), ['title' => $metaColumn->getLabel(), 'class' => 'd-none', 'orderBy' => false]);
}
if ($canSeeUsername) {
$table->addColumn('username', ['class' => 'd-none d-sm-table-cell', 'orderBy' => false]);
}
$table->addColumn('billable', ['class' => 'text-center d-none w-min', 'orderBy' => false]);
$table->addColumn('exported', ['class' => 'text-center d-none w-min', 'orderBy' => false]);
$table->addColumn('actions', ['class' => 'actions']);
$page = $this->createPageSetup();
$page->setActionName($this->getActionName());
return $this->render('timesheet/index.html.twig', [
'page_setup' => $page,
'dataTable' => $table,
'action_single' => $this->getActionNameSingle(),
'canSeeUsername' => $canSeeUsername,
'canSeeRate' => $canSeeRate,
'stats' => $result->getStatistic(),
'showSummary' => $this->includeSummary(),
'showStartEndTime' => $this->canSeeStartEndTime(),
'metaColumns' => $this->findMetaColumns($query, $location),
'metaColumns' => $metaColumns,
'allowMarkdown' => $this->hasMarkdownSupport(),
'editRoute' => $this->getEditRoute()
]);
}
@@ -121,7 +140,7 @@ abstract class TimesheetAbstractController extends AbstractController
return $event->getFields();
}
protected function edit(Timesheet $entry, Request $request, string $renderTemplate): Response
protected function edit(Timesheet $entry, Request $request): Response
{
$event = new TimesheetMetaDefinitionEvent($entry);
$this->dispatcher->dispatch($event);
@@ -140,13 +159,16 @@ abstract class TimesheetAbstractController extends AbstractController
}
}
return $this->render($renderTemplate, [
return $this->render('timesheet/edit.html.twig', [
'page_setup' => $this->createPageSetup(),
'route_back' => $this->getTimesheetRoute(),
'timesheet' => $entry,
'form' => $editForm->createView(),
'template' => $this->getTrackingMode()->getEditTemplate(),
]);
}
protected function create(Request $request, string $renderTemplate): Response
protected function create(Request $request): Response
{
$entry = $this->service->createNewTimesheet($this->getUser());
@@ -166,17 +188,20 @@ abstract class TimesheetAbstractController extends AbstractController
return $this->redirectToRoute($this->getTimesheetRoute());
} catch (\Exception $ex) {
$this->flashUpdateException($ex);
$this->handleFormUpdateException($ex, $createForm);
}
}
return $this->render($renderTemplate, [
return $this->render('timesheet/edit.html.twig', [
'page_setup' => $this->createPageSetup(),
'route_back' => $this->getTimesheetRoute(),
'timesheet' => $entry,
'form' => $createForm->createView(),
'template' => $this->getTrackingMode()->getEditTemplate(),
]);
}
protected function duplicate(Timesheet $timesheet, Request $request, string $renderTemplate): Response
protected function duplicate(Timesheet $timesheet, Request $request): Response
{
$copyTimesheet = clone $timesheet;
@@ -195,25 +220,26 @@ abstract class TimesheetAbstractController extends AbstractController
return $this->redirectToRoute($this->getTimesheetRoute());
} catch (\Exception $ex) {
$this->flashUpdateException($ex);
$this->handleFormUpdateException($ex, $form);
}
}
return $this->render($renderTemplate, [
return $this->render('timesheet/edit.html.twig', [
'timesheet' => $copyTimesheet,
'form' => $form->createView(),
'template' => $this->getTrackingMode()->getEditTemplate(),
]);
}
protected function export(Request $request, ServiceExport $serviceExport): Response
{
$query = $this->createDefaultQuery();
$query->setOrder(TimesheetQuery::ORDER_ASC);
$query->setOrder(BaseQuery::ORDER_ASC);
$form = $this->getExportForm($query);
if ($request->isMethod(Request::METHOD_POST)) {
$this->ignorePersistedSearch($request);
$request->query->set('performSearch', true);
}
if ($this->handleSearch($form, $request)) {
@@ -248,6 +274,7 @@ abstract class TimesheetAbstractController extends AbstractController
}
return $this->render('timesheet/layout-export.html.twig', [
'page_setup' => new PageSetup('export'),
'form' => $form->createView(),
'route_back' => $this->getTimesheetRoute(),
'exporter' => $serviceExport->getTimesheetExporter(),
@@ -255,7 +282,7 @@ abstract class TimesheetAbstractController extends AbstractController
]);
}
protected function multiUpdate(Request $request, string $renderTemplate)
protected function multiUpdate(Request $request)
{
$dto = new TimesheetMultiUpdateDTO();
@@ -376,9 +403,11 @@ abstract class TimesheetAbstractController extends AbstractController
}
}
return $this->render($renderTemplate, [
return $this->render('timesheet/multi-update.html.twig', [
'page_setup' => $this->createPageSetup(),
'form' => $form->createView(),
'dto' => $dto,
'back' => $this->getTimesheetRoute(),
]);
}
@@ -455,10 +484,9 @@ abstract class TimesheetAbstractController extends AbstractController
'allow_end_datetime' => $mode->canEditEnd(),
'allow_duration' => $mode->canEditDuration(),
'duration_minutes' => $this->configuration->getTimesheetIncrementDuration(),
'begin_minutes' => $this->configuration->getTimesheetIncrementBegin(),
'end_minutes' => $this->configuration->getTimesheetIncrementEnd(),
'timezone' => $this->getDateTimeFactory()->getTimezone(),
'customer' => true,
'create_activity' => $this->isGranted('create_activity'),
]);
}
@@ -467,7 +495,7 @@ abstract class TimesheetAbstractController extends AbstractController
* @param int $page
* @return FormInterface
*/
protected function getEditForm(Timesheet $entry, $page)
protected function getEditForm(Timesheet $entry, $page): FormInterface
{
$mode = $this->getTrackingMode();
@@ -480,12 +508,11 @@ abstract class TimesheetAbstractController extends AbstractController
'include_exported' => $this->isGranted('edit_export', $entry),
'include_billable' => $this->isGranted('edit_billable', $entry),
'include_user' => $this->includeUserInForms('edit'),
'create_activity' => $this->isGranted('create_activity'),
'allow_begin_datetime' => $mode->canEditBegin(),
'allow_end_datetime' => $mode->canEditEnd(),
'allow_duration' => $mode->canEditDuration(),
'duration_minutes' => $this->configuration->getTimesheetIncrementDuration(),
'begin_minutes' => $this->configuration->getTimesheetIncrementBegin(),
'end_minutes' => $this->configuration->getTimesheetIncrementEnd(),
'timezone' => $this->getDateTimeFactory()->getTimezone(),
'customer' => true,
]);
@@ -493,19 +520,18 @@ abstract class TimesheetAbstractController extends AbstractController
protected function getToolbarForm(TimesheetQuery $query): FormInterface
{
return $this->createForm(TimesheetToolbarForm::class, $query, [
return $this->createSearchForm(TimesheetToolbarForm::class, $query, [
'action' => $this->generateUrl($this->getTimesheetRoute(), [
'page' => $query->getPage(),
]),
'timezone' => $this->getDateTimeFactory()->getTimezone()->getName(),
'method' => 'GET',
'include_user' => $this->includeUserInForms('toolbar'),
]);
}
protected function getExportForm(TimesheetQuery $query): FormInterface
private function getExportForm(TimesheetQuery $query): FormInterface
{
return $this->createForm(TimesheetExportToolbarForm::class, $query, [
return $this->createSearchForm(TimesheetExportToolbarForm::class, $query, [
'action' => $this->generateUrl($this->getExportRoute()),
'timezone' => $this->getDateTimeFactory()->getTimezone()->getName(),
'method' => Request::METHOD_POST,
@@ -535,7 +561,7 @@ abstract class TimesheetAbstractController extends AbstractController
protected function includeSummary(): bool
{
return (bool) $this->getUser()->getPreferenceValue('timesheet.daily_stats', false, false);
return (bool) $this->getUser()->getPreferenceValue('daily_stats', false, false);
}
protected function includeUserInForms(string $formName): bool
@@ -586,6 +612,44 @@ abstract class TimesheetAbstractController extends AbstractController
return $query;
}
protected function canSeeRate(): bool
{
return $this->isGranted('view_rate_own_timesheet');
}
protected function canSeeUsername(): bool
{
return false;
}
protected function hasMarkdownSupport(): bool
{
return true;
}
protected function getTableName(): string
{
return 'timesheet';
}
protected function getActionName(): string
{
return 'timesheets';
}
protected function getActionNameSingle(): string
{
return 'timesheet';
}
protected function createPageSetup(): PageSetup
{
$page = new PageSetup('timesheet.title');
$page->setHelp('timesheet.html');
return $page;
}
abstract protected function getDuplicateForm(Timesheet $entry, Timesheet $original): FormInterface;
abstract protected function getCreateForm(Timesheet $entry): FormInterface;

View File

@@ -19,77 +19,61 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route(path="/timesheet")
* @Security("is_granted('view_own_timesheet')")
*/
class TimesheetController extends TimesheetAbstractController
#[Route(path: '/timesheet')]
#[Security("is_granted('view_own_timesheet')")]
final class TimesheetController extends TimesheetAbstractController
{
/**
* @Route(path="/", defaults={"page": 1}, name="timesheet", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="timesheet_paginated", methods={"GET"})
* @Security("is_granted('view_own_timesheet')")
*/
#[Route(path: '/', defaults: ['page' => 1], name: 'timesheet', methods: ['GET'])]
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'timesheet_paginated', methods: ['GET'])]
#[Security("is_granted('view_own_timesheet')")]
public function indexAction(int $page, Request $request): Response
{
$query = $this->createDefaultQuery();
$query->setPage($page);
return $this->index($query, $request, 'timesheet', 'timesheet/index.html.twig', TimesheetMetaDisplayEvent::TIMESHEET);
return $this->index($query, $request, 'timesheet', 'timesheet_paginated', TimesheetMetaDisplayEvent::TIMESHEET);
}
/**
* @Route(path="/export/", name="timesheet_export", methods={"GET", "POST"})
* @Security("is_granted('export_own_timesheet')")
*/
#[Route(path: '/export/', name: 'timesheet_export', methods: ['GET', 'POST'])]
#[Security("is_granted('export_own_timesheet')")]
public function exportAction(Request $request, ServiceExport $serviceExport): Response
{
return $this->export($request, $serviceExport);
}
/**
* @Route(path="/{id}/edit", name="timesheet_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', entry)")
*/
#[Route(path: '/{id}/edit', name: 'timesheet_edit', methods: ['GET', 'POST'])]
#[Security("is_granted('edit', entry)")]
public function editAction(Timesheet $entry, Request $request): Response
{
return $this->edit($entry, $request, 'timesheet/edit.html.twig');
return $this->edit($entry, $request);
}
/**
* @Route(path="/{id}/duplicate", name="timesheet_duplicate", methods={"GET", "POST"})
* @Security("is_granted('duplicate', entry)")
*/
#[Route(path: '/{id}/duplicate', name: 'timesheet_duplicate', methods: ['GET', 'POST'])]
#[Security("is_granted('duplicate', entry)")]
public function duplicateAction(Timesheet $entry, Request $request): Response
{
return $this->duplicate($entry, $request, 'timesheet/edit.html.twig');
return $this->duplicate($entry, $request);
}
/**
* @Route(path="/multi-update", name="timesheet_multi_update", methods={"POST"})
* @Security("is_granted('edit_own_timesheet')")
*/
#[Route(path: '/multi-update', name: 'timesheet_multi_update', methods: ['POST'])]
#[Security("is_granted('edit_own_timesheet')")]
public function multiUpdateAction(Request $request): Response
{
return $this->multiUpdate($request, 'timesheet/multi-update.html.twig');
return $this->multiUpdate($request);
}
/**
* @Route(path="/multi-delete", name="timesheet_multi_delete", methods={"POST"})
* @Security("is_granted('delete_own_timesheet')")
*/
#[Route(path: '/multi-delete', name: 'timesheet_multi_delete', methods: ['POST'])]
#[Security("is_granted('delete_own_timesheet')")]
public function multiDeleteAction(Request $request): Response
{
return $this->multiDelete($request);
}
/**
* @Route(path="/create", name="timesheet_create", methods={"GET", "POST"})
* @Security("is_granted('create_own_timesheet')")
*/
#[Route(path: '/create', name: 'timesheet_create', methods: ['GET', 'POST'])]
#[Security("is_granted('create_own_timesheet')")]
public function createAction(Request $request): Response
{
return $this->create($request, 'timesheet/edit.html.twig');
return $this->create($request);
}
protected function getCreateForm(Timesheet $entry): FormInterface

View File

@@ -19,6 +19,7 @@ use App\Form\Model\MultiUserTimesheet;
use App\Form\TimesheetAdminEditForm;
use App\Form\TimesheetMultiUserEditForm;
use App\Repository\Query\TimesheetQuery;
use App\Utils\PageSetup;
use Doctrine\Common\Collections\ArrayCollection;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\Form\FormInterface;
@@ -26,69 +27,51 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route(path="/team/timesheet")
* @Security("is_granted('view_other_timesheet')")
*/
class TimesheetTeamController extends TimesheetAbstractController
#[Route(path: '/team/timesheet')]
#[Security("is_granted('view_other_timesheet')")]
final class TimesheetTeamController extends TimesheetAbstractController
{
/**
* @Route(path="/", defaults={"page": 1}, name="admin_timesheet", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_timesheet_paginated", methods={"GET"})
* @Security("is_granted('view_other_timesheet')")
*
* @param int $page
* @param Request $request
* @return Response
*/
#[Route(path: '/', defaults: ['page' => 1], name: 'admin_timesheet', methods: ['GET'])]
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_timesheet_paginated', methods: ['GET'])]
#[Security("is_granted('view_other_timesheet')")]
public function indexAction(int $page, Request $request): Response
{
$query = $this->createDefaultQuery();
$query->setPage($page);
return $this->index($query, $request, 'admin_timesheet', 'timesheet-team/index.html.twig', TimesheetMetaDisplayEvent::TEAM_TIMESHEET);
return $this->index($query, $request, 'admin_timesheet', 'admin_timesheet_paginated', TimesheetMetaDisplayEvent::TEAM_TIMESHEET);
}
/**
* @Route(path="/export/", name="admin_timesheet_export", methods={"GET", "POST"})
* @Security("is_granted('export_other_timesheet')")
*/
#[Route(path: '/export/', name: 'admin_timesheet_export', methods: ['GET', 'POST'])]
#[Security("is_granted('export_other_timesheet')")]
public function exportAction(Request $request, ServiceExport $serviceExport): Response
{
return $this->export($request, $serviceExport);
}
/**
* @Route(path="/{id}/edit", name="admin_timesheet_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', entry)")
*/
#[Route(path: '/{id}/edit', name: 'admin_timesheet_edit', methods: ['GET', 'POST'])]
#[Security("is_granted('edit', entry)")]
public function editAction(Timesheet $entry, Request $request): Response
{
return $this->edit($entry, $request, 'timesheet-team/edit.html.twig');
return $this->edit($entry, $request);
}
/**
* @Route(path="/{id}/duplicate", name="admin_timesheet_duplicate", methods={"GET", "POST"})
* @Security("is_granted('duplicate', entry)")
*/
#[Route(path: '/{id}/duplicate', name: 'admin_timesheet_duplicate', methods: ['GET', 'POST'])]
#[Security("is_granted('duplicate', entry)")]
public function duplicateAction(Timesheet $entry, Request $request): Response
{
return $this->duplicate($entry, $request, 'timesheet-team/edit.html.twig');
return $this->duplicate($entry, $request);
}
/**
* @Route(path="/create", name="admin_timesheet_create", methods={"GET", "POST"})
* @Security("is_granted('create_other_timesheet')")
*/
#[Route(path: '/create', name: 'admin_timesheet_create', methods: ['GET', 'POST'])]
#[Security("is_granted('create_other_timesheet')")]
public function createAction(Request $request): Response
{
return $this->create($request, 'timesheet-team/edit.html.twig');
return $this->create($request);
}
/**
* @Route(path="/create_mu", name="admin_timesheet_create_multiuser", methods={"GET", "POST"})
* @Security("is_granted('create_other_timesheet')")
*/
#[Route(path: '/create_mu', name: 'admin_timesheet_create_multiuser', methods: ['GET', 'POST'])]
#[Security("is_granted('create_other_timesheet')")]
public function createForMultiUserAction(Request $request): Response
{
$entry = new MultiUserTimesheet();
@@ -134,13 +117,16 @@ class TimesheetTeamController extends TimesheetAbstractController
return $this->redirectToRoute($this->getTimesheetRoute());
} catch (\Exception $ex) {
$this->flashUpdateException($ex);
// FIXME I guess this will save timesheets for some users, but then fail only for single users
// FIXME we should run in a transaction or disallow to create running timesheets
$this->handleFormUpdateException($ex, $createForm);
}
}
return $this->render('timesheet-team/edit.html.twig', [
return $this->render('timesheet/edit.html.twig', [
'timesheet' => $entry,
'form' => $createForm->createView(),
'template' => $this->getTrackingMode()->getEditTemplate(),
]);
}
@@ -158,26 +144,20 @@ class TimesheetTeamController extends TimesheetAbstractController
'allow_end_datetime' => $mode->canEditEnd(),
'allow_duration' => $mode->canEditDuration(),
'duration_minutes' => $this->configuration->getTimesheetIncrementDuration(),
'begin_minutes' => $this->configuration->getTimesheetIncrementBegin(),
'end_minutes' => $this->configuration->getTimesheetIncrementEnd(),
'timezone' => $this->getDateTimeFactory()->getTimezone()->getName(),
'customer' => true,
]);
}
/**
* @Route(path="/multi-update", name="admin_timesheet_multi_update", methods={"POST"})
* @Security("is_granted('edit_other_timesheet')")
*/
#[Route(path: '/multi-update', name: 'admin_timesheet_multi_update', methods: ['POST'])]
#[Security("is_granted('edit_other_timesheet')")]
public function multiUpdateAction(Request $request): Response
{
return $this->multiUpdate($request, 'timesheet-team/multi-update.html.twig');
return $this->multiUpdate($request);
}
/**
* @Route(path="/multi-delete", name="admin_timesheet_multi_delete", methods={"POST"})
* @Security("is_granted('delete_other_timesheet')")
*/
#[Route(path: '/multi-delete', name: 'admin_timesheet_multi_delete', methods: ['POST'])]
#[Security("is_granted('delete_other_timesheet')")]
public function multiDeleteAction(Request $request): Response
{
return $this->multiDelete($request);
@@ -261,4 +241,42 @@ class TimesheetTeamController extends TimesheetAbstractController
{
return 'TeamTimes';
}
protected function canSeeRate(): bool
{
return $this->isGranted('view_rate_other_timesheet');
}
protected function canSeeUsername(): bool
{
return true;
}
protected function hasMarkdownSupport(): bool
{
return false;
}
protected function getTableName(): string
{
return 'timesheet_admin';
}
protected function getActionName(): string
{
return 'timesheets_team';
}
protected function getActionNameSingle(): string
{
return 'timesheet_team';
}
protected function createPageSetup(): PageSetup
{
$page = new PageSetup('all_times');
$page->setHelp('timesheet.html');
return $page;
}
}

View File

@@ -22,54 +22,30 @@ use App\Repository\Query\UserFormTypeQuery;
use App\Repository\Query\UserQuery;
use App\Repository\TimesheetRepository;
use App\Repository\UserRepository;
use App\User\UserService;
use App\Utils\DataTable;
use App\Utils\PageSetup;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
/**
* Controller used to manage users in the admin part of the site.
*
* @Route(path="/admin/user")
* @Security("is_granted('view_user')")
*/
#[Route(path: '/admin/user')]
#[Security("is_granted('IS_AUTHENTICATED_FULLY') and is_granted('view_user')")]
final class UserController extends AbstractController
{
/**
* @var UserPasswordEncoderInterface
*/
private $encoder;
/**
* @var UserRepository
*/
private $repository;
/**
* @var EventDispatcherInterface
*/
private $dispatcher;
public function __construct(UserPasswordEncoderInterface $encoder, UserRepository $repository, EventDispatcherInterface $dispatcher)
public function __construct(private UserPasswordHasherInterface $passwordHasher, private UserRepository $repository, private EventDispatcherInterface $dispatcher)
{
$this->encoder = $encoder;
$this->repository = $repository;
$this->dispatcher = $dispatcher;
}
/**
* @return UserRepository
*/
protected function getRepository()
{
return $this->repository;
}
/**
* @Route(path="/", defaults={"page": 1}, name="admin_user", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_user_paginated", methods={"GET"})
*/
#[Route(path: '/', defaults: ['page' => 1], name: 'admin_user', methods: ['GET'])]
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_user_paginated', methods: ['GET'])]
public function indexAction($page, Request $request): Response
{
$query = new UserQuery();
@@ -81,15 +57,43 @@ final class UserController extends AbstractController
return $this->redirectToRoute('admin_user');
}
$entries = $this->getRepository()->getPagerfantaForQuery($query);
$entries = $this->repository->getPagerfantaForQuery($query);
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::USERS);
$this->dispatcher->dispatch($event);
$table = new DataTable('user_admin', $query);
$table->setPagination($entries);
$table->setSearchForm($form);
$table->setPaginationRoute('admin_user_paginated');
$table->setReloadEvents('kimai.userUpdate');
$table->addColumn('avatar', ['class' => 'alwaysVisible w-avatar', 'title' => null, 'orderBy' => false]);
$table->addColumn('user', ['class' => 'alwaysVisible', 'orderBy' => 'user']);
$table->addColumn('username', ['class' => 'd-none']);
$table->addColumn('alias', ['class' => 'd-none']);
$table->addColumn('account_number', ['class' => 'd-none']);
$table->addColumn('title', ['class' => 'd-none']);
$table->addColumn('email', ['class' => 'd-none', 'orderBy' => false]);
$table->addColumn('lastLogin', ['class' => 'd-none', 'orderBy' => false]);
$table->addColumn('roles', ['class' => 'd-none', 'orderBy' => false]);
foreach ($event->getPreferences() as $userPreference) {
$table->addColumn('mf_' . $userPreference->getName(), ['title' => $userPreference->getLabel(), 'class' => 'd-none', 'orderBy' => false, 'translation_domain' => 'messages', 'data' => $userPreference]);
}
$table->addColumn('team', ['class' => 'text-center w-min', 'orderBy' => false]);
$table->addColumn('active', ['class' => 'd-none w-min', 'orderBy' => false]);
$table->addColumn('actions', ['class' => 'actions']);
$page = new PageSetup('users');
$page->setHelp('users.html');
$page->setActionName('users');
$page->setDataTable($table);
return $this->render('user/index.html.twig', [
'entries' => $entries,
'query' => $query,
'toolbarForm' => $form->createView(),
'page_setup' => $page,
'dataTable' => $table,
'preferences' => $event->getPreferences(),
]);
}
@@ -105,11 +109,9 @@ final class UserController extends AbstractController
return $user;
}
/**
* @Route(path="/create", name="admin_user_create", methods={"GET", "POST"})
* @Security("is_granted('create_user')")
*/
public function createAction(Request $request, SystemConfiguration $config): Response
#[Route(path: '/create', name: 'admin_user_create', methods: ['GET', 'POST'])]
#[Security("is_granted('create_user')")]
public function createAction(Request $request, SystemConfiguration $config, UserRepository $userRepository): Response
{
$user = $this->createNewDefaultUser($config);
$editForm = $this->getCreateUserForm($user);
@@ -117,41 +119,28 @@ final class UserController extends AbstractController
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
$password = $this->encoder->encodePassword($user, $user->getPlainPassword());
$password = $this->passwordHasher->hashPassword($user, $user->getPlainPassword());
$user->setPassword($password);
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($user);
$entityManager->flush();
$userRepository->saveUser($user);
$this->flashSuccess('action.update.success');
if ($editForm->has('create_more') && $editForm->get('create_more')->getData() !== true) {
return $this->redirectToRoute('user_profile_edit', ['username' => $user->getUsername()]);
}
$firstUser = $user;
$user = $this->createNewDefaultUser($config);
$user->setLanguage($firstUser->getLanguage());
$user->setTimezone($firstUser->getTimezone());
$editForm = $this->getCreateUserForm($user);
if ($editForm->has('create_more')) {
$editForm->get('create_more')->setData(true);
}
return $this->redirectToRouteAfterCreate('user_profile_edit', ['username' => $user->getUserIdentifier()]);
}
$page = new PageSetup('users');
$page->setHelp('users.html');
return $this->render('user/create.html.twig', [
'page_setup' => $page,
'user' => $user,
'form' => $editForm->createView()
]);
}
/**
* @Route(path="/{id}/delete", name="admin_user_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', userToDelete)")
*/
public function deleteAction(User $userToDelete, Request $request, TimesheetRepository $repository): Response
#[Route(path: '/{id}/delete', name: 'admin_user_delete', methods: ['GET', 'POST'])]
#[Security("is_granted('delete', userToDelete)")]
public function deleteAction(User $userToDelete, Request $request, TimesheetRepository $repository, UserService $userService): Response
{
// $userToDelete MUST not be called $user, as $user is always the current user!
$stats = $repository->getUserStatistics($userToDelete);
@@ -181,7 +170,7 @@ final class UserController extends AbstractController
if ($deleteForm->isSubmitted() && $deleteForm->isValid()) {
try {
$this->getRepository()->deleteUser($userToDelete, $deleteForm->get('user')->getData());
$userService->deleteUser($userToDelete, $deleteForm->get('user')->getData());
$this->flashSuccess('action.delete.success');
} catch (\Exception $ex) {
$this->flashDeleteException($ex);
@@ -190,17 +179,19 @@ final class UserController extends AbstractController
return $this->redirectToRoute('admin_user');
}
$page = new PageSetup('users');
$page->setHelp('users.html');
return $this->render('user/delete.html.twig', [
'page_setup' => $page,
'user' => $userToDelete,
'stats' => $stats,
'form' => $deleteForm->createView(),
]);
}
/**
* @Route(path="/export", name="user_export", methods={"GET"})
* @Security("is_granted('view_user')")
*/
#[Route(path: '/export', name: 'user_export', methods: ['GET'])]
#[Security("is_granted('view_user')")]
public function exportAction(Request $request, UserExporter $exporter)
{
$query = new UserQuery();
@@ -214,7 +205,7 @@ final class UserController extends AbstractController
$query->resetByFormError($form->getErrors());
}
$entries = $this->getRepository()->getUsersForQuery($query);
$entries = $this->repository->getUsersForQuery($query);
$spreadsheet = $exporter->export(
$entries,
@@ -227,11 +218,10 @@ final class UserController extends AbstractController
protected function getToolbarForm(UserQuery $query): FormInterface
{
return $this->createForm(UserToolbarForm::class, $query, [
return $this->createSearchForm(UserToolbarForm::class, $query, [
'action' => $this->generateUrl('admin_user', [
'page' => $query->getPage(),
]),
'method' => 'GET',
]);
}
@@ -242,7 +232,6 @@ final class UserController extends AbstractController
'method' => 'POST',
'include_active_flag' => true,
'include_preferences' => true,
'include_add_more' => true,
'include_teams' => $this->isGranted('teams_other_profile'),
'include_roles' => $this->isGranted('roles_other_profile'),
]);

View File

@@ -13,16 +13,12 @@ use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route(path="/widgets")
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
*/
#[Route(path: '/widgets')]
#[Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")]
final class WidgetController extends AbstractController
{
/**
* @Route(path="/working-time/{year}/{week}", requirements={"year": "[1-9]\d*", "week": "[0-9]\d*"}, name="widgets_working_time_chart", methods={"GET"})
* @Security("is_granted('view_own_timesheet')")
*/
#[Route(path: '/working-time/{year}/{week}', requirements: ['year' => '[1-9]\d*', 'week' => '[0-9]\d*'], name: 'widgets_working_time_chart', methods: ['GET'])]
#[Security("is_granted('view_own_timesheet')")]
public function workingtimechartAction($year, $week): Response
{
return $this->render('widget/paginatedworkingtimechart.html.twig', [

View File

@@ -0,0 +1,94 @@
<?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\User;
use App\Entity\UserPreference;
use App\Form\Type\LanguageType;
use App\Form\Type\SkinType;
use App\Form\Type\TimezoneType;
use App\User\UserService;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
#[Route(path: '/wizard')]
#[Security("is_granted('IS_AUTHENTICATED_FULLY')")]
final class WizardController extends AbstractController
{
#[Route(path: '/{wizard}', name: 'wizard', methods: ['GET', 'POST'])]
#[Security("is_granted('view_own_timesheet')")]
public function wizard(Request $request, UserService $userService, string $wizard): Response
{
$user = $this->getUser();
if ($wizard === 'intro') {
$user->setWizardAsSeen('intro');
$userService->updateUser($user);
return $this->render('wizard/intro.html.twig', [
'percent' => 0,
'next' => 'profile',
]);
}
if ($wizard === 'profile') {
$data = [
'language' => $request->getLocale(),
'timezone' => $user->getTimezone(),
];
$form = $this->createFormBuilder($data)
->add(UserPreference::LOCALE, LanguageType::class)
->add(UserPreference::TIMEZONE, TimezoneType::class)
->add(UserPreference::SKIN, SkinType::class, [
'attr' => [
'onchange' => "document.body.classList.remove('theme-light');document.body.classList.remove('theme-light');"
],
])
->setAction($this->generateUrl('wizard', ['wizard' => 'profile']))
->setMethod('POST')
->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var array<string, string> $data */
$data = $form->getData();
$user->setLanguage($data[UserPreference::LOCALE]);
$user->setTimezone($data[UserPreference::TIMEZONE]);
$user->setPreferenceValue(UserPreference::SKIN, $data[UserPreference::SKIN]);
$user->setWizardAsSeen('profile');
$userService->updateUser($user);
return $this->redirectToRoute('wizard', ['wizard' => 'done', '_locale' => $data['language']]);
}
return $this->render('wizard/profile.html.twig', [
'percent' => \intval(100 / \count(User::WIZARDS) * 1),
'previous' => 'intro',
'next' => 'done',
'form' => $form->createView(),
]);
}
// this is a virtual step that is not registered as wizard, but instead should be shown every time
// a new wizard is introduced: so we do not register it as "seen"
if ($wizard === 'done') {
return $this->render('wizard/done.html.twig', [
'percent' => 100,
'previous' => 'profile',
]);
}
throw $this->createNotFoundException('Unknown wizard');
}
}