Merge pull request #389 from the-luap/feat/events-bulk-delete

feat(events): bulk delete with password confirmation (#384)
This commit is contained in:
Paul Nothaft
2026-05-04 21:00:06 +02:00
committed by GitHub
10 changed files with 450 additions and 82 deletions
@@ -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';
+1
View File
@@ -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';
+14
View File
@@ -1058,6 +1058,20 @@
"guestsCannotAccessGallery": "Gäste können nicht mehr auf die Galerie zugreifen. Erwägen Sie, diese Veranstaltung zu archivieren.",
"bulkArchiveSuccess": "{{count}} Veranstaltungen erfolgreich archiviert",
"bulkArchivePartial": "{{success}} Veranstaltungen archiviert, {{failed}} fehlgeschlagen",
"deleteSelected": "Ausgewählte löschen",
"bulkDelete": {
"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.",
"passwordLabel": "Zur Bestätigung Ihr Passwort erneut eingeben",
"passwordPlaceholder": "Ihr Admin-Passwort",
"passwordHelp": "Wir benötigen Ihr Passwort als Schutz vor versehentlichen Massenlöschungen.",
"submit": "{{count}} Veranstaltungen löschen",
"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",
"successPartial": "{{success}} Veranstaltungen gelöscht, {{failed}} fehlgeschlagen",
"errorGeneric": "Veranstaltungen konnten nicht gelöscht werden"
},
"searchEventsPlaceholder": "Veranstaltungen suchen...",
"all": "Alle",
"expiring": "Läuft ab",
+14
View File
@@ -578,6 +578,20 @@
"tryAgain": "Try Again",
"bulkArchiveSuccess": "Successfully archived {{count}} events",
"bulkArchivePartial": "Archived {{success}} events, {{failed}} failed",
"deleteSelected": "Delete Selected",
"bulkDelete": {
"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.",
"passwordLabel": "Re-enter your password to confirm",
"passwordPlaceholder": "Your admin password",
"passwordHelp": "We require your password as a safeguard against accidental bulk deletions.",
"submit": "Delete {{count}} events",
"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",
"successPartial": "Deleted {{success}} events, {{failed}} failed",
"errorGeneric": "Failed to delete events"
},
"searchEventsPlaceholder": "Search events...",
"all": "All",
"expiring": "Expiring",
+14
View File
@@ -572,6 +572,20 @@
"tryAgain": "Opnieuw proberen",
"bulkArchiveSuccess": "{{count}} evenementen succesvol gearchiveerd",
"bulkArchivePartial": "{{success}} evenementen gearchiveerd, {{failed}} mislukt",
"deleteSelected": "Geselecteerde verwijderen",
"bulkDelete": {
"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.",
"passwordLabel": "Voer ter bevestiging uw wachtwoord opnieuw in",
"passwordPlaceholder": "Uw beheerderswachtwoord",
"passwordHelp": "We vragen om uw wachtwoord als bescherming tegen onbedoelde bulkverwijderingen.",
"submit": "{{count}} evenementen verwijderen",
"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",
"successPartial": "{{success}} evenementen verwijderd, {{failed}} mislukt",
"errorGeneric": "Kan evenementen niet verwijderen"
},
"searchEventsPlaceholder": "Evenementen zoeken...",
"all": "Alle",
"expiring": "Verloopt binnenkort",
+14
View File
@@ -572,6 +572,20 @@
"tryAgain": "Tentar Novamente",
"bulkArchiveSuccess": "{{count}} eventos arquivados com sucesso",
"bulkArchivePartial": "{{success}} eventos arquivados, {{failed}} com falha",
"deleteSelected": "Excluir selecionados",
"bulkDelete": {
"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.",
"passwordLabel": "Digite sua senha novamente para confirmar",
"passwordPlaceholder": "Sua senha de administrador",
"passwordHelp": "Solicitamos sua senha como proteção contra exclusões em massa acidentais.",
"submit": "Excluir {{count}} eventos",
"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",
"successPartial": "{{success}} eventos excluídos, {{failed}} com falha",
"errorGeneric": "Falha ao excluir eventos"
},
"searchEventsPlaceholder": "Buscar eventos...",
"all": "Todos",
"expiring": "Expirando",
+14
View File
@@ -572,6 +572,20 @@
"tryAgain": "Попробовать снова",
"bulkArchiveSuccess": "Успешно архивировано {{count}} событий",
"bulkArchivePartial": "Архивировано {{success}} событий, {{failed}} не удалось",
"deleteSelected": "Удалить выбранные",
"bulkDelete": {
"title": "Безвозвратно удалить {{count}} событий?",
"warning": "Выбранные события, все их фотографии, архивы и журналы аудита будут удалены безвозвратно. Это действие невозможно отменить.",
"passwordLabel": "Введите пароль для подтверждения",
"passwordPlaceholder": "Ваш пароль администратора",
"passwordHelp": "Мы запрашиваем пароль для защиты от случайного массового удаления.",
"submit": "Удалить {{count}} событий",
"processing": "Удаление {{count}} событий. Это может занять несколько минут — пожалуйста, не закрывайте это окно.",
"incorrectPassword": "Неверный пароль. События не были удалены.",
"successAll": "Безвозвратно удалено {{count}} событий",
"successPartial": "Удалено {{success}} событий, {{failed}} не удалось",
"errorGeneric": "Не удалось удалить события"
},
"searchEventsPlaceholder": "Поиск событий...",
"all": "Все",
"expiring": "Истекающие",
+63 -4
View File
@@ -23,7 +23,7 @@ import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../components/common';
import { BulkArchiveModal } from '../../components/admin';
import { BulkArchiveModal, BulkDeleteModal } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService, type EventStatusFilter } from '../../services/events.service';
import { adminService } from '../../services/admin.service';
@@ -47,6 +47,8 @@ export const EventsListPage: React.FC = () => {
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false);
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false);
const [bulkDeletePasswordError, setBulkDeletePasswordError] = useState<string | null>(null);
const [copiedEventId, setCopiedEventId] = useState<number | null>(null);
const copyShareLink = async (event: Event) => {
@@ -187,7 +189,7 @@ export const EventsListPage: React.FC = () => {
queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] });
setSelectedEvents([]);
setShowBulkArchiveModal(false);
if (data.results.failed.length === 0) {
toast.success(t('events.bulkArchiveSuccess', { count: data.results.successful.length }));
} else {
@@ -199,6 +201,36 @@ export const EventsListPage: React.FC = () => {
},
});
// Bulk delete mutation. The 401 INVALID_PASSWORD response surfaces inline
// on the modal's password field rather than as a toast, since it's a
// recoverable input error (the user can retry without losing context).
const bulkDeleteMutation = useMutation({
mutationFn: ({ eventIds, password }: { eventIds: number[]; password: string }) =>
eventsService.bulkDeleteEvents(eventIds, password),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] });
setSelectedEvents([]);
setShowBulkDeleteModal(false);
setBulkDeletePasswordError(null);
if (data.results.failed.length === 0) {
toast.success(t('events.bulkDelete.successAll', { count: data.results.successful.length }));
} else {
toast.warning(t('events.bulkDelete.successPartial', { success: data.results.successful.length, failed: data.results.failed.length }));
}
},
onError: (error: unknown) => {
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'));
setShowBulkDeleteModal(false);
}
},
});
// Filtering and searching now happen server-side. Use the response directly,
// ordered as the backend returned them (created_at desc by default).
const events: Event[] = data?.events ?? [];
@@ -399,13 +431,24 @@ export const EventsListPage: React.FC = () => {
<Button variant="outline" size="sm" onClick={() => setSelectedEvents([])}>
{t('events.clear')}
</Button>
<Button
variant="outline"
<Button
variant="outline"
size="sm"
onClick={() => setShowBulkArchiveModal(true)}
>
{t('events.archiveSelected')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
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"
>
{t('events.deleteSelected', 'Delete Selected')}
</Button>
</div>
</div>
)}
@@ -719,6 +762,22 @@ export const EventsListPage: React.FC = () => {
selectedEvents={events.filter(e => selectedEvents.includes(e.id))}
isLoading={bulkArchiveMutation.isPending}
/>
{/* Bulk Delete Modal */}
<BulkDeleteModal
isOpen={showBulkDeleteModal}
onClose={() => {
setShowBulkDeleteModal(false);
setBulkDeletePasswordError(null);
}}
onConfirm={async (password) => {
await bulkDeleteMutation.mutateAsync({ eventIds: selectedEvents, password });
}}
selectedEvents={events.filter(e => selectedEvents.includes(e.id))}
isLoading={bulkDeleteMutation.isPending}
passwordError={bulkDeletePasswordError}
onPasswordErrorClear={() => setBulkDeletePasswordError(null)}
/>
</div>
</ErrorBoundary>
);
+17
View File
@@ -148,6 +148,23 @@ 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<{
message: string;
results: {
successful: Array<{ id: number; name: string }>;
failed: Array<{ id: number; name: string | null; error: string }>;
};
}> {
const response = await api.post('/admin/events/bulk-delete', {
eventIds,
password,
});
return response.data;
},
// Extend event expiration (admin)
async extendExpiration(id: number, days: number): Promise<Event> {
const response = await api.post<Event>(`/events/${id}/extend`, {