Refactored UI and compose file

This commit is contained in:
2026-09-17 07:07:05 +02:00
parent 2d74f7f1bd
commit 831d57a8a0
47 changed files with 9718 additions and 10088 deletions
@@ -1,31 +1,33 @@
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { UserManager, WebStorageStateStore, User as OidcUser } from 'oidc-client-ts';
import { Navigate, useLocation } from 'react-router-dom';
import { getSecuritySettings, loadRuntimeConfig } from '../config';
import { getSecuritySettings, loadRuntimeConfig, onConfigChanged } from '../config';
import { parseJwtClaims } from './jwt';
import { BrandLogo } from '../components/ui/BrandLogo';
export interface AuthUserProfile {
id: string;
sub: string;
name: string;
email: string;
role: string;
organisationName?: string;
avatarUrl?: string;
id: string;
sub: string;
name: string;
email: string;
role: string;
organisationName?: string;
tenant?: string;
avatarUrl?: string;
}
export type UserClaims = Record<string, any>;
interface AuthContextType {
user: AuthUserProfile | null;
sub: string;
claims: UserClaims;
accessToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: () => Promise<void>;
logout: () => Promise<void>;
authError: string | null;
user: AuthUserProfile | null;
sub: string;
claims: UserClaims;
accessToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: () => Promise<void>;
logout: () => Promise<void>;
authError: string | null;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
@@ -36,240 +38,261 @@ export const LOCAL_STORAGE_SUB_KEY = 'pfm_user_sub';
export const LOCAL_STORAGE_CLAIMS_KEY = 'pfm_user_claims';
export function getCurrentUserSub(): string {
if (typeof window !== 'undefined') {
return localStorage.getItem(LOCAL_STORAGE_SUB_KEY) || '';
}
return '';
if (typeof window !== 'undefined') {
return localStorage.getItem(LOCAL_STORAGE_SUB_KEY) || '';
}
return '';
}
export function getCurrentUserClaims(): UserClaims {
if (typeof window !== 'undefined') {
const raw = localStorage.getItem(LOCAL_STORAGE_CLAIMS_KEY);
if (raw) {
try {
return JSON.parse(raw);
} catch {
return {};
}
}
if (typeof window !== 'undefined') {
const raw = localStorage.getItem(LOCAL_STORAGE_CLAIMS_KEY);
if (raw) {
try {
return JSON.parse(raw);
} catch {
return {};
}
}
return {};
}
return {};
}
let userManagerInstance: UserManager | null = null;
function getUserManager(): UserManager {
if (!userManagerInstance && typeof window !== 'undefined') {
const sec = getSecuritySettings();
const origin = window.location.origin;
export function resetUserManager(): void {
userManagerInstance = null;
}
userManagerInstance = new UserManager({
authority: sec.Authority,
client_id: sec.ClientId,
client_secret: sec.ClientSecret,
redirect_uri: `${origin}/dashboard`,
post_logout_redirect_uri: `${origin}/`,
response_type: 'code',
scope: 'openid profile roles email address pfm-api-dev',
extraQueryParams: {
audience: sec.Audience,
},
userStore: new WebStorageStateStore({ store: window.localStorage }),
automaticSilentRenew: false,
});
}
return userManagerInstance!;
// Automatically reset UserManager when runtime configuration is updated
onConfigChanged(() => {
resetUserManager();
});
export function getUserManager(): UserManager {
if (!userManagerInstance && typeof window !== 'undefined') {
const sec = getSecuritySettings();
const origin = window.location.origin;
userManagerInstance = new UserManager({
authority: sec.Authority,
client_id: sec.ClientId,
client_secret: sec.ClientSecret,
redirect_uri: `${origin}/dashboard`,
post_logout_redirect_uri: `${origin}/`,
response_type: 'code',
scope: sec.Scope || 'openid profile roles email address pfm-api-dev',
extraQueryParams: {
audience: sec.Audience,
},
userStore: new WebStorageStateStore({ store: window.localStorage }),
automaticSilentRenew: false,
});
}
return userManagerInstance!;
}
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [user, setUser] = useState<AuthUserProfile | null>(null);
const [sub, setSub] = useState<string>('');
const [claims, setClaims] = useState<UserClaims>({});
const [accessToken, setAccessToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [authError, setAuthError] = useState<string | null>(null);
const [user, setUser] = useState<AuthUserProfile | null>(null);
const [sub, setSub] = useState<string>('');
const [claims, setClaims] = useState<UserClaims>({});
const [accessToken, setAccessToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [authError, setAuthError] = useState<string | null>(null);
// Initialize auth state and check for OIDC callback
useEffect(() => {
const initAuth = async () => {
try {
await loadRuntimeConfig();
// Initialize auth state and check for OIDC callback
useEffect(() => {
const initAuth = async () => {
try {
await loadRuntimeConfig();
// Check for URL query code indicating OIDC redirect callback
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('code') && urlParams.has('state')) {
const mgr = getUserManager();
const oidcUser: OidcUser = await mgr.signinCallback();
// Check for URL query code indicating OIDC redirect callback
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('code') && urlParams.has('state')) {
const mgr = getUserManager();
const oidcUser: OidcUser = await mgr.signinCallback();
if (oidcUser && oidcUser.access_token) {
const token = oidcUser.access_token;
setAccessToken(token);
localStorage.setItem(LOCAL_STORAGE_TOKEN_KEY, token);
if (oidcUser && oidcUser.access_token) {
const token = oidcUser.access_token;
setAccessToken(token);
localStorage.setItem(LOCAL_STORAGE_TOKEN_KEY, token);
// Merge claims from profile and decoded JWT token
const decodedTokenClaims = parseJwtClaims(token);
const mergedClaims: UserClaims = {
...decodedTokenClaims,
...oidcUser.profile,
};
// Merge claims from profile and decoded JWT token
const decodedTokenClaims = parseJwtClaims(token);
const mergedClaims: UserClaims = {
...decodedTokenClaims,
...oidcUser.profile,
};
const userSub = (oidcUser.profile?.sub || mergedClaims.sub || '') as string;
setSub(userSub);
setClaims(mergedClaims);
const userSub = (oidcUser.profile?.sub || mergedClaims.sub || '') as string;
setSub(userSub);
setClaims(mergedClaims);
localStorage.setItem(LOCAL_STORAGE_SUB_KEY, userSub);
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims));
localStorage.setItem(LOCAL_STORAGE_SUB_KEY, userSub);
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims));
const profile: AuthUserProfile = {
id: userSub || 'user-1',
sub: userSub,
name: oidcUser.profile.name || oidcUser.profile.preferred_username || (mergedClaims.name as string) || 'Authorized User',
email: oidcUser.profile.email || (mergedClaims.email as string) || '',
role: (oidcUser.profile.role as string) || (mergedClaims.role as string) || (Array.isArray(mergedClaims.roles) ? mergedClaims.roles[0] : 'Programme Sponsor'),
organisationName: (mergedClaims.organisation as string) || (mergedClaims.org as string) || undefined,
};
const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || undefined;
setUser(profile);
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
const profile: AuthUserProfile = {
id: userSub || 'user-1',
sub: userSub,
name: oidcUser.profile.name || oidcUser.profile.preferred_username || (mergedClaims.name as string) || 'Authorized User',
email: oidcUser.profile.email || (mergedClaims.email as string) || '',
role: (oidcUser.profile.role as string) || (mergedClaims.role as string) || (Array.isArray(mergedClaims.roles) ? mergedClaims.roles[0] : 'Programme Sponsor'),
organisationName: (mergedClaims.organisation as string) || (mergedClaims.org as string) || undefined,
tenant: tenantClaim,
};
// Clean up code and state from URL
window.history.replaceState({}, document.title, window.location.pathname);
setIsLoading(false);
return;
}
}
setUser(profile);
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
// Check local storage for persisted session
const storedToken = localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY);
const storedUser = localStorage.getItem(LOCAL_STORAGE_USER_KEY);
let storedSub = localStorage.getItem(LOCAL_STORAGE_SUB_KEY);
const storedClaimsRaw = localStorage.getItem(LOCAL_STORAGE_CLAIMS_KEY);
if (storedToken) {
setAccessToken(storedToken);
let parsedClaims: UserClaims = {};
if (storedClaimsRaw) {
try {
parsedClaims = JSON.parse(storedClaimsRaw);
} catch { }
} else {
parsedClaims = parseJwtClaims(storedToken);
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(parsedClaims));
}
setClaims(parsedClaims);
if (!storedSub && parsedClaims.sub) {
storedSub = String(parsedClaims.sub);
localStorage.setItem(LOCAL_STORAGE_SUB_KEY, storedSub);
}
if (storedSub) {
setSub(storedSub);
}
if (storedUser) {
try {
setUser(JSON.parse(storedUser));
} catch { }
}
}
} catch (err: unknown) {
console.warn('OIDC initialization notice:', err);
setAuthError(err instanceof Error ? err.message : 'Auth initialization notice');
} finally {
setIsLoading(false);
}
};
initAuth();
}, []);
const login = async () => {
try {
setAuthError(null);
const mgr = getUserManager();
await mgr.signinRedirect();
} catch (err: unknown) {
console.warn('OIDC Redirect encountered:', err);
// In sandboxed preview iframe, navigation to third-party STS 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. For testing with Duende STS (https://sts.security.khongisa.co.za), open the application in a new tab or Docker container.';
setAuthError(errorMsg);
} else {
setAuthError(err instanceof Error ? err.message : 'Identity Provider redirect error');
}
// Clean up code and state from URL
window.history.replaceState({}, document.title, window.location.pathname);
setIsLoading(false);
return;
}
}
// Check local storage for persisted session
const storedToken = localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY);
const storedUser = localStorage.getItem(LOCAL_STORAGE_USER_KEY);
let storedSub = localStorage.getItem(LOCAL_STORAGE_SUB_KEY);
const storedClaimsRaw = localStorage.getItem(LOCAL_STORAGE_CLAIMS_KEY);
if (storedToken) {
setAccessToken(storedToken);
let parsedClaims: UserClaims = {};
if (storedClaimsRaw) {
try {
parsedClaims = JSON.parse(storedClaimsRaw);
} catch {}
} else {
parsedClaims = parseJwtClaims(storedToken);
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(parsedClaims));
}
setClaims(parsedClaims);
if (!storedSub && parsedClaims.sub) {
storedSub = String(parsedClaims.sub);
localStorage.setItem(LOCAL_STORAGE_SUB_KEY, storedSub);
}
if (storedSub) {
setSub(storedSub);
}
if (storedUser) {
try {
const parsedUser = JSON.parse(storedUser);
if (!parsedUser.tenant && parsedClaims.tenant) {
parsedUser.tenant = parsedClaims.tenant;
}
setUser(parsedUser);
} catch {}
}
}
} catch (err: unknown) {
console.warn('OIDC initialization notice:', err);
setAuthError(err instanceof Error ? err.message : 'Auth initialization notice');
} finally {
setIsLoading(false);
}
};
const logout = async () => {
try {
localStorage.removeItem(LOCAL_STORAGE_TOKEN_KEY);
localStorage.removeItem(LOCAL_STORAGE_USER_KEY);
localStorage.removeItem(LOCAL_STORAGE_SUB_KEY);
localStorage.removeItem(LOCAL_STORAGE_CLAIMS_KEY);
setUser(null);
setSub('');
setClaims({});
setAccessToken(null);
initAuth();
}, []);
try {
const mgr = getUserManager();
await mgr.signoutRedirect();
} catch {
// Local state cleared
}
} catch (err) {
console.error('Logout error:', err);
}
};
const login = async () => {
try {
setAuthError(null);
await loadRuntimeConfig();
resetUserManager();
const mgr = getUserManager();
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.';
setAuthError(errorMsg);
} else {
setAuthError(err instanceof Error ? err.message : 'Identity Provider redirect error');
}
}
};
return (
<AuthContext.Provider
value={{
user,
sub,
claims,
accessToken,
isAuthenticated: !!accessToken,
isLoading,
login,
logout,
authError,
}}
>
{children}
</AuthContext.Provider>
);
const logout = async () => {
try {
localStorage.removeItem(LOCAL_STORAGE_TOKEN_KEY);
localStorage.removeItem(LOCAL_STORAGE_USER_KEY);
localStorage.removeItem(LOCAL_STORAGE_SUB_KEY);
localStorage.removeItem(LOCAL_STORAGE_CLAIMS_KEY);
setUser(null);
setSub('');
setClaims({});
setAccessToken(null);
try {
const mgr = getUserManager();
await mgr.signoutRedirect();
} catch {
// Local state cleared
}
} catch (err) {
console.error('Logout error:', err);
}
};
return (
<AuthContext.Provider
value={{
user,
sub,
claims,
accessToken,
isAuthenticated: !!accessToken,
isLoading,
login,
logout,
authError,
}}
>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
// Route Guard
export const ProtectedRoute: React.FC<{ children: ReactNode }> = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
return (
<div className="flex h-screen w-full items-center justify-center bg-[#0d0d0d]">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent"></div>
<p className="text-xs font-medium text-gray-400">Verifying PFM Console Session...</p>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="flex h-screen w-full items-center justify-center bg-[#090d16]">
<div className="flex flex-col items-center gap-4">
<div className="relative flex items-center justify-center">
<BrandLogo size={52} showGlow animateOnHover={false} />
<div className="absolute -inset-2.5 rounded-full border-2 border-blue-500/20 border-t-blue-400 animate-spin" />
</div>
<p className="text-xs font-medium text-slate-400 tracking-wide">Verifying PFM Console Session...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/" state={{ from: location }} replace />;
}
if (!isAuthenticated) {
return <Navigate to="/" state={{ from: location }} replace />;
}
return <>{children}</>;
return <>{children}</>;
};