Release 2.23.0 (#5075)
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
20
src/Doctrine/Behavior/CreatedAt.php
Normal file
20
src/Doctrine/Behavior/CreatedAt.php
Normal 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;
|
||||
}
|
||||
28
src/Doctrine/Behavior/CreatedTrait.php
Normal file
28
src/Doctrine/Behavior/CreatedTrait.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
28
src/Doctrine/Behavior/ModifiedTrait.php
Normal file
28
src/Doctrine/Behavior/ModifiedTrait.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -285,7 +285,7 @@ class BaseQuery
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setBookmark(Bookmark $bookmark): void
|
||||
public function setBookmark(?Bookmark $bookmark): void
|
||||
{
|
||||
$this->bookmark = $bookmark;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user