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:
@@ -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 →
|
||||
</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">
|
||||
↑ 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">—</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 →
|
||||
</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">
|
||||
↑ 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–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–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–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–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}] × Likelihood [{selectedCell.likelihoodLabel}]
|
||||
</h4>
|
||||
<p className="text-[10px] text-slate-400">
|
||||
Residual Score: <strong className="text-blue-300">{selectedCell.score} / 25</strong> • 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>
|
||||
|
||||
Reference in New Issue
Block a user