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:
@@ -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