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:
@@ -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}</>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user