feat(events): bulk delete with password confirmation (#384)
Adds the bulk-delete half of #384 — admins can select multiple events from the list and delete them in one batch, gated by re-entering their password. ## Why password confirmation Bulk delete is destructive and irreversible (cascades across 5 DB tables and 3 filesystem paths per event). Re-entering the password matches the pattern already used by /auth/admin/change-password and makes accidental clicks much harder than a plain "type DELETE to confirm" — the muscle-memory required to type your real password is a stronger gate than typing a literal word. ## Changes ### Backend (adminEvents.js) - Extracted the per-event cascade-delete logic into a module-private `deleteEventCascade(eventId, adminContext)` helper. The DELETE /:id route now calls it instead of inlining 60 lines of cascade — same behaviour, no drift between the per-event and bulk paths. - New `POST /admin/events/bulk-delete`. Body: `{ eventIds, password }`. Permission: `events.delete`. - Validates `eventIds` array length (1–100) and that each id is an integer. The 100-cap keeps request time bounded; the per-event cascade touches DB + filesystem so 1000 events at once would risk timing out the request. - Verifies `password` against the calling admin's bcrypt hash via `bcrypt.compare()` (same as /auth/admin/change-password). Wrong password → 401 `{ error, code: 'INVALID_PASSWORD' }` and no events are touched. - Loops via `deleteEventCascade`, returns `{ results: { successful, failed } }` with the same shape as /bulk-archive so the frontend can show partial-failure feedback. - Logs `bulk_delete_completed` activity with totals. ### Frontend - `events.service.ts`: `bulkDeleteEvents(eventIds, password)`. - New `BulkDeleteModal.tsx`. Red/destructive variant of the bulk-archive modal: - Lists the events to be deleted (so the admin can verify). - Password input with show/hide toggle, autofocus, Enter-to-submit. - Inline `passwordError` prop surfaces the 401 INVALID_PASSWORD response without losing the modal state — admin can retry without re-typing the event list. - "Processing" state replaces the form with a spinner + "Deleting N events. This may take a few minutes — please don't close this window." (i18n) so admins know not to abandon the page during a slow operation. - `EventsListPage.tsx`: "Delete Selected" button next to "Archive Selected" in the bulk-actions bar (red-styled to signal danger), bulkDeleteMutation that maps the 401 to the modal's inline error and any other failure to a generic toast. ### i18n 12 new keys under `events.bulkDelete.*` in all 5 locales (en/de/nl/pt/ru): title, warning, password label/placeholder/help, submit, processing, incorrectPassword, successAll, successPartial, errorGeneric, plus `events.deleteSelected` for the button. Hand- written for de; nl/pt/ru should get a native-speaker pass at some point but read naturally. ### Verified - `npx tsc --noEmit` clean - `npx eslint` clean on every touched file (4 pre-existing errors in adminEvents.js for unused vars unrelated to this PR) - All 5 locale JSON files parse cleanly - `node -e "require('./src/routes/adminEvents')"` loads the module Closes the bulk-delete half of #384. The Photos-column half lands separately in PR #387.
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Trash2, AlertTriangle, X, Lock, Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Card, Input } from '../common';
|
||||
import type { Event } from '../../types';
|
||||
|
||||
interface BulkDeleteModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (password: string) => Promise<void>;
|
||||
selectedEvents: Event[];
|
||||
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> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
selectedEvents,
|
||||
isLoading = false,
|
||||
passwordError = null,
|
||||
onPasswordErrorClear,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const count = selectedEvents.length;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!password || isLoading) return;
|
||||
await onConfirm(password);
|
||||
};
|
||||
|
||||
const handlePasswordChange = (val: string) => {
|
||||
setPassword(val);
|
||||
if (passwordError && onPasswordErrorClear) onPasswordErrorClear();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-red-700 dark:text-red-400">
|
||||
{t('events.bulkDelete.title', 'Permanently delete {{count}} events?', { count })}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
disabled={isLoading}
|
||||
aria-label={t('common.close', 'Close')}
|
||||
>
|
||||
<X className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Processing-state banner replaces the warning + form when in flight. */}
|
||||
{isLoading ? (
|
||||
<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" />
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{t('events.bulkDelete.processing', 'Deleting {{count}} events. This may take a few minutes — please don\'t close this window.', { count })}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-800 dark:text-red-200">
|
||||
{t('events.bulkDelete.warning', 'This will permanently delete the selected events, all their photos, archives, and audit logs. This action cannot be undone.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-neutral-200 dark:border-neutral-700 rounded-lg max-h-40 overflow-y-auto mb-4">
|
||||
<ul className="p-3 space-y-1">
|
||||
{selectedEvents.map((event) => (
|
||||
<li key={event.id} className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
• {event.event_name} ({event.event_type})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
label={t('events.bulkDelete.passwordLabel', 'Re-enter your password to confirm')}
|
||||
value={password}
|
||||
onChange={(e) => handlePasswordChange(e.target.value)}
|
||||
placeholder={t('events.bulkDelete.passwordPlaceholder', 'Your admin password')}
|
||||
helperText={t('events.bulkDelete.passwordHelp', 'We require your password as a safeguard against accidental bulk deletions.')}
|
||||
error={passwordError || undefined}
|
||||
leftIcon={<Lock className="w-5 h-5" />}
|
||||
rightIcon={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="p-1"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && password) handleSubmit();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={!password || isLoading}
|
||||
leftIcon={<Trash2 className="w-4 h-4" />}
|
||||
className="bg-red-600 hover:bg-red-700 focus:ring-red-500 text-white"
|
||||
>
|
||||
{t('events.bulkDelete.submit', 'Delete {{count}} events', { count })}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BulkDeleteModal.displayName = 'BulkDeleteModal';
|
||||
@@ -10,6 +10,7 @@ export { EventCategoryManager } from './EventCategoryManager';
|
||||
export { CMSEditor } from './CMSEditor';
|
||||
export { WelcomeMessageEditor } from './WelcomeMessageEditor';
|
||||
export { BulkArchiveModal } from './BulkArchiveModal';
|
||||
export { BulkDeleteModal } from './BulkDeleteModal';
|
||||
export { MaintenanceBanner } from './MaintenanceBanner';
|
||||
export { EmailPreviewModal } from './EmailPreviewModal';
|
||||
export { AdminPhotoGrid } from './AdminPhotoGrid';
|
||||
|
||||
Reference in New Issue
Block a user