mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-08 16:40:15 +02:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
b8b50e6d50
|
|||
|
e8a8fb3f54
|
|||
|
15e3ecb2e4
|
|||
|
748f51e1a5
|
|||
| 1d3e99d2b2 | |||
| d7e8d094cf | |||
| ba1e8d8eb6 | |||
| 5d92d91432 | |||
| 04c40412cd | |||
| b915d87d4a |
@@ -1,3 +1,5 @@
|
||||
# define your env variables for the test env here
|
||||
KERNEL_CLASS='Tvdt\Kernel'
|
||||
APP_SECRET='$ecretf0rt3st'
|
||||
MAILER_DSN=null://null
|
||||
MAILER_SENDER=test@example.org
|
||||
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
- name: Load fixtures
|
||||
run: docker compose exec -T php bin/console -e test doctrine:fixtures:load --no-interaction --group=test
|
||||
- name: Run PHPUnit
|
||||
run: docker compose exec -T php vendor/bin/phpunit --log-junit var/phpunit/junit.xml
|
||||
run: docker compose exec -T -e MAILER_DSN=null://null php vendor/bin/phpunit --log-junit var/phpunit/junit.xml
|
||||
- name: Publish PHPUnit test results
|
||||
if: always()
|
||||
uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6
|
||||
@@ -166,7 +166,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
permissions:
|
||||
actions: read
|
||||
actions: write
|
||||
steps:
|
||||
- name: Wait for and verify successful CI run on this commit
|
||||
env:
|
||||
@@ -174,6 +174,8 @@ jobs:
|
||||
run: |
|
||||
max_attempts=30
|
||||
attempt=0
|
||||
triggered=false
|
||||
|
||||
while [[ $attempt -lt $max_attempts ]]; do
|
||||
attempt=$((attempt + 1))
|
||||
|
||||
@@ -191,12 +193,32 @@ jobs:
|
||||
--jq "[.workflow_runs[] | select(.id != ${{ github.run_id }}) | select(.status == \"in_progress\" or .status == \"queued\" or .status == \"waiting\" or .status == \"requested\" or .status == \"pending\")] | length")
|
||||
|
||||
if [[ "$in_progress_count" -gt 0 ]]; then
|
||||
echo "CI still in progress (attempt $attempt/$max_attempts), waiting 30s..."
|
||||
echo "CI in progress (attempt $attempt/$max_attempts), waiting 30s..."
|
||||
sleep 30
|
||||
else
|
||||
echo "::error::No prior successful CI run found for ${{ github.sha }}. Only tag commits that have passed CI on main."
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$triggered" == "false" ]]; then
|
||||
echo "No prior CI run found for ${{ github.sha }}. Triggering CI on ${{ github.ref }}..."
|
||||
gh workflow run ci.yml --repo "${{ github.repository }}" --ref "${{ github.ref }}"
|
||||
triggered=true
|
||||
echo "Triggered. Waiting 20s for run to register..."
|
||||
sleep 20
|
||||
continue
|
||||
fi
|
||||
|
||||
failed_conclusion=$(gh api \
|
||||
"repos/${{ github.repository }}/actions/workflows/ci.yml/runs?head_sha=${{ github.sha }}&per_page=10" \
|
||||
--jq "[.workflow_runs[] | select(.id != ${{ github.run_id }}) | select(.status == \"completed\") | select(.conclusion != \"success\")] | first | .conclusion // empty" \
|
||||
--raw-output)
|
||||
|
||||
if [[ -n "$failed_conclusion" ]]; then
|
||||
echo "::error::Triggered CI run on main failed with conclusion: $failed_conclusion. Fix the issue before re-tagging."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Waiting for triggered run to register (attempt $attempt/$max_attempts)..."
|
||||
sleep 20
|
||||
done
|
||||
|
||||
echo "::error::Timed out waiting for CI run to complete for ${{ github.sha }}."
|
||||
@@ -234,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}"
|
||||
@@ -260,9 +283,12 @@ jobs:
|
||||
compose.yaml
|
||||
compose.build.yaml
|
||||
set: |
|
||||
*.cache-from=type=gha,scope=${{github.ref}}-devbuild
|
||||
*.cache-from=type=gha,scope=refs/heads/main-devbuild
|
||||
*.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
|
||||
|
||||
Generated
+1
@@ -171,6 +171,7 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-deepclone" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/file-filter" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/object-mapper" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfonycasts/reset-password-bundle" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
|
||||
Generated
+1
@@ -205,6 +205,7 @@
|
||||
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
|
||||
<path value="$PROJECT_DIR$/vendor/martin-georgiev/postgresql-for-doctrine" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/object-mapper" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfonycasts/reset-password-bundle" />
|
||||
</include_path>
|
||||
</component>
|
||||
<component name="PhpInterpreters">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@
|
||||
"symfony/ux-turbo": "^3.1",
|
||||
"symfony/validator": "8.1.*",
|
||||
"symfony/yaml": "8.1.*",
|
||||
"symfonycasts/reset-password-bundle": "^1.25",
|
||||
"symfonycasts/sass-bundle": "^0.10",
|
||||
"symfonycasts/verify-email-bundle": "^1.18.0",
|
||||
"thecodingmachine/safe": "^3.4.0",
|
||||
|
||||
Generated
+56
-8
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "7171824ca13f4df0801dfa5d7f58d6a0",
|
||||
"content-hash": "010a4456ebc1a8ebaf73c6db051d3d09",
|
||||
"packages": [
|
||||
{
|
||||
"name": "composer/pcre",
|
||||
@@ -8281,6 +8281,54 @@
|
||||
],
|
||||
"time": "2026-06-09T11:06:24+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfonycasts/reset-password-bundle",
|
||||
"version": "v1.25.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/SymfonyCasts/reset-password-bundle.git",
|
||||
"reference": "084aac1cc40ef75b26134c7967d1e423eeff72f4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/SymfonyCasts/reset-password-bundle/zipball/084aac1cc40ef75b26134c7967d1e423eeff72f4",
|
||||
"reference": "084aac1cc40ef75b26134c7967d1e423eeff72f4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1.10",
|
||||
"symfony/clock": "^6.3 | ^7.0 | ^8.0",
|
||||
"symfony/config": "^5.4 | ^6.0 | ^7.0 | ^8.0",
|
||||
"symfony/dependency-injection": "^5.4 | ^6.0 | ^7.0 | ^8.0",
|
||||
"symfony/deprecation-contracts": "^2.2 | ^3.0",
|
||||
"symfony/http-kernel": "^5.4 | ^6.0 | ^7.0 | ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/annotations": "^1.0 | ^2.0",
|
||||
"doctrine/doctrine-bundle": "^2.13 | ^3.0",
|
||||
"doctrine/orm": "^2.20 | ^3.0",
|
||||
"symfony/framework-bundle": "^5.4 | ^6.0 | ^7.0 | ^8.0",
|
||||
"symfony/phpunit-bridge": "^5.4 | ^6.0 | ^7.0 | ^8.0",
|
||||
"symfony/process": "^6.4 | ^7.0 | ^8.0",
|
||||
"symfonycasts/internal-test-helpers": "dev-main"
|
||||
},
|
||||
"type": "symfony-bundle",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"SymfonyCasts\\Bundle\\ResetPassword\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "Symfony bundle that adds password reset functionality.",
|
||||
"support": {
|
||||
"issues": "https://github.com/SymfonyCasts/reset-password-bundle/issues",
|
||||
"source": "https://github.com/SymfonyCasts/reset-password-bundle/tree/v1.25.0"
|
||||
},
|
||||
"time": "2026-03-26T10:16:40+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfonycasts/sass-bundle",
|
||||
"version": "v0.10.0",
|
||||
@@ -9360,16 +9408,16 @@
|
||||
},
|
||||
{
|
||||
"name": "friendsofphp/php-cs-fixer",
|
||||
"version": "v3.95.11",
|
||||
"version": "v3.95.12",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
|
||||
"reference": "35f98e1293283397824d7f349ce5afb8747c3cd5"
|
||||
"reference": "b1b9055997a98dce3c2338e884626e718a25a923"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/35f98e1293283397824d7f349ce5afb8747c3cd5",
|
||||
"reference": "35f98e1293283397824d7f349ce5afb8747c3cd5",
|
||||
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/b1b9055997a98dce3c2338e884626e718a25a923",
|
||||
"reference": "b1b9055997a98dce3c2338e884626e718a25a923",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -9409,7 +9457,7 @@
|
||||
"php-coveralls/php-coveralls": "^2.9.1",
|
||||
"php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8",
|
||||
"php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8",
|
||||
"phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.55",
|
||||
"phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56",
|
||||
"symfony/polyfill-php85": "^1.38",
|
||||
"symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.0",
|
||||
"symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.0"
|
||||
@@ -9453,7 +9501,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
|
||||
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.11"
|
||||
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.12"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -9461,7 +9509,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-25T14:17:04+00:00"
|
||||
"time": "2026-07-07T13:29:36+00:00"
|
||||
},
|
||||
{
|
||||
"name": "myclabs/deep-copy",
|
||||
|
||||
@@ -15,6 +15,7 @@ use Symfony\Bundle\TwigBundle\TwigBundle;
|
||||
use Symfony\Bundle\WebProfilerBundle\WebProfilerBundle;
|
||||
use Symfony\UX\StimulusBundle\StimulusBundle;
|
||||
use Symfony\UX\Turbo\TurboBundle;
|
||||
use SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle;
|
||||
use SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle;
|
||||
use Symfonycasts\SassBundle\SymfonycastsSassBundle;
|
||||
use Twig\Extra\TwigExtraBundle\TwigExtraBundle;
|
||||
@@ -36,4 +37,5 @@ return [
|
||||
TurboBundle::class => ['all' => true],
|
||||
DAMADoctrineTestBundle::class => ['test' => true],
|
||||
StofDoctrineExtensionsBundle::class => ['all' => true],
|
||||
SymfonyCastsResetPasswordBundle::class => ['all' => true],
|
||||
];
|
||||
|
||||
@@ -14,7 +14,7 @@ when@prod:
|
||||
# shortcut for private IP address ranges of your proxy
|
||||
trusted_proxies: 'private_ranges'
|
||||
# or, if your proxy instead uses the "Forwarded" header
|
||||
trusted_headers: [ 'forwarded' ]
|
||||
trusted_headers: [ 'x-forwarded-proto' ]
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
symfonycasts_reset_password:
|
||||
request_password_repository: Tvdt\Repository\ResetPasswordRequestRepository
|
||||
Generated
+10
@@ -1490,6 +1490,12 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* skip_translation_on_load?: bool|Param, // Default: false
|
||||
* metadata_cache_pool?: scalar|Param|null, // Default: null
|
||||
* }
|
||||
* @psalm-type SymfonycastsResetPasswordConfig = array{
|
||||
* request_password_repository?: scalar|Param|null, // A class that implements ResetPasswordRequestRepositoryInterface - usually your ResetPasswordRequestRepository.
|
||||
* lifetime?: int|Param, // The length of time in seconds that a password reset request is valid for after it is created. // Default: 3600
|
||||
* throttle_limit?: int|Param, // Another password reset cannot be made faster than this throttle time in seconds. // Default: 3600
|
||||
* enable_garbage_collection?: bool|Param, // Enable/Disable automatic garbage collection. // Default: true
|
||||
* }
|
||||
* @psalm-type ConfigType = array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
@@ -1505,6 +1511,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* stimulus?: StimulusConfig,
|
||||
* turbo?: TurboConfig,
|
||||
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
|
||||
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
|
||||
* "when@dev"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
@@ -1523,6 +1530,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* stimulus?: StimulusConfig,
|
||||
* turbo?: TurboConfig,
|
||||
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
|
||||
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
|
||||
* },
|
||||
* "when@prod"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
@@ -1540,6 +1548,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* stimulus?: StimulusConfig,
|
||||
* turbo?: TurboConfig,
|
||||
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
|
||||
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
|
||||
* },
|
||||
* "when@test"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
@@ -1558,6 +1567,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* turbo?: TurboConfig,
|
||||
* dama_doctrine_test?: DamaDoctrineTestConfig,
|
||||
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
|
||||
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
|
||||
* },
|
||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||
* imports?: ImportsConfig,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/** Auto-generated Migration: Please modify to your needs! */
|
||||
final class Version20260707205155 extends AbstractMigration
|
||||
{
|
||||
#[\Override]
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE reset_password_request (id UUID NOT NULL, selector VARCHAR(20) NOT NULL, hashed_token VARCHAR(100) NOT NULL, requested_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, expires_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, user_id UUID NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE INDEX IDX_7CE748AA76ED395 ON reset_password_request (user_id)');
|
||||
$this->addSql('ALTER TABLE reset_password_request ADD CONSTRAINT FK_7CE748AA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE reset_password_request DROP CONSTRAINT FK_7CE748AA76ED395');
|
||||
$this->addSql('DROP TABLE reset_password_request');
|
||||
}
|
||||
}
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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')]
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
$this->emailVerifier->sendDefaultConfirmation($user);
|
||||
|
||||
$response = $this->security->login($user, 'form_login', 'main');
|
||||
\assert($response instanceof Response);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Controller;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use SymfonyCasts\Bundle\ResetPassword\Controller\ResetPasswordControllerTrait;
|
||||
use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface;
|
||||
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordToken;
|
||||
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
|
||||
use Tvdt\Entity\User;
|
||||
use Tvdt\Enum\FlashType;
|
||||
use Tvdt\Form\ChangePasswordFormType;
|
||||
use Tvdt\Form\ResetPasswordRequestFormType;
|
||||
|
||||
final class ResetPasswordController extends AbstractController
|
||||
{
|
||||
use ResetPasswordControllerTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly ResetPasswordHelperInterface $resetPasswordHelper,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly MailerInterface $mailer,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
) {}
|
||||
|
||||
#[Route('/reset-password', name: 'tvdt_forgot_password_request')]
|
||||
public function request(Request $request): Response
|
||||
{
|
||||
$form = $this->createForm(ResetPasswordRequestFormType::class);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
/** @var string $email */
|
||||
$email = $form->get('email')->getData();
|
||||
|
||||
return $this->processSendingPasswordResetEmail($email, $this->mailer, $this->translator);
|
||||
}
|
||||
|
||||
return $this->render('reset_password/request.html.twig', [
|
||||
'requestForm' => $form,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/reset-password/check-email', name: 'tvdt_check_email')]
|
||||
public function checkEmail(): Response
|
||||
{
|
||||
if (!($resetToken = $this->getTokenObjectFromSession()) instanceof ResetPasswordToken) {
|
||||
$resetToken = $this->resetPasswordHelper->generateFakeResetToken();
|
||||
}
|
||||
|
||||
return $this->render('reset_password/check_email.html.twig', [
|
||||
'resetToken' => $resetToken,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/reset-password/reset/{token}', name: 'tvdt_reset_password')]
|
||||
public function reset(Request $request, ?string $token = null): Response
|
||||
{
|
||||
if ($token) {
|
||||
$this->storeTokenInSession($token);
|
||||
|
||||
return $this->redirectToRoute('tvdt_reset_password');
|
||||
}
|
||||
|
||||
$token = $this->getTokenFromSession();
|
||||
if (null === $token) {
|
||||
throw $this->createNotFoundException('No reset password token found in the URL or in the session.');
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var User $user */
|
||||
$user = $this->resetPasswordHelper->validateTokenAndFetchUser($token);
|
||||
} catch (ResetPasswordExceptionInterface $resetPasswordException) {
|
||||
$this->addFlash(FlashType::Danger->value, \sprintf(
|
||||
'%s - %s',
|
||||
$this->translator->trans(ResetPasswordExceptionInterface::MESSAGE_PROBLEM_VALIDATE, [], 'ResetPasswordBundle'),
|
||||
$this->translator->trans($resetPasswordException->getReason(), [], 'ResetPasswordBundle'),
|
||||
));
|
||||
|
||||
return $this->redirectToRoute('tvdt_forgot_password_request');
|
||||
}
|
||||
|
||||
$form = $this->createForm(ChangePasswordFormType::class);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->resetPasswordHelper->removeResetRequest($token);
|
||||
|
||||
/** @var string $plainPassword */
|
||||
$plainPassword = $form->get('plainPassword')->getData();
|
||||
|
||||
$user->password = $this->passwordHasher->hashPassword($user, $plainPassword);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->cleanSessionAfterReset();
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_index');
|
||||
}
|
||||
|
||||
return $this->render('reset_password/reset.html.twig', [
|
||||
'resetForm' => $form,
|
||||
]);
|
||||
}
|
||||
|
||||
private function processSendingPasswordResetEmail(string $emailFormData, MailerInterface $mailer, TranslatorInterface $translator): RedirectResponse
|
||||
{
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy([
|
||||
'email' => $emailFormData,
|
||||
]);
|
||||
|
||||
if (!$user instanceof User) {
|
||||
return $this->redirectToRoute('tvdt_check_email');
|
||||
}
|
||||
|
||||
try {
|
||||
$resetToken = $this->resetPasswordHelper->generateResetToken($user);
|
||||
} catch (ResetPasswordExceptionInterface) {
|
||||
return $this->redirectToRoute('tvdt_check_email');
|
||||
}
|
||||
|
||||
$email = new TemplatedEmail()
|
||||
->to($user->getUserIdentifier())
|
||||
->subject($translator->trans('Your password reset request'))
|
||||
->htmlTemplate('reset_password/email.html.twig')
|
||||
->context([
|
||||
'resetToken' => $resetToken,
|
||||
]);
|
||||
|
||||
$mailer->send($email);
|
||||
|
||||
$this->setTokenObjectInSession($resetToken);
|
||||
|
||||
return $this->redirectToRoute('tvdt_check_email');
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Types\UuidType;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
|
||||
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestTrait;
|
||||
use Tvdt\Repository\ResetPasswordRequestRepository;
|
||||
|
||||
#[ORM\Entity(repositoryClass: ResetPasswordRequestRepository::class)]
|
||||
class ResetPasswordRequest implements ResetPasswordRequestInterface
|
||||
{
|
||||
use ResetPasswordRequestTrait;
|
||||
|
||||
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
|
||||
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
|
||||
#[ORM\Id]
|
||||
public private(set) Uuid $id;
|
||||
|
||||
public function __construct(#[ORM\JoinColumn(nullable: false)]
|
||||
#[ORM\ManyToOne]
|
||||
private User $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken)
|
||||
{
|
||||
$this->initialize($expiresAt, $selector, $hashedToken);
|
||||
}
|
||||
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,10 @@ use Tvdt\Repository\UserRepository;
|
||||
#[UniqueEntity(fields: ['email'], message: 'There is already an account with this email')]
|
||||
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
{
|
||||
public const int PASSWORD_MIN_LENGTH = 8;
|
||||
|
||||
public const int PASSWORD_MAX_LENGTH = 4096;
|
||||
|
||||
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
|
||||
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?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\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\Length;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
/** @extends AbstractType<array{plainPassword: string}> */
|
||||
final class ChangePasswordFormType extends AbstractType
|
||||
{
|
||||
public function __construct(private readonly TranslatorInterface $translator) {}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->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,
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([]);
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ class RegistrationFormType extends AbstractType
|
||||
'mapped' => false,
|
||||
'constraints' => [
|
||||
new NotBlank(message: 'Please enter a password'),
|
||||
new Length(min: 8, max: 4096, minMessage: 'Your password should be at least {{ limit }} characters'),
|
||||
new Length(min: User::PASSWORD_MIN_LENGTH, max: User::PASSWORD_MAX_LENGTH, minMessage: 'Your password should be at least {{ limit }} characters'),
|
||||
],
|
||||
'translation_domain' => false,
|
||||
])
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?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\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/** @extends AbstractType<array{email: string}> */
|
||||
final class ResetPasswordRequestFormType 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('Email'),
|
||||
'attr' => ['autocomplete' => 'email'],
|
||||
'translation_domain' => false,
|
||||
'constraints' => [
|
||||
new NotBlank(message: 'Please enter your email'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Repository;
|
||||
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
|
||||
use SymfonyCasts\Bundle\ResetPassword\Persistence\Repository\ResetPasswordRequestRepositoryTrait;
|
||||
use SymfonyCasts\Bundle\ResetPassword\Persistence\ResetPasswordRequestRepositoryInterface;
|
||||
use Tvdt\Entity\ResetPasswordRequest;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
/** @extends ServiceEntityRepository<ResetPasswordRequest> */
|
||||
final class ResetPasswordRequestRepository extends ServiceEntityRepository implements ResetPasswordRequestRepositoryInterface
|
||||
{
|
||||
use ResetPasswordRequestRepositoryTrait;
|
||||
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ResetPasswordRequest::class);
|
||||
}
|
||||
|
||||
public function createResetPasswordRequest(object $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken): ResetPasswordRequestInterface
|
||||
{
|
||||
\assert($user instanceof User);
|
||||
|
||||
return new ResetPasswordRequest($user, $expiresAt, $selector, $hashedToken);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -356,6 +356,18 @@
|
||||
"config/routes/web_profiler.yaml"
|
||||
]
|
||||
},
|
||||
"symfonycasts/reset-password-bundle": {
|
||||
"version": "1.25",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.0",
|
||||
"ref": "97c1627c0384534997ae1047b93be517ca16de43"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/reset_password.yaml"
|
||||
]
|
||||
},
|
||||
"symfonycasts/sass-bundle": {
|
||||
"version": "v0.8.2"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
{% block body %}
|
||||
<form method="post">
|
||||
<h1 class="py-2 h3 mb-3 font-weight-normal">{{ 'Please sign in'|trans }}</h1>
|
||||
<h3 class="mb-3">{{ 'Please sign in'|trans }}</h3>
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">{{ 'Email'|trans }}</label>
|
||||
<input type="email" value="{{ last_username }}" name="_username" id="username" class="form-control"
|
||||
@@ -31,5 +31,7 @@
|
||||
</button>
|
||||
<a href="{{ path('tvdt_register') }}"
|
||||
class="btn btn-link">{{ 'Create an account'|trans }}</a>
|
||||
<a href="{{ path('tvdt_forgot_password_request') }}"
|
||||
class="btn btn-link">{{ 'Forgot your password?'|trans }}</a>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% block title %}{{ 'Register'|trans }}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h3>{{ 'Register'|trans }}</h3>
|
||||
<h3 class="mb-3">{{ 'Register'|trans }}</h3>
|
||||
|
||||
{{ form_errors(registrationForm) }}
|
||||
|
||||
|
||||
@@ -8,11 +8,13 @@
|
||||
</div>
|
||||
<div class="list-group mb-3">
|
||||
{% for quiz in season.quizzes %}
|
||||
<a class="list-group-item list-group-item-action{% if season.activeQuiz == quiz %} active{% endif %}"
|
||||
<a class="list-group-item list-group-item-action d-flex align-items-center gap-2{% if season.activeQuiz == quiz %} active{% endif %}"
|
||||
href="{{ path('tvdt_backoffice_quiz', {seasonCode: season.seasonCode, quiz: quiz.id}) }}">
|
||||
{{ quiz.name }}
|
||||
{% if quiz.isFinalized %}
|
||||
<span class="badge text-bg-success">{{ 'Finalized'|trans }}</span>
|
||||
{% if season.activeQuiz == quiz %}
|
||||
<span class="badge text-bg-light ms-auto">{{ 'Active'|trans }}</span>
|
||||
{% elseif quiz.isFinalized %}
|
||||
<span class="badge text-bg-success ms-auto">{{ 'Ready'|trans }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
|
||||
@@ -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,16 @@
|
||||
{% extends 'backoffice/base.html.twig' %}
|
||||
|
||||
{% block title %}E-mail verstuurd{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h3 class="mb-3">{{ 'Check your email'|trans }}</h3>
|
||||
|
||||
<p>
|
||||
{{ 'If an account matching your email exists, then an email was just sent that contains a link that you can use to reset your password.'|trans }}
|
||||
{{ 'This link will expire in %count%.'|trans({'%count%': resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle')}) }}
|
||||
</p>
|
||||
<p>
|
||||
{{ 'If you don\'t receive an email please check your spam folder or'|trans }}
|
||||
<a href="{{ path('tvdt_forgot_password_request') }}">{{ 'try again'|trans }}</a>.
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,9 @@
|
||||
<h1>Hi!</h1>
|
||||
|
||||
<p>To reset your password, please visit the following link</p>
|
||||
|
||||
<a href="{{ url('tvdt_reset_password', {token: resetToken.token}) }}">{{ url('tvdt_reset_password', {token: resetToken.token}) }}</a>
|
||||
|
||||
<p>This link will expire in {{ resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }}.</p>
|
||||
|
||||
<p>Cheers!</p>
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends 'backoffice/base.html.twig' %}
|
||||
|
||||
{% block title %}Wachtwoord vergeten{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h3 class="mb-3">{{ 'Reset your password'|trans }}</h3>
|
||||
|
||||
{{ form_start(requestForm) }}
|
||||
{{ form_row(requestForm.email) }}
|
||||
|
||||
<small class="d-block mb-3">
|
||||
{{ 'Enter your email address, and we will send you a link to reset your password.'|trans }}
|
||||
</small>
|
||||
|
||||
<button class="btn btn-primary" type="submit">{{ 'Send password reset email'|trans }}</button>
|
||||
<a href="{{ path('tvdt_login_login') }}" class="btn btn-link">{{ 'Back to login'|trans }}</a>
|
||||
{{ form_end(requestForm) }}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends 'backoffice/base.html.twig' %}
|
||||
|
||||
{% block title %}Nieuw wachtwoord instellen{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h3 class="mb-3">{{ 'Reset your password'|trans }}</h3>
|
||||
|
||||
{{ form_start(resetForm) }}
|
||||
{{ form_row(resetForm.plainPassword) }}
|
||||
<button class="btn btn-primary" type="submit">{{ 'Reset password'|trans }}</button>
|
||||
{{ form_end(resetForm) }}
|
||||
{% endblock %}
|
||||
@@ -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,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller;
|
||||
|
||||
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 Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
|
||||
use Tvdt\Controller\ResetPasswordController;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
#[CoversClass(ResetPasswordController::class)]
|
||||
final class ResetPasswordControllerTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = self::createClient();
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
}
|
||||
|
||||
public function testRequestPageLoads(): void
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, '/reset-password');
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$this->assertSelectorExists('form');
|
||||
}
|
||||
|
||||
public function testRequestWithUnknownEmailRedirectsToCheckEmail(): void
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, '/reset-password');
|
||||
$form = $this->client->getCrawler()->filter('form')->form([
|
||||
'reset_password_request_form[email]' => 'unknown@example.org',
|
||||
]);
|
||||
$this->client->submit($form);
|
||||
|
||||
$this->assertResponseRedirects('/reset-password/check-email');
|
||||
}
|
||||
|
||||
public function testRequestWithKnownEmailRedirectsToCheckEmail(): void
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, '/reset-password');
|
||||
$form = $this->client->getCrawler()->filter('form')->form([
|
||||
'reset_password_request_form[email]' => 'test@example.org',
|
||||
]);
|
||||
$this->client->submit($form);
|
||||
|
||||
$this->assertResponseRedirects('/reset-password/check-email');
|
||||
}
|
||||
|
||||
public function testCheckEmailPageLoads(): void
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, '/reset-password/check-email');
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testResetWithInvalidTokenRedirectsToRequest(): void
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, '/reset-password/reset/invalidtoken');
|
||||
$this->client->followRedirect();
|
||||
|
||||
$this->assertResponseRedirects('/reset-password');
|
||||
}
|
||||
|
||||
public function testFullResetFlow(): void
|
||||
{
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
|
||||
/** @var ResetPasswordHelperInterface $helper */
|
||||
$helper = self::getContainer()->get(ResetPasswordHelperInterface::class);
|
||||
$resetToken = $helper->generateResetToken($user);
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/reset-password/reset/'.$resetToken->getToken());
|
||||
$this->assertResponseRedirects('/reset-password/reset');
|
||||
|
||||
$this->client->followRedirect();
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
$form = $this->client->getCrawler()->filter('form')->form([
|
||||
'change_password_form[plainPassword][first]' => 'NewPass123!',
|
||||
'change_password_form[plainPassword][second]' => 'NewPass123!',
|
||||
]);
|
||||
$this->client->submit($form);
|
||||
$this->assertResponseRedirects('/backoffice/');
|
||||
|
||||
$this->entityManager->clear();
|
||||
$updatedUser = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
|
||||
$this->assertInstanceOf(User::class, $updatedUser);
|
||||
|
||||
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
||||
$this->assertTrue($hasher->isPasswordValid($updatedUser, 'NewPass123!'));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
|
||||
<file source-language="nl" target-language="nl" datatype="plaintext" original="file.ext">
|
||||
<header>
|
||||
<tool tool-id="symfony" tool-name="Symfony"/>
|
||||
</header>
|
||||
<body>
|
||||
<trans-unit id="E69bPZ6" resname="%count% day|%count% days">
|
||||
<source>%count% day|%count% days</source>
|
||||
<target>%count% dag|%count% dagen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="H3jnVKg" resname="%count% hour|%count% hours">
|
||||
<source>%count% hour|%count% hours</source>
|
||||
<target>%count% uur|%count% uren</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="30tji6_" resname="%count% minute|%count% minutes">
|
||||
<source>%count% minute|%count% minutes</source>
|
||||
<target>%count% minuut|%count% minuten</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="NCe6niW" resname="%count% month|%count% months">
|
||||
<source>%count% month|%count% months</source>
|
||||
<target>%count% maand|%count% maanden</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="U_NoHhq" resname="%count% year|%count% years">
|
||||
<source>%count% year|%count% years</source>
|
||||
<target>%count% jaar|%count% jaar</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="NUQGnNx" resname="Please update the request_password_repository configuration in config/packages/reset_password.yaml to point to your "request password repository" service.">
|
||||
<source>Please update the request_password_repository configuration in config/packages/reset_password.yaml to point to your "request password repository" service.</source>
|
||||
<target>Update de request_password_repository waarde in config/packages/reset_password.yaml met een verwijzing naar je "request password repository" service.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="XyawDv9" resname="The link in your email is expired. Please try to reset your password again.">
|
||||
<source>The link in your email is expired. Please try to reset your password again.</source>
|
||||
<target>De link in je e-mail is verlopen. Probeer opnieuw je wachtwoord te herstellen.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="RPp.BKQ" resname="The reset password link is invalid. Please try to reset your password again.">
|
||||
<source>The reset password link is invalid. Please try to reset your password again.</source>
|
||||
<target>De link om je wachtwoord te herstellen is niet geldig. Probeer opnieuw je wachtwoord te herstellen.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id=".BeFwl." resname="There was a problem handling your password reset request">
|
||||
<source>There was a problem handling your password reset request</source>
|
||||
<target>Er is een probleem opgetreden bij het afhandelen van het verzoek om je wachtwoord te herstellen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="ZeI3pSX" resname="There was a problem validating your password reset request">
|
||||
<source>There was a problem validating your password reset request</source>
|
||||
<target>Er is een probleem opgetreden bij het valideren van het verzoek om je wachtwoord te herstellen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="IZsa0iw" resname="You have already requested a reset password email. Please check your email or try again soon.">
|
||||
<source>You have already requested a reset password email. Please check your email or try again soon.</source>
|
||||
<target>Je hebt al een verzoek ingediend voor een e-mail om je wachtwoord te herstellen. Controleer of je een e-mail hebt ontvangen of probeer het later nog eens.</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
@@ -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>
|
||||
@@ -109,6 +117,10 @@
|
||||
<source>Back</source>
|
||||
<target>Terug</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="mR7n673" resname="Back to login">
|
||||
<source>Back to login</source>
|
||||
<target>Terug naar inloggen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="JUdglpF" resname="Backoffice">
|
||||
<source>Backoffice</source>
|
||||
<target>Backoffice</target>
|
||||
@@ -145,6 +157,18 @@
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="J1c2y63" resname="Clear Quiz...">
|
||||
<source>Clear Quiz...</source>
|
||||
<target>Test leegmaken...</target>
|
||||
@@ -165,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>
|
||||
@@ -193,10 +221,22 @@
|
||||
<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="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>
|
||||
@@ -217,10 +257,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>
|
||||
@@ -249,6 +305,10 @@
|
||||
<source>Enter name</source>
|
||||
<target>Voer een naam in</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="ifotYH6" resname="Enter your email address, and we will send you a link to reset your password.">
|
||||
<source>Enter your email address, and we will send you a link to reset your password.</source>
|
||||
<target>Voer je e-mailadres in en we sturen je een link om je wachtwoord te herstellen.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="RnI7jJT" resname="Enter your name">
|
||||
<source>Enter your name</source>
|
||||
<target>Voer je naam in</target>
|
||||
@@ -273,6 +333,10 @@
|
||||
<source>Finalized</source>
|
||||
<target>Afgerond</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="Fztxgjr" resname="Forgot your password?">
|
||||
<source>Forgot your password?</source>
|
||||
<target>Wachtwoord vergeten?</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="MebBrmp" resname="Gray">
|
||||
<source>Gray</source>
|
||||
<target>Grijs</target>
|
||||
@@ -301,6 +365,14 @@
|
||||
<source>Home</source>
|
||||
<target>Home</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="NJn0f4_" resname="If an account matching your email exists, then an email was just sent that contains a link that you can use to reset your password.">
|
||||
<source>If an account matching your email exists, then an email was just sent that contains a link that you can use to reset your password.</source>
|
||||
<target>Als er een account met dit e-mailadres bestaat, is er zojuist een e-mail verstuurd met een link om je wachtwoord te herstellen.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="bDPZm0a" resname="If you don't receive an email please check your spam folder or">
|
||||
<source>If you don't receive an email please check your spam folder or</source>
|
||||
<target>Als je geen e-mail ontvangt, controleer dan je spammap of</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="oPzjCWg" resname="Import">
|
||||
<source>Import</source>
|
||||
<target>Importeren</target>
|
||||
@@ -337,6 +409,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>
|
||||
@@ -369,10 +449,18 @@
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="Zm.jZiK" resname="New password">
|
||||
<source>New password</source>
|
||||
<target>Nieuw wachtwoord</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="gefhnBC" resname="Next">
|
||||
<source>Next</source>
|
||||
<target>Volgende</target>
|
||||
@@ -409,6 +497,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>
|
||||
@@ -569,6 +661,10 @@
|
||||
<source>Re-opens the quiz for editing. Candidates will no longer be able to take the quiz until it is finalized again.</source>
|
||||
<target>Heropent de test voor bewerking. Deelnemers kunnen de test niet meer afnemen totdat deze opnieuw is afgerond.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="MuwD4xO" resname="Ready">
|
||||
<source>Ready</source>
|
||||
<target>Voorbereid</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="P1HcfAu" resname="Red">
|
||||
<source>Red</source>
|
||||
<target>Rood</target>
|
||||
@@ -593,6 +689,18 @@
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="eyayNGN" resname="Reset your password">
|
||||
<source>Reset your password</source>
|
||||
<target>Wachtwoord herstellen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="7UvBPrb" resname="Results & Elimination">
|
||||
<source>Results & Elimination</source>
|
||||
<target><![CDATA[Resultaat & Eliminatie]]></target>
|
||||
@@ -629,6 +737,10 @@
|
||||
<source>Seasons</source>
|
||||
<target>Seizoenen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="5XiCCZa" resname="Send password reset email">
|
||||
<source>Send password reset email</source>
|
||||
<target>Stuur herstel-e-mail</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="VXFwlwn" resname="Settings">
|
||||
<source>Settings</source>
|
||||
<target>Instellingen</target>
|
||||
@@ -641,6 +753,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 A–Z">
|
||||
<source>Sort A–Z</source>
|
||||
<target>Sorteer A-Z</target>
|
||||
@@ -685,10 +801,22 @@
|
||||
<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="YueaA2f" resname="This link will expire in %count%.">
|
||||
<source>This link will expire in %count%.</source>
|
||||
<target>Deze link verloopt over %count%.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="o.FTilS" resname="This question cannot be deleted because it is used in a locked or active quiz">
|
||||
<source>This question cannot be deleted because it is used in a locked or active quiz</source>
|
||||
<target>Deze vraag kan niet verwijderd worden omdat die gebruik wordt in een vergrendelde of actieve test</target>
|
||||
@@ -745,6 +873,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>
|
||||
@@ -769,10 +901,42 @@
|
||||
<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="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="kWpL7Rn" 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="mQxV2Jf" 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="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>
|
||||
</trans-unit>
|
||||
<trans-unit id="zqHSO6v" resname="try again">
|
||||
<source>try again</source>
|
||||
<target>probeer opnieuw</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -109,10 +109,22 @@
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="9RtYCIm" resname="Please provide a valid phone number.">
|
||||
<source>Please provide a valid phone number.</source>
|
||||
<target>Vul een geldig telefoonnummer in.</target>
|
||||
@@ -433,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>
|
||||
|
||||
Reference in New Issue
Block a user