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 @@