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:
Kevin Papst
2023-10-19 11:21:50 +02:00
committed by GitHub
parent 7a5b12762a
commit 38e37f1c2e
210 changed files with 1784 additions and 1147 deletions

View File

@@ -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')]

View File

@@ -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;

View File

@@ -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')

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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;
/**

View File

@@ -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
{

View File

@@ -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();

View File

@@ -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

View 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;
}
}

View File

@@ -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';
/**

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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',
];
}
}

View File

@@ -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',
];
}
}

View File

@@ -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',
];
}

View 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();
}
}

View File

@@ -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(),
]);
}

View File

@@ -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')]

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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(),
]);

View File

@@ -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

View File

@@ -343,7 +343,7 @@ final class Configuration implements ConfigurationInterface
->defaultValue('{Y}/{cy,3}')
->end()
->booleanNode('upload_twig')
->defaultTrue()
->defaultFalse()
->end()
->end()
;

View 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);
}
}
}

View File

@@ -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
{
/**

View File

@@ -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'))]

View File

@@ -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;

View File

@@ -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;

View File

@@ -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)]

View File

@@ -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)]

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>')]

View File

@@ -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;

View File

@@ -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'))]

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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')]

View File

@@ -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)]

View File

@@ -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;

View File

@@ -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'))]

View File

@@ -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'])]

View File

@@ -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;

View File

@@ -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;

View File

@@ -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__');

View File

@@ -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);
}

View File

@@ -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;
/**

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
}
}
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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
{

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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

View File

@@ -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.

View File

@@ -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');

View File

@@ -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;
/**

View File

@@ -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

View File

@@ -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

View File

@@ -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();

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 [

View File

@@ -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 [

View File

@@ -27,9 +27,6 @@ final class PaginationExtension extends AbstractExtension
{
}
/**
* {@inheritdoc}
*/
public function getFunctions(): array
{
return [

View 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);
}
}
}

View 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
{
}
}

View 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);
}
}

View 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);
}
}
}

View 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);
}
}

View File

@@ -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) {

View File

@@ -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
{

View File

@@ -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;

View File

@@ -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