Merge pull request #419 from the-luap/fix/bulk-delete-typed-confirm-417
fix(events): typed-DELETE confirmation for bulk delete (#417)
This commit is contained in:
@@ -1652,18 +1652,23 @@ router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bulk delete — destructive, irreversible. Requires the calling admin to
|
// Bulk delete — destructive, irreversible. Caps at 100 events per request
|
||||||
// re-enter their password as a confirmation gate (verified against the
|
// to keep request time bounded; the per-event cascade touches 5 DB tables
|
||||||
// stored bcrypt hash, same pattern as /auth/admin/change-password). Caps at
|
// + 3 filesystem paths so 1000 events would risk timing out the request.
|
||||||
// 100 events per request to keep request time bounded; the per-event
|
// Loops via deleteEventCascade so the per-event delete behaviour stays in
|
||||||
// cascade touches 5 DB tables + 3 filesystem paths so 1000 events would
|
// lock-step with DELETE /:id.
|
||||||
// risk timing out the request. Loops via deleteEventCascade so the per-
|
//
|
||||||
// event delete behaviour stays in lock-step with DELETE /:id.
|
// Confirmation is enforced client-side via the typed-DELETE pattern in
|
||||||
|
// BulkDeleteModal (#417). The previous server-side bcrypt-password gate
|
||||||
|
// was dropped because the destructive single-event DELETE /:id has never
|
||||||
|
// required a password either — events.delete permission + admin session
|
||||||
|
// is the auth boundary for both. The typed-literal client gate is the
|
||||||
|
// "accidental click" safeguard, and unlike a password input it isn't
|
||||||
|
// affected by passkey/Windows Hello autofill that auto-submits the form.
|
||||||
const BULK_DELETE_MAX = 100;
|
const BULK_DELETE_MAX = 100;
|
||||||
router.post('/bulk-delete', adminAuth, requirePermission('events.delete'), [
|
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').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('eventIds.*').isInt().withMessage('Each eventId must be an integer')
|
||||||
body('password').isString().notEmpty().withMessage('Password is required for confirmation')
|
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
@@ -1671,19 +1676,7 @@ router.post('/bulk-delete', adminAuth, requirePermission('events.delete'), [
|
|||||||
return res.status(400).json({ errors: errors.array() });
|
return res.status(400).json({ errors: errors.array() });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { eventIds, password } = req.body;
|
const { eventIds } = 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
|
// Editor-role events.delete permission is already gated by the route
|
||||||
// middleware. We do NOT additionally filter to created_by here because
|
// middleware. We do NOT additionally filter to created_by here because
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Trash2, AlertTriangle, X, Lock, Eye, EyeOff, Loader2 } from 'lucide-react';
|
import { Trash2, AlertTriangle, X, Loader2 } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Card, Input } from '../common';
|
import { Button, Card, Input } from '../common';
|
||||||
import type { Event } from '../../types';
|
import type { Event } from '../../types';
|
||||||
|
|
||||||
|
// The exact literal a user must type to confirm bulk deletion. Kept English
|
||||||
|
// across locales (matching GitHub's repo-deletion pattern) so it can never
|
||||||
|
// be interpreted as autofillable text or be triggered by passkey/Windows
|
||||||
|
// Hello flows on a password field — see issue #417.
|
||||||
|
const CONFIRM_LITERAL = 'DELETE';
|
||||||
|
|
||||||
interface BulkDeleteModalProps {
|
interface BulkDeleteModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: (password: string) => Promise<void>;
|
onConfirm: () => Promise<void>;
|
||||||
selectedEvents: Event[];
|
selectedEvents: Event[];
|
||||||
isLoading?: boolean;
|
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> = ({
|
export const BulkDeleteModal: React.FC<BulkDeleteModalProps> = ({
|
||||||
@@ -22,25 +24,18 @@ export const BulkDeleteModal: React.FC<BulkDeleteModalProps> = ({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
selectedEvents,
|
selectedEvents,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
passwordError = null,
|
|
||||||
onPasswordErrorClear,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [password, setPassword] = useState('');
|
const [confirmText, setConfirmText] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const count = selectedEvents.length;
|
const count = selectedEvents.length;
|
||||||
|
const confirmed = confirmText === CONFIRM_LITERAL;
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!password || isLoading) return;
|
if (!confirmed || isLoading) return;
|
||||||
await onConfirm(password);
|
await onConfirm();
|
||||||
};
|
|
||||||
|
|
||||||
const handlePasswordChange = (val: string) => {
|
|
||||||
setPassword(val);
|
|
||||||
if (passwordError && onPasswordErrorClear) onPasswordErrorClear();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -61,7 +56,6 @@ export const BulkDeleteModal: React.FC<BulkDeleteModalProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Processing-state banner replaces the warning + form when in flight. */}
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="py-8 text-center">
|
<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" />
|
<Loader2 className="w-8 h-8 mx-auto mb-3 animate-spin text-red-600 dark:text-red-400" />
|
||||||
@@ -90,28 +84,22 @@ export const BulkDeleteModal: React.FC<BulkDeleteModalProps> = ({
|
|||||||
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Input
|
<Input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type="text"
|
||||||
label={t('events.bulkDelete.passwordLabel', 'Re-enter your password to confirm')}
|
label={t(
|
||||||
value={password}
|
'events.bulkDelete.confirmLabel',
|
||||||
onChange={(e) => handlePasswordChange(e.target.value)}
|
'Type {{literal}} to confirm',
|
||||||
placeholder={t('events.bulkDelete.passwordPlaceholder', 'Your admin password')}
|
{ literal: CONFIRM_LITERAL }
|
||||||
helperText={t('events.bulkDelete.passwordHelp', 'We require your password as a safeguard against accidental bulk deletions.')}
|
)}
|
||||||
error={passwordError || undefined}
|
value={confirmText}
|
||||||
leftIcon={<Lock className="w-5 h-5" />}
|
onChange={(e) => setConfirmText(e.target.value)}
|
||||||
rightIcon={
|
placeholder={CONFIRM_LITERAL}
|
||||||
<button
|
helperText={t(
|
||||||
type="button"
|
'events.bulkDelete.confirmHelp',
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
'A typed confirmation prevents accidental deletions and isn\'t affected by browser autofill or passkey shortcuts.'
|
||||||
className="p-1"
|
)}
|
||||||
tabIndex={-1}
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
autoFocus
|
autoFocus
|
||||||
onKeyDown={(e) => {
|
autoComplete="off"
|
||||||
if (e.key === 'Enter' && password) handleSubmit();
|
spellCheck={false}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -126,7 +114,7 @@ export const BulkDeleteModal: React.FC<BulkDeleteModalProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!password || isLoading}
|
disabled={!confirmed || isLoading}
|
||||||
leftIcon={<Trash2 className="w-4 h-4" />}
|
leftIcon={<Trash2 className="w-4 h-4" />}
|
||||||
className="bg-red-600 hover:bg-red-700 focus:ring-red-500 text-white"
|
className="bg-red-600 hover:bg-red-700 focus:ring-red-500 text-white"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1062,12 +1062,10 @@
|
|||||||
"bulkDelete": {
|
"bulkDelete": {
|
||||||
"title": "{{count}} Veranstaltungen endgültig löschen?",
|
"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.",
|
"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",
|
"confirmLabel": "Geben Sie {{literal}} ein, um zu bestätigen",
|
||||||
"passwordPlaceholder": "Ihr Admin-Passwort",
|
"confirmHelp": "Eine getippte Bestätigung verhindert versehentliche Löschungen und ist nicht von Browser-Autofill oder Passkey-Verknüpfungen betroffen.",
|
||||||
"passwordHelp": "Wir benötigen Ihr Passwort als Schutz vor versehentlichen Massenlöschungen.",
|
|
||||||
"submit": "{{count}} Veranstaltungen löschen",
|
"submit": "{{count}} Veranstaltungen löschen",
|
||||||
"processing": "{{count}} Veranstaltungen werden gelöscht. Dies kann einige Minuten dauern — bitte schließen Sie dieses Fenster nicht.",
|
"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",
|
"successAll": "{{count}} Veranstaltungen endgültig gelöscht",
|
||||||
"successPartial": "{{success}} Veranstaltungen gelöscht, {{failed}} fehlgeschlagen",
|
"successPartial": "{{success}} Veranstaltungen gelöscht, {{failed}} fehlgeschlagen",
|
||||||
"errorGeneric": "Veranstaltungen konnten nicht gelöscht werden"
|
"errorGeneric": "Veranstaltungen konnten nicht gelöscht werden"
|
||||||
|
|||||||
@@ -582,12 +582,10 @@
|
|||||||
"bulkDelete": {
|
"bulkDelete": {
|
||||||
"title": "Permanently delete {{count}} events?",
|
"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.",
|
"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",
|
"confirmLabel": "Type {{literal}} to confirm",
|
||||||
"passwordPlaceholder": "Your admin password",
|
"confirmHelp": "A typed confirmation prevents accidental deletions and isn't affected by browser autofill or passkey shortcuts.",
|
||||||
"passwordHelp": "We require your password as a safeguard against accidental bulk deletions.",
|
|
||||||
"submit": "Delete {{count}} events",
|
"submit": "Delete {{count}} events",
|
||||||
"processing": "Deleting {{count}} events. This may take a few minutes — please don't close this window.",
|
"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",
|
"successAll": "Permanently deleted {{count}} events",
|
||||||
"successPartial": "Deleted {{success}} events, {{failed}} failed",
|
"successPartial": "Deleted {{success}} events, {{failed}} failed",
|
||||||
"errorGeneric": "Failed to delete events"
|
"errorGeneric": "Failed to delete events"
|
||||||
|
|||||||
@@ -562,7 +562,7 @@
|
|||||||
"draft": "Concept",
|
"draft": "Concept",
|
||||||
"publishAndNotify": "Publiceren & klant informeren",
|
"publishAndNotify": "Publiceren & klant informeren",
|
||||||
"publishConfirm": "Hiermee wordt de galerij toegankelijk en wordt de notificatie-e-mail naar de klant verzonden. Doorgaan?",
|
"publishConfirm": "Hiermee wordt de galerij toegankelijk en wordt de notificatie-e-mail naar de klant verzonden. Doorgaan?",
|
||||||
"publishSuccess": "Galerij gepubliceerd en klant ge\u00efnformeerd!",
|
"publishSuccess": "Galerij gepubliceerd en klant geïnformeerd!",
|
||||||
"draftBanner": "Deze galerij staat in conceptmodus. Upload je foto's en publiceer wanneer je klaar bent.",
|
"draftBanner": "Deze galerij staat in conceptmodus. Upload je foto's en publiceer wanneer je klaar bent.",
|
||||||
"daysLeft": "{{count}}d resterend",
|
"daysLeft": "{{count}}d resterend",
|
||||||
"daysLeft_plural": "{{count}}d resterend",
|
"daysLeft_plural": "{{count}}d resterend",
|
||||||
@@ -576,12 +576,10 @@
|
|||||||
"bulkDelete": {
|
"bulkDelete": {
|
||||||
"title": "{{count}} evenementen permanent verwijderen?",
|
"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.",
|
"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",
|
"confirmLabel": "Typ {{literal}} om te bevestigen",
|
||||||
"passwordPlaceholder": "Uw beheerderswachtwoord",
|
"confirmHelp": "Een getypte bevestiging voorkomt onbedoelde verwijderingen en wordt niet beïnvloed door browser-autofill of passkey-snelkoppelingen.",
|
||||||
"passwordHelp": "We vragen om uw wachtwoord als bescherming tegen onbedoelde bulkverwijderingen.",
|
|
||||||
"submit": "{{count}} evenementen verwijderen",
|
"submit": "{{count}} evenementen verwijderen",
|
||||||
"processing": "{{count}} evenementen worden verwijderd. Dit kan enkele minuten duren — sluit dit venster niet.",
|
"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",
|
"successAll": "{{count}} evenementen permanent verwijderd",
|
||||||
"successPartial": "{{success}} evenementen verwijderd, {{failed}} mislukt",
|
"successPartial": "{{success}} evenementen verwijderd, {{failed}} mislukt",
|
||||||
"errorGeneric": "Kan evenementen niet verwijderen"
|
"errorGeneric": "Kan evenementen niet verwijderen"
|
||||||
|
|||||||
@@ -576,12 +576,10 @@
|
|||||||
"bulkDelete": {
|
"bulkDelete": {
|
||||||
"title": "Excluir permanentemente {{count}} eventos?",
|
"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.",
|
"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",
|
"confirmLabel": "Digite {{literal}} para confirmar",
|
||||||
"passwordPlaceholder": "Sua senha de administrador",
|
"confirmHelp": "Uma confirmação digitada evita exclusões acidentais e não é afetada pelo autopreenchimento do navegador ou atalhos de passkey.",
|
||||||
"passwordHelp": "Solicitamos sua senha como proteção contra exclusões em massa acidentais.",
|
|
||||||
"submit": "Excluir {{count}} eventos",
|
"submit": "Excluir {{count}} eventos",
|
||||||
"processing": "Excluindo {{count}} eventos. Isso pode levar alguns minutos — não feche esta janela.",
|
"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",
|
"successAll": "{{count}} eventos excluídos permanentemente",
|
||||||
"successPartial": "{{success}} eventos excluídos, {{failed}} com falha",
|
"successPartial": "{{success}} eventos excluídos, {{failed}} com falha",
|
||||||
"errorGeneric": "Falha ao excluir eventos"
|
"errorGeneric": "Falha ao excluir eventos"
|
||||||
|
|||||||
@@ -576,12 +576,10 @@
|
|||||||
"bulkDelete": {
|
"bulkDelete": {
|
||||||
"title": "Безвозвратно удалить {{count}} событий?",
|
"title": "Безвозвратно удалить {{count}} событий?",
|
||||||
"warning": "Выбранные события, все их фотографии, архивы и журналы аудита будут удалены безвозвратно. Это действие невозможно отменить.",
|
"warning": "Выбранные события, все их фотографии, архивы и журналы аудита будут удалены безвозвратно. Это действие невозможно отменить.",
|
||||||
"passwordLabel": "Введите пароль для подтверждения",
|
"confirmLabel": "Введите {{literal}} для подтверждения",
|
||||||
"passwordPlaceholder": "Ваш пароль администратора",
|
"confirmHelp": "Подтверждение вводом текста предотвращает случайные удаления и не зависит от автозаполнения браузера или сочетаний клавиш с passkey.",
|
||||||
"passwordHelp": "Мы запрашиваем пароль для защиты от случайного массового удаления.",
|
|
||||||
"submit": "Удалить {{count}} событий",
|
"submit": "Удалить {{count}} событий",
|
||||||
"processing": "Удаление {{count}} событий. Это может занять несколько минут — пожалуйста, не закрывайте это окно.",
|
"processing": "Удаление {{count}} событий. Это может занять несколько минут — пожалуйста, не закрывайте это окно.",
|
||||||
"incorrectPassword": "Неверный пароль. События не были удалены.",
|
|
||||||
"successAll": "Безвозвратно удалено {{count}} событий",
|
"successAll": "Безвозвратно удалено {{count}} событий",
|
||||||
"successPartial": "Удалено {{success}} событий, {{failed}} не удалось",
|
"successPartial": "Удалено {{success}} событий, {{failed}} не удалось",
|
||||||
"errorGeneric": "Не удалось удалить события"
|
"errorGeneric": "Не удалось удалить события"
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ export const EventsListPage: React.FC = () => {
|
|||||||
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
|
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
|
||||||
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false);
|
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false);
|
||||||
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false);
|
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false);
|
||||||
const [bulkDeletePasswordError, setBulkDeletePasswordError] = useState<string | null>(null);
|
|
||||||
const [copiedEventId, setCopiedEventId] = useState<number | null>(null);
|
const [copiedEventId, setCopiedEventId] = useState<number | null>(null);
|
||||||
|
|
||||||
const copyShareLink = async (event: Event) => {
|
const copyShareLink = async (event: Event) => {
|
||||||
@@ -201,18 +200,15 @@ export const EventsListPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bulk delete mutation. The 401 INVALID_PASSWORD response surfaces inline
|
// Bulk delete mutation. Confirmation is handled client-side by the modal's
|
||||||
// on the modal's password field rather than as a toast, since it's a
|
// typed-DELETE gate (#417); server still enforces auth + permission.
|
||||||
// recoverable input error (the user can retry without losing context).
|
|
||||||
const bulkDeleteMutation = useMutation({
|
const bulkDeleteMutation = useMutation({
|
||||||
mutationFn: ({ eventIds, password }: { eventIds: number[]; password: string }) =>
|
mutationFn: (eventIds: number[]) => eventsService.bulkDeleteEvents(eventIds),
|
||||||
eventsService.bulkDeleteEvents(eventIds, password),
|
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] });
|
||||||
setSelectedEvents([]);
|
setSelectedEvents([]);
|
||||||
setShowBulkDeleteModal(false);
|
setShowBulkDeleteModal(false);
|
||||||
setBulkDeletePasswordError(null);
|
|
||||||
|
|
||||||
if (data.results.failed.length === 0) {
|
if (data.results.failed.length === 0) {
|
||||||
toast.success(t('events.bulkDelete.successAll', { count: data.results.successful.length }));
|
toast.success(t('events.bulkDelete.successAll', { count: data.results.successful.length }));
|
||||||
@@ -220,14 +216,9 @@ export const EventsListPage: React.FC = () => {
|
|||||||
toast.warning(t('events.bulkDelete.successPartial', { success: data.results.successful.length, failed: data.results.failed.length }));
|
toast.warning(t('events.bulkDelete.successPartial', { success: data.results.successful.length, failed: data.results.failed.length }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: () => {
|
||||||
const e = error as { response?: { status?: number; data?: { code?: string; error?: string } } };
|
toast.error(t('events.bulkDelete.errorGeneric'));
|
||||||
if (e?.response?.status === 401 && e.response.data?.code === 'INVALID_PASSWORD') {
|
setShowBulkDeleteModal(false);
|
||||||
setBulkDeletePasswordError(t('events.bulkDelete.incorrectPassword'));
|
|
||||||
} else {
|
|
||||||
toast.error(t('events.bulkDelete.errorGeneric'));
|
|
||||||
setShowBulkDeleteModal(false);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -441,10 +432,7 @@ export const EventsListPage: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => setShowBulkDeleteModal(true)}
|
||||||
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"
|
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')}
|
{t('events.deleteSelected', 'Delete Selected')}
|
||||||
@@ -766,17 +754,12 @@ export const EventsListPage: React.FC = () => {
|
|||||||
{/* Bulk Delete Modal */}
|
{/* Bulk Delete Modal */}
|
||||||
<BulkDeleteModal
|
<BulkDeleteModal
|
||||||
isOpen={showBulkDeleteModal}
|
isOpen={showBulkDeleteModal}
|
||||||
onClose={() => {
|
onClose={() => setShowBulkDeleteModal(false)}
|
||||||
setShowBulkDeleteModal(false);
|
onConfirm={async () => {
|
||||||
setBulkDeletePasswordError(null);
|
await bulkDeleteMutation.mutateAsync(selectedEvents);
|
||||||
}}
|
|
||||||
onConfirm={async (password) => {
|
|
||||||
await bulkDeleteMutation.mutateAsync({ eventIds: selectedEvents, password });
|
|
||||||
}}
|
}}
|
||||||
selectedEvents={events.filter(e => selectedEvents.includes(e.id))}
|
selectedEvents={events.filter(e => selectedEvents.includes(e.id))}
|
||||||
isLoading={bulkDeleteMutation.isPending}
|
isLoading={bulkDeleteMutation.isPending}
|
||||||
passwordError={bulkDeletePasswordError}
|
|
||||||
onPasswordErrorClear={() => setBulkDeletePasswordError(null)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -148,10 +148,12 @@ export const eventsService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Bulk delete events (admin) — destructive. Requires the calling admin's
|
// Bulk delete events (admin) — destructive. The client-side confirmation
|
||||||
// password as a server-side confirmation gate. On 401 the server returns
|
// gate is a typed-literal pattern in the modal (issue #417); no password
|
||||||
// { error, code: 'INVALID_PASSWORD' } and no events are touched.
|
// is sent because passkey/autofill flows on a password input could
|
||||||
async bulkDeleteEvents(eventIds: number[], password: string): Promise<{
|
// auto-submit the form. The admin session JWT remains the auth boundary,
|
||||||
|
// matching DELETE /admin/events/:id which has never required a password.
|
||||||
|
async bulkDeleteEvents(eventIds: number[]): Promise<{
|
||||||
message: string;
|
message: string;
|
||||||
results: {
|
results: {
|
||||||
successful: Array<{ id: number; name: string }>;
|
successful: Array<{ id: number; name: string }>;
|
||||||
@@ -160,7 +162,6 @@ export const eventsService = {
|
|||||||
}> {
|
}> {
|
||||||
const response = await api.post('/admin/events/bulk-delete', {
|
const response = await api.post('/admin/events/bulk-delete', {
|
||||||
eventIds,
|
eventIds,
|
||||||
password,
|
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user