Release 2.59 (#5957)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
123
src/Event/WizardEvent.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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'
|
||||
|
||||
48
src/Repository/Query/UsersTrait.php
Normal file
48
src/Repository/Query/UsersTrait.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
113
src/Wizard/WizardManager.php
Normal file
113
src/Wizard/WizardManager.php
Normal 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
31
src/Wizard/WizardStep.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user