fix(events): typed-DELETE confirmation for bulk delete (#417)

The bulk-delete modal previously used a password input as a confirmation
gate, with an Enter-to-submit handler. Windows Hello / passkey flows
that target password fields were able to autofill and synthesise an
Enter keystroke, which submitted the form and triggered the destructive
delete without an explicit click on the red Delete button (Rekoo's
report in #417).

Replace the password gate with a GitHub-style typed-literal pattern:
the user types the literal "DELETE" (English, case-sensitive) into a
plain text input. The Delete button stays disabled until the input
matches, and there is no Enter-to-submit handler — only an explicit
click on the red button proceeds. Plain text inputs aren't subject to
password autofill or passkey ceremony so the auto-submit class of bug
is gone.

Server side, drop the bcrypt password verify on /admin/events/bulk-delete
and the related INVALID_PASSWORD response. The server's auth boundary
remains adminAuth + requirePermission('events.delete'); this matches
DELETE /admin/events/:id which has never required a re-entered password.
The client-side typed gate is the safeguard against accidental clicks.

i18n: drop password-related keys, add confirmLabel + confirmHelp across
en, de, nl, pt, ru. The literal "DELETE" stays English in all locales
to keep the gesture immune to translation drift and unambiguous.

Verified locally: typed-DELETE sanity spec covers the gate (wrong case
disabled, correct enables, Enter-on-input no-ops, click submits, events
deleted). Existing 03-bulk-archive smoke remains green.
This commit is contained in:
Paul Nothaft
2026-05-08 09:46:24 +02:00
parent f57429faf2
commit 99e420b1b9
9 changed files with 70 additions and 115 deletions
+6 -5
View File
@@ -148,10 +148,12 @@ export const eventsService = {
return response.data;
},
// Bulk delete events (admin) — destructive. Requires the calling admin's
// password as a server-side confirmation gate. On 401 the server returns
// { error, code: 'INVALID_PASSWORD' } and no events are touched.
async bulkDeleteEvents(eventIds: number[], password: string): Promise<{
// Bulk delete events (admin) — destructive. The client-side confirmation
// gate is a typed-literal pattern in the modal (issue #417); no password
// is sent because passkey/autofill flows on a password input could
// auto-submit the form. The admin session JWT remains the auth boundary,
// matching DELETE /admin/events/:id which has never required a password.
async bulkDeleteEvents(eventIds: number[]): Promise<{
message: string;
results: {
successful: Array<{ id: number; name: string }>;
@@ -160,7 +162,6 @@ export const eventsService = {
}> {
const response = await api.post('/admin/events/bulk-delete', {
eventIds,
password,
});
return response.data;
},