mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-10 09:30:14 +02:00
0ee15e3cbb
* 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.
103 lines
5.5 KiB
Twig
103 lines
5.5 KiB
Twig
{% extends 'backoffice/base.html.twig' %}
|
|
|
|
{% block title %}{{ parent() }}{{ 'Settings'|trans }}{% endblock %}
|
|
|
|
{% block breadcrumbs %}
|
|
<nav aria-label="breadcrumb" class="mb-3">
|
|
<ol class="breadcrumb">
|
|
<li class="breadcrumb-item"><a href="{{ path('tvdt_backoffice_index') }}">{{ 'Home'|trans }}</a></li>
|
|
<li class="breadcrumb-item active" aria-current="page">{{ 'Settings'|trans }}</li>
|
|
</ol>
|
|
</nav>
|
|
{% endblock %}
|
|
|
|
{% block body %}
|
|
<div class="row">
|
|
<div class="col-lg-6 col-12">
|
|
<h2 class="mb-4">{{ 'Settings'|trans }}</h2>
|
|
|
|
<section class="mb-5">
|
|
<h4>{{ 'Language'|trans }}</h4>
|
|
<form action="{{ path('tvdt_backoffice_settings_language') }}" method="POST">
|
|
<input type="hidden" name="_token" value="{{ csrf_token('settings_language') }}">
|
|
<div class="mb-3">
|
|
<label class="form-label" for="settings-language">{{ 'Language'|trans }}</label>
|
|
<select class="form-select" id="settings-language" name="language">
|
|
<option value="nl" selected>Nederlands</option>
|
|
</select>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary">{{ 'Save'|trans }}</button>
|
|
</form>
|
|
</section>
|
|
|
|
<section class="mb-5">
|
|
<h4>{{ 'Change password'|trans }}</h4>
|
|
{{ form(passwordForm, {action: path('tvdt_backoffice_settings_password')}) }}
|
|
</section>
|
|
|
|
<section class="mb-5">
|
|
<h4>{{ 'Change email'|trans }}</h4>
|
|
<p class="mb-1">
|
|
<strong>{{ 'Current email address:'|trans }}</strong> {{ app.user.userIdentifier }}
|
|
{% if app.user.isVerified %}
|
|
<span class="badge text-bg-success">{{ 'Confirmed'|trans }}</span>
|
|
{% else %}
|
|
<span class="badge text-bg-warning">{{ 'Not confirmed'|trans }}</span>
|
|
<form class="d-inline" action="{{ path('tvdt_backoffice_settings_resend_confirmation') }}" method="POST">
|
|
<input type="hidden" name="_token" value="{{ csrf_token('resend_confirmation') }}">
|
|
<button type="submit" class="btn btn-link btn-sm p-0 align-baseline">
|
|
{{ 'Resend confirmation email'|trans }}
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
</p>
|
|
<p>{{ 'After changing your email address you will receive a new confirmation email.'|trans }}</p>
|
|
{{ form(emailForm, {action: path('tvdt_backoffice_settings_email')}) }}
|
|
</section>
|
|
|
|
<section class="mb-5">
|
|
<h4>{{ 'Your data'|trans }}</h4>
|
|
<p>{{ 'Download an archive of everything stored under your account: your profile, the seasons you own, their quizzes, results and candidates.'|trans }}</p>
|
|
{% if not app.user.isVerified %}
|
|
<p class="text-warning">{{ 'Confirm your email address to enable this feature.'|trans }}</p>
|
|
{% endif %}
|
|
<a class="btn btn-primary" href="{{ path('tvdt_backoffice_settings_download_data') }}">{{ 'Download data'|trans }}</a>
|
|
</section>
|
|
|
|
<section class="mb-5">
|
|
<h4 class="text-danger">{{ 'Danger zone'|trans }}</h4>
|
|
<p>{{ 'Deleting your account also deletes every season you are the only owner of. This cannot be undone.'|trans }}</p>
|
|
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#deleteAccountModal">
|
|
{{ 'Delete account...'|trans }}
|
|
</button>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="deleteAccountModal" data-bs-backdrop="static"
|
|
tabindex="-1"
|
|
aria-labelledby="deleteAccountModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<form action="{{ path('tvdt_backoffice_settings_delete') }}" method="POST">
|
|
<div class="modal-header">
|
|
<h1 class="modal-title fs-5" id="deleteAccountModalLabel">{{ 'Delete account'|trans }}</h1>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p>{{ 'This deletes your account and every season you are the only owner of. Enter your password to confirm.'|trans }}</p>
|
|
<input type="hidden" name="_token" value="{{ csrf_token('delete_account') }}">
|
|
<label class="form-label" for="delete-account-password">{{ 'Current password'|trans }}</label>
|
|
<input type="password" class="form-control" id="delete-account-password"
|
|
name="password" required autocomplete="current-password">
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
|
|
<button type="submit" class="btn btn-danger">{{ 'Delete account'|trans }}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|