Files
picpeak/frontend/src/components/admin/BulkDeleteModal.tsx
T
Paul Nothaft 48d538f94f 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.
2026-05-04 20:56:00 +02:00

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';