Compare commits

..

8 Commits

Author SHA1 Message Date
coderabbitai[bot] a75bc14638 📝 CodeRabbit Chat: Add retry and unsaved notice for question list ordering 2026-07-07 06:46:09 +00:00
Marijn e2558c70f5 fix: use correct Turbo v8 session export to disable Drive 2026-07-07 08:33:34 +02:00
Marijn 12d40b2602 fix: exclude auto-generated reference.php from pre-commit CS-fixer 2026-07-07 08:01:57 +02:00
Marijn cb577d60d3 feat: replace fetch-based modal forms with Turbo Frames (#181)
Enable @hotwired/turbo with Drive explicitly disabled, then migrate the
bo--modal-form Stimulus controller (custom fetch + X-Modal-Request pattern)
to a thin bo--modal controller that lets Turbo handle HTTP and DOM swap.
Adds frame templates for quiz questions and bank questions; controllers now
detect Turbo-Frame header instead of X-Modal-Request.
2026-07-06 23:13:55 +02:00
Marijn 3b6ff680e5 fix: modal save button + missing translations
- Replace form="id" cross-element approach with requestSubmit() for
  reliable save button wiring in modal footer
- Re-call _bindDirty after validation-error re-render so dirty guard
  is preserved across save attempts
- Translate missing Dutch strings: Order saved, Error saving order,
  Question details, View
2026-07-06 22:41:34 +02:00
Marijn 4a87ac574d feat: add bank question via modal + dirty modal guard
- Add question in question bank now opens a modal instead of navigating
  to a full-page form, consistent with the edit modal pattern
- Modal closes are blocked by static backdrop once the user has made any
  change (input, checkbox, drag-reorder, sort, randomize, remove answer)
- Dirty state resets when the modal is fully hidden
2026-07-06 22:32:44 +02:00
Marijn 394b8c7d33 feat: answer field UX improvements
- Auto-add one empty answer field when opening a blank question form
- Auto-append new empty field when typing in the last answer field
- Strip empty answer rows before submit (novalidate + JS cleanup)
- Tab key skips correct/delete buttons, jumping straight to next answer
2026-07-06 22:28:57 +02:00
Marijn 64a09453e6 feat: quiz page question rework (#181)
- Replace Bootstrap accordion with flat card list for questions
- Add HTML5 drag-and-drop reordering with placeholder-between-cards UX
  and amber/green/red save status indicator next to the heading
- Add edit button per question opening a Bootstrap modal (bo--modal-form
  Stimulus controller with X-Modal-Request header pattern)
- Show read-only view button instead of edit for locked/finalized quizzes
- Add BankQuestion edit modal in question bank tab using same infrastructure
- Move modal action buttons into modal-footer via <template data-modal-footer>
- Fix IS_AUTHENTICATED_FULLY 403: replace ROLE_USER with IS_AUTHENTICATED
  on all backoffice controllers and in security.yaml access_control
2026-07-06 22:19:51 +02:00
85 changed files with 441 additions and 4260 deletions
-2
View File
@@ -1,5 +1,3 @@
# 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
+5 -31
View File
@@ -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 -e MAILER_DSN=null://null php vendor/bin/phpunit --log-junit var/phpunit/junit.xml
run: docker compose exec -T 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: write
actions: read
steps:
- name: Wait for and verify successful CI run on this commit
env:
@@ -174,8 +174,6 @@ jobs:
run: |
max_attempts=30
attempt=0
triggered=false
while [[ $attempt -lt $max_attempts ]]; do
attempt=$((attempt + 1))
@@ -193,32 +191,12 @@ 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 in progress (attempt $attempt/$max_attempts), waiting 30s..."
echo "CI still in progress (attempt $attempt/$max_attempts), waiting 30s..."
sleep 30
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."
else
echo "::error::No prior successful CI run found for ${{ github.sha }}. Only tag commits that have passed CI on main."
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 }}."
@@ -256,7 +234,6 @@ 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}"
@@ -283,12 +260,9 @@ 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
-1
View File
@@ -171,7 +171,6 @@
<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
View File
@@ -205,7 +205,6 @@
<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">
+26 -132
View File
@@ -4,11 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
**Tijd voor de test** is a PHP/Symfony 8.1 application for managing quizzes in the style of **Wie is de Mol?** (WIDM) —
a Dutch TV show where contestants try to identify a saboteur ("de Mol") among them. At the end of each episode,
participants take a quiz about the Mol's identity and actions; the candidate with the least correct answers is
eliminated. This app replicates that quiz format with:
**Tijd voor de test** is a PHP/Symfony 8.1 application for managing quizzes in the style of **Wie is de Mol?** (WIDM) — a Dutch TV show where contestants try to identify a saboteur ("de Mol") among them. At the end of each episode, participants take a quiz about the Mol's identity and actions; the candidate with the least correct answers is eliminated. This app replicates that quiz format with:
- Test creation with variable question counts
- Season management with active test controls
- Candidate answer tracking with automatic timing
@@ -16,7 +12,6 @@ eliminated. This app replicates that quiz format with:
- Backoffice management for quiz administration and statistics
Tech Stack:
- **Framework**: Symfony 8.1
- **PHP**: 8.5+
- **Database**: PostgreSQL 16
@@ -81,7 +76,7 @@ All code quality checks run in CI/CD (.github/workflows/ci.yml) and should pass
```
src/
Controller/ # HTTP request handlers (attribute-routed)
Controller/ # HTTP request handlers (attribute-routed)
Backoffice/ # Admin panel controllers
Entity/ # Doctrine ORM entities
Repository/ # Database queries
@@ -127,151 +122,53 @@ tests/
- **Elimination**: Records red/green screens and forced results with joker adjustments.
- **User**: Administrative accounts for managing the system.
## Domain Context: "De Test" (Wie is de Mol)
**Wie is de Mol?** (WIDM) is a Dutch reality competition: a group of contestants ("kandidaten") travels together while
one of them, "de Mol", secretly sabotages assignments. Each episode ends with the fixed line: *"Tijd voor de test.
Twintig vragen over de identiteit en het doen en laten van de Mol. Degene die het minst weet, ligt uit het spel. Behalve
de Mol. Die hoeft nooit naar huis."* ("Time for the test. Twenty questions about the identity and the actions of the
Mol. Whoever knows the least is out of the game. Except the Mol — they never have to go home.") The contestant with the
worst score is eliminated ("afvallen"); the Mol is immune regardless of score, since they already know the answers. This
app is a generic engine for running that quiz format for private/fan seasons, not just modeling the TV show
incidentally — the entity model below exists specifically to reproduce WIDM's test mechanics.
### What a test's 20 questions actually are
Per the intro line, questions fall into two factual categories — never opinion ("who would you vote off") — plus a
third recurring format used on the show:
1. **Identity of the Mol**: guessing which contestant is the Mol.
2. **The Mol's actions**: what the Mol did or where the Mol was during a specific assignment/moment.
3. **Candidate self-answered questions**: earlier, every contestant privately answered a question about themselves
(an interview-style question); the test then asks other contestants to guess what a *specific* candidate answered
about themselves. This tests how well contestants know each other, not just Mol-tracking.
### Why answers can be bound to candidates
All three categories above can have contestants themselves as the answer options rather than free text: "who is the
Mol" and "who did X" both need contestant names as options, and "what did candidate Y answer" needs Y's own submitted
answer among the options. In the domain model this is `Answer::$candidates` (a `ManyToMany` to `Candidate`, on both
sides): an answer option can *be* another contestant, not just text.
Because the relationship is many-to-many on the answer side too, a single answer option can cover **more than one
candidate at once** — e.g. "Anna en Bram" as one option for "who missed the assignment together", or an option
representing everyone who gave a particular self-answer in category 3 above. So a candidate-bound answer isn't always
one candidate, it can be a group; treat `Answer::$candidates` as "the set of contestants this option represents", not
as a single foreign key.
Combined with `GivenAnswer::$candidate` (who answered), every given answer on a candidate-bound question is a directed
relationship from the answering candidate to *every* candidate covered by the chosen option — a one-to-many edge when
the option is a group, not just candidate A pointed at candidate B. This is the mechanic behind any "who's suspected of
what" or sociogram-style statistic — it only applies to candidate-bound questions, plain trivia questions have no such
relationship. `Quiz::getQuestionErrors()` already relies on this distinction to validate that every active candidate is
covered exactly once per candidate-bound question (a candidate appearing across multiple group-options on the same
question counts as covered more than once).
### Elimination mechanics
- **Red/green screens**: at the end of a test, contestants are shown red or green screens one at a time to build tension
before the elimination is revealed. `Elimination::$data` stores the colour shown per candidate (
`SCREEN_RED/SCREEN_GREEN` via `getScreenColour()`), independent of the actual quiz score.
- **Jokers / corrections**: contestants can hold a "joker" (an advantage, e.g. an extra correct answer) that adjusts
their effective score without changing what they actually answered. This is `QuizCandidate::$corrections` — a float
added to the raw score, kept separate from `GivenAnswer` so the audit trail of what was actually answered stays
untouched.
- **Dropouts**: `Quiz::$dropouts` controls how many contestants can be eliminated in a single test (normally 1, but some
episodes eliminate more).
- **Finalization/locking**: `Quiz::$isFinalized` and `$isLocked` gate when a quiz's questions/answers can still be
edited — a quiz becomes immutable once a candidate has started it or an admin explicitly finalizes it. Treat this as
the natural point where computed results (scores, statistics) can be cached indefinitely, since nothing that feeds
them can change afterward.
### Terminology map (Dutch UI ↔ domain code)
| UI/domain term (Dutch) | Code |
|------------------------------|-------------------------------|
| Test | `Quiz` |
| Vraag | `Question` |
| Antwoord | `Answer` |
| Kandidaat | `Candidate` |
| Ingevuld antwoord | `GivenAnswer` |
| Afvallen / rood-groen scherm | `Elimination` |
| Joker / correctie | `QuizCandidate::$corrections` |
## Architecture Notes
### Routing
- Routes are **attribute-based** (PHP 8 attributes in controller methods)
- Configured in `config/routes/attributes.yaml` for automatic discovery
- Main entry point: `config/routes.yaml`
### Service Container & Dependency Injection
- Services in `src/` are automatically registered via PSR-4 namespace `Tvdt\`
- Exclusions: Entity, DependencyInjection, Kernel classes
- Autowiring and autoconfiguration enabled by default
- Service definitions in `config/services.yaml`
### Database & Migrations
- PostgreSQL-based with Doctrine ORM
- Migrations in `migrations/` at project root, namespace `DoctrineMigrations` (intentionally not autoloaded); generate
with `bin/console make:migration`
- Migrations in `migrations/` at project root, namespace `DoctrineMigrations` (intentionally not autoloaded); generate with `bin/console make:migration`
- Test fixtures in `src/DataFixtures/` (loaded with `--group=test`)
- Test database configured separately via `.env.test`
### Testing Infrastructure
- **PHPUnit 13** with DAMA Doctrine Test Bundle for transaction rollback
- Bootstrap: `tests/bootstrap.php` loads env vars and autoloader; `tests/symfony-container.php` boots the test
kernel/container (used by Rector)
- Bootstrap: `tests/bootstrap.php` loads env vars and autoloader; `tests/symfony-container.php` boots the test kernel/container (used by Rector)
- Symfony test utilities (BrowserKit, CSS selectors) available
- Coverage excluded from: `src/DataFixtures/`
- Test environment: `APP_ENV=test` (set in phpunit.dist.xml)
### 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.
- **Prefer `TestCase` over `WebTestCase`/`KernelTestCase`.** Reach for the full kernel/DB boot only when the test
genuinely needs routing, persistence, or the container — pure logic (services, listeners, helpers) should be tested
with plain PHPUnit `TestCase` and mocked dependencies; it's faster and more isolated.
- **Boy Scout Rule**: when you're already touching a file for an unrelated change, fix small nearby issues in the same
commit (e.g. a test that unnecessarily extends `WebTestCase`, a stale comment) rather than leaving them for later —
but don't let this balloon into an unrelated refactor.
### Code Style & Standards
- **PHP-CS-Fixer**: Symfony ruleset + risky rules enabled
- Strict types declaration required
- Trailing commas in multiline structures
- No else-only blocks
- **Rector**: Aggressive modernization with all attribute sets + prepared sets (dead code, code quality, Doctrine,
Symfony, PHPUnit)
- Strict types declaration required
- Trailing commas in multiline structures
- No else-only blocks
- **Rector**: Aggressive modernization with all attribute sets + prepared sets (dead code, code quality, Doctrine, Symfony, PHPUnit)
- **PHPStan**: Level 8 with extensions for Doctrine and Symfony
- **Twig-CS-Fixer**: Template style enforcement
- **Safe functions**: Use `thecodingmachine/safe` wrappers for standard PHP functions that return `false` on failure —
they throw exceptions instead
- **Safe functions**: Use `thecodingmachine/safe` wrappers for standard PHP functions that return `false` on failure — they throw exceptions instead
### Environment Configuration
- `.env` - Local development defaults (uncommitted in .env.local)
- `.env.dev` - Development overrides
- `.env.test` - Test environment configuration
- Production uses `composer dump-env prod` for compiled configuration
- Key variables:
- `APP_ENV` - Environment (dev/test/prod)
- `DATABASE_URL` - PostgreSQL connection string
- `MAILER_SENDER` - From address for emails
- `APP_ENV` - Environment (dev/test/prod)
- `DATABASE_URL` - PostgreSQL connection string
- `MAILER_SENDER` - From address for emails
### Frontend Build
- Asset mapper (no Node.js/Webpack) for JS/CSS bundling; JS modules declared in `importmap.php`
- **Stimulus** controllers in `assets/controllers/`, **Turbo** for SPA-like navigation
- Sass sources in `assets/styles/`, compiled via `bin/console sass:build`
@@ -284,28 +181,27 @@ GitHub Actions workflow (`.github/workflows/ci.yml`):
1. **Linting**: Dockerfile (hadolint), Twig templates
2. **Code Quality**:
- PHP-CS-Fixer style check
- Twig-CS-Fixer style check
- PHPStan static analysis
- Rector dry-run
- PHP-CS-Fixer style check
- Twig-CS-Fixer style check
- PHPStan static analysis
- Rector dry-run
3. **Integration Tests**:
- Docker image build and start services
- Database creation and migration
- Fixture loading
- Full PHPUnit test suite with JUnit XML output
- Doctrine schema validation
- Docker image build and start services
- Database creation and migration
- Fixture loading
- Full PHPUnit test suite with JUnit XML output
- Doctrine schema validation
4. **Build & Deploy** (on tags or main, disabled currently):
- Docker image push to GitHub Container Registry
- Sentry release creation
- Portainer webhook trigger for production deployment
- Docker image push to GitHub Container Registry
- Sentry release creation
- Portainer webhook trigger for production deployment
Runs on all pushes to main and pull requests. Concurrency cancels old runs on new commits.
## Important Files & Conventions
- **Kernel**: `src/Kernel.php` - Symfony kernel class
- **AbstractController**: Base class for all controllers — defines route parameter regexes (`SEASON_CODE_REGEX`,
`CANDIDATE_HASH_REGEX`) and flash helpers
- **AbstractController**: Base class for all controllers — defines route parameter regexes (`SEASON_CODE_REGEX`, `CANDIDATE_HASH_REGEX`) and flash helpers
- **Flash Messages**: Use `FlashType` enum instead of string literals
- **QuizSpreadsheetService**: Handles importing quizzes from XLSX files
- **Rector container**: `tests/symfony-container.php` — boots a test kernel so Rector can resolve Symfony service types
@@ -324,7 +220,6 @@ Runs on all pushes to main and pull requests. Concurrency cancels old runs on ne
## Composer Scripts
Auto-executed scripts on install/update:
- `cache:clear` - Symfony cache clear
- `assets:install` - Copy public assets
- `importmap:install` - JS import map setup
@@ -343,5 +238,4 @@ When writing Dutch help content in `templates/backoffice/help/nl/`:
- The backoffice elimination logic is in `Controller/Backoffice/PrepareEliminationController.php`
- Quiz timing logic starts on candidate start click and stops on final answer selection
- Background music feature noted but not yet implemented (requirements only)
- Statistics module (per-quiz statistics page, candidate accusation matrix, caching) is planned per GitHub issue #199
see "Domain Context" above for why candidate-bound answers matter to it
- Statistics functionality is marked TBD in README
-4
View File
@@ -109,7 +109,3 @@ 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
+2 -1
View File
@@ -1,7 +1,8 @@
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-icons/font/bootstrap-icons.min.css';
import './styles/backoffice.scss';
import '@hotwired/turbo';
import {session as turboSession} from '@hotwired/turbo';
turboSession.drive = false;
import './stimulus.js';
import './bootstrap.js';
import * as Sentry from '@sentry/browser';
@@ -6,30 +6,30 @@ export default class extends Controller {
connect() {
this.index = this.collectionTarget.children.length;
this._setupDrag();
this._syncOrdering();
if (this.index === 0) {
this.addItem();
}
// `submit` fires on the ancestor <form>, which is outside this controller's
// subtree. Stimulus data-action only works within the controller element, so
// addEventListener on the form is the only option here.
this._form = this.element.closest('form');
if (this._form) {
this._submitHandler = () => {
this.collectionTarget.addEventListener('input', (e) => {
if (e.target.type !== 'text') return;
const item = e.target.closest('[data-collection-item]');
const last = [...this.collectionTarget.children].at(-1);
if (item && item === last && e.target.value.trim() !== '') {
this.addItem();
}
});
const form = this.element.closest('form');
if (form) {
form.addEventListener('submit', () => {
[...this.collectionTarget.children].forEach(item => {
const input = item.querySelector('input[type="text"]');
if (input && input.value.trim() === '') item.remove();
});
};
this._form.addEventListener('submit', this._submitHandler);
}
}
disconnect() {
if (this._form && this._submitHandler) {
this._form.removeEventListener('submit', this._submitHandler);
});
}
}
@@ -38,6 +38,7 @@ export default class extends Controller {
item.innerHTML = this.prototypeValue.replace(/__name__/g, this.index);
const el = item.firstElementChild;
this.collectionTarget.appendChild(el);
this._makeDraggable(el);
this.index++;
this._syncOrdering();
}
@@ -70,61 +71,59 @@ export default class extends Controller {
this._notifyChange();
}
autoExpand(event) {
if (event.target.type !== 'text') return;
const item = event.target.closest('[data-collection-item]');
const last = [...this.collectionTarget.children].at(-1);
if (item && item === last && event.target.value.trim() !== '') {
this.addItem();
}
_notifyChange() {
this.element.dispatchEvent(new Event('change', {bubbles: true}));
}
// — drag-and-drop —
dragStart(event) {
this._dragging = event.currentTarget.closest('[data-collection-item]');
this._dragging.classList.add('opacity-50');
event.dataTransfer.effectAllowed = 'move';
_setupDrag() {
[...this.collectionTarget.children].forEach(el => this._makeDraggable(el));
}
dragEnd(event) {
event.currentTarget.closest('[data-collection-item]').classList.remove('opacity-50');
this._dragging = null;
this.collectionTarget.querySelectorAll('[data-collection-item]').forEach(i =>
i.classList.remove('border-top', 'border-bottom', 'border-primary'),
);
}
_makeDraggable(el) {
const handle = el.querySelector('[data-drag-handle]');
if (!handle) return;
dragOver(event) {
event.preventDefault();
const el = event.currentTarget;
if (!this._dragging || this._dragging === el) return;
event.dataTransfer.dropEffect = 'move';
const rect = el.getBoundingClientRect();
const isBottom = event.clientY > rect.top + rect.height / 2;
el.classList.toggle('border-top', !isBottom);
el.classList.toggle('border-bottom', isBottom);
el.classList.add('border-primary');
}
handle.setAttribute('draggable', 'true');
dragLeave(event) {
event.currentTarget.classList.remove('border-top', 'border-bottom', 'border-primary');
}
handle.addEventListener('dragstart', (e) => {
this._dragging = el;
el.classList.add('opacity-50');
e.dataTransfer.effectAllowed = 'move';
});
drop(event) {
event.preventDefault();
const el = event.currentTarget;
el.classList.remove('border-top', 'border-bottom', 'border-primary');
if (!this._dragging || this._dragging === el) return;
const rect = el.getBoundingClientRect();
const isBottom = event.clientY > rect.top + rect.height / 2;
this.collectionTarget.insertBefore(this._dragging, isBottom ? el.nextSibling : el);
this._syncOrdering();
this._notifyChange();
}
handle.addEventListener('dragend', () => {
this._dragging = null;
el.classList.remove('opacity-50');
this.collectionTarget.querySelectorAll('[data-collection-item]').forEach(i => i.classList.remove('border-top', 'border-bottom', 'border-primary'));
});
_notifyChange() {
this.element.dispatchEvent(new Event('change', {bubbles: true}));
el.addEventListener('dragover', (e) => {
e.preventDefault();
if (!this._dragging || this._dragging === el) return;
e.dataTransfer.dropEffect = 'move';
const rect = el.getBoundingClientRect();
const isBottom = e.clientY > rect.top + rect.height / 2;
el.classList.toggle('border-top', !isBottom);
el.classList.toggle('border-bottom', isBottom);
el.classList.add('border-primary');
});
el.addEventListener('dragleave', () => {
el.classList.remove('border-top', 'border-bottom', 'border-primary');
});
el.addEventListener('drop', (e) => {
e.preventDefault();
el.classList.remove('border-top', 'border-bottom', 'border-primary');
if (!this._dragging || this._dragging === el) return;
const rect = el.getBoundingClientRect();
const isBottom = e.clientY > rect.top + rect.height / 2;
this.collectionTarget.insertBefore(this._dragging, isBottom ? el.nextSibling : el);
this._syncOrdering();
this._notifyChange();
});
}
_syncOrdering() {
+15 -16
View File
@@ -1,6 +1,5 @@
import {Controller} from '@hotwired/stimulus';
import {Modal} from 'bootstrap';
import {visit} from '@hotwired/turbo';
export default class extends Controller {
static targets = ['modal', 'frame'];
@@ -12,36 +11,36 @@ export default class extends Controller {
const titleEl = this.modalTarget.querySelector('.modal-title');
if (titleEl) titleEl.textContent = modalTitle;
}
this.resetDirty();
this._resetDirty();
this.frameTarget.innerHTML = '<div class="modal-body text-center py-4"><div class="spinner-border" role="status"></div></div>';
this.frameTarget.removeAttribute('src');
this.frameTarget.setAttribute('src', src);
Modal.getOrCreateInstance(this.modalTarget).show();
}
frameLoad() {
this._bindDirty();
}
frameSubmitEnd(event) {
if (event.detail.success) {
Modal.getOrCreateInstance(this.modalTarget).hide();
visit(window.location.href);
window.location.reload();
}
}
markDirty() {
if (this._dirty) return;
this._dirty = true;
// Using _config instead of preventDefault on hide.bs.modal because we need
// to block only user-triggered dismissal (backdrop click, Escape key) while
// keeping programmatic hide() working — frameSubmitEnd() calls hide() after
// a successful save and must not be blocked. _config.backdrop/keyboard is
// the correct primitive for that distinction and has been stable across all
// Bootstrap 5.x releases.
_bindDirty() {
const modal = Modal.getOrCreateInstance(this.modalTarget);
modal._config.backdrop = 'static';
modal._config.keyboard = false;
const markDirty = () => {
modal._config.backdrop = 'static';
modal._config.keyboard = false;
};
this.frameTarget.addEventListener('input', markDirty, {once: true});
this.frameTarget.addEventListener('change', markDirty, {once: true});
this.modalTarget.addEventListener('hidden.bs.modal', () => this._resetDirty(), {once: true});
}
resetDirty() {
this._dirty = false;
_resetDirty() {
const modal = Modal.getOrCreateInstance(this.modalTarget);
modal._config.backdrop = true;
modal._config.keyboard = true;
@@ -1,13 +0,0 @@
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());
}
}
@@ -1,67 +1,83 @@
import {Controller} from '@hotwired/stimulus';
import {Modal} from 'bootstrap';
const RETRY_DELAY_MS = 1000;
export default class extends Controller {
static targets = ['list', 'item', 'status'];
static targets = ['list', 'item', 'status', 'noticeModal'];
static values = {
reorderUrl: String,
csrf: String,
canModify: Boolean,
savedLabel: String,
errorLabel: String,
errorHint: String,
};
connect() {
this._locked = false;
}
dragStart(event) {
const item = event.currentTarget.closest('[data-bo--question-list-target="item"]');
this._dragging = item;
event.dataTransfer.effectAllowed = 'move';
setTimeout(() => item.classList.add('opacity-50'), 0);
}
dragEnd(event) {
const item = event.currentTarget.closest('[data-bo--question-list-target="item"]');
item.classList.remove('opacity-50');
this._dragging = null;
this._removePlaceholder();
}
dragOver(event) {
event.preventDefault();
if (!this._dragging) return;
event.dataTransfer.dropEffect = 'move';
const target = event.target.closest('[data-bo--question-list-target="item"]');
if (!target || target === this._dragging) return;
const rect = target.getBoundingClientRect();
const insertBefore = event.clientY > rect.top + rect.height / 2 ? target.nextSibling : target;
if (!this._placeholder) {
this._placeholder = document.createElement('div');
this._placeholder.className = 'bg-primary rounded mb-2';
this._placeholder.style.height = '3px';
}
if (this._placeholder.nextSibling !== insertBefore) {
this.listTarget.insertBefore(this._placeholder, insertBefore);
if (this.canModifyValue) {
this._setupDrag();
}
}
dragLeave(event) {
if (!event.relatedTarget || !this.listTarget.contains(event.relatedTarget)) {
_setupDrag() {
this.itemTargets.forEach(el => {
const handle = el.querySelector('[data-drag-handle]');
if (!handle) return;
handle.setAttribute('draggable', 'true');
handle.addEventListener('dragstart', (e) => {
if (this._locked) {
e.preventDefault();
return;
}
this._dragging = el;
e.dataTransfer.effectAllowed = 'move';
setTimeout(() => el.classList.add('opacity-50'), 0);
});
handle.addEventListener('dragend', () => {
el.classList.remove('opacity-50');
this._dragging = null;
this._removePlaceholder();
});
});
this.listTarget.addEventListener('dragover', (e) => {
e.preventDefault();
if (!this._dragging) return;
e.dataTransfer.dropEffect = 'move';
const target = e.target.closest('[data-bo--question-list-target="item"]');
if (!target || target === this._dragging) return;
const rect = target.getBoundingClientRect();
const insertBefore = e.clientY > rect.top + rect.height / 2 ? target.nextSibling : target;
if (!this._placeholder) {
this._placeholder = document.createElement('div');
this._placeholder.className = 'bg-primary rounded mb-2';
this._placeholder.style.height = '3px';
}
if (this._placeholder.nextSibling !== insertBefore) {
this.listTarget.insertBefore(this._placeholder, insertBefore);
}
});
this.listTarget.addEventListener('dragleave', (e) => {
if (!e.relatedTarget || !this.listTarget.contains(e.relatedTarget)) {
this._removePlaceholder();
}
});
this.listTarget.addEventListener('drop', async (e) => {
e.preventDefault();
if (!this._dragging || !this._placeholder) return;
this.listTarget.insertBefore(this._dragging, this._placeholder);
this._removePlaceholder();
}
}
async drop(event) {
event.preventDefault();
if (!this._dragging || !this._placeholder || this._locked) return;
this.listTarget.insertBefore(this._dragging, this._placeholder);
this._removePlaceholder();
await this._persistOrder();
await this._persistOrder();
});
}
_removePlaceholder() {
@@ -98,26 +114,40 @@ export default class extends Controller {
if (numberEl) numberEl.textContent = String(i + 1);
});
for (let attempt = 0; attempt < 2; attempt++) {
const attempt = async () => {
const res = await fetch(this.reorderUrlValue, {method: 'POST', body: params});
if (!res.ok) {
throw new Error(`Unexpected response status: ${res.status}`);
}
};
try {
await attempt();
} catch {
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
try {
const res = await fetch(this.reorderUrlValue, {method: 'POST', body: params});
if (res.ok) {
this._setStatus('saved');
return;
}
await attempt();
} catch {
// network error — retry on first attempt
this._setStatus('error');
this._lockReordering();
return;
}
}
this._locked = true;
this._setStatus('error');
this._setStatus('saved');
}
const alert = document.createElement('div');
alert.className = 'alert alert-danger alert-dismissible mt-3';
alert.setAttribute('role', 'alert');
const hint = this.errorHintValue || 'Refresh the page to try again.';
alert.innerHTML = `${this.errorLabelValue || 'Error saving order'} &mdash; ${hint} <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>`;
this.listTarget.after(alert);
_lockReordering() {
this._locked = true;
this.itemTargets.forEach(el => {
const handle = el.querySelector('[data-drag-handle]');
if (handle) {
handle.removeAttribute('draggable');
handle.classList.add('opacity-25', 'pe-none');
}
});
if (this.hasNoticeModalTarget) {
Modal.getOrCreateInstance(this.noticeModalTarget).show();
}
}
}
@@ -1,40 +0,0 @@
import {Controller} from '@hotwired/stimulus';
const STORAGE_KEY = 'tvdt-fullscreen';
export default class extends Controller {
connect() {
this.onFullscreenChange = this.onFullscreenChange.bind(this);
document.addEventListener('fullscreenchange', this.onFullscreenChange);
this.syncState();
if (sessionStorage.getItem(STORAGE_KEY) === '1' && !document.fullscreenElement) {
this.request();
}
}
disconnect() {
document.removeEventListener('fullscreenchange', this.onFullscreenChange);
}
toggle() {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
this.request();
}
}
request() {
document.documentElement.requestFullscreen().catch(() => {});
}
onFullscreenChange() {
sessionStorage.setItem(STORAGE_KEY, document.fullscreenElement ? '1' : '0');
this.syncState();
}
syncState() {
document.documentElement.classList.toggle('is-fullscreen', Boolean(document.fullscreenElement));
}
}
-26
View File
@@ -94,32 +94,6 @@ input.btn-check:checked + label.answer-btn {
gap: 0.5rem;
}
.fullscreen-btn {
position: fixed;
bottom: 0.75rem;
left: 0.75rem;
z-index: 1040;
width: 2.25rem;
height: 2.25rem;
padding: 0;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.35);
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
&:hover,
&:focus {
color: rgba(255, 255, 255, 0.8);
outline: none;
}
}
html.is-fullscreen .fullscreen-btn {
display: none;
}
.elimination-screen {
position: fixed;
top: 0;
-2
View File
@@ -35,14 +35,12 @@
"symfony/security-bundle": "8.1.*",
"symfony/security-csrf": "8.1.*",
"symfony/serializer": "8.1.*",
"symfony/string": "8.1.*",
"symfony/translation": "8.1.*",
"symfony/twig-bundle": "8.1.*",
"symfony/uid": "8.1.*",
"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
+67 -114
View File
@@ -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": "ccae654dd9c952e8920d9cb9c0f35ff5",
"content-hash": "7171824ca13f4df0801dfa5d7f58d6a0",
"packages": [
{
"name": "composer/pcre",
@@ -1475,16 +1475,16 @@
},
{
"name": "guzzlehttp/psr7",
"version": "2.12.4",
"version": "2.12.3",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
"reference": "51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c"
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c",
"reference": "51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
"shasum": ""
},
"require": {
@@ -1574,7 +1574,7 @@
],
"support": {
"issues": "https://github.com/guzzle/psr7/issues",
"source": "https://github.com/guzzle/psr7/tree/2.12.4"
"source": "https://github.com/guzzle/psr7/tree/2.12.3"
},
"funding": [
{
@@ -1590,7 +1590,7 @@
"type": "tidelift"
}
],
"time": "2026-07-08T15:56:20+00:00"
"time": "2026-06-23T15:21:08+00:00"
},
{
"name": "jean85/pretty-package-versions",
@@ -2253,16 +2253,16 @@
},
{
"name": "phpstan/phpdoc-parser",
"version": "2.3.3",
"version": "2.3.2",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git",
"reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3"
"reference": "a004701b11273a26cd7955a61d67a7f1e525a45a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3",
"reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a",
"reference": "a004701b11273a26cd7955a61d67a7f1e525a45a",
"shasum": ""
},
"require": {
@@ -2294,9 +2294,9 @@
"description": "PHPDoc parser with support for nullable, intersection and generic types",
"support": {
"issues": "https://github.com/phpstan/phpdoc-parser/issues",
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3"
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2"
},
"time": "2026-07-08T07:01:06+00:00"
"time": "2026-01-25T14:56:51+00:00"
},
{
"name": "psr/cache",
@@ -8281,54 +8281,6 @@
],
"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",
@@ -9408,16 +9360,16 @@
},
{
"name": "friendsofphp/php-cs-fixer",
"version": "v3.95.12",
"version": "v3.95.11",
"source": {
"type": "git",
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
"reference": "b1b9055997a98dce3c2338e884626e718a25a923"
"reference": "35f98e1293283397824d7f349ce5afb8747c3cd5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/b1b9055997a98dce3c2338e884626e718a25a923",
"reference": "b1b9055997a98dce3c2338e884626e718a25a923",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/35f98e1293283397824d7f349ce5afb8747c3cd5",
"reference": "35f98e1293283397824d7f349ce5afb8747c3cd5",
"shasum": ""
},
"require": {
@@ -9457,7 +9409,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.35 || ^10.5.64 || ^11.5.56",
"phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.55",
"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"
@@ -9501,7 +9453,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.12"
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.11"
},
"funding": [
{
@@ -9509,7 +9461,7 @@
"type": "github"
}
],
"time": "2026-07-07T13:29:36+00:00"
"time": "2026-06-25T14:17:04+00:00"
},
{
"name": "myclabs/deep-copy",
@@ -9573,19 +9525,20 @@
},
{
"name": "nikic/php-parser",
"version": "v5.8.0",
"version": "v5.7.0",
"source": {
"type": "git",
"url": "https://github.com/nikic/PHP-Parser.git",
"reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f"
"reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
"reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
"reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-json": "*",
"ext-tokenizer": "*",
"php": ">=7.4"
@@ -9624,9 +9577,9 @@
],
"support": {
"issues": "https://github.com/nikic/PHP-Parser/issues",
"source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0"
"source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
},
"time": "2026-07-04T14:30:18+00:00"
"time": "2025-12-06T11:56:16+00:00"
},
{
"name": "phar-io/manifest",
@@ -9796,11 +9749,11 @@
},
{
"name": "phpstan/phpstan",
"version": "2.2.5",
"version": "2.2.4",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0",
"reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/f0fe3fb03bb53ce68cc2416785b260e62226ec27",
"reference": "f0fe3fb03bb53ce68cc2416785b260e62226ec27",
"shasum": ""
},
"require": {
@@ -9856,7 +9809,7 @@
"type": "github"
}
],
"time": "2026-07-05T06:31:06+00:00"
"time": "2026-07-03T07:00:23+00:00"
},
{
"name": "phpstan/phpstan-doctrine",
@@ -9937,16 +9890,16 @@
},
{
"name": "phpstan/phpstan-phpunit",
"version": "2.0.18",
"version": "2.0.17",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-phpunit.git",
"reference": "f5dc20ff8082d02339b60cab68ec3eb0d859fb30"
"reference": "c2f977551f0736d60467b3d754b2e0cf4e337b3f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/f5dc20ff8082d02339b60cab68ec3eb0d859fb30",
"reference": "f5dc20ff8082d02339b60cab68ec3eb0d859fb30",
"url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/c2f977551f0736d60467b3d754b2e0cf4e337b3f",
"reference": "c2f977551f0736d60467b3d754b2e0cf4e337b3f",
"shasum": ""
},
"require": {
@@ -9989,9 +9942,9 @@
],
"support": {
"issues": "https://github.com/phpstan/phpstan-phpunit/issues",
"source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.18"
"source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.17"
},
"time": "2026-07-04T12:16:09+00:00"
"time": "2026-06-29T05:32:23+00:00"
},
{
"name": "phpstan/phpstan-symfony",
@@ -10069,16 +10022,16 @@
},
{
"name": "phpunit/php-code-coverage",
"version": "14.2.3",
"version": "14.2.2",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
"reference": "82f6e49ff224e2cde923d74425e583a883910783"
"reference": "10d7da3628a99289cdf4c662dd7f0d73f1baec83"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/82f6e49ff224e2cde923d74425e583a883910783",
"reference": "82f6e49ff224e2cde923d74425e583a883910783",
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/10d7da3628a99289cdf4c662dd7f0d73f1baec83",
"reference": "10d7da3628a99289cdf4c662dd7f0d73f1baec83",
"shasum": ""
},
"require": {
@@ -10086,7 +10039,7 @@
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-xmlwriter": "*",
"nikic/php-parser": "^5.8.0",
"nikic/php-parser": "^5.7.0",
"php": ">=8.4",
"phpunit/php-text-template": "^6.0",
"sebastian/complexity": "^6.0",
@@ -10097,7 +10050,7 @@
"theseer/tokenizer": "^2.0.1"
},
"require-dev": {
"phpunit/phpunit": "^13.2.2"
"phpunit/phpunit": "^13.2.0"
},
"suggest": {
"ext-pcov": "PHP extension that provides line coverage",
@@ -10135,7 +10088,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
"security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
"source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.2.3"
"source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.2.2"
},
"funding": [
{
@@ -10155,7 +10108,7 @@
"type": "tidelift"
}
],
"time": "2026-07-06T15:04:02+00:00"
"time": "2026-06-08T11:50:38+00:00"
},
{
"name": "phpunit/php-file-iterator",
@@ -10452,30 +10405,30 @@
},
{
"name": "phpunit/phpunit",
"version": "13.2.4",
"version": "13.2.2",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "8f5180f4627fc1978be2f61d8d9979dbe37e0c10"
"reference": "492c067e618de7b3c76105082c90f9d2833401b7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/8f5180f4627fc1978be2f61d8d9979dbe37e0c10",
"reference": "8f5180f4627fc1978be2f61d8d9979dbe37e0c10",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/492c067e618de7b3c76105082c90f9d2833401b7",
"reference": "492c067e618de7b3c76105082c90f9d2833401b7",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-filter": "*",
"ext-json": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-xml": "*",
"ext-xmlwriter": "*",
"myclabs/deep-copy": "^1.13.4",
"phar-io/manifest": "^2.0.4",
"phar-io/version": "^3.2.1",
"php": ">=8.4.1",
"phpunit/php-code-coverage": "^14.2.3",
"phpunit/php-code-coverage": "^14.2.2",
"phpunit/php-file-iterator": "^7.0.0",
"phpunit/php-invoker": "^7.0.0",
"phpunit/php-text-template": "^6.0.0",
@@ -10532,7 +10485,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.4"
"source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.2"
},
"funding": [
{
@@ -10540,7 +10493,7 @@
"type": "other"
}
],
"time": "2026-07-08T08:36:51+00:00"
"time": "2026-06-29T13:36:29+00:00"
},
{
"name": "react/cache",
@@ -11070,16 +11023,16 @@
},
{
"name": "rector/rector",
"version": "2.5.5",
"version": "2.5.2",
"source": {
"type": "git",
"url": "https://github.com/rectorphp/rector.git",
"reference": "9718a72e7f1aacacbdcb6eeed07a47147bce802e"
"reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/rectorphp/rector/zipball/9718a72e7f1aacacbdcb6eeed07a47147bce802e",
"reference": "9718a72e7f1aacacbdcb6eeed07a47147bce802e",
"url": "https://api.github.com/repos/rectorphp/rector/zipball/49ff6339174bdbdf50b0b35ecbcff14a05ac9e24",
"reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24",
"shasum": ""
},
"require": {
@@ -11118,7 +11071,7 @@
],
"support": {
"issues": "https://github.com/rectorphp/rector/issues",
"source": "https://github.com/rectorphp/rector/tree/2.5.5"
"source": "https://github.com/rectorphp/rector/tree/2.5.2"
},
"funding": [
{
@@ -11126,7 +11079,7 @@
"type": "github"
}
],
"time": "2026-07-09T09:48:44+00:00"
"time": "2026-06-22T11:39:33+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -11818,24 +11771,24 @@
},
{
"name": "sebastian/lines-of-code",
"version": "5.0.2",
"version": "5.0.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/lines-of-code.git",
"reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8"
"reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d1b6f8fce682505dbd048977f1abedf1b8ad3ff8",
"reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8",
"url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d2cff273a90c79b0eb590baa682d4b5c318bdbb7",
"reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7",
"shasum": ""
},
"require": {
"nikic/php-parser": "^5.8.0",
"nikic/php-parser": "^5.7.0",
"php": ">=8.4"
},
"require-dev": {
"phpunit/phpunit": "^13.2.4"
"phpunit/phpunit": "^13.1.10"
},
"type": "library",
"extra": {
@@ -11864,7 +11817,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
"security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
"source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.2"
"source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.1"
},
"funding": [
{
@@ -11884,7 +11837,7 @@
"type": "tidelift"
}
],
"time": "2026-07-09T08:42:34+00:00"
"time": "2026-05-19T16:23:37+00:00"
},
{
"name": "sebastian/object-enumerator",
-2
View File
@@ -15,7 +15,6 @@ 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;
@@ -37,5 +36,4 @@ return [
TurboBundle::class => ['all' => true],
DAMADoctrineTestBundle::class => ['test' => true],
StofDoctrineExtensionsBundle::class => ['all' => true],
SymfonyCastsResetPasswordBundle::class => ['all' => true],
];
+1 -1
View File
@@ -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: [ 'x-forwarded-proto' ]
trusted_headers: [ 'forwarded' ]
when@test:
framework:
-2
View File
@@ -1,2 +0,0 @@
symfonycasts_reset_password:
request_password_repository: Tvdt\Repository\ResetPasswordRequestRepository
-10
View File
@@ -1490,12 +1490,6 @@ 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,
@@ -1511,7 +1505,6 @@ 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,
@@ -1530,7 +1523,6 @@ 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,
@@ -1548,7 +1540,6 @@ 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,
@@ -1567,7 +1558,6 @@ 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,
-34
View File
@@ -1,34 +0,0 @@
<?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');
}
}
-19
View File
@@ -5,9 +5,6 @@ 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
@@ -16,22 +13,6 @@ 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
{
@@ -14,13 +14,11 @@ use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Translation\TranslatorInterface;
use Tvdt\Controller\AbstractController;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\Season;
use Tvdt\Enum\FlashType;
use Tvdt\Entity\User;
use Tvdt\Form\CreateSeasonFormType;
use Tvdt\Helpers\FilenameSanitizer;
use Tvdt\Repository\SeasonRepository;
use Tvdt\Security\Voter\SeasonVoter;
use Tvdt\Service\QuizSpreadsheetService;
@@ -34,15 +32,17 @@ final class BackofficeController extends AbstractController
private readonly Security $security,
private readonly QuizSpreadsheetService $excel,
private readonly EntityManagerInterface $em,
private readonly TranslatorInterface $translator,
) {}
#[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($this->authenticatedUser);
: $this->seasonRepository->getSeasonsForUser($user);
return $this->render('backoffice/index.html.twig', [
'seasons' => $seasons,
@@ -58,7 +58,10 @@ final class BackofficeController extends AbstractController
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$season->addOwner($this->authenticatedUser);
$user = $this->getUser();
\assert($user instanceof User);
$season->addOwner($user);
$season->generateSeasonCode();
$this->em->persist($season);
@@ -87,17 +90,11 @@ final class BackofficeController extends AbstractController
requirements: ['quiz' => Requirement::UUID],
methods: ['GET'],
)]
public function exportQuiz(Quiz $quiz): Response
public function exportQuiz(Quiz $quiz): StreamedResponse
{
if (!$this->authenticatedUser->isVerified) {
$this->addFlash(FlashType::Warning, $this->translator->trans('Please confirm your email address before exporting a quiz.'));
return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $quiz->season->seasonCode]);
}
$response = new StreamedResponse($this->excel->quizToXlsx($quiz));
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, FilenameSanitizer::sanitize($quiz->name).'.xlsx'));
$response->headers->set('Content-Disposition', HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $quiz->name.'.xlsx'));
return $response;
}
@@ -90,13 +90,11 @@ class QuestionBankController extends AbstractController
$isTurboFrame = $request->headers->has('Turbo-Frame');
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, [
'season' => $season,
'action' => $this->generateUrl('tvdt_backoffice_question_bank_new', ['seasonCode' => $season->seasonCode]),
]);
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->applyAnswerOrdering($bankQuestion);
$season->addBankQuestion($bankQuestion);
$this->em->persist($bankQuestion);
$this->em->flush();
@@ -114,11 +112,17 @@ class QuestionBankController extends AbstractController
? 'backoffice/question_bank/_frame.html.twig'
: 'backoffice/question_bank/form.html.twig';
return $this->render($template, [
$response = $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')]
@@ -134,13 +138,7 @@ class QuestionBankController extends AbstractController
$isTurboFrame = $request->headers->has('Turbo-Frame');
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, [
'season' => $season,
'action' => $this->generateUrl('tvdt_backoffice_question_bank_edit', [
'seasonCode' => $season->seasonCode,
'bankQuestion' => $bankQuestion->id,
]),
]);
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
@@ -161,11 +159,17 @@ class QuestionBankController extends AbstractController
? 'backoffice/question_bank/_frame.html.twig'
: 'backoffice/question_bank/form.html.twig';
return $this->render($template, [
$response = $this->render($template, [
'season' => $season,
'form' => $form,
'bankQuestion' => $bankQuestion,
]);
if ($form->isSubmitted()) {
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $response;
}
#[IsCsrfTokenValid('delete_bank_question')]
@@ -370,6 +374,21 @@ 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 applyAnswerOrdering(BankQuestion $bankQuestion): void
{
$ordering = 1;
foreach ($bankQuestion->answers as $answer) {
$answer->ordering = $ordering++;
}
}
private function syncUsagesAfterEdit(BankQuestion $bankQuestion): void
{
$pendingNames = [];
+38 -31
View File
@@ -61,7 +61,25 @@ class QuizController extends AbstractController
{
$fetchedQuiz = $this->quizRepository->fetchWithQuestionsAndCandidates($quiz->id);
$candidateData = $this->buildCandidateData($season, $quiz, $fetchedQuiz->candidateData);
// 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,
];
}
return $this->render('backoffice/quiz.html.twig', [
'season' => $season,
@@ -100,7 +118,25 @@ class QuizController extends AbstractController
)]
public function candidatesTab(Season $season, Quiz $quiz): Response
{
$candidateData = $this->buildCandidateData($season, $quiz, $quiz->candidateData);
// 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,
];
}
return $this->render('backoffice/quiz.html.twig', [
'season' => $season,
@@ -396,33 +432,4 @@ 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;
}
}
@@ -46,16 +46,11 @@ class QuizQuestionController extends AbstractController
$isTurboFrame = $request->headers->has('Turbo-Frame');
$form = $this->createForm(QuestionFormType::class, $question, [
'action' => $this->generateUrl('tvdt_backoffice_quiz_question_edit', [
'seasonCode' => $season->seasonCode,
'quiz' => $quiz->id,
'question' => $question->id,
]),
]);
$form = $this->createForm(QuestionFormType::class, $question);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->applyAnswerOrdering($question);
$this->em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Question updated'));
@@ -74,15 +69,20 @@ class QuizQuestionController extends AbstractController
? 'backoffice/quiz/_question_frame.html.twig'
: 'backoffice/quiz/question_form.html.twig';
return $this->render($template, [
$response = $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')]
#[Route(
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/question/{question}/view',
name: 'tvdt_backoffice_quiz_question_view',
@@ -127,10 +127,6 @@ class QuizQuestionController extends AbstractController
}
}
if (\count(array_unique($ordering)) !== \count($questionsById)) {
throw new BadRequestHttpException('Ordering must include every question exactly once');
}
$position = 1;
foreach ($ordering as $questionId) {
$questionsById[$questionId]->ordering = $position++;
@@ -140,4 +136,12 @@ class QuizQuestionController extends AbstractController
return new Response('', Response::HTTP_NO_CONTENT);
}
private function applyAnswerOrdering(Question $question): void
{
$ordering = 1;
foreach ($question->answers as $answer) {
$answer->ordering = $ordering++;
}
}
}
@@ -10,13 +10,10 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
@@ -29,7 +26,6 @@ use Tvdt\Enum\FlashType;
use Tvdt\Form\AddCandidatesFormType;
use Tvdt\Form\SettingsForm;
use Tvdt\Form\UploadQuizFormType;
use Tvdt\Repository\CandidateRepository;
use Tvdt\Security\Voter\SeasonVoter;
use Tvdt\Service\QuizSpreadsheetService;
@@ -41,7 +37,6 @@ class SeasonController extends AbstractController
private readonly TranslatorInterface $translator,
private readonly EntityManagerInterface $em,
private readonly QuizSpreadsheetService $quizSpreadsheet,
private readonly CandidateRepository $candidateRepository,
) {}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
@@ -102,24 +97,6 @@ class SeasonController extends AbstractController
]);
}
#[IsCsrfTokenValid('regenerate_season_code')]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/settings/regenerate-code',
name: 'tvdt_backoffice_season_regenerate_code',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
methods: ['POST'],
)]
public function regenerateSeasonCode(Season $season): RedirectResponse
{
$season->generateSeasonCode();
$this->em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Season code regenerated'));
return $this->redirectToRoute('tvdt_backoffice_season_settings', ['seasonCode' => $season->seasonCode]);
}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/add-candidate',
@@ -146,56 +123,6 @@ class SeasonController extends AbstractController
return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form, 'season' => $season]);
}
#[IsCsrfTokenValid('rename_candidate')]
#[IsGranted(SeasonVoter::EDIT, subject: 'candidate')]
#[Route(
'/backoffice/season/{seasonCode:season}/candidate/{candidate}/rename',
name: 'tvdt_backoffice_candidate_rename',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'candidate' => Requirement::UUID],
methods: ['POST'],
)]
public function renameCandidate(Season $season, Candidate $candidate, Request $request): RedirectResponse
{
$name = mb_trim($request->request->getString('name'));
if ('' === $name || mb_strlen($name) > 16) {
$this->addFlash(FlashType::Danger, $this->translator->trans('The candidate name must be between 1 and 16 characters'));
return $this->redirectToRoute('tvdt_backoffice_season_candidates', ['seasonCode' => $season->seasonCode]);
}
$candidate->name = $name;
try {
$this->em->flush();
} catch (UniqueConstraintViolationException) {
$this->addFlash(FlashType::Danger, $this->translator->trans('A candidate with this name already exists in this season'));
return $this->redirectToRoute('tvdt_backoffice_season_candidates', ['seasonCode' => $season->seasonCode]);
}
$this->addFlash(FlashType::Success, $this->translator->trans('Candidate renamed'));
return $this->redirectToRoute('tvdt_backoffice_season_candidates', ['seasonCode' => $season->seasonCode]);
}
#[IsCsrfTokenValid('delete_candidate')]
#[IsGranted(SeasonVoter::DELETE, subject: 'candidate')]
#[Route(
'/backoffice/season/{seasonCode:season}/candidate/{candidate}/delete',
name: 'tvdt_backoffice_candidate_delete',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'candidate' => Requirement::UUID],
methods: ['POST'],
)]
public function deleteCandidate(Season $season, Candidate $candidate): RedirectResponse
{
$this->candidateRepository->deleteCandidate($candidate);
$this->addFlash(FlashType::Success, $this->translator->trans('Candidate deleted'));
return $this->redirectToRoute('tvdt_backoffice_season_candidates', ['seasonCode' => $season->seasonCode]);
}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/add-quiz',
@@ -1,214 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Controller\Backoffice;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Safe\DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
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\Helpers\FilenameSanitizer;
use Tvdt\Repository\UserRepository;
use Tvdt\Security\EmailVerifier;
use Tvdt\Service\DataExportService;
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,
private readonly DataExportService $dataExportService,
) {}
#[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');
}
#[Route('/backoffice/settings/download-data', name: 'tvdt_backoffice_settings_download_data', methods: ['GET'])]
public function downloadData(): Response
{
if (!$this->authenticatedUser->isVerified) {
$this->addFlash(FlashType::Warning, $this->translator->trans('Please confirm your email address before downloading your data.'));
return $this->redirectToRoute('tvdt_backoffice_settings');
}
$zipPath = $this->dataExportService->exportForUser($this->authenticatedUser);
$filename = \sprintf(
'tijd-voor-de-test-data-%s-%s.zip',
FilenameSanitizer::sanitize($this->authenticatedUser->email),
new DateTimeImmutable()->format('Y-m-d_H-i-s'),
);
$response = new BinaryFileResponse($zipPath);
$response->deleteFileAfterSend(true);
$response->headers->set('Content-Type', 'application/zip');
$response->headers->set(
'Content-Disposition',
HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename),
);
return $response;
}
#[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),
]);
}
}
+15 -3
View File
@@ -5,11 +5,14 @@ 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;
@@ -23,7 +26,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 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 LoggerInterface $logger, private readonly UserRepository $userRepository, private readonly EntityManagerInterface $entityManager) {}
#[Route('/register', name: 'tvdt_register')]
public function register(
@@ -46,8 +49,17 @@ final class RegistrationController extends AbstractController
$this->entityManager->persist($user);
$this->entityManager->flush();
// generate a signed url and email it to the user
$this->emailVerifier->sendDefaultConfirmation($user);
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());
}
$response = $this->security->login($user, 'form_login', 'main');
\assert($response instanceof Response);
-147
View File
@@ -1,147 +0,0 @@
<?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');
}
}
-59
View File
@@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Controller;
use Safe\DateTimeImmutable;
use Safe\Exceptions\DatetimeException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/** Serves well-known URIs (https://www.rfc-editor.org/rfc/rfc8615). */
final class WellKnownController extends AbstractController
{
public function __construct(
#[Autowire(env: 'default::BUILD_TIME')]
private readonly ?string $buildTime,
#[Autowire(env: 'APP_ENV')]
private readonly string $appEnv,
) {}
/** @see https://w3c.github.io/webappsec-change-password-url/ */
#[Route('/.well-known/change-password', name: 'tvdt_well_known_change_password', methods: ['GET'])]
public function changePassword(): RedirectResponse
{
return $this->redirectToRoute('tvdt_backoffice_settings');
}
/**
* @see https://www.rfc-editor.org/rfc/rfc9116
*
* @throws DatetimeException
* @throws \Exception
*/
#[Route('/.well-known/security.txt', name: 'tvdt_well_known_security_txt', methods: ['GET'])]
public function securityTxt(): Response
{
// One year after the container build, so the file goes stale when deployments stop.
// In prod the build arg must be set; falling back to 'now' would renew Expires on every request,
// defeating the go-stale purpose. In dev/test 'now' is fine — no build bake happens there.
if ((null === $this->buildTime || '' === $this->buildTime) && 'prod' === $this->appEnv) {
throw new \LogicException('BUILD_TIME env var must be set in production (baked in during Docker build).');
}
$buildTime = (null !== $this->buildTime && '' !== $this->buildTime) ? $this->buildTime : 'now';
$expires = new DateTimeImmutable($buildTime)->modify('+1 year')->format(\DATE_RFC3339);
$content = <<<TXT
Contact: https://github.com/MarijnDoeve/TijdVoorDeTest/security/advisories/new
Expires: {$expires}
Preferred-Languages: nl, en
TXT;
return new Response($content, headers: ['Content-Type' => 'text/plain; charset=UTF-8']);
}
}
-31
View File
@@ -9,10 +9,6 @@ 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;
@@ -74,33 +70,6 @@ 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();
}
}
-7
View File
@@ -54,13 +54,6 @@ class Question implements \Stringable
return $this;
}
public function removeAnswer(Answer $answer): static
{
$this->answers->removeElement($answer);
return $this;
}
public function __toString(): string
{
return $this->question ?? '';
-36
View File
@@ -1,36 +0,0 @@
<?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;
}
}
-4
View File
@@ -21,10 +21,6 @@ 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')]
-1
View File
@@ -43,7 +43,6 @@ class BankQuestionFormType extends AbstractType
'multiple' => true,
'expanded' => true,
'required' => false,
'choice_attr' => static fn (QuestionLabel $label): array => ['data-colour' => $label->colour->value],
'query_builder' => static fn (QuestionLabelRepository $repository): QueryBuilder => $repository
->createQueryBuilder('l')
->where('l.season = :season')
-44
View File
@@ -1,44 +0,0 @@
<?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([]);
}
}
-54
View File
@@ -1,54 +0,0 @@
<?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([]);
}
}
-70
View File
@@ -1,70 +0,0 @@
<?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([]);
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ class RegistrationFormType extends AbstractType
'mapped' => false,
'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'),
new Length(min: 8, max: 4096, minMessage: 'Your password should be at least {{ limit }} characters'),
],
'translation_domain' => false,
])
-36
View File
@@ -1,36 +0,0 @@
<?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([]);
}
}
-18
View File
@@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Helpers;
use Symfony\Component\String\Slugger\AsciiSlugger;
class FilenameSanitizer
{
/** Slugs user-supplied text (e.g. a season/quiz name) into a string safe to use as a zip entry path segment or a downloaded filename. */
public static function sanitize(string $value): string
{
$slug = new AsciiSlugger()->slug($value)->toString();
return '' === $slug ? 'unnamed' : $slug;
}
}
-6
View File
@@ -19,12 +19,6 @@ class CandidateRepository extends ServiceEntityRepository
parent::__construct($registry, Candidate::class);
}
public function deleteCandidate(Candidate $candidate): void
{
$this->getEntityManager()->remove($candidate);
$this->getEntityManager()->flush();
}
public function getCandidateByHash(Season $season, string $hash): ?Candidate
{
try {
@@ -1,31 +0,0 @@
<?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);
}
}
-94
View File
@@ -5,15 +5,9 @@ declare(strict_types=1);
namespace Tvdt\Repository;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\Elimination;
use Tvdt\Entity\GivenAnswer;
use Tvdt\Entity\QuizCandidate;
use Tvdt\Entity\Season;
use Tvdt\Entity\User;
/** @extends ServiceEntityRepository<User> */
@@ -34,94 +28,6 @@ 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);
$bankQuestionIds = [];
foreach ($user->seasons->toArray() as $season) {
if (1 === $season->owners->count()) {
$this->purgeSoftDeletableData($em, $season);
array_push($bankQuestionIds, ...$this->bankQuestionIds($season));
$em->remove($season);
continue;
}
$season->removeOwner($user);
}
$em->remove($user);
$em->flush();
// Gedmo\Loggable writes its own "removed" log entry as part of the flush above, so the
// audit-log purge must happen after — purging first would just leave that final row behind.
$this->purgeBankQuestionAuditLog($em, $bankQuestionIds);
});
}
/**
* QuizCandidate, GivenAnswer, and Elimination are Gedmo\SoftDeleteable, so cascading their
* removal through the season/quiz/candidate relations only sets deletedAt — it never removes
* the row. That leaves personal data behind indefinitely and, since Candidate/Answer are hard
* deleted via orphanRemoval, it also breaks their foreign keys and rolls back the whole
* deletion. Bulk DQL deletes bypass the Gedmo listener and physically remove these rows first.
*/
private function purgeSoftDeletableData(EntityManagerInterface $em, Season $season): void
{
foreach ([QuizCandidate::class, GivenAnswer::class, Elimination::class] as $class) {
$em->createQuery(<<<DQL
delete from {$class} e
where e.quiz in (select q from Tvdt\Entity\Quiz q where q.season = :season)
DQL)
->setParameter('season', $season)
->execute();
}
}
/** @return list<string> */
private function bankQuestionIds(Season $season): array
{
return array_values(array_map(
static fn (BankQuestion $bankQuestion): string => $bankQuestion->id->toString(),
$season->bankQuestions->toArray(),
));
}
/**
* Gedmo\Loggable audit rows (ext_log_entries) aren't foreign-keyed to the entity they log —
* object_id is a plain string — so removing a BankQuestion never cleans up its history, and
* the editor's username/email would otherwise remain in those rows forever.
*
* @param list<string> $bankQuestionIds
*/
private function purgeBankQuestionAuditLog(EntityManagerInterface $em, array $bankQuestionIds): void
{
if ([] === $bankQuestionIds) {
return;
}
$em->createQuery(<<<'DQL'
delete from Tvdt\Entity\LogEntry l
where l.objectClass = :class and l.objectId in (:ids)
DQL)
->setParameter('class', BankQuestion::class)
->setParameter('ids', $bankQuestionIds)
->execute();
}
public function makeAdmin(string $email): void
{
$user = $this->findOneBy(['email' => $email]);
-23
View File
@@ -5,12 +5,10 @@ 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;
@@ -20,29 +18,8 @@ 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
{
-48
View File
@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Security;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Event\LogoutEvent;
final readonly class LogoutRedirectListener implements EventSubscriberInterface
{
private const array BLOCKED_TARGET_PREFIXES = ['/backoffice', '/elimination'];
public function __construct(private UrlGeneratorInterface $urlGenerator) {}
public function onLogout(LogoutEvent $event): void
{
$target = $event->getRequest()->query->get('target');
if (\is_string($target) && $this->isAllowedTarget($target)) {
$event->setResponse(new RedirectResponse($target));
return;
}
$event->setResponse(new RedirectResponse($this->urlGenerator->generate('tvdt_quiz_select_season')));
}
public static function getSubscribedEvents(): array
{
// Must run before Symfony's DefaultLogoutListener (priority 64), which only
// sets a response if none is set yet.
return [
LogoutEvent::class => ['onLogout', 128],
];
}
private function isAllowedTarget(string $target): bool
{
if (!str_starts_with($target, '/') || str_starts_with($target, '//')) {
return false;
}
return !array_any(self::BLOCKED_TARGET_PREFIXES, static fn (string $prefix): bool => str_starts_with($target, $prefix));
}
}
-444
View File
@@ -1,444 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Service;
use Doctrine\ORM\EntityManagerInterface;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Writer;
use Safe\Exceptions\FilesystemException;
use Tvdt\Dto\Result;
use Tvdt\Entity\BankQuestionUsage;
use Tvdt\Entity\Candidate;
use Tvdt\Entity\Question;
use Tvdt\Entity\QuestionLabel;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\Season;
use Tvdt\Entity\User;
use Tvdt\Helpers\FilenameSanitizer;
use Tvdt\Repository\QuizRepository;
use function Safe\tempnam;
use function Safe\unlink;
/** Builds a GDPR data-portability export (a zip of xlsx files) for everything owned by a single user. */
class DataExportService
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly QuizSpreadsheetService $quizSpreadsheetService,
private readonly QuizRepository $quizRepository,
) {}
/** @throws FilesystemException @return string path to a temp zip file; caller is responsible for removing it */
public function exportForUser(User $user): string
{
$filter = $this->entityManager->getFilters();
$filter->disable('softdeleteable');
try {
return $this->buildZip($user);
} finally {
$filter->enable('softdeleteable');
}
}
private function buildZip(User $user): string
{
$zipPath = tempnam(sys_get_temp_dir(), 'tvdt_export_');
$tempXlsxFiles = [];
$zip = new \ZipArchive();
if (true !== $zip->open($zipPath, \ZipArchive::OVERWRITE)) {
unlink($zipPath);
throw new \RuntimeException('Could not create the export zip archive.');
}
try {
$profilePath = $this->writeToTempFile($this->buildProfileWorkbook($user));
$tempXlsxFiles[] = $profilePath;
$zip->addFile($profilePath, 'profile.xlsx');
foreach ($user->seasons as $season) {
$folder = FilenameSanitizer::sanitize($season->seasonCode.'-'.$season->name).'/';
foreach ($season->quizzes as $quiz) {
$quizPath = $this->writeToTempFile($this->buildQuizWorkbook($quiz));
$tempXlsxFiles[] = $quizPath;
$zip->addFile($quizPath, $folder.FilenameSanitizer::sanitize($quiz->name).'.xlsx');
}
$candidatesPath = $this->writeToTempFile($this->buildCandidatesWorkbook($season));
$tempXlsxFiles[] = $candidatesPath;
$zip->addFile($candidatesPath, $folder.'candidates.xlsx');
$questionBankPath = $this->writeToTempFile($this->buildQuestionBankWorkbook($season));
$tempXlsxFiles[] = $questionBankPath;
$zip->addFile($questionBankPath, $folder.'question-bank.xlsx');
}
if (!$zip->close()) {
throw new \RuntimeException('Could not finalize the export zip archive.');
}
} catch (\Throwable $throwable) {
unlink($zipPath);
throw $throwable;
} finally {
foreach ($tempXlsxFiles as $tempXlsxFile) {
unlink($tempXlsxFile);
}
}
return $zipPath;
}
private function buildProfileWorkbook(User $user): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$account = $spreadsheet->getActiveSheet();
$account->setTitle('Account');
$account->getStyle('A:A')->getFont()->setBold(true);
$account->fromArray([
['Email', $user->email],
['Roles', implode(', ', $user->getRoles())],
['Email verified', $user->isVerified ? 'Yes' : 'No'],
['Account ID', $user->id->toString()],
], null, 'A1');
$account->getColumnDimension('A')->setAutoSize(true);
$account->getColumnDimension('B')->setAutoSize(true);
$seasons = $spreadsheet->createSheet();
$seasons->setTitle('Seasons');
$seasons->fromArray(['Season', 'Season code', 'Quizzes', 'Candidates', 'Shared with other owners'], null, 'A1');
$seasons->getStyle('1:1')->getFont()->setBold(true);
$row = 2;
foreach ($user->seasons as $season) {
$seasons->fromArray([
$season->name,
$season->seasonCode,
$season->quizzes->count(),
$season->candidates->count(),
$season->owners->count() > 1 ? 'Yes' : 'No',
], null, 'A'.$row);
++$row;
}
foreach (['A', 'B', 'C', 'D', 'E'] as $column) {
$seasons->getColumnDimension($column)->setAutoSize(true);
}
$spreadsheet->setActiveSheetIndex(0);
return $spreadsheet;
}
private function buildQuizWorkbook(Quiz $quiz): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$info = $spreadsheet->getActiveSheet();
$info->setTitle('Quiz info');
$this->fillQuizInfoSheet($info, $quiz);
$questions = $spreadsheet->createSheet();
$questions->setTitle('Questions');
$this->quizSpreadsheetService->fillQuestionsSheet($questions, $quiz);
$rawAnswers = $spreadsheet->createSheet();
$rawAnswers->setTitle('Raw answers');
$this->fillRawAnswersSheet($rawAnswers, $quiz);
$results = $spreadsheet->createSheet();
$results->setTitle('Results');
$this->fillResultsSheet($results, $quiz);
$eliminations = $spreadsheet->createSheet();
$eliminations->setTitle('Eliminations');
$this->fillEliminationsSheet($eliminations, $quiz);
$spreadsheet->setActiveSheetIndex(0);
return $spreadsheet;
}
private function fillQuizInfoSheet(Worksheet $sheet, Quiz $quiz): void
{
$disabledQuestions = $quiz->questions
->filter(static fn (Question $question): bool => !$question->enabled)
->map(static fn (Question $question): string => $question->question)
->toArray();
$sheet->getStyle('A:A')->getFont()->setBold(true);
$sheet->fromArray([
['Quiz name', $quiz->name],
['Number of dropouts', $quiz->dropouts],
['Finalized', $quiz->isFinalized ? 'Yes' : 'No'],
['Finalized at', $quiz->finalizedAt?->format(\DateTimeInterface::ATOM) ?? ''],
['Disabled questions', implode(', ', $disabledQuestions)],
], null, 'A1');
$sheet->getColumnDimension('A')->setAutoSize(true);
$sheet->getColumnDimension('B')->setAutoSize(true);
}
private function fillResultsSheet(Worksheet $sheet, Quiz $quiz): void
{
$sheet->fromArray(['Candidate', 'Correct answers', 'Corrections', 'Penalty (s)', 'Score', 'Time', 'Started', 'Active', 'Deleted'], null, 'A1');
$sheet->getStyle('1:1')->getFont()->setBold(true);
/** @var array<string, Result> $scoresByCandidateId */
$scoresByCandidateId = [];
foreach ($this->quizRepository->getScores($quiz) as $result) {
$scoresByCandidateId[$result->id->toString()] = $result;
}
$row = 2;
foreach ($quiz->candidateData as $quizCandidate) {
$candidate = $quizCandidate->candidate;
$result = $scoresByCandidateId[$candidate->id->toString()] ?? null;
$sheet->fromArray([
$candidate->name,
$result?->correct,
$result?->corrections,
$result?->penaltySeconds,
$result?->score,
$result instanceof Result ? $result->time->format('%i:%S') : null,
$quizCandidate->started?->format(\DateTimeInterface::ATOM),
$quizCandidate->active ? 'Yes' : 'No',
$quizCandidate->getDeletedAt()?->format(\DateTimeInterface::ATOM) ?? '',
], null, 'A'.$row);
++$row;
}
foreach (range('A', 'I') as $column) {
$sheet->getColumnDimension($column)->setAutoSize(true);
}
}
/** Raw crosstab: one row per candidate, one column per question, cell = the answer text they gave. */
private function fillRawAnswersSheet(Worksheet $sheet, Quiz $quiz): void
{
/** @var list<Question> $questions */
$questions = $quiz->questions->toArray();
$header = ['Candidate'];
foreach ($questions as $question) {
$header[] = $question->question;
}
$sheet->fromArray($header, null, 'A1');
$sheet->getStyle('1:1')->getFont()->setBold(true);
$sheet->getStyle('1:1')->getAlignment()->setWrapText(true);
/** @var array<string, array<string, string>> $answersByCandidateAndQuestion */
$answersByCandidateAndQuestion = [];
foreach ($questions as $question) {
foreach ($question->answers as $answer) {
foreach ($answer->givenAnswers as $givenAnswer) {
$candidateId = $givenAnswer->candidate->id->toString();
$answersByCandidateAndQuestion[$candidateId][$question->id->toString()] = $answer->text;
}
}
}
$row = 2;
foreach ($quiz->candidateData as $quizCandidate) {
$candidate = $quizCandidate->candidate;
$line = [$candidate->name];
foreach ($questions as $question) {
$line[] = $answersByCandidateAndQuestion[$candidate->id->toString()][$question->id->toString()] ?? '';
}
$sheet->fromArray($line, null, 'A'.$row);
++$row;
}
$lastColumnIndex = 1 + \count($questions);
foreach (range('A', Coordinate::stringFromColumnIndex($lastColumnIndex)) as $column) {
$sheet->getColumnDimension($column)->setWidth(30);
$sheet->getStyle($column.':'.$column)->getAlignment()->setWrapText(true);
}
}
private function fillEliminationsSheet(Worksheet $sheet, Quiz $quiz): void
{
/** @var list<Candidate> $candidates */
$candidates = $quiz->season->candidates->toArray();
$header = ['Prepared at', 'Deleted'];
foreach ($candidates as $candidate) {
$header[] = $candidate->name;
}
$sheet->fromArray($header, null, 'A1');
$sheet->getStyle('1:1')->getFont()->setBold(true);
$row = 2;
foreach ($quiz->eliminations as $elimination) {
$line = [
$elimination->getCreatedAt()?->format(\DateTimeInterface::ATOM) ?? '',
$elimination->getDeletedAt()?->format(\DateTimeInterface::ATOM) ?? '',
];
foreach ($candidates as $candidate) {
$line[] = $elimination->getScreenColour($candidate->name) ?? '';
}
$sheet->fromArray($line, null, 'A'.$row);
++$row;
}
foreach (range('A', Coordinate::stringFromColumnIndex(2 + \count($candidates))) as $column) {
$sheet->getColumnDimension($column)->setAutoSize(true);
}
}
private function buildCandidatesWorkbook(Season $season): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$candidatesSheet = $spreadsheet->getActiveSheet();
$candidatesSheet->setTitle('Candidates');
$candidatesSheet->fromArray(['Name'], null, 'A1');
$candidatesSheet->getStyle('1:1')->getFont()->setBold(true);
$row = 2;
foreach ($season->candidates as $candidate) {
$candidatesSheet->fromArray([$candidate->name], null, 'A'.$row);
++$row;
}
$candidatesSheet->getColumnDimension('A')->setAutoSize(true);
$infoSheet = $spreadsheet->createSheet();
$infoSheet->setTitle('Season info');
$infoSheet->getStyle('A:A')->getFont()->setBold(true);
$infoSheet->fromArray([
['Season name', $season->name],
['Season code', $season->seasonCode],
['Number of quizzes', $season->quizzes->count()],
['Number of candidates', $season->candidates->count()],
['Active quiz', $season->activeQuiz instanceof Quiz ? $season->activeQuiz->name : ''],
['Show numbers', $season->settings?->showNumbers ? 'Yes' : 'No'],
['Confirm answers', $season->settings?->confirmAnswers ? 'Yes' : 'No'],
['Shared with other owners', $season->owners->count() > 1 ? 'Yes' : 'No'],
], null, 'A1');
$infoSheet->getColumnDimension('A')->setAutoSize(true);
$infoSheet->getColumnDimension('B')->setAutoSize(true);
$spreadsheet->setActiveSheetIndex(0);
return $spreadsheet;
}
private function buildQuestionBankWorkbook(Season $season): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$questions = $spreadsheet->getActiveSheet();
$questions->setTitle('Questions');
$this->fillBankQuestionsSheet($questions, $season);
$labels = $spreadsheet->createSheet();
$labels->setTitle('Labels');
$this->fillQuestionLabelsSheet($labels, $season);
$spreadsheet->setActiveSheetIndex(0);
return $spreadsheet;
}
private function fillBankQuestionsSheet(Worksheet $sheet, Season $season): void
{
$metaColumns = ['Question', 'Reusable', 'Complete for quiz', 'Labels', 'Used in quizzes'];
$sheet->fromArray($metaColumns, null, 'A1');
$sheet->getStyle('1:1')->getFont()->setBold(true);
$answerStartColumnIndex = \count($metaColumns);
$maxAnswers = 0;
$row = 2;
foreach ($season->bankQuestions as $bankQuestion) {
$labels = implode(', ', array_map(
static fn (QuestionLabel $label): string => $label->name,
$bankQuestion->labels->toArray(),
));
$usedInQuizzes = implode(', ', array_map(
static fn (BankQuestionUsage $usage): string => $usage->quiz->name,
$bankQuestion->usages->toArray(),
));
$sheet->fromArray([
$bankQuestion->question,
$bankQuestion->reusable ? 'Yes' : 'No',
$bankQuestion->isCompleteForQuiz ? 'Yes' : 'No',
$labels,
$usedInQuizzes,
], null, 'A'.$row);
$col = 0;
foreach ($bankQuestion->answers as $answer) {
$sheet->setCellValue(Coordinate::stringFromColumnIndex($answerStartColumnIndex + 1 + 2 * $col).$row, $answer->text);
$sheet->setCellValue(Coordinate::stringFromColumnIndex($answerStartColumnIndex + 2 + 2 * $col).$row, $answer->isRightAnswer);
++$col;
}
$maxAnswers = max($maxAnswers, $col);
++$row;
}
for ($i = 0; $i < $maxAnswers; ++$i) {
$answerCol = Coordinate::stringFromColumnIndex($answerStartColumnIndex + 1 + 2 * $i);
$correctCol = Coordinate::stringFromColumnIndex($answerStartColumnIndex + 2 + 2 * $i);
$sheet->setCellValue($answerCol.'1', 'Answer '.($i + 1));
$sheet->setCellValue($correctCol.'1', 'Correct');
}
$lastColumnIndex = $answerStartColumnIndex + max(1, 2 * $maxAnswers);
foreach (range('A', Coordinate::stringFromColumnIndex($lastColumnIndex)) as $column) {
$sheet->getColumnDimension($column)->setAutoSize(true);
}
}
private function fillQuestionLabelsSheet(Worksheet $sheet, Season $season): void
{
$sheet->fromArray(['Name', 'Colour', 'Slug'], null, 'A1');
$sheet->getStyle('1:1')->getFont()->setBold(true);
$row = 2;
foreach ($season->questionLabels as $label) {
$sheet->fromArray([$label->name, $label->colour->name, $label->slug], null, 'A'.$row);
++$row;
}
foreach (['A', 'B', 'C'] as $column) {
$sheet->getColumnDimension($column)->setAutoSize(true);
}
}
/** @throws FilesystemException */
private function writeToTempFile(Spreadsheet $spreadsheet): string
{
$path = tempnam(sys_get_temp_dir(), 'tvdt_export_sheet_');
try {
new Writer\Xlsx($spreadsheet)->save($path);
} catch (\Throwable $throwable) {
unlink($path);
throw $throwable;
}
return $path;
}
}
+4 -8
View File
@@ -7,7 +7,6 @@ namespace Tvdt\Service;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Reader;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Writer;
use Symfony\Component\HttpFoundation\File\File;
use Tvdt\Entity\Answer;
@@ -118,13 +117,8 @@ class QuizSpreadsheetService
public function quizToXlsx(Quiz $quiz): \Closure
{
$spreadsheet = new Spreadsheet();
$this->fillQuestionsSheet($spreadsheet->getActiveSheet(), $quiz);
$sheet = $spreadsheet->getActiveSheet();
return $this->toXlsx($spreadsheet);
}
public function fillQuestionsSheet(Worksheet $sheet, Quiz $quiz): void
{
// Write data rows first so we know the maximum answer count.
$maxAnswers = 0;
$row = 2;
@@ -159,9 +153,11 @@ class QuizSpreadsheetService
$sheet->setCellValue($correctCol.'1', 'Correct');
$sheet->getColumnDimension($correctCol)->setAutoSize(true);
}
return $this->toXlsx($spreadsheet);
}
public function toXlsx(Spreadsheet $spreadsheet): \Closure
private function toXlsx(Spreadsheet $spreadsheet): \Closure
{
$writer = new Writer\Xlsx($spreadsheet);
-12
View File
@@ -356,18 +356,6 @@
"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"
},
@@ -1,4 +1,3 @@
<h6>Kandidaten</h6>
<p>Dit zijn de spelers van dit seizoen. Voeg alle deelnemers toe voordat je de eerste test start, kandidaten worden automatisch aan nieuwe testen gekoppeld.</p>
<p>Namen zijn vrij in te voeren, gebruik dezelfde schrijfwijze die je in het spel gebruikt.</p>
<p>Gebruik het potlood-icoon om een kandidaat te hernoemen en het prullenbak-icoon om er een te verwijderen. Verwijderen gooit ook alle gegeven antwoorden van die kandidaat weg.</p>
@@ -1,4 +1,3 @@
<h6>Seizoensinstellingen</h6>
<p>Pas hier de weergave-instellingen van dit seizoen aan.</p>
<p><strong>Nummers tonen:</strong> toont vraagnummers tijdens de test. <strong>Antwoord bevestigen:</strong> vraagt kandidaten om hun antwoord te bevestigen voordat ze doorgaan.</p>
<p><strong>Seizoenscode:</strong> de code waarmee kandidaten dit seizoen kunnen vinden. Genereer een nieuwe code als de huidige per ongeluk gedeeld is, de oude code werkt daarna niet meer.</p>
+1 -3
View File
@@ -4,7 +4,7 @@
{% block body %}
<form method="post">
<h3 class="mb-3">{{ 'Please sign in'|trans }}</h3>
<h1 class="py-2 h3 mb-3 font-weight-normal">{{ 'Please sign in'|trans }}</h1>
<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,7 +31,5 @@
</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 %}
-4
View File
@@ -23,10 +23,6 @@
</li>
</ul>
<ul class="navbar-nav mb-auto me-2 me-lg-0">
<li class="nav-item">
<a class="nav-link{% if 'tvdt_backoffice_settings' == app.current_route() %} active{% endif %}"
href="{{ path('tvdt_backoffice_settings') }}">{{ 'Settings'|trans }}</a>
</li>
<li class="nav-item">
<a class="nav-link"
href="{{ path('tvdt_login_logout') }}">{{ 'Logout'|trans }}</a>
@@ -1,13 +1,10 @@
{% macro answer_row(answerForm) %}
<div class="d-flex align-items-center gap-2 mb-2" data-collection-item
data-action="dragover->bo--form-collection#dragOver dragleave->bo--form-collection#dragLeave drop->bo--form-collection#drop">
<div class="d-flex align-items-center gap-2 mb-2" data-collection-item>
{{ form_widget(answerForm.ordering) }}
<span class="text-muted" data-drag-handle style="cursor: grab" title="{{ 'Drag to reorder'|trans }}"
draggable="true"
data-action="dragstart->bo--form-collection#dragStart dragend->bo--form-collection#dragEnd"><i class="bi bi-grip-vertical"></i></span>
<span class="text-muted" data-drag-handle style="cursor: grab" title="{{ 'Drag to reorder'|trans }}"><i class="bi bi-grip-vertical"></i></span>
<div class="flex-grow-1">{{ form_widget(answerForm.text) }}</div>
<div class="d-none">{{ form_widget(answerForm.isRightAnswer) }}</div>
<button type="button"
<button type="button" tabindex="-1"
class="btn btn-sm {{ answerForm.isRightAnswer.vars.checked ? 'btn-success' : 'btn-danger' }}"
title="{{ 'Toggle correct answer'|trans }}"
onclick="var cb=this.closest('[data-collection-item]').querySelector('input[type=checkbox]');cb.checked=!cb.checked;this.classList.toggle('btn-success',cb.checked);this.classList.toggle('btn-danger',!cb.checked);this.querySelector('i').className=cb.checked?'bi bi-check-lg':'bi bi-x-lg'">
@@ -3,30 +3,13 @@
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
{{ form_row(form.question) }}
{{ form_row(form.reusable) }}
<div class="mb-3">
{{ form_label(form.labels) }}
{{ form_errors(form.labels) }}
{% for labelChoice in form.labels %}
<div class="form-check">
<input type="checkbox" class="form-check-input"
id="{{ labelChoice.vars.id }}"
name="{{ labelChoice.vars.full_name }}"
value="{{ labelChoice.vars.value }}"
{% if labelChoice.vars.checked %}checked="checked"{% endif %}>
<label class="form-check-label" for="{{ labelChoice.vars.id }}">
<span class="badge rounded-pill text-bg-{{ labelChoice.vars.attr['data-colour'] }}">{{ labelChoice.vars.label }}</span>
</label>
</div>
{% endfor %}
{% do form.labels.setRendered %}
</div>
{{ form_row(form.labels) }}
<div data-controller="bo--form-collection"
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
{{ form_label(form.answers) }}
{{ form_errors(form.answers) }}
<div data-bo--form-collection-target="collection"
data-action="input->bo--form-collection#autoExpand">
<div data-bo--form-collection-target="collection">
{% for answerForm in form.answers %}
{{ macros.answer_row(answerForm) }}
{% endfor %}
@@ -5,29 +5,12 @@
<div class="modal-body">
{{ form_row(form.question) }}
{{ form_row(form.reusable) }}
<div class="mb-3">
{{ form_label(form.labels) }}
{{ form_errors(form.labels) }}
{% for labelChoice in form.labels %}
<div class="form-check">
<input type="checkbox" class="form-check-input"
id="{{ labelChoice.vars.id }}"
name="{{ labelChoice.vars.full_name }}"
value="{{ labelChoice.vars.value }}"
{% if labelChoice.vars.checked %}checked="checked"{% endif %}>
<label class="form-check-label" for="{{ labelChoice.vars.id }}">
<span class="badge rounded-pill text-bg-{{ labelChoice.vars.attr['data-colour'] }}">{{ labelChoice.vars.label }}</span>
</label>
</div>
{% endfor %}
{% do form.labels.setRendered %}
</div>
{{ form_row(form.labels) }}
<div data-controller="bo--form-collection"
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
{{ form_label(form.answers) }}
{{ form_errors(form.answers) }}
<div data-bo--form-collection-target="collection"
data-action="input->bo--form-collection#autoExpand">
<div data-bo--form-collection-target="collection">
{% for answerForm in form.answers %}
{{ macros.answer_row(answerForm) }}
{% endfor %}
@@ -7,8 +7,7 @@
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
{{ form_label(form.answers) }}
{{ form_errors(form.answers) }}
<div data-bo--form-collection-target="collection"
data-action="input->bo--form-collection#autoExpand">
<div data-bo--form-collection-target="collection">
{% for answerForm in form.answers %}
{{ macros.answer_row(answerForm) }}
{% endfor %}
@@ -8,8 +8,7 @@
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
{{ form_label(form.answers) }}
{{ form_errors(form.answers) }}
<div data-bo--form-collection-target="collection"
data-action="input->bo--form-collection#autoExpand">
<div data-bo--form-collection-target="collection">
{% for answerForm in form.answers %}
{{ macros.answer_row(answerForm) }}
{% endfor %}
@@ -86,20 +86,19 @@
</div>
<div data-controller="bo--question-list bo--modal"
data-action="turbo:submit-end->bo--modal#frameSubmitEnd"
data-action="turbo:frame-load->bo--modal#frameLoad turbo:submit-end->bo--modal#frameSubmitEnd"
data-bo--question-list-reorder-url-value="{{ path('tvdt_backoffice_quiz_questions_reorder', {seasonCode: season.seasonCode, quiz: quiz.id}) }}"
data-bo--question-list-csrf-value="{{ csrf_token('question_reorder') }}"
data-bo--question-list-can-modify-value="{{ is_granted('QUIZ_MODIFY_CONTENT', quiz) ? 'true' : 'false' }}"
data-bo--question-list-saved-label-value="{{ 'Order saved'|trans }}"
data-bo--question-list-error-label-value="{{ 'Error saving order'|trans }}"
data-bo--question-list-error-hint-value="{{ 'Refresh the page to try again.'|trans }}">
data-bo--question-list-error-label-value="{{ 'Error saving order'|trans }}">
<h4 class="mb-3 d-flex align-items-center gap-2">
{{ 'Questions'|trans }}
<span class="badge d-none fw-normal" style="font-size:.7rem;vertical-align:baseline" data-bo--question-list-target="status"></span>
</h4>
<div data-bo--question-list-target="list"
{% if is_granted('QUIZ_MODIFY_CONTENT', quiz) %}data-action="dragover->bo--question-list#dragOver dragleave->bo--question-list#dragLeave drop->bo--question-list#drop"{% endif %}>
<div data-bo--question-list-target="list">
{%~ for question in quiz.questions ~%}
<div class="card mb-2"
data-bo--question-list-target="item"
@@ -107,9 +106,7 @@
<div class="card-body py-2">
<div class="d-flex align-items-center gap-2">
{% if is_granted('QUIZ_MODIFY_CONTENT', question) %}
<span class="text-muted" style="cursor:grab"
draggable="true"
data-action="dragstart->bo--question-list#dragStart dragend->bo--question-list#dragEnd">
<span class="text-muted" style="cursor:grab" data-drag-handle>
<i class="bi bi-grip-vertical"></i>
</span>
{% endif %}
@@ -138,13 +135,12 @@
</div>
</div>
{% else %}
{{ 'No questions have been added to this quiz yet.'|trans }}
{{ 'EMPTY'|trans }}
{% endfor %}
</div>
<div class="modal fade" tabindex="-1"
data-bo--modal-target="modal"
data-action="hidden.bs.modal->bo--modal#resetDirty"
aria-labelledby="questionEditModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
@@ -152,9 +148,26 @@
<h5 class="modal-title" id="questionEditModalLabel">{{ 'Edit question'|trans }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<turbo-frame id="question-modal-frame"
data-bo--modal-target="frame"
data-action="input->bo--modal#markDirty change->bo--modal#markDirty"></turbo-frame>
<turbo-frame id="question-modal-frame" data-bo--modal-target="frame"></turbo-frame>
</div>
</div>
</div>
<div class="modal fade" tabindex="-1"
data-bo--question-list-target="noticeModal"
aria-labelledby="questionReorderErrorModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="questionReorderErrorModalLabel">{{ 'Could not save order'|trans }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{{ 'The new question order could not be saved. Reordering has been disabled until you refresh the page.'|trans }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Close'|trans }}</button>
</div>
</div>
</div>
</div>
@@ -2,7 +2,7 @@
{% block title %}{{ 'Register'|trans }}{% endblock %}
{% block body %}
<h3 class="mb-3">{{ 'Register'|trans }}</h3>
<h3>{{ 'Register'|trans }}</h3>
{{ form_errors(registrationForm) }}
@@ -4,67 +4,9 @@
<a class="btn btn-sm btn-outline-primary"
href="{{ path('tvdt_backoffice_add_candidates', {seasonCode: season.seasonCode}) }}">{{ 'Add Candidate'|trans }}</a>
</div>
<ul class="list-group mb-3">
<ul class="mb-3">
{% for candidate in season.candidates %}
<li class="list-group-item d-flex align-items-center justify-content-between gap-2">
{{ candidate.name }}
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal"
data-bs-target="#renameCandidate-{{ candidate.id }}"
title="{{ 'Rename'|trans }}"><i class="bi bi-pencil"></i></button>
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal"
data-bs-target="#deleteCandidate-{{ candidate.id }}"
title="{{ 'Delete'|trans }}"><i class="bi bi-trash"></i></button>
</div>
<div class="modal fade" id="renameCandidate-{{ candidate.id }}" data-bs-backdrop="static"
tabindex="-1" aria-labelledby="renameCandidate-{{ candidate.id }}Label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="{{ path('tvdt_backoffice_candidate_rename', {seasonCode: season.seasonCode, candidate: candidate.id}) }}"
method="POST">
<div class="modal-header">
<h1 class="modal-title fs-5" id="renameCandidate-{{ candidate.id }}Label">{{ 'Rename candidate'|trans }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-start">
<input type="hidden" name="_token" value="{{ csrf_token('rename_candidate') }}">
<label class="form-label" for="renameCandidateName-{{ candidate.id }}">{{ 'Name'|trans }}</label>
<input type="text" class="form-control" id="renameCandidateName-{{ candidate.id }}"
name="name" value="{{ candidate.name }}" maxlength="16" required autofocus>
</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-primary">{{ 'Rename'|trans }}</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal fade" id="deleteCandidate-{{ candidate.id }}" data-bs-backdrop="static"
tabindex="-1" aria-labelledby="deleteCandidate-{{ candidate.id }}Label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="deleteCandidate-{{ candidate.id }}Label">{{ 'Please Confirm'|trans }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-start">
{{ 'Are you sure you want to delete this candidate? All their answers will be lost.'|trans }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'No'|trans }}</button>
<form action="{{ path('tvdt_backoffice_candidate_delete', {seasonCode: season.seasonCode, candidate: candidate.id}) }}"
method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('delete_candidate') }}">
<button type="submit" class="btn btn-danger">{{ 'Yes'|trans }}</button>
</form>
</div>
</div>
</div>
</div>
</li>
<li>{{ candidate.name }}</li>
{% else %}
{{ 'No candidates'|trans }}
{% endfor %}
@@ -1,6 +1,6 @@
<div class="row">
<div class="col-md-8 col-12" data-controller="bo--modal"
data-action="turbo:submit-end->bo--modal#frameSubmitEnd">
data-action="turbo:frame-load->bo--modal#frameLoad turbo:submit-end->bo--modal#frameSubmitEnd">
<div class="mb-3">
<button class="btn btn-sm btn-outline-primary"
data-action="click->bo--modal#open"
@@ -129,7 +129,6 @@
<button type="button" class="btn btn-outline-secondary"
data-action="click->bo--modal#open"
data-src="{{ path('tvdt_backoffice_question_bank_edit', {seasonCode: season.seasonCode, bankQuestion: bankQuestion.id}) }}"
data-modal-title="{{ 'Edit question'|trans }}"
title="{{ 'Edit'|trans }}"><i class="bi bi-pencil"></i></button>
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal"
data-bs-target="#deleteBankQuestion-{{ bankQuestion.id }}"
@@ -173,7 +172,6 @@
</table>
<div class="modal fade" tabindex="-1"
data-bo--modal-target="modal"
data-action="hidden.bs.modal->bo--modal#resetDirty"
aria-labelledby="bankQuestionEditModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
@@ -181,9 +179,7 @@
<h5 class="modal-title" id="bankQuestionEditModalLabel">{{ 'Edit question'|trans }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<turbo-frame id="bank-question-modal-frame"
data-bo--modal-target="frame"
data-action="input->bo--modal#markDirty change->bo--modal#markDirty"></turbo-frame>
<turbo-frame id="bank-question-modal-frame" data-bo--modal-target="frame"></turbo-frame>
</div>
</div>
</div>
@@ -1,40 +1,8 @@
<div class="row">
<div class="col-md-6 col-12">
{{ form(form) }}
<hr>
<h4 class="text-danger">{{ 'Season code'|trans }}</h4>
<p>{{ 'The season code is used by candidates to join this season. Regenerating it invalidates the current code, so make sure to share the new one.'|trans }}</p>
<p><strong>{{ 'Current code:'|trans }}</strong> {{ season.seasonCode }}</p>
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#regenerateSeasonCodeModal">
{{ 'Regenerate season code...'|trans }}
</button>
</div>
<div class="col-md-6 col-12">
{{ include('backoffice/help/season_settings.html.twig') }}
</div>
</div>
<div class="modal fade" id="regenerateSeasonCodeModal" data-bs-backdrop="static"
tabindex="-1"
aria-labelledby="regenerateSeasonCodeModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="{{ path('tvdt_backoffice_season_regenerate_code', {seasonCode: season.seasonCode}) }}" method="POST">
<div class="modal-header">
<h1 class="modal-title fs-5" id="regenerateSeasonCodeModalLabel">{{ 'Regenerate season code'|trans }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>{{ 'This invalidates the current season code. Anyone using the old code will no longer be able to join this season.'|trans }}</p>
<input type="hidden" name="_token" value="{{ csrf_token('regenerate_season_code') }}">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
<button type="submit" class="btn btn-danger">{{ 'Regenerate season code'|trans }}</button>
</div>
</form>
</div>
</div>
</div>
@@ -8,13 +8,11 @@
</div>
<div class="list-group mb-3">
{% for quiz in season.quizzes %}
<a class="list-group-item list-group-item-action d-flex align-items-center gap-2{% if season.activeQuiz == quiz %} active{% endif %}"
<a class="list-group-item list-group-item-action{% if season.activeQuiz == quiz %} active{% endif %}"
href="{{ path('tvdt_backoffice_quiz', {seasonCode: season.seasonCode, quiz: quiz.id}) }}">
{{ quiz.name }}
{% 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>
{% if quiz.isFinalized %}
<span class="badge text-bg-success">{{ 'Finalized'|trans }}</span>
{% endif %}
</a>
{% else %}
@@ -1,102 +0,0 @@
{% 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">
<h4>{{ 'Your data'|trans }}</h4>
<p>{{ 'Download an archive of everything stored under your account: your profile, the seasons you own, their quizzes, results and candidates.'|trans }}</p>
{% if not app.user.isVerified %}
<p class="text-warning">{{ 'Confirm your email address to enable this feature.'|trans }}</p>
{% endif %}
<a class="btn btn-primary" href="{{ path('tvdt_backoffice_settings_download_data') }}">{{ 'Download data'|trans }}</a>
</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 %}
-10
View File
@@ -1,13 +1,3 @@
{% extends 'base.html.twig' %}
{% block importmap %}{{ importmap('quiz') }}{% endblock %}
{% block nav %}{{ include('quiz/nav.html.twig') }}{% endblock %}
{% block main %}
<div data-controller="fullscreen">
<button type="button"
class="fullscreen-btn"
data-action="fullscreen#toggle"
aria-label="{{ 'Fullscreen'|trans }}"
title="{{ 'Fullscreen'|trans }}">⛶</button>
{{ parent() }}
</div>
{% endblock %}
+1 -1
View File
@@ -4,7 +4,7 @@
<a href="{{ path('tvdt_backoffice_index') }}" class="btn btn-outline-secondary btn-sm">
{{ 'Backoffice'|trans }}
</a>
<a href="{{ path('tvdt_login_logout', {target: app.request.pathInfo}) }}" class="btn btn-outline-secondary btn-sm">
<a href="{{ path('tvdt_login_logout') }}" class="btn btn-outline-secondary btn-sm">
{{ 'Logout'|trans }}
</a>
{% else %}
@@ -1,16 +0,0 @@
{% 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 %}
-9
View File
@@ -1,9 +0,0 @@
<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>
@@ -1,18 +0,0 @@
{% 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 %}
-12
View File
@@ -1,12 +0,0 @@
{% 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 %}
@@ -1,57 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\BackofficeController;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\User;
#[CoversClass(BackofficeController::class)]
final class BackofficeControllerTest extends WebTestCase
{
public function testExportQuizFilenameIsSanitized(): void
{
$client = self::createClient();
$entityManager = self::getContainer()->get(EntityManagerInterface::class);
$user = $entityManager->getRepository(User::class)->findOneBy(['email' => 'user2@example.org']);
$this->assertInstanceOf(User::class, $user);
$user->isVerified = true;
$entityManager->flush();
$client->loginUser($user);
$quiz = $entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1']);
$this->assertInstanceOf(Quiz::class, $quiz);
$client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
self::assertResponseIsSuccessful();
$disposition = (string) $client->getResponse()->headers->get('Content-Disposition');
$this->assertStringContainsString('filename=Quiz-1.xlsx', $disposition);
$this->assertStringNotContainsString('Quiz 1.xlsx', $disposition);
}
public function testExportQuizRequiresVerifiedEmail(): void
{
$client = self::createClient();
$entityManager = self::getContainer()->get(EntityManagerInterface::class);
$user = $entityManager->getRepository(User::class)->findOneBy(['email' => 'user2@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->assertFalse($user->isVerified);
$client->loginUser($user);
$quiz = $entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1']);
$this->assertInstanceOf(Quiz::class, $quiz);
$client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
self::assertResponseRedirects(\sprintf('/backoffice/season/%s', $quiz->season->seasonCode));
}
}
@@ -10,7 +10,6 @@ use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\QuestionBankController;
use Tvdt\Entity\BankAnswer;
use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\Question;
use Tvdt\Entity\QuestionLabel;
@@ -299,79 +298,6 @@ final class QuestionBankControllerTest extends WebTestCase
$this->assertResponseStatusCodeSame(403);
}
public function testCreateBankQuestionPreservesAnswerOrdering(): void
{
$this->loginAsOwner();
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
$this->assertResponseIsSuccessful();
$token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value');
// Submit 3 answers with non-sequential ordering values.
// The stored ordering field (not the submission index) must dictate retrieval order.
$this->client->request(Request::METHOD_POST, '/backoffice/season/krtek/question-bank/new', [
'bank_question_form' => [
'question' => 'Volgorderingstest nieuwe vraag',
'answers' => [
0 => ['text' => 'Antwoord C', 'isRightAnswer' => '1', 'ordering' => '5'],
1 => ['text' => 'Antwoord A', 'ordering' => '1'],
2 => ['text' => 'Antwoord B', 'ordering' => '3'],
],
'_token' => $token,
],
]);
$this->assertResponseRedirects('/backoffice/season/krtek/question-bank');
$this->entityManager->clear();
$bankQuestion = $this->getBankQuestion('Volgorderingstest nieuwe vraag');
$answers = $bankQuestion->answers->toArray();
$this->assertCount(3, $answers);
// @OrderBy(['ordering' => 'ASC']): ordering 1 → 3 → 5
$this->assertSame('Antwoord A', $answers[0]->text);
$this->assertSame('Antwoord B', $answers[1]->text);
$this->assertSame('Antwoord C', $answers[2]->text);
}
public function testEditBankQuestionPreservesAnswerOrdering(): void
{
$this->loginAsOwner();
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
// Fixture answers in insertion order (all have ordering=0): Brood (correct), Yoghurt, Niks
$url = \sprintf('/backoffice/season/krtek/question-bank/%s/edit', $bankQuestion->id);
$crawler = $this->client->request(Request::METHOD_GET, $url);
$this->assertResponseIsSuccessful();
$token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value');
$answers = $bankQuestion->answers->toArray();
$this->assertCount(3, $answers);
$texts = array_map(static fn (BankAnswer $a): string => $a->text, $answers);
// Assign ordering values: first answer gets 4, second gets 0, third gets 2.
// Expected retrieval order after @OrderBy ASC: index 1 (0) → index 2 (2) → index 0 (4).
$this->client->request(Request::METHOD_POST, $url, [
'bank_question_form' => [
'question' => $bankQuestion->question,
'answers' => [
0 => ['text' => $texts[0], 'isRightAnswer' => '1', 'ordering' => '4'],
1 => ['text' => $texts[1], 'ordering' => '0'],
2 => ['text' => $texts[2], 'ordering' => '2'],
],
'_token' => $token,
],
]);
$this->assertResponseRedirects('/backoffice/season/krtek/question-bank');
$this->entityManager->clear();
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
$reloadedAnswers = $bankQuestion->answers->toArray();
$this->assertCount(3, $reloadedAnswers);
$this->assertSame($texts[1], $reloadedAnswers[0]->text); // ordering=0 → first
$this->assertSame($texts[2], $reloadedAnswers[1]->text); // ordering=2 → second
$this->assertSame($texts[0], $reloadedAnswers[2]->text); // ordering=4 → third
}
public function testAddAndDeleteLabel(): void
{
$this->loginAsOwner();
@@ -1,147 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\QuizQuestionController;
use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\User;
#[CoversClass(QuizQuestionController::class)]
final class QuizQuestionControllerTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = self::createClient();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
}
private function loginAsOwner(): void
{
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
}
private function getQuizByName(string $name): Quiz
{
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
$this->assertInstanceOf(Quiz::class, $quiz);
return $quiz;
}
public function testEditPreservesAnswerOrdering(): void
{
$this->loginAsOwner();
$quiz = $this->getQuizByName('Quiz 2');
$question = null;
foreach ($quiz->questions as $q) {
if ('Is de Krtek een man of een vrouw?' === $q->question) {
$question = $q;
break;
}
}
$this->assertInstanceOf(Question::class, $question);
$answers = $question->answers->toArray();
$this->assertCount(2, $answers);
$firstText = $answers[0]->text;
$secondText = $answers[1]->text;
$url = \sprintf(
'/backoffice/season/krtek/quiz/%s/question/%s/edit',
$quiz->id,
$question->id,
);
$crawler = $this->client->request(Request::METHOD_GET, $url);
$this->assertResponseIsSuccessful();
$token = (string) $crawler->filter('input[name="question_form[_token]"]')->attr('value');
// Submit with ordering values that invert which answer appears first on reload.
// The answer currently at index 0 ($firstText) gets ordering=7,
// the one at index 1 ($secondText) gets ordering=3.
// @OrderBy(['ordering' => 'ASC']) on Question::$answers will return
// $secondText (3) before $firstText (7) after flush+clear.
$this->client->request(Request::METHOD_POST, $url, [
'question_form' => [
'question' => $question->question,
'answers' => [
0 => ['text' => $firstText, 'ordering' => '7'],
1 => ['text' => $secondText, 'ordering' => '3'],
],
'_token' => $token,
],
]);
$this->assertResponseRedirects();
$this->entityManager->clear();
$quiz = $this->getQuizByName('Quiz 2');
$reloadedQuestion = null;
foreach ($quiz->questions as $q) {
if ('Is de Krtek een man of een vrouw?' === $q->question) {
$reloadedQuestion = $q;
break;
}
}
$this->assertInstanceOf(Question::class, $reloadedQuestion);
$reloadedAnswers = $reloadedQuestion->answers->toArray();
$this->assertSame(3, $reloadedAnswers[0]->ordering);
$this->assertSame($secondText, $reloadedAnswers[0]->text);
$this->assertSame(7, $reloadedAnswers[1]->ordering);
$this->assertSame($firstText, $reloadedAnswers[1]->text);
}
public function testReorderQuestionsWithinQuiz(): void
{
$this->loginAsOwner();
$quiz = $this->getQuizByName('Quiz 2');
$originalQuestions = $quiz->questions->toArray();
$this->assertGreaterThanOrEqual(3, \count($originalQuestions));
$originalFirstId = (string) $originalQuestions[0]->id;
$originalLastId = (string) $originalQuestions[\count($originalQuestions) - 1]->id;
$overviewUrl = \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id);
$crawler = $this->client->request(Request::METHOD_GET, $overviewUrl);
$this->assertResponseIsSuccessful();
$csrfToken = $crawler->filter('[data-bo--question-list-csrf-value]')->attr('data-bo--question-list-csrf-value');
$this->assertNotEmpty($csrfToken);
$reversedIds = array_reverse(array_map(static fn (Question $q): string => (string) $q->id, $originalQuestions));
$reorderUrl = \sprintf('/backoffice/season/krtek/quiz/%s/questions/reorder', $quiz->id);
$this->client->request(Request::METHOD_POST, $reorderUrl, [
'_token' => $csrfToken,
'ordering' => $reversedIds,
]);
$this->assertResponseStatusCodeSame(204);
$this->entityManager->clear();
$quiz = $this->getQuizByName('Quiz 2');
$reorderedQuestions = $quiz->questions->toArray();
$this->assertSame($originalLastId, (string) $reorderedQuestions[0]->id);
$this->assertSame($originalFirstId, (string) $reorderedQuestions[\count($reorderedQuestions) - 1]->id);
}
}
@@ -1,159 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\SeasonController;
use Tvdt\Entity\Candidate;
use Tvdt\Entity\Season;
use Tvdt\Entity\User;
#[CoversClass(SeasonController::class)]
final class SeasonControllerTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = self::createClient();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
}
public function testRegenerateSeasonCodeChangesTheCode(): void
{
$oldCode = 'krtek';
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/%s/settings', $oldCode));
self::assertResponseIsSuccessful();
$token = (string) $crawler->filter('form[action*="/regenerate-code"] input[name="_token"]')->first()->attr('value');
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/%s/settings/regenerate-code', $oldCode), [
'_token' => $token,
]);
self::assertResponseRedirects();
$this->entityManager->clear();
$this->assertNotInstanceOf(Season::class, $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => $oldCode]));
$location = (string) $this->client->getResponse()->headers->get('Location');
$this->assertMatchesRegularExpression('#^/backoffice/season/[a-z]{5}/settings$#', $location);
}
public function testRegenerateSeasonCodeIsDeniedForNonOwner(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/settings');
self::assertResponseIsSuccessful();
$token = (string) $crawler->filter('form[action*="/regenerate-code"] input[name="_token"]')->first()->attr('value');
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
$this->client->request(Request::METHOD_POST, '/backoffice/season/krtek/settings/regenerate-code', [
'_token' => $token,
]);
self::assertResponseStatusCodeSame(403);
}
private function getCandidate(string $name): Candidate
{
$candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => $name]);
$this->assertInstanceOf(Candidate::class, $candidate);
return $candidate;
}
private function getCsrfTokenFromCandidatesTab(string $formActionContains): string
{
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/candidates');
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 testRenameCandidate(): void
{
$candidate = $this->getCandidate('Tom');
$token = $this->getCsrfTokenFromCandidatesTab(\sprintf('/candidate/%s/rename', $candidate->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [
'_token' => $token,
'name' => 'Tommy',
]);
self::assertResponseRedirects('/backoffice/season/krtek/candidates');
$this->entityManager->clear();
$renamed = $this->entityManager->getRepository(Candidate::class)->find($candidate->id);
$this->assertInstanceOf(Candidate::class, $renamed);
$this->assertSame('Tommy', $renamed->name);
}
public function testRenameCandidateToExistingNameShowsError(): void
{
$candidate = $this->getCandidate('Tom');
$token = $this->getCsrfTokenFromCandidatesTab(\sprintf('/candidate/%s/rename', $candidate->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [
'_token' => $token,
'name' => 'Claudia',
]);
self::assertResponseRedirects('/backoffice/season/krtek/candidates');
$this->entityManager->clear();
$unchanged = $this->entityManager->getRepository(Candidate::class)->find($candidate->id);
$this->assertInstanceOf(Candidate::class, $unchanged);
$this->assertSame('Tom', $unchanged->name);
}
public function testDeleteCandidate(): void
{
$candidate = $this->getCandidate('Tom');
$candidateId = $candidate->id;
$token = $this->getCsrfTokenFromCandidatesTab(\sprintf('/candidate/%s/delete', $candidate->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/delete', $candidate->id), [
'_token' => $token,
]);
self::assertResponseRedirects('/backoffice/season/krtek/candidates');
$this->entityManager->clear();
$this->assertNotInstanceOf(Candidate::class, $this->entityManager->getRepository(Candidate::class)->find($candidateId));
}
public function testRenameCandidateIsDeniedForNonOwner(): void
{
$candidate = $this->getCandidate('Tom');
$token = $this->getCsrfTokenFromCandidatesTab(\sprintf('/candidate/%s/rename', $candidate->id));
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [
'_token' => $token,
'name' => 'Tommy',
]);
self::assertResponseStatusCodeSame(403);
}
}
@@ -1,396 +0,0 @@
<?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);
}
}
public function testDownloadDataRequiresAuthentication(): void
{
$this->client->restart();
$this->client->request(Request::METHOD_GET, '/backoffice/settings/download-data');
self::assertResponseRedirects();
}
public function testDownloadDataReturnsAZipWithATimestampedAccountFilename(): void
{
$this->markUserVerified('test@example.org');
$this->client->request(Request::METHOD_GET, '/backoffice/settings/download-data');
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('Content-Type', 'application/zip');
$disposition = (string) $this->client->getResponse()->headers->get('Content-Disposition');
$this->assertMatchesRegularExpression(
'/filename=tijd-voor-de-test-data-test-example-org-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.zip/',
$disposition,
);
}
public function testDownloadDataRequiresVerifiedEmail(): void
{
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->assertFalse($user->isVerified);
$this->client->request(Request::METHOD_GET, '/backoffice/settings/download-data');
self::assertResponseRedirects('/backoffice/settings');
}
private function markUserVerified(string $email): void
{
$user = $this->getUserByEmail($email);
$this->assertInstanceOf(User::class, $user);
$user->isVerified = true;
$this->entityManager->flush();
}
}
@@ -1,104 +0,0 @@
<?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!'));
}
}
@@ -1,48 +0,0 @@
<?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);
}
}
-44
View File
@@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Helpers;
use PHPUnit\Framework\TestCase;
use Tvdt\Helpers\FilenameSanitizer;
final class FilenameSanitizerTest extends TestCase
{
public function testReplacesSpacesWithDashes(): void
{
$this->assertSame('Krtek-Weekend', FilenameSanitizer::sanitize('Krtek Weekend'));
}
public function testStripsPathSeparatorsAndTraversal(): void
{
$this->assertSame('etc-passwd', FilenameSanitizer::sanitize('../../etc/passwd'));
$this->assertSame('a-b', FilenameSanitizer::sanitize('a/b'));
$this->assertSame('a-b', FilenameSanitizer::sanitize('a\\b'));
}
public function testStripsControlCharactersAndSpecialSymbols(): void
{
$this->assertSame('Quiz-1-script', FilenameSanitizer::sanitize("Quiz #1 <script>\0"));
}
public function testTransliteratesUnicodeToAscii(): void
{
$this->assertSame('Weird-Name', FilenameSanitizer::sanitize('Wéird Ñame'));
}
public function testTransliteratesAtSignInEmail(): void
{
$this->assertSame('test-example-org', FilenameSanitizer::sanitize('test@example.org'));
}
public function testReturnsUnnamedForEmptyOrFullyStrippedInput(): void
{
$this->assertSame('unnamed', FilenameSanitizer::sanitize(''));
$this->assertSame('unnamed', FilenameSanitizer::sanitize('///'));
}
}
-91
View File
@@ -7,12 +7,6 @@ namespace Tvdt\Tests\Repository;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Tvdt\DataFixtures\TestFixtures;
use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\Elimination;
use Tvdt\Entity\GivenAnswer;
use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\QuizCandidate;
use Tvdt\Repository\UserRepository;
use function PHPUnit\Framework\assertEmpty;
@@ -48,89 +42,4 @@ final class UserRepositoryTest extends DatabaseTestCase
$this->expectException(\InvalidArgumentException::class);
$this->userRepository->makeAdmin('invalid@example.org');
}
/**
* GDPR right-to-erasure: deleting the sole owner of a season must physically remove every
* row tied to it, not merely soft-delete it. QuizCandidate, GivenAnswer, and Elimination are
* all Gedmo\SoftDeleteable, so a naive $em->remove($season) cascade leaves them (or the
* transaction itself) behind. Assertions bypass the softdeleteable filter and read raw SQL,
* since a soft-deleted row would otherwise still be invisible to a filtered ORM query.
*/
public function testDeleteUserHardDeletesQuizCandidateGivenAnswerAndElimination(): void
{
$user = $this->getUserByEmail('sole-owner@example.org');
$season = $this->getSeasonByCode('doomd');
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Doomed Quiz', 'season' => $season]);
$this->assertInstanceOf(Quiz::class, $quiz);
$candidate = $this->getCandidateBySeasonAndName($season, 'Vera');
/** @var Question $question */
$question = $quiz->questions->first();
$rightAnswer = $question->answers->first();
$this->assertNotFalse($rightAnswer);
$this->quizCandidateRepository->createIfNotExist($quiz, $candidate);
$quizCandidate = $this->quizCandidateRepository->findOneBy(['quiz' => $quiz, 'candidate' => $candidate]);
$this->assertInstanceOf(QuizCandidate::class, $quizCandidate);
$givenAnswer = new GivenAnswer($candidate, $quiz, $rightAnswer);
$this->entityManager->persist($givenAnswer);
$elimination = new Elimination($quiz);
$elimination->data = ['Vera' => Elimination::SCREEN_GREEN];
$this->entityManager->persist($elimination);
$this->entityManager->flush();
$quizCandidateId = $quizCandidate->id->toString();
$givenAnswerId = $givenAnswer->id->toString();
$eliminationId = $elimination->id->toString();
$this->userRepository->deleteUser($user);
$this->entityManager->clear();
$connection = $this->entityManager->getConnection();
$this->assertSame(0, (int) $connection->fetchOne('select count(*) from quiz_candidate where id = ?', [$quizCandidateId]));
$this->assertSame(0, (int) $connection->fetchOne('select count(*) from given_answer where id = ?', [$givenAnswerId]));
$this->assertSame(0, (int) $connection->fetchOne('select count(*) from elimination where id = ?', [$eliminationId]));
}
/**
* Gedmo\Loggable writes an audit row (including the editor's username/email) to
* ext_log_entries for every change to a Versioned field. Those rows aren't linked via a
* foreign key (object_id is a plain string), so deleting the season/BankQuestion never
* cleans them up on its own the deleted account's email would otherwise live on forever.
*/
public function testDeleteUserPurgesBankQuestionAuditLogEntries(): void
{
$user = $this->getUserByEmail('sole-owner@example.org');
$season = $this->getSeasonByCode('doomd');
$bankQuestion = new BankQuestion();
$bankQuestion->question = 'Wie is de Krtek eigenlijk?';
$bankQuestion->season = $season;
$this->entityManager->persist($bankQuestion);
$this->entityManager->flush();
$bankQuestionId = $bankQuestion->id->toString();
$connection = $this->entityManager->getConnection();
$logCountBefore = (int) $connection->fetchOne(
'select count(*) from ext_log_entries where object_class = ? and object_id = ?',
[BankQuestion::class, $bankQuestionId],
);
$this->assertGreaterThan(0, $logCountBefore);
$this->userRepository->deleteUser($user);
$this->entityManager->clear();
$logCountAfter = (int) $connection->fetchOne(
'select count(*) from ext_log_entries where object_class = ? and object_id = ?',
[BankQuestion::class, $bankQuestionId],
);
$this->assertSame(0, $logCountAfter);
}
}
@@ -1,71 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Security;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Event\LogoutEvent;
use Tvdt\Security\LogoutRedirectListener;
#[CoversClass(LogoutRedirectListener::class)]
final class LogoutRedirectListenerTest extends TestCase
{
public function testLogoutRedirectsBackToTheGivenTarget(): void
{
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->expects($this->never())->method('generate');
$listener = new LogoutRedirectListener($urlGenerator);
$event = new LogoutEvent(Request::create('/logout?target=/krtek'), null);
$listener->onLogout($event);
$this->assertSame('/krtek', $event->getResponse()?->headers->get('Location'));
}
public function testLogoutWithoutTargetRedirectsToSeasonSelect(): void
{
$listener = new LogoutRedirectListener($this->seasonSelectUrlGenerator());
$event = new LogoutEvent(Request::create('/logout'), null);
$listener->onLogout($event);
$this->assertSame('/', $event->getResponse()?->headers->get('Location'));
}
#[DataProvider('blockedTargetProvider')]
public function testLogoutIgnoresBlockedOrUnsafeTargets(string $target): void
{
$listener = new LogoutRedirectListener($this->seasonSelectUrlGenerator());
$event = new LogoutEvent(Request::create('/logout?target='.urlencode($target)), null);
$listener->onLogout($event);
$this->assertSame('/', $event->getResponse()?->headers->get('Location'));
}
/** @return iterable<string, array{string}> */
public static function blockedTargetProvider(): iterable
{
yield 'backoffice page' => ['/backoffice/season/krtek'];
yield 'elimination page' => ['/elimination/00000000-0000-0000-0000-000000000000'];
yield 'protocol-relative url' => ['//evil.example.org'];
yield 'absolute url' => ['https://evil.example.org'];
}
private function seasonSelectUrlGenerator(): UrlGeneratorInterface&MockObject
{
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->expects($this->once())
->method('generate')
->with('tvdt_quiz_select_season')
->willReturn('/');
return $urlGenerator;
}
}
-286
View File
@@ -1,286 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Service;
use PhpOffice\PhpSpreadsheet\Reader;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PHPUnit\Framework\Attributes\CoversClass;
use Tvdt\Entity\Answer;
use Tvdt\Entity\GivenAnswer;
use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\QuizCandidate;
use Tvdt\Entity\User;
use Tvdt\Service\DataExportService;
use Tvdt\Tests\Repository\DatabaseTestCase;
use function Safe\file_put_contents;
use function Safe\tempnam;
use function Safe\unlink;
#[CoversClass(DataExportService::class)]
final class DataExportServiceTest extends DatabaseTestCase
{
private DataExportService $subject;
/** @var list<string> */
private array $tempFiles = [];
protected function setUp(): void
{
parent::setUp();
$this->subject = self::getContainer()->get(DataExportService::class);
}
protected function tearDown(): void
{
foreach ($this->tempFiles as $path) {
if (file_exists($path)) {
unlink($path);
}
}
parent::tearDown();
}
public function testExportForUserWithNoSeasonsContainsOnlyProfile(): void
{
$zip = $this->openZip($this->getUserByEmail('test@example.org'));
$this->assertSame(1, $zip->numFiles);
$this->assertNotFalse($zip->locateName('profile.xlsx'));
$zip->close();
}
public function testExportForUserIncludesOwnedSeasonsQuizzesAndCandidates(): void
{
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
$names = $this->entryNames($zip);
$this->assertContains('profile.xlsx', $names);
$this->assertContains('krtek-Krtek-Weekend/Quiz-1.xlsx', $names);
$this->assertContains('krtek-Krtek-Weekend/Quiz-2.xlsx', $names);
$this->assertContains('krtek-Krtek-Weekend/candidates.xlsx', $names);
$this->assertContains('krtek-Krtek-Weekend/question-bank.xlsx', $names);
$this->assertContains('bbbbb-Another-Season/candidates.xlsx', $names);
$this->assertContains('bbbbb-Another-Season/question-bank.xlsx', $names);
// Another Season has no quizzes, so no quiz xlsx should be present for it.
foreach ($names as $name) {
$this->assertStringStartsNotWith('bbbbb-Another-Season/Quiz', $name);
}
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
$this->assertIsString($quizContent);
$this->assertSame(['Quiz info', 'Questions', 'Raw answers', 'Results', 'Eliminations'], $this->sheetNames($quizContent));
$candidatesContent = $zip->getFromName('krtek-Krtek-Weekend/candidates.xlsx');
$this->assertIsString($candidatesContent);
$this->assertSame(['Candidates', 'Season info'], $this->sheetNames($candidatesContent));
$questionBankContent = $zip->getFromName('krtek-Krtek-Weekend/question-bank.xlsx');
$this->assertIsString($questionBankContent);
$this->assertSame(['Questions', 'Labels'], $this->sheetNames($questionBankContent));
$zip->close();
}
public function testQuestionBankSheetIncludesBankQuestionsAndUsage(): void
{
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
$questionBankContent = $zip->getFromName('krtek-Krtek-Weekend/question-bank.xlsx');
$this->assertIsString($questionBankContent);
$zip->close();
$rows = $this->loadSheet($questionBankContent, 'Questions')->toArray();
$header = $rows[0];
$dataRows = \array_slice($rows, 1);
$questionIndex = array_search('Question', $header, true);
$reusableIndex = array_search('Reusable', $header, true);
$labelsIndex = array_search('Labels', $header, true);
$usedInQuizzesIndex = array_search('Used in quizzes', $header, true);
$reusableRow = current(array_filter($dataRows, static fn (array $row): bool => 'Wie is de Krtek?' === $row[$questionIndex]));
$this->assertIsArray($reusableRow);
$this->assertSame('Yes', $reusableRow[$reusableIndex]);
$this->assertSame('Finale', $reusableRow[$labelsIndex]);
$usedRow = current(array_filter($dataRows, static fn (array $row): bool => 'Waar sliep de Krtek?' === $row[$questionIndex]));
$this->assertIsArray($usedRow);
$this->assertSame('Quiz 2', $usedRow[$usedInQuizzesIndex]);
$labelRows = $this->loadSheet($questionBankContent, 'Labels')->toArray();
$labelNames = array_column(\array_slice($labelRows, 1), 0);
$this->assertContains('Locatie', $labelNames);
$this->assertContains('Finale', $labelNames);
}
public function testProfileSheetDoesNotContainPasswordHash(): void
{
$user = $this->getUserByEmail('user2@example.org');
$zip = $this->openZip($user);
$profileContent = $zip->getFromName('profile.xlsx');
$this->assertIsString($profileContent);
$zip->close();
$rows = $this->loadSheet($profileContent, 'Account')->toArray();
$flattened = implode(' ', array_merge(...$rows));
$this->assertStringNotContainsString($user->password, $flattened);
}
public function testResultsSheetIncludesSoftDeletedQuizCandidates(): void
{
$season = $this->getSeasonByCode('krtek');
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1', 'season' => $season]);
$this->assertInstanceOf(Quiz::class, $quiz);
$candidate = $this->getCandidateBySeasonAndName($season, 'Claudia');
$quizCandidate = new QuizCandidate($quiz, $candidate);
$this->entityManager->persist($quizCandidate);
$this->entityManager->flush();
$this->entityManager->remove($quizCandidate);
$this->entityManager->flush();
$this->entityManager->clear();
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
$this->assertIsString($quizContent);
$zip->close();
$rows = $this->loadSheet($quizContent, 'Results')->toArray();
$deletedColumnIndex = array_search('Deleted', $rows[0], true);
$this->assertIsInt($deletedColumnIndex);
$hasDeletedRow = array_any(\array_slice($rows, 1), static fn (array $row): bool => null !== $row[$deletedColumnIndex] && '' !== $row[$deletedColumnIndex]);
$this->assertTrue($hasDeletedRow, 'Expected the soft-deleted QuizCandidate to still appear with a Deleted timestamp');
}
public function testRawAnswersSheetShowsCandidatesByQuestionsGrid(): void
{
$season = $this->getSeasonByCode('krtek');
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1', 'season' => $season]);
$this->assertInstanceOf(Quiz::class, $quiz);
$candidate = $this->getCandidateBySeasonAndName($season, 'Claudia');
/** @var Question $firstQuestion */
$firstQuestion = $quiz->questions->first();
$chosenAnswer = $firstQuestion->answers->filter(static fn (Answer $answer): bool => 'Man' === $answer->text)->first();
$this->assertInstanceOf(Answer::class, $chosenAnswer);
$this->quizCandidateRepository->createIfNotExist($quiz, $candidate);
$givenAnswer = new GivenAnswer($candidate, $quiz, $chosenAnswer);
$this->entityManager->persist($givenAnswer);
$this->entityManager->flush();
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
$this->assertIsString($quizContent);
$zip->close();
$rows = $this->loadSheet($quizContent, 'Raw answers')->toArray();
$header = $rows[0];
$this->assertSame('Candidate', $header[0]);
$questionColumnIndex = array_search($firstQuestion->question, $header, true);
$this->assertIsInt($questionColumnIndex);
$claudiaRow = current(array_filter(
\array_slice($rows, 1),
static fn (array $row): bool => 'Claudia' === $row[0],
));
$this->assertIsArray($claudiaRow);
$this->assertSame('Man', $claudiaRow[$questionColumnIndex]);
}
public function testQuizInfoSheetShowsDropoutsFinalizationAndDisabledQuestions(): void
{
$season = $this->getSeasonByCode('krtek');
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1', 'season' => $season]);
$this->assertInstanceOf(Quiz::class, $quiz);
$this->assertTrue($quiz->isFinalized);
/** @var Question $disabledQuestion */
$disabledQuestion = $quiz->questions->first();
$disabledQuestion->enabled = false;
$this->entityManager->flush();
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
$this->assertIsString($quizContent);
$zip->close();
$rows = $this->loadSheet($quizContent, 'Quiz info')->toArray();
$values = [];
foreach ($rows as $row) {
$values[$row[0]] = $row[1];
}
$this->assertSame('Quiz 1', $values['Quiz name']);
$this->assertSame($quiz->dropouts, (int) $values['Number of dropouts']);
$this->assertSame('Yes', $values['Finalized']);
$this->assertNotEmpty($values['Finalized at']);
$this->assertStringContainsString($disabledQuestion->question, (string) $values['Disabled questions']);
}
private function openZip(User $user): \ZipArchive
{
$zipPath = $this->subject->exportForUser($user);
$this->tempFiles[] = $zipPath;
$zip = new \ZipArchive();
$this->assertTrue($zip->open($zipPath));
return $zip;
}
/** @return list<string> */
private function entryNames(\ZipArchive $zip): array
{
$names = [];
for ($i = 0; $i < $zip->numFiles; ++$i) {
$name = $zip->getNameIndex($i);
$this->assertIsString($name);
$names[] = $name;
}
return $names;
}
/** @return list<string> */
private function sheetNames(string $xlsxContent): array
{
$path = $this->createTempPath();
file_put_contents($path, $xlsxContent);
return array_values(new Reader\Xlsx()->load($path)->getSheetNames());
}
private function loadSheet(string $xlsxContent, string $sheetName): Worksheet
{
$path = $this->createTempPath();
file_put_contents($path, $xlsxContent);
$sheet = new Reader\Xlsx()->load($path)->getSheetByName($sheetName);
$this->assertInstanceOf(Worksheet::class, $sheet);
return $sheet;
}
private function createTempPath(): string
{
$path = tempnam(sys_get_temp_dir(), 'tvdt_export_test_');
$this->tempFiles[] = $path;
return $path;
}
}
-54
View File
@@ -1,54 +0,0 @@
<?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 &quot;request password repository&quot; 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>
+10 -262
View File
@@ -5,18 +5,10 @@
<tool tool-id="symfony" tool-name="Symfony"/>
</header>
<body>
<trans-unit id="QcFeGZy" resname="A candidate with this name already exists in this season">
<source>A candidate with this name already exists in this season</source>
<target>Er bestaat al een kandidaat met deze naam in dit seizoen</target>
</trans-unit>
<trans-unit id="VNxXghX" resname="A label with a similar name already exists">
<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>
@@ -81,10 +73,6 @@
<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>
@@ -105,10 +93,6 @@
<source>Are you sure you want to clear all the results? This will also delete all the eliminations.</source>
<target>Weet je zeker dat je de resultaten wilt leegmaken? Dit gooit ook alle eliminaties weg.</target>
</trans-unit>
<trans-unit id="zisWo9d" resname="Are you sure you want to delete this candidate? All their answers will be lost.">
<source>Are you sure you want to delete this candidate? All their answers will be lost.</source>
<target>Weet je zeker dat je deze kandidaat wilt verwijderen? Alle gegeven antwoorden gaan dan verloren.</target>
</trans-unit>
<trans-unit id="8HZ5s3T" resname="Are you sure you want to delete this question from the question bank?">
<source>Are you sure you want to delete this question from the question bank?</source>
<target>Weet je zeker dat je deze vraag uit de vragenbank wilt verwijderen?</target>
@@ -125,10 +109,6 @@
<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>
@@ -153,18 +133,10 @@
<source>Candidate answers saved</source>
<target>Kandidaatantwoorden opgeslagen</target>
</trans-unit>
<trans-unit id="tggdgJl" resname="Candidate deleted">
<source>Candidate deleted</source>
<target>Kandidaat verwijderd</target>
</trans-unit>
<trans-unit id="TiTLBGW" resname="Candidate not found">
<source>Candidate not found</source>
<target>Kandidaat niet gevonden</target>
</trans-unit>
<trans-unit id="QH4e_Ho" resname="Candidate renamed">
<source>Candidate renamed</source>
<target>Kandidaat hernoemd</target>
</trans-unit>
<trans-unit id="6QiGbuz" resname="Candidate status updated">
<source>Candidate status updated</source>
<target>Kandidaatstatus bijgewerkt</target>
@@ -173,18 +145,6 @@
<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>
@@ -205,18 +165,6 @@
<source>Confirm Answers</source>
<target>Bevestig antwoorden</target>
</trans-unit>
<trans-unit id="T1Z6nI1" resname="Confirm your email address to enable this feature.">
<source>Confirm your email address to enable this feature.</source>
<target>Bevestig je e-mailadres om deze functie te kunnen gebruiken.</target>
</trans-unit>
<trans-unit id="QZfvKMx" resname="Confirm your email address to enable this.">
<source>Confirm your email address to enable this.</source>
<target>Bevestig je e-mailadres om dit te gebruiken.</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>
@@ -233,6 +181,10 @@
<source>Could not find candidate with name {name} in elimination.</source>
<target>Kon geen kandidaat vinden met de naam {name} in de eliminatie</target>
</trans-unit>
<trans-unit id="Cn0sav3" resname="Could not save order">
<source>Could not save order</source>
<target>Kon volgorde niet opslaan</target>
</trans-unit>
<trans-unit id="0DvmToq" resname="Create a season">
<source>Create a season</source>
<target>Maak een seizoen aan</target>
@@ -245,26 +197,10 @@
<source>Create an empty quiz and add questions from the question bank.</source>
<target>Maak een lege quiz aan en voeg vragen toe vanuit de vragenbank.</target>
</trans-unit>
<trans-unit id="jrXhJOR" resname="Current code:">
<source>Current code:</source>
<target>Huidige code:</target>
</trans-unit>
<trans-unit id="3leUyoA" resname="Current email address:">
<source>Current email address:</source>
<target>Huidig e-mailadres:</target>
</trans-unit>
<trans-unit id="ukaFrcB" resname="Current password">
<source>Current password</source>
<target>Huidig wachtwoord</target>
</trans-unit>
<trans-unit id="PkrbQOH" resname="Cyan">
<source>Cyan</source>
<target>Cyaan</target>
</trans-unit>
<trans-unit id="dG6EuYH" resname="Danger zone">
<source>Danger zone</source>
<target>Gevarenzone</target>
</trans-unit>
<trans-unit id="S5P7nQd" resname="Deactivate">
<source>Deactivate</source>
<target>Deactiveren</target>
@@ -285,30 +221,10 @@
<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="43g0Dc8" resname="Download an archive of everything stored under your account: your profile, the seasons you own, their quizzes, results and candidates.">
<source>Download an archive of everything stored under your account: your profile, the seasons you own, their quizzes, results and candidates.</source>
<target>Download een archief met alles wat onder je account is opgeslagen: je profiel, de seizoenen die je bezit, met de bijbehorende testen, resultaten en kandidaten.</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>
@@ -337,10 +253,6 @@
<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>
@@ -351,7 +263,7 @@
</trans-unit>
<trans-unit id="NXU7HO." resname="Error saving order">
<source>Error saving order</source>
<target>Fout bij het opslaan van de volgorde</target>
<target>Fout bij opslaan volgorde</target>
</trans-unit>
<trans-unit id="bgWPQMg" resname="Export to XLSX">
<source>Export to XLSX</source>
@@ -365,14 +277,6 @@
<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="3cWWP_q" resname="Fullscreen">
<source>Fullscreen</source>
<target>Volledig scherm</target>
</trans-unit>
<trans-unit id="MebBrmp" resname="Gray">
<source>Gray</source>
<target>Grijs</target>
@@ -401,14 +305,6 @@
<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>
@@ -445,14 +341,6 @@
<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>
@@ -485,18 +373,10 @@
<source>Name</source>
<target>Naam</target>
</trans-unit>
<trans-unit id="uWfLt3x" resname="New email address">
<source>New email address</source>
<target>Nieuw e-mailadres</target>
</trans-unit>
<trans-unit id="lqTjJ4a" resname="New label">
<source>New label</source>
<target>Nieuw label</target>
</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>
@@ -513,10 +393,6 @@
<source>No candidates</source>
<target>Geen kandidaten</target>
</trans-unit>
<trans-unit id="WPJalKI" resname="No questions have been added to this quiz yet.">
<source>No questions have been added to this quiz yet.</source>
<target>Er zijn nog geen vragen aan deze test toegevoegd.</target>
</trans-unit>
<trans-unit id="IsJa5UL" resname="No questions in the question bank yet">
<source>No questions in the question bank yet</source>
<target>Nog geen vragen in de vragenbank</target>
@@ -533,10 +409,6 @@
<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>
@@ -555,7 +427,7 @@
</trans-unit>
<trans-unit id="PywqOf4" resname="Owner(s)">
<source>Owner(s)</source>
<target>Eigenaar/Eigenaren</target>
<target>Eigena(a)r(en)</target>
</trans-unit>
<trans-unit id="GqmFSHc" resname="Password">
<source>Password</source>
@@ -573,18 +445,6 @@
<source>Please Confirm your Email</source>
<target>Bevestig je e-mailadres alsjeblieft</target>
</trans-unit>
<trans-unit id="7osID7L" resname="Please confirm your email address before downloading your data.">
<source>Please confirm your email address before downloading your data.</source>
<target>Bevestig eerst je e-mailadres voordat je je gegevens downloadt.</target>
</trans-unit>
<trans-unit id="XS4opCD" resname="Please confirm your email address before exporting a quiz.">
<source>Please confirm your email address before exporting a quiz.</source>
<target>Bevestig je e-mailadres voordat je een quiz exporteert.</target>
</trans-unit>
<trans-unit id="y4TgBCP" resname="Please confirm your email address before exporting data.">
<source>Please confirm your email address before exporting data.</source>
<target>Bevestig eerst je e-mailadres voordat je gegevens exporteert.</target>
</trans-unit>
<trans-unit id="mq1QYAv" resname="Please select an answer">
<source>Please select an answer</source>
<target>Selecteer een antwoorden alsjeblieft</target>
@@ -709,26 +569,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>
</trans-unit>
<trans-unit id="plQzNQU" resname="Refresh the page to try again.">
<source>Refresh the page to try again.</source>
<target>Ververs de pagina om het opnieuw te proberen.</target>
</trans-unit>
<trans-unit id="EGW_4W_" resname="Regenerate season code">
<source>Regenerate season code</source>
<target>Seizoenscode opnieuw genereren</target>
</trans-unit>
<trans-unit id="EWuNNNu" resname="Regenerate season code...">
<source>Regenerate season code...</source>
<target>Seizoenscode opnieuw genereren...</target>
</trans-unit>
<trans-unit id="fGfBzt6" resname="Register">
<source>Register</source>
<target>Registreren</target>
@@ -741,30 +585,10 @@
<source>Remove label</source>
<target>Label verwijderen</target>
</trans-unit>
<trans-unit id="WHywg0z" resname="Rename">
<source>Rename</source>
<target>Hernoemen</target>
</trans-unit>
<trans-unit id="K2RP6H8" resname="Rename candidate">
<source>Rename candidate</source>
<target>Kandidaat hernoemen</target>
</trans-unit>
<trans-unit id="Z9CSKpk" resname="Repeat Password">
<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 &amp; Elimination">
<source>Results &amp; Elimination</source>
<target><![CDATA[Resultaat & Eliminatie]]></target>
@@ -797,22 +621,10 @@
<source>Season Name</source>
<target>Seizoennaam</target>
</trans-unit>
<trans-unit id="IxKDF4a" resname="Season code">
<source>Season code</source>
<target>Seizoenscode</target>
</trans-unit>
<trans-unit id="iigT2eM" resname="Season code regenerated">
<source>Season code regenerated</source>
<target>Seizoenscode opnieuw gegenereerd</target>
</trans-unit>
<trans-unit id="kc_J96C" resname="Seasons">
<source>Seasons</source>
<target>Seizoenen</target>
</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>
@@ -825,10 +637,6 @@
<source>Sign in</source>
<target>Log in</target>
</trans-unit>
<trans-unit id=".9GO03z" resname="Soon™">
<source>Soon™</source>
<target>Soon™</target>
</trans-unit>
<trans-unit id="A0aGG7W" resname="Sort AZ">
<source>Sort AZ</source>
<target>Sorteer A-Z</target>
@@ -845,17 +653,9 @@
<source>Sync latest changes to this quiz</source>
<target>Laatste wijzigingen synchroniseren naar deze quiz</target>
</trans-unit>
<trans-unit id="cug5d45" resname="The candidate name must be between 1 and 16 characters">
<source>The candidate name must be between 1 and 16 characters</source>
<target>De naam van de kandidaat moet tussen de 1 en 16 tekens zijn</target>
</trans-unit>
<trans-unit id="XwQ1Fav" resname="The confirmation email could not be sent. Please try again later.">
<source>The confirmation email could not be sent. Please try again later.</source>
<target>De bevestigingsmail kon niet worden verzonden. Probeer het later opnieuw.</target>
</trans-unit>
<trans-unit id="mMDwcxj" resname="The confirmation email could not be sent. Please use the resend button to try again.">
<source>The confirmation email could not be sent. Please use the resend button to try again.</source>
<target>De bevestigingsmail kon niet worden verzonden. Gebruik de knop om het opnieuw te proberen.</target>
<trans-unit id="F3q1oXk" resname="The new question order could not be saved. Reordering has been disabled until you refresh the page.">
<source>The new question order could not be saved. Reordering has been disabled until you refresh the page.</source>
<target>De nieuwe volgorde van de vragen kon niet worden opgeslagen. Herordenen is uitgeschakeld totdat u de pagina vernieuwt.</target>
</trans-unit>
<trans-unit id="_z4el3Z" resname="The password fields must match.">
<source>The password fields must match.</source>
@@ -881,34 +681,14 @@
<source>The quiz must be finalized before it can be activated</source>
<target>De test moet afgerond zijn voordat deze geactiveerd kan worden</target>
</trans-unit>
<trans-unit id="IKPt_Pf" resname="The season code is used by candidates to join this season. Regenerating it invalidates the current code, so make sure to share the new one.">
<source>The season code is used by candidates to join this season. Regenerating it invalidates the current code, so make sure to share the new one.</source>
<target>De seizoenscode wordt door kandidaten gebruikt om dit seizoen te vinden. Als je een nieuwe code genereert, werkt de oude niet meer, dus deel de nieuwe code opnieuw.</target>
</trans-unit>
<trans-unit id="HuzRgeN" resname="There are no answers for this question">
<source>There are no answers for this question</source>
<target>Er zijn geen antwoorden voor deze vraag</target>
</trans-unit>
<trans-unit id="vsM4tSv" resname="There is already an account with this email">
<source>There is already an account with this email</source>
<target>Er is al een account met dit e-mailadres</target>
</trans-unit>
<trans-unit id=".LrcTyU" resname="There is no active quiz">
<source>There is no active quiz</source>
<target>Er is geen test actief</target>
</trans-unit>
<trans-unit id="nv0a7LG" resname="This deletes your account and every season you are the only owner of. Enter your password to confirm.">
<source>This deletes your account and every season you are the only owner of. Enter your password to confirm.</source>
<target>Dit verwijdert je account en alle seizoenen waarvan jij de enige eigenaar bent. Vul je wachtwoord in om te bevestigen.</target>
</trans-unit>
<trans-unit id="EBWUWJu" resname="This invalidates the current season code. Anyone using the old code will no longer be able to join this season.">
<source>This invalidates the current season code. Anyone using the old code will no longer be able to join this season.</source>
<target>Hiermee wordt de huidige seizoenscode ongeldig. Iedereen die de oude code gebruikt, kan dit seizoen niet meer vinden.</target>
</trans-unit>
<trans-unit id="YueaA2f" resname="This link will expire in %count%.">
<source>This link will expire in %count%.</source>
<target>Deze link verloopt over %count%.</target>
</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>
@@ -943,7 +723,7 @@
</trans-unit>
<trans-unit id=".j31AXY" resname="Toggle correct answer">
<source>Toggle correct answer</source>
<target>Goed antwoord aan/uitzetten</target>
<target></target>
</trans-unit>
<trans-unit id="XLYBGca" resname="Unassign">
<source>Unassign</source>
@@ -965,10 +745,6 @@
<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>
@@ -993,38 +769,10 @@
<source>Your Seasons</source>
<target>Jouw seizoenen</target>
</trans-unit>
<trans-unit id="Eh0pcpd" resname="Your data">
<source>Your data</source>
<target>Je gegevens</target>
</trans-unit>
<trans-unit id="sAb9l8I" resname="Your email address has been changed.">
<source>Your email address has been changed.</source>
<target>Je e-mailadres is gewijzigd.</target>
</trans-unit>
<trans-unit id="OqDtnJw" resname="Your email address has been changed. Please check your inbox to confirm it.">
<source>Your email address has been changed. Please check your inbox to confirm it.</source>
<target>Je e-mailadres is gewijzigd. Check je inbox om het te bevestigen.</target>
</trans-unit>
<trans-unit id="m80cBv0" resname="Your email address has been verified.">
<source>Your email address has been verified.</source>
<target>Je e-mailadres is geverifieerd.</target>
</trans-unit>
<trans-unit id="mPitWNe" resname="Your email address is already confirmed.">
<source>Your email address is already confirmed.</source>
<target>Je e-mailadres is al bevestigd.</target>
</trans-unit>
<trans-unit id="yAT.oxx" resname="Your password has been changed.">
<source>Your password has been changed.</source>
<target>Je wachtwoord is gewijzigd.</target>
</trans-unit>
<trans-unit id="YDIkAA1" resname="Your password reset request">
<source>Your password reset request</source>
<target>Verzoek tot wachtwoordherstel</target>
</trans-unit>
<trans-unit id="zqHSO6v" resname="try again">
<source>try again</source>
<target>probeer opnieuw</target>
</trans-unit>
</body>
</file>
</xliff>
-16
View File
@@ -109,22 +109,10 @@
<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>
@@ -445,10 +433,6 @@
<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>