Refactored UI and compose file
This commit is contained in:
@@ -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}</>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user