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 (
- setConfirm(true)}
- className="group p-2 text-muted-foreground hover:text-rose-400 hover:bg-rose-500/10 rounded-lg border border-transparent hover:border-rose-500/20 transition-all motion-safe:active:scale-95"
- title={`Delete ${nodeLabel.toLowerCase()}`}
- >
-
-
+
+
+
+ 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",