From c3bca051ea8b53c35347226608120625f2f35927 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Wed, 1 Jul 2026 00:22:43 +0200 Subject: [PATCH] fix(ux): confirm + feedback for destructive bulk actions Resolves the Critical destructive-action safety gap (and the CSRF-adjacent risk of frictionless Gmail mutation): - Bulk Trash now requires an explicit confirmation dialog (count + 30-day-recovery note) instead of firing on one click. - Every bulk action surfaces a success / partial-failure / error toast. - Rows are removed from the list only when the server confirms the whole batch succeeded; partial failures leave the list intact so the user can retry, instead of optimistically hiding failed items. Built on the new Dialog/Toast primitives (also restyles the toolbar to the new system). Real cross-session Undo is deferred to the activity-log backend (specced). Co-Authored-By: Claude Opus 4.8 --- frontend/src/components/BulkToolbar.jsx | 99 ++++++++++++++++++++----- 1 file changed, 81 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/BulkToolbar.jsx b/frontend/src/components/BulkToolbar.jsx index ef2bcfe..97fa140 100644 --- a/frontend/src/components/BulkToolbar.jsx +++ b/frontend/src/components/BulkToolbar.jsx @@ -1,30 +1,93 @@ +import { useState } from 'react'; +import { MailOpen, Mail, Star, Archive, Trash2 } from 'lucide-react'; import { BulkApi } from '../api/client.js'; +import { + Button, useToast, + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose, +} from './ui'; -/// -/// Toolbar shown above an email list when one or more rows are selected. -/// `onDone` is called with the action key after a successful bulk call so the -/// caller can update local state (e.g. remove trashed/archived rows). -/// +/** + * Toolbar shown above an email list when rows are selected. + * + * Safety (Phase 4 / UX-Critical): the destructive Trash action now requires an + * explicit confirmation dialog, every action surfaces success/partial-failure via + * a toast, and rows are only removed from the list when the server confirms the + * whole batch succeeded (no more optimistic removal that hides failures). + * `onDone(action, ids)` is called only on full success so the caller can prune state. + */ export default function BulkToolbar({ selectedIds, onDone, onClear }) { + const { toast } = useToast(); + const [busy, setBusy] = useState(false); + const [confirmTrash, setConfirmTrash] = useState(false); const count = selectedIds.length; if (count === 0) return null; - const run = (fn, action) => async () => { - await fn(selectedIds); - onDone(action, selectedIds); - onClear(); + const apply = async (fn, action, label) => { + setBusy(true); + try { + const res = await fn(selectedIds); + // CleanupResultDto: { succeededCount, failedCount, errors } + const ok = res?.succeededCount ?? count; + const failed = res?.failedCount ?? 0; + if (failed > 0) { + toast({ + variant: 'warning', + title: `${label}: ${ok} done, ${failed} failed`, + description: 'Some items could not be updated — the list was left unchanged so you can retry.', + }); + } else { + toast({ variant: 'success', title: `${label} ${ok} email${ok === 1 ? '' : 's'}` }); + onDone(action, selectedIds); + onClear(); + } + } catch { + toast({ variant: 'danger', title: `Couldn't ${label.toLowerCase()} ${count} email${count === 1 ? '' : 's'}`, description: 'Please try again.' }); + } finally { + setBusy(false); + setConfirmTrash(false); + } }; return ( -
- {count} selected -
- - - - - - +
+ {count} selected +
+ + + + + + + + !busy && setConfirmTrash(o)}> + + + Move {count} email{count === 1 ? '' : 's'} to Trash? + + This moves the selected mail to your Gmail Trash, where it stays recoverable + for 30 days before Gmail permanently deletes it. + + + + + + + + + +
); }