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
+15 -22
View File
@@ -1652,18 +1652,23 @@ router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [
} }
}); });
// Bulk delete — destructive, irreversible. Requires the calling admin to // Bulk delete — destructive, irreversible. Caps at 100 events per request
// re-enter their password as a confirmation gate (verified against the // to keep request time bounded; the per-event cascade touches 5 DB tables
// stored bcrypt hash, same pattern as /auth/admin/change-password). Caps at // + 3 filesystem paths so 1000 events would risk timing out the request.
// 100 events per request to keep request time bounded; the per-event // Loops via deleteEventCascade so the per-event delete behaviour stays in
// cascade touches 5 DB tables + 3 filesystem paths so 1000 events would // lock-step with DELETE /:id.
// risk timing out the request. Loops via deleteEventCascade so the per- //
// event delete behaviour stays in lock-step with DELETE /:id. // Confirmation is enforced client-side via the typed-DELETE pattern in
// BulkDeleteModal (#417). The previous server-side bcrypt-password gate
// was dropped because the destructive single-event DELETE /:id has never
// required a password either — events.delete permission + admin session
// is the auth boundary for both. The typed-literal client gate is the
// "accidental click" safeguard, and unlike a password input it isn't
// affected by passkey/Windows Hello autofill that auto-submits the form.
const BULK_DELETE_MAX = 100; const BULK_DELETE_MAX = 100;
router.post('/bulk-delete', adminAuth, requirePermission('events.delete'), [ router.post('/bulk-delete', adminAuth, requirePermission('events.delete'), [
body('eventIds').isArray({ min: 1, max: BULK_DELETE_MAX }).withMessage(`eventIds must be an array of 1-${BULK_DELETE_MAX} ids`), body('eventIds').isArray({ min: 1, max: BULK_DELETE_MAX }).withMessage(`eventIds must be an array of 1-${BULK_DELETE_MAX} ids`),
body('eventIds.*').isInt().withMessage('Each eventId must be an integer'), body('eventIds.*').isInt().withMessage('Each eventId must be an integer')
body('password').isString().notEmpty().withMessage('Password is required for confirmation')
], async (req, res) => { ], async (req, res) => {
try { try {
const errors = validationResult(req); const errors = validationResult(req);
@@ -1671,19 +1676,7 @@ router.post('/bulk-delete', adminAuth, requirePermission('events.delete'), [
return res.status(400).json({ errors: errors.array() }); return res.status(400).json({ errors: errors.array() });
} }
const { eventIds, password } = req.body; const { eventIds } = req.body;
// Verify the admin's password before doing anything destructive.
// Same pattern as /auth/admin/change-password (auth.js).
const admin = await db('admin_users').where({ id: req.admin.id }).first();
if (!admin) {
return res.status(401).json({ error: 'Authentication required' });
}
const validPassword = await bcrypt.compare(password, admin.password_hash);
if (!validPassword) {
logger.warn('Incorrect password on bulk-delete attempt', { adminId: req.admin.id, eventCount: eventIds.length });
return res.status(401).json({ error: 'Incorrect password', code: 'INVALID_PASSWORD' });
}
// Editor-role events.delete permission is already gated by the route // Editor-role events.delete permission is already gated by the route
// middleware. We do NOT additionally filter to created_by here because // middleware. We do NOT additionally filter to created_by here because
@@ -1,19 +1,21 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Trash2, AlertTriangle, X, Lock, Eye, EyeOff, Loader2 } from 'lucide-react'; import { Trash2, AlertTriangle, X, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Card, Input } from '../common'; import { Button, Card, Input } from '../common';
import type { Event } from '../../types'; import type { Event } from '../../types';
// The exact literal a user must type to confirm bulk deletion. Kept English
// across locales (matching GitHub's repo-deletion pattern) so it can never
// be interpreted as autofillable text or be triggered by passkey/Windows
// Hello flows on a password field — see issue #417.
const CONFIRM_LITERAL = 'DELETE';
interface BulkDeleteModalProps { interface BulkDeleteModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onConfirm: (password: string) => Promise<void>; onConfirm: () => Promise<void>;
selectedEvents: Event[]; selectedEvents: Event[];
isLoading?: boolean; isLoading?: boolean;
/** Set when the server responded 401 INVALID_PASSWORD; surfaces inline. */
passwordError?: string | null;
/** Clear the inline password error when the user starts typing again. */
onPasswordErrorClear?: () => void;
} }
export const BulkDeleteModal: React.FC<BulkDeleteModalProps> = ({ export const BulkDeleteModal: React.FC<BulkDeleteModalProps> = ({
@@ -22,25 +24,18 @@ export const BulkDeleteModal: React.FC<BulkDeleteModalProps> = ({
onConfirm, onConfirm,
selectedEvents, selectedEvents,
isLoading = false, isLoading = false,
passwordError = null,
onPasswordErrorClear,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [password, setPassword] = useState(''); const [confirmText, setConfirmText] = useState('');
const [showPassword, setShowPassword] = useState(false);
if (!isOpen) return null; if (!isOpen) return null;
const count = selectedEvents.length; const count = selectedEvents.length;
const confirmed = confirmText === CONFIRM_LITERAL;
const handleSubmit = async () => { const handleSubmit = async () => {
if (!password || isLoading) return; if (!confirmed || isLoading) return;
await onConfirm(password); await onConfirm();
};
const handlePasswordChange = (val: string) => {
setPassword(val);
if (passwordError && onPasswordErrorClear) onPasswordErrorClear();
}; };
return ( return (
@@ -61,7 +56,6 @@ export const BulkDeleteModal: React.FC<BulkDeleteModalProps> = ({
</button> </button>
</div> </div>
{/* Processing-state banner replaces the warning + form when in flight. */}
{isLoading ? ( {isLoading ? (
<div className="py-8 text-center"> <div className="py-8 text-center">
<Loader2 className="w-8 h-8 mx-auto mb-3 animate-spin text-red-600 dark:text-red-400" /> <Loader2 className="w-8 h-8 mx-auto mb-3 animate-spin text-red-600 dark:text-red-400" />
@@ -90,28 +84,22 @@ export const BulkDeleteModal: React.FC<BulkDeleteModalProps> = ({
<div className="mb-6"> <div className="mb-6">
<Input <Input
type={showPassword ? 'text' : 'password'} type="text"
label={t('events.bulkDelete.passwordLabel', 'Re-enter your password to confirm')} label={t(
value={password} 'events.bulkDelete.confirmLabel',
onChange={(e) => handlePasswordChange(e.target.value)} 'Type {{literal}} to confirm',
placeholder={t('events.bulkDelete.passwordPlaceholder', 'Your admin password')} { literal: CONFIRM_LITERAL }
helperText={t('events.bulkDelete.passwordHelp', 'We require your password as a safeguard against accidental bulk deletions.')} )}
error={passwordError || undefined} value={confirmText}
leftIcon={<Lock className="w-5 h-5" />} onChange={(e) => setConfirmText(e.target.value)}
rightIcon={ placeholder={CONFIRM_LITERAL}
<button helperText={t(
type="button" 'events.bulkDelete.confirmHelp',
onClick={() => setShowPassword(!showPassword)} 'A typed confirmation prevents accidental deletions and isn\'t affected by browser autofill or passkey shortcuts.'
className="p-1" )}
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
}
autoFocus autoFocus
onKeyDown={(e) => { autoComplete="off"
if (e.key === 'Enter' && password) handleSubmit(); spellCheck={false}
}}
/> />
</div> </div>
@@ -126,7 +114,7 @@ export const BulkDeleteModal: React.FC<BulkDeleteModalProps> = ({
<Button <Button
variant="primary" variant="primary"
onClick={handleSubmit} onClick={handleSubmit}
disabled={!password || isLoading} disabled={!confirmed || isLoading}
leftIcon={<Trash2 className="w-4 h-4" />} leftIcon={<Trash2 className="w-4 h-4" />}
className="bg-red-600 hover:bg-red-700 focus:ring-red-500 text-white" className="bg-red-600 hover:bg-red-700 focus:ring-red-500 text-white"
> >
+2 -4
View File
@@ -1062,12 +1062,10 @@
"bulkDelete": { "bulkDelete": {
"title": "{{count}} Veranstaltungen endgültig löschen?", "title": "{{count}} Veranstaltungen endgültig löschen?",
"warning": "Die ausgewählten Veranstaltungen, alle ihre Fotos, Archive und Audit-Logs werden endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", "warning": "Die ausgewählten Veranstaltungen, alle ihre Fotos, Archive und Audit-Logs werden endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"passwordLabel": "Zur Bestätigung Ihr Passwort erneut eingeben", "confirmLabel": "Geben Sie {{literal}} ein, um zu bestätigen",
"passwordPlaceholder": "Ihr Admin-Passwort", "confirmHelp": "Eine getippte Bestätigung verhindert versehentliche Löschungen und ist nicht von Browser-Autofill oder Passkey-Verknüpfungen betroffen.",
"passwordHelp": "Wir benötigen Ihr Passwort als Schutz vor versehentlichen Massenlöschungen.",
"submit": "{{count}} Veranstaltungen löschen", "submit": "{{count}} Veranstaltungen löschen",
"processing": "{{count}} Veranstaltungen werden gelöscht. Dies kann einige Minuten dauern — bitte schließen Sie dieses Fenster nicht.", "processing": "{{count}} Veranstaltungen werden gelöscht. Dies kann einige Minuten dauern — bitte schließen Sie dieses Fenster nicht.",
"incorrectPassword": "Falsches Passwort. Es wurden keine Veranstaltungen gelöscht.",
"successAll": "{{count}} Veranstaltungen endgültig gelöscht", "successAll": "{{count}} Veranstaltungen endgültig gelöscht",
"successPartial": "{{success}} Veranstaltungen gelöscht, {{failed}} fehlgeschlagen", "successPartial": "{{success}} Veranstaltungen gelöscht, {{failed}} fehlgeschlagen",
"errorGeneric": "Veranstaltungen konnten nicht gelöscht werden" "errorGeneric": "Veranstaltungen konnten nicht gelöscht werden"
+2 -4
View File
@@ -582,12 +582,10 @@
"bulkDelete": { "bulkDelete": {
"title": "Permanently delete {{count}} events?", "title": "Permanently delete {{count}} events?",
"warning": "This will permanently delete the selected events, all their photos, archives, and audit logs. This action cannot be undone.", "warning": "This will permanently delete the selected events, all their photos, archives, and audit logs. This action cannot be undone.",
"passwordLabel": "Re-enter your password to confirm", "confirmLabel": "Type {{literal}} to confirm",
"passwordPlaceholder": "Your admin password", "confirmHelp": "A typed confirmation prevents accidental deletions and isn't affected by browser autofill or passkey shortcuts.",
"passwordHelp": "We require your password as a safeguard against accidental bulk deletions.",
"submit": "Delete {{count}} events", "submit": "Delete {{count}} events",
"processing": "Deleting {{count}} events. This may take a few minutes — please don't close this window.", "processing": "Deleting {{count}} events. This may take a few minutes — please don't close this window.",
"incorrectPassword": "Incorrect password. No events were deleted.",
"successAll": "Permanently deleted {{count}} events", "successAll": "Permanently deleted {{count}} events",
"successPartial": "Deleted {{success}} events, {{failed}} failed", "successPartial": "Deleted {{success}} events, {{failed}} failed",
"errorGeneric": "Failed to delete events" "errorGeneric": "Failed to delete events"
+3 -5
View File
@@ -562,7 +562,7 @@
"draft": "Concept", "draft": "Concept",
"publishAndNotify": "Publiceren & klant informeren", "publishAndNotify": "Publiceren & klant informeren",
"publishConfirm": "Hiermee wordt de galerij toegankelijk en wordt de notificatie-e-mail naar de klant verzonden. Doorgaan?", "publishConfirm": "Hiermee wordt de galerij toegankelijk en wordt de notificatie-e-mail naar de klant verzonden. Doorgaan?",
"publishSuccess": "Galerij gepubliceerd en klant ge\u00efnformeerd!", "publishSuccess": "Galerij gepubliceerd en klant geïnformeerd!",
"draftBanner": "Deze galerij staat in conceptmodus. Upload je foto's en publiceer wanneer je klaar bent.", "draftBanner": "Deze galerij staat in conceptmodus. Upload je foto's en publiceer wanneer je klaar bent.",
"daysLeft": "{{count}}d resterend", "daysLeft": "{{count}}d resterend",
"daysLeft_plural": "{{count}}d resterend", "daysLeft_plural": "{{count}}d resterend",
@@ -576,12 +576,10 @@
"bulkDelete": { "bulkDelete": {
"title": "{{count}} evenementen permanent verwijderen?", "title": "{{count}} evenementen permanent verwijderen?",
"warning": "De geselecteerde evenementen, al hun foto's, archieven en auditlogboeken worden permanent verwijderd. Deze actie kan niet ongedaan worden gemaakt.", "warning": "De geselecteerde evenementen, al hun foto's, archieven en auditlogboeken worden permanent verwijderd. Deze actie kan niet ongedaan worden gemaakt.",
"passwordLabel": "Voer ter bevestiging uw wachtwoord opnieuw in", "confirmLabel": "Typ {{literal}} om te bevestigen",
"passwordPlaceholder": "Uw beheerderswachtwoord", "confirmHelp": "Een getypte bevestiging voorkomt onbedoelde verwijderingen en wordt niet beïnvloed door browser-autofill of passkey-snelkoppelingen.",
"passwordHelp": "We vragen om uw wachtwoord als bescherming tegen onbedoelde bulkverwijderingen.",
"submit": "{{count}} evenementen verwijderen", "submit": "{{count}} evenementen verwijderen",
"processing": "{{count}} evenementen worden verwijderd. Dit kan enkele minuten duren — sluit dit venster niet.", "processing": "{{count}} evenementen worden verwijderd. Dit kan enkele minuten duren — sluit dit venster niet.",
"incorrectPassword": "Onjuist wachtwoord. Er zijn geen evenementen verwijderd.",
"successAll": "{{count}} evenementen permanent verwijderd", "successAll": "{{count}} evenementen permanent verwijderd",
"successPartial": "{{success}} evenementen verwijderd, {{failed}} mislukt", "successPartial": "{{success}} evenementen verwijderd, {{failed}} mislukt",
"errorGeneric": "Kan evenementen niet verwijderen" "errorGeneric": "Kan evenementen niet verwijderen"
+2 -4
View File
@@ -576,12 +576,10 @@
"bulkDelete": { "bulkDelete": {
"title": "Excluir permanentemente {{count}} eventos?", "title": "Excluir permanentemente {{count}} eventos?",
"warning": "Os eventos selecionados, todas as suas fotos, arquivos e logs de auditoria serão excluídos permanentemente. Esta ação não pode ser desfeita.", "warning": "Os eventos selecionados, todas as suas fotos, arquivos e logs de auditoria serão excluídos permanentemente. Esta ação não pode ser desfeita.",
"passwordLabel": "Digite sua senha novamente para confirmar", "confirmLabel": "Digite {{literal}} para confirmar",
"passwordPlaceholder": "Sua senha de administrador", "confirmHelp": "Uma confirmação digitada evita exclusões acidentais e não é afetada pelo autopreenchimento do navegador ou atalhos de passkey.",
"passwordHelp": "Solicitamos sua senha como proteção contra exclusões em massa acidentais.",
"submit": "Excluir {{count}} eventos", "submit": "Excluir {{count}} eventos",
"processing": "Excluindo {{count}} eventos. Isso pode levar alguns minutos — não feche esta janela.", "processing": "Excluindo {{count}} eventos. Isso pode levar alguns minutos — não feche esta janela.",
"incorrectPassword": "Senha incorreta. Nenhum evento foi excluído.",
"successAll": "{{count}} eventos excluídos permanentemente", "successAll": "{{count}} eventos excluídos permanentemente",
"successPartial": "{{success}} eventos excluídos, {{failed}} com falha", "successPartial": "{{success}} eventos excluídos, {{failed}} com falha",
"errorGeneric": "Falha ao excluir eventos" "errorGeneric": "Falha ao excluir eventos"
+2 -4
View File
@@ -576,12 +576,10 @@
"bulkDelete": { "bulkDelete": {
"title": "Безвозвратно удалить {{count}} событий?", "title": "Безвозвратно удалить {{count}} событий?",
"warning": "Выбранные события, все их фотографии, архивы и журналы аудита будут удалены безвозвратно. Это действие невозможно отменить.", "warning": "Выбранные события, все их фотографии, архивы и журналы аудита будут удалены безвозвратно. Это действие невозможно отменить.",
"passwordLabel": "Введите пароль для подтверждения", "confirmLabel": "Введите {{literal}} для подтверждения",
"passwordPlaceholder": "Ваш пароль администратора", "confirmHelp": "Подтверждение вводом текста предотвращает случайные удаления и не зависит от автозаполнения браузера или сочетаний клавиш с passkey.",
"passwordHelp": "Мы запрашиваем пароль для защиты от случайного массового удаления.",
"submit": "Удалить {{count}} событий", "submit": "Удалить {{count}} событий",
"processing": "Удаление {{count}} событий. Это может занять несколько минут — пожалуйста, не закрывайте это окно.", "processing": "Удаление {{count}} событий. Это может занять несколько минут — пожалуйста, не закрывайте это окно.",
"incorrectPassword": "Неверный пароль. События не были удалены.",
"successAll": "Безвозвратно удалено {{count}} событий", "successAll": "Безвозвратно удалено {{count}} событий",
"successPartial": "Удалено {{success}} событий, {{failed}} не удалось", "successPartial": "Удалено {{success}} событий, {{failed}} не удалось",
"errorGeneric": "Не удалось удалить события" "errorGeneric": "Не удалось удалить события"
+8 -25
View File
@@ -48,7 +48,6 @@ export const EventsListPage: React.FC = () => {
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null); const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false); const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false);
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false); const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false);
const [bulkDeletePasswordError, setBulkDeletePasswordError] = useState<string | null>(null);
const [copiedEventId, setCopiedEventId] = useState<number | null>(null); const [copiedEventId, setCopiedEventId] = useState<number | null>(null);
const copyShareLink = async (event: Event) => { const copyShareLink = async (event: Event) => {
@@ -201,18 +200,15 @@ export const EventsListPage: React.FC = () => {
}, },
}); });
// Bulk delete mutation. The 401 INVALID_PASSWORD response surfaces inline // Bulk delete mutation. Confirmation is handled client-side by the modal's
// on the modal's password field rather than as a toast, since it's a // typed-DELETE gate (#417); server still enforces auth + permission.
// recoverable input error (the user can retry without losing context).
const bulkDeleteMutation = useMutation({ const bulkDeleteMutation = useMutation({
mutationFn: ({ eventIds, password }: { eventIds: number[]; password: string }) => mutationFn: (eventIds: number[]) => eventsService.bulkDeleteEvents(eventIds),
eventsService.bulkDeleteEvents(eventIds, password),
onSuccess: (data) => { onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['admin-events'] }); queryClient.invalidateQueries({ queryKey: ['admin-events'] });
queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] }); queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] });
setSelectedEvents([]); setSelectedEvents([]);
setShowBulkDeleteModal(false); setShowBulkDeleteModal(false);
setBulkDeletePasswordError(null);
if (data.results.failed.length === 0) { if (data.results.failed.length === 0) {
toast.success(t('events.bulkDelete.successAll', { count: data.results.successful.length })); toast.success(t('events.bulkDelete.successAll', { count: data.results.successful.length }));
@@ -220,14 +216,9 @@ export const EventsListPage: React.FC = () => {
toast.warning(t('events.bulkDelete.successPartial', { success: data.results.successful.length, failed: data.results.failed.length })); toast.warning(t('events.bulkDelete.successPartial', { success: data.results.successful.length, failed: data.results.failed.length }));
} }
}, },
onError: (error: unknown) => { onError: () => {
const e = error as { response?: { status?: number; data?: { code?: string; error?: string } } };
if (e?.response?.status === 401 && e.response.data?.code === 'INVALID_PASSWORD') {
setBulkDeletePasswordError(t('events.bulkDelete.incorrectPassword'));
} else {
toast.error(t('events.bulkDelete.errorGeneric')); toast.error(t('events.bulkDelete.errorGeneric'));
setShowBulkDeleteModal(false); setShowBulkDeleteModal(false);
}
}, },
}); });
@@ -441,10 +432,7 @@ export const EventsListPage: React.FC = () => {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={() => setShowBulkDeleteModal(true)}
setBulkDeletePasswordError(null);
setShowBulkDeleteModal(true);
}}
className="border-red-300 text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/30" className="border-red-300 text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/30"
> >
{t('events.deleteSelected', 'Delete Selected')} {t('events.deleteSelected', 'Delete Selected')}
@@ -766,17 +754,12 @@ export const EventsListPage: React.FC = () => {
{/* Bulk Delete Modal */} {/* Bulk Delete Modal */}
<BulkDeleteModal <BulkDeleteModal
isOpen={showBulkDeleteModal} isOpen={showBulkDeleteModal}
onClose={() => { onClose={() => setShowBulkDeleteModal(false)}
setShowBulkDeleteModal(false); onConfirm={async () => {
setBulkDeletePasswordError(null); await bulkDeleteMutation.mutateAsync(selectedEvents);
}}
onConfirm={async (password) => {
await bulkDeleteMutation.mutateAsync({ eventIds: selectedEvents, password });
}} }}
selectedEvents={events.filter(e => selectedEvents.includes(e.id))} selectedEvents={events.filter(e => selectedEvents.includes(e.id))}
isLoading={bulkDeleteMutation.isPending} isLoading={bulkDeleteMutation.isPending}
passwordError={bulkDeletePasswordError}
onPasswordErrorClear={() => setBulkDeletePasswordError(null)}
/> />
</div> </div>
</ErrorBoundary> </ErrorBoundary>
+6 -5
View File
@@ -148,10 +148,12 @@ export const eventsService = {
return response.data; return response.data;
}, },
// Bulk delete events (admin) — destructive. Requires the calling admin's // Bulk delete events (admin) — destructive. The client-side confirmation
// password as a server-side confirmation gate. On 401 the server returns // gate is a typed-literal pattern in the modal (issue #417); no password
// { error, code: 'INVALID_PASSWORD' } and no events are touched. // is sent because passkey/autofill flows on a password input could
async bulkDeleteEvents(eventIds: number[], password: string): Promise<{ // 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; message: string;
results: { results: {
successful: Array<{ id: number; name: string }>; successful: Array<{ id: number; name: string }>;
@@ -160,7 +162,6 @@ export const eventsService = {
}> { }> {
const response = await api.post('/admin/events/bulk-delete', { const response = await api.post('/admin/events/bulk-delete', {
eventIds, eventIds,
password,
}); });
return response.data; return response.data;
}, },