Implemented session and account lockout management

Enabled account sync control and session expiration management
Applied some fixes to the govername page and project page
This commit is contained in:
2026-09-18 10:19:24 +02:00
parent 41bf9b63bf
commit ec777fabf8
24 changed files with 3888 additions and 500 deletions
@@ -0,0 +1,269 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ShieldAlert,
RefreshCw,
LogOut,
UserCheck,
CheckCircle2,
AlertTriangle,
ArrowLeft,
Mail,
Building2,
Fingerprint,
} from 'lucide-react';
import { BrandLogo } from '../ui/BrandLogo';
import { AuthUserProfile, UserClaims } from '../../auth/AuthProvider';
import { cancelSessionExpiration, dismissSubordinateModals } from '../../services/sessionExpiration';
interface AccountActivationModalProps {
user: AuthUserProfile | null;
claims: UserClaims;
sub: string;
onSync: (force?: boolean) => Promise<{ success: boolean; isUnauthorized?: boolean; errorMessage?: string }>;
onLogout: () => Promise<void>;
initialError?: string | null;
}
export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
user,
claims,
sub,
onSync,
onLogout,
initialError,
}) => {
const navigate = useNavigate();
const [isSyncing, setIsSyncing] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(initialError || null);
const [syncAttemptCount, setSyncAttemptCount] = useState(0);
// Activation process dismisses all other modals so that it is displayed without overlapping.
// By extension, any active session expiration modal is dismissed and its processes cancelled.
useEffect(() => {
cancelSessionExpiration();
dismissSubordinateModals();
}, []);
const displayName =
user?.name ||
(claims.name as string) ||
(claims.preferred_username as string) ||
'Registered User';
const displayEmail =
user?.email ||
(claims.email as string) ||
(claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) ||
'';
const tenant = user?.tenant || (claims.tenant as string) || (claims.org as string) || 'Standard Enterprise';
const role = user?.role || (claims.role as string) || 'User';
const handleSyncClick = async () => {
if (isSyncing) return;
setIsSyncing(true);
setErrorMessage(null);
try {
const result = await onSync(true);
if (result.isUnauthorized) {
// HTTP 401: Routine process of routing user to landing page
await onLogout();
navigate('/', { replace: true });
return;
}
if (!result.success) {
setSyncAttemptCount((prev) => prev + 1);
setErrorMessage(
result.errorMessage ||
'Account is not yet enabled. The administrator has not activated your account in the system directory.'
);
}
// If result.success is true, AuthProvider updates state, and this modal unmounts automatically!
} catch (err: any) {
if (err?.response?.status === 401) {
await onLogout();
navigate('/', { replace: true });
return;
}
setErrorMessage(
err?.message ||
'Account activation check encountered an error. Please confirm with your administrator and try again.'
);
} finally {
setIsSyncing(false);
}
};
const handleSignOut = async () => {
await onLogout();
navigate('/', { replace: true });
};
const handleNavigateLanding = () => {
navigate('/');
};
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="activation-modal-title"
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-[#060a12]/90 backdrop-blur-md animate-in fade-in duration-200 select-none"
>
{/* Non-dismissable Card Container: No X button, no escape dismiss, no backdrop click */}
<div
className="relative w-full max-w-xl bg-[#0d1322] border border-amber-500/30 rounded-2xl shadow-2xl shadow-black/80 overflow-hidden flex flex-col text-slate-100"
onClick={(e) => e.stopPropagation()}
>
{/* Amber accent top bar */}
<div className="h-1.5 w-full bg-gradient-to-r from-amber-500 via-amber-400 to-yellow-500" />
{/* Modal Header */}
<div className="p-6 pb-4 border-b border-slate-800/80 bg-[#0f172a]/60">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3.5">
<div className="relative p-2.5 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-400">
<ShieldAlert className="w-6 h-6" />
<span className="absolute -top-1 -right-1 flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-amber-500"></span>
</span>
</div>
<div>
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold tracking-wider uppercase text-amber-400 bg-amber-500/10 border border-amber-500/20 px-2 py-0.5 rounded-full mb-1">
Access Restricted • Awaiting Enablement
</span>
<h2 id="activation-modal-title" className="text-lg sm:text-xl font-bold text-white tracking-tight">
Account Pending Administrator Activation
</h2>
</div>
</div>
<BrandLogo size={36} showGlow={false} animateOnHover={false} />
</div>
</div>
{/* Modal Body */}
<div className="p-6 space-y-5 overflow-y-auto max-h-[70vh]">
{/* Status notification block */}
<div className="bg-slate-900/80 border border-slate-800 rounded-xl p-4 space-y-2.5">
<div className="flex items-center gap-2 text-emerald-400 text-xs font-semibold">
<CheckCircle2 className="w-4 h-4 shrink-0" />
<span>Identity Verified & Signed In Successfully</span>
</div>
<p className="text-xs text-slate-300 leading-relaxed">
You are a registered user and have successfully authenticated via Enterprise Single Sign-On.
However, your account has not yet been enabled in the system directory by a system administrator.
</p>
<p className="text-xs text-slate-400 leading-relaxed border-t border-slate-800/80 pt-2 font-medium">
If your administrator informs you that your account has been activated, please click the{' '}
<strong className="text-white">Sync Account</strong> button below to refresh your status and unlock the system.
</p>
</div>
{/* User Profile Card */}
<div className="rounded-xl border border-slate-800 bg-[#090e1a]/60 p-4 space-y-3">
<div className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider flex items-center justify-between">
<span>Authenticated User Profile</span>
<span className="text-amber-400/90 text-[10px] font-mono bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/20">
Lockout Active
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
<UserCheck className="w-4 h-4 text-blue-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-slate-500 block">Name</span>
<span className="font-semibold text-white truncate block">{displayName}</span>
</div>
</div>
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
<Mail className="w-4 h-4 text-blue-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-slate-500 block">Email</span>
<span className="font-semibold text-slate-200 truncate block">{displayEmail || 'Not specified'}</span>
</div>
</div>
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
<Building2 className="w-4 h-4 text-indigo-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-slate-500 block">Tenant / Role</span>
<span className="font-semibold text-slate-200 truncate block">
{tenant} ({role})
</span>
</div>
</div>
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
<Fingerprint className="w-4 h-4 text-slate-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-slate-500 block">Subject ID</span>
<span className="font-mono text-[11px] text-slate-400 truncate block">{sub || 'SSO-Principal'}</span>
</div>
</div>
</div>
</div>
{/* Sync Attempt Feedback / Error Notice */}
{errorMessage && (
<div className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3.5 flex items-start gap-3 animate-in fade-in duration-150">
<AlertTriangle className="w-4 h-4 text-rose-400 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-xs font-semibold text-rose-300">Account Not Activated Yet</p>
<p className="text-xs text-rose-200/90 leading-relaxed">{errorMessage}</p>
{syncAttemptCount > 0 && (
<p className="text-[11px] text-rose-400/80 pt-1">
Attempt #{syncAttemptCount} checked at {new Date().toLocaleTimeString()}
</p>
)}
</div>
</div>
)}
</div>
{/* Modal Actions */}
<div className="p-5 border-t border-slate-800/80 bg-[#0a0f1d] flex flex-col sm:flex-row items-center justify-between gap-3">
<div className="flex items-center gap-2 w-full sm:w-auto">
<button
onClick={handleNavigateLanding}
type="button"
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-medium text-slate-400 hover:text-slate-200 bg-slate-900 hover:bg-slate-800 border border-slate-800 rounded-xl transition cursor-pointer"
title="View public landing page. Entering the console again will display this modal until enabled."
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Landing Page</span>
</button>
<button
onClick={handleSignOut}
type="button"
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-medium text-slate-400 hover:text-rose-400 bg-slate-900 hover:bg-rose-500/10 border border-slate-800 hover:border-rose-500/30 rounded-xl transition cursor-pointer"
title="Sign out of current SSO session"
>
<LogOut className="w-3.5 h-3.5" />
<span>Sign Out</span>
</button>
</div>
<button
onClick={handleSyncClick}
disabled={isSyncing}
type="button"
id="activation-sync-btn"
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-5 py-2.5 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 active:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none rounded-xl shadow-lg shadow-blue-600/30 transition cursor-pointer"
>
<RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} />
<span>{isSyncing ? 'Verifying Activation...' : 'Sync Account'}</span>
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,186 @@
import React, { useEffect, useState, useRef } from 'react';
import { ShieldAlert, Clock, ArrowRight, X, AlertTriangle, LogOut } from 'lucide-react';
import { clearSessionAndRedirectToLanding, dismissSubordinateModals } from '../../services/sessionExpiration';
interface SessionExpiredModalProps {
isOpen: boolean;
onDismiss?: () => void;
reason?: string | null;
/** Total countdown time in seconds (defaults to 8 seconds) */
countdownSeconds?: number;
}
export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
isOpen,
onDismiss,
reason,
countdownSeconds = 8,
}) => {
const [timeLeftMs, setTimeLeftMs] = useState(countdownSeconds * 1000);
const [isPaused, setIsPaused] = useState(false);
const totalDurationMs = countdownSeconds * 1000;
const isClearingRef = useRef(false);
// When session expiration modal is displayed, dismiss all subordinate modals
useEffect(() => {
if (isOpen) {
dismissSubordinateModals();
}
}, [isOpen]);
// Listen for cancellation event (e.g., when activation lockout takes priority)
useEffect(() => {
const handleCancelled = () => {
setTimeLeftMs(countdownSeconds * 1000);
setIsPaused(false);
isClearingRef.current = false;
};
window.addEventListener('pfm:session-expired-cancelled', handleCancelled);
return () => {
window.removeEventListener('pfm:session-expired-cancelled', handleCancelled);
};
}, [countdownSeconds]);
// Reset or start countdown when modal opens
useEffect(() => {
if (!isOpen) {
setTimeLeftMs(countdownSeconds * 1000);
setIsPaused(false);
isClearingRef.current = false;
return;
}
const intervalMs = 50;
const interval = setInterval(() => {
setTimeLeftMs((prev) => {
if (isPaused) return prev;
const next = prev - intervalMs;
if (next <= 0) {
clearInterval(interval);
handleAction();
return 0;
}
return next;
});
}, intervalMs);
return () => clearInterval(interval);
}, [isOpen, isPaused, countdownSeconds]);
const handleAction = () => {
if (isClearingRef.current) return;
isClearingRef.current = true;
if (onDismiss) {
onDismiss();
} else {
clearSessionAndRedirectToLanding();
}
};
if (!isOpen) return null;
const secondsRemaining = Math.max(0, Math.ceil(timeLeftMs / 1000));
const progressPercent = Math.max(0, Math.min(100, (timeLeftMs / totalDurationMs) * 100));
return (
<div
id="session-expired-modal-backdrop"
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/85 backdrop-blur-md p-4 animate-in fade-in duration-200"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
role="dialog"
aria-modal="true"
aria-labelledby="session-expired-title"
>
<div
id="session-expired-modal-content"
className="relative w-full max-w-md bg-[#0c121e] border border-amber-500/40 rounded-2xl shadow-2xl shadow-black/90 p-6 overflow-hidden text-slate-200 space-y-5"
>
{/* Subtle decorative top accent bar */}
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500" />
{/* Top bar with close button */}
<div className="flex items-start justify-between pt-1">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-amber-500/10 border border-amber-500/30 flex items-center justify-center text-amber-400 shrink-0 shadow-lg shadow-amber-500/10">
<ShieldAlert className="w-6 h-6" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 id="session-expired-title" className="text-base font-bold text-white tracking-tight">
Session Expired
</h3>
<span className="text-[10px] font-mono font-semibold px-2 py-0.5 rounded bg-rose-950/60 text-rose-300 border border-rose-800/60">
HTTP 401
</span>
</div>
<p className="text-xs text-slate-400 mt-0.5">Authorization token invalidated</p>
</div>
</div>
<button
id="session-expired-close-btn"
type="button"
onClick={handleAction}
aria-label="Dismiss and return to landing page"
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800/80 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Informative Explanation (non-technical and clear) */}
<div className="space-y-2.5 text-xs text-slate-300 leading-relaxed bg-slate-900/60 p-4 rounded-xl border border-slate-800/80">
<p>
Your active working session has ended or your security credentials have expired. To protect your data and prevent unauthorized changes, your session has been closed.
</p>
{reason && (
<div className="flex items-start gap-2 pt-1 text-[11px] text-amber-300/90 font-mono">
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-0.5 text-amber-400" />
<span className="break-words">{reason}</span>
</div>
)}
</div>
{/* Progress Bar Countdown */}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-slate-400 flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5 text-amber-400" />
Returning to landing page in{' '}
<strong className="text-white font-mono">{secondsRemaining}s</strong>
</span>
{isPaused && (
<span className="text-[10px] text-amber-400/80 font-medium px-1.5 py-0.2 rounded bg-amber-950/40 border border-amber-900/60">
Paused on hover
</span>
)}
</div>
{/* Progress bar line */}
<div className="w-full h-2 bg-slate-800/90 rounded-full overflow-hidden border border-slate-700/60 p-0.5">
<div
className="h-full rounded-full bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 transition-all duration-75 ease-linear"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
{/* Actions */}
<div className="pt-2 flex flex-col sm:flex-row items-center gap-2.5">
<button
id="session-expired-return-btn"
type="button"
onClick={handleAction}
className="w-full py-2.5 px-4 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition-all flex items-center justify-center gap-2"
>
<LogOut className="w-4 h-4" />
<span>Return to Landing Page Now</span>
<ArrowRight className="w-3.5 h-3.5 ml-0.5" />
</button>
</div>
</div>
</div>
);
};
@@ -18,6 +18,9 @@ import {
} from 'lucide-react';
import { useAuth } from '../../auth/AuthProvider';
import { BrandLogo } from '../ui/BrandLogo';
import { useQuery } from '@tanstack/react-query';
import { getUsers } from '../../services/api';
import { getUserEmail, registerUserEmail } from '../../lib/formatters';
const navItems = [
{
@@ -62,6 +65,18 @@ export const AppLayout: React.FC = () => {
await logout();
};
// Pre-populate user directory cache so all components and modals resolve user emails instantly
useQuery({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
});
// Ensure current logged-in user is registered in the email cache
if (sub && (user?.email || claims.email)) {
registerUserEmail(sub, ((user?.email || claims.email) as string));
}
// Extract name and email directly from validated token claims
const tokenUserName =
user?.name ||
@@ -75,7 +90,7 @@ export const AppLayout: React.FC = () => {
(claims.email as string) ||
(claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) ||
(claims.upn as string) ||
(sub ? `sub:${sub}` : '');
(sub ? getUserEmail(sub) : '');
// Extract tenant name directly from the user claim named 'tenant'
const tenantName =
@@ -252,8 +267,7 @@ export const AppLayout: React.FC = () => {
>
<div className="p-2.5 border-b border-slate-800">
<p className="text-xs font-semibold text-white">{tokenUserName}</p>
<p className="text-[11px] text-slate-400 font-mono truncate">{tokenUserEmail}</p>
{sub && <p className="text-[10px] text-slate-500 font-mono mt-1">sub: {sub}</p>}
<p className="text-[11px] text-slate-300 font-medium truncate">{tokenUserEmail || (sub ? getUserEmail(sub) : '')}</p>
</div>
<Link
@@ -22,13 +22,19 @@ export const Modal: React.FC<ModalProps> = ({
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const handleDismissSubordinates = () => {
onClose();
};
if (isOpen) {
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('pfm:dismiss-subordinates', handleDismissSubordinates);
}
return () => {
document.body.style.overflow = 'unset';
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('pfm:dismiss-subordinates', handleDismissSubordinates);
};
}, [isOpen, onClose]);
@@ -0,0 +1,588 @@
import React, { useState, useMemo } from 'react';
import { Risk, Project } from '../../types';
import { getUserEmail } from '../../lib/formatters';
import { AlertTriangle, ShieldAlert, CheckCircle2, ChevronRight, X, Info } from 'lucide-react';
interface RiskHeatmapMatrixProps {
risks: Risk[];
projects?: Project[];
compact?: boolean;
onSelectRisk?: (risk: Risk) => void;
title?: string;
subtitle?: string;
}
// Standard 5-point likelihood and impact levels
const LIKELIHOOD_LEVELS = [
{ val: 1, label: 'Rare', short: '1 - Rare' },
{ val: 2, label: 'Unlikely', short: '2 - Unlikely' },
{ val: 3, label: 'Possible', short: '3 - Possible' },
{ val: 4, label: 'Likely', short: '4 - Likely' },
{ val: 5, label: 'Almost Certain', short: '5 - Almost Certain' },
];
const IMPACT_LEVELS = [
{ val: 5, label: 'Severe', short: '5 - Severe' },
{ val: 4, label: 'Critical', short: '4 - Critical' },
{ val: 3, label: 'Major', short: '3 - Major' },
{ val: 2, label: 'Moderate', short: '2 - Moderate' },
{ val: 1, label: 'Minor', short: '1 - Minor' },
];
export const getCellSeverity = (score: number) => {
if (score >= 15) {
return {
name: 'Critical',
bgClass: 'bg-rose-950/70 border-rose-600/70 text-rose-100 hover:bg-rose-900/90',
badgeClass: 'bg-rose-500/20 text-rose-300 border-rose-500/40',
textColor: 'text-rose-400',
};
}
if (score >= 10) {
return {
name: 'High',
bgClass: 'bg-orange-950/60 border-orange-700/60 text-orange-200 hover:bg-orange-900/80',
badgeClass: 'bg-orange-500/20 text-orange-300 border-orange-500/40',
textColor: 'text-orange-400',
};
}
if (score >= 5) {
return {
name: 'Moderate',
bgClass: 'bg-amber-950/50 border-amber-800/50 text-amber-300 hover:bg-amber-900/70',
badgeClass: 'bg-amber-500/20 text-amber-300 border-amber-500/40',
textColor: 'text-amber-400',
};
}
return {
name: 'Low',
bgClass: 'bg-emerald-950/40 border-emerald-800/40 text-emerald-300 hover:bg-emerald-900/60',
badgeClass: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40',
textColor: 'text-emerald-400',
};
};
export const RiskHeatmapMatrix: React.FC<RiskHeatmapMatrixProps> = ({
risks,
projects = [],
compact = false,
onSelectRisk,
title,
subtitle,
}) => {
const [matrixMode, setMatrixMode] = useState<'5x5' | '3x3'>('5x5');
const [selectedCell, setSelectedCell] = useState<{
impactLabel: string;
likelihoodLabel: string;
score: number;
risks: Risk[];
} | null>(null);
// Map project names for easy lookup
const projectMap = useMemo(() => {
const map: Record<string, string> = {};
projects.forEach((p) => {
map[String(p.id)] = p.name;
});
return map;
}, [projects]);
// Pre-calculate risk counts by exact coordinates (1-5)
const matrix5x5 = useMemo(() => {
const grid: Record<string, Risk[]> = {};
for (let imp = 1; imp <= 5; imp++) {
for (let lik = 1; lik <= 5; lik++) {
grid[`${imp}-${lik}`] = [];
}
}
risks.forEach((r) => {
const imp = Math.min(5, Math.max(1, Number(r.impact) || 1));
const lik = Math.min(5, Math.max(1, Number(r.likelihood) || 1));
grid[`${imp}-${lik}`].push(r);
});
return grid;
}, [risks]);
// Pre-calculate 3x3 summary matrix
const matrix3x3 = useMemo(() => {
const buckets: Record<string, Risk[]> = {
'high-low': [],
'high-med': [],
'high-high': [],
'med-low': [],
'med-med': [],
'med-high': [],
'low-low': [],
'low-med': [],
'low-high': [],
};
risks.forEach((r) => {
const imp = Number(r.impact) || 1;
const lik = Number(r.likelihood) || 1;
const impKey = imp >= 4 ? 'high' : imp === 3 ? 'med' : 'low';
const likKey = lik >= 4 ? 'high' : lik === 3 ? 'med' : 'low';
buckets[`${impKey}-${likKey}`].push(r);
});
return buckets;
}, [risks]);
// Overall severity summary counters
const summaryCounters = useMemo(() => {
let low = 0;
let moderate = 0;
let high = 0;
let critical = 0;
risks.forEach((r) => {
const score = (Number(r.impact) || 1) * (Number(r.likelihood) || 1);
if (score >= 15) critical++;
else if (score >= 10) high++;
else if (score >= 5) moderate++;
else low++;
});
return { low, moderate, high, critical };
}, [risks]);
const handleCellClick = (impactLabel: string, likelihoodLabel: string, score: number, cellRisks: Risk[]) => {
if (cellRisks.length === 0) {
if (selectedCell) setSelectedCell(null);
return;
}
setSelectedCell({
impactLabel,
likelihoodLabel,
score,
risks: cellRisks,
});
};
return (
<div className="space-y-4">
{/* Header & Controls */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
{title && <h3 className="text-sm font-semibold text-white">{title}</h3>}
{subtitle && <p className="text-xs text-slate-400 mt-0.5">{subtitle}</p>}
</div>
<div className="flex items-center gap-2 self-end sm:self-auto">
{/* Mode Switcher */}
<div className="flex items-center bg-slate-900/90 p-0.5 rounded-lg border border-slate-700/80 text-[11px]">
<button
type="button"
onClick={() => {
setMatrixMode('5x5');
setSelectedCell(null);
}}
className={`px-2.5 py-1 rounded-md font-medium transition cursor-pointer ${
matrixMode === '5x5' ? 'bg-blue-600 text-white shadow-xs' : 'text-slate-400 hover:text-white'
}`}
>
5×5 Exact Matrix
</button>
<button
type="button"
onClick={() => {
setMatrixMode('3x3');
setSelectedCell(null);
}}
className={`px-2.5 py-1 rounded-md font-medium transition cursor-pointer ${
matrixMode === '3x3' ? 'bg-blue-600 text-white shadow-xs' : 'text-slate-400 hover:text-white'
}`}
>
3×3 Summary
</button>
</div>
</div>
</div>
{/* Summary Score Badges */}
<div className="grid grid-cols-4 gap-2">
<div className="bg-rose-500/10 border border-rose-500/30 p-2 rounded-xl text-center">
<span className="text-[10px] text-rose-400 font-semibold block uppercase">Critical (15–25)</span>
<span className="text-base font-bold text-rose-200">{summaryCounters.critical}</span>
</div>
<div className="bg-orange-500/10 border border-orange-500/30 p-2 rounded-xl text-center">
<span className="text-[10px] text-orange-400 font-semibold block uppercase">High (10–14)</span>
<span className="text-base font-bold text-orange-200">{summaryCounters.high}</span>
</div>
<div className="bg-amber-500/10 border border-amber-500/30 p-2 rounded-xl text-center">
<span className="text-[10px] text-amber-400 font-semibold block uppercase">Moderate (5–9)</span>
<span className="text-base font-bold text-amber-200">{summaryCounters.moderate}</span>
</div>
<div className="bg-emerald-500/10 border border-emerald-500/30 p-2 rounded-xl text-center">
<span className="text-[10px] text-emerald-400 font-semibold block uppercase">Low (1–4)</span>
<span className="text-base font-bold text-emerald-200">{summaryCounters.low}</span>
</div>
</div>
{/* 5x5 Matrix Mode */}
{matrixMode === '5x5' && (
<div className="bg-[#090d16]/80 p-3 rounded-2xl border border-slate-800/90 overflow-x-auto">
<div className="min-w-[480px]">
{/* Top axis label: Likelihood */}
<div className="text-center text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2 pl-24">
Likelihood / Probability &rarr;
</div>
<div className="flex">
{/* Left Y-Axis Label: Impact */}
<div className="w-6 flex items-center justify-center -mr-2">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest -rotate-90 whitespace-nowrap">
&uarr; Impact / Severity
</span>
</div>
{/* Grid with Row Headers */}
<div className="flex-1">
{/* Column Headers */}
<div className="grid grid-cols-6 gap-1.5 mb-1.5 text-center">
<div className="text-[10px] font-semibold text-slate-500 flex items-center justify-end pr-2">
Impact \ Lik.
</div>
{LIKELIHOOD_LEVELS.map((lik) => (
<div key={lik.val} className="text-[10px] font-semibold text-slate-400 bg-slate-900/60 py-1 rounded-md">
{lik.short}
</div>
))}
</div>
{/* Rows (Impact 5 down to 1) */}
<div className="space-y-1.5">
{IMPACT_LEVELS.map((imp) => (
<div key={imp.val} className="grid grid-cols-6 gap-1.5">
{/* Row Header */}
<div className="text-[10px] font-semibold text-slate-400 bg-slate-900/60 rounded-md flex items-center justify-end pr-2">
{imp.short}
</div>
{/* 5 Cells */}
{LIKELIHOOD_LEVELS.map((lik) => {
const cellRisks = matrix5x5[`${imp.val}-${lik.val}`] || [];
const score = imp.val * lik.val;
const sev = getCellSeverity(score);
const hasRisks = cellRisks.length > 0;
const isSelected =
selectedCell?.impactLabel === imp.label && selectedCell?.likelihoodLabel === lik.label;
return (
<button
key={lik.val}
type="button"
onClick={() => handleCellClick(imp.label, lik.label, score, cellRisks)}
className={`relative rounded-xl border p-2 text-center transition-all cursor-pointer ${
sev.bgClass
} ${isSelected ? 'ring-2 ring-blue-400 scale-[1.03] z-10 shadow-lg' : ''} ${
hasRisks ? 'shadow-xs font-bold' : 'opacity-70 hover:opacity-100'
}`}
title={`Impact: ${imp.label} (${imp.val}) × Likelihood: ${lik.label} (${lik.val}) = Score ${score} (${sev.name})`}
>
{/* Score small indicator */}
<span className="text-[9px] block text-slate-400/80 font-mono">
{score}
</span>
{/* Risk count or empty dash */}
{hasRisks ? (
<div className="mt-0.5 flex items-center justify-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-white animate-pulse"></span>
<span className="text-xs font-extrabold text-white px-1.5 py-0.2 rounded-full bg-black/40 border border-white/20">
{cellRisks.length} {cellRisks.length === 1 ? 'Risk' : 'Risks'}
</span>
</div>
) : (
<span className="text-xs text-slate-600 block mt-0.5">&mdash;</span>
)}
</button>
);
})}
</div>
))}
</div>
</div>
</div>
</div>
</div>
)}
{/* 3x3 Summary Mode */}
{matrixMode === '3x3' && (
<div className="bg-[#090d16]/80 p-3 rounded-2xl border border-slate-800/90 overflow-x-auto">
<div className="min-w-[440px]">
{/* Top axis label: Likelihood */}
<div className="text-center text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2 pl-24">
Likelihood / Probability &rarr;
</div>
<div className="flex">
{/* Left Y-Axis Label: Impact */}
<div className="w-6 flex items-center justify-center -mr-2">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest -rotate-90 whitespace-nowrap">
&uarr; Impact / Severity
</span>
</div>
{/* 3x3 Table */}
<div className="flex-1">
{/* Column Headers */}
<div className="grid grid-cols-4 gap-2 mb-2 text-center">
<div className="text-[10px] font-semibold text-slate-500 flex items-center justify-end pr-2">
Impact \ Lik.
</div>
<div className="text-[10px] font-semibold text-slate-300 bg-slate-900/60 py-1.5 rounded-lg">
Low (1&ndash;2 Rare/Unlikely)
</div>
<div className="text-[10px] font-semibold text-slate-300 bg-slate-900/60 py-1.5 rounded-lg">
Medium (3 Possible)
</div>
<div className="text-[10px] font-semibold text-slate-300 bg-slate-900/60 py-1.5 rounded-lg">
High (4&ndash;5 Likely/Certain)
</div>
</div>
{/* Row 1: High Impact (4-5) */}
<div className="grid grid-cols-4 gap-2 mb-2">
<div className="text-[10px] font-semibold text-slate-300 bg-slate-900/60 rounded-lg flex items-center justify-end pr-2">
High (4&ndash;5 Major/Crit.)
</div>
{/* High Imp - Low Lik */}
{(() => {
const cRisks = matrix3x3['high-low'];
return (
<button
type="button"
onClick={() => handleCellClick('High (4-5)', 'Low (1-2)', 8, cRisks)}
className="bg-amber-950/50 border border-amber-800/60 p-3 rounded-xl text-center hover:bg-amber-900/70 transition cursor-pointer"
>
<span className="text-[10px] font-semibold text-amber-400 block">Moderate Severity</span>
<span className="text-xs font-bold text-white mt-1 block">
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
</span>
</button>
);
})()}
{/* High Imp - Med Lik */}
{(() => {
const cRisks = matrix3x3['high-med'];
return (
<button
type="button"
onClick={() => handleCellClick('High (4-5)', 'Medium (3)', 12, cRisks)}
className="bg-orange-950/60 border border-orange-700/60 p-3 rounded-xl text-center hover:bg-orange-900/80 transition cursor-pointer"
>
<span className="text-[10px] font-semibold text-orange-400 block">High Severity</span>
<span className="text-xs font-bold text-white mt-1 block">
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
</span>
</button>
);
})()}
{/* High Imp - High Lik */}
{(() => {
const cRisks = matrix3x3['high-high'];
return (
<button
type="button"
onClick={() => handleCellClick('High (4-5)', 'High (4-5)', 20, cRisks)}
className="bg-rose-950/80 border border-rose-600 p-3 rounded-xl text-center hover:bg-rose-900 transition cursor-pointer shadow-md"
>
<span className="text-[10px] font-bold text-rose-300 block">Critical Severity</span>
<span className="text-xs font-extrabold text-white mt-1 block">
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
</span>
</button>
);
})()}
</div>
{/* Row 2: Medium Impact (3) */}
<div className="grid grid-cols-4 gap-2 mb-2">
<div className="text-[10px] font-semibold text-slate-300 bg-slate-900/60 rounded-lg flex items-center justify-end pr-2">
Medium (3 Moderate)
</div>
{/* Med Imp - Low Lik */}
{(() => {
const cRisks = matrix3x3['med-low'];
return (
<button
type="button"
onClick={() => handleCellClick('Medium (3)', 'Low (1-2)', 3, cRisks)}
className="bg-emerald-950/40 border border-emerald-800/40 p-3 rounded-xl text-center hover:bg-emerald-900/60 transition cursor-pointer"
>
<span className="text-[10px] font-semibold text-emerald-400 block">Low Severity</span>
<span className="text-xs font-bold text-white mt-1 block">
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
</span>
</button>
);
})()}
{/* Med Imp - Med Lik */}
{(() => {
const cRisks = matrix3x3['med-med'];
return (
<button
type="button"
onClick={() => handleCellClick('Medium (3)', 'Medium (3)', 9, cRisks)}
className="bg-amber-950/50 border border-amber-800/50 p-3 rounded-xl text-center hover:bg-amber-900/70 transition cursor-pointer"
>
<span className="text-[10px] font-semibold text-amber-400 block">Moderate Severity</span>
<span className="text-xs font-bold text-white mt-1 block">
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
</span>
</button>
);
})()}
{/* Med Imp - High Lik */}
{(() => {
const cRisks = matrix3x3['med-high'];
return (
<button
type="button"
onClick={() => handleCellClick('Medium (3)', 'High (4-5)', 12, cRisks)}
className="bg-orange-950/60 border border-orange-700/60 p-3 rounded-xl text-center hover:bg-orange-900/80 transition cursor-pointer"
>
<span className="text-[10px] font-semibold text-orange-400 block">High Severity</span>
<span className="text-xs font-bold text-white mt-1 block">
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
</span>
</button>
);
})()}
</div>
{/* Row 3: Low Impact (1-2) */}
<div className="grid grid-cols-4 gap-2">
<div className="text-[10px] font-semibold text-slate-300 bg-slate-900/60 rounded-lg flex items-center justify-end pr-2">
Low (1&ndash;2 Minor)
</div>
{/* Low Imp - Low Lik */}
{(() => {
const cRisks = matrix3x3['low-low'];
return (
<button
type="button"
onClick={() => handleCellClick('Low (1-2)', 'Low (1-2)', 1, cRisks)}
className="bg-slate-900/80 border border-slate-800 p-3 rounded-xl text-center hover:bg-slate-800/80 transition cursor-pointer"
>
<span className="text-[10px] font-semibold text-slate-400 block">Trivial Severity</span>
<span className="text-xs font-bold text-slate-300 mt-1 block">
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
</span>
</button>
);
})()}
{/* Low Imp - Med Lik */}
{(() => {
const cRisks = matrix3x3['low-med'];
return (
<button
type="button"
onClick={() => handleCellClick('Low (1-2)', 'Medium (3)', 3, cRisks)}
className="bg-emerald-950/40 border border-emerald-800/40 p-3 rounded-xl text-center hover:bg-emerald-900/60 transition cursor-pointer"
>
<span className="text-[10px] font-semibold text-emerald-400 block">Low Severity</span>
<span className="text-xs font-bold text-white mt-1 block">
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
</span>
</button>
);
})()}
{/* Low Imp - High Lik */}
{(() => {
const cRisks = matrix3x3['low-high'];
return (
<button
type="button"
onClick={() => handleCellClick('Low (1-2)', 'High (4-5)', 8, cRisks)}
className="bg-amber-950/50 border border-amber-800/50 p-3 rounded-xl text-center hover:bg-amber-900/70 transition cursor-pointer"
>
<span className="text-[10px] font-semibold text-amber-400 block">Moderate Severity</span>
<span className="text-xs font-bold text-white mt-1 block">
{cRisks.length} {cRisks.length === 1 ? 'Risk' : 'Risks'}
</span>
</button>
);
})()}
</div>
</div>
</div>
</div>
</div>
)}
{/* Selected Cell Inspector (Drill-down) */}
{selectedCell && selectedCell.risks.length > 0 && (
<div className="bg-[#0f1523] border border-blue-500/40 p-4 rounded-2xl shadow-xl animate-in fade-in slide-in-from-top-2 duration-200">
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
<div className="flex items-center gap-2">
<div className="p-1 rounded-md bg-blue-500/20 text-blue-400">
<Info className="w-4 h-4" />
</div>
<div>
<h4 className="text-xs font-bold text-white">
Risks in Box: Impact [{selectedCell.impactLabel}] &times; Likelihood [{selectedCell.likelihoodLabel}]
</h4>
<p className="text-[10px] text-slate-400">
Residual Score: <strong className="text-blue-300">{selectedCell.score} / 25</strong> &bull; Total Items: {selectedCell.risks.length}
</p>
</div>
</div>
<button
type="button"
onClick={() => setSelectedCell(null)}
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="mt-3 space-y-2 max-h-56 overflow-y-auto pr-1 divide-y divide-slate-800/60">
{selectedCell.risks.map((rk) => (
<div
key={rk.id}
onClick={() => onSelectRisk && onSelectRisk(rk)}
className={`pt-2 first:pt-0 ${onSelectRisk ? 'cursor-pointer hover:bg-slate-800/30 p-2 rounded-xl transition' : ''}`}
>
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-xs text-white line-clamp-1">{rk.name}</span>
<span className="text-[10px] font-mono text-slate-400 shrink-0">
Project #{rk.projectId} {projectMap[String(rk.projectId)] ? `(${projectMap[String(rk.projectId)]})` : ''}
</span>
</div>
{rk.description && (
<p className="text-[11px] text-slate-400 line-clamp-2 mt-0.5">{rk.description}</p>
)}
<div className="flex items-center gap-3 mt-1 text-[10px] text-slate-400">
<span>Likelihood: <strong className="text-slate-200">{rk.likelihood}</strong></span>
<span>Impact: <strong className="text-slate-200">{rk.impact}</strong></span>
<span>Owner: <strong className="text-slate-300">{getUserEmail(rk.owner)}</strong></span>
</div>
{rk.mitigationAction && (
<div className="mt-1 text-[10px] bg-slate-900/80 p-1.5 rounded-lg text-slate-300 border border-slate-800">
<strong className="text-blue-400">Mitigation: </strong>{rk.mitigationAction}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
);
};
@@ -1,24 +1,230 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
import { CheckCircle2, AlertCircle, Info, X } from 'lucide-react';
import React, { createContext, useContext, useState, useEffect, useRef, ReactNode } from 'react';
import {
CheckCircle2,
AlertCircle,
Info,
X,
Copy,
Check,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { parseFriendlyError, ErrorCategory } from '../../utils/apiErrorParser';
export type ToastType = 'success' | 'error' | 'info';
interface ToastItem {
export interface ToastItem {
id: string;
type: ToastType;
title: string;
message?: string;
duration: number; // in milliseconds
createdAt: number;
statusCode?: number;
category?: ErrorCategory;
categoryLabel?: string;
technicalDetails?: string;
fieldErrors?: Array<{ field: string; message: string }>;
}
export interface ToastOptions {
duration?: number;
statusCode?: number;
category?: ErrorCategory;
categoryLabel?: string;
technicalDetails?: string;
fieldErrors?: Array<{ field: string; message: string }>;
}
interface ToastContextType {
toast: (type: ToastType, title: string, message?: string) => void;
success: (title: string, message?: string) => void;
error: (title: string, message?: string) => void;
info: (title: string, message?: string) => void;
toast: (type: ToastType, title: string, message?: string, options?: ToastOptions) => void;
success: (title: string, message?: string, options?: ToastOptions) => void;
error: (titleOrError: unknown, messageOrError?: unknown, options?: ToastOptions) => void;
info: (title: string, message?: string, options?: ToastOptions) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
// Default durations (in ms):
// Errors linger significantly longer (14s) so users have sufficient time to read and note the failure.
const DEFAULT_DURATIONS: Record<ToastType, number> = {
success: 4500,
info: 6000,
error: 14000,
};
const ToastCard: React.FC<{
toast: ToastItem;
onRemove: (id: string) => void;
}> = ({ toast, onRemove }) => {
const [isPaused, setIsPaused] = useState(false);
const [remainingTime, setRemainingTime] = useState(toast.duration);
const [copied, setCopied] = useState(false);
const [showTechnical, setShowTechnical] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (isPaused) {
if (intervalRef.current) clearInterval(intervalRef.current);
return;
}
const step = 100;
intervalRef.current = setInterval(() => {
setRemainingTime((prev) => {
if (prev <= step) {
if (intervalRef.current) clearInterval(intervalRef.current);
onRemove(toast.id);
return 0;
}
return prev - step;
});
}, step);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [isPaused, toast.id, onRemove]);
const handleCopy = () => {
const textToCopy = [
`PFM Error Notice: ${toast.title}`,
toast.message ? `Details: ${toast.message}` : '',
toast.statusCode ? `Status Code: ${toast.statusCode}` : '',
toast.technicalDetails ? `Technical Info: ${toast.technicalDetails}` : '',
`Timestamp: ${new Date(toast.createdAt).toISOString()}`,
]
.filter(Boolean)
.join('\n');
navigator.clipboard.writeText(textToCopy).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const progressPercent = Math.max(0, Math.min(100, (remainingTime / toast.duration) * 100));
return (
<div
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
className={`pointer-events-auto relative flex flex-col rounded-xl border shadow-2xl transition-all duration-200 overflow-hidden bg-[#111622] ${
toast.type === 'success'
? 'border-emerald-500/30 text-gray-200'
: toast.type === 'error'
? 'border-rose-500/40 text-gray-200 shadow-rose-950/20'
: 'border-blue-500/30 text-gray-200'
}`}
>
<div className="flex items-start gap-3 p-4">
{toast.type === 'success' && <CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" />}
{toast.type === 'error' && <AlertCircle className="w-5 h-5 text-rose-400 shrink-0 mt-0.5" />}
{toast.type === 'info' && <Info className="w-5 h-5 text-blue-400 shrink-0 mt-0.5" />}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<h4 className="text-sm font-semibold text-white tracking-tight leading-snug">{toast.title}</h4>
{toast.categoryLabel && (
<span
className={`text-[10px] font-medium px-1.5 py-0.5 rounded border ${
toast.category === 'validation'
? 'bg-amber-950/60 text-amber-300 border-amber-800/60'
: toast.category === 'creation'
? 'bg-indigo-950/60 text-indigo-300 border-indigo-800/60'
: toast.category === 'update'
? 'bg-sky-950/60 text-sky-300 border-sky-800/60'
: toast.category === 'security'
? 'bg-rose-950/60 text-rose-300 border-rose-800/60'
: toast.category === 'notFound'
? 'bg-slate-800 text-slate-300 border-slate-700'
: toast.category === 'conflict'
? 'bg-purple-950/60 text-purple-300 border-purple-800/60'
: 'bg-slate-800 text-slate-300 border-slate-700'
}`}
>
{toast.categoryLabel}
</span>
)}
{toast.statusCode && (
<span className="text-[10px] font-mono font-medium px-1.5 py-0.5 rounded bg-slate-800 text-slate-300 border border-slate-700">
HTTP {toast.statusCode}
</span>
)}
</div>
{toast.message && (
<p className="text-xs mt-1.5 text-slate-300 leading-relaxed whitespace-pre-line break-words">
{toast.message}
</p>
)}
{/* Optional collapsible technical reference */}
{toast.technicalDetails && (
<div className="mt-2.5 pt-2 border-t border-slate-800/80">
<button
type="button"
onClick={() => setShowTechnical(!showTechnical)}
className="inline-flex items-center gap-1 text-[11px] text-slate-400 hover:text-slate-200 transition-colors cursor-pointer"
>
<span>{showTechnical ? 'Hide Technical Reference' : 'Show Technical Reference'}</span>
{showTechnical ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
{showTechnical && (
<div className="mt-1.5 p-2 rounded bg-[#090d16] border border-slate-800 font-mono text-[10px] text-slate-400 break-all select-all">
{toast.technicalDetails}
</div>
)}
</div>
)}
{/* Action bar for error toasts (Copy info & linger helper) */}
{toast.type === 'error' && (
<div className="mt-3 flex items-center justify-between gap-2 pt-2 border-t border-slate-800/60 text-[11px] text-slate-400">
<span className="text-[10px] text-slate-500">
{isPaused ? 'Timer paused (hovering)' : 'Notification lingers for review'}
</span>
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center gap-1 px-2 py-1 rounded bg-slate-800/80 hover:bg-slate-700 text-slate-300 hover:text-white transition-colors cursor-pointer"
title="Copy details to clipboard for support or administrator"
>
{copied ? <Check className="w-3 h-3 text-emerald-400" /> : <Copy className="w-3 h-3" />}
<span>{copied ? 'Copied' : 'Copy Details'}</span>
</button>
</div>
)}
</div>
<button
onClick={() => onRemove(toast.id)}
className="text-slate-400 hover:text-white p-1 rounded-lg hover:bg-slate-800/60 transition-colors shrink-0 cursor-pointer"
title="Dismiss notification"
aria-label="Dismiss"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Visual countdown progress bar */}
<div className="w-full bg-slate-800/40 h-1 overflow-hidden">
<div
className={`h-full transition-all duration-100 ease-linear ${
toast.type === 'success'
? 'bg-emerald-500'
: toast.type === 'error'
? 'bg-rose-500'
: 'bg-blue-500'
}`}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
);
};
export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<ToastItem[]>([]);
@@ -26,45 +232,89 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) =
setToasts((prev) => prev.filter((t) => t.id !== id));
};
const addToast = (type: ToastType, title: string, message?: string) => {
const addToast = (
type: ToastType,
title: string,
message?: string,
options?: ToastOptions
) => {
const id = Math.random().toString(36).substring(2, 9);
setToasts((prev) => [...prev, { id, type, title, message }]);
setTimeout(() => removeToast(id), 5000);
const duration = options?.duration || DEFAULT_DURATIONS[type];
setToasts((prev) => [
...prev,
{
id,
type,
title,
message,
duration,
createdAt: Date.now(),
statusCode: options?.statusCode,
category: options?.category,
categoryLabel: options?.categoryLabel,
technicalDetails: options?.technicalDetails,
fieldErrors: options?.fieldErrors,
},
]);
};
const success = (title: string, message?: string) => addToast('success', title, message);
const error = (title: string, message?: string) => addToast('error', title, message);
const info = (title: string, message?: string) => addToast('info', title, message);
const success = (title: string, message?: string, options?: ToastOptions) =>
addToast('success', title, message, options);
const info = (title: string, message?: string, options?: ToastOptions) =>
addToast('info', title, message, options);
/**
* Intelligently formats errors using ProblemDetails and non-technical language.
* Differentiates BadRequest errors for input validation, creation failures, and update failures.
* Accepts flexible argument patterns:
* - error("Action Failed", err)
* - error("Action Failed", "Custom message")
* - error(err, "Action Failed")
* - error(err)
*/
const error = (
titleOrError: unknown,
messageOrError?: unknown,
options?: ToastOptions
) => {
let rawError: unknown = null;
let actionTitle = 'Action Failed';
if (typeof titleOrError === 'string') {
actionTitle = titleOrError;
rawError = messageOrError;
} else {
rawError = titleOrError;
if (typeof messageOrError === 'string') {
actionTitle = messageOrError;
}
}
// Parse friendly non-technical error with ProblemDetails awareness
const parsed = parseFriendlyError(rawError, actionTitle);
addToast('error', parsed.title, parsed.message, {
duration: options?.duration || DEFAULT_DURATIONS.error,
statusCode: parsed.statusCode || options?.statusCode,
category: options?.category || parsed.category,
categoryLabel: options?.categoryLabel || parsed.categoryLabel,
technicalDetails: parsed.technicalDetails || options?.technicalDetails,
fieldErrors: parsed.fieldErrors || options?.fieldErrors,
});
};
return (
<ToastContext.Provider value={{ toast: addToast, success, error, info }}>
{children}
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-md w-full pointer-events-none p-2">
<div
role="region"
aria-label="System Notifications"
className="fixed bottom-4 right-4 z-50 flex flex-col gap-3 max-w-lg w-full pointer-events-none p-2"
>
{toasts.map((t) => (
<div
key={t.id}
className={`pointer-events-auto flex items-start gap-3 p-4 rounded-xl border shadow-2xl transition-all duration-200 bg-[#141414] ${
t.type === 'success'
? 'border-green-500/30 text-gray-200'
: t.type === 'error'
? 'border-rose-500/30 text-gray-200'
: 'border-indigo-500/30 text-gray-200'
}`}
>
{t.type === 'success' && <CheckCircle2 className="w-5 h-5 text-green-400 shrink-0 mt-0.5" />}
{t.type === 'error' && <AlertCircle className="w-5 h-5 text-rose-400 shrink-0 mt-0.5" />}
{t.type === 'info' && <Info className="w-5 h-5 text-indigo-400 shrink-0 mt-0.5" />}
<div className="flex-1">
<h4 className="text-sm font-semibold text-white">{t.title}</h4>
{t.message && <p className="text-xs mt-0.5 text-gray-400 leading-relaxed">{t.message}</p>}
</div>
<button
onClick={() => removeToast(t.id)}
className="text-gray-400 hover:text-white p-0.5 rounded transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<ToastCard key={t.id} toast={t} onRemove={removeToast} />
))}
</div>
</ToastContext.Provider>