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