diff --git a/components/automation/ComplianceToastAlerts.tsx b/components/automation/ComplianceToastAlerts.tsx index 34a09beea..e7b587670 100644 --- a/components/automation/ComplianceToastAlerts.tsx +++ b/components/automation/ComplianceToastAlerts.tsx @@ -1,237 +1,101 @@ /** * Compliance Toast Alerts - * In-app toast notifications for critical compliance risks + * In-app toast notifications for critical compliance risks. + * + * Audit Sprint 7b (2026-05-24): the previous implementation hand-rolled + * its own portal + ToastItem render + dismiss buttons. The 30-second + * polling against getAutomationHistory() stays (that's the real job); + * the rendering side moved to sonner via the shared Toaster mounted at + * the app root. Component now returns null. */ 'use client'; -import { useEffect, useState } from 'react'; -import { AlertCircle, CheckCircle2, X, Shield } from 'lucide-react'; +import { useEffect, useRef } from 'react'; import { getAutomationHistory } from '@/app/app/actions/automation'; - -interface Toast { - id: string; - type: 'critical' | 'warning' | 'success'; - title: string; - message: string; - timestamp: string; -} - -export function ComplianceToastAlerts() { - const [toasts, setToasts] = useState([]); - const [lastCheckedId, setLastCheckedId] = useState(null); - - useEffect(() => { - checkForNewAlerts(); - // Check for new alerts every 30 seconds - const interval = setInterval(checkForNewAlerts, 30000); - return () => clearInterval(interval); - }, []); - - async function checkForNewAlerts() { - try { - const history = await getAutomationHistory(5); - - if (!Array.isArray(history) || history.length === 0) return; - - // Get most recent event - const latestEvent = history[0]; - - // Skip if we've already shown this event - if (latestEvent.id === lastCheckedId) return; - - // Only show toasts for critical/high priority events - const criticalTriggers = [ - 'control_failed', - 'risk_score_change', - 'control_incomplete', - 'task_overdue', - ]; - - if (criticalTriggers.includes(latestEvent.trigger)) { - const toast: Toast = { - id: latestEvent.id, - type: getCriticalityType(latestEvent.trigger), - title: getToastTitle(latestEvent.trigger), - message: getToastMessage( - latestEvent.trigger, - latestEvent.actionsExecuted, - ), - timestamp: latestEvent.executedAt, - }; - - setToasts((prev) => [toast, ...prev].slice(0, 3)); // Keep max 3 toasts - setLastCheckedId(latestEvent.id); - - // Auto-dismiss after 10 seconds - setTimeout(() => { - dismissToast(toast.id); - }, 10000); - } else { - setLastCheckedId(latestEvent.id); - } - } catch (error) { - console.error('Failed to check for alerts:', error); - } - } - - function dismissToast(id: string) { - setToasts((prev) => prev.filter((t) => t.id !== id)); +import { toast } from '@/components/ui/toaster'; + +const POLL_INTERVAL_MS = 30_000; +const TOAST_DURATION_MS = 10_000; + +const CRITICAL_TRIGGERS = new Set([ + 'control_failed', + 'risk_score_change', + 'control_incomplete', + 'task_overdue', +]); + +const TITLE_BY_TRIGGER: Record = { + control_failed: 'Critical: Control Failure', + risk_score_change: 'Compliance Risk Increased', + control_incomplete: 'Control Requires Attention', + task_overdue: 'Tasks Overdue', +}; + +function messageFor(trigger: string, actionsExecuted: number): string { + switch (trigger) { + case 'control_failed': + return `Control compliance failed. ${actionsExecuted} remediation task${actionsExecuted === 1 ? '' : 's'} created and admins notified.`; + case 'risk_score_change': + return 'Your compliance risk level has increased. Leadership has been notified.'; + case 'control_incomplete': + return `${actionsExecuted} control${actionsExecuted === 1 ? '' : 's'} require${actionsExecuted === 1 ? 's' : ''} completion. Tasks created.`; + case 'task_overdue': + return `${actionsExecuted} overdue task${actionsExecuted === 1 ? '' : 's'}. Escalation notifications sent.`; + default: + return `Automation workflow executed ${actionsExecuted} actions.`; } - - if (toasts.length === 0) return null; - - return ( -
- {toasts.map((toast) => ( - - ))} -
- ); -} - -interface ToastItemProps { - toast: Toast; - onDismiss: (id: string) => void; } -function ToastItem({ toast, onDismiss }: ToastItemProps) { - const Icon = getToastIcon(toast.type); - - return ( -
- {/* Icon */} -
- -
- - {/* Content */} -
-

- {toast.title} -

-

- {toast.message} -

-

- {formatRelativeTime(toast.timestamp)} -

-
- - {/* Dismiss Button */} - -
- ); -} - -function getCriticalityType( +function variantFor( trigger: string, -): 'critical' | 'warning' | 'success' { - if (['control_failed', 'risk_score_change'].includes(trigger)) { - return 'critical'; - } - if (['control_incomplete', 'task_overdue'].includes(trigger)) { - return 'warning'; +): (msg: string, opts?: { description?: string; duration?: number }) => void { + if (trigger === 'control_failed' || trigger === 'risk_score_change') { + return toast.error; } - return 'success'; + return toast.warning; } -function getToastIcon(type: string) { - switch (type) { - case 'critical': - return AlertCircle; - case 'warning': - return Shield; - default: - return CheckCircle2; - } -} - -function getToastTitle(trigger: string): string { - const titles: Record = { - control_failed: 'Critical: Control Failure', - risk_score_change: 'Compliance Risk Increased', - control_incomplete: 'Control Requires Attention', - task_overdue: 'Tasks Overdue', - }; - return titles[trigger] || 'Compliance Alert'; -} - -function getToastMessage(trigger: string, actionsExecuted: number): string { - const messages: Record = { - control_failed: `Control compliance failed. ${actionsExecuted} remediation task${actionsExecuted > 1 ? 's' : ''} created and admins notified.`, - risk_score_change: - 'Your compliance risk level has increased. Leadership has been notified.', - control_incomplete: `${actionsExecuted} control${actionsExecuted > 1 ? 's' : ''} require${actionsExecuted === 1 ? 's' : ''} completion. Tasks created.`, - task_overdue: `${actionsExecuted} overdue task${actionsExecuted > 1 ? 's' : ''}. Escalation notifications sent.`, - }; - return ( - messages[trigger] || - `Automation workflow executed ${actionsExecuted} actions.` - ); -} +export function ComplianceToastAlerts() { + // Ref instead of state — we don't re-render, only need the value + // across poll ticks so we don't toast the same event twice. + const lastSeenIdRef = useRef(null); -function formatRelativeTime(dateString: string): string { - const date = new Date(dateString); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); + useEffect(() => { + let cancelled = false; + + async function checkForNewAlerts() { + if (cancelled) return; + try { + const history = await getAutomationHistory(5); + if (!Array.isArray(history) || history.length === 0) return; + + const latest = history[0]; + if (latest.id === lastSeenIdRef.current) return; + lastSeenIdRef.current = latest.id; + + if (!CRITICAL_TRIGGERS.has(latest.trigger)) return; + + variantFor(latest.trigger)( + TITLE_BY_TRIGGER[latest.trigger] ?? 'Compliance Alert', + { + description: messageFor(latest.trigger, latest.actionsExecuted), + duration: TOAST_DURATION_MS, + }, + ); + } catch (error) { + // Polling failure is non-fatal — the next tick retries. + console.error('Failed to check for alerts:', error); + } + } - if (diffMins < 1) return 'Just now'; - if (diffMins < 60) return `${diffMins}m ago`; + void checkForNewAlerts(); + const interval = window.setInterval(checkForNewAlerts, POLL_INTERVAL_MS); + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, []); - return 'Recently'; + return null; } diff --git a/components/compliance-system/compliance-toast.tsx b/components/compliance-system/compliance-toast.tsx index af3c408de..65cb40c56 100644 --- a/components/compliance-system/compliance-toast.tsx +++ b/components/compliance-system/compliance-toast.tsx @@ -1,30 +1,37 @@ "use client"; -import React, { createContext, useContext, useState, useCallback, useRef } from "react"; -import { cn } from "@/lib/utils"; -import { - CheckCircle2, - AlertCircle, - Info, - X, - ArrowRight, - Zap -} from "lucide-react"; - -/** - * ========================================================= - * COMPLIANCE TOAST SYSTEM - * ========================================================= - * Toast notifications that show compliance graph changes. - * Displays: what node changed, what wires updated, compliance impact. - */ +// Audit Sprint 7b (2026-05-24): hand-rolled portal + Context + custom +// renderer replaced with a thin sonner shim. The public API +// (`useComplianceToast()`, `ComplianceToastProvider`, `ComplianceToastData`) +// is unchanged so callers don't change. Internally the Provider is now +// a passthrough — sonner's is mounted at the app root +// (Sprint 4c, components/ui/toaster.tsx). +// +// What we lose: rich node-color badges (cyan/teal/violet per node type) +// + impact-delta indicator + slide-in animation. These were off-brand +// per the stored enterprise-aesthetic preference. The collapsed +// title + description format is more consistent with the rest of the app. +// +// What we keep: the typed surface for callers (useComplianceAction +// builds these on every node-graph event), the priority levels, and +// the dismissToast escape hatch. + +import React, { createContext, useCallback, useContext } from "react"; +import { toast as sonnerToast } from "@/components/ui/toaster"; export interface ComplianceToastData { id?: string; type: "success" | "error" | "info" | "warning"; title: string; message?: string; - nodeType?: "policy" | "control" | "evidence" | "audit" | "risk" | "task" | "entity"; + nodeType?: + | "policy" + | "control" + | "evidence" + | "audit" + | "risk" + | "task" + | "entity"; nodeAction?: "created" | "updated" | "linked" | "verified" | "deleted"; wireFrom?: string; wireTo?: string; @@ -43,195 +50,55 @@ const ToastContext = createContext(null); export function useComplianceToast() { const context = useContext(ToastContext); if (!context) { - throw new Error("useComplianceToast must be used within ComplianceToastProvider"); + throw new Error( + "useComplianceToast must be used within ComplianceToastProvider", + ); } return context; } -const NODE_COLORS: Record = { - policy: "text-cyan-400", - control: "text-teal-400", - evidence: "text-violet-400", - audit: "text-amber-400", - risk: "text-rose-400", - task: "text-emerald-400", -}; - -const TYPE_CONFIG = { - success: { - icon: CheckCircle2, - bgClass: "bg-emerald-500/10 border-emerald-400/40", - iconClass: "text-emerald-400", - }, - error: { - icon: AlertCircle, - bgClass: "bg-rose-500/10 border-rose-400/40", - iconClass: "text-rose-400", - }, - info: { - icon: Info, - bgClass: "bg-sky-500/10 border-sky-400/40", - iconClass: "text-sky-400", - }, - warning: { - icon: AlertCircle, - bgClass: "bg-amber-500/10 border-amber-400/40", - iconClass: "text-amber-400", - }, -}; - -function ComplianceToast({ - data, - onDismiss -}: { - data: ComplianceToastData; - onDismiss: () => void; -}) { - const config = TYPE_CONFIG[data.type]; - const Icon = config.icon; - - return ( -
-
-
- -
- -
-

- {data.title} -

- - {data.message && ( -

{data.message}

- )} - - {/* Node change indicator */} - {data.nodeType && data.nodeAction && ( -
- - {data.nodeType} - - - {data.nodeAction} -
- )} - - {/* Wire update indicator */} - {data.wireFrom && data.wireTo && ( -
- {data.wireFrom} - - {data.wireTo} -
- )} - - {/* Impact indicator */} - {data.impactArea && ( -
- 0 - ? "bg-emerald-500/20 text-emerald-300" - : "bg-amber-500/20 text-amber-300" - )}> - - {data.impactArea} - {data.impactDelta && ` ${data.impactDelta > 0 ? '+' : ''}${data.impactDelta}%`} - -
- )} -
- - -
- - {/* Progress bar for auto-dismiss; suppressed when duration === 0 - (sticky toast — the manual X is the only dismissal). */} - {data.duration !== 0 && ( -
-
-
- )} -
- ); +function buildDescription(data: ComplianceToastData): string | undefined { + const parts: string[] = []; + if (data.message) parts.push(data.message); + if (data.nodeType && data.nodeAction) { + parts.push(`${data.nodeType} ${data.nodeAction}`); + } + if (data.impactArea && typeof data.impactDelta === "number") { + const sign = data.impactDelta > 0 ? "+" : ""; + parts.push(`${data.impactArea}: ${sign}${data.impactDelta}`); + } + return parts.length > 0 ? parts.join(" · ") : undefined; } -export function ComplianceToastProvider({ children }: { children: React.ReactNode }) { - const [toasts, setToasts] = useState([]); - const idCounter = useRef(0); - +export function ComplianceToastProvider({ + children, +}: { + children: React.ReactNode; +}) { const showToast = useCallback((data: ComplianceToastData) => { - const id = data.id || `toast-${++idCounter.current}`; - // duration === 0 is the explicit "no auto-dismiss" opt-out for - // error states the user must read and dismiss themselves. Anything - // else (including undefined) keeps the prior 5s default. The - // manual-close button remains the dismissal path either way. - const duration = data.duration ?? 5000; - - setToasts(prev => [...prev, { ...data, id }]); - - if (duration > 0) { - setTimeout(() => { - setToasts(prev => prev.filter(t => t.id !== id)); - }, duration); - } + const description = buildDescription(data); + const variant = + data.type === "error" + ? sonnerToast.error + : data.type === "warning" + ? sonnerToast.warning + : data.type === "success" + ? sonnerToast.success + : sonnerToast.info; + variant(data.title, { + id: data.id, + description, + duration: data.duration, + }); }, []); const dismissToast = useCallback((id: string) => { - setToasts(prev => prev.filter(t => t.id !== id)); + sonnerToast.dismiss(id); }, []); return ( {children} - - {/* Toast container */} -
- {toasts.map(toast => ( - dismissToast(toast.id!)} - /> - ))} -
); } - -// Add keyframe for shrink animation -if (typeof document !== "undefined") { - const style = document.createElement("style"); - style.textContent = ` - @keyframes shrink { - from { width: 100%; } - to { width: 0%; } - } - `; - document.head.appendChild(style); -} - -export { ComplianceToast }; diff --git a/components/notifications/notification-toast.tsx b/components/notifications/notification-toast.tsx index 6ffdc2b51..5ffca494f 100644 --- a/components/notifications/notification-toast.tsx +++ b/components/notifications/notification-toast.tsx @@ -1,13 +1,17 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +// Audit Sprint 7b (2026-05-24): replaces the previous hand-rolled +// portal + auto-dismiss timer + 3-toast queue with sonner. The Realtime +// subscription (the *real* job of this component) stays — only the +// rendering side moves to the shared Toaster mounted at the root. +// Component now returns null; mount in app/app/layout.tsx is unchanged. + +import { useEffect, useMemo } from 'react'; import { useRouter } from 'next/navigation'; -import { X } from 'lucide-react'; import { createSupabaseClient } from '@/lib/supabase/client'; +import { toast } from '@/components/ui/toaster'; import type { NotificationRecord } from '@/lib/notifications/types'; -type ToastItem = NotificationRecord & { timeoutId?: number }; - export function NotificationToast({ userId, orgId, @@ -19,7 +23,6 @@ export function NotificationToast({ }) { const router = useRouter(); const supabase = useMemo(() => createSupabaseClient(), []); - const [toasts, setToasts] = useState([]); useEffect(() => { const channel = supabase @@ -37,92 +40,33 @@ export function NotificationToast({ if (notification.org_id !== orgId) return; if (!['critical', 'high'].includes(notification.priority)) return; - const timeoutId = window.setTimeout(() => { - setToasts((current) => - current.filter((item) => item.id !== notification.id), - ); - }, autoDismissMs); + const href = + typeof notification.data?.href === 'string' + ? notification.data.href + : '/app'; + + const variant = + notification.priority === 'critical' ? toast.error : toast.warning; - setToasts((current) => - [{ ...notification, timeoutId }, ...current].slice(0, 3), - ); + variant(notification.title, { + description: notification.body, + duration: autoDismissMs, + action: { + label: 'View', + onClick: () => router.push(href), + }, + }); }, ) .subscribe(); return () => { void supabase.removeChannel(channel); - setToasts((current) => { - current.forEach((toast) => { - if (toast.timeoutId) { - window.clearTimeout(toast.timeoutId); - } - }); - return []; - }); }; - }, [autoDismissMs, orgId, supabase, userId]); - - if (!toasts.length) return null; - - return ( -
- {toasts.map((toast) => ( -
{ - const href = - typeof toast.data?.href === 'string' ? toast.data.href : '/app'; - setToasts((current) => - current.filter((item) => item.id !== toast.id), - ); - router.push(href); - }} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - const href = - typeof toast.data?.href === 'string' ? toast.data.href : '/app'; - setToasts((current) => - current.filter((item) => item.id !== toast.id), - ); - router.push(href); - } - }} - className="pointer-events-auto rounded-2xl border border-rose-400/30 bg-slate-950/95 p-4 text-left shadow-2xl shadow-black/30 transition hover:border-rose-300/50" - > -
-
-
-

- {toast.priority} priority -

-

- {toast.title} -

-

- {toast.body} -

-
+ }, [autoDismissMs, orgId, supabase, userId, router]); - -
-
- ))} -
- ); + // Rendering moved to the shared Toaster (components/ui/toaster.tsx, + // mounted in app/app/layout.tsx). This component is now side-effect- + // only; existing call sites don't change. + return null; }