Compare commits

...

2 Commits

Author SHA1 Message Date
Marijn 82f8f0e22a feat: add button to regenerate season code
Adds a "regenerate season code" action to the season settings page,
allowing owners to invalidate the current code (e.g. if it was shared
by mistake) and get a fresh one for candidates to join with.

Closes #14
2026-07-08 15:12:48 +02:00
Marijn 1d3e99d2b2 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
2026-07-08 14:38:32 +02:00
27 changed files with 1299 additions and 90 deletions
+2
View File
@@ -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
+1
View File
@@ -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
+4
View File
@@ -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
@@ -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());
}
}
+19
View File
@@ -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
{
@@ -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);
@@ -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 = [];
+31 -38
View File
@@ -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<QuizCandidate> $quizCandidates
*
* @return list<array{candidate: Candidate, quizCandidate: QuizCandidate|null, givenAnswersCount: int}>
*/
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;
}
}
@@ -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')]
@@ -10,10 +10,12 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
@@ -97,6 +99,24 @@ class SeasonController extends AbstractController
]);
}
#[IsCsrfTokenValid('regenerate_season_code')]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/settings/regenerate-code',
name: 'tvdt_backoffice_season_regenerate_code',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
methods: ['POST'],
)]
public function regenerateSeasonCode(Season $season): RedirectResponse
{
$season->generateSeasonCode();
$this->em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Season code regenerated'));
return $this->redirectToRoute('tvdt_backoffice_season_settings', ['seasonCode' => $season->seasonCode]);
}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/add-candidate',
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace Tvdt\Controller\Backoffice;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
use Symfony\Contracts\Translation\TranslatorInterface;
use Tvdt\Controller\AbstractController;
use Tvdt\Entity\User;
use Tvdt\Enum\FlashType;
use Tvdt\Form\ChangeEmailFormType;
use Tvdt\Form\ChangeUserPasswordFormType;
use Tvdt\Repository\UserRepository;
use Tvdt\Security\EmailVerifier;
final class SettingsController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly UserRepository $userRepository,
private readonly EmailVerifier $emailVerifier,
private readonly Security $security,
private readonly TranslatorInterface $translator,
) {}
#[Route('/backoffice/settings', name: 'tvdt_backoffice_settings', methods: ['GET'])]
public function index(): Response
{
return $this->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<array{currentPassword: string, plainPassword: string}|null>|null $passwordForm
* @param FormInterface<array{email: string}|null>|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),
]);
}
}
+3 -15
View File
@@ -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);
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Tvdt\Controller;
use Safe\DateTimeImmutable;
use Safe\Exceptions\DatetimeException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/** Serves well-known URIs (https://www.rfc-editor.org/rfc/rfc8615). */
final class WellKnownController extends AbstractController
{
public function __construct(
#[Autowire(env: 'default::BUILD_TIME')]
private readonly ?string $buildTime,
#[Autowire(env: 'APP_ENV')]
private readonly string $appEnv,
) {}
/** @see https://w3c.github.io/webappsec-change-password-url/ */
#[Route('/.well-known/change-password', name: 'tvdt_well_known_change_password', methods: ['GET'])]
public function changePassword(): RedirectResponse
{
return $this->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 = <<<TXT
Contact: https://github.com/MarijnDoeve/TijdVoorDeTest/security/advisories/new
Expires: {$expires}
Preferred-Languages: nl, en
TXT;
return new Response($content, headers: ['Content-Type' => 'text/plain; charset=UTF-8']);
}
}
+31
View File
@@ -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();
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Tvdt\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Contracts\Translation\TranslatorInterface;
/** @extends AbstractType<array{email: string}> */
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([]);
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Tvdt\Form;
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\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Security\Core\Validator\Constraints\UserPassword;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Contracts\Translation\TranslatorInterface;
use Tvdt\Entity\User;
/** @extends AbstractType<array{currentPassword: string, plainPassword: string}> */
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([]);
}
}
+31
View File
@@ -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]);
+23
View File
@@ -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
{
@@ -1,3 +1,4 @@
<h6>Seizoensinstellingen</h6>
<p>Pas hier de weergave-instellingen van dit seizoen aan.</p>
<p><strong>Nummers tonen:</strong> toont vraagnummers tijdens de test. <strong>Antwoord bevestigen:</strong> vraagt kandidaten om hun antwoord te bevestigen voordat ze doorgaan.</p>
<p><strong>Seizoenscode:</strong> de code waarmee kandidaten dit seizoen kunnen vinden. Genereer een nieuwe code als de huidige per ongeluk gedeeld is, de oude code werkt daarna niet meer.</p>
+4
View File
@@ -23,6 +23,10 @@
</li>
</ul>
<ul class="navbar-nav mb-auto me-2 me-lg-0">
<li class="nav-item">
<a class="nav-link{% if 'tvdt_backoffice_settings' == app.current_route() %} active{% endif %}"
href="{{ path('tvdt_backoffice_settings') }}">{{ 'Settings'|trans }}</a>
</li>
<li class="nav-item">
<a class="nav-link"
href="{{ path('tvdt_login_logout') }}">{{ 'Logout'|trans }}</a>
@@ -1,8 +1,40 @@
<div class="row">
<div class="col-md-6 col-12">
{{ form(form) }}
<hr>
<h4 class="text-danger">{{ 'Season code'|trans }}</h4>
<p>{{ 'The season code is used by candidates to join this season. Regenerating it invalidates the current code, so make sure to share the new one.'|trans }}</p>
<p><strong>{{ 'Current code:'|trans }}</strong> {{ season.seasonCode }}</p>
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#regenerateSeasonCodeModal">
{{ 'Regenerate season code...'|trans }}
</button>
</div>
<div class="col-md-6 col-12">
{{ include('backoffice/help/season_settings.html.twig') }}
</div>
</div>
<div class="modal fade" id="regenerateSeasonCodeModal" data-bs-backdrop="static"
tabindex="-1"
aria-labelledby="regenerateSeasonCodeModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="{{ path('tvdt_backoffice_season_regenerate_code', {seasonCode: season.seasonCode}) }}" method="POST">
<div class="modal-header">
<h1 class="modal-title fs-5" id="regenerateSeasonCodeModalLabel">{{ 'Regenerate season code'|trans }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>{{ 'This invalidates the current season code. Anyone using the old code will no longer be able to join this season.'|trans }}</p>
<input type="hidden" name="_token" value="{{ csrf_token('regenerate_season_code') }}">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
<button type="submit" class="btn btn-danger">{{ 'Regenerate season code'|trans }}</button>
</div>
</form>
</div>
</div>
</div>
@@ -0,0 +1,103 @@
{% extends 'backoffice/base.html.twig' %}
{% block title %}{{ parent() }}{{ 'Settings'|trans }}{% endblock %}
{% block breadcrumbs %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ path('tvdt_backoffice_index') }}">{{ 'Home'|trans }}</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ 'Settings'|trans }}</li>
</ol>
</nav>
{% endblock %}
{% block body %}
<div class="row">
<div class="col-lg-6 col-12">
<h2 class="mb-4">{{ 'Settings'|trans }}</h2>
<section class="mb-5">
<h4>{{ 'Language'|trans }}</h4>
<form action="{{ path('tvdt_backoffice_settings_language') }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('settings_language') }}">
<div class="mb-3">
<label class="form-label" for="settings-language">{{ 'Language'|trans }}</label>
<select class="form-select" id="settings-language" name="language">
<option value="nl" selected>Nederlands</option>
</select>
</div>
<button type="submit" class="btn btn-primary">{{ 'Save'|trans }}</button>
</form>
</section>
<section class="mb-5">
<h4>{{ 'Change password'|trans }}</h4>
{{ form(passwordForm, {action: path('tvdt_backoffice_settings_password')}) }}
</section>
<section class="mb-5">
<h4>{{ 'Change email'|trans }}</h4>
<p class="mb-1">
<strong>{{ 'Current email address:'|trans }}</strong> {{ app.user.userIdentifier }}
{% if app.user.isVerified %}
<span class="badge text-bg-success">{{ 'Confirmed'|trans }}</span>
{% else %}
<span class="badge text-bg-warning">{{ 'Not confirmed'|trans }}</span>
<form class="d-inline" action="{{ path('tvdt_backoffice_settings_resend_confirmation') }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('resend_confirmation') }}">
<button type="submit" class="btn btn-link btn-sm p-0 align-baseline">
{{ 'Resend confirmation email'|trans }}
</button>
</form>
{% endif %}
</p>
<p>{{ 'After changing your email address you will receive a new confirmation email.'|trans }}</p>
{{ form(emailForm, {action: path('tvdt_backoffice_settings_email')}) }}
</section>
<section class="mb-5" data-controller="bo--popover">
<h4>{{ 'Your data'|trans }}</h4>
<span class="d-inline-block" tabindex="0"
data-bs-toggle="popover"
data-bs-trigger="hover focus"
data-bs-content="{{ 'Soon™'|trans }}">
<button type="button" class="btn btn-secondary pe-none" disabled>{{ 'Download data'|trans }}</button>
</span>
</section>
<section class="mb-5">
<h4 class="text-danger">{{ 'Danger zone'|trans }}</h4>
<p>{{ 'Deleting your account also deletes every season you are the only owner of. This cannot be undone.'|trans }}</p>
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#deleteAccountModal">
{{ 'Delete account...'|trans }}
</button>
</section>
</div>
</div>
<div class="modal fade" id="deleteAccountModal" data-bs-backdrop="static"
tabindex="-1"
aria-labelledby="deleteAccountModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="{{ path('tvdt_backoffice_settings_delete') }}" method="POST">
<div class="modal-header">
<h1 class="modal-title fs-5" id="deleteAccountModalLabel">{{ 'Delete account'|trans }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>{{ 'This deletes your account and every season you are the only owner of. Enter your password to confirm.'|trans }}</p>
<input type="hidden" name="_token" value="{{ csrf_token('delete_account') }}">
<label class="form-label" for="delete-account-password">{{ 'Current password'|trans }}</label>
<input type="password" class="form-control" id="delete-account-password"
name="password" required autocomplete="current-password">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
<button type="submit" class="btn btn-danger">{{ 'Delete account'|trans }}</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\SeasonController;
use Tvdt\Entity\Season;
use Tvdt\Entity\User;
#[CoversClass(SeasonController::class)]
final class SeasonControllerTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = self::createClient();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
}
public function testRegenerateSeasonCodeChangesTheCode(): void
{
$oldCode = 'krtek';
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/%s/settings', $oldCode));
self::assertResponseIsSuccessful();
$token = (string) $crawler->filter('form[action*="/regenerate-code"] input[name="_token"]')->first()->attr('value');
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/%s/settings/regenerate-code', $oldCode), [
'_token' => $token,
]);
self::assertResponseRedirects();
$this->entityManager->clear();
$this->assertNotInstanceOf(Season::class, $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => $oldCode]));
$location = (string) $this->client->getResponse()->headers->get('Location');
$this->assertMatchesRegularExpression('#^/backoffice/season/[a-z]{5}/settings$#', $location);
}
public function testRegenerateSeasonCodeIsDeniedForNonOwner(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/settings');
self::assertResponseIsSuccessful();
$token = (string) $crawler->filter('form[action*="/regenerate-code"] input[name="_token"]')->first()->attr('value');
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
$this->client->request(Request::METHOD_POST, '/backoffice/season/krtek/settings/regenerate-code', [
'_token' => $token,
]);
self::assertResponseStatusCodeSame(403);
}
}
@@ -0,0 +1,353 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Safe\DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Tvdt\Controller\Backoffice\SettingsController;
use Tvdt\DataFixtures\TestFixtures;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\ResetPasswordRequest;
use Tvdt\Entity\Season;
use Tvdt\Entity\User;
#[CoversClass(SettingsController::class)]
final class SettingsControllerTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = self::createClient();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$this->loginAs('test@example.org');
}
private function loginAs(string $email): void
{
$user = $this->getUserByEmail($email);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
}
private function getUserByEmail(string $email): ?User
{
return $this->entityManager->getRepository(User::class)->findOneBy(['email' => $email]);
}
private function getCsrfTokenFromSettings(string $formActionContains): string
{
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseIsSuccessful();
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
return (string) $input->first()->attr('value');
}
public function testSettingsPageLoadsAndNavContainsSettingsLink(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseIsSuccessful();
self::assertSelectorExists('nav a[href="/backoffice/settings"]');
}
public function testSettingsPageRequiresAuthentication(): void
{
$this->client->restart();
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseRedirects();
}
public function testLanguageSaveRedirectsBackToSettings(): void
{
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/language');
$this->client->request(Request::METHOD_POST, '/backoffice/settings/language', [
'_token' => $token,
'language' => 'nl',
]);
self::assertResponseRedirects('/backoffice/settings');
}
public function testChangePassword(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/password"]')->form([
'change_user_password_form[currentPassword]' => TestFixtures::PASSWORD,
'change_user_password_form[plainPassword][first]' => 'NewPass123!',
'change_user_password_form[plainPassword][second]' => 'NewPass123!',
]);
$this->client->submit($form);
self::assertResponseRedirects('/backoffice/settings');
$this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$this->assertTrue($hasher->isPasswordValid($user, 'NewPass123!'));
// User stays logged in
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseIsSuccessful();
}
public function testChangePasswordWithWrongCurrentPasswordIsRejected(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/password"]')->form([
'change_user_password_form[currentPassword]' => 'wrong-password',
'change_user_password_form[plainPassword][first]' => 'NewPass123!',
'change_user_password_form[plainPassword][second]' => 'NewPass123!',
]);
$this->client->submit($form);
self::assertResponseStatusCodeSame(422);
$this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$this->assertTrue($hasher->isPasswordValid($user, TestFixtures::PASSWORD));
}
public function testChangePasswordWithMismatchedRepeatIsRejected(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/password"]')->form([
'change_user_password_form[currentPassword]' => TestFixtures::PASSWORD,
'change_user_password_form[plainPassword][first]' => 'NewPass123!',
'change_user_password_form[plainPassword][second]' => 'SomethingElse!',
]);
$this->client->submit($form);
self::assertResponseStatusCodeSame(422);
$this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$this->assertTrue($hasher->isPasswordValid($user, TestFixtures::PASSWORD));
}
public function testChangeEmailSendsConfirmationAndKeepsUserLoggedIn(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/email"]')->form([
'change_email_form[email]' => 'new-address@example.org',
]);
$this->client->submit($form);
self::assertResponseRedirects('/backoffice/settings');
self::assertEmailCount(1);
$this->entityManager->clear();
$this->assertNotInstanceOf(User::class, $this->getUserByEmail('test@example.org'));
$user = $this->getUserByEmail('new-address@example.org');
$this->assertInstanceOf(User::class, $user);
$this->assertFalse($user->isVerified);
// User stays logged in
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseIsSuccessful();
}
public function testChangeEmailToTakenAddressIsRejected(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/email"]')->form([
'change_email_form[email]' => 'user1@example.org',
]);
$this->client->submit($form);
self::assertResponseStatusCodeSame(422);
self::assertEmailCount(0);
$this->entityManager->clear();
$this->assertInstanceOf(User::class, $this->getUserByEmail('test@example.org'));
}
public function testResendConfirmationEmailSendsEmail(): void
{
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/resend-confirmation');
$this->client->request(Request::METHOD_POST, '/backoffice/settings/resend-confirmation', [
'_token' => $token,
]);
self::assertResponseRedirects('/backoffice/settings');
self::assertEmailCount(1);
}
public function testResendConfirmationEmailForVerifiedUserSendsNothing(): void
{
// Get a valid CSRF token while still unverified, then mark the user as verified
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/resend-confirmation');
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$user->isVerified = true;
$this->entityManager->flush();
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseIsSuccessful();
$this->assertCount(0, $crawler->filter('form[action*="/backoffice/settings/resend-confirmation"]'));
$this->client->request(Request::METHOD_POST, '/backoffice/settings/resend-confirmation', [
'_token' => $token,
]);
self::assertResponseRedirects('/backoffice/settings');
self::assertEmailCount(0);
}
public function testChangeEmailToSameAddressIsAccepted(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/email"]')->form([
'change_email_form[email]' => 'test@example.org',
]);
$this->client->submit($form);
self::assertResponseRedirects('/backoffice/settings');
}
private function createResetPasswordRequest(User $user): void
{
$request = new ResetPasswordRequest(
$user,
new DateTimeImmutable('+1 hour'),
str_repeat('a', 20),
str_repeat('b', 100),
);
$this->entityManager->persist($request);
$this->entityManager->flush();
}
public function testChangePasswordInvalidatesResetPasswordRequests(): void
{
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->createResetPasswordRequest($user);
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/password"]')->form([
'change_user_password_form[currentPassword]' => TestFixtures::PASSWORD,
'change_user_password_form[plainPassword][first]' => 'NewPass123!',
'change_user_password_form[plainPassword][second]' => 'NewPass123!',
]);
$this->client->submit($form);
self::assertResponseRedirects('/backoffice/settings');
$this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->assertSame(0, $this->entityManager->getRepository(ResetPasswordRequest::class)->count(['user' => $user]));
}
public function testChangeEmailInvalidatesResetPasswordRequests(): void
{
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->createResetPasswordRequest($user);
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/email"]')->form([
'change_email_form[email]' => 'new-address@example.org',
]);
$this->client->submit($form);
self::assertResponseRedirects('/backoffice/settings');
$this->entityManager->clear();
$user = $this->getUserByEmail('new-address@example.org');
$this->assertInstanceOf(User::class, $user);
$this->assertSame(0, $this->entityManager->getRepository(ResetPasswordRequest::class)->count(['user' => $user]));
}
public function testDeleteAccountWithWrongPasswordIsRejected(): void
{
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/delete');
$this->client->request(Request::METHOD_POST, '/backoffice/settings/delete', [
'_token' => $token,
'password' => 'wrong-password',
]);
self::assertResponseRedirects('/backoffice/settings');
$this->entityManager->clear();
$this->assertInstanceOf(User::class, $this->getUserByEmail('test@example.org'));
}
public function testDeleteAccountRemovesSoleOwnerSeasonsAndKeepsSharedSeasons(): void
{
$this->loginAs('sole-owner@example.org');
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/delete');
$this->client->request(Request::METHOD_POST, '/backoffice/settings/delete', [
'_token' => $token,
'password' => TestFixtures::PASSWORD,
]);
self::assertResponseRedirects();
$this->entityManager->clear();
$this->assertNotInstanceOf(User::class, $this->getUserByEmail('sole-owner@example.org'));
// Sole-owner season is removed, including its quiz
$this->assertNotInstanceOf(Season::class, $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'doomd']));
$this->assertNotInstanceOf(Quiz::class, $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Doomed Quiz']));
// Shared season survives, without the deleted owner
$anotherSeason = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'bbbbb']);
$this->assertInstanceOf(Season::class, $anotherSeason);
$ownerEmails = $anotherSeason->owners->map(static fn (User $owner): string => $owner->email)->toArray();
$this->assertNotContains('sole-owner@example.org', $ownerEmails);
$this->assertContains('user1@example.org', $ownerEmails);
// User is logged out
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseRedirects();
}
public function testDeleteAccountKeepsMultiOwnerSeasons(): void
{
$this->loginAs('user2@example.org');
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/delete');
$this->client->request(Request::METHOD_POST, '/backoffice/settings/delete', [
'_token' => $token,
'password' => TestFixtures::PASSWORD,
]);
self::assertResponseRedirects();
$this->entityManager->clear();
$this->assertNotInstanceOf(User::class, $this->getUserByEmail('user2@example.org'));
foreach (['krtek', 'bbbbb'] as $seasonCode) {
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => $seasonCode]);
$this->assertInstanceOf(Season::class, $season);
$ownerEmails = $season->owners->map(static fn (User $owner): string => $owner->email)->toArray();
$this->assertNotContains('user2@example.org', $ownerEmails);
$this->assertNotEmpty($ownerEmails);
}
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller;
use PHPUnit\Framework\Attributes\CoversClass;
use Safe\DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\WellKnownController;
#[CoversClass(WellKnownController::class)]
final class WellKnownControllerTest extends WebTestCase
{
private KernelBrowser $client;
protected function setUp(): void
{
$this->client = self::createClient();
}
public function testChangePasswordRedirectsToSettings(): void
{
$this->client->request(Request::METHOD_GET, '/.well-known/change-password');
self::assertResponseRedirects('/backoffice/settings');
}
/** @throws \Exception */
public function testSecurityTxt(): void
{
$this->client->request(Request::METHOD_GET, '/.well-known/security.txt');
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('Content-Type', 'text/plain; charset=UTF-8');
$content = (string) $this->client->getResponse()->getContent();
$this->assertStringContainsString('Contact:', $content);
$this->assertMatchesRegularExpression('/^Expires: (.+)$/m', $content);
\Safe\preg_match('/^Expires: (.+)$/m', $content, $matches);
$this->assertArrayHasKey(1, $matches);
$expires = new DateTimeImmutable($matches[1]);
$this->assertGreaterThan(new DateTimeImmutable('now'), $expires);
}
}
+140
View File
@@ -9,6 +9,10 @@
<source>A label with a similar name already exists</source>
<target>Er bestaat al een label met deze naam</target>
</trans-unit>
<trans-unit id="Yu2_QSh" resname="A new confirmation email has been sent. Please check your inbox.">
<source>A new confirmation email has been sent. Please check your inbox.</source>
<target>Er is een nieuwe bevestigingsmail verstuurd. Check je inbox.</target>
</trans-unit>
<trans-unit id="spyU5K3" resname="A quiz with this name already exists in this season">
<source>A quiz with this name already exists in this season</source>
<target>Er bestaat al een test met deze naam in dit seizoen</target>
@@ -73,6 +77,10 @@
<source>Add question</source>
<target>Vraag toevoegen</target>
</trans-unit>
<trans-unit id="nLlOrcy" resname="After changing your email address you will receive a new confirmation email.">
<source>After changing your email address you will receive a new confirmation email.</source>
<target>Na het wijzigen van je e-mailadres ontvang je een nieuwe bevestigingsmail.</target>
</trans-unit>
<trans-unit id="tHdA52O" resname="All">
<source>All</source>
<target>Alle</target>
@@ -149,6 +157,14 @@
<source>Candidates</source>
<target>Kandidaten</target>
</trans-unit>
<trans-unit id="BqCoDf2" resname="Change email">
<source>Change email</source>
<target>E-mailadres wijzigen</target>
</trans-unit>
<trans-unit id="EbzUhXX" resname="Change password">
<source>Change password</source>
<target>Wachtwoord wijzigen</target>
</trans-unit>
<trans-unit id="o6WwCao" resname="Check your email">
<source>Check your email</source>
<target>Controleer je e-mail</target>
@@ -173,6 +189,10 @@
<source>Confirm Answers</source>
<target>Bevestig antwoorden</target>
</trans-unit>
<trans-unit id="PiAVEe9" resname="Confirmed">
<source>Confirmed</source>
<target>Bevestigd</target>
</trans-unit>
<trans-unit id="sFpB4C2" resname="Correct Answers">
<source>Correct Answers</source>
<target>Goede antwoorden</target>
@@ -201,10 +221,26 @@
<source>Create an empty quiz and add questions from the question bank.</source>
<target>Maak een lege quiz aan en voeg vragen toe vanuit de vragenbank.</target>
</trans-unit>
<trans-unit id="jrXhJOR" resname="Current code:">
<source>Current code:</source>
<target>Huidige code:</target>
</trans-unit>
<trans-unit id="3leUyoA" resname="Current email address:">
<source>Current email address:</source>
<target>Huidig e-mailadres:</target>
</trans-unit>
<trans-unit id="ukaFrcB" resname="Current password">
<source>Current password</source>
<target>Huidig wachtwoord</target>
</trans-unit>
<trans-unit id="PkrbQOH" resname="Cyan">
<source>Cyan</source>
<target>Cyaan</target>
</trans-unit>
<trans-unit id="dG6EuYH" resname="Danger zone">
<source>Danger zone</source>
<target>Gevarenzone</target>
</trans-unit>
<trans-unit id="S5P7nQd" resname="Deactivate">
<source>Deactivate</source>
<target>Deactiveren</target>
@@ -225,10 +261,26 @@
<source>Delete Quiz...</source>
<target>Test verwijderen...</target>
</trans-unit>
<trans-unit id="rZiKnxa" resname="Delete account">
<source>Delete account</source>
<target>Account verwijderen</target>
</trans-unit>
<trans-unit id="S8jZ6w1" resname="Delete account...">
<source>Delete account...</source>
<target>Account verwijderen...</target>
</trans-unit>
<trans-unit id="bw.C4wH" resname="Deleting your account also deletes every season you are the only owner of. This cannot be undone.">
<source>Deleting your account also deletes every season you are the only owner of. This cannot be undone.</source>
<target>Als je je account verwijdert, worden ook alle seizoenen verwijderd waarvan jij de enige eigenaar bent. Dit kan niet ongedaan worden gemaakt.</target>
</trans-unit>
<trans-unit id="R9yHzHv" resname="Download Template">
<source>Download Template</source>
<target>Download sjabloon</target>
</trans-unit>
<trans-unit id="58e2QWG" resname="Download data">
<source>Download data</source>
<target>Gegevens downloaden</target>
</trans-unit>
<trans-unit id="dwUtS3b" resname="Draft">
<source>Draft</source>
<target>Concept</target>
@@ -361,6 +413,14 @@
<source>Labels</source>
<target>Labels</target>
</trans-unit>
<trans-unit id="5q6OTdQ" resname="Language">
<source>Language</source>
<target>Taal</target>
</trans-unit>
<trans-unit id="dzQVFhY" resname="Language saved">
<source>Language saved</source>
<target>Taal opgeslagen</target>
</trans-unit>
<trans-unit id="q0FeoCr" resname="Load Prepared Elimination">
<source>Load Prepared Elimination</source>
<target>Laad voorbereide eliminatie</target>
@@ -393,6 +453,10 @@
<source>Name</source>
<target>Naam</target>
</trans-unit>
<trans-unit id="uWfLt3x" resname="New email address">
<source>New email address</source>
<target>Nieuw e-mailadres</target>
</trans-unit>
<trans-unit id="lqTjJ4a" resname="New label">
<source>New label</source>
<target>Nieuw label</target>
@@ -437,6 +501,10 @@
<source>Not Started</source>
<target>Niet gestart</target>
</trans-unit>
<trans-unit id="zGRLjYz" resname="Not confirmed">
<source>Not confirmed</source>
<target>Niet bevestigd</target>
</trans-unit>
<trans-unit id="k7Eqnjt" resname="Number of dropouts:">
<source>Number of dropouts:</source>
<target>Aantal afvallers:</target>
@@ -605,6 +673,14 @@
<source>Refresh the page to try again.</source>
<target>Ververs de pagina om het opnieuw te proberen.</target>
</trans-unit>
<trans-unit id="EGW_4W_" resname="Regenerate season code">
<source>Regenerate season code</source>
<target>Seizoenscode opnieuw genereren</target>
</trans-unit>
<trans-unit id="EWuNNNu" resname="Regenerate season code...">
<source>Regenerate season code...</source>
<target>Seizoenscode opnieuw genereren...</target>
</trans-unit>
<trans-unit id="fGfBzt6" resname="Register">
<source>Register</source>
<target>Registreren</target>
@@ -621,6 +697,10 @@
<source>Repeat Password</source>
<target>Herhaal wachtwoord</target>
</trans-unit>
<trans-unit id="UJ54wLL" resname="Resend confirmation email">
<source>Resend confirmation email</source>
<target>Bevestigingsmail opnieuw versturen</target>
</trans-unit>
<trans-unit id="9JCvQkt" resname="Reset password">
<source>Reset password</source>
<target>Wachtwoord herstellen</target>
@@ -661,6 +741,14 @@
<source>Season Name</source>
<target>Seizoennaam</target>
</trans-unit>
<trans-unit id="IxKDF4a" resname="Season code">
<source>Season code</source>
<target>Seizoenscode</target>
</trans-unit>
<trans-unit id="iigT2eM" resname="Season code regenerated">
<source>Season code regenerated</source>
<target>Seizoenscode opnieuw gegenereerd</target>
</trans-unit>
<trans-unit id="kc_J96C" resname="Seasons">
<source>Seasons</source>
<target>Seizoenen</target>
@@ -681,6 +769,10 @@
<source>Sign in</source>
<target>Log in</target>
</trans-unit>
<trans-unit id=".9GO03z" resname="Soon™">
<source>Soon™</source>
<target>Soon™</target>
</trans-unit>
<trans-unit id="A0aGG7W" resname="Sort AZ">
<source>Sort AZ</source>
<target>Sorteer A-Z</target>
@@ -697,6 +789,14 @@
<source>Sync latest changes to this quiz</source>
<target>Laatste wijzigingen synchroniseren naar deze quiz</target>
</trans-unit>
<trans-unit id="XwQ1Fav" resname="The confirmation email could not be sent. Please try again later.">
<source>The confirmation email could not be sent. Please try again later.</source>
<target>De bevestigingsmail kon niet worden verzonden. Probeer het later opnieuw.</target>
</trans-unit>
<trans-unit id="mMDwcxj" resname="The confirmation email could not be sent. Please use the resend button to try again.">
<source>The confirmation email could not be sent. Please use the resend button to try again.</source>
<target>De bevestigingsmail kon niet worden verzonden. Gebruik de knop om het opnieuw te proberen.</target>
</trans-unit>
<trans-unit id="_z4el3Z" resname="The password fields must match.">
<source>The password fields must match.</source>
<target>De wachtwoorden moeten overeen komen.</target>
@@ -721,14 +821,30 @@
<source>The quiz must be finalized before it can be activated</source>
<target>De test moet afgerond zijn voordat deze geactiveerd kan worden</target>
</trans-unit>
<trans-unit id="IKPt_Pf" resname="The season code is used by candidates to join this season. Regenerating it invalidates the current code, so make sure to share the new one.">
<source>The season code is used by candidates to join this season. Regenerating it invalidates the current code, so make sure to share the new one.</source>
<target>De seizoenscode wordt door kandidaten gebruikt om dit seizoen te vinden. Als je een nieuwe code genereert, werkt de oude niet meer, dus deel de nieuwe code opnieuw.</target>
</trans-unit>
<trans-unit id="HuzRgeN" resname="There are no answers for this question">
<source>There are no answers for this question</source>
<target>Er zijn geen antwoorden voor deze vraag</target>
</trans-unit>
<trans-unit id="vsM4tSv" resname="There is already an account with this email">
<source>There is already an account with this email</source>
<target>Er is al een account met dit e-mailadres</target>
</trans-unit>
<trans-unit id=".LrcTyU" resname="There is no active quiz">
<source>There is no active quiz</source>
<target>Er is geen test actief</target>
</trans-unit>
<trans-unit id="nv0a7LG" resname="This deletes your account and every season you are the only owner of. Enter your password to confirm.">
<source>This deletes your account and every season you are the only owner of. Enter your password to confirm.</source>
<target>Dit verwijdert je account en alle seizoenen waarvan jij de enige eigenaar bent. Vul je wachtwoord in om te bevestigen.</target>
</trans-unit>
<trans-unit id="EBWUWJu" resname="This invalidates the current season code. Anyone using the old code will no longer be able to join this season.">
<source>This invalidates the current season code. Anyone using the old code will no longer be able to join this season.</source>
<target>Hiermee wordt de huidige seizoenscode ongeldig. Iedereen die de oude code gebruikt, kan dit seizoen niet meer vinden.</target>
</trans-unit>
<trans-unit id="YueaA2f" resname="This link will expire in %count%.">
<source>This link will expire in %count%.</source>
<target>Deze link verloopt over %count%.</target>
@@ -789,6 +905,10 @@
<source>White</source>
<target>Wit</target>
</trans-unit>
<trans-unit id="AVLy021" resname="Wrong password, your account has not been deleted.">
<source>Wrong password, your account has not been deleted.</source>
<target>Verkeerd wachtwoord, je account is niet verwijderd.</target>
</trans-unit>
<trans-unit id="RV6M450" resname="Yellow">
<source>Yellow</source>
<target>Geel</target>
@@ -813,10 +933,30 @@
<source>Your Seasons</source>
<target>Jouw seizoenen</target>
</trans-unit>
<trans-unit id="Eh0pcpd" resname="Your data">
<source>Your data</source>
<target>Je gegevens</target>
</trans-unit>
<trans-unit id="sAb9l8I" resname="Your email address has been changed.">
<source>Your email address has been changed.</source>
<target>Je e-mailadres is gewijzigd.</target>
</trans-unit>
<trans-unit id="OqDtnJw" resname="Your email address has been changed. Please check your inbox to confirm it.">
<source>Your email address has been changed. Please check your inbox to confirm it.</source>
<target>Je e-mailadres is gewijzigd. Check je inbox om het te bevestigen.</target>
</trans-unit>
<trans-unit id="m80cBv0" resname="Your email address has been verified.">
<source>Your email address has been verified.</source>
<target>Je e-mailadres is geverifieerd.</target>
</trans-unit>
<trans-unit id="mPitWNe" resname="Your email address is already confirmed.">
<source>Your email address is already confirmed.</source>
<target>Je e-mailadres is al bevestigd.</target>
</trans-unit>
<trans-unit id="yAT.oxx" resname="Your password has been changed.">
<source>Your password has been changed.</source>
<target>Je wachtwoord is gewijzigd.</target>
</trans-unit>
<trans-unit id="YDIkAA1" resname="Your password reset request">
<source>Your password reset request</source>
<target>Verzoek tot wachtwoordherstel</target>
+12
View File
@@ -109,10 +109,18 @@
<source>Please enter a valid week.</source>
<target>Vul een geldige week in.</target>
</trans-unit>
<trans-unit id="PI63Rjp" resname="Please enter an email address">
<source>Please enter an email address</source>
<target>Vul een e-mailadres in</target>
</trans-unit>
<trans-unit id="o.y86J1" resname="Please enter an integer.">
<source>Please enter an integer.</source>
<target>Vul een geldig getal in.</target>
</trans-unit>
<trans-unit id="Vl4HgaN" resname="Please enter your current password">
<source>Please enter your current password</source>
<target>Vul je huidige wachtwoord in</target>
</trans-unit>
<trans-unit id="7VWHcE0" resname="Please enter your email">
<source>Please enter your email</source>
<target>Voer je e-mailadres in</target>
@@ -437,6 +445,10 @@
<source>This is not a valid UUID.</source>
<target>Deze waarde is geen geldige UUID.</target>
</trans-unit>
<trans-unit id="E7D7p81" resname="This is not your current password.">
<source>This is not your current password.</source>
<target>Dit is niet je huidige wachtwoord.</target>
</trans-unit>
<trans-unit id="o0vqbDQ" resname="This password has been leaked in a data breach, it must not be used. Please use another password.">
<source>This password has been leaked in a data breach, it must not be used. Please use another password.</source>
<target>Dit wachtwoord is gelekt bij een datalek en mag niet worden gebruikt. Kies een ander wachtwoord.</target>