Release 2.59 (#5957)

This commit is contained in:
Kevin Papst
2026-06-05 19:05:10 +02:00
committed by GitHub
parent 79d0a2102b
commit 87c85270a9
77 changed files with 2571 additions and 1697 deletions

View File

@@ -31,6 +31,8 @@ use Symfony\Component\Validator\Validator\ValidatorInterface;
*/
class ActivityService
{
private int $generatedNumbers = 0;
public function __construct(
private readonly ActivityRepository $repository,
private readonly SystemConfiguration $configuration,
@@ -138,7 +140,8 @@ class ActivityService
}
// we cannot use max(number) because a varchar column returns unexpected results
$start = $this->repository->countActivity();
$count = $this->repository->countActivity();
$start = $count + $this->generatedNumbers;
$i = 0;
$createDate = new \DateTimeImmutable();
@@ -170,6 +173,10 @@ class ActivityService
return null;
}
// Remember how far we advanced — including iterations spent skipping numbers that
// already exist — so the next call on this instance starts beyond the issued number.
$this->generatedNumbers = $start - $count;
return $number;
}
}

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.58.0';
public const VERSION = '2.59.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 25800;
public const VERSION_ID = 25900;
/**
* The software name
*/

View File

@@ -102,7 +102,7 @@ final class DoctorController extends AbstractController
}
/**
* @return array{enabled: bool, status: false|array<mixed>}
* @return array{unknown: bool, enabled: bool, status: false|array<mixed>}
*/
private function getOpcacheConfiguration(): array
{
@@ -111,7 +111,7 @@ final class DoctorController extends AbstractController
$enabled = \is_array($status) && $status['opcache_enabled'];
if ($enabled && \array_key_exists('scripts', $status)) {
if ($enabled && \is_array($status) && \array_key_exists('scripts', $status)) {
unset($status['scripts']);
}

View File

@@ -23,7 +23,7 @@ use App\Export\Spreadsheet\Writer\XlsxWriter;
use App\Form\InvoiceDocumentUploadForm;
use App\Form\InvoiceEditForm;
use App\Form\InvoiceTemplateForm;
use App\Form\Toolbar\InvoiceArchiveForm;
use App\Form\Toolbar\InvoiceArchiveToolbarForm;
use App\Form\Toolbar\InvoiceToolbarForm;
use App\Form\Type\DatePickerType;
use App\Form\Type\InvoiceTemplateType;
@@ -753,7 +753,7 @@ final class InvoiceController extends AbstractController
private function getArchiveToolbarForm(InvoiceArchiveQuery $query): FormInterface
{
return $this->createSearchForm(InvoiceArchiveForm::class, $query, [
return $this->createSearchForm(InvoiceArchiveToolbarForm::class, $query, [
'action' => $this->generateUrl('admin_invoice_list', []),
'timezone' => $this->getDateTimeFactory()->getTimezone()->getName(),
'attr' => [

View File

@@ -28,6 +28,15 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* This is the anonymous "password forgotten" flow.
* The flow itself can be deactivated via a system-configuration.
*
* When a user enters his username or email:
* - a login link will be generated
* - the user will be flagged as "a new password is required during next login"
* - the login link will be sent to the user via email
*/
#[Route(path: '/resetting')]
final class PasswordResetController extends AbstractController
{

View File

@@ -17,6 +17,7 @@ use App\Form\Type\UserLanguageType;
use App\Form\Type\UserLocaleType;
use App\Form\UserPasswordType;
use App\User\UserService;
use App\Wizard\WizardManager;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -27,112 +28,156 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('IS_AUTHENTICATED_FULLY')]
final class WizardController extends AbstractController
{
#[Route(path: '/{wizard}', name: 'wizard', methods: ['GET', 'POST'])]
public function wizard(Request $request, UserService $userService, string $wizard): Response
/**
* Virtual "forward" route. Redirects to the first step the user has not
* seen yet, or to the finish page if all steps are done.
*
* The WizardSubscriber already intercepts this route on kernel.request,
* but the action is kept as a defensive fallback (the subscriber returns
* early when all steps are seen).
*/
#[Route(path: '/next/', name: 'wizard_next', methods: ['GET'])]
public function next(WizardManager $wizardManager): Response
{
$user = $this->getUser();
$step = $wizardManager->getFirstUnseenStep($user);
if ($step !== null) {
return $this->redirectToRoute($step->route);
}
return $this->redirectToRoute('wizard_finish');
}
/**
* Virtual "backward" route. The caller passes its own step id as
* {@code from} so that the previous step can be resolved without the
* caller needing to know who comes before it.
*/
#[Route(path: '/previous/{from}', name: 'wizard_previous', requirements: ['from' => '[a-zA-Z0-9_-]+'], methods: ['GET'])]
public function previous(string $from, WizardManager $wizardManager): Response
{
/** @var User $user */
$user = $this->getUser();
$step = $wizardManager->getPreviousStep($user, $from);
if ($step !== null) {
return $this->redirectToRoute($step->route);
}
return $this->redirectToRoute('wizard_intro');
}
#[Route(path: '/intro', name: 'wizard_intro', methods: ['GET'])]
public function intro(UserService $userService, WizardManager $wizardManager): Response
{
$user = $this->getUser();
if ($wizard === 'intro') {
$user->setWizardAsSeen('intro');
$user->setWizardAsSeen('intro');
$userService->saveUser($user);
return $this->render(
'wizard/intro.html.twig',
$wizardManager->getNavigation($user, 'intro')
);
}
#[Route(path: '/profile', name: 'wizard_profile', methods: ['GET', 'POST'])]
public function profile(Request $request, UserService $userService, WizardManager $wizardManager): Response
{
$user = $this->getUser();
$data = [
UserPreference::LANGUAGE => $user->getPreferenceValue(UserPreference::LANGUAGE, $request->getLocale(), false),
UserPreference::LOCALE => $user->getPreferenceValue(UserPreference::LOCALE, $request->getLocale(), false),
UserPreference::TIMEZONE => $user->getTimezone(),
UserPreference::SKIN => $user->getSkin(),
'reload' => '0',
];
$form = $this->createFormBuilder($data)
->add(UserPreference::LANGUAGE, UserLanguageType::class)
->add(UserPreference::LOCALE, UserLocaleType::class, ['help' => null])
->add(UserPreference::TIMEZONE, TimezoneType::class)
->add(UserPreference::SKIN, SkinType::class)
->add('reload', HiddenType::class)
->setAction($this->generateUrl('wizard_profile'))
->setMethod('POST')
->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var array<string, string> $data */
$data = $form->getData();
$user->setLanguage($data[UserPreference::LANGUAGE]);
$user->setLocale($data[UserPreference::LOCALE]);
$user->setTimezone($data[UserPreference::TIMEZONE]);
$user->setPreferenceValue(UserPreference::SKIN, $data[UserPreference::SKIN]);
$user->setWizardAsSeen('profile');
$userService->saveUser($user);
return $this->render('wizard/intro.html.twig', [
'percent' => 0,
'next' => 'profile',
]);
}
if ($wizard === 'profile') {
$data = [
UserPreference::LANGUAGE => $user->getPreferenceValue(UserPreference::LANGUAGE, $request->getLocale(), false),
UserPreference::LOCALE => $user->getPreferenceValue(UserPreference::LOCALE, $request->getLocale(), false),
UserPreference::TIMEZONE => $user->getTimezone(),
UserPreference::SKIN => $user->getSkin(),
'reload' => '0',
];
$form = $this->createFormBuilder($data)
->add(UserPreference::LANGUAGE, UserLanguageType::class)
->add(UserPreference::LOCALE, UserLocaleType::class, ['help' => null])
->add(UserPreference::TIMEZONE, TimezoneType::class)
->add(UserPreference::SKIN, SkinType::class)
->add('reload', HiddenType::class)
->setAction($this->generateUrl('wizard', ['wizard' => 'profile']))
->setMethod('POST')
->getForm();
$next = 'done';
if ($user->requiresPasswordReset()) {
$next = 'password';
if ($data['reload'] === '1') {
return $this->redirectToRoute('wizard_profile', ['_locale' => $user->getLanguage()]);
}
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var array<string, string> $data */
$data = $form->getData();
$user->setLanguage($data[UserPreference::LANGUAGE]);
$user->setLocale($data[UserPreference::LOCALE]);
$user->setTimezone($data[UserPreference::TIMEZONE]);
$user->setPreferenceValue(UserPreference::SKIN, $data[UserPreference::SKIN]);
$user->setWizardAsSeen('profile');
$userService->saveUser($user);
if ($data['reload'] === '1') {
return $this->redirectToRoute('wizard', ['wizard' => 'profile', '_locale' => $user->getLanguage()]);
} else {
return $this->redirectToRoute('wizard', ['wizard' => $next, '_locale' => $user->getLanguage()]);
}
}
return $this->render('wizard/profile.html.twig', [
'percent' => \intval(100 / \count(User::WIZARDS) * 1),
'previous' => 'intro',
'next' => $next,
'form' => $form->createView(),
]);
// Delegate "what comes next" to the WizardManager so optional steps
// (e.g. the password step, or plugin-contributed steps) are picked
// up automatically.
return $this->redirectToRoute('wizard_next', ['_locale' => $user->getLanguage()]);
}
if ($wizard === 'password' || $user->requiresPasswordReset()) {
$form = $this->createForm(UserPasswordType::class, $user, [
'action' => $this->generateUrl('wizard', ['wizard' => 'password']),
'method' => 'POST',
]);
return $this->render(
'wizard/profile.html.twig',
array_merge(
['form' => $form->createView()],
$wizardManager->getNavigation($user, 'profile')
)
);
}
$form->handleRequest($request);
#[Route(path: '/password', name: 'wizard_password', methods: ['GET', 'POST'])]
public function password(Request $request, UserService $userService, WizardManager $wizardManager): Response
{
/** @var User $user */
$user = $this->getUser();
if ($form->isSubmitted() && $form->isValid()) {
$user->setRequiresPasswordReset(false);
$userService->saveUser($user);
$form = $this->createForm(UserPasswordType::class, $user, [
'action' => $this->generateUrl('wizard_password'),
'method' => 'POST',
]);
return $this->redirectToRoute('wizard', ['wizard' => 'done']);
}
$form->handleRequest($request);
$previous = 'profile';
$percent = \intval(100 / \count(User::WIZARDS) * 1);
if ($form->isSubmitted() && $form->isValid()) {
$user->setRequiresPasswordReset(false);
// do not set wizard as seen, as password reset utilizes the wizard framework but lives outside its flow
$userService->saveUser($user);
if ($user->requiresPasswordReset()) {
$previous = null;
$percent = null;
}
return $this->render('wizard/password.html.twig', [
'percent' => $percent,
'previous' => $previous,
'next' => 'done',
'form' => $form->createView(),
]);
return $this->redirectToRoute('wizard_next');
}
// this is a virtual step that is not registered as wizard, but instead should be shown every time
// a new wizard is introduced: so we do not register it as "seen"
if ($wizard === 'done') {
return $this->render('wizard/done.html.twig', [
'percent' => 100,
'previous' => 'profile',
]);
// this has no wizard navigation, as it lives outside the normal wizard flow
return $this->render('wizard/password.html.twig', ['form' => $form->createView()]);
}
/**
* Virtual finish step. Not registered as a wizard step, so it is shown
* every time a new wizard step is introduced and never marked as "seen".
*/
#[Route(path: '/finish', name: 'wizard_finish', methods: ['GET'])]
public function finish(WizardManager $wizardManager): Response
{
$user = $this->getUser();
// Previous link points back to the last registered step in the sequence.
$previousStep = null;
foreach ($wizardManager->getSteps($user) as $step) {
$previousStep = $step;
}
throw $this->createNotFoundException('Unknown wizard');
return $this->render('wizard/done.html.twig', [
'previous' => $previousStep?->route,
]);
}
}

View File

@@ -28,6 +28,8 @@ use Symfony\Component\Validator\Validator\ValidatorInterface;
final class CustomerService
{
private int $generatedNumbers = 0;
public function __construct(
private readonly CustomerRepository $repository,
private readonly SystemConfiguration $configuration,
@@ -155,7 +157,8 @@ final class CustomerService
}
// we cannot use max(number) because a varchar column returns unexpected results
$start = $this->repository->countCustomer();
$count = $this->repository->countCustomer();
$start = $count + $this->generatedNumbers;
$i = 0;
$createDate = new \DateTimeImmutable();
@@ -187,6 +190,10 @@ final class CustomerService
return null;
}
// Remember how far we advanced — including iterations spent skipping numbers that
// already exist — so the next call on this instance starts beyond the issued number.
$this->generatedNumbers = $start - $count;
return $number;
}
}

View File

@@ -237,6 +237,8 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
#[Serializer\Groups(['User_Entity'])]
#[OA\Property(ref: '#/components/schemas/User')]
private ?User $supervisor = null;
#[ORM\Column(name: 'signature_date', type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $signatureDate = null;
use ColorTrait;
@@ -699,6 +701,21 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
return $this->getRoles() === [static::DEFAULT_ROLE];
}
public function getSignatureDate(): string
{
return $this->signatureDate?->format(\DateTimeInterface::ATOM) ?? '';
}
/**
* This will reset all security signatures and therefor invalidate:
* - login links
* - remember me cookies
*/
public function resetSecuritySignature(): void
{
$this->signatureDate = new \DateTimeImmutable('now', new \DateTimeZone($this->getTimezone()));
}
/**
* List of all teams, this user is part of
*

123
src/Event/WizardEvent.php Normal file
View File

@@ -0,0 +1,123 @@
<?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\Event;
use App\Entity\User;
use App\Wizard\WizardStep;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Dispatched once per request whenever Kimai needs to know which wizard
* steps exist for a given user. Listeners (Kimai core and plugins) add
* their steps via {@see self::addStep()} and may inspect the user to
* decide whether their step applies (e.g. only when a password reset
* is required).
*
* Steps do not need to know about each other — order and navigation are
* resolved by {@see \App\Wizard\WizardManager} based on the {@see WizardStep::$order}
* value.
*/
final class WizardEvent extends Event
{
/**
* @var array<string, WizardStep>
*/
private array $steps = [];
public function __construct(private readonly User $user)
{
}
public function getUser(): User
{
return $this->user;
}
/**
* Register a wizard step. If a step with the same id was already
* registered, it is replaced — listeners with a higher priority win.
*/
public function addStep(WizardStep $step): void
{
$this->steps[$step->id] = $step;
}
public function hasStep(string $id): bool
{
return \array_key_exists($id, $this->steps);
}
public function getStep(string $id): ?WizardStep
{
return $this->steps[$id] ?? null;
}
public function removeStep(string $id): void
{
if (\array_key_exists($id, $this->steps)) {
unset($this->steps[$id]);
}
}
/**
* @return WizardStep[] steps sorted by their order (ascending)
*/
public function getSteps(): array
{
$steps = array_values($this->steps);
usort($steps, static fn (WizardStep $a, WizardStep $b): int => $a->order <=> $b->order);
return $steps;
}
/**
* Convenience wrapper around {@see self::addStep()} that creates a
* {@see WizardStep} from an id and route, appending it to the end of
* the current step list.
*/
public function addWizard(string $wizard, string $route): void
{
$this->addStep(new WizardStep($wizard, $route, (\count($this->steps) + 1) * 100));
}
/**
* @return array<string, string> map of step id => route name, in order
*/
public function getWizards(): array
{
$result = [];
foreach ($this->getSteps() as $step) {
$result[$step->id] = $step->route;
}
return $result;
}
/**
* Returns the route name of the step that follows $wizard in the
* configured order, or the fallback route 'wizard_finish' if $wizard
* is unknown or is the last step.
*/
public function getNextWizard(string $wizard): string
{
$steps = $this->getSteps();
$found = false;
foreach ($steps as $step) {
if ($found) {
return $step->route;
}
if ($step->id === $wizard) {
$found = true;
}
}
return 'wizard_finish';
}
}

View File

@@ -64,7 +64,7 @@ class PasswordResetSubscriber implements EventSubscriberInterface
return;
}
$response = new RedirectResponse($this->urlGenerator->generate('wizard', ['wizard' => 'password']));
$response = new RedirectResponse($this->urlGenerator->generate('wizard_password'));
$event->setResponse($response);
}
}

View File

@@ -11,6 +11,7 @@ namespace App\EventSubscriber;
use App\Configuration\SystemConfiguration;
use App\Entity\User;
use App\Wizard\WizardManager;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
@@ -19,13 +20,17 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* Responsible for displaying the correct wizard
*/
class WizardSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
private readonly AuthorizationCheckerInterface $security,
private readonly TokenStorageInterface $storage,
private readonly SystemConfiguration $systemConfiguration
private readonly SystemConfiguration $systemConfiguration,
private readonly WizardManager $wizardManager,
) {
}
@@ -45,9 +50,20 @@ class WizardSubscriber implements EventSubscriberInterface
$uri = $event->getRequest()->getRequestUri();
// never trigger wizard on API calls
// TODO 3.0 remove /register/
if (str_starts_with($uri, '/api/') || stripos($uri, '/register/') !== false || stripos($uri, '/wizard/') !== false) {
if (stripos($uri, '/register/') !== false) {
return;
}
// never trigger wizard on API calls
if (str_starts_with($uri, '/api/')) {
return;
}
// never trigger on wizard routes themselves — except the virtual /next/
// route, which intentionally re-enters this subscriber so that the user
// is redirected to the first step they have not seen yet.
if (stripos($uri, '/wizard/') !== false && stripos($uri, '/wizard/next/') === false) {
return;
}
@@ -65,13 +81,15 @@ class WizardSubscriber implements EventSubscriberInterface
return;
}
foreach (User::WIZARDS as $wizard) {
if (!$user->hasSeenWizard($wizard)) {
$response = new RedirectResponse($this->urlGenerator->generate('wizard', ['wizard' => $wizard]));
$event->setResponse($response);
$step = $this->wizardManager->getFirstUnseenStep($user);
return;
}
if ($step === null) {
// All registered steps have been seen — fall through to the regular
// application response. If we were intercepting /wizard/next/, the
// controller will redirect to wizard_finish.
return;
}
$event->setResponse(new RedirectResponse($this->urlGenerator->generate($step->route)));
}
}

View File

@@ -183,13 +183,13 @@ final class ColumnConverter
$columns[$column] = (new Column('duration', $this->getFormatter('duration_seconds')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
} elseif ($column === 'break') {
// TODO remove method_exists with 3.0
$columns[$column] = (new Column('break', $this->getFormatter('duration')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL); // @phpstan-ignore function.alreadyNarrowedType
$columns[$column] = (new Column('break', $this->getFormatter('duration')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL);
} elseif ($column === 'break_decimal') {
// TODO remove method_exists with 3.0
$columns[$column] = (new Column('break', $this->getFormatter('duration_decimal')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL); // @phpstan-ignore function.alreadyNarrowedType
$columns[$column] = (new Column('break', $this->getFormatter('duration_decimal')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL);
} elseif ($column === 'break_seconds') {
// TODO remove method_exists with 3.0
$columns[$column] = (new Column('break', $this->getFormatter('duration_seconds')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL); // @phpstan-ignore function.alreadyNarrowedType
$columns[$column] = (new Column('break', $this->getFormatter('duration_seconds')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL);
} elseif ($column === 'currency' && $showRates) {
$columns[$column] = (new Column('currency', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getCurrency())->withColumnWidth(ColumnWidth::SMALL);
} elseif ($column === 'rate' && $showRates) {

View File

@@ -19,16 +19,20 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
* Defines the form used for filtering timesheet entries for invoices.
* @extends AbstractType<InvoiceArchiveQuery>
*/
final class InvoiceArchiveForm extends AbstractType
final class InvoiceArchiveToolbarForm extends AbstractType
{
use ToolbarFormTrait;
/**
* @param array<string, mixed> $options
*/
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$this->addSearchTermInputField($builder);
$this->addDateRange($builder, ['timezone' => $options['timezone']]);
$this->addCustomerMultiChoice($builder, ['required' => false, 'start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true, 'placeholder' => ''], true);
$builder->add('status', InvoiceStatusType::class, ['required' => false]);
$this->addUsersChoice($builder);
$this->addPageSizeChoice($builder);
$this->addHiddenPagination($builder);
$this->addOrder($builder);

View File

@@ -12,6 +12,7 @@ namespace App\Form\Toolbar;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\User;
use App\Form\Type\ActivityType;
use App\Form\Type\BillableSearchType;
use App\Form\Type\CustomerType;
@@ -45,6 +46,9 @@ use Symfony\Component\Form\FormEvents;
*/
trait ToolbarFormTrait
{
/**
* @param array<string, mixed> $options
*/
protected function addUsersChoice(FormBuilderInterface $builder, string $field = 'users', array $options = []): void
{
$builder->add($field, UserType::class, array_merge([
@@ -59,6 +63,9 @@ trait ToolbarFormTrait
], $options));
}
/**
* @param array<string, mixed> $options
*/
protected function addTeamsChoice(FormBuilderInterface $builder, string $field = 'teams', array $options = []): void
{
$builder->add($field, TeamType::class, array_merge([
@@ -73,11 +80,17 @@ trait ToolbarFormTrait
], $options));
}
/**
* @param array<string, mixed> $options
*/
protected function addCustomerMultiChoice(FormBuilderInterface $builder, array $options = [], bool $multiProject = false): void
{
$this->addCustomerSelect($builder, $options, true, $multiProject);
}
/**
* @param array<string, mixed> $options
*/
private function addCustomerSelect(FormBuilderInterface $builder, array $options, bool $multiCustomer, bool $multiProject): void
{
$name = 'customer';
@@ -110,7 +123,10 @@ trait ToolbarFormTrait
'start_date_param' => '%daterange%',
'query_builder' => function (CustomerRepository $repo) use ($builder, $data, $name) {
$query = new CustomerFormTypeQuery();
$query->setUser($builder->getOption('user'));
$tu = $builder->getOption('user');
if ($tu instanceof User) {
$query->setUser($tu);
}
if (\array_key_exists($name, $data) && $data[$name] !== null && $data[$name] !== '') {
$customers = \is_array($data[$name]) ? $data[$name] : [$data[$name]];
@@ -149,6 +165,9 @@ trait ToolbarFormTrait
]);
}
/**
* @param array<string, mixed> $options
*/
protected function addDateRange(FormBuilderInterface $builder, array $options, bool $allowEmpty = true): void
{
$params = [
@@ -163,11 +182,17 @@ trait ToolbarFormTrait
$builder->add('daterange', DateRangeType::class, $params);
}
/**
* @param array<string, mixed> $options
*/
protected function addProjectMultiChoice(FormBuilderInterface $builder, array $options = [], bool $multiCustomer = false, bool $multiActivity = false): void
{
$this->addProjectSelect($builder, $options, true, $multiCustomer, $multiActivity);
}
/**
* @param array<string, mixed> $options
*/
private function addProjectSelect(FormBuilderInterface $builder, array $options, bool $multiProject, bool $multiCustomer, bool $multiActivity): void
{
$name = 'project';
@@ -197,7 +222,10 @@ trait ToolbarFormTrait
'activity_select' => $multiActivity ? 'activities' : 'activity',
'query_builder' => function (ProjectRepository $repo) use ($builder, $data, $options, $multiCustomer, $multiProject) {
$query = new ProjectFormTypeQuery();
$query->setUser($builder->getOption('user'));
$tu = $builder->getOption('user');
if ($tu instanceof User) {
$query->setUser($tu);
}
$name = $multiCustomer ? 'customers' : 'customer';
if (\array_key_exists($name, $data) && $data[$name] !== null && $data[$name] !== '') {
@@ -234,11 +262,17 @@ trait ToolbarFormTrait
);
}
/**
* @param array<string, mixed> $options
*/
protected function addActivityMultiChoice(FormBuilderInterface $builder, array $options = [], bool $multiProject = false): void
{
$this->addActivitySelect($builder, $options, true, $multiProject);
}
/**
* @param array<string, mixed> $options
*/
private function addActivitySelect(FormBuilderInterface $builder, array $options = [], bool $multiActivity = false, bool $multiProject = false, bool $autoFill = true): void
{
$name = $multiActivity ? 'activities' : 'activity';
@@ -330,6 +364,9 @@ trait ToolbarFormTrait
]);
}
/**
* @param array<int|string, string> $allowedColumns
*/
protected function addOrderBy(FormBuilderInterface $builder, array $allowedColumns): void
{
$all = [];

View File

@@ -102,6 +102,7 @@ final class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
'entry.user_title' => $user->getTitle() ?? '',
'entry.user_alias' => $user->getAlias() ?? '',
'entry.user_display' => $user->getDisplayName(),
'entry.user_account' => $user->getAccountNumber() ?? '',
]);
foreach ($user->getVisiblePreferences() as $pref) {
@@ -135,6 +136,7 @@ final class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
}
}
// @deprecated since 2.59.0 - invoices have one global customer - removed from the docs 2026-05-27
if (null !== $customer) {
$values = array_merge($values, [
'entry.customer' => $customer->getName(),

View File

@@ -28,6 +28,7 @@ final class InvoiceModelUserHydrator implements InvoiceModelHydrator
'user.title' => $user->getTitle() ?? '',
'user.alias' => $user->getAlias() ?? '',
'user.display' => $user->getDisplayName(),
'user.account' => $user->getAccountNumber() ?? '',
];
foreach ($user->getPreferences() as $metaField) {

View File

@@ -12,6 +12,7 @@ namespace App\Pdf;
use Mpdf\Http\ClientInterface;
use Mpdf\PsrHttpMessageShim\Response;
use Psr\Http\Message\RequestInterface;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface as HttpClientExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
@@ -33,16 +34,20 @@ final class SafeRemoteContentClient implements ClientInterface
*/
private const TIMEOUT = 10;
public function __construct(private readonly HttpClientInterface $client)
public function __construct(
private readonly HttpClientInterface $client,
private readonly LoggerInterface $logger
)
{
}
public function sendRequest(RequestInterface $request): Response
{
$url = (string) $request->getUri();
try {
$response = $this->client->request(
$request->getMethod(),
(string) $request->getUri(),
$url,
[
'headers' => $this->flattenHeaders($request),
'timeout' => self::TIMEOUT,
@@ -55,8 +60,10 @@ final class SafeRemoteContentClient implements ClientInterface
[],
$response->getContent(false)
);
} catch (HttpClientExceptionInterface) {
} catch (HttpClientExceptionInterface $e) {
// Request blocked (private network), DNS failure, timeout, etc.
$this->logger->error(\sprintf('Failed fetching from URL "%s" with : %s', $url, $e->getMessage()));
return new Response(502);
}
}

View File

@@ -32,6 +32,8 @@ use Symfony\Component\Validator\Validator\ValidatorInterface;
*/
final class ProjectService
{
private int $generatedNumbers = 0;
public function __construct(
private readonly ProjectRepository $repository,
private readonly SystemConfiguration $configuration,
@@ -150,7 +152,8 @@ final class ProjectService
}
// we cannot use max(number) because a varchar column returns unexpected results
$start = $this->repository->countProject();
$count = $this->repository->countProject();
$start = $count + $this->generatedNumbers;
$i = 0;
$createDate = new \DateTimeImmutable();
@@ -182,6 +185,10 @@ final class ProjectService
return null;
}
// Remember how far we advanced — including iterations spent skipping numbers that
// already exist — so the next call on this instance starts beyond the issued number.
$this->generatedNumbers = $start - $count;
return $number;
}
}

View File

@@ -207,6 +207,11 @@ class InvoiceRepository extends EntityRepository
$qb->setParameter('status', $query->getStatus());
}
if ($query->hasUsers()) {
$qb->andWhere($qb->expr()->in('i.user', ':users'));
$qb->setParameter('users', $query->getUsers());
}
$orderBy = $query->getOrderBy();
switch ($orderBy) {
case 'date':

View File

@@ -19,6 +19,7 @@ use App\Form\Model\DateRange;
class InvoiceArchiveQuery extends BaseQuery implements DateRangeInterface
{
use DateRangeTrait;
use UsersTrait;
public const INVOICE_ARCHIVE_ORDER_ALLOWED = [
'date', 'invoice.number', 'status', 'total_rate'

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\Repository\Query;
use App\Entity\User;
trait UsersTrait
{
/**
* @var array<User>
*/
protected array $users = [];
public function addUser(User $user): void
{
$this->users[$user->getId()] = $user;
}
public function removeUser(User $user): void
{
if (isset($this->users[$user->getId()])) {
unset($this->users[$user->getId()]);
}
}
/**
* @return User[]
*/
public function getUsers(): array
{
return array_values($this->users);
}
/**
* Check if there is one or more users in the query
*/
public function hasUsers(): bool
{
return \count($this->users) > 0;
}
}

View File

@@ -33,28 +33,30 @@ final class SamlAuthenticationSuccessHandler extends DefaultAuthenticationSucces
// we use only the path part of the URL to prevent external redirects
$path = null;
if (\is_array($values) && \array_key_exists('path', $values)) {
$path = $values['path'];
}
if (\is_string($path)
&& $path !== ''
&& str_starts_with($path, '/')
&& !str_starts_with($path, '//')
&& !str_contains($path, '\\')
) {
$target = $this->httpUtils->generateUri($request, $path);
$loginUrl = $this->httpUtils->generateUri($request, (string) $this->options['login_path']);
if (\array_key_exists('scheme', $values) && str_starts_with($values['scheme'], 'http')) {
if (\array_key_exists('host', $values) && !str_starts_with($target, $values['scheme'] . '://' . $values['host'])) {
$target = null;
}
if (\is_array($values)) {
if (\array_key_exists('path', $values)) {
$path = $values['path'];
}
// make sure that the login URL is not the target, which would be an endless loop for the user
if ($target !== null && $target !== $loginUrl) {
return $target;
if (\is_string($path)
&& $path !== ''
&& str_starts_with($path, '/')
&& !str_starts_with($path, '//')
&& !str_contains($path, '\\')
) {
$target = $this->httpUtils->generateUri($request, $path);
$loginUrl = $this->httpUtils->generateUri($request, (string) $this->options['login_path']);
if (\array_key_exists('scheme', $values) && str_starts_with($values['scheme'], 'http')) {
if (\array_key_exists('host', $values) && !str_starts_with($target, $values['scheme'] . '://' . $values['host'])) {
$target = null;
}
}
// make sure that the login URL is not the target, which would be an endless loop for the user
if ($target !== null && $target !== $loginUrl) {
return $target;
}
}
}
}

View File

@@ -11,7 +11,9 @@ namespace App\Voter;
use App\Entity\User;
use App\Security\RolePermissionManager;
use Scheb\TwoFactorBundle\Security\Authentication\Token\TwoFactorTokenInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -21,7 +23,10 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
*/
final class ApiVoter extends Voter
{
public function __construct(private readonly RolePermissionManager $permissionManager)
public function __construct(
private readonly RolePermissionManager $permissionManager,
private readonly AuthorizationCheckerInterface $authorizationChecker,
)
{
}
@@ -48,6 +53,22 @@ final class ApiVoter extends Voter
return false;
}
// this check does not work, because remember_me sessions would not pass this check
// as the frontend uses the API, the user need to be able to use the API via session, even if not "fully authenticated"
// !$this->authorizationChecker->isGranted('IS_AUTHENTICATED_FULLY', $user)
// the instanceof check is not mentioned in the official docs https://symfony.com/bundles/SchebTwoFactorBundle/8.x/index.html
// but it should be a tiny bit faster than asking the TwoFactorInProgressVoter, which does the same ...
// as we rely on internal bundle knowledge, we keep the defense-in-depth branch here as well
if (
$token instanceof TwoFactorTokenInterface ||
$this->authorizationChecker->isGranted('IS_AUTHENTICATED_2FA_IN_PROGRESS', $user)
) {
return false;
}
// derived from AccessTokenSuccessHandler
if ($token->hasAttribute('api-token')) {
return $this->permissionManager->hasRolePermission($user, 'api_access');
}

View File

@@ -0,0 +1,113 @@
<?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\Wizard;
use App\Entity\User;
use App\Event\WizardEvent;
use Psr\EventDispatcher\EventDispatcherInterface;
/**
* Central entry point for working with the user onboarding wizard.
*
* Dispatches the {@see WizardEvent} so every registered listener (Kimai
* core and plugins) can contribute steps, then answers navigation questions
* on top of the resulting ordered step list. This is the only place that
* needs to know how the steps relate to each other — individual step
* controllers never reference their neighbours by name.
*/
final class WizardManager
{
public function __construct(private readonly EventDispatcherInterface $dispatcher)
{
}
/**
* @return WizardStep[] all registered steps for $user, sorted by order
*/
public function getSteps(User $user): array
{
$event = new WizardEvent($user);
// these stes
$event->addStep(new WizardStep('intro', 'wizard_intro', 100));
$event->addStep(new WizardStep('profile', 'wizard_profile', 200));
$this->dispatcher->dispatch($event);
return $event->getSteps();
}
/**
* Returns the first step the user has not seen yet, or null if
* everything is done.
*/
public function getFirstUnseenStep(User $user): ?WizardStep
{
foreach ($this->getSteps($user) as $step) {
if (!$user->hasSeenWizard($step->id)) {
return $step;
}
}
return null;
}
/**
* Returns the step that comes after $currentStepId in the configured
* order, or null if $currentStepId is unknown or is the last step.
*/
public function getNextStep(User $user, string $currentStepId): ?WizardStep
{
$found = false;
foreach ($this->getSteps($user) as $step) {
if ($found) {
return $step;
}
if ($step->id === $currentStepId) {
$found = true;
}
}
return null;
}
/**
* Returns the step that comes before $currentStepId in the configured
* order, or null if $currentStepId is unknown or is the first step.
*/
public function getPreviousStep(User $user, string $currentStepId): ?WizardStep
{
$previous = null;
foreach ($this->getSteps($user) as $step) {
if ($step->id === $currentStepId) {
return $previous;
}
$previous = $step;
}
return null;
}
/**
* Resolve previous/next route names for a step via the WizardManager so
* step controllers never reference their neighbours by name.
*
* @return array<string, mixed>
*/
public function getNavigation(User $user, string $currentStepId): array
{
$previous = $this->getPreviousStep($user, $currentStepId);
$next = $this->getNextStep($user, $currentStepId);
return [
'previous' => $previous?->route,
'next' => $next?->route ?? 'wizard_finish', // @phpstan-ignore nullsafe.neverNull
];
}
}

31
src/Wizard/WizardStep.php Normal file
View File

@@ -0,0 +1,31 @@
<?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\Wizard;
/**
* A single step inside the user onboarding wizard.
*
* Plugins can register their own steps via the {@see \App\Event\WizardEvent}.
* A step is identified by its id (used for the "seen" flag on {@see \App\Entity\User})
* and points to an existing Symfony route that renders the step.
*
* The order property defines the position inside the wizard sequence — lower
* numbers come first. Built-in steps use round numbers (100, 200, 300, ...),
* leaving plenty of room for plugins to insert in between.
*/
final class WizardStep
{
public function __construct(
public readonly string $id,
public readonly string $route,
public readonly int $order = 0,
) {
}
}