Release 2.23.0 (#5075)

This commit is contained in:
Kevin Papst
2024-10-03 10:34:20 +02:00
committed by GitHub
parent 40154be8f9
commit fb9a0dc499
142 changed files with 855 additions and 910 deletions

View File

@@ -19,7 +19,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:list', description: 'List all users')]
final class ListUserCommand extends Command
{
public function __construct(private UserRepository $repository)
public function __construct(private readonly UserRepository $repository)
{
parent::__construct();
}
@@ -35,12 +35,12 @@ final class ListUserCommand extends Command
$user->getUserIdentifier(),
$user->getEmail(),
implode(', ', $user->getRoles()),
$user->isEnabled() ? 'X' : '',
$user->getPasswordRequestedAt()?->format('Y-m-d H:i:s')
$user->isEnabled() ? 'Yes' : '-',
$user->getAuth() ?? '',
];
}
$header = ['Username', 'Email', 'Roles', 'Active', 'PW Reset'];
$header = ['Username', 'Email', 'Roles', 'Active', 'Authenticator'];
$output->table($header, $data);

View File

@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '2.22.0';
public const VERSION = '2.23.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 22200;
public const VERSION_ID = 22300;
/**
* The software name
*/

View File

@@ -22,7 +22,6 @@ use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormTypeInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
@@ -229,35 +228,15 @@ abstract class AbstractController extends BaseAbstractController implements Serv
return $this->container->get(BookmarkRepository::class);
}
private function getLastSearch(SessionInterface $session, BaseQuery $query): ?array
{
$name = 'search_' . $this->getSearchName($query);
if (!$session->has($name)) {
return null;
}
return $session->get($name);
}
private function removeLastSearch(SessionInterface $session, BaseQuery $query): void
{
$name = 'search_' . $this->getSearchName($query);
if ($session->has($name)) {
$session->remove($name);
}
}
private function getSearchName(BaseQuery $query): string
{
return substr($query->getName(), 0, 50);
}
/**
* Use "performSearch=1" to skip loading session searches.
* Use "performSearch=1" to skip loading bookmarked searches.
*
* @param array<string> $filterParams parameter names, which should not be saved (neither session, nor database)
* @param array<string> $filterParams parameter names, which should not be persisted with bookmark
* @throws \Exception
*/
protected function handleSearch(FormInterface $form, Request $request, array $filterParams = []): bool
@@ -267,7 +246,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
throw new \InvalidArgumentException('handleSearchForm() requires an instanceof BaseQuery as form data');
}
$actions = ['resetSearchFilter', 'removeDefaultQuery', 'setDefaultQuery'];
$actions = ['removeDefaultQuery', 'setDefaultQuery'];
foreach ($actions as $action) {
if ($request->query->has($action)) {
if (!$this->isCsrfTokenValid('search', $request->query->get('_token'))) {
@@ -280,13 +259,6 @@ abstract class AbstractController extends BaseAbstractController implements Serv
$request->query->remove('_token');
if ($request->query->has('resetSearchFilter')) {
$data->resetFilter();
$this->removeLastSearch($request->getSession(), $data);
return true;
}
$queryKey = null;
if (!empty($formName = $form->getConfig()->getName()) && $request->request->has($formName)) {
// allow using forms with block-prefix
@@ -300,40 +272,14 @@ abstract class AbstractController extends BaseAbstractController implements Serv
}
$searchName = $this->getSearchName($data);
$bookmarkRepo = $this->getBookmark();
$bookmark = $bookmarkRepo->getSearchDefaultOptions($this->getUser(), $searchName);
if ($bookmark !== null) {
if ($request->query->has('removeDefaultQuery')) {
if ($request->query->has('removeDefaultQuery')) {
$bookmarkRepo = $this->getBookmark();
$bookmark = $bookmarkRepo->getSearchDefaultOptions($this->getUser(), $searchName);
if ($bookmark !== null) {
$bookmarkRepo->deleteBookmark($bookmark);
$bookmark = null;
return true;
} else {
$data->setBookmark($bookmark);
}
}
// apply persisted search data ONLY if search form was not submitted manually
if (!$request->query->has('performSearch')) {
$sessionSearch = $this->getLastSearch($request->getSession(), $data);
if ($sessionSearch !== null) {
$submitData = array_merge($sessionSearch, $submitData);
} elseif ($bookmark !== null && !$request->query->has('setDefaultQuery')) {
$bookContent = $bookmark->getContent();
$isBookmarkSearch = true;
foreach ($submitData as $key => $value) {
if (!\array_key_exists($key, $bookContent) || $value !== $bookContent[$key]) {
$isBookmarkSearch = false;
break;
}
}
if ($isBookmarkSearch) {
$data->flagAsBookmarkSearch();
}
$submitData = array_merge($bookContent, $submitData);
}
return true;
}
// clean up parameters from unknown search values
@@ -343,6 +289,26 @@ abstract class AbstractController extends BaseAbstractController implements Serv
}
}
if (\count($submitData) === 0) {
$bookmark = $this->getBookmark()->getSearchDefaultOptions($this->getUser(), $searchName);
$data->setBookmark($bookmark);
// apply persisted search data ONLY if search form was not submitted manually
if ($bookmark !== null && !$request->query->has('performSearch') && !$request->query->has('setDefaultQuery')) {
$bookContent = $bookmark->getContent();
$data->flagAsBookmarkSearch();
// clean up parameters from unknown search values that were stored in an old bookmark
foreach ($bookContent as $name => $values) {
if (!$form->has($name)) {
unset($bookContent[$name]);
}
}
$submitData = $bookContent;
}
}
$form->submit($submitData, false);
if (!$form->isValid()) {
@@ -357,19 +323,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
}
// these should NEVER be saved
$filter = array_merge(['setDefaultQuery', 'removeDefaultQuery', 'performSearch'], $filterParams);
foreach ($filter as $name) {
if (isset($params[$name])) {
unset($params[$name]);
}
}
if ($request->query->has('performSearch')) {
$request->getSession()->set('search_' . $searchName, $params);
}
// filter stuff, that does not belong in a bookmark
$filter = ['page'];
$filter = array_merge(['setDefaultQuery', 'removeDefaultQuery', 'performSearch', 'page'], $filterParams);
foreach ($filter as $name) {
if (isset($params[$name])) {
unset($params[$name]);
@@ -377,7 +331,8 @@ abstract class AbstractController extends BaseAbstractController implements Serv
}
if ($request->query->has('setDefaultQuery')) {
$this->removeLastSearch($request->getSession(), $data);
$bookmark = $this->getBookmark()->getSearchDefaultOptions($this->getUser(), $searchName);
if ($bookmark === null) {
$bookmark = new Bookmark();
$bookmark->setType(Bookmark::SEARCH_DEFAULT);
@@ -386,7 +341,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
}
$bookmark->setContent($params);
$bookmarkRepo->saveBookmark($bookmark);
$this->getBookmark()->saveBookmark($bookmark);
return true;
}

View File

@@ -30,7 +30,7 @@ final class BookmarkController extends AbstractController
public const PARAM_DATATABLE = 'datatable_name';
public const PARAM_PROFILE = 'datatable_profile';
public function __construct(private BookmarkRepository $bookmarkRepository, private ProfileManager $profileManager)
public function __construct(private readonly BookmarkRepository $bookmarkRepository, private readonly ProfileManager $profileManager)
{
}
@@ -41,11 +41,11 @@ final class BookmarkController extends AbstractController
throw $this->createNotFoundException('Missing CSRF Token');
}
if (!$csrfTokenManager->isTokenValid(new CsrfToken(self::DATATABLE_TOKEN, $request->request->get(self::PARAM_TOKEN_NAME)))) {
if (!$csrfTokenManager->isTokenValid(new CsrfToken(self::DATATABLE_TOKEN, (string) $request->request->get(self::PARAM_TOKEN_NAME)))) {
throw $this->createAccessDeniedException('Invalid CSRF Token');
}
$profile = $request->request->get(self::PARAM_PROFILE);
$profile = (string) $request->request->get(self::PARAM_PROFILE);
if (!$this->profileManager->isValidProfile($profile)) {
throw $this->createNotFoundException('Invalid profile given');
}
@@ -63,16 +63,16 @@ final class BookmarkController extends AbstractController
throw $this->createNotFoundException('Missing data: csrf token, datatable name or profile');
}
if (!$csrfTokenManager->isTokenValid(new CsrfToken(self::DATATABLE_TOKEN, $request->request->get(self::PARAM_TOKEN_NAME)))) {
if (!$csrfTokenManager->isTokenValid(new CsrfToken(self::DATATABLE_TOKEN, (string) $request->request->get(self::PARAM_TOKEN_NAME)))) {
throw $this->createAccessDeniedException('Invalid CSRF Token');
}
$profile = $request->request->get(self::PARAM_PROFILE);
$profile = (string) $request->request->get(self::PARAM_PROFILE);
if (!$this->profileManager->isValidProfile($profile)) {
throw $this->createNotFoundException('Invalid profile given');
}
$datatableName = $request->request->get(self::PARAM_DATATABLE);
$datatableName = (string) $request->request->get(self::PARAM_DATATABLE);
$datatableName = $this->profileManager->getDatatableName($datatableName, $profile);
if (empty($datatableName) || mb_strlen($datatableName) > 50) {
@@ -116,16 +116,16 @@ final class BookmarkController extends AbstractController
throw $this->createNotFoundException('Missing data: csrf token, datatable name or profile');
}
if (!$csrfTokenManager->isTokenValid(new CsrfToken(self::DATATABLE_TOKEN, $request->request->get(self::PARAM_TOKEN_NAME)))) {
if (!$csrfTokenManager->isTokenValid(new CsrfToken(self::DATATABLE_TOKEN, (string) $request->request->get(self::PARAM_TOKEN_NAME)))) {
throw $this->createAccessDeniedException('Invalid CSRF Token');
}
$profile = $request->request->get(self::PARAM_PROFILE);
$profile = (string) $request->request->get(self::PARAM_PROFILE);
if (!$this->profileManager->isValidProfile($profile)) {
throw $this->createNotFoundException('Invalid profile given');
}
$datatableName = $request->request->get(self::PARAM_DATATABLE);
$datatableName = (string) $request->request->get(self::PARAM_DATATABLE);
$datatableName = $this->profileManager->getDatatableName($datatableName, $profile);
$bookmark = $this->bookmarkRepository->findBookmark($this->getUser(), Bookmark::COLUMN_VISIBILITY, $datatableName);

View File

@@ -40,6 +40,7 @@ use Endroid\QrCode\RoundBlockSizeMode\RoundBlockSizeModeMargin;
use Endroid\QrCode\Writer\PngWriter;
use Psr\EventDispatcher\EventDispatcherInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
@@ -73,7 +74,12 @@ final class ProfileController extends AbstractController
#[Route(path: '/{username}', name: 'user_profile', methods: ['GET'])]
#[IsGranted('view', 'profile')]
public function indexAction(User $profile, TimesheetRepository $repository, TimesheetStatisticService $statisticService): Response
public function indexAction(
#[MapEntity(mapping: ['username' => 'username'])]
User $profile,
TimesheetRepository $repository,
TimesheetStatisticService $statisticService
): Response
{
$dateFactory = $this->getDateTimeFactory();
$userStats = $repository->getUserStatistics($profile);
@@ -105,7 +111,12 @@ final class ProfileController extends AbstractController
#[Route(path: '/{username}/edit', name: 'user_profile_edit', methods: ['GET', 'POST'])]
#[IsGranted('edit', 'profile')]
public function editAction(User $profile, Request $request, UserRepository $userRepository): Response
public function editAction(
#[MapEntity(mapping: ['username' => 'username'])]
User $profile,
Request $request,
UserRepository $userRepository
): Response
{
$form = $this->createEditForm($profile);
$form->handleRequest($request);
@@ -134,7 +145,12 @@ final class ProfileController extends AbstractController
#[Route(path: '/{username}/password', name: 'user_profile_password', methods: ['GET', 'POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[IsGranted('password', 'profile')]
public function passwordAction(User $profile, Request $request, UserService $userService): Response
public function passwordAction(
#[MapEntity(mapping: ['username' => 'username'])]
User $profile,
Request $request,
UserService $userService
): Response
{
$form = $this->createPasswordForm($profile);
$form->handleRequest($request);
@@ -158,7 +174,12 @@ final class ProfileController extends AbstractController
#[Route(path: '/{username}/create-access-token', name: 'user_profile_access_token', methods: ['GET', 'POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[IsGranted('api-token', 'profile')]
public function createAccessToken(User $profile, Request $request, AccessTokenRepository $accessTokenRepository): Response
public function createAccessToken(
#[MapEntity(mapping: ['username' => 'username'])]
User $profile,
Request $request,
AccessTokenRepository $accessTokenRepository
): Response
{
$accessToken = new AccessToken($profile, substr(bin2hex(random_bytes(100)), 0, 25));
@@ -187,7 +208,13 @@ final class ProfileController extends AbstractController
#[Route(path: '/{username}/api-token', name: 'user_profile_api_token', methods: ['GET', 'POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[IsGranted('api-token', 'profile')]
public function apiTokenAction(User $profile, Request $request, UserService $userService, AccessTokenRepository $accessTokenRepository): Response
public function apiTokenAction(
#[MapEntity(mapping: ['username' => 'username'])]
User $profile,
Request $request,
UserService $userService,
AccessTokenRepository $accessTokenRepository
): Response
{
$form = $this->createForm(UserApiPasswordType::class, $profile, [
'action' => $this->generateUrl('user_profile_api_token', ['username' => $profile->getUserIdentifier()]),
@@ -234,7 +261,12 @@ final class ProfileController extends AbstractController
#[Route(path: '/{username}/roles', name: 'user_profile_roles', methods: ['GET', 'POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[IsGranted('roles', 'profile')]
public function rolesAction(User $profile, Request $request, UserRepository $userRepository): Response
public function rolesAction(
#[MapEntity(mapping: ['username' => 'username'])]
User $profile,
Request $request,
UserRepository $userRepository
): Response
{
$isSuperAdmin = $profile->isSuperAdmin();
@@ -266,7 +298,12 @@ final class ProfileController extends AbstractController
#[Route(path: '/{username}/contract', name: 'user_profile_contract', methods: ['GET', 'POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[IsGranted('contract', 'profile')]
public function contractAction(User $profile, Request $request, UserRepository $userRepository): Response
public function contractAction(
#[MapEntity(mapping: ['username' => 'username'])]
User $profile,
Request $request,
UserRepository $userRepository
): Response
{
$form = $this->createForm(UserContractType::class, new UserContractModel($profile), [
'action' => $this->generateUrl('user_profile_contract', ['username' => $profile->getUserIdentifier()]),
@@ -292,7 +329,13 @@ final class ProfileController extends AbstractController
#[Route(path: '/{username}/teams', name: 'user_profile_teams', methods: ['GET', 'POST'])]
#[IsGranted('teams', 'profile')]
public function teamsAction(User $profile, Request $request, UserRepository $userRepository, TeamRepository $teamRepository): Response
public function teamsAction(
#[MapEntity(mapping: ['username' => 'username'])]
User $profile,
Request $request,
UserRepository $userRepository,
TeamRepository $teamRepository
): Response
{
$originalMembers = new ArrayCollection();
foreach ($profile->getMemberships() as $member) {
@@ -328,7 +371,13 @@ final class ProfileController extends AbstractController
#[Route(path: '/{username}/prefs', name: 'user_profile_preferences', methods: ['GET', 'POST'])]
#[IsGranted('preferences', 'profile')]
public function preferencesAction(User $profile, Request $request, EventDispatcherInterface $dispatcher, UserRepository $userRepository): Response
public function preferencesAction(
#[MapEntity(mapping: ['username' => 'username'])]
User $profile,
Request $request,
EventDispatcherInterface $dispatcher,
UserRepository $userRepository
): Response
{
// we need to prepare the user preferences, which is done via an EventSubscriber
$event = new PrepareUserEvent($profile, false);
@@ -449,7 +498,13 @@ final class ProfileController extends AbstractController
#[Route(path: '/{username}/2fa', name: 'user_profile_2fa', methods: ['GET', 'POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[IsGranted('2fa', 'profile')]
public function twoFactorAction(User $profile, Request $request, UserService $userService, TotpAuthenticatorInterface $totpAuthenticator): Response
public function twoFactorAction(
#[MapEntity(mapping: ['username' => 'username'])]
User $profile,
Request $request,
UserService $userService,
TotpAuthenticatorInterface $totpAuthenticator
): Response
{
if (!$profile->hasTotpSecret()) {
$profile->setTotpSecret($totpAuthenticator->generateSecret());
@@ -509,7 +564,12 @@ final class ProfileController extends AbstractController
#[Route(path: '/{username}/2fa_deactivate', name: 'user_profile_2fa_deactivate', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[IsGranted('2fa', 'profile')]
public function deactivateTwoFactorAction(User $profile, Request $request, UserService $userService, TotpAuthenticatorInterface $totpAuthenticator): Response
public function deactivateTwoFactorAction(
#[MapEntity(mapping: ['username' => 'username'])]
User $profile,
Request $request,
UserService $userService
): Response
{
if ($profile->hasTotpSecret()) {
$form = $this->getTwoFactorDeactivationForm($profile);

View File

@@ -14,23 +14,25 @@ use App\Controller\AbstractController;
use App\Entity\User;
use App\Event\EmailEvent;
use App\Event\EmailPasswordResetEvent;
use App\Form\PasswordResetForm;
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;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
#[Route(path: '/resetting')]
final class PasswordResetController extends AbstractController
{
public const CSRF_TOKEN = 'password_reset';
public function __construct(
private readonly EventDispatcherInterface $eventDispatcher,
private readonly UserService $userService,
@@ -48,6 +50,10 @@ final class PasswordResetController extends AbstractController
throw $this->createNotFoundException();
}
if ($this->isGranted('IS_AUTHENTICATED')) {
return $this->redirectToRoute('homepage');
}
return $this->render('security/password-reset/request.html.twig');
}
@@ -55,126 +61,92 @@ final class PasswordResetController extends AbstractController
* Request reset user password: submit form and send email.
*/
#[Route(path: '/send-email', name: 'resetting_send_email', methods: ['POST'])]
public function sendEmailAction(Request $request, TranslatorInterface $translator): Response
public function sendEmailAction(
Request $request,
TranslatorInterface $translator,
CsrfTokenManagerInterface $csrfTokenManager,
LoginLinkHandlerInterface $loginLinkHandler,
RateLimiterFactory $resetPasswordLimiter
): Response
{
if (!$this->configuration->isPasswordResetActive()) {
throw $this->createNotFoundException();
}
if ($this->isGranted('IS_AUTHENTICATED')) {
return $this->redirectToRoute('homepage');
}
$limiter = $resetPasswordLimiter->create($request->getClientIp());
$limit = $limiter->consume();
if (!$limit->isAccepted()) {
return new Response(null, Response::HTTP_TOO_MANY_REQUESTS);
}
$username = $request->request->get('username');
if (!\is_string($username) || trim($username) === '') {
throw $this->createAccessDeniedException('Username cannot be empty');
}
$token = $request->request->get('_csrf_token');
$user = $this->userService->findUserByUsernameOrEmail($username);
try {
$user = null;
if (!$user->isPasswordRequestNonExpired($this->configuration->getPasswordResetRetryLifetime())) {
if (!$user->isInternalUser()) {
throw $this->createAccessDeniedException(
\sprintf('The user "%s" tried to reset the password, but it is registered as "%s" auth-type.', $user->getUserIdentifier(), $user->getAuth())
);
if (\is_string($token) && $csrfTokenManager->isTokenValid(new CsrfToken(self::CSRF_TOKEN, $token))) {
if (\is_string($username) && $username !== '') {
$user = $this->userService->findUserByUsernameOrEmail($username);
}
}
if (null === $user->getConfirmationToken()) {
$user->setConfirmationToken($this->userService->generateSecurityToken());
$csrfTokenManager->refreshToken(self::CSRF_TOKEN);
// do not leak the information that this user is not registered OR cannot use this type of login
if ($user === null || !$user->isInternalUser()) {
return $this->redirectToRoute('resetting_check_email');
}
$mail = $this->generateResettingEmailMessage($user, $translator);
$event = new EmailPasswordResetEvent($user, $mail);
$this->eventDispatcher->dispatch($event);
if (!$user->isPasswordRequestNonExpired($this->configuration->getPasswordResetRetryLifetime())) {
$loginLinkDetails = $loginLinkHandler->createLoginLink($user, $request, $this->configuration->getPasswordResetRetryLifetime()); // @phpstan-ignore-line
$loginLink = $loginLinkDetails->getUrl();
// this will finally send the email
$this->eventDispatcher->dispatch(new EmailEvent($event->getEmail()));
$mail = $this->generateResettingEmailMessage($user, $translator, $loginLink);
$event = new EmailPasswordResetEvent($user, $mail);
$this->eventDispatcher->dispatch($event);
$user->markPasswordRequested();
$this->userService->saveUser($user);
// this will send the email
$this->eventDispatcher->dispatch(new EmailEvent($event->getEmail()));
$user->markPasswordRequested();
$user->setRequiresPasswordReset(true);
$this->userService->saveUser($user);
}
} catch (\Exception $ex) {
// this is an expected exception: do not log this attempt
}
return $this->redirectToRoute('resetting_check_email', ['username' => $username]);
return $this->redirectToRoute('resetting_check_email');
}
/**
* Tell the user to check his email provider.
* Tell the user to check his emails.
*/
#[Route(path: '/check-email', name: 'resetting_check_email', methods: ['GET'])]
public function checkEmailAction(Request $request): Response
public function checkEmailAction(): Response
{
if (!$this->configuration->isPasswordResetActive()) {
throw $this->createNotFoundException();
}
$username = $request->query->get('username');
if (empty($username)) {
// the user does not come from the sendEmail action
return $this->redirectToRoute('resetting_request');
}
return $this->render('security/password-reset/check_email.html.twig', [
'tokenLifetime' => $this->configuration->getPasswordResetRetryLifetime(),
]);
}
/**
* Reset user password.
*/
#[Route(path: '/reset/{token}', name: 'resetting_reset', methods: ['GET', 'POST'])]
public function resetAction(Request $request, LoginManager $loginManager, ?string $token): Response
{
if (!$this->configuration->isPasswordResetActive()) {
throw $this->createNotFoundException();
}
$user = $this->userService->findUserByConfirmationToken($token);
if (null === $user) {
return $this->redirectToRoute('login');
}
if (!$user->isPasswordRequestNonExpired($this->configuration->getPasswordResetTokenLifetime())) {
$this->flashWarning('This link has already expired');
return $this->redirectToRoute('resetting_request');
}
$form = $this->createResetForm();
$form->setData($user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$user->markPasswordResetted();
$user->setEnabled(true);
$this->userService->saveUser($user);
$response = $this->redirectToRoute('my_profile');
$loginManager->logInUser($user, $response);
return $response;
}
return $this->render('security/password-reset/reset.html.twig', [
'token' => $token,
'form' => $form->createView(),
]);
}
private function createResetForm(): FormInterface
{
$options = ['validation_groups' => ['ResetPassword', 'Default']];
return $this->createFormBuilder()->create('resetting_form', PasswordResetForm::class, $options)->getForm();
}
private function generateResettingEmailMessage(User $user, TranslatorInterface $translator): Email
private function generateResettingEmailMessage(User $user, TranslatorInterface $translator, string $url): Email
{
$username = $user->getDisplayName();
$language = $user->getLanguage();
$url = $this->generateUrl('resetting_reset', ['token' => $user->getConfirmationToken()], UrlGeneratorInterface::ABSOLUTE_URL);
return (new TemplatedEmail())
->locale($language)
->to(new Address($user->getEmail()))
->subject(
$translator->trans('reset.subject', ['%username%' => $username], 'email', $language)

View File

@@ -552,7 +552,7 @@ final class Configuration implements ConfigurationInterface
->defaultTrue()
->end()
->integerNode('password_reset_retry_ttl')
->defaultValue(7200)
->defaultValue(3600)
->end()
->integerNode('password_reset_token_ttl')
->defaultValue(86400)

View File

@@ -0,0 +1,20 @@
<?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\Behavior;
/**
* @internal
*/
interface CreatedAt
{
public function getCreatedAt(): ?\DateTimeImmutable;
public function setCreatedAt(\DateTimeImmutable $dateTime): void;
}

View File

@@ -0,0 +1,28 @@
<?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\Behavior;
use Doctrine\ORM\Mapping as ORM;
trait CreatedTrait
{
#[ORM\Column(name: 'created_at', type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $createdAt = null;
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeImmutable $dateTime): void
{
$this->createdAt = $dateTime;
}
}

View File

@@ -7,9 +7,11 @@
* file that was distributed with this source code.
*/
namespace App\Doctrine;
namespace App\Doctrine\Behavior;
interface ModifiedAt
{
public function getModifiedAt(): ?\DateTimeImmutable;
public function setModifiedAt(\DateTimeImmutable $dateTime): void;
}

View File

@@ -0,0 +1,28 @@
<?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\Behavior;
use Doctrine\ORM\Mapping as ORM;
trait ModifiedTrait
{
#[ORM\Column(name: 'modified_at', type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $modifiedAt = null;
public function getModifiedAt(): ?\DateTimeImmutable
{
return $this->modifiedAt;
}
public function setModifiedAt(\DateTimeImmutable $dateTime): void
{
$this->modifiedAt = $dateTime;
}
}

View File

@@ -9,6 +9,8 @@
namespace App\Doctrine;
use App\Doctrine\Behavior\CreatedAt;
use App\Doctrine\Behavior\ModifiedAt;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\OnFlushEventArgs;
@@ -36,12 +38,18 @@ final class ModifiedSubscriber implements EventSubscriber, DataSubscriberInterfa
if ($entity instanceof ModifiedAt) {
$entity->setModifiedAt($now);
}
if ($entity instanceof CreatedAt && $entity->getCreatedAt() === null) {
$entity->setCreatedAt($now);
}
}
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if ($entity instanceof ModifiedAt) {
$entity->setModifiedAt($now);
}
if ($entity instanceof CreatedAt && $entity->getCreatedAt() === null) {
$entity->setCreatedAt($now);
}
}
}
}

View File

@@ -9,6 +9,8 @@
namespace App\Entity;
use App\Doctrine\Behavior\CreatedAt;
use App\Doctrine\Behavior\CreatedTrait;
use App\Export\Annotation as Exporter;
use App\Validator\Constraints as Constraints;
use Doctrine\Common\Collections\ArrayCollection;
@@ -30,10 +32,11 @@ use Symfony\Component\Validator\Constraints as Assert;
#[Exporter\Order(['id', 'name', 'project', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable', 'number'])]
#[Exporter\Expose(name: 'project', label: 'project', exp: 'object.getProject() === null ? null : object.getProject().getName()')]
#[Constraints\Activity]
class Activity implements EntityWithMetaFields, EntityWithBudget
class Activity implements EntityWithMetaFields, EntityWithBudget, CreatedAt
{
use BudgetTrait;
use ColorTrait;
use CreatedTrait;
/**
* Unique activity ID
@@ -122,6 +125,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
{
$this->meta = new ArrayCollection();
$this->teams = new ArrayCollection();
$this->setCreatedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
}
public function getId(): ?int
@@ -298,6 +302,8 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
$this->id = null;
}
$this->setCreatedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
$currentTeams = $this->teams;
$this->teams = new ArrayCollection();
/** @var Team $team */
@@ -305,6 +311,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
$this->addTeam($team);
}
$this->number = null;
$currentMeta = $this->meta;
$this->meta = new ArrayCollection();
/** @var ActivityMeta $meta */

View File

@@ -9,6 +9,8 @@
namespace App\Entity;
use App\Doctrine\Behavior\CreatedAt;
use App\Doctrine\Behavior\CreatedTrait;
use App\Export\Annotation as Exporter;
use App\Validator\Constraints as Constraints;
use Doctrine\Common\Collections\ArrayCollection;
@@ -25,12 +27,13 @@ use Symfony\Component\Validator\Constraints as Assert;
#[Serializer\ExclusionPolicy('all')]
#[Exporter\Order(['id', 'name', 'company', 'number', 'vatId', 'address', 'contact', 'email', 'phone', 'mobile', 'fax', 'homepage', 'country', 'currency', 'timezone', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable'])]
#[Constraints\Customer]
class Customer implements EntityWithMetaFields, EntityWithBudget
class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
{
public const DEFAULT_CURRENCY = 'EUR';
use BudgetTrait;
use ColorTrait;
use CreatedTrait;
#[ORM\Column(name: 'id', type: 'integer')]
#[ORM\Id]
@@ -191,6 +194,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
$this->name = $name;
$this->meta = new ArrayCollection();
$this->teams = new ArrayCollection();
$this->setCreatedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
}
public function getId(): ?int
@@ -479,6 +483,8 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
$this->id = null;
}
$this->setCreatedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
$currentTeams = $this->teams;
$this->teams = new ArrayCollection();
/** @var Team $team */
@@ -486,6 +492,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
$this->addTeam($team);
}
$this->number = null;
$currentMeta = $this->meta;
$this->meta = new ArrayCollection();
/** @var CustomerMeta $meta */

View File

@@ -9,6 +9,8 @@
namespace App\Entity;
use App\Doctrine\Behavior\CreatedAt;
use App\Doctrine\Behavior\CreatedTrait;
use App\Export\Annotation as Exporter;
use App\Validator\Constraints as Constraints;
use Doctrine\Common\Collections\ArrayCollection;
@@ -29,10 +31,11 @@ use Symfony\Component\Validator\Constraints as Assert;
#[Exporter\Order(['id', 'name', 'customer', 'orderNumber', 'orderDate', 'start', 'end', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable', 'number'])]
#[Exporter\Expose(name: 'customer', label: 'customer', exp: 'object.getCustomer() === null ? null : object.getCustomer().getName()')]
#[Constraints\Project]
class Project implements EntityWithMetaFields, EntityWithBudget
class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
{
use BudgetTrait;
use ColorTrait;
use CreatedTrait;
/**
* Unique Project ID
@@ -178,6 +181,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget
{
$this->meta = new ArrayCollection();
$this->teams = new ArrayCollection();
$this->setCreatedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
}
public function getId(): ?int
@@ -474,6 +478,8 @@ class Project implements EntityWithMetaFields, EntityWithBudget
$this->id = null;
}
$this->setCreatedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
$currentTeams = $this->teams;
$this->teams = new ArrayCollection();
/** @var Team $team */

View File

@@ -9,7 +9,8 @@
namespace App\Entity;
use App\Doctrine\ModifiedAt;
use App\Doctrine\Behavior\ModifiedAt;
use App\Doctrine\Behavior\ModifiedTrait;
use App\Validator\Constraints as Constraints;
use DateTime;
use DateTimeZone;
@@ -48,6 +49,8 @@ use Symfony\Component\Validator\Constraints as Assert;
#[Constraints\TimesheetDeactivated]
class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
{
use ModifiedTrait;
/**
* Category: Normal work-time (default category)
*/
@@ -127,6 +130,8 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
private ?int $duration = 0;
#[ORM\Column(name: 'break', type: 'integer', nullable: true)]
private ?int $break = 0;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: '`user`', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
@@ -190,8 +195,6 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
#[ORM\Column(name: 'category', type: 'string', length: 10, nullable: false, options: ['default' => 'work'])]
#[Assert\NotNull]
private ?string $category = self::WORK;
#[ORM\Column(name: 'modified_at', type: 'datetime_immutable', nullable: true)]
private \DateTimeImmutable $modifiedAt;
/**
* Tags
*
@@ -220,7 +223,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
{
$this->tags = new ArrayCollection();
$this->meta = new ArrayCollection();
$this->modifiedAt = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$this->setModifiedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
}
/**
@@ -318,12 +321,22 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
public function getCalculatedDuration(): ?int
{
if ($this->begin !== null && $this->end !== null) {
return $this->end->getTimestamp() - $this->begin->getTimestamp();
return $this->end->getTimestamp() - $this->begin->getTimestamp() - $this->getBreak();
}
return null;
}
public function getBreak(): int
{
return $this->break ?? 0;
}
public function setBreak(?int $break): void
{
$this->break = $break ?? 0;
}
public function setUser(?User $user): Timesheet
{
$this->user = $user;
@@ -550,16 +563,6 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
return $this;
}
public function getModifiedAt(): \DateTimeImmutable
{
return $this->modifiedAt;
}
public function setModifiedAt(\DateTimeImmutable $dateTime): void
{
$this->modifiedAt = $dateTime;
}
/**
* @return Collection|MetaTableTypeInterface[]
*/
@@ -607,7 +610,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
{
// this needs to be done, otherwise doctrine will not see the item as changed
// and the calculators will not run
$this->modifiedAt = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$this->setModifiedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
if (null === ($current = $this->getMetaField($meta->getName()))) {
$meta->setEntity($this);
@@ -655,8 +658,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
$this->id = null;
}
// field will not be set, if it contains a value
$this->modifiedAt = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$this->setModifiedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
$this->exported = false;
$currentMeta = $this->meta;

View File

@@ -986,31 +986,12 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
public function markPasswordRequested(): void
{
$this->setPasswordRequestedAt(new \DateTimeImmutable('now', new \DateTimeZone($this->getTimezone())));
}
public function markPasswordResetted(): void
{
$this->setConfirmationToken(null);
$this->setPasswordRequestedAt(null);
}
public function setPasswordRequestedAt(?\DateTimeImmutable $date): void
{
$this->passwordRequestedAt = $date;
}
/**
* Gets the timestamp that the user requested a password reset.
*/
public function getPasswordRequestedAt(): ?\DateTimeImmutable
{
return $this->passwordRequestedAt;
$this->passwordRequestedAt = new \DateTimeImmutable('now', new \DateTimeZone($this->getTimezone()));
}
public function isPasswordRequestNonExpired(int $seconds): bool
{
$date = $this->getPasswordRequestedAt();
$date = $this->passwordRequestedAt;
if (!($date instanceof \DateTimeInterface)) {
return false;
@@ -1159,6 +1140,10 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
public function setRequiresPasswordReset(bool $require = true): void
{
$this->setPreferenceValue('__pw_reset__', ($require ? '1' : '0'));
if (!$require) {
$this->passwordRequestedAt = null;
}
}
public function hasSeenWizard(string $wizard): bool

View File

@@ -1,48 +0,0 @@
<?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\Form;
use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class PasswordResetForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'options' => [
'attr' => [
'autocomplete' => 'new-password',
],
],
'first_options' => ['label' => 'password'],
'second_options' => ['label' => 'password_repeat'],
'invalid_message' => 'The entered passwords don\'t match.',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => User::class,
'csrf_token_id' => 'resetting',
]);
}
public function getBlockPrefix(): string
{
return 'password_resetting';
}
}

View File

@@ -17,7 +17,7 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class ReportSumType extends AbstractType
{
public function __construct(private AuthorizationCheckerInterface $authorizationChecker)
public function __construct(private readonly AuthorizationCheckerInterface $authorizationChecker)
{
}
@@ -30,11 +30,11 @@ final class ReportSumType extends AbstractType
]);
$resolver->setDefault('choices', function (Options $options) {
$choices = ['stats.durationTotal' => 'duration'];
$choices = ['stats.workingTime' => 'duration'];
if ($this->authorizationChecker->isGranted('view_rate_other_timesheet')) {
$choices['stats.amountTotal'] = 'rate';
$choices['internalRate'] = 'internalRate';
$choices['revenue'] = 'rate';
$choices['costs'] = 'internalRate';
}
return $choices;

View File

@@ -285,7 +285,7 @@ class BaseQuery
return $this;
}
public function setBookmark(Bookmark $bookmark): void
public function setBookmark(?Bookmark $bookmark): void
{
$this->bookmark = $bookmark;
}

View File

@@ -128,18 +128,6 @@ class TimesheetQuery extends ActivityQuery implements BillableInterface, DateRan
$this->timesheetUser = $user;
}
/**
* @return array<int>
*/
public function getActivityIds(): array
{
return array_values(array_filter(array_unique(array_map(function (Activity $activity) {
return $activity->getId();
}, $this->activities)), function ($id) {
return $id !== null;
}));
}
/**
* @return array<Activity>
*/

View File

@@ -620,7 +620,7 @@ class TimesheetRepository extends EntityRepository
if ($query->hasActivities()) {
$qb->andWhere($qb->expr()->in('t.activity', ':activity'))
->setParameter('activity', $query->getActivityIds());
->setParameter('activity', $query->getActivities());
}
if ($query->hasProjects()) {

View File

@@ -18,7 +18,7 @@ use App\Timesheet\RoundingService;
*/
final class DurationCalculator implements CalculatorInterface
{
public function __construct(private RoundingService $roundings)
public function __construct(private readonly RoundingService $roundings)
{
}

View File

@@ -182,9 +182,8 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
return $this->getFormatter()->dayName($dateTime, $short);
}
public function getJavascriptConfiguration(?User $user = null): array
public function getJavascriptConfiguration(?User $user = null, ?string $language = null): array
{
$language = User::DEFAULT_LANGUAGE;
$browserTitle = false;
$id = null;
$name = 'anonymous';
@@ -194,7 +193,7 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
if ($user !== null) {
$browserTitle = (bool) $user->getPreferenceValue('update_browser_title');
$language = $user->getLanguage();
$language ??= $user->getLanguage();
$id = $user->getId();
$name = $user->getDisplayName();
$admin = $user->isAdmin();
@@ -202,6 +201,8 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
$timezone = $user->getTimezone();
}
$language ??= $this->locale ?? User::DEFAULT_LANGUAGE;
return [
'locale' => $this->locale,
'language' => $language,

View File

@@ -17,12 +17,11 @@ final class DateTimeFormatValidator extends ConstraintValidator
{
/**
* @param string|mixed|null $value
* @param Constraint $constraint
*/
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof DateTimeFormat)) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\DateTimeFormat');
throw new UnexpectedTypeException($constraint, DateTimeFormat::class);
}
if (!\is_string($value)) {

View File

@@ -18,7 +18,7 @@ final class HexColorValidator extends ConstraintValidator
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof HexColor) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\HexColor');
throw new UnexpectedTypeException($constraint, HexColor::class);
}
$color = $value;

View File

@@ -16,14 +16,14 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class RoleValidator extends ConstraintValidator
{
public function __construct(private RoleService $service)
public function __construct(private readonly RoleService $service)
{
}
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof Role) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\Role');
throw new UnexpectedTypeException($constraint, Role::class);
}
$roles = $value;

View File

@@ -18,12 +18,11 @@ final class TimeFormatValidator extends ConstraintValidator
{
/**
* @param string|mixed $value
* @param Constraint $constraint
*/
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof TimeFormat)) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\TimeFormat');
throw new UnexpectedTypeException($constraint, TimeFormat::class);
}
if (null === $value || '' === $value) {

View File

@@ -20,12 +20,11 @@ final class TimesheetMultiUpdateValidator extends ConstraintValidator
{
/**
* @param TimesheetMultiUpdateDTO|mixed $value
* @param Constraint $constraint
*/
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof TimesheetMultiUpdateConstraint)) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\TimesheetMultiUpdate');
throw new UnexpectedTypeException($constraint, TimesheetMultiUpdateConstraint::class);
}
if (!\is_object($value) || !($value instanceof TimesheetMultiUpdateDTO)) {
@@ -49,10 +48,6 @@ final class TimesheetMultiUpdateValidator extends ConstraintValidator
}
}
/**
* @param TimesheetMultiUpdateDTO $dto
* @param ExecutionContextInterface $context
*/
protected function validateActivityAndProject(TimesheetMultiUpdateDTO $dto, ExecutionContextInterface $context): void
{
$activity = $dto->getActivity();