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

37
.gitignore vendored
View File

@@ -1,45 +1,34 @@
# some hosters require a htaccess to change the PHP version
.htaccess
.env-*
.idea/
.DS_Store
rector.php
phpstan.sh
/public/.htaccess
/.env-*
/.idea/
/.DS_Store
/phpstan.sh
# custom apache rules e.g. to deactivate ioncube loader
public/.user.ini
/public/.user.ini
# for symfony local webserver
php.ini
.php-version
/php.ini
/.php-version
# YARN 2
.yarnrc.yml
.yarn
/.yarnrc.yml
/.yarn
# for keeping empty directories
!.gitkeep
/bin/*
!/bin/console
*local.yaml
/config/packages/local.yaml
/config/bundles-local.php
/templates/invoice/renderer/.~lock*
/translations/branding.*.xlf
/var/data/*
/var/coverage/
/var/cache/*
/var/invoices/*
/var/invoices*
/var/export/*
/var/log/*
/var/sessions/*
/var/packages/*
/var/plugins/*
/var/plugins_old/
*.disabled
/var/plugins/*/*.disabled
###> symfony/framework-bundle ###
/.env.local

View File

@@ -35,7 +35,7 @@
"friendsofsymfony/rest-bundle": "^3.0",
"gedmo/doctrine-extensions": "^3.6",
"jms/serializer-bundle": "^5.0",
"kevinpapst/tabler-bundle": "^0.22",
"kevinpapst/tabler-bundle": "^1.0",
"league/csv": "^9.4",
"mpdf/mpdf": "^8.0",
"nelmio/api-doc-bundle": "^4.0",
@@ -129,6 +129,7 @@
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-mbstring": "*",
"symfony/polyfill-intl": "*",
"symfony/polyfill-iconv": "*",

715
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@ doctrine:
default_table_options:
charset: utf8mb4
collate: utf8mb4_unicode_ci
schema_manager_factory: doctrine.dbal.default_schema_manager_factory
types:
datetime: App\Doctrine\UTCDateTimeType
@@ -26,6 +27,7 @@ doctrine:
default_entity_manager: default
entity_managers:
default:
report_fields_where_declared: true
connection: default
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true

View File

@@ -17,8 +17,6 @@ framework:
cookie_httponly: true
storage_factory_id: session.storage.factory.native
#esi: ~
#fragments: ~
php_errors:
log: true

View File

@@ -151,11 +151,13 @@ kimai:
# --------------------------------------------------------------------------------
# INVOICES
# --------------------------------------------------------------------------------
# invoice:
# # all files in these directories will be used as invoice documents (if supported by a renderer)
# documents:
# - 'var/invoices/'
# - 'templates/invoice/renderer/'
# invoice:
# # all files in these directories will be used as invoice documents (if supported by a renderer)
# documents:
# - 'var/invoices/'
# - 'templates/invoice/renderer/'
# # allow to upload twig templates as invoice documents
# upload_twig: true
# --------------------------------------------------------------------------------

View File

@@ -65,6 +65,12 @@ security:
max_attempts: 5
interval: '5 minutes'
login_link:
check_route: link_login_check
signature_properties: ['id']
lifetime: 300
max_uses: 1
access_decision_manager:
strategy: unanimous
allow_if_all_abstain: false

View File

@@ -1,6 +1,6 @@
controllers:
resource: ../src/Controller/
type: annotation
type: attribute
prefix: /{_locale}
requirements:
_locale: '%app_locales%'
@@ -19,17 +19,17 @@ api.swagger:
api:
resource: ../src/API/
type: annotation
type: attribute
prefix: /api
auth:
resource: ../src/Controller/Auth/
type: annotation
type: attribute
prefix: /auth
security:
resource: ../src/Controller/Security/
type: annotation
type: attribute
prefix: /{_locale}
requirements:
_locale: '%app_locales%'
@@ -38,7 +38,7 @@ security:
kernel:
resource: ../src/Kernel.php
type: annotation
type: attribute
home:
path: /

View File

@@ -91,16 +91,10 @@ services:
App\Doctrine\TimesheetSubscriber:
class: App\Doctrine\TimesheetSubscriber
arguments: [!tagged timesheet.calculator]
tags:
- { name: doctrine.event_subscriber, priority: 50 }
# updates timestampable columns (higher priority, so the TimesheetSubscriber will be executed later)
Gedmo\Timestampable\TimestampableListener:
class: Gedmo\Timestampable\TimestampableListener
tags:
- { name: doctrine.event_subscriber, priority: 60 }
calls:
- [ setAnnotationReader, [ "@annotation_reader" ] ]
App\Doctrine\ModifiedSubscriber:
class: App\Doctrine\ModifiedSubscriber
# ================================================================================
# TIMESHEET RECORD CALCULATOR

View File

@@ -928,7 +928,7 @@ parameters:
-
message: "#^Cannot access offset 'user' on mixed\\.$#"
count: 2
count: 1
path: src/Controller/CalendarController.php
-
@@ -2578,12 +2578,12 @@ parameters:
-
message: "#^Cannot access offset 'customer' on mixed\\.$#"
count: 3
count: 1
path: src/Form/MultiUpdate/TimesheetMultiUpdate.php
-
message: "#^Cannot access offset 'project' on mixed\\.$#"
count: 6
count: 2
path: src/Form/MultiUpdate/TimesheetMultiUpdate.php
-
@@ -3603,12 +3603,12 @@ parameters:
-
message: "#^Cannot access offset 'activity' on mixed\\.$#"
count: 3
count: 1
path: src/Form/Type/QuickEntryWeekType.php
-
message: "#^Cannot access offset 'project' on mixed\\.$#"
count: 3
count: 1
path: src/Form/Type/QuickEntryWeekType.php
-
@@ -4293,7 +4293,7 @@ parameters:
-
message: "#^Cannot access offset 'lastRecord' on mixed\\.$#"
count: 2
count: 1
path: src/Project/ProjectStatisticService.php
-
@@ -4438,7 +4438,7 @@ parameters:
-
message: "#^Cannot access offset 'project' on mixed\\.$#"
count: 3
count: 1
path: src/Reporting/ProjectDetails/ProjectDetailsForm.php
-
@@ -5716,11 +5716,6 @@ parameters:
count: 1
path: src/Utils/MenuItemModel.php
-
message: "#^Method App\\\\Utils\\\\MenuItemModel\\:\\:getRouteArgs\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Utils/MenuItemModel.php
-
message: "#^Method App\\\\Utils\\\\MenuItemModel\\:\\:setChildRoutes\\(\\) has parameter \\$routes with no value type specified in iterable type array\\.$#"
count: 1

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 [

Some files were not shown because too many files have changed in this diff Show More