48d538f94f
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.
145 lines
5.8 KiB
TypeScript
145 lines
5.8 KiB
TypeScript
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';
|