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 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-01 00:22:43 +02:00
parent e6a0239436
commit c3bca051ea
+79 -16
View File
@@ -1,30 +1,93 @@
import { useState } from 'react';
import { MailOpen, Mail, Star, Archive, Trash2 } from 'lucide-react';
import { BulkApi } from '../api/client.js'; import { BulkApi } from '../api/client.js';
import {
Button, useToast,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose,
} from './ui';
/// <summary> /**
/// Toolbar shown above an email list when one or more rows are selected. * Toolbar shown above an email list when 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). * Safety (Phase 4 / UX-Critical): the destructive Trash action now requires an
/// </summary> * 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 }) { export default function BulkToolbar({ selectedIds, onDone, onClear }) {
const { toast } = useToast();
const [busy, setBusy] = useState(false);
const [confirmTrash, setConfirmTrash] = useState(false);
const count = selectedIds.length; const count = selectedIds.length;
if (count === 0) return null; if (count === 0) return null;
const run = (fn, action) => async () => { const apply = async (fn, action, label) => {
await fn(selectedIds); 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); onDone(action, selectedIds);
onClear(); 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 ( return (
<div className="bulk-toolbar"> <div className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 shadow-sm">
<span>{count} selected</span> <span className="text-sm font-medium">{count} selected</span>
<div className="bulk-toolbar-spacer" /> <div className="flex-1" />
<button className="bulk-btn" onClick={run(BulkApi.markRead, 'read')}>Mark read</button> <Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.markRead, 'read', 'Marked read')}>
<button className="bulk-btn" onClick={run(BulkApi.markUnread, 'unread')}>Mark unread</button> <MailOpen /> Read
<button className="bulk-btn" onClick={run(BulkApi.star, 'star')}>Star</button> </Button>
<button className="bulk-btn" onClick={run(BulkApi.archive, 'archive')}>Archive</button> <Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.markUnread, 'unread', 'Marked unread')}>
<button className="bulk-btn bulk-btn--danger" onClick={run(BulkApi.trash, 'trash')}>Trash</button> <Mail /> Unread
<button className="bulk-btn" onClick={onClear}>Cancel</button> </Button>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.star, 'star', 'Starred')}>
<Star /> Star
</Button>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.archive, 'archive', 'Archived')}>
<Archive /> Archive
</Button>
<Button variant="danger-outline" size="sm" disabled={busy} onClick={() => setConfirmTrash(true)}>
<Trash2 /> Trash
</Button>
<Button variant="ghost" size="sm" disabled={busy} onClick={onClear}>Cancel</Button>
<Dialog open={confirmTrash} onOpenChange={(o) => !busy && setConfirmTrash(o)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Move {count} email{count === 1 ? '' : 's'} to Trash?</DialogTitle>
<DialogDescription>
This moves the selected mail to your Gmail Trash, where it stays recoverable
for 30 days before Gmail permanently deletes it.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" size="sm" disabled={busy}>Cancel</Button>
</DialogClose>
<Button variant="danger" size="sm" disabled={busy} onClick={() => apply(BulkApi.trash, 'trash', 'Trashed')}>
{busy ? 'Moving…' : 'Move to Trash'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }