From 1d3e99d2b2dfc38412b9a09d400c14ef362431e8 Mon Sep 17 00:00:00 2001 From: Marijn Doeve Date: Wed, 8 Jul 2026 14:38:32 +0200 Subject: [PATCH] feat: user settings page (#191) * feat: user settings page (#182) - Settings link in the backoffice nav next to Logout - Language selector (Dutch only, noop save) - Change password form with current-password check - Change email form that re-triggers email confirmation - Resend confirmation email button for unconfirmed addresses - Disabled Download data button with Soon(tm) popover - Delete account with password confirmation modal, removes seasons the user is the sole owner of - Well-known URLs: change-password redirect and security.txt * feat: base security.txt Expires on the container build time The BUILD_TIME build arg is baked into the prod image as an env var and set by CI at image build. security.txt expires one year after the build, so the file goes stale when deployments stop. Dev and test fall back to one year from the request time. * refactor: extract shared controller functionality - AbstractController: authenticatedUser property hook and assertSameSeason() (moved from QuestionBankController) - EmailVerifier::sendDefaultConfirmation() replaces the duplicated confirmation email block in RegistrationController and SettingsController - QuizController: deduplicate candidate-data preparation into buildCandidateData() - Drop manual 422 status handling in QuizQuestionController, QuestionBankController and SettingsController: render() already returns 422 for submitted invalid forms passed as parameters * fix: address PR review comments - Catch UniqueConstraintViolationException when changing email to handle the race between the uniqueness check and the flush - Avoid else-only block in UserRepository::deleteUser - Align duplicate-email translation with the validators domain * fix: apply code-review findings for PR #191 - Invalidate outstanding ResetPasswordRequests after password or email change to close the account-takeover window (tokens otherwise remain valid) - Exclude the current user from email uniqueness check so submitting your own address no longer returns an error - Surface transport failures from sendDefaultConfirmation via a warning flash instead of silently showing success - Move Dockerfile ARG BUILD_TIME/ENV to after all build steps so changing the build timestamp no longer busts the composer/asset cache - Throw in prod when BUILD_TIME is missing (WellKnownController) so the security.txt Expires goes stale as intended when deployments stop; fall back to 'now' only in dev/test --- .github/workflows/ci.yml | 2 + CLAUDE.md | 1 + Dockerfile | 4 + assets/controllers/bo/popover_controller.js | 13 + src/Controller/AbstractController.php | 19 + .../Backoffice/BackofficeController.php | 11 +- .../Backoffice/QuestionBankController.php | 23 +- src/Controller/Backoffice/QuizController.php | 69 ++-- .../Backoffice/QuizQuestionController.php | 8 +- .../Backoffice/SettingsController.php | 180 +++++++++ src/Controller/RegistrationController.php | 18 +- src/Controller/WellKnownController.php | 59 +++ src/DataFixtures/TestFixtures.php | 31 ++ src/Form/ChangeEmailFormType.php | 44 +++ src/Form/ChangeUserPasswordFormType.php | 70 ++++ src/Repository/UserRepository.php | 31 ++ src/Security/EmailVerifier.php | 23 ++ templates/backoffice/nav.html.twig | 4 + templates/backoffice/settings/index.html.twig | 103 +++++ .../Backoffice/SettingsControllerTest.php | 353 ++++++++++++++++++ tests/Controller/WellKnownControllerTest.php | 48 +++ translations/messages+intl-icu.nl.xliff | 108 ++++++ translations/validators.nl.xliff | 12 + 23 files changed, 1144 insertions(+), 90 deletions(-) create mode 100644 assets/controllers/bo/popover_controller.js create mode 100644 src/Controller/Backoffice/SettingsController.php create mode 100644 src/Controller/WellKnownController.php create mode 100644 src/Form/ChangeEmailFormType.php create mode 100644 src/Form/ChangeUserPasswordFormType.php create mode 100644 templates/backoffice/settings/index.html.twig create mode 100644 tests/Controller/Backoffice/SettingsControllerTest.php create mode 100644 tests/Controller/WellKnownControllerTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88c4bc7..e33042b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -256,6 +256,7 @@ jobs: id: meta run: | REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') + echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" if [[ "${{ github.ref }}" == refs/tags/* ]]; then TAG="${GITHUB_REF#refs/tags/}" SENTRY_VERSION="${TAG#v}" @@ -287,6 +288,7 @@ jobs: *.cache-from=type=gha,scope=${{github.ref}} *.cache-from=type=gha,scope=refs/heads/main *.cache-to=type=gha,scope=${{github.ref}},mode=max + *.args.BUILD_TIME=${{ steps.meta.outputs.build_time }} *.tags=${{ steps.meta.outputs.full_name }} - name: Create Sentry release diff --git a/CLAUDE.md b/CLAUDE.md index 8897dc6..aef60a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,6 +151,7 @@ tests/ ### Testing Conventions (TDD) - **Write the failing test first.** When fixing any PHP-reachable bug, write a PHPUnit test that reproduces the failure before touching the production code. Fix the code until the test passes. - Only skip a test if the bug is purely in JavaScript/frontend where PHPUnit cannot reach it. +- Don't write tests for trivial presentational markup (e.g. asserting a tooltip/popover attribute or a CSS class exists in a template). Tests cover behavior: routing, forms, persistence, authorization. - Follow the pattern in `tests/Controller/Backoffice/` for controller/integration tests: log in, GET for CSRF token, POST form data, assert redirect, clear entity manager, assert DB state. ### Code Style & Standards diff --git a/Dockerfile b/Dockerfile index 967e614..3c20b88 100644 --- a/Dockerfile +++ b/Dockerfile @@ -109,3 +109,7 @@ RUN set -eux; \ bin/console sass:build; \ bin/console asset-map:compile --no-debug --quiet --no-ansi; \ sync; + +# Build timestamp for /.well-known/security.txt Expires; must be injected last to avoid cache busting. +ARG BUILD_TIME="" +ENV BUILD_TIME=$BUILD_TIME diff --git a/assets/controllers/bo/popover_controller.js b/assets/controllers/bo/popover_controller.js new file mode 100644 index 0000000..f0b254d --- /dev/null +++ b/assets/controllers/bo/popover_controller.js @@ -0,0 +1,13 @@ +import {Controller} from '@hotwired/stimulus'; +import {Popover} from 'bootstrap'; + +export default class extends Controller { + connect() { + this.popovers = [...this.element.querySelectorAll('[data-bs-toggle="popover"]')] + .map(popoverTriggerEl => Popover.getOrCreateInstance(popoverTriggerEl)); + } + + disconnect() { + this.popovers.forEach(popover => popover.dispose()); + } +} diff --git a/src/Controller/AbstractController.php b/src/Controller/AbstractController.php index d59329f..37d1874 100644 --- a/src/Controller/AbstractController.php +++ b/src/Controller/AbstractController.php @@ -5,6 +5,9 @@ declare(strict_types=1); namespace Tvdt\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as AbstractBaseController; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Tvdt\Entity\Season; +use Tvdt\Entity\User; use Tvdt\Enum\FlashType; abstract class AbstractController extends AbstractBaseController @@ -13,6 +16,22 @@ abstract class AbstractController extends AbstractBaseController protected const string CANDIDATE_HASH_REGEX = '[\w\-=]+'; + protected User $authenticatedUser { + get { + $user = $this->getUser(); + \assert($user instanceof User); + + return $user; + } + } + + protected function assertSameSeason(Season $season, Season $subjectSeason): void + { + if ($season !== $subjectSeason) { + throw new NotFoundHttpException(); + } + } + #[\Override] protected function addFlash(FlashType|string $type, mixed $message): void { diff --git a/src/Controller/Backoffice/BackofficeController.php b/src/Controller/Backoffice/BackofficeController.php index a47a0ae..97e1bb6 100644 --- a/src/Controller/Backoffice/BackofficeController.php +++ b/src/Controller/Backoffice/BackofficeController.php @@ -17,7 +17,6 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; use Tvdt\Controller\AbstractController; use Tvdt\Entity\Quiz; use Tvdt\Entity\Season; -use Tvdt\Entity\User; use Tvdt\Form\CreateSeasonFormType; use Tvdt\Repository\SeasonRepository; use Tvdt\Security\Voter\SeasonVoter; @@ -37,12 +36,9 @@ final class BackofficeController extends AbstractController #[Route('/backoffice/', name: 'tvdt_backoffice_index')] public function index(): Response { - $user = $this->getUser(); - \assert($user instanceof User); - $seasons = $this->security->isGranted('ROLE_ADMIN') ? $this->seasonRepository->findAll() - : $this->seasonRepository->getSeasonsForUser($user); + : $this->seasonRepository->getSeasonsForUser($this->authenticatedUser); return $this->render('backoffice/index.html.twig', [ 'seasons' => $seasons, @@ -58,10 +54,7 @@ final class BackofficeController extends AbstractController $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - $user = $this->getUser(); - \assert($user instanceof User); - - $season->addOwner($user); + $season->addOwner($this->authenticatedUser); $season->generateSeasonCode(); $this->em->persist($season); diff --git a/src/Controller/Backoffice/QuestionBankController.php b/src/Controller/Backoffice/QuestionBankController.php index 7f87369..98520e6 100644 --- a/src/Controller/Backoffice/QuestionBankController.php +++ b/src/Controller/Backoffice/QuestionBankController.php @@ -114,17 +114,11 @@ class QuestionBankController extends AbstractController ? 'backoffice/question_bank/_frame.html.twig' : 'backoffice/question_bank/form.html.twig'; - $response = $this->render($template, [ + return $this->render($template, [ 'season' => $season, 'form' => $form, 'bankQuestion' => null, ]); - - if ($form->isSubmitted()) { - $response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY); - } - - return $response; } #[IsGranted(SeasonVoter::EDIT, subject: 'season')] @@ -167,17 +161,11 @@ class QuestionBankController extends AbstractController ? 'backoffice/question_bank/_frame.html.twig' : 'backoffice/question_bank/form.html.twig'; - $response = $this->render($template, [ + return $this->render($template, [ 'season' => $season, 'form' => $form, 'bankQuestion' => $bankQuestion, ]); - - if ($form->isSubmitted()) { - $response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY); - } - - return $response; } #[IsCsrfTokenValid('delete_bank_question')] @@ -382,13 +370,6 @@ class QuestionBankController extends AbstractController return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]); } - private function assertSameSeason(Season $season, Season $subjectSeason): void - { - if ($season !== $subjectSeason) { - throw new NotFoundHttpException(); - } - } - private function syncUsagesAfterEdit(BankQuestion $bankQuestion): void { $pendingNames = []; diff --git a/src/Controller/Backoffice/QuizController.php b/src/Controller/Backoffice/QuizController.php index 7ee7460..6d54259 100644 --- a/src/Controller/Backoffice/QuizController.php +++ b/src/Controller/Backoffice/QuizController.php @@ -61,25 +61,7 @@ class QuizController extends AbstractController { $fetchedQuiz = $this->quizRepository->fetchWithQuestionsAndCandidates($quiz->id); - // Create indexed lookup for quiz candidates by candidate ID - $quizCandidatesByCandidateId = []; - foreach ($fetchedQuiz->candidateData as $qc) { - $quizCandidatesByCandidateId[$qc->candidate->id->toString()] = $qc; - } - - // Get given answers counts efficiently via database query - $givenAnswersCountByCandidateId = $this->quizRepository->getGivenAnswersCountPerCandidate($quiz); - - // Pre-compute candidate data to avoid nested loops in template - $candidateData = []; - foreach ($season->candidates as $candidate) { - $candidateIdString = $candidate->id->toString(); - $candidateData[] = [ - 'candidate' => $candidate, - 'quizCandidate' => $quizCandidatesByCandidateId[$candidateIdString] ?? null, - 'givenAnswersCount' => $givenAnswersCountByCandidateId[$candidateIdString] ?? 0, - ]; - } + $candidateData = $this->buildCandidateData($season, $quiz, $fetchedQuiz->candidateData); return $this->render('backoffice/quiz.html.twig', [ 'season' => $season, @@ -118,25 +100,7 @@ class QuizController extends AbstractController )] public function candidatesTab(Season $season, Quiz $quiz): Response { - // Create indexed lookup for quiz candidates by candidate ID - $quizCandidatesByCandidateId = []; - foreach ($quiz->candidateData as $qc) { - $quizCandidatesByCandidateId[$qc->candidate->id->toString()] = $qc; - } - - // Get given answers counts efficiently via database query - $givenAnswersCountByCandidateId = $this->quizRepository->getGivenAnswersCountPerCandidate($quiz); - - // Pre-compute candidate data to avoid nested loops in template - $candidateData = []; - foreach ($season->candidates as $candidate) { - $candidateIdString = $candidate->id->toString(); - $candidateData[] = [ - 'candidate' => $candidate, - 'quizCandidate' => $quizCandidatesByCandidateId[$candidateIdString] ?? null, - 'givenAnswersCount' => $givenAnswersCountByCandidateId[$candidateIdString] ?? 0, - ]; - } + $candidateData = $this->buildCandidateData($season, $quiz, $quiz->candidateData); return $this->render('backoffice/quiz.html.twig', [ 'season' => $season, @@ -432,4 +396,33 @@ class QuizController extends AbstractController return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]); } + + /** + * Pre-computes per-candidate data (quiz participation and given answer counts) to avoid nested loops in templates. + * + * @param iterable $quizCandidates + * + * @return list + */ + private function buildCandidateData(Season $season, Quiz $quiz, iterable $quizCandidates): array + { + $quizCandidatesByCandidateId = []; + foreach ($quizCandidates as $qc) { + $quizCandidatesByCandidateId[$qc->candidate->id->toString()] = $qc; + } + + $givenAnswersCountByCandidateId = $this->quizRepository->getGivenAnswersCountPerCandidate($quiz); + + $candidateData = []; + foreach ($season->candidates as $candidate) { + $candidateIdString = $candidate->id->toString(); + $candidateData[] = [ + 'candidate' => $candidate, + 'quizCandidate' => $quizCandidatesByCandidateId[$candidateIdString] ?? null, + 'givenAnswersCount' => $givenAnswersCountByCandidateId[$candidateIdString] ?? 0, + ]; + } + + return $candidateData; + } } diff --git a/src/Controller/Backoffice/QuizQuestionController.php b/src/Controller/Backoffice/QuizQuestionController.php index 3f8f0b8..10f7e9c 100644 --- a/src/Controller/Backoffice/QuizQuestionController.php +++ b/src/Controller/Backoffice/QuizQuestionController.php @@ -74,18 +74,12 @@ class QuizQuestionController extends AbstractController ? 'backoffice/quiz/_question_frame.html.twig' : 'backoffice/quiz/question_form.html.twig'; - $response = $this->render($template, [ + return $this->render($template, [ 'season' => $season, 'quiz' => $quiz, 'question' => $question, 'form' => $form, ]); - - if ($form->isSubmitted()) { - $response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY); - } - - return $response; } #[IsGranted(SeasonVoter::EDIT, subject: 'season')] diff --git a/src/Controller/Backoffice/SettingsController.php b/src/Controller/Backoffice/SettingsController.php new file mode 100644 index 0000000..c8bf816 --- /dev/null +++ b/src/Controller/Backoffice/SettingsController.php @@ -0,0 +1,180 @@ +renderSettings(); + } + + #[IsCsrfTokenValid('settings_language')] + #[Route('/backoffice/settings/language', name: 'tvdt_backoffice_settings_language', methods: ['POST'])] + public function saveLanguage(): RedirectResponse + { + // Only Dutch is available for now, so saving is a noop. + $this->addFlash(FlashType::Success, $this->translator->trans('Language saved')); + + return $this->redirectToRoute('tvdt_backoffice_settings'); + } + + #[Route('/backoffice/settings/password', name: 'tvdt_backoffice_settings_password', methods: ['POST'])] + public function changePassword(Request $request): Response + { + $user = $this->authenticatedUser; + $form = $this->createForm(ChangeUserPasswordFormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /** @var string $plainPassword */ + $plainPassword = $form->get('plainPassword')->getData(); + + $user->password = $this->passwordHasher->hashPassword($user, $plainPassword); + $this->entityManager->flush(); + $this->userRepository->invalidateResetPasswordRequests($user); + + $this->security->login($user, 'form_login', 'main'); + $this->addFlash(FlashType::Success, $this->translator->trans('Your password has been changed.')); + + return $this->redirectToRoute('tvdt_backoffice_settings'); + } + + return $this->renderSettings(passwordForm: $form); + } + + #[Route('/backoffice/settings/email', name: 'tvdt_backoffice_settings_email', methods: ['POST'])] + public function changeEmail(Request $request): Response + { + $user = $this->authenticatedUser; + $form = $this->createForm(ChangeEmailFormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /** @var string $email */ + $email = $form->get('email')->getData(); + + $found = $this->userRepository->findOneBy(['email' => $email]); + if ($found instanceof User && $found !== $user) { + $form->get('email')->addError(new FormError($this->translator->trans('There is already an account with this email'))); + + return $this->renderSettings(emailForm: $form); + } + + $originalEmail = $user->email; + $originalIsVerified = $user->isVerified; + + $user->email = $email; + $user->isVerified = false; + + try { + $this->entityManager->flush(); + } catch (UniqueConstraintViolationException) { + // A concurrent request can claim the email between the uniqueness check above and the flush + $user->email = $originalEmail; + $user->isVerified = $originalIsVerified; + $form->get('email')->addError(new FormError($this->translator->trans('There is already an account with this email'))); + + return $this->renderSettings(emailForm: $form); + } + + $this->userRepository->invalidateResetPasswordRequests($user); + + if ($this->emailVerifier->sendDefaultConfirmation($user)) { + $this->addFlash(FlashType::Success, $this->translator->trans('Your email address has been changed. Please check your inbox to confirm it.')); + } else { + $this->addFlash(FlashType::Success, $this->translator->trans('Your email address has been changed.')); + $this->addFlash(FlashType::Warning, $this->translator->trans('The confirmation email could not be sent. Please use the resend button to try again.')); + } + + $this->security->login($user, 'form_login', 'main'); + + return $this->redirectToRoute('tvdt_backoffice_settings'); + } + + return $this->renderSettings(emailForm: $form); + } + + #[IsCsrfTokenValid('resend_confirmation')] + #[Route('/backoffice/settings/resend-confirmation', name: 'tvdt_backoffice_settings_resend_confirmation', methods: ['POST'])] + public function resendConfirmationEmail(): RedirectResponse + { + $user = $this->authenticatedUser; + + if ($user->isVerified) { + $this->addFlash(FlashType::Info, $this->translator->trans('Your email address is already confirmed.')); + + return $this->redirectToRoute('tvdt_backoffice_settings'); + } + + if ($this->emailVerifier->sendDefaultConfirmation($user)) { + $this->addFlash(FlashType::Success, $this->translator->trans('A new confirmation email has been sent. Please check your inbox.')); + } else { + $this->addFlash(FlashType::Warning, $this->translator->trans('The confirmation email could not be sent. Please try again later.')); + } + + return $this->redirectToRoute('tvdt_backoffice_settings'); + } + + #[IsCsrfTokenValid('delete_account')] + #[Route('/backoffice/settings/delete', name: 'tvdt_backoffice_settings_delete', methods: ['POST'])] + public function deleteAccount(Request $request): Response + { + $user = $this->authenticatedUser; + $password = (string) $request->request->get('password', ''); + + if (!$this->passwordHasher->isPasswordValid($user, $password)) { + $this->addFlash(FlashType::Danger, $this->translator->trans('Wrong password, your account has not been deleted.')); + + return $this->redirectToRoute('tvdt_backoffice_settings'); + } + + $this->userRepository->deleteUser($user); + + return $this->security->logout(false) ?? $this->redirectToRoute('tvdt_login_login'); + } + + /** + * @param FormInterface|null $passwordForm + * @param FormInterface|null $emailForm + */ + private function renderSettings(?FormInterface $passwordForm = null, ?FormInterface $emailForm = null): Response + { + return $this->render('backoffice/settings/index.html.twig', [ + 'passwordForm' => $passwordForm ?? $this->createForm(ChangeUserPasswordFormType::class), + 'emailForm' => $emailForm ?? $this->createForm(ChangeEmailFormType::class), + ]); + } +} diff --git a/src/Controller/RegistrationController.php b/src/Controller/RegistrationController.php index 7b0c415..d7b0350 100644 --- a/src/Controller/RegistrationController.php +++ b/src/Controller/RegistrationController.php @@ -5,14 +5,11 @@ declare(strict_types=1); namespace Tvdt\Controller; use Doctrine\ORM\EntityManagerInterface; -use Psr\Log\LoggerInterface; -use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Core\User\UserInterface; @@ -26,7 +23,7 @@ use Tvdt\Security\EmailVerifier; final class RegistrationController extends AbstractController { - public function __construct(private readonly EmailVerifier $emailVerifier, private readonly TranslatorInterface $translator, private readonly UserPasswordHasherInterface $userPasswordHasher, private readonly Security $security, private readonly LoggerInterface $logger, private readonly UserRepository $userRepository, private readonly EntityManagerInterface $entityManager) {} + public function __construct(private readonly EmailVerifier $emailVerifier, private readonly TranslatorInterface $translator, private readonly UserPasswordHasherInterface $userPasswordHasher, private readonly Security $security, private readonly UserRepository $userRepository, private readonly EntityManagerInterface $entityManager) {} #[Route('/register', name: 'tvdt_register')] public function register( @@ -49,17 +46,8 @@ final class RegistrationController extends AbstractController $this->entityManager->persist($user); $this->entityManager->flush(); - try { - // generate a signed url and email it to the user - $this->emailVerifier->sendEmailConfirmation('tvdt_verify_email', $user, - new TemplatedEmail() - ->to($user->email) - ->subject($this->translator->trans('Please Confirm your Email')) - ->htmlTemplate('backoffice/registration/confirmation_email.html.twig'), - ); - } catch (TransportExceptionInterface $e) { - $this->logger->error($e->getMessage()); - } + // generate a signed url and email it to the user + $this->emailVerifier->sendDefaultConfirmation($user); $response = $this->security->login($user, 'form_login', 'main'); \assert($response instanceof Response); diff --git a/src/Controller/WellKnownController.php b/src/Controller/WellKnownController.php new file mode 100644 index 0000000..8725409 --- /dev/null +++ b/src/Controller/WellKnownController.php @@ -0,0 +1,59 @@ +redirectToRoute('tvdt_backoffice_settings'); + } + + /** + * @see https://www.rfc-editor.org/rfc/rfc9116 + * + * @throws DatetimeException + * @throws \Exception + */ + #[Route('/.well-known/security.txt', name: 'tvdt_well_known_security_txt', methods: ['GET'])] + public function securityTxt(): Response + { + // One year after the container build, so the file goes stale when deployments stop. + // In prod the build arg must be set; falling back to 'now' would renew Expires on every request, + // defeating the go-stale purpose. In dev/test 'now' is fine — no build bake happens there. + if ((null === $this->buildTime || '' === $this->buildTime) && 'prod' === $this->appEnv) { + throw new \LogicException('BUILD_TIME env var must be set in production (baked in during Docker build).'); + } + + $buildTime = (null !== $this->buildTime && '' !== $this->buildTime) ? $this->buildTime : 'now'; + $expires = new DateTimeImmutable($buildTime)->modify('+1 year')->format(\DATE_RFC3339); + + $content = << 'text/plain; charset=UTF-8']); + } +} diff --git a/src/DataFixtures/TestFixtures.php b/src/DataFixtures/TestFixtures.php index 0c5be0a..7c89bdb 100644 --- a/src/DataFixtures/TestFixtures.php +++ b/src/DataFixtures/TestFixtures.php @@ -9,6 +9,10 @@ use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface; use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Persistence\ObjectManager; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; +use Tvdt\Entity\Answer; +use Tvdt\Entity\Candidate; +use Tvdt\Entity\Question; +use Tvdt\Entity\Quiz; use Tvdt\Entity\Season; use Tvdt\Entity\User; @@ -70,6 +74,33 @@ final class TestFixtures extends Fixture implements FixtureGroupInterface, Depen $krtek->addOwner($user); $anotherSeason->addOwner($user); + $soleOwner = new User(); + $soleOwner->email = 'sole-owner@example.org'; + $soleOwner->password = $this->passwordHasher->hashPassword($soleOwner, self::PASSWORD); + + $manager->persist($soleOwner); + + $doomedSeason = new Season(); + $doomedSeason->name = 'Doomed Season'; + $doomedSeason->seasonCode = 'doomd'; + $doomedSeason->addCandidate(new Candidate('Vera')); + + $quiz = new Quiz(); + $quiz->name = 'Doomed Quiz'; + + $question = new Question(); + $question->question = 'Wie is de Krtek?'; + $question->ordering = 1; + $question->addAnswer(new Answer('Vera', true)); + + $quiz->addQuestion($question); + $doomedSeason->addQuiz($quiz); + + $manager->persist($doomedSeason); + + $doomedSeason->addOwner($soleOwner); + $anotherSeason->addOwner($soleOwner); + $manager->flush(); } } diff --git a/src/Form/ChangeEmailFormType.php b/src/Form/ChangeEmailFormType.php new file mode 100644 index 0000000..623d33b --- /dev/null +++ b/src/Form/ChangeEmailFormType.php @@ -0,0 +1,44 @@ + */ +final class ChangeEmailFormType extends AbstractType +{ + public function __construct(private readonly TranslatorInterface $translator) {} + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('email', EmailType::class, [ + 'label' => $this->translator->trans('New email address'), + 'attr' => ['autocomplete' => 'email'], + 'mapped' => false, + 'constraints' => [ + new NotBlank(message: 'Please enter an email address'), + new Email(), + ], + 'translation_domain' => false, + ]) + ->add('save', SubmitType::class, [ + 'label' => $this->translator->trans('Change email'), + 'translation_domain' => false, + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([]); + } +} diff --git a/src/Form/ChangeUserPasswordFormType.php b/src/Form/ChangeUserPasswordFormType.php new file mode 100644 index 0000000..5a6a8ef --- /dev/null +++ b/src/Form/ChangeUserPasswordFormType.php @@ -0,0 +1,70 @@ + */ +final class ChangeUserPasswordFormType extends AbstractType +{ + public function __construct(private readonly TranslatorInterface $translator) {} + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('currentPassword', PasswordType::class, [ + 'label' => $this->translator->trans('Current password'), + 'attr' => ['autocomplete' => 'current-password'], + 'mapped' => false, + 'constraints' => [ + new NotBlank(message: 'Please enter your current password'), + new UserPassword(message: 'This is not your current password.'), + ], + 'translation_domain' => false, + ]) + ->add('plainPassword', RepeatedType::class, [ + 'type' => PasswordType::class, + 'options' => [ + 'attr' => ['autocomplete' => 'new-password'], + ], + 'first_options' => [ + 'label' => $this->translator->trans('New password'), + 'constraints' => [ + new NotBlank(message: 'Please enter a password'), + new Length( + min: User::PASSWORD_MIN_LENGTH, + max: User::PASSWORD_MAX_LENGTH, + minMessage: 'Your password should be at least {{ limit }} characters', + ), + ], + ], + 'second_options' => [ + 'label' => $this->translator->trans('Repeat Password'), + ], + 'invalid_message' => $this->translator->trans('The password fields must match.'), + 'mapped' => false, + 'translation_domain' => false, + ]) + ->add('save', SubmitType::class, [ + 'label' => $this->translator->trans('Change password'), + 'translation_domain' => false, + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([]); + } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index afceb86..0a3433f 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -28,6 +28,37 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader $this->getEntityManager()->flush(); } + /** Deletes all outstanding reset-password tokens for the user (e.g. after a password or email change). */ + public function invalidateResetPasswordRequests(User $user): void + { + $this->getEntityManager() + ->createQuery('delete from Tvdt\Entity\ResetPasswordRequest r where r.user = :user') + ->setParameter('user', $user) + ->execute(); + } + + /** Deletes the user, all seasons the user is the sole owner of, and the user's ownership of shared seasons. */ + public function deleteUser(User $user): void + { + $em = $this->getEntityManager(); + $em->wrapInTransaction(function () use ($em, $user): void { + $this->invalidateResetPasswordRequests($user); + + foreach ($user->seasons->toArray() as $season) { + if (1 === $season->owners->count()) { + $em->remove($season); + + continue; + } + + $season->removeOwner($user); + } + + $em->remove($user); + $em->flush(); + }); + } + public function makeAdmin(string $email): void { $user = $this->findOneBy(['email' => $email]); diff --git a/src/Security/EmailVerifier.php b/src/Security/EmailVerifier.php index a334d53..ac8fcc1 100644 --- a/src/Security/EmailVerifier.php +++ b/src/Security/EmailVerifier.php @@ -5,10 +5,12 @@ declare(strict_types=1); namespace Tvdt\Security; use Doctrine\ORM\EntityManagerInterface; +use Psr\Log\LoggerInterface; use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mailer\MailerInterface; +use Symfony\Contracts\Translation\TranslatorInterface; use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface; use Tvdt\Entity\User; @@ -18,8 +20,29 @@ readonly class EmailVerifier private VerifyEmailHelperInterface $verifyEmailHelper, private MailerInterface $mailer, private EntityManagerInterface $entityManager, + private TranslatorInterface $translator, + private LoggerInterface $logger, ) {} + /** Sends the standard confirmation email to the user. Returns false (and logs) on transport errors. */ + public function sendDefaultConfirmation(User $user): bool + { + try { + $this->sendEmailConfirmation('tvdt_verify_email', $user, + new TemplatedEmail() + ->to($user->email) + ->subject($this->translator->trans('Please Confirm your Email')) + ->htmlTemplate('backoffice/registration/confirmation_email.html.twig'), + ); + + return true; + } catch (TransportExceptionInterface $transportException) { + $this->logger->error($transportException->getMessage()); + + return false; + } + } + /** @throws TransportExceptionInterface */ public function sendEmailConfirmation(string $verifyEmailRouteName, User $user, TemplatedEmail $email): void { diff --git a/templates/backoffice/nav.html.twig b/templates/backoffice/nav.html.twig index 8c62468..18fee18 100644 --- a/templates/backoffice/nav.html.twig +++ b/templates/backoffice/nav.html.twig @@ -23,6 +23,10 @@