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:
@@ -250,6 +250,78 @@ const hasCustomerContactColumns = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Cascade-delete a single event: photos, audit/access logs, queued emails,
|
||||
// the event row itself (in one transaction), then the on-disk folder /
|
||||
// archive zip / hero logo (best-effort — file failures don't unwind the DB
|
||||
// changes since the source of truth is the database). Used by both the
|
||||
// per-event DELETE /:id route and the bulk-delete route to avoid drift.
|
||||
//
|
||||
// Throws { code: 'EVENT_NOT_FOUND' } if the event id doesn't exist so the
|
||||
// bulk-delete loop can report it as a per-id failure without aborting the
|
||||
// whole batch. Any other error propagates and is the caller's problem.
|
||||
async function deleteEventCascade(eventId, adminContext) {
|
||||
const event = await db('events').where('id', eventId).first();
|
||||
if (!event) {
|
||||
const err = new Error('Event not found');
|
||||
err.code = 'EVENT_NOT_FOUND';
|
||||
throw err;
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
// 1. Delete activity logs (audit trail)
|
||||
await trx('activity_logs').where('event_id', eventId).del();
|
||||
// 2. Delete access logs
|
||||
await trx('access_logs').where('event_id', eventId).del();
|
||||
// 3. Delete email queue entries
|
||||
await trx('email_queue').where('event_id', eventId).del();
|
||||
// 4. Delete photos (also handles hero_photo_id foreign key)
|
||||
await trx('photos').where('event_id', eventId).del();
|
||||
// 5. Finally delete the event row
|
||||
await trx('events').where('id', eventId).del();
|
||||
|
||||
// Best-effort filesystem cleanup. Failures are logged but don't unwind
|
||||
// the transaction — the canonical state lives in the DB; orphan files
|
||||
// are recoverable noise, a half-deleted DB row is a permanent mess.
|
||||
if (event.folder_path) {
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const eventFolderPath = path.join(storagePath, 'events', 'active', event.folder_path);
|
||||
try {
|
||||
await fs.rm(eventFolderPath, { recursive: true, force: true });
|
||||
} catch (fsErr) {
|
||||
logger.warn('Failed to delete event folder during cascade delete', { eventId, path: eventFolderPath, error: fsErr.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (event.archive_path) {
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const archiveFile = path.join(storagePath, event.archive_path);
|
||||
try {
|
||||
await fs.unlink(archiveFile);
|
||||
} catch (fsErr) {
|
||||
logger.warn('Failed to delete archive file during cascade delete', { eventId, path: archiveFile, error: fsErr.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (event.hero_logo_path) {
|
||||
try {
|
||||
await fs.unlink(event.hero_logo_path);
|
||||
} catch (fsErr) {
|
||||
logger.warn('Failed to delete event logo during cascade delete', { eventId, path: event.hero_logo_path, error: fsErr.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Audit trail (outside the transaction so a logging failure can't undo
|
||||
// the actual delete).
|
||||
await logActivity('event_deleted',
|
||||
{ event_name: event.event_name },
|
||||
null,
|
||||
{ type: 'admin', id: adminContext.id, name: adminContext.username }
|
||||
);
|
||||
|
||||
return { id: event.id, name: event.event_name };
|
||||
}
|
||||
|
||||
// Create new event
|
||||
router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
body('event_type').notEmpty().trim().custom(async (value) => {
|
||||
@@ -1238,90 +1310,19 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
|
||||
router.delete('/:id', adminAuth, requirePermission('events.delete'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if event exists
|
||||
const event = await db('events').where('id', id).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
// Start a transaction to ensure all deletions succeed or fail together
|
||||
await db.transaction(async (trx) => {
|
||||
// 1. Delete activity logs (audit trail)
|
||||
await trx('activity_logs').where('event_id', id).del();
|
||||
|
||||
// 2. Delete access logs
|
||||
await trx('access_logs').where('event_id', id).del();
|
||||
|
||||
// 3. Delete email queue entries
|
||||
await trx('email_queue').where('event_id', id).del();
|
||||
|
||||
// 4. Delete photos (this will also handle hero_photo_id foreign key)
|
||||
await trx('photos').where('event_id', id).del();
|
||||
|
||||
// 5. Finally delete the event
|
||||
await trx('events').where('id', id).del();
|
||||
|
||||
// Delete event folder from storage if it exists
|
||||
if (event.folder_path) {
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const eventFolderPath = path.join(storagePath, 'events', 'active', event.folder_path);
|
||||
|
||||
try {
|
||||
const fsPromises = require('fs').promises;
|
||||
await fsPromises.rm(eventFolderPath, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
console.error('Failed to delete event folder:', err);
|
||||
// Don't fail the transaction if folder deletion fails
|
||||
}
|
||||
}
|
||||
|
||||
// Delete archive if exists
|
||||
if (event.archive_path) {
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const archivePath = path.join(storagePath, event.archive_path);
|
||||
|
||||
try {
|
||||
const fsPromises = require('fs').promises;
|
||||
await fsPromises.unlink(archivePath);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete archive file:', err);
|
||||
// Don't fail the transaction if file deletion fails
|
||||
}
|
||||
}
|
||||
|
||||
// Delete custom event logo if exists
|
||||
if (event.hero_logo_path) {
|
||||
try {
|
||||
const fsPromises = require('fs').promises;
|
||||
await fsPromises.unlink(event.hero_logo_path);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to delete event logo file during event deletion', { path: event.hero_logo_path, error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Log activity (outside transaction)
|
||||
await logActivity('event_deleted',
|
||||
{ event_name: event.event_name },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
await deleteEventCascade(id, { id: req.admin.id, username: req.admin.username });
|
||||
res.json({ message: 'Event deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting event:', error);
|
||||
|
||||
// Provide more specific error messages
|
||||
if (error.code === 'EVENT_NOT_FOUND') {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
logger.error('Error deleting event', { eventId: req.params.id, error: error.message });
|
||||
if (error.message && error.message.includes('foreign key constraint')) {
|
||||
res.status(500).json({
|
||||
return res.status(500).json({
|
||||
error: 'Cannot delete event due to existing references. Please contact support.'
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
error: 'Failed to delete event'
|
||||
});
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to delete event' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1651,6 +1652,82 @@ router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk delete — destructive, irreversible. Requires the calling admin to
|
||||
// re-enter their password as a confirmation gate (verified against the
|
||||
// stored bcrypt hash, same pattern as /auth/admin/change-password). Caps at
|
||||
// 100 events per request to keep request time bounded; the per-event
|
||||
// cascade touches 5 DB tables + 3 filesystem paths so 1000 events would
|
||||
// risk timing out the request. Loops via deleteEventCascade so the per-
|
||||
// event delete behaviour stays in lock-step with DELETE /:id.
|
||||
const BULK_DELETE_MAX = 100;
|
||||
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.*').isInt().withMessage('Each eventId must be an integer'),
|
||||
body('password').isString().notEmpty().withMessage('Password is required for confirmation')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { eventIds, password } = 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
|
||||
// middleware. We do NOT additionally filter to created_by here because
|
||||
// the per-event delete-cascade is global (matches DELETE /:id which
|
||||
// also has no role-based filter — that's why events.delete is a
|
||||
// sensitive permission).
|
||||
|
||||
const results = { successful: [], failed: [] };
|
||||
const adminContext = { id: req.admin.id, username: req.admin.username };
|
||||
|
||||
for (const eventId of eventIds) {
|
||||
try {
|
||||
const deleted = await deleteEventCascade(eventId, adminContext);
|
||||
results.successful.push(deleted);
|
||||
} catch (err) {
|
||||
results.failed.push({
|
||||
id: eventId,
|
||||
name: null,
|
||||
error: err.code === 'EVENT_NOT_FOUND' ? 'Event not found' : 'Failed to delete event'
|
||||
});
|
||||
logger.warn('Bulk-delete: per-event failure', { eventId, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
await logActivity('bulk_delete_completed',
|
||||
{
|
||||
totalEvents: eventIds.length,
|
||||
successfulCount: results.successful.length,
|
||||
failedCount: results.failed.length
|
||||
},
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: `Bulk delete completed: ${results.successful.length} succeeded, ${results.failed.length} failed`,
|
||||
results
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error in bulk delete', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to perform bulk delete' });
|
||||
}
|
||||
});
|
||||
|
||||
// Upload event custom logo
|
||||
router.post('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, eventLogoUpload.single('logo'), async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Истекающие",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
@@ -713,6 +756,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>
|
||||
);
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
Reference in New Issue
Block a user