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
@@ -4,6 +4,23 @@ import { Navigate, useLocation } from 'react-router-dom';
import { getSecuritySettings, loadRuntimeConfig, onConfigChanged } from '../config';
import { parseJwtClaims } from './jwt';
import { BrandLogo } from '../components/ui/BrandLogo';
import {
syncUserAccount,
isUserSyncedInBrowser,
isActivationPending,
getActivationPendingReason,
clearUserSyncInBrowser,
UserSyncResult,
} from '../services/userSyncService';
import { AccountActivationModal } from '../components/auth/AccountActivationModal';
import { SessionExpiredModal } from '../components/auth/SessionExpiredModal';
import {
onSessionExpired,
triggerSessionExpired,
clearSessionAndRedirectToLanding,
cancelSessionExpiration,
dismissSubordinateModals,
} from '../services/sessionExpiration';
export interface AuthUserProfile {
id: string;
@@ -25,8 +42,14 @@ interface AuthContextType {
accessToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
isSynced: boolean;
isPendingActivation: boolean;
activationError: string | null;
isSessionExpired: boolean;
triggerSessionExpired: (reason?: string) => void;
login: () => Promise<void>;
logout: () => Promise<void>;
syncUserSession: (force?: boolean) => Promise<UserSyncResult>;
authError: string | null;
}
@@ -98,9 +121,58 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const [claims, setClaims] = useState<UserClaims>({});
const [accessToken, setAccessToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isSynced, setIsSynced] = useState<boolean>(() => isUserSyncedInBrowser());
const [isPendingActivation, setIsPendingActivation] = useState<boolean>(() => isActivationPending());
const [activationError, setActivationError] = useState<string | null>(() => getActivationPendingReason());
const [authError, setAuthError] = useState<string | null>(null);
const [isSessionExpired, setIsSessionExpired] = useState<boolean>(false);
const [sessionExpiredReason, setSessionExpiredReason] = useState<string | null>(null);
// Initialize auth state and check for OIDC callback
// Subscribe to global HTTP 401 session expiration events
useEffect(() => {
const unsubscribe = onSessionExpired((reason) => {
// Subordination: If account activation lockout is active, session expiration is completely avoided and bypassed
if (isPendingActivation || isActivationPending()) {
return;
}
// Only display modal if user currently has an active token or authenticated state
const token = typeof window !== 'undefined' ? localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY) : null;
if (token || accessToken) {
setSessionExpiredReason(reason || 'Session expired or token invalidated (HTTP 401)');
setIsSessionExpired(true);
}
});
return () => {
unsubscribe();
};
}, [accessToken, isPendingActivation]);
// Priority Subordination: When activation process is active, dismiss all other modals,
// cancel session expiration and avoid its processes so that there is no overlapping.
useEffect(() => {
if (isPendingActivation) {
setIsSessionExpired(false);
setSessionExpiredReason(null);
cancelSessionExpiration();
dismissSubordinateModals();
}
}, [isPendingActivation]);
// When session expiration modal is displayed, dismiss all other subordinates
useEffect(() => {
if (isSessionExpired) {
dismissSubordinateModals();
}
}, [isSessionExpired]);
const handleSessionExpiredDismiss = () => {
setIsSessionExpired(false);
setSessionExpiredReason(null);
clearSessionAndRedirectToLanding();
};
// Initialize auth state, check for OIDC callback, and perform browser-cached user sync
useEffect(() => {
const initAuth = async () => {
try {
@@ -148,6 +220,38 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
// Clean up code and state from URL
window.history.replaceState({}, document.title, window.location.pathname);
// Execute user sync endpoint upon login.
// If HTTP 401 -> routes to landing page
// If non-401 failure -> displays non-dismissable activation modal
// If success -> saved in browser to prevent endpoint hotspot
try {
const syncResult = await syncUserAccount(userSub, token, profile, true);
if (syncResult.isUnauthorized) {
await logout();
window.location.replace('/');
return;
}
if (syncResult.isActivationPending) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncResult.errorMessage || null);
} else if (syncResult.success) {
setIsPendingActivation(false);
setIsSynced(true);
setActivationError(null);
}
} catch (syncErr: any) {
if (syncErr?.response?.status === 401) {
await logout();
window.location.replace('/');
return;
}
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncErr?.message || 'Account activation pending');
}
setIsLoading(false);
return;
}
@@ -181,6 +285,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setSub(storedSub);
}
let resolvedUser: AuthUserProfile | null = null;
if (storedUser) {
try {
const parsedUser = JSON.parse(storedUser);
@@ -188,8 +293,48 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
parsedUser.tenant = parsedClaims.tenant;
}
setUser(parsedUser);
resolvedUser = parsedUser;
} catch {}
}
// Check if session is already marked pending activation or needs sync check
const wasPending = isActivationPending();
setIsPendingActivation(wasPending);
setActivationError(getActivationPendingReason());
try {
// Verify sync with browser cache priority (hotspot prevention)
const syncResult = await syncUserAccount(
storedSub || '',
storedToken,
resolvedUser || undefined,
false
);
if (syncResult.isUnauthorized) {
// HTTP 401: Normal process of routing user to landing page
await logout();
return;
}
if (syncResult.isActivationPending) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncResult.errorMessage || null);
} else if (syncResult.success) {
setIsPendingActivation(false);
setIsSynced(true);
setActivationError(null);
}
} catch (syncErr: any) {
if (syncErr?.response?.status === 401) {
await logout();
return;
}
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncErr?.message || 'Account activation pending');
}
}
} catch (err: unknown) {
console.warn('OIDC initialization notice:', err);
@@ -202,6 +347,36 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
initAuth();
}, []);
const syncUserSession = async (force = true): Promise<UserSyncResult> => {
try {
const result = await syncUserAccount(sub, accessToken, user || undefined, force);
if (result.isUnauthorized) {
await logout();
return result;
}
if (result.success) {
setIsPendingActivation(false);
setIsSynced(true);
setActivationError(null);
} else if (result.isActivationPending) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(result.errorMessage || null);
}
return result;
} catch (err: any) {
if (err?.response?.status === 401) {
await logout();
return { success: false, isUnauthorized: true };
}
const msg = err?.message || 'Sync failed';
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(msg);
return { success: false, isActivationPending: true, errorMessage: msg };
}
};
const login = async () => {
try {
setAuthError(null);
@@ -211,7 +386,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
await mgr.signinRedirect();
} catch (err: unknown) {
console.warn('OIDC Redirect encountered:', err);
// In sandboxed preview iframe, navigation to external identity provider domain can be blocked by CSP/frame-ancestors.
if (typeof window !== 'undefined' && window.self !== window.top) {
const errorMsg =
'Identity Provider redirect was restricted inside this preview frame. To complete single sign-on authentication, open the application in a new browser tab.';
@@ -228,10 +402,14 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
localStorage.removeItem(LOCAL_STORAGE_USER_KEY);
localStorage.removeItem(LOCAL_STORAGE_SUB_KEY);
localStorage.removeItem(LOCAL_STORAGE_CLAIMS_KEY);
clearUserSyncInBrowser();
setUser(null);
setSub('');
setClaims({});
setAccessToken(null);
setIsSynced(false);
setIsPendingActivation(false);
setActivationError(null);
try {
const mgr = getUserManager();
@@ -253,12 +431,24 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
accessToken,
isAuthenticated: !!accessToken,
isLoading,
isSynced,
isPendingActivation,
activationError,
isSessionExpired,
triggerSessionExpired,
login,
logout,
syncUserSession,
authError,
}}
>
{children}
<SessionExpiredModal
isOpen={isSessionExpired && !isPendingActivation}
reason={sessionExpiredReason}
onDismiss={handleSessionExpiredDismiss}
countdownSeconds={8}
/>
</AuthContext.Provider>
);
};
@@ -273,7 +463,17 @@ export const useAuth = () => {
// Route Guard
export const ProtectedRoute: React.FC<{ children: ReactNode }> = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
const {
isAuthenticated,
isLoading,
isPendingActivation,
user,
claims,
sub,
logout,
syncUserSession,
activationError,
} = useAuth();
const location = useLocation();
if (isLoading) {
@@ -294,5 +494,20 @@ export const ProtectedRoute: React.FC<{ children: ReactNode }> = ({ children })
return <Navigate to="/" state={{ from: location }} replace />;
}
// If user is authenticated but account has not yet been enabled by an administrator:
// Render the non-dismissable activation modal, blocking all downstream layout and queries.
if (isPendingActivation) {
return (
<AccountActivationModal
user={user}
claims={claims}
sub={sub}
onSync={syncUserSession}
onLogout={logout}
initialError={activationError}
/>
);
}
return <>{children}</>;
};