Commit Graph

2 Commits

Author SHA1 Message Date
Marijn 0ee15e3cbb feat: add GDPR data export (download data) button (#198)
* feat: add GDPR data export (download data) button

Wires up the previously disabled "Download data" button on the settings
page. Downloads a zip with a profile.xlsx (account + owned seasons), and
per owned season a folder with each quiz's xlsx (questions, results,
eliminations tabs) and a candidates.xlsx (candidates + season info tabs).
Soft-deleted rows are included and flagged so the export reflects
everything the app still holds about the user.

* feat: include question bank in GDPR data export

Adds a question-bank.xlsx per season folder with Questions (bank
questions, answers, reusable/complete flags, labels, and which quizzes
they've been used in) and Labels tabs, since BankQuestion/BankAnswer
content was previously missing from the export.

* fix: hard-delete quiz/audit-log data when deleting an account

QuizCandidate, GivenAnswer, and Elimination are Gedmo\SoftDeleteable, so
cascading their removal through Season -> Quiz/Candidate only set
deletedAt instead of physically deleting the row. Since Candidate and
Answer are hard-deleted via orphanRemoval, this broke their foreign keys
and rolled back the entire account deletion whenever a candidate had
actually participated in a quiz. Bulk DQL deletes now purge these rows
before the cascade runs.

Also purge BankQuestion audit-log rows (ext_log_entries), which store
the editor's username/email but aren't foreign-keyed to the entity they
log, so they were never cleaned up and would otherwise keep a deleted
user's email around indefinitely.

* style: make the download data button primary (blue)

* feat: add raw answers crosstab to quiz export

Adds a "Raw answers" tab to each quiz xlsx: one row per candidate, one
column per question, with the given answer text in each cell — the raw
data behind the aggregated Results tab.

* i18n: translate new settings page string to Dutch

* refactor: sanitize filenames with Symfony's AsciiSlugger instead of a hand-rolled regex

Extracts a shared Tvdt\Helpers\FilenameSanitizer (backed by
symfony/string's AsciiSlugger) and uses it everywhere user-controlled
text (season/quiz names, account email) ends up in a zip entry path or
a downloaded filename. AsciiSlugger is allowlist-based (only A-Z/0-9
survive; everything else, including unicode and path-traversal
sequences, is folded or stripped) rather than a denylist of "unsafe"
characters, and it's an officially maintained Symfony component already
present as a transitive dependency.

Also fixes BackofficeController::exportQuiz(), a pre-existing endpoint
that built its Content-Disposition filename directly from an
unsanitized quiz name — the same class of risk the data export already
guarded against.

Naming note: sanitized names are now slugs (spaces become dashes,
unicode is ASCII-transliterated), e.g. "Krtek Weekend" -> "Krtek-Weekend".

* fix: check ZipArchive open/close results and clean up temp files on failure

Addresses CodeRabbit findings on the GDPR export:
- ZipArchive::open() and close() can both return false without
  throwing; neither was checked, so a failure silently produced an
  empty or corrupt zip, and the temp zip path was never cleaned up on
  a mid-build exception.
- writeToTempFile() leaked its tempnam()'d file if Writer\Xlsx::save()
  threw before the caller could track it for cleanup.

* fix: drop public link identifier from the candidates export sheet

The nameHash is a public quiz-access token, not something a data
export should hand out — remove it from candidates.xlsx.

* feat: add Quiz info tab covering dropouts, finalization, and disabled questions

An entity-by-entity audit of the export vs. delete flows found the
delete flow fully covered, but three Quiz/Question fields missing from
the export: dropouts, finalizedAt, and Question.enabled. Adds a new
"Quiz info" tab (first sheet) to each quiz's xlsx with this data,
without touching the shared fillQuestionsSheet() used by the existing
single-quiz template export/import feature.

Deliberately left out per user decision: the BankQuestion audit log
(would leak other owners' emails, consistent with hiding co-owner
identities elsewhere in this export) and a few low-value timestamp
fields already covered by existing Started/time-taken columns.

* feat: require a confirmed email before exporting data

Antispam measure: both the full data export (SettingsController::downloadData)
and the single-quiz export (BackofficeController::exportQuiz) now redirect
with a flash warning instead of exporting when the account's email isn't
verified yet. Adds a matching hint on the settings page next to the
download button.
2026-07-09 20:28:10 +00:00
Marijn 1d3e99d2b2 feat: user settings page (#191)
* feat: user settings page (#182)

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

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

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

* refactor: extract shared controller functionality

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

* fix: address PR review comments

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

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

- Invalidate outstanding ResetPasswordRequests after password or email change to close the account-takeover window (tokens otherwise remain valid)
- Exclude the current user from email uniqueness check so submitting your own address no longer returns an error
- Surface transport failures from sendDefaultConfirmation via a warning flash instead of silently showing success
- Move Dockerfile ARG BUILD_TIME/ENV to after all build steps so changing the build timestamp no longer busts the composer/asset cache
- Throw in prod when BUILD_TIME is missing (WellKnownController) so the security.txt Expires goes stale as intended when deployments stop; fall back to 'now' only in dev/test
2026-07-08 14:38:32 +02:00