Release 2.1.0 (#4321)
* fix deprecations * remove unused config * replace invalid annotation type with attribute * use AsDoctrineListener to fix deprecation * new ModifiedSubscriber to support custom logic and fix deprecation * removed inheritdoc comment * new ModifiedSubscriber to support custom logic and fix deprecation * cleanup event dispatcher interface * re-order annotation params * one more doctrine based deprecation * fix query to count active timesheets * link to "all times" to identify active timesheets * link icon instead of text * fix "skin" translation in wizard * use duration filter to show duration * added login link command and controller * bump tabler theme to 1.0 * added wizard to force password reset by user * allow to configure that new accounts need to reset their password * prevent uploading twig templates by default * bump composer packages * enable sandbox and basic security measures for custom twig templates for invoice and export * bump to symfony 6.3.5 * allow to export single user reports to excel * removed broken method to reload twig cache * added api parameter to fetch user collection fully serialized * allow to replace or append description via timesheet batch update * show api username above form
This commit is contained in:
@@ -21,10 +21,10 @@ use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Model;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
#[Route(path: '/actions')]
|
||||
|
||||
@@ -26,8 +26,8 @@ use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
namespace App\API;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\Query\BaseQuery;
|
||||
use App\Timesheet\DateTimeFactory;
|
||||
use App\Utils\Pagination;
|
||||
use FOS\RestBundle\View\View;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FormType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormTypeInterface;
|
||||
|
||||
@@ -30,13 +30,13 @@ abstract class BaseApiController extends AbstractController
|
||||
|
||||
/**
|
||||
* @template TFormType of FormTypeInterface<TData>
|
||||
* @template TData of mixed
|
||||
* @template TData of BaseQuery
|
||||
* @param class-string<TFormType> $type
|
||||
* @param TData|null $data
|
||||
* @param TData $data
|
||||
* @param array<mixed> $options
|
||||
* @return FormInterface<TData|null>
|
||||
* @return FormInterface<BaseQuery>
|
||||
*/
|
||||
protected function createSearchForm(string $type = FormType::class, mixed $data = null, array $options = []): FormInterface
|
||||
protected function createSearchForm(string $type, BaseQuery $data, array $options = []): FormInterface
|
||||
{
|
||||
return $this->container
|
||||
->get('form.factory')
|
||||
|
||||
@@ -26,8 +26,8 @@ use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
@@ -27,8 +27,8 @@ use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
@@ -33,6 +33,7 @@ use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -40,7 +41,6 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\Validator\Constraints;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
#[Route(path: '/timesheets')]
|
||||
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
|
||||
@@ -93,7 +93,7 @@ final class TimesheetController extends BaseApiController
|
||||
#[Rest\QueryParam(name: 'exported', requirements: '0|1', strict: true, nullable: true, description: 'Use this flag if you want to filter for export state. Allowed values: 0=not exported, 1=exported (default: all)')]
|
||||
#[Rest\QueryParam(name: 'active', requirements: '0|1', strict: true, nullable: true, description: 'Filter for running/active records. Allowed values: 0=stopped, 1=active (default: all)')]
|
||||
#[Rest\QueryParam(name: 'billable', requirements: '0|1', strict: true, nullable: true, description: 'Filter for non-/billable records. Allowed values: 0=non-billable, 1=billable (default: all)')]
|
||||
#[Rest\QueryParam(name: 'full', strict: true, nullable: true, description: 'Allows to fetch fully serialized objects including subresources. Allowed values: true (default: false)')]
|
||||
#[Rest\QueryParam(name: 'full', requirements: '0|1|true|false', strict: true, nullable: true, description: 'Allows to fetch full objects including subresources. Allowed values: 0|1|false|true (default: false)')]
|
||||
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
|
||||
#[Rest\QueryParam(name: 'modified_after', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records changed after this date will be included (format: HTML5). Available since Kimai 1.10 and works only for records that were created/updated since then.')]
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository, ProjectRepository $projectRepository, ActivityRepository $activityRepository, UserRepository $userRepository): Response
|
||||
@@ -257,7 +257,8 @@ final class TimesheetController extends BaseApiController
|
||||
$view = new View($results, 200);
|
||||
$this->addPagination($view, $data);
|
||||
|
||||
if (null !== $paramFetcher->get('full')) {
|
||||
$full = $paramFetcher->get('full');
|
||||
if ($full === '1' || $full === 'true') {
|
||||
$view->getContext()->setGroups(self::GROUPS_COLLECTION_FULL);
|
||||
} else {
|
||||
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
|
||||
|
||||
@@ -23,7 +23,7 @@ use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
@@ -39,6 +39,7 @@ final class UserController extends BaseApiController
|
||||
public const GROUPS_ENTITY = ['Default', 'Entity', 'User', 'User_Entity'];
|
||||
public const GROUPS_FORM = ['Default', 'Entity', 'User', 'User_Entity'];
|
||||
public const GROUPS_COLLECTION = ['Default', 'Collection', 'User'];
|
||||
public const GROUPS_COLLECTION_FULL = ['Default', 'Collection', 'User', 'User_Entity'];
|
||||
|
||||
public function __construct(
|
||||
private ViewHandlerInterface $viewHandler,
|
||||
@@ -60,6 +61,7 @@ final class UserController extends BaseApiController
|
||||
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|username|alias|email', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, username, alias, email (default: username)')]
|
||||
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
|
||||
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
|
||||
#[Rest\QueryParam(name: 'full', requirements: '0|1|true|false', strict: true, nullable: true, description: 'Allows to fetch full objects including subresources. Allowed values: 0|1|false|true (default: false)')]
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$query = new UserQuery();
|
||||
@@ -88,7 +90,13 @@ final class UserController extends BaseApiController
|
||||
$query->setIsApiCall(true);
|
||||
$data = $this->repository->getUsersForQuery($query);
|
||||
$view = new View($data, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
|
||||
|
||||
$full = $paramFetcher->get('full');
|
||||
if ($full === '1' || $full === 'true') {
|
||||
$view->getContext()->setGroups(self::GROUPS_COLLECTION_FULL);
|
||||
} else {
|
||||
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
|
||||
}
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ use App\Event\ActivityUpdatePreEvent;
|
||||
use App\Repository\ActivityRepository;
|
||||
use App\Validator\ValidationFailedException;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,7 +18,7 @@ use App\Event\CalendarSourceEvent;
|
||||
use App\Event\RecentActivityEvent;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Utils\Color;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
final class CalendarService
|
||||
{
|
||||
|
||||
@@ -33,9 +33,6 @@ final class DemoteUserCommand extends AbstractRoleCommand
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role): void
|
||||
{
|
||||
$username = $user->getUserIdentifier();
|
||||
|
||||
@@ -22,6 +22,7 @@ use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Timesheet\DateTimeFactory;
|
||||
use App\Utils\SearchTerm;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
@@ -32,7 +33,6 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
#[AsCommand(name: 'kimai:invoice:create')]
|
||||
final class InvoiceCreateCommand extends Command
|
||||
|
||||
89
src/Command/UserLoginLinkCommand.php
Normal file
89
src/Command/UserLoginLinkCommand.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?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\Command;
|
||||
|
||||
use App\Repository\UserRepository;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
|
||||
|
||||
#[AsCommand(name: 'kimai:user:login-link', description: 'Create a URL that can be used to login as that user', hidden: true)]
|
||||
/**
|
||||
* @CloudRequired
|
||||
*/
|
||||
final class UserLoginLinkCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private LoginLinkHandlerInterface $loginLink,
|
||||
private UserRepository $userRepository,
|
||||
private RequestStack $requestStack
|
||||
)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->addArgument('email', InputArgument::REQUIRED, 'The email of the user');
|
||||
$this->addOption('password-reset', null, InputOption::VALUE_NONE, 'Whether the user needs to reset the password afterwards');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$email = $input->getArgument('email');
|
||||
if ($email === null || $email === '') {
|
||||
$io->error('Need email to create login URL');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$user = $this->userRepository->findOneBy(['email' => $email]);
|
||||
|
||||
if ($user === null) {
|
||||
$io->error('Need username to create login URL');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!$user->isEnabled()) {
|
||||
$io->error('User is not enabled');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!$user->isInternalUser()) {
|
||||
$io->error('User does not use internal login');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$request = new Request();
|
||||
$request->setLocale($user->getLocale());
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$loginLinkDetails = $this->loginLink->createLoginLink($user, $request);
|
||||
$loginLink = $loginLinkDetails->getUrl();
|
||||
|
||||
if ($input->getOption('password-reset') === true) {
|
||||
$user->setPasswordRequestedAt(new \DateTime());
|
||||
$user->setRequiresPasswordReset(true);
|
||||
$this->userRepository->saveUser($user);
|
||||
}
|
||||
|
||||
$output->writeln($loginLink);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,11 @@ class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '2.0.35';
|
||||
public const VERSION = '2.1.0';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 20035;
|
||||
public const VERSION_ID = 20100;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
@@ -31,7 +31,7 @@ class Constants
|
||||
*/
|
||||
public const GITHUB = 'https://github.com/kimai/kimai/';
|
||||
/**
|
||||
* The Github repository name
|
||||
* The GitHub repository name
|
||||
*/
|
||||
public const GITHUB_REPO = 'kimai/kimai';
|
||||
/**
|
||||
|
||||
@@ -17,7 +17,6 @@ use App\Timesheet\DateTimeFactory;
|
||||
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\Form\FormTypeInterface;
|
||||
@@ -76,13 +75,13 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
|
||||
/**
|
||||
* @template TFormType of FormTypeInterface<TData>
|
||||
* @template TData of mixed
|
||||
* @template TData of array|object
|
||||
* @param class-string<TFormType> $type
|
||||
* @param TData|null $data
|
||||
* @param TData $data
|
||||
* @param array<mixed> $options
|
||||
* @return FormInterface<TData|null>
|
||||
* @return FormInterface<TData>
|
||||
*/
|
||||
protected function createFormWithName(string $name, string $type = FormType::class, mixed $data = null, array $options = []): FormInterface
|
||||
protected function createFormWithName(string $name, string $type, mixed $data, array $options = []): FormInterface
|
||||
{
|
||||
return $this->container->get('form.factory')->createNamed($name, $type, $data, $options);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ use App\Repository\TeamRepository;
|
||||
use App\Utils\DataTable;
|
||||
use App\Utils\PageSetup;
|
||||
use Exception;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
@@ -16,10 +16,10 @@ use App\Reporting\YearByUser\YearByUser;
|
||||
use App\Utils\PageSetup;
|
||||
use App\WorkingTime\Model\BoxConfiguration;
|
||||
use App\WorkingTime\WorkingTimeService;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
/**
|
||||
* Users can control their working time statistics
|
||||
|
||||
@@ -36,7 +36,7 @@ use App\Repository\Query\ProjectQuery;
|
||||
use App\Repository\TeamRepository;
|
||||
use App\Utils\DataTable;
|
||||
use App\Utils\PageSetup;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
@@ -16,7 +16,7 @@ use App\Repository\BookmarkRepository;
|
||||
use App\Utils\PageSetup;
|
||||
use App\Widget\WidgetInterface;
|
||||
use App\Widget\WidgetService;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
@@ -13,7 +13,7 @@ use App\Configuration\LocaleService;
|
||||
use App\Entity\User;
|
||||
use App\Event\ConfigureMainMenuEvent;
|
||||
use App\Repository\UserRepository;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
@@ -38,6 +38,7 @@ use App\Repository\Query\InvoiceQuery;
|
||||
use App\Utils\DataTable;
|
||||
use App\Utils\PageSetup;
|
||||
use Exception;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FormType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
@@ -48,7 +49,6 @@ use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Csrf\CsrfToken;
|
||||
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
@@ -468,36 +468,6 @@ final class InvoiceController extends AbstractController
|
||||
throw $this->createNotFoundException('Unknown document: ' . $document);
|
||||
}
|
||||
|
||||
#[Route(path: '/document_reload/{document}', name: 'admin_invoice_document_reload', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('upload_invoice_template')]
|
||||
public function reloadDocument(string $document, Environment $twig): Response
|
||||
{
|
||||
$event = new InvoiceDocumentsEvent($this->service->getDocuments(true));
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
$reloaded = false;
|
||||
|
||||
foreach ($event->getInvoiceDocuments() as $doc) {
|
||||
if ($document === $doc->getId() && $doc->isTwig()) {
|
||||
$reloaded = true;
|
||||
try {
|
||||
$twig->enableAutoReload();
|
||||
$twig->load('@invoice/' . basename($doc->getFilename()));
|
||||
$twig->disableAutoReload();
|
||||
$this->flashSuccess('Reloaded template');
|
||||
} catch (Exception $ex) {
|
||||
$this->flashException($ex, 'Failed to reload template: ' . $ex->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$reloaded) {
|
||||
throw $this->createNotFoundException('Unknown document: ' . $document);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_document_upload');
|
||||
}
|
||||
|
||||
#[Route(path: '/document_upload', name: 'admin_invoice_document_upload', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('upload_invoice_template')]
|
||||
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository, Environment $twig, SystemConfiguration $systemConfiguration): Response
|
||||
|
||||
@@ -22,7 +22,7 @@ use App\Security\RolePermissionManager;
|
||||
use App\Security\RoleService;
|
||||
use App\User\PermissionService;
|
||||
use App\Utils\PageSetup;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
@@ -32,8 +32,8 @@ use Endroid\QrCode\Encoding\Encoding;
|
||||
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh;
|
||||
use Endroid\QrCode\RoundBlockSizeMode\RoundBlockSizeModeMargin;
|
||||
use Endroid\QrCode\Writer\PngWriter;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
@@ -40,7 +40,7 @@ use App\Repository\TeamRepository;
|
||||
use App\Utils\Context;
|
||||
use App\Utils\DataTable;
|
||||
use App\Utils\PageSetup;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
@@ -42,7 +42,7 @@ final class ReportUsersMonthController extends AbstractController
|
||||
{
|
||||
$data = $this->getData($request, $statisticService, $userRepository);
|
||||
|
||||
$content = $this->container->get('twig')->render('reporting/report_user_list_export.html.twig', $data);
|
||||
$content = $this->renderView('reporting/report_user_list_export.html.twig', $data);
|
||||
|
||||
$reader = new Html();
|
||||
$spreadsheet = $reader->loadFromString($content);
|
||||
|
||||
@@ -42,7 +42,7 @@ final class ReportUsersWeekController extends AbstractController
|
||||
{
|
||||
$data = $this->getData($request, $statisticService, $userRepository);
|
||||
|
||||
$content = $this->container->get('twig')->render('reporting/report_user_list_export.html.twig', $data);
|
||||
$content = $this->renderView('reporting/report_user_list_export.html.twig', $data);
|
||||
|
||||
$reader = new Html();
|
||||
$spreadsheet = $reader->loadFromString($content);
|
||||
|
||||
@@ -19,7 +19,6 @@ use App\Reporting\YearlyUserList\YearlyUserListForm;
|
||||
use App\Repository\Query\UserQuery;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Timesheet\TimesheetStatisticService;
|
||||
use Exception;
|
||||
use PhpOffice\PhpSpreadsheet\Reader\Html;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -30,11 +29,6 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
#[IsGranted('report:other')]
|
||||
final class ReportUsersYearController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @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
|
||||
{
|
||||
@@ -44,17 +38,12 @@ final class ReportUsersYearController extends AbstractController
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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);
|
||||
|
||||
$content = $this->container->get('twig')->render('reporting/report_user_list_monthly_export.html.twig', $data);
|
||||
$content = $this->renderView('reporting/report_user_list_monthly_export.html.twig', $data);
|
||||
|
||||
$reader = new Html();
|
||||
$spreadsheet = $reader->loadFromString($content);
|
||||
|
||||
@@ -10,10 +10,13 @@
|
||||
namespace App\Controller\Reporting;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
|
||||
use App\Export\Spreadsheet\Writer\XlsxWriter;
|
||||
use App\Model\DailyStatistic;
|
||||
use App\Reporting\MonthByUser\MonthByUser;
|
||||
use App\Reporting\MonthByUser\MonthByUserForm;
|
||||
use Exception;
|
||||
use PhpOffice\PhpSpreadsheet\Reader\Html;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
@@ -35,6 +38,21 @@ final class UserMonthController extends AbstractUserReportController
|
||||
return $this->render('reporting/report_by_user.html.twig', $this->getData($request));
|
||||
}
|
||||
|
||||
#[Route(path: '/month_export', name: 'report_user_month_export', methods: ['GET', 'POST'])]
|
||||
public function export(Request $request): Response
|
||||
{
|
||||
$data = $this->getData($request);
|
||||
|
||||
$content = $this->renderView('reporting/report_by_user_data.html.twig', $data);
|
||||
|
||||
$reader = new Html();
|
||||
$spreadsheet = $reader->loadFromString($content);
|
||||
|
||||
$writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-export-user-monthly');
|
||||
|
||||
return $writer->getFileResponse($spreadsheet);
|
||||
}
|
||||
|
||||
private function getData(Request $request): array
|
||||
{
|
||||
$currentUser = $this->getUser();
|
||||
@@ -95,6 +113,7 @@ final class UserMonthController extends AbstractUserReportController
|
||||
'current' => $start,
|
||||
'next' => $nextMonth,
|
||||
'previous' => $previousMonth,
|
||||
'export_route' => 'report_user_month_export',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,13 @@
|
||||
|
||||
namespace App\Controller\Reporting;
|
||||
|
||||
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
|
||||
use App\Export\Spreadsheet\Writer\XlsxWriter;
|
||||
use App\Model\DailyStatistic;
|
||||
use App\Reporting\WeekByUser\WeekByUser;
|
||||
use App\Reporting\WeekByUser\WeekByUserForm;
|
||||
use Exception;
|
||||
use PhpOffice\PhpSpreadsheet\Reader\Html;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
@@ -34,6 +37,21 @@ final class UserWeekController extends AbstractUserReportController
|
||||
return $this->render('reporting/report_by_user.html.twig', $this->getData($request));
|
||||
}
|
||||
|
||||
#[Route(path: '/week_export', name: 'report_user_week_export', methods: ['GET', 'POST'])]
|
||||
public function export(Request $request): Response
|
||||
{
|
||||
$data = $this->getData($request);
|
||||
|
||||
$content = $this->renderView('reporting/report_by_user_data.html.twig', $data);
|
||||
|
||||
$reader = new Html();
|
||||
$spreadsheet = $reader->loadFromString($content);
|
||||
|
||||
$writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-export-user-weekly');
|
||||
|
||||
return $writer->getFileResponse($spreadsheet);
|
||||
}
|
||||
|
||||
private function getData(Request $request): array
|
||||
{
|
||||
$currentUser = $this->getUser();
|
||||
@@ -88,6 +106,7 @@ final class UserWeekController extends AbstractUserReportController
|
||||
'current' => $start,
|
||||
'next' => $next,
|
||||
'previous' => $previous,
|
||||
'export_route' => 'report_user_week_export',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ namespace App\Controller\Reporting;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\User;
|
||||
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
|
||||
use App\Export\Spreadsheet\Writer\XlsxWriter;
|
||||
use App\Model\DateStatisticInterface;
|
||||
use App\Model\MonthlyStatistic;
|
||||
use App\Reporting\YearByUser\YearByUser;
|
||||
@@ -18,6 +20,7 @@ use App\Reporting\YearByUser\YearByUserForm;
|
||||
use DateTime;
|
||||
use DateTimeInterface;
|
||||
use Exception;
|
||||
use PhpOffice\PhpSpreadsheet\Reader\Html;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
@@ -39,6 +42,21 @@ final class UserYearController extends AbstractUserReportController
|
||||
return $this->render('reporting/report_by_user_year.html.twig', $this->getData($request, $systemConfiguration));
|
||||
}
|
||||
|
||||
#[Route(path: '/year_export', name: 'report_user_year_export', methods: ['GET', 'POST'])]
|
||||
public function export(Request $request, SystemConfiguration $systemConfiguration): Response
|
||||
{
|
||||
$data = $this->getData($request, $systemConfiguration);
|
||||
|
||||
$content = $this->renderView('reporting/report_by_user_year_export.html.twig', $data);
|
||||
|
||||
$reader = new Html();
|
||||
$spreadsheet = $reader->loadFromString($content);
|
||||
|
||||
$writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-export-user-yearly');
|
||||
|
||||
return $writer->getFileResponse($spreadsheet);
|
||||
}
|
||||
|
||||
private function getData(Request $request, SystemConfiguration $systemConfiguration): array
|
||||
{
|
||||
$currentUser = $this->getUser();
|
||||
@@ -104,6 +122,7 @@ final class UserYearController extends AbstractUserReportController
|
||||
'current' => $start,
|
||||
'next' => $next,
|
||||
'previous' => $previous,
|
||||
'export_route' => 'report_user_year_export',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
27
src/Controller/Security/LoginLinkController.php
Normal file
27
src/Controller/Security/LoginLinkController.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?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\Security;
|
||||
|
||||
use App\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
#[Route(path: '/auth/link')]
|
||||
/**
|
||||
* @CloudRequired
|
||||
*/
|
||||
final class LoginLinkController extends AbstractController
|
||||
{
|
||||
#[Route(path: '/check', name: 'link_login_check', methods: ['GET'])]
|
||||
public function check(): Response
|
||||
{
|
||||
return new Response();
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ use App\Form\PasswordResetForm;
|
||||
use App\User\LoginManager;
|
||||
use App\User\UserService;
|
||||
use DateTime;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -26,7 +27,6 @@ use Symfony\Component\Mime\Address;
|
||||
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')]
|
||||
@@ -108,7 +108,7 @@ final class PasswordResetController extends AbstractController
|
||||
}
|
||||
|
||||
return $this->render('security/password-reset/check_email.html.twig', [
|
||||
'tokenLifetime' => ceil($this->configuration->getPasswordResetRetryLifetime() / 3600),
|
||||
'tokenLifetime' => $this->configuration->getPasswordResetRetryLifetime(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Event\EmailSelfRegistrationEvent;
|
||||
use App\Form\SelfRegistrationForm;
|
||||
use App\User\LoginManager;
|
||||
use App\User\UserService;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -27,7 +28,6 @@ use Symfony\Component\Mime\Email;
|
||||
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')]
|
||||
|
||||
@@ -36,7 +36,7 @@ use App\Utils\PageSetup;
|
||||
use App\Validator\Constraints\ColorChoices;
|
||||
use App\Validator\Constraints\DateTimeFormat;
|
||||
use App\Validator\Constraints\TimeFormat;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CountryType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CurrencyType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
|
||||
@@ -33,7 +33,7 @@ use App\Timesheet\TimesheetService;
|
||||
use App\Timesheet\TrackingMode\TrackingModeInterface;
|
||||
use App\Utils\DataTable;
|
||||
use App\Utils\PageSetup;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -334,6 +334,13 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
$execute = false;
|
||||
/** @var Timesheet $timesheet */
|
||||
foreach ($timesheets as $timesheet) {
|
||||
if ($dto->isReplaceDescription()) {
|
||||
$timesheet->setDescription($dto->getDescription());
|
||||
$execute = true;
|
||||
} elseif($dto->getDescription() !== null && $dto->getDescription() !== '') {
|
||||
$timesheet->setDescription($timesheet->getDescription() . PHP_EOL . $dto->getDescription());
|
||||
$execute = true;
|
||||
}
|
||||
if ($dto->isReplaceTags()) {
|
||||
foreach ($timesheet->getTags() as $tag) {
|
||||
$timesheet->removeTag($tag);
|
||||
|
||||
@@ -25,7 +25,7 @@ use App\Repository\UserRepository;
|
||||
use App\User\UserService;
|
||||
use App\Utils\DataTable;
|
||||
use App\Utils\PageSetup;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Entity\UserPreference;
|
||||
use App\Form\Type\LanguageType;
|
||||
use App\Form\Type\SkinType;
|
||||
use App\Form\Type\TimezoneType;
|
||||
use App\Form\UserPasswordType;
|
||||
use App\User\UserService;
|
||||
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -58,6 +59,11 @@ final class WizardController extends AbstractController
|
||||
->setMethod('POST')
|
||||
->getForm();
|
||||
|
||||
$next = 'done';
|
||||
if ($user->requiresPasswordReset()) {
|
||||
$next = 'password';
|
||||
}
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
@@ -72,13 +78,44 @@ final class WizardController extends AbstractController
|
||||
if ($data['reload'] === '1') {
|
||||
return $this->redirectToRoute('wizard', ['wizard' => 'profile', '_locale' => $data['language']]);
|
||||
} else {
|
||||
return $this->redirectToRoute('wizard', ['wizard' => 'done', '_locale' => $data['language']]);
|
||||
return $this->redirectToRoute('wizard', ['wizard' => $next, '_locale' => $data['language']]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('wizard/profile.html.twig', [
|
||||
'percent' => \intval(100 / \count(User::WIZARDS) * 1),
|
||||
'previous' => 'intro',
|
||||
'next' => $next,
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($wizard === 'password' || $user->requiresPasswordReset()) {
|
||||
$form = $this->createForm(UserPasswordType::class, $user, [
|
||||
'action' => $this->generateUrl('wizard', ['wizard' => 'password']),
|
||||
'method' => 'POST',
|
||||
]);
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$user->setRequiresPasswordReset(false);
|
||||
$userService->updateUser($user);
|
||||
|
||||
return $this->redirectToRoute('wizard', ['wizard' => 'done']);
|
||||
}
|
||||
|
||||
$previous = 'profile';
|
||||
$percent = \intval(100 / \count(User::WIZARDS) * 1);
|
||||
|
||||
if ($user->requiresPasswordReset()) {
|
||||
$previous = null;
|
||||
$percent = null;
|
||||
}
|
||||
|
||||
return $this->render('wizard/password.html.twig', [
|
||||
'percent' => $percent,
|
||||
'previous' => $previous,
|
||||
'next' => 'done',
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
|
||||
@@ -22,7 +22,7 @@ use App\Repository\Query\CustomerQuery;
|
||||
use App\Utils\NumberGenerator;
|
||||
use App\Validator\ValidationFailedException;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
final class CustomerService
|
||||
|
||||
@@ -343,7 +343,7 @@ final class Configuration implements ConfigurationInterface
|
||||
->defaultValue('{Y}/{cy,3}')
|
||||
->end()
|
||||
->booleanNode('upload_twig')
|
||||
->defaultTrue()
|
||||
->defaultFalse()
|
||||
->end()
|
||||
->end()
|
||||
;
|
||||
|
||||
50
src/Doctrine/ModifiedSubscriber.php
Normal file
50
src/Doctrine/ModifiedSubscriber.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?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\Doctrine;
|
||||
|
||||
use App\Entity\Timesheet;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\Common\EventSubscriber;
|
||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
|
||||
/**
|
||||
* Automatically set the modifiedAt field for all Timesheet entries.
|
||||
*/
|
||||
#[AsDoctrineListener(event: Events::onFlush, priority: 60)]
|
||||
final class ModifiedSubscriber implements EventSubscriber, DataSubscriberInterface
|
||||
{
|
||||
public function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
Events::onFlush,
|
||||
];
|
||||
}
|
||||
|
||||
public function onFlush(OnFlushEventArgs $args): void
|
||||
{
|
||||
$uow = $args->getObjectManager()->getUnitOfWork();
|
||||
$now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
|
||||
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
if (!($entity instanceof Timesheet)) {
|
||||
continue;
|
||||
}
|
||||
$entity->setModifiedAt($now);
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
if (!($entity instanceof Timesheet)) {
|
||||
continue;
|
||||
}
|
||||
$entity->setModifiedAt($now);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ namespace App\Doctrine;
|
||||
|
||||
use App\Entity\Timesheet;
|
||||
use App\Timesheet\CalculatorInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\Common\EventSubscriber;
|
||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
@@ -18,6 +19,7 @@ use Doctrine\ORM\Events;
|
||||
/**
|
||||
* A listener to make sure all Timesheet entries will be calculated properly (e.g. duration and rates).
|
||||
*/
|
||||
#[AsDoctrineListener(event: Events::onFlush, priority: 50)]
|
||||
final class TimesheetSubscriber implements EventSubscriber, DataSubscriberInterface
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -44,7 +44,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
|
||||
#[Exporter\Expose(label: 'id', type: 'integer')]
|
||||
private ?int $id = null;
|
||||
#[ORM\ManyToOne(targetEntity: Project::class)]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: true)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Subresource', 'Expanded'])]
|
||||
#[OA\Property(ref: '#/components/schemas/ProjectExpanded')]
|
||||
@@ -87,7 +87,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
|
||||
*
|
||||
* @var Collection<ActivityMeta>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: ActivityMeta::class, mappedBy: 'activity', cascade: ['persist'])]
|
||||
#[ORM\OneToMany(mappedBy: 'activity', targetEntity: ActivityMeta::class, cascade: ['persist'])]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Activity'])]
|
||||
#[Serializer\Type(name: 'array<App\Entity\ActivityMeta>')]
|
||||
@@ -102,7 +102,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
|
||||
#[ORM\JoinTable(name: 'kimai2_activities_teams')]
|
||||
#[ORM\JoinColumn(name: 'activity_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'team_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\ManyToMany(targetEntity: Team::class, cascade: ['persist'], inversedBy: 'activities')]
|
||||
#[ORM\ManyToMany(targetEntity: Team::class, inversedBy: 'activities', cascade: ['persist'])]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Activity'])]
|
||||
#[OA\Property(type: 'array', items: new OA\Items(ref: '#/components/schemas/Team'))]
|
||||
|
||||
@@ -23,7 +23,7 @@ class ActivityMeta implements MetaTableTypeInterface
|
||||
use MetaTableTypeTrait;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Activity::class, inversedBy: 'meta')]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
private ?Activity $activity = null;
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ class ActivityRate implements RateInterface
|
||||
use Rate;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Activity::class)]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
private ?Activity $activity = null;
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ class Bookmark
|
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
|
||||
private ?int $id = null;
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
private ?User $user = null;
|
||||
#[ORM\Column(name: 'type', type: 'string', length: 20, nullable: false)]
|
||||
|
||||
@@ -22,7 +22,7 @@ trait CommentTableTypeTrait
|
||||
#[Assert\NotNull]
|
||||
private ?string $message = null;
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
private ?User $createdBy = null;
|
||||
#[ORM\Column(name: 'created_at', type: 'datetime', nullable: false)]
|
||||
|
||||
@@ -156,7 +156,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
|
||||
*
|
||||
* @var Collection<CustomerMeta>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: CustomerMeta::class, mappedBy: 'customer', cascade: ['persist'])]
|
||||
#[ORM\OneToMany(mappedBy: 'customer', targetEntity: CustomerMeta::class, cascade: ['persist'])]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer'])]
|
||||
#[Serializer\Type(name: 'array<App\Entity\CustomerMeta>')]
|
||||
@@ -171,7 +171,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
|
||||
#[ORM\JoinTable(name: 'kimai2_customers_teams')]
|
||||
#[ORM\JoinColumn(name: 'customer_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'team_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\ManyToMany(targetEntity: Team::class, cascade: ['persist'], inversedBy: 'customers')]
|
||||
#[ORM\ManyToMany(targetEntity: Team::class, inversedBy: 'customers', cascade: ['persist'])]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer'])]
|
||||
#[OA\Property(type: 'array', items: new OA\Items(ref: '#/components/schemas/Team'))]
|
||||
@@ -180,7 +180,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
|
||||
* Default invoice template for this customer
|
||||
*/
|
||||
#[ORM\ManyToOne(targetEntity: InvoiceTemplate::class)]
|
||||
#[ORM\JoinColumn(onDelete: 'SET NULL', nullable: true)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?InvoiceTemplate $invoiceTemplate = null;
|
||||
#[ORM\Column(name: 'invoice_text', type: 'text', nullable: true)]
|
||||
private ?string $invoiceText = null;
|
||||
|
||||
@@ -21,7 +21,7 @@ class CustomerComment implements CommentInterface
|
||||
use CommentTableTypeTrait;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Customer::class)]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
private Customer $customer;
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ class CustomerMeta implements MetaTableTypeInterface
|
||||
use MetaTableTypeTrait;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Customer::class, inversedBy: 'meta')]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
private ?Customer $customer = null;
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ class CustomerRate implements RateInterface
|
||||
use Rate;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Customer::class)]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
private ?Customer $customer = null;
|
||||
|
||||
|
||||
@@ -56,11 +56,11 @@ class Invoice implements EntityWithMetaFields
|
||||
#[Exporter\Expose(label: 'comment')]
|
||||
private ?string $comment = null;
|
||||
#[ORM\ManyToOne(targetEntity: Customer::class)]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
private ?Customer $customer = null;
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
private ?User $user = null;
|
||||
#[ORM\Column(name: 'created_at', type: 'datetime', nullable: false)]
|
||||
@@ -109,7 +109,7 @@ class Invoice implements EntityWithMetaFields
|
||||
*
|
||||
* @var Collection<InvoiceMeta>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: InvoiceMeta::class, mappedBy: 'invoice', cascade: ['persist'])]
|
||||
#[ORM\OneToMany(mappedBy: 'invoice', targetEntity: InvoiceMeta::class, cascade: ['persist'])]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Invoice'])]
|
||||
#[Serializer\Type(name: 'array<App\Entity\InvoiceMeta>')]
|
||||
|
||||
@@ -23,7 +23,7 @@ class InvoiceMeta implements MetaTableTypeInterface
|
||||
use MetaTableTypeTrait;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Invoice::class, inversedBy: 'meta')]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
private ?Invoice $invoice = null;
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget
|
||||
* Customer for this project
|
||||
*/
|
||||
#[ORM\ManyToOne(targetEntity: Customer::class)]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Subresource', 'Expanded'])]
|
||||
@@ -137,7 +137,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget
|
||||
*
|
||||
* @var Collection<ProjectMeta>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: ProjectMeta::class, mappedBy: 'project', cascade: ['persist'])]
|
||||
#[ORM\OneToMany(mappedBy: 'project', targetEntity: ProjectMeta::class, cascade: ['persist'])]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Project'])]
|
||||
#[Serializer\Type(name: 'array<App\Entity\ProjectMeta>')]
|
||||
@@ -152,7 +152,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget
|
||||
#[ORM\JoinTable(name: 'kimai2_projects_teams')]
|
||||
#[ORM\JoinColumn(name: 'project_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'team_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\ManyToMany(targetEntity: Team::class, cascade: ['persist'], inversedBy: 'projects')]
|
||||
#[ORM\ManyToMany(targetEntity: Team::class, inversedBy: 'projects', cascade: ['persist'])]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Project'])]
|
||||
#[OA\Property(type: 'array', items: new OA\Items(ref: '#/components/schemas/Team'))]
|
||||
|
||||
@@ -21,7 +21,7 @@ class ProjectComment implements CommentInterface
|
||||
use CommentTableTypeTrait;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Project::class)]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
private Project $project;
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ class ProjectMeta implements MetaTableTypeInterface
|
||||
use MetaTableTypeTrait;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Project::class, inversedBy: 'meta')]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
private ?Project $project = null;
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ class ProjectRate implements RateInterface
|
||||
use Rate;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Project::class)]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
private ?Project $project = null;
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ trait Rate
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?int $id = null;
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: true)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[OA\Property(ref: '#/components/schemas/User')]
|
||||
|
||||
@@ -25,7 +25,7 @@ class RolePermission
|
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
|
||||
private ?int $id = null;
|
||||
#[ORM\ManyToOne(targetEntity: Role::class)]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
private ?Role $role = null;
|
||||
#[ORM\Column(name: 'permission', type: 'string', length: 50, nullable: false)]
|
||||
|
||||
@@ -39,7 +39,7 @@ class Tag
|
||||
#[ORM\Column(name: 'name', type: 'string', length: 100, nullable: false)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(min: 2, max: 100, normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: '/,/', match: false, message: 'Tag name cannot contain comma')]
|
||||
#[Assert\Regex(pattern: '/,/', message: 'Tag name cannot contain comma', match: false)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?string $name = null;
|
||||
|
||||
@@ -47,7 +47,7 @@ class Team
|
||||
*
|
||||
* @var Collection<TeamMember>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: TeamMember::class, mappedBy: 'team', fetch: 'LAZY', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OneToMany(mappedBy: 'team', targetEntity: TeamMember::class, cascade: ['persist', 'remove'], fetch: 'LAZY', orphanRemoval: true)]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE')]
|
||||
#[Assert\Count(min: 1)]
|
||||
#[Serializer\Expose]
|
||||
@@ -59,7 +59,7 @@ class Team
|
||||
*
|
||||
* @var Collection<Customer>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: Customer::class, mappedBy: 'teams', fetch: 'EXTRA_LAZY', cascade: ['persist'])]
|
||||
#[ORM\ManyToMany(targetEntity: Customer::class, mappedBy: 'teams', cascade: ['persist'], fetch: 'EXTRA_LAZY')]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Team_Entity'])]
|
||||
#[OA\Property(type: 'array', items: new OA\Items(ref: '#/components/schemas/Customer'))]
|
||||
@@ -69,7 +69,7 @@ class Team
|
||||
*
|
||||
* @var Collection<Project>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: Project::class, mappedBy: 'teams', fetch: 'EXTRA_LAZY', cascade: ['persist'])]
|
||||
#[ORM\ManyToMany(targetEntity: Project::class, mappedBy: 'teams', cascade: ['persist'], fetch: 'EXTRA_LAZY')]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Team_Entity', 'Expanded'])]
|
||||
#[OA\Property(type: 'array', items: new OA\Items(ref: '#/components/schemas/Project'))]
|
||||
@@ -79,7 +79,7 @@ class Team
|
||||
*
|
||||
* @var Collection<Activity>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: Activity::class, mappedBy: 'teams', fetch: 'EXTRA_LAZY', cascade: ['persist'])]
|
||||
#[ORM\ManyToMany(targetEntity: Activity::class, mappedBy: 'teams', cascade: ['persist'], fetch: 'EXTRA_LAZY')]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Team_Entity', 'Expanded'])]
|
||||
#[OA\Property(type: 'array', items: new OA\Items(ref: '#/components/schemas/Activity'))]
|
||||
|
||||
@@ -26,14 +26,14 @@ class TeamMember
|
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
|
||||
private ?int $id = null;
|
||||
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'memberships')]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default', 'Entity', 'Team_Entity'])]
|
||||
#[OA\Property(ref: '#/components/schemas/User')]
|
||||
private ?User $user = null;
|
||||
#[ORM\ManyToOne(targetEntity: Team::class, inversedBy: 'members')]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default', 'Entity', 'User_Entity'])]
|
||||
|
||||
@@ -15,7 +15,6 @@ use DateTimeZone;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Gedmo\Mapping\Annotation as Gedmo;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
@@ -128,21 +127,21 @@ class Timesheet implements EntityWithMetaFields, ExportableItem
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?int $duration = 0;
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: '`user`', referencedColumnName: 'id', onDelete: 'CASCADE', nullable: false)]
|
||||
#[ORM\JoinColumn(name: '`user`', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Subresource', 'Expanded'])]
|
||||
#[OA\Property(ref: '#/components/schemas/User')]
|
||||
private ?User $user = null;
|
||||
#[ORM\ManyToOne(targetEntity: Activity::class)]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Subresource', 'Expanded'])]
|
||||
#[OA\Property(ref: '#/components/schemas/ActivityExpanded')]
|
||||
private ?Activity $activity = null;
|
||||
#[ORM\ManyToOne(targetEntity: Project::class)]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Subresource', 'Expanded'])]
|
||||
@@ -189,12 +188,8 @@ class Timesheet implements EntityWithMetaFields, ExportableItem
|
||||
#[ORM\Column(name: 'category', type: 'string', length: 10, nullable: false, options: ['default' => 'work'])]
|
||||
#[Assert\NotNull]
|
||||
private ?string $category = self::WORK;
|
||||
/**
|
||||
* @internal used for limiting queries, eg. via API sync
|
||||
*/
|
||||
#[ORM\Column(name: 'modified_at', type: 'datetime', nullable: true)]
|
||||
#[Gedmo\Timestampable]
|
||||
private ?\DateTime $modifiedAt = null;
|
||||
private \DateTimeInterface $modifiedAt;
|
||||
/**
|
||||
* Tags
|
||||
*
|
||||
@@ -211,7 +206,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem
|
||||
*
|
||||
* @var Collection<TimesheetMeta>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: TimesheetMeta::class, mappedBy: 'timesheet', cascade: ['persist'])]
|
||||
#[ORM\OneToMany(mappedBy: 'timesheet', targetEntity: TimesheetMeta::class, cascade: ['persist'])]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Timesheet'])]
|
||||
#[Serializer\Type(name: 'array<App\Entity\TimesheetMeta>')]
|
||||
@@ -223,6 +218,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem
|
||||
{
|
||||
$this->tags = new ArrayCollection();
|
||||
$this->meta = new ArrayCollection();
|
||||
$this->modifiedAt = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -590,11 +586,16 @@ class Timesheet implements EntityWithMetaFields, ExportableItem
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getModifiedAt(): ?DateTime
|
||||
public function getModifiedAt(): \DateTimeInterface
|
||||
{
|
||||
return $this->modifiedAt;
|
||||
}
|
||||
|
||||
public function setModifiedAt(\DateTimeInterface $dateTime): void
|
||||
{
|
||||
$this->modifiedAt = $dateTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection|MetaTableTypeInterface[]
|
||||
*/
|
||||
@@ -687,7 +688,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem
|
||||
}
|
||||
|
||||
// field will not be set, if it contains a value
|
||||
$this->modifiedAt = null;
|
||||
$this->modifiedAt = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
|
||||
$this->exported = false;
|
||||
|
||||
$currentMeta = $this->meta;
|
||||
|
||||
@@ -23,7 +23,7 @@ class TimesheetMeta implements MetaTableTypeInterface
|
||||
use MetaTableTypeTrait;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Timesheet::class, inversedBy: 'meta')]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
private ?Timesheet $timesheet = null;
|
||||
|
||||
|
||||
@@ -42,9 +42,9 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[Exporter\Expose(name: 'username', label: 'username', exp: 'object.getUserIdentifier()')]
|
||||
#[Exporter\Expose(name: 'timezone', label: 'timezone', exp: 'object.getTimezone()')]
|
||||
#[Exporter\Expose(name: 'language', label: 'language', exp: 'object.getLanguage()')]
|
||||
#[Exporter\Expose(name: 'last_login', label: 'lastLogin', exp: 'object.getLastLogin()', type: 'datetime')]
|
||||
#[Exporter\Expose(name: 'roles', label: 'roles', exp: 'object.getRoles()', type: 'array')]
|
||||
#[Exporter\Expose(name: 'active', label: 'active', exp: 'object.isEnabled()', type: 'boolean')]
|
||||
#[Exporter\Expose(name: 'last_login', label: 'lastLogin', type: 'datetime', exp: 'object.getLastLogin()')]
|
||||
#[Exporter\Expose(name: 'roles', label: 'roles', type: 'array', exp: 'object.getRoles()')]
|
||||
#[Exporter\Expose(name: 'active', label: 'active', type: 'boolean', exp: 'object.isEnabled()')]
|
||||
#[Constraints\User(groups: ['UserCreate', 'Registration', 'Default', 'Profile'])]
|
||||
class User implements UserInterface, EquatableInterface, ThemeUserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface
|
||||
{
|
||||
@@ -127,15 +127,15 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
*
|
||||
* @var Collection<UserPreference>|null
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: UserPreference::class, mappedBy: 'user', cascade: ['persist'])]
|
||||
#[ORM\OneToMany(mappedBy: 'user', targetEntity: UserPreference::class, cascade: ['persist'])]
|
||||
private ?Collection $preferences = null;
|
||||
/**
|
||||
* List of all team memberships.
|
||||
*
|
||||
* @var Collection<TeamMember>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: TeamMember::class, mappedBy: 'user', fetch: 'LAZY', cascade: ['persist'], orphanRemoval: true)]
|
||||
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
|
||||
#[ORM\OneToMany(mappedBy: 'user', targetEntity: TeamMember::class, cascade: ['persist'], fetch: 'LAZY', orphanRemoval: true)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['User_Entity'])]
|
||||
@@ -217,7 +217,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
#[ORM\Column(name: 'system_account', type: 'boolean', nullable: false, options: ['default' => false])]
|
||||
private bool $systemAccount = false;
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(onDelete: 'SET NULL', nullable: true)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['User_Entity'])]
|
||||
#[OA\Property(ref: '#/components/schemas/User')]
|
||||
@@ -297,6 +297,14 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Serializer\VirtualProperty]
|
||||
#[Serializer\SerializedName('apiToken')]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
public function hasApiToken(): bool
|
||||
{
|
||||
return $this->apiToken !== null;
|
||||
}
|
||||
|
||||
public function getPlainApiToken(): ?string
|
||||
{
|
||||
return $this->plainApiToken;
|
||||
@@ -836,9 +844,6 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
return $this->email !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getPassword(): ?string
|
||||
{
|
||||
return $this->password;
|
||||
@@ -864,9 +869,6 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
return $this->confirmationToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getRoles(): array
|
||||
{
|
||||
$roles = $this->roles;
|
||||
@@ -963,7 +965,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setPasswordRequestedAt(\DateTime $date = null): User
|
||||
public function setPasswordRequestedAt(?\DateTime $date = null): User
|
||||
{
|
||||
$this->passwordRequestedAt = $date;
|
||||
|
||||
@@ -1113,6 +1115,20 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
return $this->getDisplayName();
|
||||
}
|
||||
|
||||
public function requiresPasswordReset(): bool
|
||||
{
|
||||
if (!$this->isInternalUser() || !$this->isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->getPreferenceValue('__pw_reset__') === '1';
|
||||
}
|
||||
|
||||
public function setRequiresPasswordReset(bool $require = true): void
|
||||
{
|
||||
$this->setPreferenceValue('__pw_reset__', ($require ? '1' : '0'));
|
||||
}
|
||||
|
||||
public function hasSeenWizard(string $wizard): bool
|
||||
{
|
||||
$wizards = $this->getPreferenceValue('__wizards__');
|
||||
|
||||
@@ -45,10 +45,6 @@ final class InvoiceDocumentSubscriber extends AbstractActionsSubscriber
|
||||
|
||||
$event->addAction('download', ['url' => $this->path('admin_invoice_document_download', ['document' => $document->getId()])]);
|
||||
|
||||
if ($document->isTwig()) {
|
||||
$event->addAction('reload', ['url' => $this->path('admin_invoice_document_reload', ['document' => $document->getId()])]);
|
||||
}
|
||||
|
||||
if (!$inUse) {
|
||||
$event->addDelete($this->path('invoice_document_delete', ['id' => $document->getId(), 'token' => $token]), false);
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ use App\Event\ConfigureMainMenuEvent;
|
||||
use App\Utils\MenuItemModel;
|
||||
use KevinPapst\TablerBundle\Event\MenuEvent;
|
||||
use KevinPapst\TablerBundle\Model\MenuItemInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,7 +23,7 @@ use App\Form\Type\SkinType;
|
||||
use App\Form\Type\TimezoneType;
|
||||
use App\Form\Type\UserLanguageType;
|
||||
use App\Form\Type\YesNoType;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace App\EventSubscriber;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Event\PrepareUserEvent;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\KernelEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
@@ -49,29 +49,32 @@ class WizardSubscriber implements EventSubscriberInterface
|
||||
$uri = $event->getRequest()->getRequestUri();
|
||||
|
||||
// never require 2FA on API calls
|
||||
if (str_starts_with($uri, '/api/') || stripos($uri, '/register/') !== false) {
|
||||
if (str_starts_with($uri, '/api/') || stripos($uri, '/register/') !== false || stripos($uri, '/wizard/') !== false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $token->getUser();
|
||||
|
||||
if ($user instanceof User) {
|
||||
if (stripos($uri, '/wizard/') !== false) {
|
||||
if (!($user instanceof User)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted('IS_AUTHENTICATED_FULLY')) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (User::WIZARDS as $wizard) {
|
||||
if (!$user->hasSeenWizard($wizard)) {
|
||||
$response = new RedirectResponse($this->urlGenerator->generate('wizard', ['wizard' => $wizard]));
|
||||
$event->setResponse($response);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted('IS_AUTHENTICATED_FULLY')) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (User::WIZARDS as $wizard) {
|
||||
if (!$user->hasSeenWizard($wizard)) {
|
||||
$response = new RedirectResponse($this->urlGenerator->generate('wizard', ['wizard' => $wizard]));
|
||||
$event->setResponse($response);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ($user->requiresPasswordReset()) {
|
||||
$response = new RedirectResponse($this->urlGenerator->generate('wizard', ['wizard' => 'password']));
|
||||
$event->setResponse($response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
|
||||
@@ -21,9 +21,11 @@ use App\Event\UserPreferenceDisplayEvent;
|
||||
use App\Project\ProjectStatisticService;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use App\Twig\SecurityPolicy\ExportPolicy;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Twig\Environment;
|
||||
use Twig\Extension\SandboxExtension;
|
||||
|
||||
class HtmlRenderer
|
||||
{
|
||||
@@ -93,6 +95,11 @@ class HtmlRenderer
|
||||
|
||||
$summary = $this->calculateSummary($timesheets);
|
||||
|
||||
// enable basic security measures
|
||||
$sandbox = new SandboxExtension(new ExportPolicy());
|
||||
$sandbox->enableSandbox();
|
||||
$this->twig->addExtension($sandbox);
|
||||
|
||||
$content = $this->twig->render($this->getTemplate(), array_merge([
|
||||
'entries' => $timesheets,
|
||||
'query' => $query,
|
||||
|
||||
@@ -16,8 +16,10 @@ use App\Pdf\PdfContext;
|
||||
use App\Pdf\PdfRendererTrait;
|
||||
use App\Project\ProjectStatisticService;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Twig\SecurityPolicy\ExportPolicy;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Twig\Environment;
|
||||
use Twig\Extension\SandboxExtension;
|
||||
|
||||
class PDFRenderer implements DispositionInlineInterface
|
||||
{
|
||||
@@ -76,6 +78,12 @@ class PDFRenderer implements DispositionInlineInterface
|
||||
$context->setOption('filename', $filename->getFilename());
|
||||
|
||||
$summary = $this->calculateSummary($timesheets);
|
||||
|
||||
// enable basic security measures
|
||||
$sandbox = new SandboxExtension(new ExportPolicy());
|
||||
$sandbox->enableSandbox();
|
||||
$this->twig->addExtension($sandbox);
|
||||
|
||||
$content = $this->twig->render($this->getTemplate(), array_merge([
|
||||
'entries' => $timesheets,
|
||||
'query' => $query,
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace App\Export\Renderer;
|
||||
|
||||
use App\Activity\ActivityStatisticService;
|
||||
use App\Project\ProjectStatisticService;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Twig\Environment;
|
||||
|
||||
final class HtmlRendererFactory
|
||||
|
||||
@@ -14,7 +14,7 @@ use App\Event\ExportItemsQueryEvent;
|
||||
use App\Export\Renderer\HtmlRendererFactory;
|
||||
use App\Export\Renderer\PdfRendererFactory;
|
||||
use App\Repository\Query\ExportQuery;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
final class ServiceExport
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace App\Export\Spreadsheet\Extractor;
|
||||
use App\Entity\EntityWithMetaFields;
|
||||
use App\Event\MetaDisplayEventInterface;
|
||||
use App\Export\Spreadsheet\ColumnDefinition;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace App\Export\Spreadsheet\Extractor;
|
||||
use App\Entity\User;
|
||||
use App\Event\UserPreferenceDisplayEvent;
|
||||
use App\Export\Spreadsheet\ColumnDefinition;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace App\Form\MultiUpdate;
|
||||
|
||||
use App\Form\Type\ActivityType;
|
||||
use App\Form\Type\CustomerType;
|
||||
use App\Form\Type\DescriptionType;
|
||||
use App\Form\Type\FixedRateType;
|
||||
use App\Form\Type\HourlyRateType;
|
||||
use App\Form\Type\MetaFieldsCollectionType;
|
||||
@@ -141,9 +142,12 @@ final class TimesheetMultiUpdate extends AbstractType
|
||||
'label' => false,
|
||||
'required' => true,
|
||||
'expanded' => true,
|
||||
'label_attr' => [
|
||||
'class' => 'radio-inline',
|
||||
],
|
||||
'choices' => [
|
||||
'replaceTags' => true,
|
||||
'appendTags' => false,
|
||||
'append' => false,
|
||||
'replace' => true,
|
||||
]
|
||||
]);
|
||||
|
||||
@@ -151,6 +155,23 @@ final class TimesheetMultiUpdate extends AbstractType
|
||||
'required' => false,
|
||||
]);
|
||||
|
||||
$builder->add('replaceDescription', ChoiceType::class, [
|
||||
'label' => false,
|
||||
'required' => true,
|
||||
'expanded' => true,
|
||||
'label_attr' => [
|
||||
'class' => 'radio-inline',
|
||||
],
|
||||
'choices' => [
|
||||
'append' => false,
|
||||
'replace' => true,
|
||||
]
|
||||
]);
|
||||
|
||||
$builder->add('description', DescriptionType::class, [
|
||||
'required' => false,
|
||||
]);
|
||||
|
||||
if ($options['include_user']) {
|
||||
$builder->add('user', UserType::class, [
|
||||
'required' => false,
|
||||
|
||||
@@ -49,6 +49,8 @@ final class TimesheetMultiUpdateDTO extends MultiUpdateTableDTO implements Entit
|
||||
* @var array<string>
|
||||
*/
|
||||
private array $updateMeta = [];
|
||||
private bool $replaceDescription = false;
|
||||
private ?string $description = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -98,6 +100,16 @@ final class TimesheetMultiUpdateDTO extends MultiUpdateTableDTO implements Entit
|
||||
$this->tags = $tags;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(?string $description): void
|
||||
{
|
||||
$this->description = $description;
|
||||
}
|
||||
|
||||
public function getUser(): ?User
|
||||
{
|
||||
return $this->user;
|
||||
@@ -148,6 +160,16 @@ final class TimesheetMultiUpdateDTO extends MultiUpdateTableDTO implements Entit
|
||||
$this->replaceTags = $replaceTags;
|
||||
}
|
||||
|
||||
public function isReplaceDescription(): bool
|
||||
{
|
||||
return $this->replaceDescription;
|
||||
}
|
||||
|
||||
public function setReplaceDescription(bool $replaceDescription): void
|
||||
{
|
||||
$this->replaceDescription = $replaceDescription;
|
||||
}
|
||||
|
||||
public function getFixedRate(): ?float
|
||||
{
|
||||
return $this->fixedRate;
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace App\Form\Type;
|
||||
|
||||
use App\Event\ConfigureMainMenuEvent;
|
||||
use App\Utils\MenuItemModel;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\OptionsResolver\Options;
|
||||
|
||||
@@ -26,6 +26,7 @@ final class SkinType extends AbstractType
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'label' => 'skin',
|
||||
'search' => false,
|
||||
'required' => true,
|
||||
'choices' => self::THEMES,
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace App\Form;
|
||||
|
||||
use App\Form\Type\TeamType;
|
||||
use App\Form\Type\UserRoleType;
|
||||
use App\Form\Type\YesNoType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
@@ -63,6 +64,12 @@ class UserCreateType extends UserEditType
|
||||
'required' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
$builder->add('requiresPasswordReset', YesNoType::class, [
|
||||
'label' => 'force_password_change',
|
||||
'help' => 'force_password_change_help',
|
||||
'required' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
|
||||
@@ -22,9 +22,9 @@ use App\Repository\InvoiceDocumentRepository;
|
||||
use App\Repository\InvoiceRepository;
|
||||
use App\Repository\Query\InvoiceQuery;
|
||||
use App\Utils\FileHelper;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
/**
|
||||
* Service to manage invoice dependencies.
|
||||
|
||||
@@ -169,7 +169,7 @@ class Kernel extends BaseKernel
|
||||
$container->registerExtension(new AppExtension());
|
||||
|
||||
$container->setParameter('container.autowiring.strict_mode', true);
|
||||
$container->setParameter('container.dumper.inline_class_loader', true);
|
||||
$container->setParameter('.container.dumper.inline_class_loader', true);
|
||||
$confDir = $this->getProjectDir() . '/config';
|
||||
|
||||
// using this one instead of $loader->load($confDir . '/packages/*' . self::CONFIG_EXTS, 'glob');
|
||||
|
||||
@@ -22,7 +22,7 @@ use App\Repository\ProjectRepository;
|
||||
use App\Utils\Context;
|
||||
use App\Validator\ValidationFailedException;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,7 +36,7 @@ use App\Repository\UserRepository;
|
||||
use App\Timesheet\DateTimeFactory;
|
||||
use DateTime;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
/**
|
||||
* @final
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace App\Reporting;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Event\ReportingEvent;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
final class ReportingService
|
||||
|
||||
@@ -355,14 +355,17 @@ class TimesheetRepository extends EntityRepository
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
|
||||
$qb->select('COUNT(t)')
|
||||
$qb->select($qb->expr()->count('t'))
|
||||
->from(Timesheet::class, 't')
|
||||
->andWhere($qb->expr()->isNull('t.end'))
|
||||
->orderBy('t.begin', 'DESC');
|
||||
;
|
||||
|
||||
if (null !== $user) {
|
||||
$qb->andWhere('t.user = :user');
|
||||
$qb->setParameter('user', $user);
|
||||
$qb
|
||||
->andWhere('t.user = :user')
|
||||
->groupBy('t.user')
|
||||
->setParameter('user', $user)
|
||||
;
|
||||
}
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use App\Entity\User;
|
||||
use Scheb\TwoFactorBundle\Security\TwoFactor\AuthenticationContextInterface;
|
||||
use Scheb\TwoFactorBundle\Security\TwoFactor\Condition\TwoFactorConditionInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
@@ -22,9 +21,6 @@ final class TwoFactorCondition implements TwoFactorConditionInterface
|
||||
|
||||
public function shouldPerformTwoFactorAuthentication(AuthenticationContextInterface $context): bool
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $context->getUser();
|
||||
|
||||
// never require 2FA on API calls
|
||||
if (str_starts_with($context->getRequest()->getRequestUri(), '/api/')) {
|
||||
return false;
|
||||
|
||||
@@ -31,7 +31,7 @@ use App\Timesheet\TrackingMode\TrackingModeInterface;
|
||||
use App\Validator\ValidationException;
|
||||
use App\Validator\ValidationFailedException;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
|
||||
@@ -21,9 +21,6 @@ final class Extensions extends AbstractExtension
|
||||
{
|
||||
public const REPORT_DATE = 'Y-m-d';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getFilters(): array
|
||||
{
|
||||
return [
|
||||
@@ -37,9 +34,6 @@ final class Extensions extends AbstractExtension
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -33,9 +33,6 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getFilters(): array
|
||||
{
|
||||
return [
|
||||
@@ -74,9 +71,6 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -27,9 +27,6 @@ final class PaginationExtension extends AbstractExtension
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [
|
||||
|
||||
48
src/Twig/SecurityPolicy/ChainPolicy.php
Normal file
48
src/Twig/SecurityPolicy/ChainPolicy.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?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\Twig\SecurityPolicy;
|
||||
|
||||
use Twig\Sandbox\SecurityPolicyInterface;
|
||||
|
||||
final class ChainPolicy implements SecurityPolicyInterface
|
||||
{
|
||||
/** @var array<SecurityPolicyInterface> */
|
||||
private array $policies = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function addPolicy(SecurityPolicyInterface $policy): void
|
||||
{
|
||||
$this->policies[] = $policy;
|
||||
}
|
||||
|
||||
public function checkSecurity($tags, $filters, $functions): void
|
||||
{
|
||||
foreach ($this->policies as $policy) {
|
||||
$policy->checkSecurity($tags, $filters, $functions);
|
||||
}
|
||||
}
|
||||
|
||||
public function checkMethodAllowed($obj, $method): void
|
||||
{
|
||||
foreach ($this->policies as $policy) {
|
||||
$policy->checkMethodAllowed($obj, $method);
|
||||
}
|
||||
}
|
||||
|
||||
public function checkPropertyAllowed($obj, $property): void
|
||||
{
|
||||
foreach ($this->policies as $policy) {
|
||||
$policy->checkPropertyAllowed($obj, $property);
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/Twig/SecurityPolicy/DefaultPolicy.php
Normal file
30
src/Twig/SecurityPolicy/DefaultPolicy.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?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\Twig\SecurityPolicy;
|
||||
|
||||
use Twig\Sandbox\SecurityPolicyInterface;
|
||||
|
||||
/**
|
||||
* The Twig environment needs the sandbox extension, which itself needs a policy to start working.
|
||||
*/
|
||||
final class DefaultPolicy implements SecurityPolicyInterface
|
||||
{
|
||||
public function checkSecurity($tags, $filters, $functions): void
|
||||
{
|
||||
}
|
||||
|
||||
public function checkMethodAllowed($obj, $method): void
|
||||
{
|
||||
}
|
||||
|
||||
public function checkPropertyAllowed($obj, $property): void
|
||||
{
|
||||
}
|
||||
}
|
||||
41
src/Twig/SecurityPolicy/ExportPolicy.php
Normal file
41
src/Twig/SecurityPolicy/ExportPolicy.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?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\Twig\SecurityPolicy;
|
||||
|
||||
use Twig\Sandbox\SecurityPolicyInterface;
|
||||
|
||||
/**
|
||||
* Represents the security policy for custom Twig export templates.
|
||||
*/
|
||||
final class ExportPolicy implements SecurityPolicyInterface
|
||||
{
|
||||
private ChainPolicy $policy;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->policy = new ChainPolicy();
|
||||
$this->policy->addPolicy(new DefaultPolicy());
|
||||
}
|
||||
|
||||
public function checkSecurity($tags, $filters, $functions): void
|
||||
{
|
||||
$this->policy->checkSecurity($tags, $filters, $functions);
|
||||
}
|
||||
|
||||
public function checkMethodAllowed($obj, $method): void
|
||||
{
|
||||
$this->policy->checkMethodAllowed($obj, $method);
|
||||
}
|
||||
|
||||
public function checkPropertyAllowed($obj, $property): void
|
||||
{
|
||||
$this->policy->checkPropertyAllowed($obj, $property);
|
||||
}
|
||||
}
|
||||
109
src/Twig/SecurityPolicy/ForbiddenPolicy.php
Normal file
109
src/Twig/SecurityPolicy/ForbiddenPolicy.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?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\Twig\SecurityPolicy;
|
||||
|
||||
use Twig\Markup;
|
||||
use Twig\Sandbox\SecurityNotAllowedFilterError;
|
||||
use Twig\Sandbox\SecurityNotAllowedFunctionError;
|
||||
use Twig\Sandbox\SecurityNotAllowedMethodError;
|
||||
use Twig\Sandbox\SecurityNotAllowedPropertyError;
|
||||
use Twig\Sandbox\SecurityNotAllowedTagError;
|
||||
use Twig\Sandbox\SecurityPolicyInterface;
|
||||
use Twig\Template;
|
||||
|
||||
/**
|
||||
* A blocking approach for Twig templates.
|
||||
*/
|
||||
final class ForbiddenPolicy implements SecurityPolicyInterface
|
||||
{
|
||||
/** @var array<string, array<string>> */
|
||||
private array $forbiddenMethods = [];
|
||||
|
||||
/**
|
||||
* @param array<string> $forbiddenTags
|
||||
* @param array<string> $forbiddenFilters
|
||||
* @param array<string, array<string>> $forbiddenMethods
|
||||
* @param array<string, array<string>> $forbiddenProperties
|
||||
* @param array<string> $forbiddenFunctions
|
||||
*/
|
||||
public function __construct(
|
||||
private array $forbiddenTags = [],
|
||||
private array $forbiddenFilters = [],
|
||||
array $forbiddenMethods = [],
|
||||
private array $forbiddenProperties = [],
|
||||
private array $forbiddenFunctions = []
|
||||
)
|
||||
{
|
||||
$this->forbiddenMethods = [];
|
||||
foreach ($forbiddenMethods as $class => $m) {
|
||||
$this->forbiddenMethods[$class] = array_map(function ($value) { return strtr($value, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); }, \is_array($m) ? $m : [$m]);
|
||||
}
|
||||
}
|
||||
|
||||
public function checkSecurity($tags, $filters, $functions): void
|
||||
{
|
||||
foreach ($tags as $tag) {
|
||||
if (\in_array($tag, $this->forbiddenTags)) {
|
||||
throw new SecurityNotAllowedTagError(sprintf('Tag "%s" is not allowed.', $tag), $tag);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($filters as $filter) {
|
||||
if (\in_array($filter, $this->forbiddenFilters)) {
|
||||
throw new SecurityNotAllowedFilterError(sprintf('Filter "%s" is not allowed.', $filter), $filter);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($functions as $function) {
|
||||
if (\in_array($function, $this->forbiddenFunctions)) {
|
||||
throw new SecurityNotAllowedFunctionError(sprintf('Function "%s" is not allowed.', $function), $function);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function checkMethodAllowed($obj, $method): void
|
||||
{
|
||||
if ($obj instanceof Template || $obj instanceof Markup) {
|
||||
return;
|
||||
}
|
||||
|
||||
$forbidden = false;
|
||||
$method = strtr($method, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
|
||||
foreach ($this->forbiddenMethods as $class => $methods) {
|
||||
if ($obj instanceof $class) {
|
||||
$forbidden = \in_array($method, $methods);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($forbidden) {
|
||||
$class = \get_class($obj);
|
||||
throw new SecurityNotAllowedMethodError(sprintf('Calling "%s" method on a "%s" object is not allowed.', $method, $class), $class, $method);
|
||||
}
|
||||
}
|
||||
|
||||
public function checkPropertyAllowed($obj, $property): void
|
||||
{
|
||||
$forbidden = false;
|
||||
foreach ($this->forbiddenProperties as $class => $properties) {
|
||||
if ($obj instanceof $class) {
|
||||
$forbidden = \in_array($property, \is_array($properties) ? $properties : [$properties]);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($forbidden) {
|
||||
$class = \get_class($obj);
|
||||
throw new SecurityNotAllowedPropertyError(sprintf('Calling "%s" property on a "%s" object is not allowed.', $property, $class), $class, $property);
|
||||
}
|
||||
}
|
||||
}
|
||||
86
src/Twig/SecurityPolicy/InvoicePolicy.php
Normal file
86
src/Twig/SecurityPolicy/InvoicePolicy.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?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\Twig\SecurityPolicy;
|
||||
|
||||
use App\Invoice\InvoiceModel;
|
||||
use App\Pdf\PdfContext;
|
||||
use Symfony\Component\String\UnicodeString;
|
||||
use Twig\Markup;
|
||||
use Twig\Sandbox\SecurityPolicy;
|
||||
use Twig\Sandbox\SecurityPolicyInterface;
|
||||
use Twig\Template;
|
||||
|
||||
/**
|
||||
* Represents the security policy for custom Twig invoice templates.
|
||||
*/
|
||||
final class InvoicePolicy implements SecurityPolicyInterface
|
||||
{
|
||||
private ChainPolicy $policy;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->policy = new ChainPolicy();
|
||||
$this->policy->addPolicy(new DefaultPolicy());
|
||||
$this->policy->addPolicy(new SecurityPolicy(
|
||||
['block', 'if', 'for', 'set', 'extends'],
|
||||
[
|
||||
// Twig core filters
|
||||
'map', 'escape', 'trans', 'default', 'nl2br', 'trim', 'raw',
|
||||
'join', 'u', 'slice', 'date', 'month_name', 'first', 'country_name',
|
||||
'replace', 'length', 'number_format', 'split',
|
||||
|
||||
// Kimai filters
|
||||
'md2html', 'desc2html', 'comment2html', 'comment1line', 'multiline_indent', 'nl2str',
|
||||
'date_short', 'duration', 'amount', 'money', 'duration_decimal',
|
||||
],
|
||||
[
|
||||
PdfContext::class => ['setoption'],
|
||||
InvoiceModel::class => ['toarray'],
|
||||
],
|
||||
[], // properties
|
||||
[
|
||||
// Twig core functions
|
||||
'cycle', 'asset', 'range',
|
||||
|
||||
// Kimai functions
|
||||
'encore_entry_css_source', 'qr_code_data_uri', 'config',
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
public function checkSecurity($tags, $filters, $functions): void
|
||||
{
|
||||
$this->policy->checkSecurity($tags, $filters, $functions);
|
||||
}
|
||||
|
||||
public function checkMethodAllowed($obj, $method): void
|
||||
{
|
||||
if ($obj instanceof Template || $obj instanceof Markup || $obj instanceof UnicodeString) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lm = strtolower($method);
|
||||
|
||||
if (str_starts_with($lm, 'get') || str_starts_with($lm, 'is') || str_starts_with($lm, 'has')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($lm === '__tostring') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->policy->checkMethodAllowed($obj, $method);
|
||||
}
|
||||
|
||||
public function checkPropertyAllowed($obj, $property): void
|
||||
{
|
||||
$this->policy->checkPropertyAllowed($obj, $property);
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,11 @@
|
||||
|
||||
namespace App\Twig;
|
||||
|
||||
use App\Twig\SecurityPolicy\InvoicePolicy;
|
||||
use Symfony\Bridge\Twig\Extension\TranslationExtension;
|
||||
use Symfony\Contracts\Translation\LocaleAwareInterface;
|
||||
use Twig\Environment;
|
||||
use Twig\Extension\SandboxExtension;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@@ -30,6 +32,13 @@ trait TwigRendererTrait
|
||||
$previousFormatLocale = $this->switchFormatLocale($twig, $formatLocale);
|
||||
}
|
||||
|
||||
// enable basic security measures
|
||||
if (!$twig->hasExtension(SandboxExtension::class)) {
|
||||
$sandbox = new SandboxExtension(new InvoicePolicy());
|
||||
$sandbox->enableSandbox();
|
||||
$twig->addExtension($sandbox);
|
||||
}
|
||||
|
||||
$content = $twig->render($template, $options);
|
||||
|
||||
if ($previousTranslation !== null) {
|
||||
|
||||
@@ -12,12 +12,12 @@ namespace App\User;
|
||||
use App\Entity\User;
|
||||
use App\Event\UserInteractiveLoginEvent;
|
||||
use App\Security\UserChecker;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
|
||||
use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
final class LoginManager
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@ use App\Event\UserUpdatePreEvent;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Validator\ValidationFailedException;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ namespace App\Widget\Type;
|
||||
abstract class AbstractActiveUsers extends AbstractWidgetType
|
||||
{
|
||||
/**
|
||||
* @param array<string, string|bool|int|null> $options
|
||||
* @return array<string, string|bool|int|null>
|
||||
* @param array<string, string|bool|int|null|array<string, mixed>> $options
|
||||
@return array<string, string|bool|int|null|array<string, mixed>>
|
||||
*/
|
||||
public function getOptions(array $options = []): array
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user