Compare commits

..

10 Commits

Author SHA1 Message Date
Marijn a5b0b23559 feat: add candidate rename and delete
Adds per-candidate rename and delete actions to the candidates tab,
guarded by confirmation modals since deletion also discards the
candidate's given answers.

Closes #18
2026-07-08 15:50:30 +02:00
Marijn 1d3e99d2b2 feat: user settings page (#191)
* feat: user settings page (#182)

- Settings link in the backoffice nav next to Logout
- Language selector (Dutch only, noop save)
- Change password form with current-password check
- Change email form that re-triggers email confirmation
- Resend confirmation email button for unconfirmed addresses
- Disabled Download data button with Soon(tm) popover
- Delete account with password confirmation modal, removes
  seasons the user is the sole owner of
- Well-known URLs: change-password redirect and security.txt

* feat: base security.txt Expires on the container build time

The BUILD_TIME build arg is baked into the prod image as an env var
and set by CI at image build. security.txt expires one year after the
build, so the file goes stale when deployments stop. Dev and test fall
back to one year from the request time.

* refactor: extract shared controller functionality

- AbstractController: authenticatedUser property hook and
  assertSameSeason() (moved from QuestionBankController)
- EmailVerifier::sendDefaultConfirmation() replaces the duplicated
  confirmation email block in RegistrationController and
  SettingsController
- QuizController: deduplicate candidate-data preparation into
  buildCandidateData()
- Drop manual 422 status handling in QuizQuestionController,
  QuestionBankController and SettingsController: render() already
  returns 422 for submitted invalid forms passed as parameters

* fix: address PR review comments

- Catch UniqueConstraintViolationException when changing email to
  handle the race between the uniqueness check and the flush
- Avoid else-only block in UserRepository::deleteUser
- Align duplicate-email translation with the validators domain

* fix: apply code-review findings for PR #191

- Invalidate outstanding ResetPasswordRequests after password or email change to close the account-takeover window (tokens otherwise remain valid)
- Exclude the current user from email uniqueness check so submitting your own address no longer returns an error
- Surface transport failures from sendDefaultConfirmation via a warning flash instead of silently showing success
- Move Dockerfile ARG BUILD_TIME/ENV to after all build steps so changing the build timestamp no longer busts the composer/asset cache
- Throw in prod when BUILD_TIME is missing (WellKnownController) so the security.txt Expires goes stale as intended when deployments stop; fall back to 'now' only in dev/test
2026-07-08 14:38:32 +02:00
Marijn d7e8d094cf fix: use global mailer From header for password reset email (#190)
Remove the hardcoded from address (info@tijdvoordetest.nl) so the
global headers.From in mailer.yaml is used instead, which includes the
"Tijd voor de test" display name and the correct noreply sender.
2026-07-08 08:10:57 +00:00
Marijn ba1e8d8eb6 ci: auto-trigger main CI when tagging a Dependabot commit (#188)
* ci: auto-trigger main CI run when tagging a Dependabot commit

Dependabot auto-merges use GITHUB_TOKEN which GitHub intentionally does
not re-trigger other workflows on. This means tagging those commits
immediately fails the verify-prior-run gate.

Instead of hard-failing, trigger ci.yml on main and wait for it to
succeed before proceeding with the deploy. Only triggers once; detects
and surfaces failures from the triggered run.

Bumps actions permission from read to write to allow workflow dispatch.

* ci: address CodeRabbit feedback on verify-prior-run job

Reduce max_attempts from 40 to 30 so worst-case runtime (15m) fits within
the 20-minute job timeout with margin. Trigger the fallback workflow run on
the tag ref instead of main so the dispatched run's head_sha matches the
tagged commit SHA that the polling loop is checking for.
2026-07-08 08:00:37 +00:00
Marijn 5d92d91432 fix: trust X-Forwarded-Proto header from Traefik proxy (#189)
Traefik terminates TLS and forwards requests over HTTP internally,
setting X-Forwarded-Proto: https. Without trusting this header,
Symfony generates http:// URLs (e.g. in password reset emails).
2026-07-08 07:45:07 +00:00
dependabot[bot] 04c40412cd Bump friendsofphp/php-cs-fixer in the dev-dependencies group (#187)
Bumps the dev-dependencies group with 1 update: [friendsofphp/php-cs-fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer).


Updates `friendsofphp/php-cs-fixer` from 3.95.11 to 3.95.12
- [Release notes](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/releases)
- [Changelog](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/compare/v3.95.11...v3.95.12)

---
updated-dependencies:
- dependency-name: friendsofphp/php-cs-fixer
  dependency-version: 3.95.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 23:55:30 +00:00
Marijn b915d87d4a feat: password reset (#179) (#186)
* feat: password reset via symfonycasts/reset-password-bundle (#179)

Implements the full password reset flow using the SymfonyCasts reset-password-bundle.

* ci: share base layer cache between dev and prod builds

* fix: use CSS form selector in tests instead of translated button text

* fix: translate missing validator string for reset password email field

* fix: translate reset password form labels via TranslatorInterface

* ci: override MAILER_DSN to null for PHPUnit so mailer host is not required
2026-07-07 21:43:24 +00:00
Marijn 47077288d5 Quiz page: individual question rework (#181) (#183)
* 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

* 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

* 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

* 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

* 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.

* fix: exclude auto-generated reference.php from pre-commit CS-fixer

* fix: use correct Turbo v8 session export to disable Drive

* fix: address CodeRabbit review findings on PR #183

- Add SeasonVoter::EDIT guard to QuizQuestionController::view()
- Add full-count check in reorder() to reject partial ordering payloads
- Retry once in _persistOrder(); lock drag and show dismissible alert on failure
- Remove tabindex="-1" from correct-answer toggle button (accessibility)
- Replace 'EMPTY'|trans placeholder with proper copy
- Add data-modal-title to question bank Edit button to prevent stale title

* fix: correct Dutch translations flagged in review

- 'Error saving order': 'Fout bij het opslaan van de volgorde' (consistent with sibling strings)
- 'Owner(s)': 'Eigenaar/Eigenaren' (unambiguous, correct Dutch plural)

* fix: set explicit form action URLs for Turbo Frame modal forms

When a form has no explicit action, Symfony renders action="" which the
browser resolves to the page URL, not the URL the Turbo Frame was
fetched from. This caused edits submitted via the modal to POST to the
wrong route and silently discard changes.

* fix: preserve answer ordering on save and add coverage for all sort operations

- Enable Turbo Drive and use visit() in modal controller so submit redirects to the page under the modal
- Add removeAnswer() to Question entity so Symfony form can manage the collection with PHP 8.5 private(set)
- Remove applyAnswerOrdering() from QuizQuestionController and QuestionBankController — it iterated the Doctrine collection in its old DB order and overwrote the ordering values submitted from the form
- Add QuizQuestionControllerTest covering answer ordering preservation and question reordering within a quiz
- Extend QuestionBankControllerTest with answer ordering tests for both new and edit bank questions

* fix: translate three missing Dutch strings in nl.xliff

* Updated CLAUDE.md

* refactor: replace addEventListener with Stimulus data-action in modal and question-list controllers

* refactor: replace addEventListener with Stimulus data-action in form-collection controller

Move drag-and-drop and auto-expand event handling from imperative addEventListener
calls to declarative data-action descriptors in answer_row.html.twig and the
collection target templates. Stimulus MutationObserver picks up the descriptors on
dynamically added rows, removing the need for _makeDraggable(). The ancestor-form
submit listener stays as addEventListener since Stimulus data-action cannot reach
elements outside the controller's subtree.

* feat: show label colour badges in question bank edit form

Replace plain checkbox text with coloured Bootstrap badge pills in the
labels section of the bank question edit form (both standalone and modal
frame variants). Adds choice_attr to pass data-colour to each checkbox,
then renders the input manually so the label can hold the badge without
the Bootstrap 5 form theme wrapping in a second plain-text label.
2026-07-07 22:37:15 +02:00
dependabot[bot] b965b2f10a Bump the dev-dependencies group with 4 updates (#184)
Bumps the dev-dependencies group with 4 updates: [phpstan/phpstan](https://github.com/phpstan/phpstan-phar-composer-source), [phpstan/phpstan-phpunit](https://github.com/phpstan/phpstan-phpunit), [phpunit/phpunit](https://github.com/sebastianbergmann/phpunit) and [rector/rector](https://github.com/rectorphp/rector).


Updates `phpstan/phpstan` from 2.2.4 to 2.2.5
- [Commits](https://github.com/phpstan/phpstan-phar-composer-source/commits)

Updates `phpstan/phpstan-phpunit` from 2.0.17 to 2.0.18
- [Release notes](https://github.com/phpstan/phpstan-phpunit/releases)
- [Commits](https://github.com/phpstan/phpstan-phpunit/compare/2.0.17...2.0.18)

Updates `phpunit/phpunit` from 13.2.2 to 13.2.3
- [Release notes](https://github.com/sebastianbergmann/phpunit/releases)
- [Changelog](https://github.com/sebastianbergmann/phpunit/blob/13.2.3/ChangeLog-13.2.md)
- [Commits](https://github.com/sebastianbergmann/phpunit/compare/13.2.2...13.2.3)

Updates `rector/rector` from 2.5.2 to 2.5.4
- [Release notes](https://github.com/rectorphp/rector/releases)
- [Commits](https://github.com/rectorphp/rector/compare/2.5.2...2.5.4)

---
updated-dependencies:
- dependency-name: phpstan/phpstan
  dependency-version: 2.2.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: phpstan/phpstan-phpunit
  dependency-version: 2.0.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: phpunit/phpunit
  dependency-version: 13.2.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: rector/rector
  dependency-version: 2.5.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 23:55:46 +00:00
Marijn 88cff7f480 feat: Sentry User Feedback in backoffice (#178) (#180)
* feat: add Sentry User Feedback widget to backoffice (#178)

Install @sentry/browser via AssetMapper and initialize the feedback
integration on all backoffice pages. The CDN loader script is moved to
an overridable block so backoffice pages use the npm SDK without
double-initialising Sentry.

Sentry is initialised only when a DSN is configured (SENTRY_DSN env
var). When a user is logged in their email is pre-filled in the feedback
form via Sentry.setUser(); both name and email remain optional so
anonymous submissions are supported by simply leaving those fields empty.

* feat: add Spotlight sidecar for local Sentry development testing

Add the Spotlight container to compose.override.yaml so the feedback
widget and error events can be inspected locally without sending anything
to Sentry. The JS SDK switches to spotlight mode automatically when no
SENTRY_DSN is configured.

* feat: route PHP Sentry events to Spotlight in dev

Add a when@dev block to sentry.yaml that initialises the PHP Sentry SDK
with a placeholder DSN and forwards all events to the Spotlight sidecar
over the internal Docker network (http://spotlight:8969/stream).

The JS SDK already uses localhost:8969 via spotlight:true when no real
SENTRY_DSN is set, which is correct since the browser cannot reach the
internal Docker hostname.

* fix: use spotlight_url to route PHP dev errors to Spotlight sidecar

* fix: manually mount Sentry feedback widget to work around v10 setupOnce guard

Sentry v10's setupOnce() is skipped when the integration name is already
in the module-level guard array, so autoInject never mounts the widget.
Switching to autoInject: false and calling createWidget() directly is
reliable and confirmed working in the browser.

Also use the tunnel option to forward JS events to the local Spotlight
sidecar when no real DSN is configured, and enable SentryBundle in dev
so PHP errors are also routed to Spotlight.
2026-07-05 23:27:49 +02:00
84 changed files with 3205 additions and 307 deletions
+2
View File
@@ -1,3 +1,5 @@
# define your env variables for the test env here # define your env variables for the test env here
KERNEL_CLASS='Tvdt\Kernel' KERNEL_CLASS='Tvdt\Kernel'
APP_SECRET='$ecretf0rt3st' APP_SECRET='$ecretf0rt3st'
MAILER_DSN=null://null
MAILER_SENDER=test@example.org
+2 -2
View File
@@ -4,7 +4,7 @@ setopt ERR_EXIT PIPE_FAIL NOUNSET
# Collect staged PHP and Twig files # Collect staged PHP and Twig files
STAGED_PHP=() STAGED_PHP=()
while IFS= read -r file; do while IFS= read -r file; do
[[ -n "$file" ]] && STAGED_PHP+=("$file") [[ -n "$file" && "$file" != "config/reference.php" ]] && STAGED_PHP+=("$file")
done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.php$' || true) done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.php$' || true)
STAGED_TWIG=() STAGED_TWIG=()
@@ -32,7 +32,7 @@ if [[ ${#STAGED_PHP[@]} -gt 0 ]]; then
git add "${STAGED_PHP[@]}" git add "${STAGED_PHP[@]}"
echo " → PHP-CS-Fixer" echo " → PHP-CS-Fixer"
"${DOCKER_CMD[@]}" vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php "${STAGED_PHP[@]}" "${DOCKER_CMD[@]}" vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --path-mode=intersection "${STAGED_PHP[@]}"
git add "${STAGED_PHP[@]}" git add "${STAGED_PHP[@]}"
echo " → PHPStan" echo " → PHPStan"
+31 -5
View File
@@ -150,7 +150,7 @@ jobs:
- name: Load fixtures - name: Load fixtures
run: docker compose exec -T php bin/console -e test doctrine:fixtures:load --no-interaction --group=test run: docker compose exec -T php bin/console -e test doctrine:fixtures:load --no-interaction --group=test
- name: Run PHPUnit - name: Run PHPUnit
run: docker compose exec -T php vendor/bin/phpunit --log-junit var/phpunit/junit.xml run: docker compose exec -T -e MAILER_DSN=null://null php vendor/bin/phpunit --log-junit var/phpunit/junit.xml
- name: Publish PHPUnit test results - name: Publish PHPUnit test results
if: always() if: always()
uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6 uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6
@@ -166,7 +166,7 @@ jobs:
timeout-minutes: 20 timeout-minutes: 20
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
permissions: permissions:
actions: read actions: write
steps: steps:
- name: Wait for and verify successful CI run on this commit - name: Wait for and verify successful CI run on this commit
env: env:
@@ -174,6 +174,8 @@ jobs:
run: | run: |
max_attempts=30 max_attempts=30
attempt=0 attempt=0
triggered=false
while [[ $attempt -lt $max_attempts ]]; do while [[ $attempt -lt $max_attempts ]]; do
attempt=$((attempt + 1)) attempt=$((attempt + 1))
@@ -191,12 +193,32 @@ jobs:
--jq "[.workflow_runs[] | select(.id != ${{ github.run_id }}) | select(.status == \"in_progress\" or .status == \"queued\" or .status == \"waiting\" or .status == \"requested\" or .status == \"pending\")] | length") --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 if [[ "$in_progress_count" -gt 0 ]]; then
echo "CI still in progress (attempt $attempt/$max_attempts), waiting 30s..." echo "CI in progress (attempt $attempt/$max_attempts), waiting 30s..."
sleep 30 sleep 30
else continue
echo "::error::No prior successful CI run found for ${{ github.sha }}. Only tag commits that have passed CI on main." fi
if [[ "$triggered" == "false" ]]; then
echo "No prior CI run found for ${{ github.sha }}. Triggering CI on ${{ github.ref }}..."
gh workflow run ci.yml --repo "${{ github.repository }}" --ref "${{ github.ref }}"
triggered=true
echo "Triggered. Waiting 20s for run to register..."
sleep 20
continue
fi
failed_conclusion=$(gh api \
"repos/${{ github.repository }}/actions/workflows/ci.yml/runs?head_sha=${{ github.sha }}&per_page=10" \
--jq "[.workflow_runs[] | select(.id != ${{ github.run_id }}) | select(.status == \"completed\") | select(.conclusion != \"success\")] | first | .conclusion // empty" \
--raw-output)
if [[ -n "$failed_conclusion" ]]; then
echo "::error::Triggered CI run on main failed with conclusion: $failed_conclusion. Fix the issue before re-tagging."
exit 1 exit 1
fi fi
echo "Waiting for triggered run to register (attempt $attempt/$max_attempts)..."
sleep 20
done done
echo "::error::Timed out waiting for CI run to complete for ${{ github.sha }}." echo "::error::Timed out waiting for CI run to complete for ${{ github.sha }}."
@@ -234,6 +256,7 @@ jobs:
id: meta id: meta
run: | run: |
REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') 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 if [[ "${{ github.ref }}" == refs/tags/* ]]; then
TAG="${GITHUB_REF#refs/tags/}" TAG="${GITHUB_REF#refs/tags/}"
SENTRY_VERSION="${TAG#v}" SENTRY_VERSION="${TAG#v}"
@@ -260,9 +283,12 @@ jobs:
compose.yaml compose.yaml
compose.build.yaml compose.build.yaml
set: | 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=${{github.ref}}
*.cache-from=type=gha,scope=refs/heads/main *.cache-from=type=gha,scope=refs/heads/main
*.cache-to=type=gha,scope=${{github.ref}},mode=max *.cache-to=type=gha,scope=${{github.ref}},mode=max
*.args.BUILD_TIME=${{ steps.meta.outputs.build_time }}
*.tags=${{ steps.meta.outputs.full_name }} *.tags=${{ steps.meta.outputs.full_name }}
- name: Create Sentry release - name: Create Sentry release
+1
View File
@@ -171,6 +171,7 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-deepclone" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-deepclone" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/file-filter" /> <excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/file-filter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/object-mapper" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/object-mapper" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfonycasts/reset-password-bundle" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
Generated
+1
View File
@@ -205,6 +205,7 @@
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" /> <path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
<path value="$PROJECT_DIR$/vendor/martin-georgiev/postgresql-for-doctrine" /> <path value="$PROJECT_DIR$/vendor/martin-georgiev/postgresql-for-doctrine" />
<path value="$PROJECT_DIR$/vendor/symfony/object-mapper" /> <path value="$PROJECT_DIR$/vendor/symfony/object-mapper" />
<path value="$PROJECT_DIR$/vendor/symfonycasts/reset-password-bundle" />
</include_path> </include_path>
</component> </component>
<component name="PhpInterpreters"> <component name="PhpInterpreters">
+6
View File
@@ -148,6 +148,12 @@ tests/
- Coverage excluded from: `src/DataFixtures/` - Coverage excluded from: `src/DataFixtures/`
- Test environment: `APP_ENV=test` (set in phpunit.dist.xml) - 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.
### Code Style & Standards ### Code Style & Standards
- **PHP-CS-Fixer**: Symfony ruleset + risky rules enabled - **PHP-CS-Fixer**: Symfony ruleset + risky rules enabled
- Strict types declaration required - Strict types declaration required
+4 -1
View File
@@ -32,7 +32,6 @@ RUN set -eux; \
opcache \ opcache \
zip \ zip \
gd \ gd \
excimer \
; ;
# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser # https://getcomposer.org/doc/03-cli.md#composer-allow-superuser
@@ -110,3 +109,7 @@ RUN set -eux; \
bin/console sass:build; \ bin/console sass:build; \
bin/console asset-map:compile --no-debug --quiet --no-ansi; \ bin/console asset-map:compile --no-debug --quiet --no-ansi; \
sync; 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
+35
View File
@@ -1,5 +1,40 @@
import 'bootstrap/dist/css/bootstrap.min.css'; import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-icons/font/bootstrap-icons.min.css'; import 'bootstrap-icons/font/bootstrap-icons.min.css';
import './styles/backoffice.scss'; import './styles/backoffice.scss';
import '@hotwired/turbo';
import './stimulus.js'; import './stimulus.js';
import './bootstrap.js'; import './bootstrap.js';
import * as Sentry from '@sentry/browser';
const dsn = document.querySelector('meta[name="sentry-dsn"]')?.content ?? '';
const userEmail = document.querySelector('meta[name="user-email"]')?.content ?? '';
// When no real DSN is configured, route to the local Spotlight sidecar so
// nothing reaches Sentry. A syntactically valid DSN is still required for the
// SDK to initialise; the tunnel option redirects all transport to Spotlight.
const useSpotlight = !dsn;
const effectiveDsn = dsn || 'https://0@o0.ingest.sentry.io/0';
const feedbackIntegration = Sentry.feedbackIntegration({
colorScheme: 'system',
showName: false,
showEmail: true,
isEmailRequired: false,
autoInject: false,
triggerLabel: 'Report feedback',
formTitle: 'Report Feedback',
submitButtonLabel: 'Send Feedback',
});
Sentry.init({
dsn: effectiveDsn,
tunnel: useSpotlight ? 'http://localhost:8969/stream' : undefined,
integrations: [feedbackIntegration],
});
// autoInject is unreliable in Sentry v10 due to the setupOnce guard; mount manually.
feedbackIntegration.createWidget();
if (userEmail) {
Sentry.setUser({ email: userEmail });
}
@@ -6,8 +6,31 @@ export default class extends Controller {
connect() { connect() {
this.index = this.collectionTarget.children.length; this.index = this.collectionTarget.children.length;
this._setupDrag();
this._syncOrdering(); 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.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);
}
} }
addItem() { addItem() {
@@ -15,13 +38,13 @@ export default class extends Controller {
item.innerHTML = this.prototypeValue.replace(/__name__/g, this.index); item.innerHTML = this.prototypeValue.replace(/__name__/g, this.index);
const el = item.firstElementChild; const el = item.firstElementChild;
this.collectionTarget.appendChild(el); this.collectionTarget.appendChild(el);
this._makeDraggable(el);
this.index++; this.index++;
this._syncOrdering(); this._syncOrdering();
} }
removeItem(event) { removeItem(event) {
event.target.closest('[data-collection-item]').remove(); event.target.closest('[data-collection-item]').remove();
this._notifyChange();
} }
sortAlphabetically() { sortAlphabetically() {
@@ -33,6 +56,7 @@ export default class extends Controller {
}); });
items.forEach(item => this.collectionTarget.appendChild(item)); items.forEach(item => this.collectionTarget.appendChild(item));
this._syncOrdering(); this._syncOrdering();
this._notifyChange();
} }
randomize() { randomize() {
@@ -43,56 +67,64 @@ export default class extends Controller {
} }
items.forEach(item => this.collectionTarget.appendChild(item)); items.forEach(item => this.collectionTarget.appendChild(item));
this._syncOrdering(); this._syncOrdering();
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();
}
} }
// — drag-and-drop — // — drag-and-drop —
_setupDrag() { dragStart(event) {
[...this.collectionTarget.children].forEach(el => this._makeDraggable(el)); this._dragging = event.currentTarget.closest('[data-collection-item]');
this._dragging.classList.add('opacity-50');
event.dataTransfer.effectAllowed = 'move';
} }
_makeDraggable(el) { dragEnd(event) {
const handle = el.querySelector('[data-drag-handle]'); event.currentTarget.closest('[data-collection-item]').classList.remove('opacity-50');
if (!handle) return; this._dragging = null;
this.collectionTarget.querySelectorAll('[data-collection-item]').forEach(i =>
i.classList.remove('border-top', 'border-bottom', 'border-primary'),
);
}
handle.setAttribute('draggable', 'true'); 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.addEventListener('dragstart', (e) => { dragLeave(event) {
this._dragging = el; event.currentTarget.classList.remove('border-top', 'border-bottom', 'border-primary');
el.classList.add('opacity-50'); }
e.dataTransfer.effectAllowed = 'move';
});
handle.addEventListener('dragend', () => { drop(event) {
this._dragging = null; event.preventDefault();
el.classList.remove('opacity-50'); const el = event.currentTarget;
this.collectionTarget.querySelectorAll('[data-collection-item]').forEach(i => i.classList.remove('border-top', 'border-bottom', 'border-primary')); 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();
}
el.addEventListener('dragover', (e) => { _notifyChange() {
e.preventDefault(); this.element.dispatchEvent(new Event('change', {bubbles: true}));
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();
});
} }
_syncOrdering() { _syncOrdering() {
+49
View File
@@ -0,0 +1,49 @@
import {Controller} from '@hotwired/stimulus';
import {Modal} from 'bootstrap';
import {visit} from '@hotwired/turbo';
export default class extends Controller {
static targets = ['modal', 'frame'];
open(event) {
event.preventDefault();
const {src, modalTitle} = event.currentTarget.dataset;
if (modalTitle) {
const titleEl = this.modalTarget.querySelector('.modal-title');
if (titleEl) titleEl.textContent = modalTitle;
}
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();
}
frameSubmitEnd(event) {
if (event.detail.success) {
Modal.getOrCreateInstance(this.modalTarget).hide();
visit(window.location.href);
}
}
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.
const modal = Modal.getOrCreateInstance(this.modalTarget);
modal._config.backdrop = 'static';
modal._config.keyboard = false;
}
resetDirty() {
this._dirty = false;
const modal = Modal.getOrCreateInstance(this.modalTarget);
modal._config.backdrop = true;
modal._config.keyboard = true;
}
}
@@ -0,0 +1,13 @@
import {Controller} from '@hotwired/stimulus';
import {Popover} from 'bootstrap';
export default class extends Controller {
connect() {
this.popovers = [...this.element.querySelectorAll('[data-bs-toggle="popover"]')]
.map(popoverTriggerEl => Popover.getOrCreateInstance(popoverTriggerEl));
}
disconnect() {
this.popovers.forEach(popover => popover.dispose());
}
}
@@ -0,0 +1,123 @@
import {Controller} from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['list', 'item', 'status'];
static values = {
reorderUrl: String,
csrf: String,
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);
}
}
dragLeave(event) {
if (!event.relatedTarget || !this.listTarget.contains(event.relatedTarget)) {
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();
}
_removePlaceholder() {
if (this._placeholder) {
this._placeholder.remove();
this._placeholder = null;
}
}
_setStatus(state) {
if (!this.hasStatusTarget) return;
const el = this.statusTarget;
el.classList.remove('d-none', 'text-bg-success', 'text-bg-danger', 'text-bg-warning');
if (state === 'saving') {
el.classList.add('text-bg-warning');
el.textContent = '…';
} else if (state === 'saved') {
el.classList.add('text-bg-success');
el.textContent = this.savedLabelValue || 'Saved';
} else if (state === 'error') {
el.classList.add('text-bg-danger');
el.textContent = this.errorLabelValue || 'Error';
}
}
async _persistOrder() {
this._setStatus('saving');
const params = new URLSearchParams();
params.append('_token', this.csrfValue);
this.itemTargets.forEach((el, i) => {
params.append('ordering[]', el.dataset.questionId);
const numberEl = el.querySelector('[data-question-number]');
if (numberEl) numberEl.textContent = String(i + 1);
});
for (let attempt = 0; attempt < 2; attempt++) {
try {
const res = await fetch(this.reorderUrlValue, {method: 'POST', body: params});
if (res.ok) {
this._setStatus('saved');
return;
}
} catch {
// network error — retry on first attempt
}
}
this._locked = true;
this._setStatus('error');
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);
}
}
+2
View File
@@ -1,3 +1,5 @@
.col-result-xs { width: 10%; } .col-result-xs { width: 10%; }
.col-result-sm { width: 15%; } .col-result-sm { width: 15%; }
.col-result-md { width: 20%; } .col-result-md { width: 20%; }
.modal-content > turbo-frame { display: contents; }
+5
View File
@@ -70,5 +70,10 @@ services:
MP_SMTP_AUTH_ALLOW_INSECURE: 1 MP_SMTP_AUTH_ALLOW_INSECURE: 1
###< symfony/mailer ### ###< symfony/mailer ###
spotlight:
image: ghcr.io/getsentry/spotlight:latest
ports:
- "8969:8969"
volumes: volumes:
sass: sass:
+1
View File
@@ -41,6 +41,7 @@
"symfony/ux-turbo": "^3.1", "symfony/ux-turbo": "^3.1",
"symfony/validator": "8.1.*", "symfony/validator": "8.1.*",
"symfony/yaml": "8.1.*", "symfony/yaml": "8.1.*",
"symfonycasts/reset-password-bundle": "^1.25",
"symfonycasts/sass-bundle": "^0.10", "symfonycasts/sass-bundle": "^0.10",
"symfonycasts/verify-email-bundle": "^1.18.0", "symfonycasts/verify-email-bundle": "^1.18.0",
"thecodingmachine/safe": "^3.4.0", "thecodingmachine/safe": "^3.4.0",
Generated
+93 -46
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "7171824ca13f4df0801dfa5d7f58d6a0", "content-hash": "010a4456ebc1a8ebaf73c6db051d3d09",
"packages": [ "packages": [
{ {
"name": "composer/pcre", "name": "composer/pcre",
@@ -8281,6 +8281,54 @@
], ],
"time": "2026-06-09T11:06:24+00:00" "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", "name": "symfonycasts/sass-bundle",
"version": "v0.10.0", "version": "v0.10.0",
@@ -9360,16 +9408,16 @@
}, },
{ {
"name": "friendsofphp/php-cs-fixer", "name": "friendsofphp/php-cs-fixer",
"version": "v3.95.11", "version": "v3.95.12",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
"reference": "35f98e1293283397824d7f349ce5afb8747c3cd5" "reference": "b1b9055997a98dce3c2338e884626e718a25a923"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/35f98e1293283397824d7f349ce5afb8747c3cd5", "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/b1b9055997a98dce3c2338e884626e718a25a923",
"reference": "35f98e1293283397824d7f349ce5afb8747c3cd5", "reference": "b1b9055997a98dce3c2338e884626e718a25a923",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -9409,7 +9457,7 @@
"php-coveralls/php-coveralls": "^2.9.1", "php-coveralls/php-coveralls": "^2.9.1",
"php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8",
"php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8",
"phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.55", "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56",
"symfony/polyfill-php85": "^1.38", "symfony/polyfill-php85": "^1.38",
"symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.0", "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" "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.0"
@@ -9453,7 +9501,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.11" "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.12"
}, },
"funding": [ "funding": [
{ {
@@ -9461,7 +9509,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-06-25T14:17:04+00:00" "time": "2026-07-07T13:29:36+00:00"
}, },
{ {
"name": "myclabs/deep-copy", "name": "myclabs/deep-copy",
@@ -9525,20 +9573,19 @@
}, },
{ {
"name": "nikic/php-parser", "name": "nikic/php-parser",
"version": "v5.7.0", "version": "v5.8.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/nikic/PHP-Parser.git", "url": "https://github.com/nikic/PHP-Parser.git",
"reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
"reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-ctype": "*",
"ext-json": "*", "ext-json": "*",
"ext-tokenizer": "*", "ext-tokenizer": "*",
"php": ">=7.4" "php": ">=7.4"
@@ -9577,9 +9624,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/nikic/PHP-Parser/issues", "issues": "https://github.com/nikic/PHP-Parser/issues",
"source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0"
}, },
"time": "2025-12-06T11:56:16+00:00" "time": "2026-07-04T14:30:18+00:00"
}, },
{ {
"name": "phar-io/manifest", "name": "phar-io/manifest",
@@ -9749,11 +9796,11 @@
}, },
{ {
"name": "phpstan/phpstan", "name": "phpstan/phpstan",
"version": "2.2.4", "version": "2.2.5",
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/f0fe3fb03bb53ce68cc2416785b260e62226ec27", "url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0",
"reference": "f0fe3fb03bb53ce68cc2416785b260e62226ec27", "reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -9809,7 +9856,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-07-03T07:00:23+00:00" "time": "2026-07-05T06:31:06+00:00"
}, },
{ {
"name": "phpstan/phpstan-doctrine", "name": "phpstan/phpstan-doctrine",
@@ -9890,16 +9937,16 @@
}, },
{ {
"name": "phpstan/phpstan-phpunit", "name": "phpstan/phpstan-phpunit",
"version": "2.0.17", "version": "2.0.18",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpstan/phpstan-phpunit.git", "url": "https://github.com/phpstan/phpstan-phpunit.git",
"reference": "c2f977551f0736d60467b3d754b2e0cf4e337b3f" "reference": "f5dc20ff8082d02339b60cab68ec3eb0d859fb30"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/c2f977551f0736d60467b3d754b2e0cf4e337b3f", "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/f5dc20ff8082d02339b60cab68ec3eb0d859fb30",
"reference": "c2f977551f0736d60467b3d754b2e0cf4e337b3f", "reference": "f5dc20ff8082d02339b60cab68ec3eb0d859fb30",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -9942,9 +9989,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/phpstan/phpstan-phpunit/issues", "issues": "https://github.com/phpstan/phpstan-phpunit/issues",
"source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.17" "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.18"
}, },
"time": "2026-06-29T05:32:23+00:00" "time": "2026-07-04T12:16:09+00:00"
}, },
{ {
"name": "phpstan/phpstan-symfony", "name": "phpstan/phpstan-symfony",
@@ -10022,16 +10069,16 @@
}, },
{ {
"name": "phpunit/php-code-coverage", "name": "phpunit/php-code-coverage",
"version": "14.2.2", "version": "14.2.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
"reference": "10d7da3628a99289cdf4c662dd7f0d73f1baec83" "reference": "82f6e49ff224e2cde923d74425e583a883910783"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/10d7da3628a99289cdf4c662dd7f0d73f1baec83", "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/82f6e49ff224e2cde923d74425e583a883910783",
"reference": "10d7da3628a99289cdf4c662dd7f0d73f1baec83", "reference": "82f6e49ff224e2cde923d74425e583a883910783",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -10039,7 +10086,7 @@
"ext-libxml": "*", "ext-libxml": "*",
"ext-mbstring": "*", "ext-mbstring": "*",
"ext-xmlwriter": "*", "ext-xmlwriter": "*",
"nikic/php-parser": "^5.7.0", "nikic/php-parser": "^5.8.0",
"php": ">=8.4", "php": ">=8.4",
"phpunit/php-text-template": "^6.0", "phpunit/php-text-template": "^6.0",
"sebastian/complexity": "^6.0", "sebastian/complexity": "^6.0",
@@ -10050,7 +10097,7 @@
"theseer/tokenizer": "^2.0.1" "theseer/tokenizer": "^2.0.1"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^13.2.0" "phpunit/phpunit": "^13.2.2"
}, },
"suggest": { "suggest": {
"ext-pcov": "PHP extension that provides line coverage", "ext-pcov": "PHP extension that provides line coverage",
@@ -10088,7 +10135,7 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
"security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
"source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.2.2" "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.2.3"
}, },
"funding": [ "funding": [
{ {
@@ -10108,7 +10155,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-08T11:50:38+00:00" "time": "2026-07-06T15:04:02+00:00"
}, },
{ {
"name": "phpunit/php-file-iterator", "name": "phpunit/php-file-iterator",
@@ -10405,24 +10452,24 @@
}, },
{ {
"name": "phpunit/phpunit", "name": "phpunit/phpunit",
"version": "13.2.2", "version": "13.2.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git", "url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "492c067e618de7b3c76105082c90f9d2833401b7" "reference": "d76d0e24225e587d6a5f0c6f6d9fef0d90712b54"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/492c067e618de7b3c76105082c90f9d2833401b7", "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d76d0e24225e587d6a5f0c6f6d9fef0d90712b54",
"reference": "492c067e618de7b3c76105082c90f9d2833401b7", "reference": "d76d0e24225e587d6a5f0c6f6d9fef0d90712b54",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-dom": "*", "ext-dom": "*",
"ext-filter": "*",
"ext-json": "*", "ext-json": "*",
"ext-libxml": "*", "ext-libxml": "*",
"ext-mbstring": "*", "ext-mbstring": "*",
"ext-xml": "*",
"ext-xmlwriter": "*", "ext-xmlwriter": "*",
"myclabs/deep-copy": "^1.13.4", "myclabs/deep-copy": "^1.13.4",
"phar-io/manifest": "^2.0.4", "phar-io/manifest": "^2.0.4",
@@ -10485,7 +10532,7 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues", "issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy", "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.2" "source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.3"
}, },
"funding": [ "funding": [
{ {
@@ -10493,7 +10540,7 @@
"type": "other" "type": "other"
} }
], ],
"time": "2026-06-29T13:36:29+00:00" "time": "2026-07-06T14:55:56+00:00"
}, },
{ {
"name": "react/cache", "name": "react/cache",
@@ -11023,16 +11070,16 @@
}, },
{ {
"name": "rector/rector", "name": "rector/rector",
"version": "2.5.2", "version": "2.5.4",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/rectorphp/rector.git", "url": "https://github.com/rectorphp/rector.git",
"reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24" "reference": "adaa18d7cd6b3c960967cfbc98c03efb3116ac0e"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/rectorphp/rector/zipball/49ff6339174bdbdf50b0b35ecbcff14a05ac9e24", "url": "https://api.github.com/repos/rectorphp/rector/zipball/adaa18d7cd6b3c960967cfbc98c03efb3116ac0e",
"reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24", "reference": "adaa18d7cd6b3c960967cfbc98c03efb3116ac0e",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11071,7 +11118,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/rectorphp/rector/issues", "issues": "https://github.com/rectorphp/rector/issues",
"source": "https://github.com/rectorphp/rector/tree/2.5.2" "source": "https://github.com/rectorphp/rector/tree/2.5.4"
}, },
"funding": [ "funding": [
{ {
@@ -11079,7 +11126,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-06-22T11:39:33+00:00" "time": "2026-07-06T12:41:46+00:00"
}, },
{ {
"name": "sebastian/cli-parser", "name": "sebastian/cli-parser",
+3 -1
View File
@@ -15,6 +15,7 @@ use Symfony\Bundle\TwigBundle\TwigBundle;
use Symfony\Bundle\WebProfilerBundle\WebProfilerBundle; use Symfony\Bundle\WebProfilerBundle\WebProfilerBundle;
use Symfony\UX\StimulusBundle\StimulusBundle; use Symfony\UX\StimulusBundle\StimulusBundle;
use Symfony\UX\Turbo\TurboBundle; use Symfony\UX\Turbo\TurboBundle;
use SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle;
use SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle; use SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle;
use Symfonycasts\SassBundle\SymfonycastsSassBundle; use Symfonycasts\SassBundle\SymfonycastsSassBundle;
use Twig\Extra\TwigExtraBundle\TwigExtraBundle; use Twig\Extra\TwigExtraBundle\TwigExtraBundle;
@@ -30,10 +31,11 @@ return [
TwigExtraBundle::class => ['all' => true], TwigExtraBundle::class => ['all' => true],
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
SymfonyCastsVerifyEmailBundle::class => ['all' => true], SymfonyCastsVerifyEmailBundle::class => ['all' => true],
SentryBundle::class => ['prod' => true], SentryBundle::class => ['dev' => true, 'prod' => true],
SymfonycastsSassBundle::class => ['all' => true], SymfonycastsSassBundle::class => ['all' => true],
StimulusBundle::class => ['all' => true], StimulusBundle::class => ['all' => true],
TurboBundle::class => ['all' => true], TurboBundle::class => ['all' => true],
DAMADoctrineTestBundle::class => ['test' => true], DAMADoctrineTestBundle::class => ['test' => true],
StofDoctrineExtensionsBundle::class => ['all' => 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 # shortcut for private IP address ranges of your proxy
trusted_proxies: 'private_ranges' trusted_proxies: 'private_ranges'
# or, if your proxy instead uses the "Forwarded" header # or, if your proxy instead uses the "Forwarded" header
trusted_headers: [ 'forwarded' ] trusted_headers: [ 'x-forwarded-proto' ]
when@test: when@test:
framework: framework:
+2
View File
@@ -0,0 +1,2 @@
symfonycasts_reset_password:
request_password_repository: Tvdt\Repository\ResetPasswordRequestRepository
+1 -1
View File
@@ -30,7 +30,7 @@ security:
access_control: access_control:
- { path: ^/admin, roles: ROLE_ADMIN } - { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/backoffice, roles: ROLE_USER } - { path: ^/backoffice, roles: IS_AUTHENTICATED }
when@test: when@test:
security: security:
+7
View File
@@ -1,3 +1,10 @@
when@dev:
sentry:
dsn: 'https://placeholder@placeholder.ingest.sentry.io/0'
options:
spotlight: true
spotlight_url: 'http://spotlight:8969/stream'
when@prod: when@prod:
sentry: sentry:
dsn: '%env(SENTRY_DSN)%' dsn: '%env(SENTRY_DSN)%'
+2
View File
@@ -1,6 +1,8 @@
twig: twig:
file_name_pattern: '*.twig' file_name_pattern: '*.twig'
form_themes: [ 'bootstrap_5_layout.html.twig' ] form_themes: [ 'bootstrap_5_layout.html.twig' ]
globals:
sentry_dsn: '%env(SENTRY_DSN)%'
when@test: when@test:
twig: twig:
+11
View File
@@ -1490,6 +1490,12 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* skip_translation_on_load?: bool|Param, // Default: false * skip_translation_on_load?: bool|Param, // Default: false
* metadata_cache_pool?: scalar|Param|null, // Default: null * 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{ * @psalm-type ConfigType = array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
@@ -1505,6 +1511,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* stimulus?: StimulusConfig, * stimulus?: StimulusConfig,
* turbo?: TurboConfig, * turbo?: TurboConfig,
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig, * stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
* "when@dev"?: array{ * "when@dev"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
@@ -1518,10 +1525,12 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* web_profiler?: WebProfilerConfig, * web_profiler?: WebProfilerConfig,
* twig_extra?: TwigExtraConfig, * twig_extra?: TwigExtraConfig,
* symfonycasts_verify_email?: SymfonycastsVerifyEmailConfig, * symfonycasts_verify_email?: SymfonycastsVerifyEmailConfig,
* sentry?: SentryConfig,
* symfonycasts_sass?: SymfonycastsSassConfig, * symfonycasts_sass?: SymfonycastsSassConfig,
* stimulus?: StimulusConfig, * stimulus?: StimulusConfig,
* turbo?: TurboConfig, * turbo?: TurboConfig,
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig, * stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
* }, * },
* "when@prod"?: array{ * "when@prod"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
@@ -1539,6 +1548,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* stimulus?: StimulusConfig, * stimulus?: StimulusConfig,
* turbo?: TurboConfig, * turbo?: TurboConfig,
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig, * stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
* }, * },
* "when@test"?: array{ * "when@test"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
@@ -1557,6 +1567,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* turbo?: TurboConfig, * turbo?: TurboConfig,
* dama_doctrine_test?: DamaDoctrineTestConfig, * dama_doctrine_test?: DamaDoctrineTestConfig,
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig, * stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
* }, * },
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias * ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
* imports?: ImportsConfig, * imports?: ImportsConfig,
+7
View File
@@ -34,4 +34,11 @@ return [
'@hotwired/stimulus' => ['version' => '3.2.2'], '@hotwired/stimulus' => ['version' => '3.2.2'],
'@hotwired/turbo' => ['version' => '8.0.23'], '@hotwired/turbo' => ['version' => '8.0.23'],
'bootstrap-icons/font/bootstrap-icons.min.css' => ['version' => '1.13.1', 'type' => 'css'], 'bootstrap-icons/font/bootstrap-icons.min.css' => ['version' => '1.13.1', 'type' => 'css'],
'@sentry/browser' => ['version' => '10.63.0'],
'@sentry/feedback' => ['version' => '10.63.0'],
'@sentry/core/browser' => ['version' => '10.63.0'],
'@sentry/browser-utils' => ['version' => '10.63.0'],
'@sentry/replay' => ['version' => '10.63.0'],
'@sentry/replay-canvas' => ['version' => '10.63.0'],
'@sentry/core' => ['version' => '10.63.0'],
]; ];
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/** Auto-generated Migration: Please modify to your needs! */
final class Version20260707205155 extends AbstractMigration
{
#[\Override]
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE reset_password_request (id UUID NOT NULL, selector VARCHAR(20) NOT NULL, hashed_token VARCHAR(100) NOT NULL, requested_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, expires_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, user_id UUID NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_7CE748AA76ED395 ON reset_password_request (user_id)');
$this->addSql('ALTER TABLE reset_password_request ADD CONSTRAINT FK_7CE748AA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE');
}
#[\Override]
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE reset_password_request DROP CONSTRAINT FK_7CE748AA76ED395');
$this->addSql('DROP TABLE reset_password_request');
}
}
+19
View File
@@ -5,6 +5,9 @@ declare(strict_types=1);
namespace Tvdt\Controller; namespace Tvdt\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as AbstractBaseController; 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; use Tvdt\Enum\FlashType;
abstract class AbstractController extends AbstractBaseController abstract class AbstractController extends AbstractBaseController
@@ -13,6 +16,22 @@ abstract class AbstractController extends AbstractBaseController
protected const string CANDIDATE_HASH_REGEX = '[\w\-=]+'; 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] #[\Override]
protected function addFlash(FlashType|string $type, mixed $message): void protected function addFlash(FlashType|string $type, mixed $message): void
{ {
@@ -17,14 +17,13 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
use Tvdt\Controller\AbstractController; use Tvdt\Controller\AbstractController;
use Tvdt\Entity\Quiz; use Tvdt\Entity\Quiz;
use Tvdt\Entity\Season; use Tvdt\Entity\Season;
use Tvdt\Entity\User;
use Tvdt\Form\CreateSeasonFormType; use Tvdt\Form\CreateSeasonFormType;
use Tvdt\Repository\SeasonRepository; use Tvdt\Repository\SeasonRepository;
use Tvdt\Security\Voter\SeasonVoter; use Tvdt\Security\Voter\SeasonVoter;
use Tvdt\Service\QuizSpreadsheetService; use Tvdt\Service\QuizSpreadsheetService;
#[AsController] #[AsController]
#[IsGranted('ROLE_USER')] #[IsGranted('IS_AUTHENTICATED')]
final class BackofficeController extends AbstractController final class BackofficeController extends AbstractController
{ {
public function __construct( public function __construct(
@@ -37,12 +36,9 @@ final class BackofficeController extends AbstractController
#[Route('/backoffice/', name: 'tvdt_backoffice_index')] #[Route('/backoffice/', name: 'tvdt_backoffice_index')]
public function index(): Response public function index(): Response
{ {
$user = $this->getUser();
\assert($user instanceof User);
$seasons = $this->security->isGranted('ROLE_ADMIN') $seasons = $this->security->isGranted('ROLE_ADMIN')
? $this->seasonRepository->findAll() ? $this->seasonRepository->findAll()
: $this->seasonRepository->getSeasonsForUser($user); : $this->seasonRepository->getSeasonsForUser($this->authenticatedUser);
return $this->render('backoffice/index.html.twig', [ return $this->render('backoffice/index.html.twig', [
'seasons' => $seasons, 'seasons' => $seasons,
@@ -58,10 +54,7 @@ final class BackofficeController extends AbstractController
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$user = $this->getUser(); $season->addOwner($this->authenticatedUser);
\assert($user instanceof User);
$season->addOwner($user);
$season->generateSeasonCode(); $season->generateSeasonCode();
$this->em->persist($season); $this->em->persist($season);
@@ -38,7 +38,7 @@ use Tvdt\Security\Voter\SeasonVoter;
use Tvdt\Service\QuestionBankService; use Tvdt\Service\QuestionBankService;
#[AsController] #[AsController]
#[IsGranted('ROLE_USER')] #[IsGranted('IS_AUTHENTICATED')]
class QuestionBankController extends AbstractController class QuestionBankController extends AbstractController
{ {
public function __construct( public function __construct(
@@ -88,21 +88,33 @@ class QuestionBankController extends AbstractController
{ {
$bankQuestion = new BankQuestion(); $bankQuestion = new BankQuestion();
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]); $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->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$this->applyAnswerOrdering($bankQuestion);
$season->addBankQuestion($bankQuestion); $season->addBankQuestion($bankQuestion);
$this->em->persist($bankQuestion); $this->em->persist($bankQuestion);
$this->em->flush(); $this->em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Question added to the question bank')); $this->addFlash(FlashType::Success, $this->translator->trans('Question added to the question bank'));
if ($isTurboFrame) {
return new Response('<turbo-frame id="bank-question-modal-frame"></turbo-frame>');
}
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]); return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
} }
return $this->render('backoffice/question_bank/form.html.twig', [ $template = $isTurboFrame
? 'backoffice/question_bank/_frame.html.twig'
: 'backoffice/question_bank/form.html.twig';
return $this->render($template, [
'season' => $season, 'season' => $season,
'form' => $form, 'form' => $form,
'bankQuestion' => null, 'bankQuestion' => null,
@@ -120,7 +132,15 @@ class QuestionBankController extends AbstractController
{ {
$this->assertSameSeason($season, $bankQuestion->season); $this->assertSameSeason($season, $bankQuestion->season);
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]); $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->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
@@ -130,10 +150,18 @@ class QuestionBankController extends AbstractController
$this->addFlash(FlashType::Success, $this->translator->trans('Question updated')); $this->addFlash(FlashType::Success, $this->translator->trans('Question updated'));
if ($isTurboFrame) {
return new Response('<turbo-frame id="bank-question-modal-frame"></turbo-frame>');
}
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]); return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
} }
return $this->render('backoffice/question_bank/form.html.twig', [ $template = $isTurboFrame
? 'backoffice/question_bank/_frame.html.twig'
: 'backoffice/question_bank/form.html.twig';
return $this->render($template, [
'season' => $season, 'season' => $season,
'form' => $form, 'form' => $form,
'bankQuestion' => $bankQuestion, 'bankQuestion' => $bankQuestion,
@@ -342,21 +370,6 @@ class QuestionBankController extends AbstractController
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]); 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 private function syncUsagesAfterEdit(BankQuestion $bankQuestion): void
{ {
$pendingNames = []; $pendingNames = [];
+32 -39
View File
@@ -30,7 +30,7 @@ use Tvdt\Repository\QuizRepository;
use Tvdt\Security\Voter\SeasonVoter; use Tvdt\Security\Voter\SeasonVoter;
#[AsController] #[AsController]
#[IsGranted('ROLE_USER')] #[IsGranted('IS_AUTHENTICATED')]
class QuizController extends AbstractController class QuizController extends AbstractController
{ {
public function __construct( public function __construct(
@@ -61,25 +61,7 @@ class QuizController extends AbstractController
{ {
$fetchedQuiz = $this->quizRepository->fetchWithQuestionsAndCandidates($quiz->id); $fetchedQuiz = $this->quizRepository->fetchWithQuestionsAndCandidates($quiz->id);
// Create indexed lookup for quiz candidates by candidate ID $candidateData = $this->buildCandidateData($season, $quiz, $fetchedQuiz->candidateData);
$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', [ return $this->render('backoffice/quiz.html.twig', [
'season' => $season, 'season' => $season,
@@ -118,25 +100,7 @@ class QuizController extends AbstractController
)] )]
public function candidatesTab(Season $season, Quiz $quiz): Response public function candidatesTab(Season $season, Quiz $quiz): Response
{ {
// Create indexed lookup for quiz candidates by candidate ID $candidateData = $this->buildCandidateData($season, $quiz, $quiz->candidateData);
$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', [ return $this->render('backoffice/quiz.html.twig', [
'season' => $season, 'season' => $season,
@@ -432,4 +396,33 @@ class QuizController extends AbstractController
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]); 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;
}
} }
@@ -8,9 +8,11 @@ use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement; use Symfony\Component\Routing\Requirement\Requirement;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
use Tvdt\Controller\AbstractController; use Tvdt\Controller\AbstractController;
@@ -22,7 +24,7 @@ use Tvdt\Form\QuestionFormType;
use Tvdt\Security\Voter\SeasonVoter; use Tvdt\Security\Voter\SeasonVoter;
#[AsController] #[AsController]
#[IsGranted('ROLE_USER')] #[IsGranted('IS_AUTHENTICATED')]
class QuizQuestionController extends AbstractController class QuizQuestionController extends AbstractController
{ {
public function __construct( public function __construct(
@@ -42,22 +44,37 @@ class QuizQuestionController extends AbstractController
throw new NotFoundHttpException(); throw new NotFoundHttpException();
} }
$form = $this->createForm(QuestionFormType::class, $question); $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->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$this->applyAnswerOrdering($question);
$this->em->flush(); $this->em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Question updated')); $this->addFlash(FlashType::Success, $this->translator->trans('Question updated'));
if ($isTurboFrame) {
return new Response('<turbo-frame id="question-modal-frame"></turbo-frame>');
}
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', [ return $this->redirectToRoute('tvdt_backoffice_quiz_overview', [
'seasonCode' => $season->seasonCode, 'seasonCode' => $season->seasonCode,
'quiz' => $quiz->id, 'quiz' => $quiz->id,
]); ]);
} }
return $this->render('backoffice/quiz/question_form.html.twig', [ $template = $isTurboFrame
? 'backoffice/quiz/_question_frame.html.twig'
: 'backoffice/quiz/question_form.html.twig';
return $this->render($template, [
'season' => $season, 'season' => $season,
'quiz' => $quiz, 'quiz' => $quiz,
'question' => $question, 'question' => $question,
@@ -65,11 +82,62 @@ class QuizQuestionController extends AbstractController
]); ]);
} }
private function applyAnswerOrdering(Question $question): void #[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/question/{question}/view',
name: 'tvdt_backoffice_quiz_question_view',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID, 'question' => Requirement::UUID],
)]
public function view(Season $season, Quiz $quiz, Question $question): Response
{ {
$ordering = 1; if ($question->quiz !== $quiz || $quiz->season !== $season) {
foreach ($question->answers as $answer) { throw new NotFoundHttpException();
$answer->ordering = $ordering++;
} }
return $this->render('backoffice/quiz/_question_detail_frame.html.twig', [
'question' => $question,
]);
}
#[IsCsrfTokenValid('question_reorder')]
#[IsGranted(SeasonVoter::MODIFY_QUIZ_CONTENT, subject: 'quiz')]
#[Route(
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/questions/reorder',
name: 'tvdt_backoffice_quiz_questions_reorder',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID],
methods: ['POST'],
)]
public function reorder(Season $season, Quiz $quiz, Request $request): Response
{
if ($quiz->season !== $season) {
throw new NotFoundHttpException();
}
/** @var list<string> $ordering */
$ordering = $request->request->all('ordering');
$questionsById = [];
foreach ($quiz->questions as $question) {
$questionsById[$question->id->toString()] = $question;
}
foreach ($ordering as $questionId) {
if (!isset($questionsById[$questionId])) {
throw new BadRequestHttpException(\sprintf('Unknown question id: %s', $questionId));
}
}
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++;
}
$this->em->flush();
return new Response('', Response::HTTP_NO_CONTENT);
} }
} }
+56 -1
View File
@@ -10,10 +10,13 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route; 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\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Constraints\Length; use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\NotBlank;
@@ -26,17 +29,19 @@ use Tvdt\Enum\FlashType;
use Tvdt\Form\AddCandidatesFormType; use Tvdt\Form\AddCandidatesFormType;
use Tvdt\Form\SettingsForm; use Tvdt\Form\SettingsForm;
use Tvdt\Form\UploadQuizFormType; use Tvdt\Form\UploadQuizFormType;
use Tvdt\Repository\CandidateRepository;
use Tvdt\Security\Voter\SeasonVoter; use Tvdt\Security\Voter\SeasonVoter;
use Tvdt\Service\QuizSpreadsheetService; use Tvdt\Service\QuizSpreadsheetService;
#[AsController] #[AsController]
#[IsGranted('ROLE_USER')] #[IsGranted('IS_AUTHENTICATED')]
class SeasonController extends AbstractController class SeasonController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly TranslatorInterface $translator, private readonly TranslatorInterface $translator,
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
private readonly QuizSpreadsheetService $quizSpreadsheet, private readonly QuizSpreadsheetService $quizSpreadsheet,
private readonly CandidateRepository $candidateRepository,
) {} ) {}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')] #[IsGranted(SeasonVoter::EDIT, subject: 'season')]
@@ -123,6 +128,56 @@ class SeasonController extends AbstractController
return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form, 'season' => $season]); 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')] #[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route( #[Route(
'/backoffice/season/{seasonCode:season}/add-quiz', '/backoffice/season/{seasonCode:season}/add-quiz',
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace Tvdt\Controller\Backoffice;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
use Symfony\Contracts\Translation\TranslatorInterface;
use Tvdt\Controller\AbstractController;
use Tvdt\Entity\User;
use Tvdt\Enum\FlashType;
use Tvdt\Form\ChangeEmailFormType;
use Tvdt\Form\ChangeUserPasswordFormType;
use Tvdt\Repository\UserRepository;
use Tvdt\Security\EmailVerifier;
final class SettingsController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly UserRepository $userRepository,
private readonly EmailVerifier $emailVerifier,
private readonly Security $security,
private readonly TranslatorInterface $translator,
) {}
#[Route('/backoffice/settings', name: 'tvdt_backoffice_settings', methods: ['GET'])]
public function index(): Response
{
return $this->renderSettings();
}
#[IsCsrfTokenValid('settings_language')]
#[Route('/backoffice/settings/language', name: 'tvdt_backoffice_settings_language', methods: ['POST'])]
public function saveLanguage(): RedirectResponse
{
// Only Dutch is available for now, so saving is a noop.
$this->addFlash(FlashType::Success, $this->translator->trans('Language saved'));
return $this->redirectToRoute('tvdt_backoffice_settings');
}
#[Route('/backoffice/settings/password', name: 'tvdt_backoffice_settings_password', methods: ['POST'])]
public function changePassword(Request $request): Response
{
$user = $this->authenticatedUser;
$form = $this->createForm(ChangeUserPasswordFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var string $plainPassword */
$plainPassword = $form->get('plainPassword')->getData();
$user->password = $this->passwordHasher->hashPassword($user, $plainPassword);
$this->entityManager->flush();
$this->userRepository->invalidateResetPasswordRequests($user);
$this->security->login($user, 'form_login', 'main');
$this->addFlash(FlashType::Success, $this->translator->trans('Your password has been changed.'));
return $this->redirectToRoute('tvdt_backoffice_settings');
}
return $this->renderSettings(passwordForm: $form);
}
#[Route('/backoffice/settings/email', name: 'tvdt_backoffice_settings_email', methods: ['POST'])]
public function changeEmail(Request $request): Response
{
$user = $this->authenticatedUser;
$form = $this->createForm(ChangeEmailFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var string $email */
$email = $form->get('email')->getData();
$found = $this->userRepository->findOneBy(['email' => $email]);
if ($found instanceof User && $found !== $user) {
$form->get('email')->addError(new FormError($this->translator->trans('There is already an account with this email')));
return $this->renderSettings(emailForm: $form);
}
$originalEmail = $user->email;
$originalIsVerified = $user->isVerified;
$user->email = $email;
$user->isVerified = false;
try {
$this->entityManager->flush();
} catch (UniqueConstraintViolationException) {
// A concurrent request can claim the email between the uniqueness check above and the flush
$user->email = $originalEmail;
$user->isVerified = $originalIsVerified;
$form->get('email')->addError(new FormError($this->translator->trans('There is already an account with this email')));
return $this->renderSettings(emailForm: $form);
}
$this->userRepository->invalidateResetPasswordRequests($user);
if ($this->emailVerifier->sendDefaultConfirmation($user)) {
$this->addFlash(FlashType::Success, $this->translator->trans('Your email address has been changed. Please check your inbox to confirm it.'));
} else {
$this->addFlash(FlashType::Success, $this->translator->trans('Your email address has been changed.'));
$this->addFlash(FlashType::Warning, $this->translator->trans('The confirmation email could not be sent. Please use the resend button to try again.'));
}
$this->security->login($user, 'form_login', 'main');
return $this->redirectToRoute('tvdt_backoffice_settings');
}
return $this->renderSettings(emailForm: $form);
}
#[IsCsrfTokenValid('resend_confirmation')]
#[Route('/backoffice/settings/resend-confirmation', name: 'tvdt_backoffice_settings_resend_confirmation', methods: ['POST'])]
public function resendConfirmationEmail(): RedirectResponse
{
$user = $this->authenticatedUser;
if ($user->isVerified) {
$this->addFlash(FlashType::Info, $this->translator->trans('Your email address is already confirmed.'));
return $this->redirectToRoute('tvdt_backoffice_settings');
}
if ($this->emailVerifier->sendDefaultConfirmation($user)) {
$this->addFlash(FlashType::Success, $this->translator->trans('A new confirmation email has been sent. Please check your inbox.'));
} else {
$this->addFlash(FlashType::Warning, $this->translator->trans('The confirmation email could not be sent. Please try again later.'));
}
return $this->redirectToRoute('tvdt_backoffice_settings');
}
#[IsCsrfTokenValid('delete_account')]
#[Route('/backoffice/settings/delete', name: 'tvdt_backoffice_settings_delete', methods: ['POST'])]
public function deleteAccount(Request $request): Response
{
$user = $this->authenticatedUser;
$password = (string) $request->request->get('password', '');
if (!$this->passwordHasher->isPasswordValid($user, $password)) {
$this->addFlash(FlashType::Danger, $this->translator->trans('Wrong password, your account has not been deleted.'));
return $this->redirectToRoute('tvdt_backoffice_settings');
}
$this->userRepository->deleteUser($user);
return $this->security->logout(false) ?? $this->redirectToRoute('tvdt_login_login');
}
/**
* @param FormInterface<array{currentPassword: string, plainPassword: string}|null>|null $passwordForm
* @param FormInterface<array{email: string}|null>|null $emailForm
*/
private function renderSettings(?FormInterface $passwordForm = null, ?FormInterface $emailForm = null): Response
{
return $this->render('backoffice/settings/index.html.twig', [
'passwordForm' => $passwordForm ?? $this->createForm(ChangeUserPasswordFormType::class),
'emailForm' => $emailForm ?? $this->createForm(ChangeEmailFormType::class),
]);
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ use Tvdt\Security\Voter\SeasonVoter;
use function Symfony\Component\Translation\t; use function Symfony\Component\Translation\t;
#[AsController] #[AsController]
#[IsGranted('ROLE_USER')] #[IsGranted('IS_AUTHENTICATED')]
final class EliminationController extends AbstractController final class EliminationController extends AbstractController
{ {
public function __construct(private readonly TranslatorInterface $translator, private readonly CandidateRepository $candidateRepository) {} public function __construct(private readonly TranslatorInterface $translator, private readonly CandidateRepository $candidateRepository) {}
+3 -15
View File
@@ -5,14 +5,11 @@ declare(strict_types=1);
namespace Tvdt\Controller; namespace Tvdt\Controller;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
@@ -26,7 +23,7 @@ use Tvdt\Security\EmailVerifier;
final class RegistrationController extends AbstractController final class RegistrationController extends AbstractController
{ {
public function __construct(private readonly EmailVerifier $emailVerifier, private readonly TranslatorInterface $translator, private readonly UserPasswordHasherInterface $userPasswordHasher, private readonly Security $security, private readonly LoggerInterface $logger, private readonly UserRepository $userRepository, private readonly EntityManagerInterface $entityManager) {} public function __construct(private readonly EmailVerifier $emailVerifier, private readonly TranslatorInterface $translator, private readonly UserPasswordHasherInterface $userPasswordHasher, private readonly Security $security, private readonly UserRepository $userRepository, private readonly EntityManagerInterface $entityManager) {}
#[Route('/register', name: 'tvdt_register')] #[Route('/register', name: 'tvdt_register')]
public function register( public function register(
@@ -49,17 +46,8 @@ final class RegistrationController extends AbstractController
$this->entityManager->persist($user); $this->entityManager->persist($user);
$this->entityManager->flush(); $this->entityManager->flush();
try { // generate a signed url and email it to the user
// generate a signed url and email it to the user $this->emailVerifier->sendDefaultConfirmation($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'); $response = $this->security->login($user, 'form_login', 'main');
\assert($response instanceof Response); \assert($response instanceof Response);
+147
View File
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Tvdt\Controller;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Translation\TranslatorInterface;
use SymfonyCasts\Bundle\ResetPassword\Controller\ResetPasswordControllerTrait;
use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface;
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordToken;
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
use Tvdt\Entity\User;
use Tvdt\Enum\FlashType;
use Tvdt\Form\ChangePasswordFormType;
use Tvdt\Form\ResetPasswordRequestFormType;
final class ResetPasswordController extends AbstractController
{
use ResetPasswordControllerTrait;
public function __construct(
private readonly ResetPasswordHelperInterface $resetPasswordHelper,
private readonly EntityManagerInterface $entityManager,
private readonly MailerInterface $mailer,
private readonly TranslatorInterface $translator,
private readonly UserPasswordHasherInterface $passwordHasher,
) {}
#[Route('/reset-password', name: 'tvdt_forgot_password_request')]
public function request(Request $request): Response
{
$form = $this->createForm(ResetPasswordRequestFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var string $email */
$email = $form->get('email')->getData();
return $this->processSendingPasswordResetEmail($email, $this->mailer, $this->translator);
}
return $this->render('reset_password/request.html.twig', [
'requestForm' => $form,
]);
}
#[Route('/reset-password/check-email', name: 'tvdt_check_email')]
public function checkEmail(): Response
{
if (!($resetToken = $this->getTokenObjectFromSession()) instanceof ResetPasswordToken) {
$resetToken = $this->resetPasswordHelper->generateFakeResetToken();
}
return $this->render('reset_password/check_email.html.twig', [
'resetToken' => $resetToken,
]);
}
#[Route('/reset-password/reset/{token}', name: 'tvdt_reset_password')]
public function reset(Request $request, ?string $token = null): Response
{
if ($token) {
$this->storeTokenInSession($token);
return $this->redirectToRoute('tvdt_reset_password');
}
$token = $this->getTokenFromSession();
if (null === $token) {
throw $this->createNotFoundException('No reset password token found in the URL or in the session.');
}
try {
/** @var User $user */
$user = $this->resetPasswordHelper->validateTokenAndFetchUser($token);
} catch (ResetPasswordExceptionInterface $resetPasswordException) {
$this->addFlash(FlashType::Danger->value, \sprintf(
'%s - %s',
$this->translator->trans(ResetPasswordExceptionInterface::MESSAGE_PROBLEM_VALIDATE, [], 'ResetPasswordBundle'),
$this->translator->trans($resetPasswordException->getReason(), [], 'ResetPasswordBundle'),
));
return $this->redirectToRoute('tvdt_forgot_password_request');
}
$form = $this->createForm(ChangePasswordFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->resetPasswordHelper->removeResetRequest($token);
/** @var string $plainPassword */
$plainPassword = $form->get('plainPassword')->getData();
$user->password = $this->passwordHasher->hashPassword($user, $plainPassword);
$this->entityManager->flush();
$this->cleanSessionAfterReset();
return $this->redirectToRoute('tvdt_backoffice_index');
}
return $this->render('reset_password/reset.html.twig', [
'resetForm' => $form,
]);
}
private function processSendingPasswordResetEmail(string $emailFormData, MailerInterface $mailer, TranslatorInterface $translator): RedirectResponse
{
$user = $this->entityManager->getRepository(User::class)->findOneBy([
'email' => $emailFormData,
]);
if (!$user instanceof User) {
return $this->redirectToRoute('tvdt_check_email');
}
try {
$resetToken = $this->resetPasswordHelper->generateResetToken($user);
} catch (ResetPasswordExceptionInterface) {
return $this->redirectToRoute('tvdt_check_email');
}
$email = new TemplatedEmail()
->to($user->getUserIdentifier())
->subject($translator->trans('Your password reset request'))
->htmlTemplate('reset_password/email.html.twig')
->context([
'resetToken' => $resetToken,
]);
$mailer->send($email);
$this->setTokenObjectInSession($resetToken);
return $this->redirectToRoute('tvdt_check_email');
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Tvdt\Controller;
use Safe\DateTimeImmutable;
use Safe\Exceptions\DatetimeException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/** Serves well-known URIs (https://www.rfc-editor.org/rfc/rfc8615). */
final class WellKnownController extends AbstractController
{
public function __construct(
#[Autowire(env: 'default::BUILD_TIME')]
private readonly ?string $buildTime,
#[Autowire(env: 'APP_ENV')]
private readonly string $appEnv,
) {}
/** @see https://w3c.github.io/webappsec-change-password-url/ */
#[Route('/.well-known/change-password', name: 'tvdt_well_known_change_password', methods: ['GET'])]
public function changePassword(): RedirectResponse
{
return $this->redirectToRoute('tvdt_backoffice_settings');
}
/**
* @see https://www.rfc-editor.org/rfc/rfc9116
*
* @throws DatetimeException
* @throws \Exception
*/
#[Route('/.well-known/security.txt', name: 'tvdt_well_known_security_txt', methods: ['GET'])]
public function securityTxt(): Response
{
// One year after the container build, so the file goes stale when deployments stop.
// In prod the build arg must be set; falling back to 'now' would renew Expires on every request,
// defeating the go-stale purpose. In dev/test 'now' is fine — no build bake happens there.
if ((null === $this->buildTime || '' === $this->buildTime) && 'prod' === $this->appEnv) {
throw new \LogicException('BUILD_TIME env var must be set in production (baked in during Docker build).');
}
$buildTime = (null !== $this->buildTime && '' !== $this->buildTime) ? $this->buildTime : 'now';
$expires = new DateTimeImmutable($buildTime)->modify('+1 year')->format(\DATE_RFC3339);
$content = <<<TXT
Contact: https://github.com/MarijnDoeve/TijdVoorDeTest/security/advisories/new
Expires: {$expires}
Preferred-Languages: nl, en
TXT;
return new Response($content, headers: ['Content-Type' => 'text/plain; charset=UTF-8']);
}
}
+31
View File
@@ -9,6 +9,10 @@ use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager; use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; 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\Season;
use Tvdt\Entity\User; use Tvdt\Entity\User;
@@ -70,6 +74,33 @@ final class TestFixtures extends Fixture implements FixtureGroupInterface, Depen
$krtek->addOwner($user); $krtek->addOwner($user);
$anotherSeason->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(); $manager->flush();
} }
} }
+7
View File
@@ -54,6 +54,13 @@ class Question implements \Stringable
return $this; return $this;
} }
public function removeAnswer(Answer $answer): static
{
$this->answers->removeElement($answer);
return $this;
}
public function __toString(): string public function __toString(): string
{ {
return $this->question ?? ''; return $this->question ?? '';
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Tvdt\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestTrait;
use Tvdt\Repository\ResetPasswordRequestRepository;
#[ORM\Entity(repositoryClass: ResetPasswordRequestRepository::class)]
class ResetPasswordRequest implements ResetPasswordRequestInterface
{
use ResetPasswordRequestTrait;
#[ORM\Column(type: UuidType::NAME, unique: true)]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\Id]
public private(set) Uuid $id;
public function __construct(#[ORM\JoinColumn(nullable: false)]
#[ORM\ManyToOne]
private User $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken)
{
$this->initialize($expiresAt, $selector, $hashedToken);
}
public function getUser(): User
{
return $this->user;
}
}
+4
View File
@@ -21,6 +21,10 @@ use Tvdt\Repository\UserRepository;
#[UniqueEntity(fields: ['email'], message: 'There is already an account with this email')] #[UniqueEntity(fields: ['email'], message: 'There is already an account with this email')]
class User implements UserInterface, PasswordAuthenticatedUserInterface 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\Column(type: UuidType::NAME, unique: true)]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
#[ORM\GeneratedValue(strategy: 'CUSTOM')] #[ORM\GeneratedValue(strategy: 'CUSTOM')]
+1
View File
@@ -43,6 +43,7 @@ class BankQuestionFormType extends AbstractType
'multiple' => true, 'multiple' => true,
'expanded' => true, 'expanded' => true,
'required' => false, 'required' => false,
'choice_attr' => static fn (QuestionLabel $label): array => ['data-colour' => $label->colour->value],
'query_builder' => static fn (QuestionLabelRepository $repository): QueryBuilder => $repository 'query_builder' => static fn (QuestionLabelRepository $repository): QueryBuilder => $repository
->createQueryBuilder('l') ->createQueryBuilder('l')
->where('l.season = :season') ->where('l.season = :season')
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Tvdt\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Contracts\Translation\TranslatorInterface;
/** @extends AbstractType<array{email: string}> */
final class ChangeEmailFormType extends AbstractType
{
public function __construct(private readonly TranslatorInterface $translator) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email', EmailType::class, [
'label' => $this->translator->trans('New email address'),
'attr' => ['autocomplete' => 'email'],
'mapped' => false,
'constraints' => [
new NotBlank(message: 'Please enter an email address'),
new Email(),
],
'translation_domain' => false,
])
->add('save', SubmitType::class, [
'label' => $this->translator->trans('Change email'),
'translation_domain' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Tvdt\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Contracts\Translation\TranslatorInterface;
use Tvdt\Entity\User;
/** @extends AbstractType<array{plainPassword: string}> */
final class ChangePasswordFormType extends AbstractType
{
public function __construct(private readonly TranslatorInterface $translator) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'options' => [
'attr' => ['autocomplete' => 'new-password'],
],
'first_options' => [
'label' => $this->translator->trans('New password'),
'constraints' => [
new NotBlank(message: 'Please enter a password'),
new Length(
min: User::PASSWORD_MIN_LENGTH,
max: User::PASSWORD_MAX_LENGTH,
minMessage: 'Your password should be at least {{ limit }} characters',
),
],
],
'second_options' => [
'label' => $this->translator->trans('Repeat Password'),
],
'invalid_message' => $this->translator->trans('The password fields must match.'),
'mapped' => false,
'translation_domain' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Tvdt\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Security\Core\Validator\Constraints\UserPassword;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Contracts\Translation\TranslatorInterface;
use Tvdt\Entity\User;
/** @extends AbstractType<array{currentPassword: string, plainPassword: string}> */
final class ChangeUserPasswordFormType extends AbstractType
{
public function __construct(private readonly TranslatorInterface $translator) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('currentPassword', PasswordType::class, [
'label' => $this->translator->trans('Current password'),
'attr' => ['autocomplete' => 'current-password'],
'mapped' => false,
'constraints' => [
new NotBlank(message: 'Please enter your current password'),
new UserPassword(message: 'This is not your current password.'),
],
'translation_domain' => false,
])
->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'options' => [
'attr' => ['autocomplete' => 'new-password'],
],
'first_options' => [
'label' => $this->translator->trans('New password'),
'constraints' => [
new NotBlank(message: 'Please enter a password'),
new Length(
min: User::PASSWORD_MIN_LENGTH,
max: User::PASSWORD_MAX_LENGTH,
minMessage: 'Your password should be at least {{ limit }} characters',
),
],
],
'second_options' => [
'label' => $this->translator->trans('Repeat Password'),
],
'invalid_message' => $this->translator->trans('The password fields must match.'),
'mapped' => false,
'translation_domain' => false,
])
->add('save', SubmitType::class, [
'label' => $this->translator->trans('Change password'),
'translation_domain' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ class RegistrationFormType extends AbstractType
'mapped' => false, 'mapped' => false,
'constraints' => [ 'constraints' => [
new NotBlank(message: 'Please enter a password'), new NotBlank(message: 'Please enter a password'),
new Length(min: 8, max: 4096, minMessage: 'Your password should be at least {{ limit }} characters'), new Length(min: User::PASSWORD_MIN_LENGTH, max: User::PASSWORD_MAX_LENGTH, minMessage: 'Your password should be at least {{ limit }} characters'),
], ],
'translation_domain' => false, 'translation_domain' => false,
]) ])
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Tvdt\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Contracts\Translation\TranslatorInterface;
/** @extends AbstractType<array{email: string}> */
final class ResetPasswordRequestFormType extends AbstractType
{
public function __construct(private readonly TranslatorInterface $translator) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email', EmailType::class, [
'label' => $this->translator->trans('Email'),
'attr' => ['autocomplete' => 'email'],
'translation_domain' => false,
'constraints' => [
new NotBlank(message: 'Please enter your email'),
],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}
+6
View File
@@ -19,6 +19,12 @@ class CandidateRepository extends ServiceEntityRepository
parent::__construct($registry, Candidate::class); 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 public function getCandidateByHash(Season $season, string $hash): ?Candidate
{ {
try { try {
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Tvdt\Repository;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
use SymfonyCasts\Bundle\ResetPassword\Persistence\Repository\ResetPasswordRequestRepositoryTrait;
use SymfonyCasts\Bundle\ResetPassword\Persistence\ResetPasswordRequestRepositoryInterface;
use Tvdt\Entity\ResetPasswordRequest;
use Tvdt\Entity\User;
/** @extends ServiceEntityRepository<ResetPasswordRequest> */
final class ResetPasswordRequestRepository extends ServiceEntityRepository implements ResetPasswordRequestRepositoryInterface
{
use ResetPasswordRequestRepositoryTrait;
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ResetPasswordRequest::class);
}
public function createResetPasswordRequest(object $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken): ResetPasswordRequestInterface
{
\assert($user instanceof User);
return new ResetPasswordRequest($user, $expiresAt, $selector, $hashedToken);
}
}
+31
View File
@@ -28,6 +28,37 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader
$this->getEntityManager()->flush(); $this->getEntityManager()->flush();
} }
/** Deletes all outstanding reset-password tokens for the user (e.g. after a password or email change). */
public function invalidateResetPasswordRequests(User $user): void
{
$this->getEntityManager()
->createQuery('delete from Tvdt\Entity\ResetPasswordRequest r where r.user = :user')
->setParameter('user', $user)
->execute();
}
/** Deletes the user, all seasons the user is the sole owner of, and the user's ownership of shared seasons. */
public function deleteUser(User $user): void
{
$em = $this->getEntityManager();
$em->wrapInTransaction(function () use ($em, $user): void {
$this->invalidateResetPasswordRequests($user);
foreach ($user->seasons->toArray() as $season) {
if (1 === $season->owners->count()) {
$em->remove($season);
continue;
}
$season->removeOwner($user);
}
$em->remove($user);
$em->flush();
});
}
public function makeAdmin(string $email): void public function makeAdmin(string $email): void
{ {
$user = $this->findOneBy(['email' => $email]); $user = $this->findOneBy(['email' => $email]);
+23
View File
@@ -5,10 +5,12 @@ declare(strict_types=1);
namespace Tvdt\Security; namespace Tvdt\Security;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\MailerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface; use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface;
use Tvdt\Entity\User; use Tvdt\Entity\User;
@@ -18,8 +20,29 @@ readonly class EmailVerifier
private VerifyEmailHelperInterface $verifyEmailHelper, private VerifyEmailHelperInterface $verifyEmailHelper,
private MailerInterface $mailer, private MailerInterface $mailer,
private EntityManagerInterface $entityManager, 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 */ /** @throws TransportExceptionInterface */
public function sendEmailConfirmation(string $verifyEmailRouteName, User $user, TemplatedEmail $email): void public function sendEmailConfirmation(string $verifyEmailRouteName, User $user, TemplatedEmail $email): void
{ {
+12
View File
@@ -356,6 +356,18 @@
"config/routes/web_profiler.yaml" "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": { "symfonycasts/sass-bundle": {
"version": "v0.8.2" "version": "v0.8.2"
}, },
+5
View File
@@ -1,5 +1,10 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block importmap %}{{ importmap('backoffice') }}{% endblock %} {% block importmap %}{{ importmap('backoffice') }}{% endblock %}
{% block sentry_loader %}{% endblock %}
{% block head_extra %}
<meta name="sentry-dsn" content="{{ sentry_dsn }}">
<meta name="user-email" content="{{ app.user ? app.user.userIdentifier : '' }}">
{% endblock %}
{% block title %}Tijd voor de test | {% endblock %} {% block title %}Tijd voor de test | {% endblock %}
{% block nav %}{{ include('backoffice/nav.html.twig') }}{% endblock %} {% block nav %}{{ include('backoffice/nav.html.twig') }}{% endblock %}
@@ -1,3 +1,4 @@
<h6>Kandidaten</h6> <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>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>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>
+3 -1
View File
@@ -4,7 +4,7 @@
{% block body %} {% block body %}
<form method="post"> <form method="post">
<h1 class="py-2 h3 mb-3 font-weight-normal">{{ 'Please sign in'|trans }}</h1> <h3 class="mb-3">{{ 'Please sign in'|trans }}</h3>
<div class="mb-3"> <div class="mb-3">
<label for="username" class="form-label">{{ 'Email'|trans }}</label> <label for="username" class="form-label">{{ 'Email'|trans }}</label>
<input type="email" value="{{ last_username }}" name="_username" id="username" class="form-control" <input type="email" value="{{ last_username }}" name="_username" id="username" class="form-control"
@@ -31,5 +31,7 @@
</button> </button>
<a href="{{ path('tvdt_register') }}" <a href="{{ path('tvdt_register') }}"
class="btn btn-link">{{ 'Create an account'|trans }}</a> 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> </form>
{% endblock %} {% endblock %}
+4
View File
@@ -23,6 +23,10 @@
</li> </li>
</ul> </ul>
<ul class="navbar-nav mb-auto me-2 me-lg-0"> <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"> <li class="nav-item">
<a class="nav-link" <a class="nav-link"
href="{{ path('tvdt_login_logout') }}">{{ 'Logout'|trans }}</a> href="{{ path('tvdt_login_logout') }}">{{ 'Logout'|trans }}</a>
@@ -1,7 +1,10 @@
{% macro answer_row(answerForm) %} {% macro answer_row(answerForm) %}
<div class="d-flex align-items-center gap-2 mb-2" data-collection-item> <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">
{{ form_widget(answerForm.ordering) }} {{ form_widget(answerForm.ordering) }}
<span class="text-muted" data-drag-handle style="cursor: grab" title="{{ 'Drag to reorder'|trans }}"><i class="bi bi-grip-vertical"></i></span> <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>
<div class="flex-grow-1">{{ form_widget(answerForm.text) }}</div> <div class="flex-grow-1">{{ form_widget(answerForm.text) }}</div>
<div class="d-none">{{ form_widget(answerForm.isRightAnswer) }}</div> <div class="d-none">{{ form_widget(answerForm.isRightAnswer) }}</div>
<button type="button" <button type="button"
@@ -10,7 +13,7 @@
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'"> 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'">
<i class="{{ answerForm.isRightAnswer.vars.checked ? 'bi bi-check-lg' : 'bi bi-x-lg' }}"></i> <i class="{{ answerForm.isRightAnswer.vars.checked ? 'bi bi-check-lg' : 'bi bi-x-lg' }}"></i>
</button> </button>
<button type="button" class="btn btn-sm btn-outline-danger" <button type="button" tabindex="-1" class="btn btn-sm btn-outline-danger"
data-action="bo--form-collection#removeItem"><i class="bi bi-trash"></i></button> data-action="bo--form-collection#removeItem"><i class="bi bi-trash"></i></button>
</div> </div>
{% endmacro %} {% endmacro %}
@@ -0,0 +1,54 @@
{% import 'backoffice/partials/answer_row.html.twig' as macros %}
{{ 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>
<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">
{% for answerForm in form.answers %}
{{ macros.answer_row(answerForm) }}
{% endfor %}
</div>
{% do form.answers.setRendered %}
<div class="d-flex gap-2 mb-3">
<button type="button" class="btn btn-sm btn-outline-primary"
data-action="bo--form-collection#addItem">{{ 'Add answer'|trans }}</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="bo--form-collection#sortAlphabetically">{{ 'Sort AZ'|trans }}</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="bo--form-collection#randomize">{{ 'Randomize'|trans }}</button>
</div>
</div>
{% if isModal ?? false %}
{% do form.save.setRendered %}
{% endif %}
{{ form_end(form) }}
{% if isModal ?? false %}
<template data-modal-footer>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
<button type="submit" class="btn btn-primary">{{ 'Save'|trans }}</button>
</template>
{% endif %}
@@ -0,0 +1,51 @@
{% import 'backoffice/partials/answer_row.html.twig' as macros %}
<turbo-frame id="bank-question-modal-frame">
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
<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>
<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">
{% for answerForm in form.answers %}
{{ macros.answer_row(answerForm) }}
{% endfor %}
</div>
{% do form.answers.setRendered %}
<div class="d-flex gap-2 mb-3">
<button type="button" class="btn btn-sm btn-outline-primary"
data-action="bo--form-collection#addItem">{{ 'Add answer'|trans }}</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="bo--form-collection#sortAlphabetically">{{ 'Sort AZ'|trans }}</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="bo--form-collection#randomize">{{ 'Randomize'|trans }}</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
{{ form_widget(form.save, {attr: {class: 'btn btn-primary'}}) }}
</div>
{{ form_end(form) }}
</turbo-frame>
@@ -18,33 +18,7 @@
<div class="row"> <div class="row">
<div class="col-md-6 col-12"> <div class="col-md-6 col-12">
<h2 class="mb-3">{{ bankQuestion is null ? 'Add question'|trans : 'Edit question'|trans }}</h2> <h2 class="mb-3">{{ bankQuestion is null ? 'Add question'|trans : 'Edit question'|trans }}</h2>
{{ include('backoffice/question_bank/_form_body.html.twig') }}
{{ form_start(form) }}
{{ form_row(form.question) }}
{{ form_row(form.reusable) }}
{{ 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">
{% for answerForm in form.answers %}
{{ macros.answer_row(answerForm) }}
{% endfor %}
</div>
{% do form.answers.setRendered %}
<div class="d-flex gap-2 mb-3">
<button type="button" class="btn btn-sm btn-outline-primary"
data-action="bo--form-collection#addItem">{{ 'Add answer'|trans }}</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="bo--form-collection#sortAlphabetically">{{ 'Sort AZ'|trans }}</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="bo--form-collection#randomize">{{ 'Randomize'|trans }}</button>
</div>
</div>
{{ form_end(form) }}
</div> </div>
<div class="col-md-6 col-12"> <div class="col-md-6 col-12">
{{ include('backoffice/help/quiz_question_bank_form.html.twig') }} {{ include('backoffice/help/quiz_question_bank_form.html.twig') }}
@@ -0,0 +1,21 @@
<p class="fw-semibold mb-3">{{ question.question }}</p>
<ul class="list-group">
{% for answer in question.answers %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span{% if answer.isRightAnswer %} class="fw-semibold"{% endif %}>
{% if answer.isRightAnswer %}<i class="bi bi-check-circle-fill text-success me-1"></i>{% endif %}
{{ answer.text }}
</span>
{% if answer.candidates|length > 0 %}
<span class="text-muted small">{{ answer.candidates|map(c => c.name)|join(', ') }}</span>
{% endif %}
</li>
{% else %}
<li class="list-group-item text-muted">{{ 'There are no answers for this question'|trans }}</li>
{% endfor %}
</ul>
<template data-modal-footer>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Close'|trans }}</button>
</template>
@@ -0,0 +1,23 @@
<turbo-frame id="question-modal-frame">
<div class="modal-body">
<p class="fw-semibold mb-3">{{ question.question }}</p>
<ul class="list-group">
{% for answer in question.answers %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span{% if answer.isRightAnswer %} class="fw-semibold"{% endif %}>
{% if answer.isRightAnswer %}<i class="bi bi-check-circle-fill text-success me-1"></i>{% endif %}
{{ answer.text }}
</span>
{% if answer.candidates|length > 0 %}
<span class="text-muted small">{{ answer.candidates|map(c => c.name)|join(', ') }}</span>
{% endif %}
</li>
{% else %}
<li class="list-group-item text-muted">{{ 'There are no answers for this question'|trans }}</li>
{% endfor %}
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Close'|trans }}</button>
</div>
</turbo-frame>
@@ -0,0 +1,36 @@
{% import 'backoffice/partials/answer_row.html.twig' as macros %}
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
{{ form_row(form.question) }}
<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">
{% for answerForm in form.answers %}
{{ macros.answer_row(answerForm) }}
{% endfor %}
</div>
{% do form.answers.setRendered %}
<div class="d-flex gap-2 mb-3">
<button type="button" class="btn btn-sm btn-outline-primary"
data-action="bo--form-collection#addItem">{{ 'Add answer'|trans }}</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="bo--form-collection#sortAlphabetically">{{ 'Sort AZ'|trans }}</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="bo--form-collection#randomize">{{ 'Randomize'|trans }}</button>
</div>
</div>
{% if isModal ?? false %}
{% do form.save.setRendered %}
{% endif %}
{{ form_end(form) }}
{% if isModal ?? false %}
<template data-modal-footer>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
<button type="submit" class="btn btn-primary">{{ 'Save'|trans }}</button>
</template>
{% endif %}
@@ -0,0 +1,33 @@
{% import 'backoffice/partials/answer_row.html.twig' as macros %}
<turbo-frame id="question-modal-frame">
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
<div class="modal-body">
{{ form_row(form.question) }}
<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">
{% for answerForm in form.answers %}
{{ macros.answer_row(answerForm) }}
{% endfor %}
</div>
{% do form.answers.setRendered %}
<div class="d-flex gap-2 mb-3">
<button type="button" class="btn btn-sm btn-outline-primary"
data-action="bo--form-collection#addItem">{{ 'Add answer'|trans }}</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="bo--form-collection#sortAlphabetically">{{ 'Sort AZ'|trans }}</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="bo--form-collection#randomize">{{ 'Randomize'|trans }}</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
{{ form_widget(form.save, {attr: {class: 'btn btn-primary'}}) }}
</div>
{{ form_end(form) }}
</turbo-frame>
@@ -18,31 +18,7 @@
<div class="row"> <div class="row">
<div class="col-md-6 col-12"> <div class="col-md-6 col-12">
<h2 class="mb-3">{{ 'Edit question'|trans }}</h2> <h2 class="mb-3">{{ 'Edit question'|trans }}</h2>
{{ include('backoffice/quiz/_question_form_body.html.twig') }}
{{ form_start(form) }}
{{ form_row(form.question) }}
<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">
{% for answerForm in form.answers %}
{{ macros.answer_row(answerForm) }}
{% endfor %}
</div>
{% do form.answers.setRendered %}
<div class="d-flex gap-2 mb-3">
<button type="button" class="btn btn-sm btn-outline-primary"
data-action="bo--form-collection#addItem">{{ 'Add answer'|trans }}</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="bo--form-collection#sortAlphabetically">{{ 'Sort AZ'|trans }}</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="bo--form-collection#randomize">{{ 'Randomize'|trans }}</button>
</div>
</div>
{{ form_end(form) }}
</div> </div>
<div class="col-md-6 col-12"> <div class="col-md-6 col-12">
{{ include('backoffice/help/quiz_question_bank_form.html.twig') }} {{ include('backoffice/help/quiz_question_bank_form.html.twig') }}
@@ -73,10 +73,10 @@
</button> </button>
</form> </form>
{% endif %} {% endif %}
<button class="btn btn-danger" data-action="click->bo--quiz#clearQuiz"> <button class="btn btn-danger rounded-0" data-action="click->bo--quiz#clearQuiz">
{{ 'Clear Quiz...'|trans }} {{ 'Clear Quiz...'|trans }}
</button> </button>
<button class="btn btn-danger rounded-0 " data-action="click->bo--quiz#deleteQuiz"> <button class="btn btn-danger rounded-0" data-action="click->bo--quiz#deleteQuiz">
{{ 'Delete Quiz...'|trans }} {{ 'Delete Quiz...'|trans }}
</button> </button>
<a class="btn btn-secondary rounded-0 rounded-end" <a class="btn btn-secondary rounded-0 rounded-end"
@@ -85,51 +85,79 @@
</a> </a>
</div> </div>
<h4 class="mb-3">{{ 'Questions'|trans }}</h4> <div data-controller="bo--question-list bo--modal"
<div class="accordion"> data-action="turbo:submit-end->bo--modal#frameSubmitEnd"
{%~ for question in quiz.questions ~%} data-bo--question-list-reorder-url-value="{{ path('tvdt_backoffice_quiz_questions_reorder', {seasonCode: season.seasonCode, quiz: quiz.id}) }}"
<div class="accordion-item"> data-bo--question-list-csrf-value="{{ csrf_token('question_reorder') }}"
<h2 class="accordion-header"> data-bo--question-list-saved-label-value="{{ 'Order saved'|trans }}"
<button class="accordion-button collapsed" data-bo--question-list-error-label-value="{{ 'Error saving order'|trans }}"
type="button" data-bo--question-list-error-hint-value="{{ 'Refresh the page to try again.'|trans }}">
data-bs-toggle="collapse"
data-bs-target="#question-{{ loop.index0 }}" <h4 class="mb-3 d-flex align-items-center gap-2">
aria-controls="question-{{ loop.index0 }}"> {{ 'Questions'|trans }}
{% set questionError = questionErrors[question.id.toString] ?? null %} <span class="badge d-none fw-normal" style="font-size:.7rem;vertical-align:baseline" data-bo--question-list-target="status"></span>
<span </h4>
class="badge rounded-pill me-2{% if questionError %} text-bg-danger{% else %} invisible{% endif %}"{% if questionError %} data-bs-toggle="tooltip" title="{{ questionError }}"{% endif %}>!</span>
{{~ loop.index -}}. {{ question.question -}} <div data-bo--question-list-target="list"
</button> {% 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 %}>
</h2> {%~ for question in quiz.questions ~%}
<div id="question-{{ loop.index0 }}" <div class="card mb-2"
class="accordion-collapse collapse"> data-bo--question-list-target="item"
<div class="accordion-body"> data-question-id="{{ question.id }}">
{% if is_granted('QUIZ_MODIFY_CONTENT', question) %} <div class="card-body py-2">
<a class="btn btn-sm btn-outline-secondary mb-2" <div class="d-flex align-items-center gap-2">
href="{{ path('tvdt_backoffice_quiz_question_edit', {seasonCode: season.seasonCode, quiz: quiz.id, question: question.id}) }}"> {% if is_granted('QUIZ_MODIFY_CONTENT', question) %}
<i class="bi bi-pencil"></i> {{ 'Edit'|trans }} <span class="text-muted" style="cursor:grab"
</a> draggable="true"
{% endif %} data-action="dragstart->bo--question-list#dragStart dragend->bo--question-list#dragEnd">
<ul> <i class="bi bi-grip-vertical"></i>
{%~ for answer in question.answers %} </span>
<li{% if answer.isRightAnswer %} class="text-decoration-underline"{% endif %}> {% endif %}
{{ answer.text }} <div class="flex-grow-1">
{% if answer.candidates|length > 0 %} {% set questionError = questionErrors[question.id.toString] ?? null %}
<small class="text-muted"> <span class="badge rounded-pill me-1{% if questionError %} text-bg-danger{% else %} invisible{% endif %}"
({{ answer.candidates|map(c => c.name)|join(', ') }}) {% if questionError %}data-bs-toggle="tooltip" title="{{ questionError }}"{% endif %}>!</span>
</small> <strong><span data-question-number>{{ loop.index }}</span>. {{ question.question }}</strong>
{% endif %} </div>
</li> {% if is_granted('QUIZ_MODIFY_CONTENT', question) %}
{%~ else %} <button class="btn btn-sm btn-outline-secondary flex-shrink-0"
{{ 'There are no answers for this question'|trans -}} data-action="click->bo--modal#open"
{%~ endfor %} data-src="{{ path('tvdt_backoffice_quiz_question_edit', {seasonCode: season.seasonCode, quiz: quiz.id, question: question.id}) }}"
</ul> data-modal-title="{{ 'Edit question'|trans }}">
<i class="bi bi-pencil"></i> {{ 'Edit'|trans }}
</button>
{% elseif quiz.isLocked %}
<button class="btn btn-sm btn-outline-secondary flex-shrink-0"
data-action="click->bo--modal#open"
data-src="{{ path('tvdt_backoffice_quiz_question_view', {seasonCode: season.seasonCode, quiz: quiz.id, question: question.id}) }}"
data-modal-title="{{ 'Question details'|trans }}">
<i class="bi bi-eye"></i> {{ 'View'|trans }}
</button>
{% endif %}
</div>
</div> </div>
</div> </div>
{% else %}
{{ 'No questions have been added to this quiz yet.'|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">
<div class="modal-header">
<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>
</div>
</div> </div>
{% else %} </div>
{{ 'EMPTY'|trans }}
{% endfor %}
</div> </div>
{{ _self.confirm_modal( {{ _self.confirm_modal(
@@ -2,7 +2,7 @@
{% block title %}{{ 'Register'|trans }}{% endblock %} {% block title %}{{ 'Register'|trans }}{% endblock %}
{% block body %} {% block body %}
<h3>{{ 'Register'|trans }}</h3> <h3 class="mb-3">{{ 'Register'|trans }}</h3>
{{ form_errors(registrationForm) }} {{ form_errors(registrationForm) }}
@@ -4,9 +4,67 @@
<a class="btn btn-sm btn-outline-primary" <a class="btn btn-sm btn-outline-primary"
href="{{ path('tvdt_backoffice_add_candidates', {seasonCode: season.seasonCode}) }}">{{ 'Add Candidate'|trans }}</a> href="{{ path('tvdt_backoffice_add_candidates', {seasonCode: season.seasonCode}) }}">{{ 'Add Candidate'|trans }}</a>
</div> </div>
<ul class="mb-3"> <ul class="list-group mb-3">
{% for candidate in season.candidates %} {% for candidate in season.candidates %}
<li>{{ candidate.name }}</li> <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>
{% else %} {% else %}
{{ 'No candidates'|trans }} {{ 'No candidates'|trans }}
{% endfor %} {% endfor %}
@@ -1,7 +1,13 @@
<div class="row"> <div class="row">
<div class="col-md-8 col-12"> <div class="col-md-8 col-12" data-controller="bo--modal"
data-action="turbo:submit-end->bo--modal#frameSubmitEnd">
<div class="mb-3"> <div class="mb-3">
<a class="btn btn-sm btn-outline-primary" href="{{ path('tvdt_backoffice_question_bank_new', {seasonCode: season.seasonCode}) }}">{{ 'Add question'|trans }}</a> <button class="btn btn-sm btn-outline-primary"
data-action="click->bo--modal#open"
data-src="{{ path('tvdt_backoffice_question_bank_new', {seasonCode: season.seasonCode}) }}"
data-modal-title="{{ 'Add question'|trans }}">
{{ 'Add question'|trans }}
</button>
</div> </div>
<div class="d-flex align-items-center flex-wrap gap-2 mb-3"> <div class="d-flex align-items-center flex-wrap gap-2 mb-3">
@@ -120,9 +126,11 @@
</form> </form>
{% endif %} {% endif %}
<div class="btn-group btn-group-sm" role="group"> <div class="btn-group btn-group-sm" role="group">
<a class="btn btn-outline-secondary" <button type="button" class="btn btn-outline-secondary"
href="{{ path('tvdt_backoffice_question_bank_edit', {seasonCode: season.seasonCode, bankQuestion: bankQuestion.id}) }}" data-action="click->bo--modal#open"
title="{{ 'Edit'|trans }}"><i class="bi bi-pencil"></i></a> 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" <button type="button" class="btn btn-outline-danger" data-bs-toggle="modal"
data-bs-target="#deleteBankQuestion-{{ bankQuestion.id }}" data-bs-target="#deleteBankQuestion-{{ bankQuestion.id }}"
title="{{ 'Delete'|trans }}"><i class="bi bi-trash"></i></button> title="{{ 'Delete'|trans }}"><i class="bi bi-trash"></i></button>
@@ -163,6 +171,22 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </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">
<div class="modal-header">
<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>
</div>
</div>
</div>
</div> </div>
<div class="col-md-4 col-12"> <div class="col-md-4 col-12">
{{ include('backoffice/help/season_question_bank.html.twig') }} {{ include('backoffice/help/season_question_bank.html.twig') }}
@@ -2,9 +2,9 @@
<div class="col-md-6 col-12"> <div class="col-md-6 col-12">
<div class="d-flex align-items-center gap-2 mb-3"> <div class="d-flex align-items-center gap-2 mb-3">
<a class="btn btn-sm btn-outline-primary" <a class="btn btn-sm btn-outline-primary"
href="{{ path('tvdt_backoffice_quiz_add', {seasonCode: season.seasonCode}) }}">{{ 'Import Quiz from Excel'|trans }}</a>
<a class="btn btn-sm btn-outline-secondary"
href="{{ path('tvdt_backoffice_quiz_add_blank', {seasonCode: season.seasonCode}) }}">{{ 'Add Empty Quiz'|trans }}</a> href="{{ path('tvdt_backoffice_quiz_add_blank', {seasonCode: season.seasonCode}) }}">{{ 'Add Empty Quiz'|trans }}</a>
<a class="btn btn-sm btn-outline-secondary"
href="{{ path('tvdt_backoffice_quiz_add', {seasonCode: season.seasonCode}) }}">{{ 'Import Quiz from Excel'|trans }}</a>
</div> </div>
<div class="list-group mb-3"> <div class="list-group mb-3">
{% for quiz in season.quizzes %} {% for quiz in season.quizzes %}
@@ -0,0 +1,103 @@
{% extends 'backoffice/base.html.twig' %}
{% block title %}{{ parent() }}{{ 'Settings'|trans }}{% endblock %}
{% block breadcrumbs %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ path('tvdt_backoffice_index') }}">{{ 'Home'|trans }}</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ 'Settings'|trans }}</li>
</ol>
</nav>
{% endblock %}
{% block body %}
<div class="row">
<div class="col-lg-6 col-12">
<h2 class="mb-4">{{ 'Settings'|trans }}</h2>
<section class="mb-5">
<h4>{{ 'Language'|trans }}</h4>
<form action="{{ path('tvdt_backoffice_settings_language') }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('settings_language') }}">
<div class="mb-3">
<label class="form-label" for="settings-language">{{ 'Language'|trans }}</label>
<select class="form-select" id="settings-language" name="language">
<option value="nl" selected>Nederlands</option>
</select>
</div>
<button type="submit" class="btn btn-primary">{{ 'Save'|trans }}</button>
</form>
</section>
<section class="mb-5">
<h4>{{ 'Change password'|trans }}</h4>
{{ form(passwordForm, {action: path('tvdt_backoffice_settings_password')}) }}
</section>
<section class="mb-5">
<h4>{{ 'Change email'|trans }}</h4>
<p class="mb-1">
<strong>{{ 'Current email address:'|trans }}</strong> {{ app.user.userIdentifier }}
{% if app.user.isVerified %}
<span class="badge text-bg-success">{{ 'Confirmed'|trans }}</span>
{% else %}
<span class="badge text-bg-warning">{{ 'Not confirmed'|trans }}</span>
<form class="d-inline" action="{{ path('tvdt_backoffice_settings_resend_confirmation') }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('resend_confirmation') }}">
<button type="submit" class="btn btn-link btn-sm p-0 align-baseline">
{{ 'Resend confirmation email'|trans }}
</button>
</form>
{% endif %}
</p>
<p>{{ 'After changing your email address you will receive a new confirmation email.'|trans }}</p>
{{ form(emailForm, {action: path('tvdt_backoffice_settings_email')}) }}
</section>
<section class="mb-5" data-controller="bo--popover">
<h4>{{ 'Your data'|trans }}</h4>
<span class="d-inline-block" tabindex="0"
data-bs-toggle="popover"
data-bs-trigger="hover focus"
data-bs-content="{{ 'Soon™'|trans }}">
<button type="button" class="btn btn-secondary pe-none" disabled>{{ 'Download data'|trans }}</button>
</span>
</section>
<section class="mb-5">
<h4 class="text-danger">{{ 'Danger zone'|trans }}</h4>
<p>{{ 'Deleting your account also deletes every season you are the only owner of. This cannot be undone.'|trans }}</p>
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#deleteAccountModal">
{{ 'Delete account...'|trans }}
</button>
</section>
</div>
</div>
<div class="modal fade" id="deleteAccountModal" data-bs-backdrop="static"
tabindex="-1"
aria-labelledby="deleteAccountModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="{{ path('tvdt_backoffice_settings_delete') }}" method="POST">
<div class="modal-header">
<h1 class="modal-title fs-5" id="deleteAccountModalLabel">{{ 'Delete account'|trans }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>{{ 'This deletes your account and every season you are the only owner of. Enter your password to confirm.'|trans }}</p>
<input type="hidden" name="_token" value="{{ csrf_token('delete_account') }}">
<label class="form-label" for="delete-account-password">{{ 'Current password'|trans }}</label>
<input type="password" class="form-control" id="delete-account-password"
name="password" required autocomplete="current-password">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
<button type="submit" class="btn btn-danger">{{ 'Delete account'|trans }}</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
+3
View File
@@ -3,14 +3,17 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
{% block sentry_loader %}
<script <script
src="https://js-de.sentry-cdn.com/30cf438bc708c97e6f45c127bed9af96.min.js" src="https://js-de.sentry-cdn.com/30cf438bc708c97e6f45c127bed9af96.min.js"
crossorigin="anonymous" crossorigin="anonymous"
></script> ></script>
{% endblock %}
<title> <title>
{% block title %}Tijd voor de test{% endblock title %} {% block title %}Tijd voor de test{% endblock title %}
</title> </title>
{% block importmap %}{% endblock %} {% block importmap %}{% endblock %}
{% block head_extra %}{% endblock %}
</head> </head>
<body> <body>
{% block nav %} {% block nav %}
@@ -0,0 +1,16 @@
{% extends 'backoffice/base.html.twig' %}
{% block title %}E-mail verstuurd{% endblock %}
{% block body %}
<h3 class="mb-3">{{ 'Check your email'|trans }}</h3>
<p>
{{ 'If an account matching your email exists, then an email was just sent that contains a link that you can use to reset your password.'|trans }}
{{ 'This link will expire in %count%.'|trans({'%count%': resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle')}) }}
</p>
<p>
{{ 'If you don\'t receive an email please check your spam folder or'|trans }}
<a href="{{ path('tvdt_forgot_password_request') }}">{{ 'try again'|trans }}</a>.
</p>
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
<h1>Hi!</h1>
<p>To reset your password, please visit the following link</p>
<a href="{{ url('tvdt_reset_password', {token: resetToken.token}) }}">{{ url('tvdt_reset_password', {token: resetToken.token}) }}</a>
<p>This link will expire in {{ resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }}.</p>
<p>Cheers!</p>
@@ -0,0 +1,18 @@
{% extends 'backoffice/base.html.twig' %}
{% block title %}Wachtwoord vergeten{% endblock %}
{% block body %}
<h3 class="mb-3">{{ 'Reset your password'|trans }}</h3>
{{ form_start(requestForm) }}
{{ form_row(requestForm.email) }}
<small class="d-block mb-3">
{{ 'Enter your email address, and we will send you a link to reset your password.'|trans }}
</small>
<button class="btn btn-primary" type="submit">{{ 'Send password reset email'|trans }}</button>
<a href="{{ path('tvdt_login_login') }}" class="btn btn-link">{{ 'Back to login'|trans }}</a>
{{ form_end(requestForm) }}
{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
{% extends 'backoffice/base.html.twig' %}
{% block title %}Nieuw wachtwoord instellen{% endblock %}
{% block body %}
<h3 class="mb-3">{{ 'Reset your password'|trans }}</h3>
{{ form_start(resetForm) }}
{{ form_row(resetForm.plainPassword) }}
<button class="btn btn-primary" type="submit">{{ 'Reset password'|trans }}</button>
{{ form_end(resetForm) }}
{% endblock %}
@@ -10,6 +10,7 @@ use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\QuestionBankController; use Tvdt\Controller\Backoffice\QuestionBankController;
use Tvdt\Entity\BankAnswer;
use Tvdt\Entity\BankQuestion; use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\Question; use Tvdt\Entity\Question;
use Tvdt\Entity\QuestionLabel; use Tvdt\Entity\QuestionLabel;
@@ -298,6 +299,79 @@ final class QuestionBankControllerTest extends WebTestCase
$this->assertResponseStatusCodeSame(403); $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 public function testAddAndDeleteLabel(): void
{ {
$this->loginAsOwner(); $this->loginAsOwner();
@@ -0,0 +1,147 @@
<?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);
}
}
@@ -0,0 +1,120 @@
<?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\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);
}
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);
}
}
@@ -0,0 +1,353 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Safe\DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Tvdt\Controller\Backoffice\SettingsController;
use Tvdt\DataFixtures\TestFixtures;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\ResetPasswordRequest;
use Tvdt\Entity\Season;
use Tvdt\Entity\User;
#[CoversClass(SettingsController::class)]
final class SettingsControllerTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = self::createClient();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$this->loginAs('test@example.org');
}
private function loginAs(string $email): void
{
$user = $this->getUserByEmail($email);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
}
private function getUserByEmail(string $email): ?User
{
return $this->entityManager->getRepository(User::class)->findOneBy(['email' => $email]);
}
private function getCsrfTokenFromSettings(string $formActionContains): string
{
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseIsSuccessful();
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
return (string) $input->first()->attr('value');
}
public function testSettingsPageLoadsAndNavContainsSettingsLink(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseIsSuccessful();
self::assertSelectorExists('nav a[href="/backoffice/settings"]');
}
public function testSettingsPageRequiresAuthentication(): void
{
$this->client->restart();
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseRedirects();
}
public function testLanguageSaveRedirectsBackToSettings(): void
{
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/language');
$this->client->request(Request::METHOD_POST, '/backoffice/settings/language', [
'_token' => $token,
'language' => 'nl',
]);
self::assertResponseRedirects('/backoffice/settings');
}
public function testChangePassword(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/password"]')->form([
'change_user_password_form[currentPassword]' => TestFixtures::PASSWORD,
'change_user_password_form[plainPassword][first]' => 'NewPass123!',
'change_user_password_form[plainPassword][second]' => 'NewPass123!',
]);
$this->client->submit($form);
self::assertResponseRedirects('/backoffice/settings');
$this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$this->assertTrue($hasher->isPasswordValid($user, 'NewPass123!'));
// User stays logged in
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseIsSuccessful();
}
public function testChangePasswordWithWrongCurrentPasswordIsRejected(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/password"]')->form([
'change_user_password_form[currentPassword]' => 'wrong-password',
'change_user_password_form[plainPassword][first]' => 'NewPass123!',
'change_user_password_form[plainPassword][second]' => 'NewPass123!',
]);
$this->client->submit($form);
self::assertResponseStatusCodeSame(422);
$this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$this->assertTrue($hasher->isPasswordValid($user, TestFixtures::PASSWORD));
}
public function testChangePasswordWithMismatchedRepeatIsRejected(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/password"]')->form([
'change_user_password_form[currentPassword]' => TestFixtures::PASSWORD,
'change_user_password_form[plainPassword][first]' => 'NewPass123!',
'change_user_password_form[plainPassword][second]' => 'SomethingElse!',
]);
$this->client->submit($form);
self::assertResponseStatusCodeSame(422);
$this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$this->assertTrue($hasher->isPasswordValid($user, TestFixtures::PASSWORD));
}
public function testChangeEmailSendsConfirmationAndKeepsUserLoggedIn(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/email"]')->form([
'change_email_form[email]' => 'new-address@example.org',
]);
$this->client->submit($form);
self::assertResponseRedirects('/backoffice/settings');
self::assertEmailCount(1);
$this->entityManager->clear();
$this->assertNotInstanceOf(User::class, $this->getUserByEmail('test@example.org'));
$user = $this->getUserByEmail('new-address@example.org');
$this->assertInstanceOf(User::class, $user);
$this->assertFalse($user->isVerified);
// User stays logged in
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseIsSuccessful();
}
public function testChangeEmailToTakenAddressIsRejected(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/email"]')->form([
'change_email_form[email]' => 'user1@example.org',
]);
$this->client->submit($form);
self::assertResponseStatusCodeSame(422);
self::assertEmailCount(0);
$this->entityManager->clear();
$this->assertInstanceOf(User::class, $this->getUserByEmail('test@example.org'));
}
public function testResendConfirmationEmailSendsEmail(): void
{
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/resend-confirmation');
$this->client->request(Request::METHOD_POST, '/backoffice/settings/resend-confirmation', [
'_token' => $token,
]);
self::assertResponseRedirects('/backoffice/settings');
self::assertEmailCount(1);
}
public function testResendConfirmationEmailForVerifiedUserSendsNothing(): void
{
// Get a valid CSRF token while still unverified, then mark the user as verified
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/resend-confirmation');
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$user->isVerified = true;
$this->entityManager->flush();
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseIsSuccessful();
$this->assertCount(0, $crawler->filter('form[action*="/backoffice/settings/resend-confirmation"]'));
$this->client->request(Request::METHOD_POST, '/backoffice/settings/resend-confirmation', [
'_token' => $token,
]);
self::assertResponseRedirects('/backoffice/settings');
self::assertEmailCount(0);
}
public function testChangeEmailToSameAddressIsAccepted(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/email"]')->form([
'change_email_form[email]' => 'test@example.org',
]);
$this->client->submit($form);
self::assertResponseRedirects('/backoffice/settings');
}
private function createResetPasswordRequest(User $user): void
{
$request = new ResetPasswordRequest(
$user,
new DateTimeImmutable('+1 hour'),
str_repeat('a', 20),
str_repeat('b', 100),
);
$this->entityManager->persist($request);
$this->entityManager->flush();
}
public function testChangePasswordInvalidatesResetPasswordRequests(): void
{
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->createResetPasswordRequest($user);
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/password"]')->form([
'change_user_password_form[currentPassword]' => TestFixtures::PASSWORD,
'change_user_password_form[plainPassword][first]' => 'NewPass123!',
'change_user_password_form[plainPassword][second]' => 'NewPass123!',
]);
$this->client->submit($form);
self::assertResponseRedirects('/backoffice/settings');
$this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->assertSame(0, $this->entityManager->getRepository(ResetPasswordRequest::class)->count(['user' => $user]));
}
public function testChangeEmailInvalidatesResetPasswordRequests(): void
{
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->createResetPasswordRequest($user);
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/email"]')->form([
'change_email_form[email]' => 'new-address@example.org',
]);
$this->client->submit($form);
self::assertResponseRedirects('/backoffice/settings');
$this->entityManager->clear();
$user = $this->getUserByEmail('new-address@example.org');
$this->assertInstanceOf(User::class, $user);
$this->assertSame(0, $this->entityManager->getRepository(ResetPasswordRequest::class)->count(['user' => $user]));
}
public function testDeleteAccountWithWrongPasswordIsRejected(): void
{
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/delete');
$this->client->request(Request::METHOD_POST, '/backoffice/settings/delete', [
'_token' => $token,
'password' => 'wrong-password',
]);
self::assertResponseRedirects('/backoffice/settings');
$this->entityManager->clear();
$this->assertInstanceOf(User::class, $this->getUserByEmail('test@example.org'));
}
public function testDeleteAccountRemovesSoleOwnerSeasonsAndKeepsSharedSeasons(): void
{
$this->loginAs('sole-owner@example.org');
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/delete');
$this->client->request(Request::METHOD_POST, '/backoffice/settings/delete', [
'_token' => $token,
'password' => TestFixtures::PASSWORD,
]);
self::assertResponseRedirects();
$this->entityManager->clear();
$this->assertNotInstanceOf(User::class, $this->getUserByEmail('sole-owner@example.org'));
// Sole-owner season is removed, including its quiz
$this->assertNotInstanceOf(Season::class, $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'doomd']));
$this->assertNotInstanceOf(Quiz::class, $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Doomed Quiz']));
// Shared season survives, without the deleted owner
$anotherSeason = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'bbbbb']);
$this->assertInstanceOf(Season::class, $anotherSeason);
$ownerEmails = $anotherSeason->owners->map(static fn (User $owner): string => $owner->email)->toArray();
$this->assertNotContains('sole-owner@example.org', $ownerEmails);
$this->assertContains('user1@example.org', $ownerEmails);
// User is logged out
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseRedirects();
}
public function testDeleteAccountKeepsMultiOwnerSeasons(): void
{
$this->loginAs('user2@example.org');
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/delete');
$this->client->request(Request::METHOD_POST, '/backoffice/settings/delete', [
'_token' => $token,
'password' => TestFixtures::PASSWORD,
]);
self::assertResponseRedirects();
$this->entityManager->clear();
$this->assertNotInstanceOf(User::class, $this->getUserByEmail('user2@example.org'));
foreach (['krtek', 'bbbbb'] as $seasonCode) {
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => $seasonCode]);
$this->assertInstanceOf(Season::class, $season);
$ownerEmails = $season->owners->map(static fn (User $owner): string => $owner->email)->toArray();
$this->assertNotContains('user2@example.org', $ownerEmails);
$this->assertNotEmpty($ownerEmails);
}
}
}
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
use Tvdt\Controller\ResetPasswordController;
use Tvdt\Entity\User;
#[CoversClass(ResetPasswordController::class)]
final class ResetPasswordControllerTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = self::createClient();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
}
public function testRequestPageLoads(): void
{
$this->client->request(Request::METHOD_GET, '/reset-password');
$this->assertResponseIsSuccessful();
$this->assertSelectorExists('form');
}
public function testRequestWithUnknownEmailRedirectsToCheckEmail(): void
{
$this->client->request(Request::METHOD_GET, '/reset-password');
$form = $this->client->getCrawler()->filter('form')->form([
'reset_password_request_form[email]' => 'unknown@example.org',
]);
$this->client->submit($form);
$this->assertResponseRedirects('/reset-password/check-email');
}
public function testRequestWithKnownEmailRedirectsToCheckEmail(): void
{
$this->client->request(Request::METHOD_GET, '/reset-password');
$form = $this->client->getCrawler()->filter('form')->form([
'reset_password_request_form[email]' => 'test@example.org',
]);
$this->client->submit($form);
$this->assertResponseRedirects('/reset-password/check-email');
}
public function testCheckEmailPageLoads(): void
{
$this->client->request(Request::METHOD_GET, '/reset-password/check-email');
$this->assertResponseIsSuccessful();
}
public function testResetWithInvalidTokenRedirectsToRequest(): void
{
$this->client->request(Request::METHOD_GET, '/reset-password/reset/invalidtoken');
$this->client->followRedirect();
$this->assertResponseRedirects('/reset-password');
}
public function testFullResetFlow(): void
{
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
$this->assertInstanceOf(User::class, $user);
/** @var ResetPasswordHelperInterface $helper */
$helper = self::getContainer()->get(ResetPasswordHelperInterface::class);
$resetToken = $helper->generateResetToken($user);
$this->client->request(Request::METHOD_GET, '/reset-password/reset/'.$resetToken->getToken());
$this->assertResponseRedirects('/reset-password/reset');
$this->client->followRedirect();
$this->assertResponseIsSuccessful();
$form = $this->client->getCrawler()->filter('form')->form([
'change_password_form[plainPassword][first]' => 'NewPass123!',
'change_password_form[plainPassword][second]' => 'NewPass123!',
]);
$this->client->submit($form);
$this->assertResponseRedirects('/backoffice/');
$this->entityManager->clear();
$updatedUser = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
$this->assertInstanceOf(User::class, $updatedUser);
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$this->assertTrue($hasher->isPasswordValid($updatedUser, 'NewPass123!'));
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller;
use PHPUnit\Framework\Attributes\CoversClass;
use Safe\DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\WellKnownController;
#[CoversClass(WellKnownController::class)]
final class WellKnownControllerTest extends WebTestCase
{
private KernelBrowser $client;
protected function setUp(): void
{
$this->client = self::createClient();
}
public function testChangePasswordRedirectsToSettings(): void
{
$this->client->request(Request::METHOD_GET, '/.well-known/change-password');
self::assertResponseRedirects('/backoffice/settings');
}
/** @throws \Exception */
public function testSecurityTxt(): void
{
$this->client->request(Request::METHOD_GET, '/.well-known/security.txt');
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('Content-Type', 'text/plain; charset=UTF-8');
$content = (string) $this->client->getResponse()->getContent();
$this->assertStringContainsString('Contact:', $content);
$this->assertMatchesRegularExpression('/^Expires: (.+)$/m', $content);
\Safe\preg_match('/^Expires: (.+)$/m', $content, $matches);
$this->assertArrayHasKey(1, $matches);
$expires = new DateTimeImmutable($matches[1]);
$this->assertGreaterThan(new DateTimeImmutable('now'), $expires);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="nl" target-language="nl" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony"/>
</header>
<body>
<trans-unit id="E69bPZ6" resname="%count% day|%count% days">
<source>%count% day|%count% days</source>
<target>%count% dag|%count% dagen</target>
</trans-unit>
<trans-unit id="H3jnVKg" resname="%count% hour|%count% hours">
<source>%count% hour|%count% hours</source>
<target>%count% uur|%count% uren</target>
</trans-unit>
<trans-unit id="30tji6_" resname="%count% minute|%count% minutes">
<source>%count% minute|%count% minutes</source>
<target>%count% minuut|%count% minuten</target>
</trans-unit>
<trans-unit id="NCe6niW" resname="%count% month|%count% months">
<source>%count% month|%count% months</source>
<target>%count% maand|%count% maanden</target>
</trans-unit>
<trans-unit id="U_NoHhq" resname="%count% year|%count% years">
<source>%count% year|%count% years</source>
<target>%count% jaar|%count% jaar</target>
</trans-unit>
<trans-unit id="NUQGnNx" resname="Please update the request_password_repository configuration in config/packages/reset_password.yaml to point to your &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>
+218 -2
View File
@@ -5,10 +5,18 @@
<tool tool-id="symfony" tool-name="Symfony"/> <tool tool-id="symfony" tool-name="Symfony"/>
</header> </header>
<body> <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"> <trans-unit id="VNxXghX" resname="A label with a similar name already exists">
<source>A label with a similar name already exists</source> <source>A label with a similar name already exists</source>
<target>Er bestaat al een label met deze naam</target> <target>Er bestaat al een label met deze naam</target>
</trans-unit> </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"> <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> <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> <target>Er bestaat al een test met deze naam in dit seizoen</target>
@@ -73,6 +81,10 @@
<source>Add question</source> <source>Add question</source>
<target>Vraag toevoegen</target> <target>Vraag toevoegen</target>
</trans-unit> </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"> <trans-unit id="tHdA52O" resname="All">
<source>All</source> <source>All</source>
<target>Alle</target> <target>Alle</target>
@@ -93,6 +105,10 @@
<source>Are you sure you want to clear all the results? This will also delete all the eliminations.</source> <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> <target>Weet je zeker dat je de resultaten wilt leegmaken? Dit gooit ook alle eliminaties weg.</target>
</trans-unit> </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?"> <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> <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> <target>Weet je zeker dat je deze vraag uit de vragenbank wilt verwijderen?</target>
@@ -109,6 +125,10 @@
<source>Back</source> <source>Back</source>
<target>Terug</target> <target>Terug</target>
</trans-unit> </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"> <trans-unit id="JUdglpF" resname="Backoffice">
<source>Backoffice</source> <source>Backoffice</source>
<target>Backoffice</target> <target>Backoffice</target>
@@ -133,10 +153,18 @@
<source>Candidate answers saved</source> <source>Candidate answers saved</source>
<target>Kandidaatantwoorden opgeslagen</target> <target>Kandidaatantwoorden opgeslagen</target>
</trans-unit> </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"> <trans-unit id="TiTLBGW" resname="Candidate not found">
<source>Candidate not found</source> <source>Candidate not found</source>
<target>Kandidaat niet gevonden</target> <target>Kandidaat niet gevonden</target>
</trans-unit> </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"> <trans-unit id="6QiGbuz" resname="Candidate status updated">
<source>Candidate status updated</source> <source>Candidate status updated</source>
<target>Kandidaatstatus bijgewerkt</target> <target>Kandidaatstatus bijgewerkt</target>
@@ -145,6 +173,18 @@
<source>Candidates</source> <source>Candidates</source>
<target>Kandidaten</target> <target>Kandidaten</target>
</trans-unit> </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..."> <trans-unit id="J1c2y63" resname="Clear Quiz...">
<source>Clear Quiz...</source> <source>Clear Quiz...</source>
<target>Test leegmaken...</target> <target>Test leegmaken...</target>
@@ -165,6 +205,10 @@
<source>Confirm Answers</source> <source>Confirm Answers</source>
<target>Bevestig antwoorden</target> <target>Bevestig antwoorden</target>
</trans-unit> </trans-unit>
<trans-unit id="PiAVEe9" resname="Confirmed">
<source>Confirmed</source>
<target>Bevestigd</target>
</trans-unit>
<trans-unit id="sFpB4C2" resname="Correct Answers"> <trans-unit id="sFpB4C2" resname="Correct Answers">
<source>Correct Answers</source> <source>Correct Answers</source>
<target>Goede antwoorden</target> <target>Goede antwoorden</target>
@@ -193,10 +237,22 @@
<source>Create an empty quiz and add questions from the question bank.</source> <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> <target>Maak een lege quiz aan en voeg vragen toe vanuit de vragenbank.</target>
</trans-unit> </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"> <trans-unit id="PkrbQOH" resname="Cyan">
<source>Cyan</source> <source>Cyan</source>
<target>Cyaan</target> <target>Cyaan</target>
</trans-unit> </trans-unit>
<trans-unit id="dG6EuYH" resname="Danger zone">
<source>Danger zone</source>
<target>Gevarenzone</target>
</trans-unit>
<trans-unit id="S5P7nQd" resname="Deactivate"> <trans-unit id="S5P7nQd" resname="Deactivate">
<source>Deactivate</source> <source>Deactivate</source>
<target>Deactiveren</target> <target>Deactiveren</target>
@@ -217,10 +273,26 @@
<source>Delete Quiz...</source> <source>Delete Quiz...</source>
<target>Test verwijderen...</target> <target>Test verwijderen...</target>
</trans-unit> </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"> <trans-unit id="R9yHzHv" resname="Download Template">
<source>Download Template</source> <source>Download Template</source>
<target>Download sjabloon</target> <target>Download sjabloon</target>
</trans-unit> </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"> <trans-unit id="dwUtS3b" resname="Draft">
<source>Draft</source> <source>Draft</source>
<target>Concept</target> <target>Concept</target>
@@ -249,6 +321,10 @@
<source>Enter name</source> <source>Enter name</source>
<target>Voer een naam in</target> <target>Voer een naam in</target>
</trans-unit> </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"> <trans-unit id="RnI7jJT" resname="Enter your name">
<source>Enter your name</source> <source>Enter your name</source>
<target>Voer je naam in</target> <target>Voer je naam in</target>
@@ -257,6 +333,10 @@
<source>Error clearing quiz</source> <source>Error clearing quiz</source>
<target>Fout bij het leegmaken van de test</target> <target>Fout bij het leegmaken van de test</target>
</trans-unit> </trans-unit>
<trans-unit id="NXU7HO." resname="Error saving order">
<source>Error saving order</source>
<target>Fout bij het opslaan van de volgorde</target>
</trans-unit>
<trans-unit id="bgWPQMg" resname="Export to XLSX"> <trans-unit id="bgWPQMg" resname="Export to XLSX">
<source>Export to XLSX</source> <source>Export to XLSX</source>
<target>Exporteren naar XLSX</target> <target>Exporteren naar XLSX</target>
@@ -269,6 +349,10 @@
<source>Finalized</source> <source>Finalized</source>
<target>Afgerond</target> <target>Afgerond</target>
</trans-unit> </trans-unit>
<trans-unit id="Fztxgjr" resname="Forgot your password?">
<source>Forgot your password?</source>
<target>Wachtwoord vergeten?</target>
</trans-unit>
<trans-unit id="MebBrmp" resname="Gray"> <trans-unit id="MebBrmp" resname="Gray">
<source>Gray</source> <source>Gray</source>
<target>Grijs</target> <target>Grijs</target>
@@ -297,6 +381,14 @@
<source>Home</source> <source>Home</source>
<target>Home</target> <target>Home</target>
</trans-unit> </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"> <trans-unit id="oPzjCWg" resname="Import">
<source>Import</source> <source>Import</source>
<target>Importeren</target> <target>Importeren</target>
@@ -333,6 +425,14 @@
<source>Labels</source> <source>Labels</source>
<target>Labels</target> <target>Labels</target>
</trans-unit> </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"> <trans-unit id="q0FeoCr" resname="Load Prepared Elimination">
<source>Load Prepared Elimination</source> <source>Load Prepared Elimination</source>
<target>Laad voorbereide eliminatie</target> <target>Laad voorbereide eliminatie</target>
@@ -365,10 +465,18 @@
<source>Name</source> <source>Name</source>
<target>Naam</target> <target>Naam</target>
</trans-unit> </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"> <trans-unit id="lqTjJ4a" resname="New label">
<source>New label</source> <source>New label</source>
<target>Nieuw label</target> <target>Nieuw label</target>
</trans-unit> </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"> <trans-unit id="gefhnBC" resname="Next">
<source>Next</source> <source>Next</source>
<target>Volgende</target> <target>Volgende</target>
@@ -385,6 +493,10 @@
<source>No candidates</source> <source>No candidates</source>
<target>Geen kandidaten</target> <target>Geen kandidaten</target>
</trans-unit> </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"> <trans-unit id="IsJa5UL" resname="No questions in the question bank yet">
<source>No questions in the question bank yet</source> <source>No questions in the question bank yet</source>
<target>Nog geen vragen in de vragenbank</target> <target>Nog geen vragen in de vragenbank</target>
@@ -401,6 +513,10 @@
<source>Not Started</source> <source>Not Started</source>
<target>Niet gestart</target> <target>Niet gestart</target>
</trans-unit> </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:"> <trans-unit id="k7Eqnjt" resname="Number of dropouts:">
<source>Number of dropouts:</source> <source>Number of dropouts:</source>
<target>Aantal afvallers:</target> <target>Aantal afvallers:</target>
@@ -409,13 +525,17 @@
<source>Open</source> <source>Open</source>
<target>Openen</target> <target>Openen</target>
</trans-unit> </trans-unit>
<trans-unit id="vhCGOEN" resname="Order saved">
<source>Order saved</source>
<target>Volgorde opgeslagen</target>
</trans-unit>
<trans-unit id="HmgPmMV" resname="Overview"> <trans-unit id="HmgPmMV" resname="Overview">
<source>Overview</source> <source>Overview</source>
<target>Overzicht</target> <target>Overzicht</target>
</trans-unit> </trans-unit>
<trans-unit id="PywqOf4" resname="Owner(s)"> <trans-unit id="PywqOf4" resname="Owner(s)">
<source>Owner(s)</source> <source>Owner(s)</source>
<target>Eigenaar(s)</target> <target>Eigenaar/Eigenaren</target>
</trans-unit> </trans-unit>
<trans-unit id="GqmFSHc" resname="Password"> <trans-unit id="GqmFSHc" resname="Password">
<source>Password</source> <source>Password</source>
@@ -473,6 +593,10 @@
<source>Question bank</source> <source>Question bank</source>
<target>Vragenbank</target> <target>Vragenbank</target>
</trans-unit> </trans-unit>
<trans-unit id="z7Pp9b9" resname="Question details">
<source>Question details</source>
<target>Vraagdetails</target>
</trans-unit>
<trans-unit id="htaUa1k" resname="Question removed from quiz %quiz%"> <trans-unit id="htaUa1k" resname="Question removed from quiz %quiz%">
<source>Question removed from quiz %quiz%</source> <source>Question removed from quiz %quiz%</source>
<target>Vraag verwijderd uit quiz %quiz%</target> <target>Vraag verwijderd uit quiz %quiz%</target>
@@ -557,6 +681,10 @@
<source>Red</source> <source>Red</source>
<target>Rood</target> <target>Rood</target>
</trans-unit> </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="fGfBzt6" resname="Register"> <trans-unit id="fGfBzt6" resname="Register">
<source>Register</source> <source>Register</source>
<target>Registreren</target> <target>Registreren</target>
@@ -569,10 +697,30 @@
<source>Remove label</source> <source>Remove label</source>
<target>Label verwijderen</target> <target>Label verwijderen</target>
</trans-unit> </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"> <trans-unit id="Z9CSKpk" resname="Repeat Password">
<source>Repeat Password</source> <source>Repeat Password</source>
<target>Herhaal wachtwoord</target> <target>Herhaal wachtwoord</target>
</trans-unit> </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"> <trans-unit id="7UvBPrb" resname="Results &amp; Elimination">
<source>Results &amp; Elimination</source> <source>Results &amp; Elimination</source>
<target><![CDATA[Resultaat & Eliminatie]]></target> <target><![CDATA[Resultaat & Eliminatie]]></target>
@@ -609,6 +757,10 @@
<source>Seasons</source> <source>Seasons</source>
<target>Seizoenen</target> <target>Seizoenen</target>
</trans-unit> </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"> <trans-unit id="VXFwlwn" resname="Settings">
<source>Settings</source> <source>Settings</source>
<target>Instellingen</target> <target>Instellingen</target>
@@ -621,6 +773,10 @@
<source>Sign in</source> <source>Sign in</source>
<target>Log in</target> <target>Log in</target>
</trans-unit> </trans-unit>
<trans-unit id=".9GO03z" resname="Soon™">
<source>Soon™</source>
<target>Soon™</target>
</trans-unit>
<trans-unit id="A0aGG7W" resname="Sort AZ"> <trans-unit id="A0aGG7W" resname="Sort AZ">
<source>Sort AZ</source> <source>Sort AZ</source>
<target>Sorteer A-Z</target> <target>Sorteer A-Z</target>
@@ -637,6 +793,18 @@
<source>Sync latest changes to this quiz</source> <source>Sync latest changes to this quiz</source>
<target>Laatste wijzigingen synchroniseren naar deze quiz</target> <target>Laatste wijzigingen synchroniseren naar deze quiz</target>
</trans-unit> </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>
<trans-unit id="_z4el3Z" resname="The password fields must match."> <trans-unit id="_z4el3Z" resname="The password fields must match.">
<source>The password fields must match.</source> <source>The password fields must match.</source>
<target>De wachtwoorden moeten overeen komen.</target> <target>De wachtwoorden moeten overeen komen.</target>
@@ -665,10 +833,22 @@
<source>There are no answers for this question</source> <source>There are no answers for this question</source>
<target>Er zijn geen antwoorden voor deze vraag</target> <target>Er zijn geen antwoorden voor deze vraag</target>
</trans-unit> </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"> <trans-unit id=".LrcTyU" resname="There is no active quiz">
<source>There is no active quiz</source> <source>There is no active quiz</source>
<target>Er is geen test actief</target> <target>Er is geen test actief</target>
</trans-unit> </trans-unit>
<trans-unit id="nv0a7LG" resname="This deletes your account and every season you are the only owner of. Enter your password to confirm.">
<source>This deletes your account and every season you are the only owner of. Enter your password to confirm.</source>
<target>Dit verwijdert je account en alle seizoenen waarvan jij de enige eigenaar bent. Vul je wachtwoord in om te bevestigen.</target>
</trans-unit>
<trans-unit id="YueaA2f" resname="This link will expire in %count%.">
<source>This link will expire in %count%.</source>
<target>Deze link verloopt over %count%.</target>
</trans-unit>
<trans-unit id="o.FTilS" resname="This question cannot be deleted because it is used in a locked or active quiz"> <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> <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> <target>Deze vraag kan niet verwijderd worden omdat die gebruik wordt in een vergrendelde of actieve test</target>
@@ -703,7 +883,7 @@
</trans-unit> </trans-unit>
<trans-unit id=".j31AXY" resname="Toggle correct answer"> <trans-unit id=".j31AXY" resname="Toggle correct answer">
<source>Toggle correct answer</source> <source>Toggle correct answer</source>
<target></target> <target>Goed antwoord aan/uitzetten</target>
</trans-unit> </trans-unit>
<trans-unit id="XLYBGca" resname="Unassign"> <trans-unit id="XLYBGca" resname="Unassign">
<source>Unassign</source> <source>Unassign</source>
@@ -717,10 +897,18 @@
<source>Used in</source> <source>Used in</source>
<target>Gebruikt in</target> <target>Gebruikt in</target>
</trans-unit> </trans-unit>
<trans-unit id=".EmYZIu" resname="View">
<source>View</source>
<target>Bekijken</target>
</trans-unit>
<trans-unit id="JWRtx_o" resname="White"> <trans-unit id="JWRtx_o" resname="White">
<source>White</source> <source>White</source>
<target>Wit</target> <target>Wit</target>
</trans-unit> </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"> <trans-unit id="RV6M450" resname="Yellow">
<source>Yellow</source> <source>Yellow</source>
<target>Geel</target> <target>Geel</target>
@@ -745,10 +933,38 @@
<source>Your Seasons</source> <source>Your Seasons</source>
<target>Jouw seizoenen</target> <target>Jouw seizoenen</target>
</trans-unit> </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."> <trans-unit id="m80cBv0" resname="Your email address has been verified.">
<source>Your email address has been verified.</source> <source>Your email address has been verified.</source>
<target>Je e-mailadres is geverifieerd.</target> <target>Je e-mailadres is geverifieerd.</target>
</trans-unit> </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> </body>
</file> </file>
</xliff> </xliff>
+16
View File
@@ -109,10 +109,22 @@
<source>Please enter a valid week.</source> <source>Please enter a valid week.</source>
<target>Vul een geldige week in.</target> <target>Vul een geldige week in.</target>
</trans-unit> </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."> <trans-unit id="o.y86J1" resname="Please enter an integer.">
<source>Please enter an integer.</source> <source>Please enter an integer.</source>
<target>Vul een geldig getal in.</target> <target>Vul een geldig getal in.</target>
</trans-unit> </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."> <trans-unit id="9RtYCIm" resname="Please provide a valid phone number.">
<source>Please provide a valid phone number.</source> <source>Please provide a valid phone number.</source>
<target>Vul een geldig telefoonnummer in.</target> <target>Vul een geldig telefoonnummer in.</target>
@@ -433,6 +445,10 @@
<source>This is not a valid UUID.</source> <source>This is not a valid UUID.</source>
<target>Deze waarde is geen geldige UUID.</target> <target>Deze waarde is geen geldige UUID.</target>
</trans-unit> </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."> <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> <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> <target>Dit wachtwoord is gelekt bij een datalek en mag niet worden gebruikt. Kies een ander wachtwoord.</target>