diff --git a/app/app/layout.tsx b/app/app/layout.tsx index a72e7524f..dd013437e 100644 --- a/app/app/layout.tsx +++ b/app/app/layout.tsx @@ -14,6 +14,7 @@ import { CommandPalette } from '@/components/command-palette/CommandPalette'; import { HelpAssistant } from '@/components/help/HelpAssistant'; import { AiAssistant } from '@/components/ai-assistant/AiAssistant'; import { NotificationToast } from '@/components/notifications/notification-toast'; +import { Toaster } from '@/components/ui/toaster'; import { OnboardingWizard } from '@/components/onboarding/OnboardingWizard'; import { recoverUserWorkspace } from '@/lib/provisioning/workspace-recovery'; import { SecurityTrackingBootstrap } from '@/components/security/SecurityTrackingBootstrap'; @@ -278,6 +279,11 @@ export default async function AppLayout({ userId={systemState.user.id} orgId={systemState.organization.id} /> + {/* Audit 2026-05-23: Sprint 4c Phase 1 — shared sonner toast + surface mounted once at the root. New code should reach for + `toast()` from `@/components/ui/toaster` instead of rolling + another in-house implementation. */} + diff --git a/components/comments/comments-section.tsx b/components/comments/comments-section.tsx index 8b3699acd..0eb435ccd 100644 --- a/components/comments/comments-section.tsx +++ b/components/comments/comments-section.tsx @@ -16,6 +16,16 @@ import { Reply, } from 'lucide-react'; import { z } from 'zod'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; const commentSchema = z.object({ content: z.string().min(1, 'Comment cannot be empty').max(5000, 'Comment must be under 5000 characters'), @@ -62,6 +72,10 @@ export default function Comments({ const [editingId, setEditingId] = useState(null); const [editContent, setEditContent] = useState(''); const [loading, setLoading] = useState(true); + // Audit 2026-05-23: replaced window.confirm() with AlertDialog. Holding + // the pending-delete id in state lets the dialog open + close cleanly + // and keeps the focus trap / aria semantics the browser confirm lacked. + const [pendingDeleteId, setPendingDeleteId] = useState(null); useEffect(() => { fetchComments(); @@ -127,8 +141,6 @@ export default function Comments({ }; const handleDelete = async (commentId: string) => { - if (!confirm('Delete this comment?')) return; - try { await fetch(`/api/comments/${commentId}`, { method: 'DELETE', @@ -137,6 +149,8 @@ export default function Comments({ await fetchComments(); } catch (error) { console.error('Failed to delete comment:', error); + } finally { + setPendingDeleteId(null); } }; @@ -224,8 +238,9 @@ export default function Comments({ @@ -400,6 +415,35 @@ export default function Comments({ comments.map((comment) => renderComment(comment)) )} + + { + if (!open) setPendingDeleteId(null); + }} + > + + + Delete this comment? + + The comment will be removed for everyone. This action is + recorded in the audit log. + + + + Cancel + { + if (pendingDeleteId) { + void handleDelete(pendingDeleteId); + } + }} + > + Delete comment + + + + ); } diff --git a/components/delete-button.tsx b/components/delete-button.tsx index 063398168..a96b58c14 100644 --- a/components/delete-button.tsx +++ b/components/delete-button.tsx @@ -3,15 +3,31 @@ import { useState } from "react" import { useRouter } from "next/navigation" import { createSupabaseClient } from "@/lib/supabase/client" -import { Trash2, Loader2, AlertTriangle, X } from "lucide-react" +import { Trash2, Loader2 } from "lucide-react" import { logActivity } from "@/lib/actions/audit" import { useComplianceAction, type NodeType } from "@/components/compliance-system" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" /** * ========================================================= * DELETE BUTTON - * Action: Removes node from compliance graph - * Shows system impact warning before deletion + * Action: Removes node from compliance graph. + * + * Audit 2026-05-23 (Sprint 4c Phase 1): migrated from inline + * confirm card to AlertDialog. The previous implementation had + * no focus trap, no ESC handler, no aria-modal, and rendered + * confirmation as a sibling element. AlertDialog gives all of + * those for free. * ========================================================= */ @@ -22,7 +38,6 @@ type DeleteButtonProps = { onDelete?: () => void } -// Map table names to node types const tableToNodeType: Record = { 'org_policies': 'policy', 'org_registers': 'control', @@ -37,88 +52,89 @@ const tableToLabel: Record = { export function DeleteButton({ id, tableName, itemTitle, onDelete }: DeleteButtonProps) { const [loading, setLoading] = useState(false) - const [confirm, setConfirm] = useState(false) + const [open, setOpen] = useState(false) const router = useRouter() const { nodeDeleted, reportError } = useComplianceAction() const nodeType = tableToNodeType[tableName] || 'entity' const nodeLabel = tableToLabel[tableName] || 'Record' + const displayTitle = itemTitle || `${nodeLabel} ${id.slice(0, 8)}` - async function handleDelete() { + async function handleDelete(event: React.MouseEvent) { + // Stop the AlertDialog default close so the spinner has time to render. + event.preventDefault() setLoading(true) - - // 1. Execute the deletion + const supabase = createSupabaseClient() const { error } = await supabase.from(tableName).delete().eq('id', id) if (error) { - reportError({ title: "Delete failed", message: `Failed to delete ${nodeLabel.toLowerCase()}: ${error.message}` }) + reportError({ + title: "Delete failed", + message: `Failed to delete ${nodeLabel.toLowerCase()}: ${error.message}`, + }) setLoading(false) - setConfirm(false) return } - - // 2. Trigger the Automated Audit Log - const recordType = tableName.split('_')[1].replace(/s$/, '').toUpperCase(); - + + const recordType = tableName.split('_')[1].replace(/s$/, '').toUpperCase() + try { await logActivity({ type: `${recordType}_DELETE`, description: `User permanently deleted ${recordType.toLowerCase()} record: ${id.slice(0, 8)}`, - metadata: { record_id: id, source_table: tableName } - }); + metadata: { record_id: id, source_table: tableName }, + }) } catch (auditError) { - console.error("Audit log failed, but record was deleted:", auditError); + console.error("Audit log failed, but record was deleted:", auditError) } - // 3. Report to compliance system - nodeDeleted(nodeType, itemTitle || `${nodeLabel} ${id.slice(0, 8)}`) + nodeDeleted(nodeType, displayTitle) - // 4. Update UI - setConfirm(false) setLoading(false) + setOpen(false) if (onDelete) onDelete() router.refresh() } - if (confirm) { - return ( -
- - - This will disconnect all linked nodes - - -
- -
- ) - } - - return ( - + + + + Delete {nodeLabel.toLowerCase()}? + + {displayTitle} + + This will disconnect all linked nodes. The deletion is permanent + and will be recorded in the audit log. + + + + + Cancel + + {loading ? ( + <> + + Deleting… + + ) : ( + <> + + Delete {nodeLabel.toLowerCase()} + + )} + + + + ) } diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 000000000..d36639644 --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,173 @@ +"use client"; + +// Audit 2026-05-23 (Sprint 4c Phase 1): destructive-confirmation +// primitive. Replaces 9+ `window.confirm()` call sites and ad-hoc +// "Are you sure?" cards across the app. AlertDialog (unlike Dialog) +// is modal and does not dismiss on outside click — the right semantics +// for "did you really mean to delete this?" prompts. + +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; +import { cn } from "@/lib/utils"; + +function AlertDialog( + props: React.ComponentProps, +) { + return ; +} + +function AlertDialogTrigger( + props: React.ComponentProps, +) { + return ( + + ); +} + +function AlertDialogPortal( + props: React.ComponentProps, +) { + return ( + + ); +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ); +} + +function AlertDialogHeader({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +function AlertDialogFooter({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +}; diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 000000000..c915a6700 --- /dev/null +++ b/components/ui/dialog.tsx @@ -0,0 +1,160 @@ +"use client"; + +// Audit 2026-05-23 (Sprint 4c Phase 1): shared dialog primitive built on +// the already-installed @radix-ui/react-dialog. The codebase had 30+ +// ad-hoc modals (role="dialog" / fixed inset-0) with no focus trap, no +// scroll lock, and 24% lacked dialog semantics entirely. This wrapper +// gives every team-member a single primitive to reach for. +// +// Style defaults are deliberately neutral (bg-background, border, no +// glow/gradient) — per the stored "enterprise aesthetic over AI feel" +// preference. Override per call with className. + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +function Dialog({ ...props }: React.ComponentProps) { + return ; +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton ? ( + + + Close + + ) : null} + + + ); +} + +function DialogHeader({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +function DialogFooter({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/components/ui/toaster.tsx b/components/ui/toaster.tsx new file mode 100644 index 000000000..2a232bd10 --- /dev/null +++ b/components/ui/toaster.tsx @@ -0,0 +1,30 @@ +"use client"; + +// Audit 2026-05-23 (Sprint 4c Phase 1): single toast surface for the +// whole app, replacing the 4 in-house implementations that hand-rolled +// portals, dismiss timers, and queue logic (compliance-toast, +// notification-toast, ComplianceToastAlerts, InteractionFeedback). +// +// Mount once at the root layout. Call `toast(...)` / `toast.success(...)` +// / `toast.error(...)` from anywhere. +// +// Default style is neutral (dark surface, no gradient) — matches the +// stored enterprise-aesthetic preference. + +import { Toaster as SonnerToaster } from "sonner"; + +export function Toaster() { + return ( + + ); +} + +export { toast } from "sonner"; diff --git a/package-lock.json b/package-lock.json index e5b060df3..65731112d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@opentelemetry/sdk-trace-base": "^2.6.0", "@opentelemetry/sdk-trace-node": "^2.6.0", "@opentelemetry/winston-transport": "^0.26.0", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", @@ -68,6 +69,7 @@ "resend": "^6.6.0", "sanitize-html": "^2.17.2", "server-only": "^0.0.1", + "sonner": "^2.0.7", "speakeasy": "^2.0.0", "stripe": "^15.11.0", "tailwind-merge": "^3.4.0", @@ -7734,6 +7736,52 @@ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -23785,6 +23833,16 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index f0aea4ace..27dc9d42e 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "@opentelemetry/sdk-trace-base": "^2.6.0", "@opentelemetry/sdk-trace-node": "^2.6.0", "@opentelemetry/winston-transport": "^0.26.0", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", @@ -145,6 +146,7 @@ "resend": "^6.6.0", "sanitize-html": "^2.17.2", "server-only": "^0.0.1", + "sonner": "^2.0.7", "speakeasy": "^2.0.0", "stripe": "^15.11.0", "tailwind-merge": "^3.4.0",