Refactored UI and compose file
This commit is contained in:
@@ -14,38 +14,38 @@ import { MonitoringPage } from './pages/MonitoringPage';
|
||||
import { SettingsPage } from './pages/SettingsPage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<Routes>
|
||||
{/* Public OIDC Login / Landing Page */}
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/login" element={<LandingPage />} />
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<Routes>
|
||||
{/* Public OIDC Login / Landing Page */}
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/login" element={<LandingPage />} />
|
||||
|
||||
{/* Protected Backoffice Application Shell */}
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route path="/dashboard" element={<ExecutiveDashboard />} />
|
||||
<Route path="/governance" element={<GovernancePage />} />
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
<Route path="/grants" element={<GrantsPage />} />
|
||||
<Route path="/monitoring" element={<MonitoringPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
{/* Protected Backoffice Application Shell */}
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route path="/dashboard" element={<ExecutiveDashboard />} />
|
||||
<Route path="/governance" element={<GovernancePage />} />
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
<Route path="/grants" element={<GrantsPage />} />
|
||||
<Route path="/monitoring" element={<MonitoringPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Fallback Redirect */}
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
{/* Fallback Redirect */}
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}</>;
|
||||
};
|
||||
|
||||
@@ -2,22 +2,22 @@
|
||||
* Lightweight browser-safe JWT payload decoder without external native dependencies.
|
||||
*/
|
||||
export function parseJwtClaims(token: string): Record<string, any> {
|
||||
if (!token || typeof token !== 'string') return {};
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length < 2) return {};
|
||||
const base64Url = parts[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(
|
||||
window
|
||||
.atob(base64)
|
||||
.split('')
|
||||
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join('')
|
||||
);
|
||||
return JSON.parse(jsonPayload);
|
||||
} catch (err) {
|
||||
console.debug('Failed to decode JWT claims payload:', err);
|
||||
return {};
|
||||
}
|
||||
if (!token || typeof token !== 'string') return {};
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length < 2) return {};
|
||||
const base64Url = parts[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(
|
||||
window
|
||||
.atob(base64)
|
||||
.split('')
|
||||
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join('')
|
||||
);
|
||||
return JSON.parse(jsonPayload);
|
||||
} catch (err) {
|
||||
console.debug('Failed to decode JWT claims payload:', err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,279 +1,309 @@
|
||||
import React, { useState } from 'react';
|
||||
import { NavLink, Outlet, useNavigate, Link } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Building2,
|
||||
FolderKanban,
|
||||
Coins,
|
||||
ShieldAlert,
|
||||
Settings,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
ChevronRight,
|
||||
ShieldCheck,
|
||||
Layers,
|
||||
Database,
|
||||
Home,
|
||||
User,
|
||||
LayoutDashboard,
|
||||
Building2,
|
||||
FolderKanban,
|
||||
Coins,
|
||||
ShieldAlert,
|
||||
Settings,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
ChevronRight,
|
||||
ShieldCheck,
|
||||
Database,
|
||||
Home,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../../auth/AuthProvider';
|
||||
import { BrandLogo } from '../ui/BrandLogo';
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
name: 'Executive Dashboard',
|
||||
path: '/dashboard',
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
name: 'Governance & Structure',
|
||||
path: '/governance',
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
name: 'Projects & Milestones',
|
||||
path: '/projects',
|
||||
icon: FolderKanban,
|
||||
},
|
||||
{
|
||||
name: 'Grant & Financials',
|
||||
path: '/grants',
|
||||
icon: Coins,
|
||||
},
|
||||
{
|
||||
name: 'M&E, Evidence & Risks',
|
||||
path: '/monitoring',
|
||||
icon: ShieldAlert,
|
||||
},
|
||||
{
|
||||
name: 'Settings & Communications',
|
||||
path: '/settings',
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
name: 'Executive Dashboard',
|
||||
path: '/dashboard',
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
name: 'Governance & Structure',
|
||||
path: '/governance',
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
name: 'Projects & Milestones',
|
||||
path: '/projects',
|
||||
icon: FolderKanban,
|
||||
},
|
||||
{
|
||||
name: 'Grant & Financials',
|
||||
path: '/grants',
|
||||
icon: Coins,
|
||||
},
|
||||
{
|
||||
name: 'M&E, Evidence & Risks',
|
||||
path: '/monitoring',
|
||||
icon: ShieldAlert,
|
||||
},
|
||||
{
|
||||
name: 'Settings & Communications',
|
||||
path: '/settings',
|
||||
icon: Settings,
|
||||
},
|
||||
];
|
||||
|
||||
export const AppLayout: React.FC = () => {
|
||||
const { user, claims, sub, logout } = useAuth();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { user, claims, sub, logout } = useAuth();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
// Extract name and email directly from validated token claims
|
||||
const tokenUserName =
|
||||
user?.name ||
|
||||
(claims.name as string) ||
|
||||
(claims.preferred_username as string) ||
|
||||
(claims.given_name ? `${claims.given_name} ${claims.family_name || ''}`.trim() : '') ||
|
||||
'Authorized User';
|
||||
// Extract name and email directly from validated token claims
|
||||
const tokenUserName =
|
||||
user?.name ||
|
||||
(claims.name as string) ||
|
||||
(claims.preferred_username as string) ||
|
||||
(claims.given_name ? `${claims.given_name} ${claims.family_name || ''}`.trim() : '') ||
|
||||
'Authorized User';
|
||||
|
||||
const tokenUserEmail =
|
||||
user?.email ||
|
||||
(claims.email as string) ||
|
||||
(claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) ||
|
||||
(claims.upn as string) ||
|
||||
(sub ? `sub:${sub}` : '');
|
||||
const tokenUserEmail =
|
||||
user?.email ||
|
||||
(claims.email as string) ||
|
||||
(claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) ||
|
||||
(claims.upn as string) ||
|
||||
(sub ? `sub:${sub}` : '');
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden bg-[#090d16] font-sans text-slate-100 selection:bg-blue-600 selection:text-white">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`${sidebarOpen ? 'w-64' : 'w-20'
|
||||
} shrink-0 bg-[#0b101b]/95 text-slate-200 border-r border-slate-800/80 transition-all duration-300 flex flex-col z-30 shadow-2xl backdrop-blur-md`}
|
||||
>
|
||||
{/* Brand Header — Clickable link navigating to auth-aware Home page */}
|
||||
<div className="h-16 px-4 flex items-center justify-between border-b border-slate-800/80 bg-[#0d121f]/90">
|
||||
<Link
|
||||
to="/"
|
||||
id="nav-brand-home-link"
|
||||
title="Go to Home page"
|
||||
className="flex items-center gap-3 overflow-hidden group cursor-pointer"
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-600 to-indigo-700 font-bold text-white shadow-md shadow-blue-500/20 border border-blue-400/20 group-hover:scale-105 transition">
|
||||
<Layers className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
{sidebarOpen && (
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-semibold text-sm tracking-tight text-white truncate group-hover:text-blue-400 transition">
|
||||
PFM Console
|
||||
</span>
|
||||
<span className="text-[11px] text-blue-400 font-medium tracking-tight">Enterprise Management</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="rounded-lg p-1.5 text-slate-400 hover:text-white hover:bg-slate-800/60 transition cursor-pointer"
|
||||
title={sidebarOpen ? 'Collapse sidebar' : 'Expand sidebar'}
|
||||
>
|
||||
{sidebarOpen ? <X className="w-4 h-4" /> : <Menu className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
// Extract tenant name directly from the user claim named 'tenant'
|
||||
const tenantName =
|
||||
(claims?.tenant as string) ||
|
||||
user?.tenant ||
|
||||
(claims?.['tenant'] as string) ||
|
||||
user?.organisationName ||
|
||||
(claims?.organisation as string) ||
|
||||
'Department of Science, Innovation & Technology (DSI)';
|
||||
|
||||
{/* Navigation Items */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-1.5">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all ${isActive
|
||||
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
|
||||
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
{sidebarOpen && <span className="truncate">{item.name}</span>}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User Card with Token-derived info & Identity Server Logout */}
|
||||
<div className="p-3 border-t border-slate-800/80 bg-[#0d121f]/90">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 shrink-0 rounded-full bg-blue-600/20 border border-blue-500/40 flex items-center justify-center font-bold text-xs text-blue-400">
|
||||
{tokenUserName ? tokenUserName.slice(0, 2).toUpperCase() : 'AU'}
|
||||
</div>
|
||||
{sidebarOpen && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-semibold text-white truncate" title={tokenUserName}>
|
||||
{tokenUserName}
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-400 truncate font-mono" title={tokenUserEmail}>
|
||||
{tokenUserEmail || 'STS Token User'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
id="sidebar-logout-btn"
|
||||
className="rounded-lg p-1.5 text-slate-400 hover:text-rose-400 hover:bg-rose-950/30 transition cursor-pointer"
|
||||
title="Sign Out via Identity Server"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden relative">
|
||||
{/* Top Header */}
|
||||
<header className="h-16 bg-[#0d121f]/90 border-b border-slate-800/80 backdrop-blur-md px-6 md:px-8 flex items-center justify-between shrink-0 z-20">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Clickable Home Link */}
|
||||
<Link
|
||||
to="/"
|
||||
id="header-home-link"
|
||||
className="flex items-center gap-1.5 text-xs font-semibold text-slate-300 hover:text-white bg-slate-900/80 hover:bg-slate-800 px-3 py-1.5 rounded-lg border border-slate-800 transition cursor-pointer shadow-xs"
|
||||
title="Return to Auth-Aware Home page"
|
||||
>
|
||||
<Home className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Home</span>
|
||||
</Link>
|
||||
|
||||
<ChevronRight className="w-4 h-4 text-slate-600" />
|
||||
|
||||
<div className="hidden sm:flex items-center gap-2 text-xs font-medium text-slate-300">
|
||||
<span className="text-slate-400">Tenant:</span>
|
||||
<span className="bg-slate-900/80 text-slate-200 px-2.5 py-1 rounded-lg border border-slate-800 flex items-center gap-1.5 font-medium shadow-xs">
|
||||
<Database className="w-3.5 h-3.5 text-blue-400" />
|
||||
Department of Science, Innovation & Technology (DSI)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ChevronRight className="w-4 h-4 text-slate-600 hidden sm:block" />
|
||||
|
||||
<span className="text-xs font-medium text-emerald-400 bg-emerald-500/10 px-2.5 py-0.5 rounded-full border border-emerald-500/30 flex items-center gap-1.5 shadow-xs">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
||||
Authenticated Session
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* User Name & Email derived from token, plus Identity Server signout */}
|
||||
<div className="relative flex items-center gap-3">
|
||||
<div className="text-right hidden sm:block">
|
||||
<p className="text-xs font-semibold text-white truncate max-w-[200px]" title={tokenUserName}>
|
||||
{tokenUserName}
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-400 font-mono truncate max-w-[200px]" title={tokenUserEmail}>
|
||||
{tokenUserEmail}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
||||
id="header-user-menu-btn"
|
||||
className="h-9 w-9 rounded-full bg-blue-600/20 border border-blue-500/40 hover:border-blue-400 flex items-center justify-center font-bold text-xs text-blue-400 transition cursor-pointer shadow-md shadow-blue-500/10"
|
||||
title="User Account"
|
||||
>
|
||||
{tokenUserName ? tokenUserName.slice(0, 2).toUpperCase() : <User className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{userMenuOpen && (
|
||||
<div
|
||||
className="absolute right-0 top-12 w-64 bg-[#0f1523] border border-slate-800 rounded-2xl shadow-2xl p-3 z-50 space-y-2 animate-in fade-in slide-in-from-top-2 backdrop-blur-md"
|
||||
onMouseLeave={() => setUserMenuOpen(false)}
|
||||
>
|
||||
<div className="p-2.5 border-b border-slate-800">
|
||||
<p className="text-xs font-semibold text-white">{tokenUserName}</p>
|
||||
<p className="text-[11px] text-slate-400 font-mono truncate">{tokenUserEmail}</p>
|
||||
{sub && <p className="text-[10px] text-slate-500 font-mono mt-1">sub: {sub}</p>}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-xs text-slate-300 hover:text-white hover:bg-slate-800/60 rounded-xl transition"
|
||||
>
|
||||
<Home className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Go to Home Page</span>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
id="dropdown-logout-btn"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-rose-400 hover:bg-rose-950/30 rounded-xl transition cursor-pointer"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
<span>Sign Out via Identity Server</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Viewport with Landing Page ambient dot grid and glow */}
|
||||
<main className="flex-1 overflow-y-auto p-6 md:p-8 bg-[#090d16] relative text-slate-100">
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-15"
|
||||
style={{
|
||||
backgroundImage: 'radial-gradient(circle at 1px 1px, rgba(255,255,255,0.15) 1px, transparent 0)',
|
||||
backgroundSize: '32px 32px',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute -top-24 -left-24 w-80 h-80 bg-blue-600/10 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute top-1/2 -right-24 w-80 h-80 bg-indigo-600/10 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Bottom Status Footer */}
|
||||
<footer className="h-10 px-6 md:px-8 border-t border-slate-800/80 bg-[#0d121f]/90 flex items-center justify-between text-[11px] text-slate-400 shrink-0">
|
||||
<span>Project Financial Management (PFM) System</span>
|
||||
<span className="text-slate-400 font-medium">Duende STS OIDC Secure Session</span>
|
||||
</footer>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden bg-[#090d16] font-sans text-slate-100 selection:bg-blue-600 selection:text-white">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`${
|
||||
sidebarOpen ? 'w-64' : 'w-20'
|
||||
} shrink-0 bg-[#0b101b]/95 text-slate-200 border-r border-slate-800/80 transition-all duration-300 flex flex-col z-30 shadow-2xl backdrop-blur-md`}
|
||||
>
|
||||
{/* Brand Header — Clickable link navigating to auth-aware Home page */}
|
||||
<div className="h-16 px-4 flex items-center justify-between border-b border-slate-800/80 bg-[#0d121f]/90">
|
||||
<Link
|
||||
to="/"
|
||||
id="nav-brand-home-link"
|
||||
title="Go to Home page"
|
||||
className="flex items-center gap-3 overflow-hidden group cursor-pointer"
|
||||
>
|
||||
<BrandLogo size={36} showGlow animateOnHover />
|
||||
{sidebarOpen && (
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-bold text-sm tracking-tight text-white truncate group-hover:text-blue-400 transition">
|
||||
PFM Console
|
||||
</span>
|
||||
<span className="text-[11px] text-blue-400 font-medium tracking-tight">Enterprise Management</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="rounded-lg p-1.5 text-slate-400 hover:text-white hover:bg-slate-800/60 transition cursor-pointer"
|
||||
title={sidebarOpen ? 'Collapse sidebar' : 'Expand sidebar'}
|
||||
>
|
||||
{sidebarOpen ? <X className="w-4 h-4" /> : <Menu className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Navigation Items */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-1.5">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all ${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
|
||||
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
{sidebarOpen && <span className="truncate">{item.name}</span>}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User Card with Token-derived info & Identity Server Logout */}
|
||||
<div className="p-3 border-t border-slate-800/80 bg-[#0d121f]/90">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 shrink-0 rounded-full bg-blue-600/20 border border-blue-500/40 flex items-center justify-center font-bold text-xs text-blue-400">
|
||||
{tokenUserName ? tokenUserName.slice(0, 2).toUpperCase() : 'AU'}
|
||||
</div>
|
||||
{sidebarOpen && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-semibold text-white truncate" title={tokenUserName}>
|
||||
{tokenUserName}
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-400 truncate font-mono" title={tokenUserEmail}>
|
||||
{tokenUserEmail || 'Authenticated User'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
id="sidebar-logout-btn"
|
||||
className="rounded-lg p-1.5 text-slate-400 hover:text-rose-400 hover:bg-rose-950/30 transition cursor-pointer"
|
||||
title="Sign Out of Session"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden relative">
|
||||
{/* Top Header */}
|
||||
<header className="h-16 bg-[#0d121f]/90 border-b border-slate-800/80 backdrop-blur-md px-4 sm:px-6 md:px-8 flex items-center justify-between shrink-0 z-20">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Top Bar Brand Icon & Direct Portal Link */}
|
||||
<Link
|
||||
to="/"
|
||||
id="header-brand-logo-link"
|
||||
className="flex items-center gap-2 group cursor-pointer"
|
||||
title="PFM Console Home"
|
||||
>
|
||||
<BrandLogo size={28} showGlow={false} animateOnHover />
|
||||
<span className="font-bold text-xs tracking-tight text-white hidden md:inline-block group-hover:text-blue-400 transition">
|
||||
PFM
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<span className="h-4 w-px bg-slate-800 hidden md:block" />
|
||||
|
||||
{/* Clickable Home Link */}
|
||||
<Link
|
||||
to="/"
|
||||
id="header-home-link"
|
||||
className="flex items-center gap-1.5 text-xs font-semibold text-slate-300 hover:text-white bg-slate-900/80 hover:bg-slate-800 px-3 py-1.5 rounded-lg border border-slate-800 transition cursor-pointer shadow-xs"
|
||||
title="Return to Auth-Aware Home page"
|
||||
>
|
||||
<Home className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Home</span>
|
||||
</Link>
|
||||
|
||||
<ChevronRight className="w-4 h-4 text-slate-600" />
|
||||
|
||||
<div className="hidden sm:flex items-center gap-2 text-xs font-medium text-slate-300">
|
||||
<span className="text-slate-400">Tenant:</span>
|
||||
<span
|
||||
className="bg-slate-900/80 text-slate-200 px-2.5 py-1 rounded-lg border border-slate-800 flex items-center gap-1.5 font-medium shadow-xs max-w-[280px] truncate"
|
||||
title={tenantName}
|
||||
>
|
||||
<Database className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
||||
<span className="truncate">{tenantName}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ChevronRight className="w-4 h-4 text-slate-600 hidden sm:block" />
|
||||
|
||||
<span className="text-xs font-medium text-emerald-400 bg-emerald-500/10 px-2.5 py-0.5 rounded-full border border-emerald-500/30 flex items-center gap-1.5 shadow-xs">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
||||
Authenticated Session
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* User Name & Email derived from token, plus Identity Server signout */}
|
||||
<div className="relative flex items-center gap-3">
|
||||
<div className="text-right hidden sm:block">
|
||||
<p className="text-xs font-semibold text-white truncate max-w-[200px]" title={tokenUserName}>
|
||||
{tokenUserName}
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-400 font-mono truncate max-w-[200px]" title={tokenUserEmail}>
|
||||
{tokenUserEmail}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
||||
id="header-user-menu-btn"
|
||||
className="h-9 w-9 rounded-full bg-blue-600/20 border border-blue-500/40 hover:border-blue-400 flex items-center justify-center font-bold text-xs text-blue-400 transition cursor-pointer shadow-md shadow-blue-500/10"
|
||||
title="User Account"
|
||||
>
|
||||
{tokenUserName ? tokenUserName.slice(0, 2).toUpperCase() : <User className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{userMenuOpen && (
|
||||
<div
|
||||
className="absolute right-0 top-12 w-64 bg-[#0f1523] border border-slate-800 rounded-2xl shadow-2xl p-3 z-50 space-y-2 animate-in fade-in slide-in-from-top-2 backdrop-blur-md"
|
||||
onMouseLeave={() => setUserMenuOpen(false)}
|
||||
>
|
||||
<div className="p-2.5 border-b border-slate-800">
|
||||
<p className="text-xs font-semibold text-white">{tokenUserName}</p>
|
||||
<p className="text-[11px] text-slate-400 font-mono truncate">{tokenUserEmail}</p>
|
||||
{sub && <p className="text-[10px] text-slate-500 font-mono mt-1">sub: {sub}</p>}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-xs text-slate-300 hover:text-white hover:bg-slate-800/60 rounded-xl transition"
|
||||
>
|
||||
<Home className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Go to Home Page</span>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
id="dropdown-logout-btn"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-rose-400 hover:bg-rose-950/30 rounded-xl transition cursor-pointer"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
<span>Sign Out of Session</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Viewport with Landing Page ambient dot grid and glow */}
|
||||
<main className="flex-1 overflow-y-auto p-6 md:p-8 bg-[#090d16] relative text-slate-100">
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-15"
|
||||
style={{
|
||||
backgroundImage: 'radial-gradient(circle at 1px 1px, rgba(255,255,255,0.15) 1px, transparent 0)',
|
||||
backgroundSize: '32px 32px',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute -top-24 -left-24 w-80 h-80 bg-blue-600/10 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute top-1/2 -right-24 w-80 h-80 bg-indigo-600/10 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Bottom Status Footer */}
|
||||
<footer className="h-10 px-6 md:px-8 border-t border-slate-800/80 bg-[#0d121f]/90 flex items-center justify-between text-[11px] text-slate-400 shrink-0">
|
||||
<span>Project Financial Management (PFM) System</span>
|
||||
<span className="text-slate-400 font-medium flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400 inline-block"></span>
|
||||
Enterprise Secure Session
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import React, { useId } from 'react';
|
||||
|
||||
export interface BrandLogoProps {
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | number;
|
||||
variant?: 'icon-only' | 'full';
|
||||
showGlow?: boolean;
|
||||
className?: string;
|
||||
animateOnHover?: boolean;
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
xs: 20,
|
||||
sm: 28,
|
||||
md: 36,
|
||||
lg: 44,
|
||||
xl: 56,
|
||||
};
|
||||
|
||||
/**
|
||||
* Modern, stylized SVG brand emblem representing Post Fund Management (PFM).
|
||||
* Features an isometric kinetic nexus with three interlocking governance facets:
|
||||
* 1. Top Canopy: Governance, Strategy & Portfolios (Radiant Indigo / Violet)
|
||||
* 2. Left Vault: Capital, Grants & Disbursement Assurance (Electric Sapphire)
|
||||
* 3. Right Wing: Milestones, Evidence & Impact Delivery (Cyan to Emerald)
|
||||
* 4. Center Core: Luminous Floating Diamond Milestone Star
|
||||
*/
|
||||
export const BrandLogo: React.FC<BrandLogoProps> = ({
|
||||
size = 'md',
|
||||
variant = 'icon-only',
|
||||
showGlow = true,
|
||||
className = '',
|
||||
animateOnHover = true,
|
||||
}) => {
|
||||
const uniqueId = useId().replace(/:/g, '');
|
||||
const pixelSize = typeof size === 'number' ? size : sizeMap[size] || 36;
|
||||
|
||||
const glowId = `pfm-ambient-${uniqueId}`;
|
||||
const topGradId = `pfm-top-${uniqueId}`;
|
||||
const leftGradId = `pfm-left-${uniqueId}`;
|
||||
const rightGradId = `pfm-right-${uniqueId}`;
|
||||
const coreGradId = `pfm-core-${uniqueId}`;
|
||||
const filterId = `pfm-filter-${uniqueId}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center gap-3 select-none ${
|
||||
animateOnHover ? 'group cursor-pointer' : ''
|
||||
} ${className}`}
|
||||
>
|
||||
<div
|
||||
style={{ width: pixelSize, height: pixelSize }}
|
||||
className={`relative shrink-0 flex items-center justify-center transition-transform duration-300 ${
|
||||
animateOnHover ? 'group-hover:scale-105' : ''
|
||||
}`}
|
||||
>
|
||||
{showGlow && (
|
||||
<div
|
||||
className="absolute inset-0 rounded-2xl bg-blue-500/20 blur-md pointer-events-none transition-opacity duration-300 group-hover:opacity-100 opacity-60"
|
||||
/>
|
||||
)}
|
||||
<svg
|
||||
viewBox="0 0 64 64"
|
||||
width={pixelSize}
|
||||
height={pixelSize}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="relative z-10 w-full h-full drop-shadow-sm"
|
||||
>
|
||||
<defs>
|
||||
{/* Ambient Radial Glow */}
|
||||
<radialGradient id={glowId} cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor="#38bdf8" stopOpacity="0.4" />
|
||||
<stop offset="70%" stopColor="#2563eb" stopOpacity="0.12" />
|
||||
<stop offset="100%" stopColor="#1e1b4b" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
|
||||
{/* Top facet: Governance & Portfolios (Indigo / Radiant Violet) */}
|
||||
<linearGradient id={topGradId} x1="12" y1="8" x2="52" y2="26" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor="#a5b4fc" />
|
||||
<stop offset="35%" stopColor="#6366f1" />
|
||||
<stop offset="100%" stopColor="#4338ca" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Left facet: Capital & Disbursement Vault (Electric Sapphire) */}
|
||||
<linearGradient id={leftGradId} x1="10" y1="22" x2="30" y2="56" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor="#38bdf8" />
|
||||
<stop offset="45%" stopColor="#2563eb" />
|
||||
<stop offset="100%" stopColor="#1e40af" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Right facet: Milestones, Evidence & Impact Delivery (Cyan to Emerald) */}
|
||||
<linearGradient id={rightGradId} x1="34" y1="28" x2="54" y2="56" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor="#22d3ee" />
|
||||
<stop offset="50%" stopColor="#06b6d4" />
|
||||
<stop offset="100%" stopColor="#10b981" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Center Radiant Diamond */}
|
||||
<linearGradient id={coreGradId} x1="27" y1="27" x2="37" y2="37" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor="#ffffff" />
|
||||
<stop offset="40%" stopColor="#e0f2fe" />
|
||||
<stop offset="100%" stopColor="#38bdf8" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Core luminous glow filter */}
|
||||
<filter id={filterId} x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Deep Contrast Foundation Disk */}
|
||||
<circle cx="32" cy="32" r="31" fill="#090d16" />
|
||||
<circle cx="32" cy="32" r="30" stroke="#1e293b" strokeWidth="1" />
|
||||
<circle cx="32" cy="32" r="28" fill={`url(#${glowId})`} />
|
||||
|
||||
{/* Outer Hexagonal Structural Shield Outline */}
|
||||
<path
|
||||
d="M32 6.5 L53.5 19 C55 19.8 56 21.5 56 23.2 L56 40.8 C56 42.5 55 44.2 53.5 45 L32 57.5 C30.5 58.3 28.5 58.3 27 57.5 L5.5 45 C4 44.2 3 42.5 3 40.8 L3 23.2 C3 21.5 4 19.8 5.5 19 L27 6.5 C28.5 5.7 30.5 5.7 32 6.5 Z"
|
||||
stroke="#3b82f6"
|
||||
strokeOpacity="0.35"
|
||||
strokeWidth="1.2"
|
||||
fill="none"
|
||||
/>
|
||||
|
||||
{/* Facet 1: Top Canopy (Governance / Oversight Architecture) */}
|
||||
<path
|
||||
d="M32 9 L51.5 20.2 L43 25.2 L32 18.8 L21 25.2 L12.5 20.2 Z"
|
||||
fill={`url(#${topGradId})`}
|
||||
/>
|
||||
|
||||
{/* Facet 2: Left Vault (Fund Management & Capital Allocation) */}
|
||||
<path
|
||||
d="M10 23 L19.2 28.4 L19.2 44.5 L30 50.8 L30 55.5 L10 43.8 Z"
|
||||
fill={`url(#${leftGradId})`}
|
||||
/>
|
||||
|
||||
{/* Facet 3: Right Wing (Milestone Performance & Impact Assurance) */}
|
||||
<path
|
||||
d="M54 23 L54 43.8 L34 55.5 L34 50.8 L44.8 44.5 L44.8 28.4 Z"
|
||||
fill={`url(#${rightGradId})`}
|
||||
/>
|
||||
|
||||
{/* Center Geometric Hub (The PFM Nexus Plate) */}
|
||||
<path
|
||||
d="M22 28.5 L32 22.8 L42 28.5 L42 35.5 L32 41.2 L22 35.5 Z"
|
||||
fill="#0b1120"
|
||||
stroke="#334155"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
{/* Kinetic Stepped Prisms */}
|
||||
<path
|
||||
d="M25 31.5 L32 27.5 L39 31.5 L32 35.5 Z"
|
||||
fill={`url(#${topGradId})`}
|
||||
opacity="0.9"
|
||||
/>
|
||||
<path
|
||||
d="M25 37 L32 41 L39 37 L32 33 L25 37 Z"
|
||||
fill={`url(#${rightGradId})`}
|
||||
opacity="0.9"
|
||||
/>
|
||||
|
||||
{/* Center Radiant Milestone Diamond */}
|
||||
<polygon
|
||||
points="32,27 36.5,32 32,37 27.5,32"
|
||||
fill={`url(#${coreGradId})`}
|
||||
filter={`url(#${filterId})`}
|
||||
/>
|
||||
|
||||
{/* Center Pure White Optical Star Point */}
|
||||
<circle cx="32" cy="32" r="1.5" fill="#ffffff" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{variant === 'full' && (
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-bold text-sm tracking-tight text-white truncate group-hover:text-blue-400 transition">
|
||||
PFM Console
|
||||
</span>
|
||||
<span className="text-[11px] text-blue-400 font-medium tracking-tight">
|
||||
Post-Fund Governance
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,66 +2,66 @@ import React, { ReactNode, useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: ReactNode;
|
||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: ReactNode;
|
||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
}
|
||||
|
||||
export const Modal: React.FC<ModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
maxWidth = 'lg',
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
maxWidth = 'lg',
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
if (!isOpen) return null;
|
||||
|
||||
const maxWidthClass = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
'2xl': 'max-w-2xl',
|
||||
}[maxWidth];
|
||||
const maxWidthClass = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
'2xl': 'max-w-2xl',
|
||||
}[maxWidth];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-150">
|
||||
<div
|
||||
className={`relative w-full ${maxWidthClass} bg-[#0f1523] rounded-2xl shadow-2xl border border-slate-800/90 overflow-hidden flex flex-col max-h-[90vh] text-slate-200`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between p-5 border-b border-slate-800/80 bg-[#0d121f]/90">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white tracking-tight">{title}</h3>
|
||||
{subtitle && <p className="text-xs text-slate-400 mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-slate-400 hover:text-white hover:bg-slate-800/60 transition cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 overflow-y-auto flex-1 bg-[#0f1523]">{children}</div>
|
||||
</div>
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-150">
|
||||
<div
|
||||
className={`relative w-full ${maxWidthClass} bg-[#0f1523] rounded-2xl shadow-2xl border border-slate-800/90 overflow-hidden flex flex-col max-h-[90vh] text-slate-200`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between p-5 border-b border-slate-800/80 bg-[#0d121f]/90">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white tracking-tight">{title}</h3>
|
||||
{subtitle && <p className="text-xs text-slate-400 mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-slate-400 hover:text-white hover:bg-slate-800/60 transition cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
<div className="p-6 overflow-y-auto flex-1 bg-[#0f1523]">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,76 +4,77 @@ import { CheckCircle2, AlertCircle, Info, X } from 'lucide-react';
|
||||
export type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
interface ToastItem {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
title: string;
|
||||
message?: string;
|
||||
id: string;
|
||||
type: ToastType;
|
||||
title: 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) => void;
|
||||
success: (title: string, message?: string) => void;
|
||||
error: (title: string, message?: string) => void;
|
||||
info: (title: string, message?: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
};
|
||||
const removeToast = (id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
};
|
||||
|
||||
const addToast = (type: ToastType, title: string, message?: string) => {
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
setToasts((prev) => [...prev, { id, type, title, message }]);
|
||||
setTimeout(() => removeToast(id), 5000);
|
||||
};
|
||||
const addToast = (type: ToastType, title: string, message?: string) => {
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
setToasts((prev) => [...prev, { id, type, title, message }]);
|
||||
setTimeout(() => removeToast(id), 5000);
|
||||
};
|
||||
|
||||
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) => addToast('success', title, message);
|
||||
const error = (title: string, message?: string) => addToast('error', title, message);
|
||||
const info = (title: string, message?: string) => addToast('info', title, message);
|
||||
|
||||
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">
|
||||
{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>
|
||||
))}
|
||||
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">
|
||||
{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>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -1,43 +1,382 @@
|
||||
import { updateHasherSettings } from './services/toolsService';
|
||||
|
||||
export interface SecuritySettings {
|
||||
Authority: string;
|
||||
Audience: string;
|
||||
ClientId: string;
|
||||
ClientSecret: string;
|
||||
Authority: string;
|
||||
Audience: string;
|
||||
ClientId: string;
|
||||
ClientSecret: string;
|
||||
Scope?: string;
|
||||
}
|
||||
|
||||
export interface ApiSettings {
|
||||
BaseUrl: string;
|
||||
BaseUrl: string;
|
||||
}
|
||||
|
||||
export interface HasherSettings {
|
||||
Salt: string;
|
||||
MinHashLength: number;
|
||||
}
|
||||
|
||||
export type ConfigSource =
|
||||
| 'runtime-injected'
|
||||
| 'runtime-fetched'
|
||||
| 'build-env'
|
||||
| 'fallback-defaults';
|
||||
|
||||
export interface AppConfig {
|
||||
SecuritySettings: SecuritySettings;
|
||||
ApiSettings: ApiSettings;
|
||||
SecuritySettings: SecuritySettings;
|
||||
ApiSettings: ApiSettings;
|
||||
HasherSettings: HasherSettings;
|
||||
ConfigSource: ConfigSource;
|
||||
LoadedAt?: string;
|
||||
}
|
||||
|
||||
// Fallback presets (used only when no runtime or build-time variable is provided)
|
||||
export const defaultSecuritySettings: SecuritySettings = {
|
||||
Authority: (import.meta.env.VITE_OIDC_AUTHORITY as string) || 'https://sts.security.khongisa.co.za',
|
||||
Audience: (import.meta.env.VITE_OIDC_AUDIENCE as string) || 'pfm-api-dev',
|
||||
ClientId: (import.meta.env.VITE_OIDC_CLIENT_ID as string) || 'pfm-console-dev',
|
||||
ClientSecret: (import.meta.env.VITE_OIDC_CLIENT_SECRET as string) || 'secret_dcaeaa28097d9ba5b06ec324d9eac9d9c81de96e0a203cf2c6154c6dd7c89e94',
|
||||
Authority: 'https://sts.security.khongisa.co.za',
|
||||
Audience: 'pfm-api-dev',
|
||||
ClientId: 'pfm-console-dev',
|
||||
ClientSecret: 'secret_dcaeaa28097d9ba5b06ec324d9eac9d9c81de96e0a203cf2c6154c6dd7c89e94',
|
||||
Scope: 'openid profile roles email address pfm-api-dev',
|
||||
};
|
||||
|
||||
export const defaultApiSettings: ApiSettings = {
|
||||
BaseUrl: (import.meta.env.VITE_API_BASE_URL as string) || 'https://api.ifri.khongisa.co.za/',
|
||||
BaseUrl: 'https://api.ifri.khongisa.co.za/',
|
||||
};
|
||||
|
||||
const runtimeConfig: AppConfig = {
|
||||
SecuritySettings: defaultSecuritySettings,
|
||||
ApiSettings: defaultApiSettings,
|
||||
export const defaultHasherSettings: HasherSettings = {
|
||||
Salt: 'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
|
||||
MinHashLength: 11,
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts first non-empty, trimmed string from candidate values.
|
||||
*/
|
||||
function pickFirstNonEmpty(...candidates: any[]): string | undefined {
|
||||
for (const c of candidates) {
|
||||
if (c !== undefined && c !== null) {
|
||||
const s = String(c).trim();
|
||||
if (s !== '' && s !== 'undefined' && s !== 'null') {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes an API base URL ensuring proper protocol and single trailing slash.
|
||||
*/
|
||||
function normalizeBaseUrl(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
return trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads any runtime config object injected into window.
|
||||
* Supports: window.__RUNTIME_CONFIG__, window.APP_CONFIG, window._env_
|
||||
*/
|
||||
function getWindowRuntimeConfig(): Record<string, any> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
const win = window as any;
|
||||
return win.__RUNTIME_CONFIG__ || win.APP_CONFIG || win._env_ || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and maps configuration from a given source object.
|
||||
* Checks flat keys, nested keys, and ASP.NET double-underscore conventions.
|
||||
*/
|
||||
function extractSourceConfig(src: Record<string, any>): {
|
||||
authority?: string;
|
||||
audience?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
scope?: string;
|
||||
baseUrl?: string;
|
||||
hasherSalt?: string;
|
||||
hasherMinLength?: number;
|
||||
} {
|
||||
if (!src || typeof src !== 'object') return {};
|
||||
|
||||
const authority = pickFirstNonEmpty(
|
||||
src.VITE_OIDC_AUTHORITY,
|
||||
src.OIDC_AUTHORITY,
|
||||
src.SecuritySettings?.Authority,
|
||||
src.SecuritySettings__Authority,
|
||||
src.SecuritySettings_Authority,
|
||||
src.AUTHORITY,
|
||||
src.STS_AUTHORITY
|
||||
);
|
||||
|
||||
const audience = pickFirstNonEmpty(
|
||||
src.VITE_OIDC_AUDIENCE,
|
||||
src.OIDC_AUDIENCE,
|
||||
src.SecuritySettings?.Audience,
|
||||
src.SecuritySettings__Audience,
|
||||
src.SecuritySettings_Audience,
|
||||
src.AUDIENCE
|
||||
);
|
||||
|
||||
const clientId = pickFirstNonEmpty(
|
||||
src.VITE_OIDC_CLIENT_ID,
|
||||
src.OIDC_CLIENT_ID,
|
||||
src.SecuritySettings?.ClientId,
|
||||
src.SecuritySettings__ClientId,
|
||||
src.SecuritySettings_ClientId,
|
||||
src.CLIENT_ID
|
||||
);
|
||||
|
||||
const clientSecret = pickFirstNonEmpty(
|
||||
src.VITE_OIDC_CLIENT_SECRET,
|
||||
src.OIDC_CLIENT_SECRET,
|
||||
src.SecuritySettings?.ClientSecret,
|
||||
src.SecuritySettings__ClientSecret,
|
||||
src.SecuritySettings_ClientSecret,
|
||||
src.CLIENT_SECRET
|
||||
);
|
||||
|
||||
const scope = pickFirstNonEmpty(
|
||||
src.VITE_OIDC_SCOPE,
|
||||
src.OIDC_SCOPE,
|
||||
src.SecuritySettings?.Scope,
|
||||
src.SecuritySettings__Scope,
|
||||
src.SecuritySettings_Scope,
|
||||
src.SCOPE
|
||||
);
|
||||
|
||||
const baseUrl = pickFirstNonEmpty(
|
||||
src.VITE_API_BASE_URL,
|
||||
src.API_BASE_URL,
|
||||
src.ApiSettings?.BaseUrl,
|
||||
src.ApiSettings__BaseUrl,
|
||||
src.ApiSettings_BaseUrl,
|
||||
src.PFM_API_BASE_URL
|
||||
);
|
||||
|
||||
const hasherSalt = pickFirstNonEmpty(
|
||||
src.VITE_HASHER_SALT,
|
||||
src.HASHER_SALT,
|
||||
src.HasherSettings?.Salt,
|
||||
src.HasherSettings__Salt,
|
||||
src.HasherSettings_Salt
|
||||
);
|
||||
|
||||
const rawMinLength = pickFirstNonEmpty(
|
||||
src.VITE_HASHER_MIN_LENGTH,
|
||||
src.HASHER_MIN_LENGTH,
|
||||
src.HasherSettings?.MinHashLength,
|
||||
src.HasherSettings__MinLength,
|
||||
src.HasherSettings_MinLength
|
||||
);
|
||||
|
||||
const hasherMinLength = rawMinLength ? Number(rawMinLength) : undefined;
|
||||
|
||||
return {
|
||||
authority,
|
||||
audience,
|
||||
clientId,
|
||||
clientSecret,
|
||||
scope,
|
||||
baseUrl: baseUrl ? normalizeBaseUrl(baseUrl) : undefined,
|
||||
hasherSalt,
|
||||
hasherMinLength: hasherMinLength && !isNaN(hasherMinLength) && hasherMinLength > 0 ? hasherMinLength : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the prioritized configuration by evaluating:
|
||||
* Tier 1: Injected Runtime Config (window.__RUNTIME_CONFIG__ from docker-entrypoint.sh)
|
||||
* Tier 2: Fetched Runtime Config (/config.json from server)
|
||||
* Tier 3: Vite Build-Time Variables (import.meta.env.VITE_*)
|
||||
* Tier 4: Fallback Defaults
|
||||
*/
|
||||
function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
||||
const windowConfig = extractSourceConfig(getWindowRuntimeConfig());
|
||||
const fetchedConfig = fetchedJson ? extractSourceConfig(fetchedJson) : {};
|
||||
const buildEnvConfig = extractSourceConfig(
|
||||
(typeof import.meta !== 'undefined' && import.meta.env ? import.meta.env : {}) as Record<string, any>
|
||||
);
|
||||
|
||||
// Check if any runtime deployment override was provided
|
||||
const hasInjected = Object.values(windowConfig).some((v) => v !== undefined);
|
||||
const hasFetched = Object.values(fetchedConfig).some((v) => v !== undefined);
|
||||
const hasBuildEnv = Object.values(buildEnvConfig).some((v) => v !== undefined);
|
||||
|
||||
let source: ConfigSource = 'fallback-defaults';
|
||||
if (hasInjected) {
|
||||
source = 'runtime-injected';
|
||||
} else if (hasFetched) {
|
||||
source = 'runtime-fetched';
|
||||
} else if (hasBuildEnv) {
|
||||
source = 'build-env';
|
||||
}
|
||||
|
||||
const authority = pickFirstNonEmpty(
|
||||
windowConfig.authority,
|
||||
fetchedConfig.authority,
|
||||
buildEnvConfig.authority,
|
||||
defaultSecuritySettings.Authority
|
||||
)!;
|
||||
|
||||
const audience = pickFirstNonEmpty(
|
||||
windowConfig.audience,
|
||||
fetchedConfig.audience,
|
||||
buildEnvConfig.audience,
|
||||
defaultSecuritySettings.Audience
|
||||
)!;
|
||||
|
||||
const clientId = pickFirstNonEmpty(
|
||||
windowConfig.clientId,
|
||||
fetchedConfig.clientId,
|
||||
buildEnvConfig.clientId,
|
||||
defaultSecuritySettings.ClientId
|
||||
)!;
|
||||
|
||||
const clientSecret = pickFirstNonEmpty(
|
||||
windowConfig.clientSecret,
|
||||
fetchedConfig.clientSecret,
|
||||
buildEnvConfig.clientSecret,
|
||||
defaultSecuritySettings.ClientSecret
|
||||
)!;
|
||||
|
||||
const scope = pickFirstNonEmpty(
|
||||
windowConfig.scope,
|
||||
fetchedConfig.scope,
|
||||
buildEnvConfig.scope,
|
||||
defaultSecuritySettings.Scope
|
||||
);
|
||||
|
||||
const baseUrl = pickFirstNonEmpty(
|
||||
windowConfig.baseUrl,
|
||||
fetchedConfig.baseUrl,
|
||||
buildEnvConfig.baseUrl,
|
||||
defaultApiSettings.BaseUrl
|
||||
)!;
|
||||
|
||||
const salt = pickFirstNonEmpty(
|
||||
windowConfig.hasherSalt,
|
||||
fetchedConfig.hasherSalt,
|
||||
buildEnvConfig.hasherSalt,
|
||||
defaultHasherSettings.Salt
|
||||
)!;
|
||||
|
||||
const minHashLength =
|
||||
windowConfig.hasherMinLength ||
|
||||
fetchedConfig.hasherMinLength ||
|
||||
buildEnvConfig.hasherMinLength ||
|
||||
defaultHasherSettings.MinHashLength;
|
||||
|
||||
return {
|
||||
SecuritySettings: {
|
||||
Authority: authority,
|
||||
Audience: audience,
|
||||
ClientId: clientId,
|
||||
ClientSecret: clientSecret,
|
||||
Scope: scope,
|
||||
},
|
||||
ApiSettings: {
|
||||
BaseUrl: normalizeBaseUrl(baseUrl),
|
||||
},
|
||||
HasherSettings: {
|
||||
Salt: salt,
|
||||
MinHashLength: minHashLength,
|
||||
},
|
||||
ConfigSource: source,
|
||||
LoadedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Initial synchronous computation from window.__RUNTIME_CONFIG__ and build-time env
|
||||
let runtimeConfig: AppConfig = resolveConfig();
|
||||
|
||||
// Immediately apply deterministic hasher settings
|
||||
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
||||
|
||||
const configListeners: Array<(config: AppConfig) => void> = [];
|
||||
|
||||
export function onConfigChanged(listener: (config: AppConfig) => void): () => void {
|
||||
configListeners.push(listener);
|
||||
return () => {
|
||||
const idx = configListeners.indexOf(listener);
|
||||
if (idx !== -1) configListeners.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
let loadPromise: Promise<AppConfig> | null = null;
|
||||
|
||||
/**
|
||||
* Asynchronously loads and verifies runtime configuration.
|
||||
* Fetches /config.json with cache-busting to ensure latest deployment variables
|
||||
* are prioritized over static build artifacts.
|
||||
*/
|
||||
export async function loadRuntimeConfig(): Promise<AppConfig> {
|
||||
if (loadPromise) return loadPromise;
|
||||
|
||||
loadPromise = (async () => {
|
||||
let fetchedJson: Record<string, any> | undefined;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
// Fetch /config.json with cache-busting to bypass any intermediary caching
|
||||
const res = await fetch(`/config.json?_t=${Date.now()}`, {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data && typeof data === 'object') {
|
||||
fetchedJson = data;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Fetching /config.json is non-blocking; window.__RUNTIME_CONFIG__ or env fallback will be used
|
||||
console.debug('[Config] Note: Remote /config.json fetch bypassed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
runtimeConfig = resolveConfig(fetchedJson);
|
||||
|
||||
// Synchronize deterministic hasher settings
|
||||
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
||||
|
||||
console.info(
|
||||
`[Config] Active runtime configuration: source=${runtimeConfig.ConfigSource}, API=${runtimeConfig.ApiSettings.BaseUrl}, STS=${runtimeConfig.SecuritySettings.Authority}`
|
||||
);
|
||||
|
||||
// Notify registered listeners
|
||||
configListeners.forEach((fn) => {
|
||||
try {
|
||||
fn(runtimeConfig);
|
||||
} catch (e) {
|
||||
console.error('[Config] Listener error:', e);
|
||||
}
|
||||
});
|
||||
|
||||
return runtimeConfig;
|
||||
})();
|
||||
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
export function getAppConfig(): AppConfig {
|
||||
return runtimeConfig;
|
||||
}
|
||||
|
||||
export function getSecuritySettings(): SecuritySettings {
|
||||
return runtimeConfig.SecuritySettings;
|
||||
return runtimeConfig.SecuritySettings;
|
||||
}
|
||||
|
||||
export function getApiBaseUrl(): string {
|
||||
return runtimeConfig.ApiSettings.BaseUrl;
|
||||
return runtimeConfig.ApiSettings.BaseUrl;
|
||||
}
|
||||
|
||||
export function getHasherSettings(): HasherSettings {
|
||||
return runtimeConfig.HasherSettings;
|
||||
}
|
||||
|
||||
export function getConfigSource(): ConfigSource {
|
||||
return runtimeConfig.ConfigSource;
|
||||
}
|
||||
|
||||
@@ -1,44 +1,58 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Plus Jakarta Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--font-sans: 'Plus Jakarta Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #0a0a0a;
|
||||
color: #e5e7eb;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
body {
|
||||
background-color: #090d16;
|
||||
color: #e2e8f0;
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Form control color scheme */
|
||||
input, select, textarea {
|
||||
color-scheme: dark;
|
||||
}
|
||||
button, input, select, textarea {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Custom dark scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.font-mono, code, kbd, samp, pre {
|
||||
font-family: var(--font-mono) !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #0a0a0a;
|
||||
}
|
||||
/* Form control color scheme */
|
||||
input, select, textarea {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #262626;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #3f3f46;
|
||||
}
|
||||
/* Custom dark scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #0a0a0a;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #262626;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #3f3f46;
|
||||
}
|
||||
}
|
||||
|
||||
/* High Contrast Dark Theme Adaptations for existing utility classes */
|
||||
.bg-white {
|
||||
background-color: #111111 !important;
|
||||
background-color: #111111 !important;
|
||||
}
|
||||
|
||||
.bg-slate-50,
|
||||
@@ -47,210 +61,211 @@
|
||||
.bg-slate-50\/75,
|
||||
.bg-slate-50\/80,
|
||||
.bg-slate-50\/90 {
|
||||
background-color: #161616 !important;
|
||||
background-color: #161616 !important;
|
||||
}
|
||||
|
||||
.bg-slate-100,
|
||||
.bg-slate-200 {
|
||||
background-color: #202020 !important;
|
||||
background-color: #202020 !important;
|
||||
}
|
||||
|
||||
.border-slate-100 {
|
||||
border-color: #222222 !important;
|
||||
border-color: #222222 !important;
|
||||
}
|
||||
|
||||
.border-slate-200 {
|
||||
border-color: #2d2d2d !important;
|
||||
border-color: #2d2d2d !important;
|
||||
}
|
||||
|
||||
.border-slate-300 {
|
||||
border-color: #3d3d3d !important;
|
||||
border-color: #3d3d3d !important;
|
||||
}
|
||||
|
||||
.divide-slate-100 > :not([hidden]) ~ :not([hidden]),
|
||||
.divide-slate-200 > :not([hidden]) ~ :not([hidden]) {
|
||||
border-color: #242424 !important;
|
||||
border-color: #242424 !important;
|
||||
}
|
||||
|
||||
/* Text contrast enhancements */
|
||||
.text-slate-900 {
|
||||
color: #ffffff !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.text-slate-800 {
|
||||
color: #f8fafc !important;
|
||||
color: #f8fafc !important;
|
||||
}
|
||||
|
||||
.text-slate-700 {
|
||||
color: #f1f5f9 !important;
|
||||
color: #f1f5f9 !important;
|
||||
}
|
||||
|
||||
.text-slate-600 {
|
||||
color: #e2e8f0 !important;
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
.text-slate-500 {
|
||||
color: #cbd5e1 !important;
|
||||
color: #cbd5e1 !important;
|
||||
}
|
||||
|
||||
.text-slate-400 {
|
||||
color: #94a3b8 !important;
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
|
||||
.text-gray-900 {
|
||||
color: #ffffff !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.text-gray-800 {
|
||||
color: #f8fafc !important;
|
||||
color: #f8fafc !important;
|
||||
}
|
||||
|
||||
.text-gray-700 {
|
||||
color: #f1f5f9 !important;
|
||||
color: #f1f5f9 !important;
|
||||
}
|
||||
|
||||
.text-gray-600 {
|
||||
color: #e2e8f0 !important;
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
.text-gray-500 {
|
||||
color: #cbd5e1 !important;
|
||||
color: #cbd5e1 !important;
|
||||
}
|
||||
|
||||
.text-gray-400 {
|
||||
color: #94a3b8 !important;
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
|
||||
/* High-contrast status badges and notification banners */
|
||||
.bg-emerald-50,
|
||||
.bg-emerald-50\/40,
|
||||
.bg-emerald-50\/60 {
|
||||
background-color: rgba(16, 185, 129, 0.16) !important;
|
||||
background-color: rgba(16, 185, 129, 0.16) !important;
|
||||
}
|
||||
|
||||
.text-emerald-700,
|
||||
.text-emerald-800,
|
||||
.text-emerald-900 {
|
||||
color: #6ee7b7 !important;
|
||||
color: #6ee7b7 !important;
|
||||
}
|
||||
|
||||
.border-emerald-200,
|
||||
.border-emerald-300 {
|
||||
border-color: rgba(110, 231, 183, 0.35) !important;
|
||||
border-color: rgba(110, 231, 183, 0.35) !important;
|
||||
}
|
||||
|
||||
.bg-amber-50,
|
||||
.bg-amber-50\/40,
|
||||
.bg-amber-50\/60 {
|
||||
background-color: rgba(245, 158, 11, 0.16) !important;
|
||||
background-color: rgba(245, 158, 11, 0.16) !important;
|
||||
}
|
||||
|
||||
.text-amber-700,
|
||||
.text-amber-800,
|
||||
.text-amber-900 {
|
||||
color: #fcd34d !important;
|
||||
color: #fcd34d !important;
|
||||
}
|
||||
|
||||
.border-amber-200,
|
||||
.border-amber-300 {
|
||||
border-color: rgba(252, 211, 77, 0.35) !important;
|
||||
border-color: rgba(252, 211, 77, 0.35) !important;
|
||||
}
|
||||
|
||||
.bg-rose-50,
|
||||
.bg-rose-50\/40,
|
||||
.bg-rose-50\/60 {
|
||||
background-color: rgba(244, 63, 94, 0.16) !important;
|
||||
background-color: rgba(244, 63, 94, 0.16) !important;
|
||||
}
|
||||
|
||||
.text-rose-700,
|
||||
.text-rose-800,
|
||||
.text-rose-900 {
|
||||
color: #fda4af !important;
|
||||
color: #fda4af !important;
|
||||
}
|
||||
|
||||
.border-rose-200,
|
||||
.border-rose-300 {
|
||||
border-color: rgba(253, 164, 175, 0.35) !important;
|
||||
border-color: rgba(253, 164, 175, 0.35) !important;
|
||||
}
|
||||
|
||||
.bg-purple-50,
|
||||
.bg-indigo-50 {
|
||||
background-color: rgba(139, 92, 246, 0.16) !important;
|
||||
background-color: rgba(139, 92, 246, 0.16) !important;
|
||||
}
|
||||
|
||||
.text-purple-700,
|
||||
.text-purple-800,
|
||||
.text-indigo-700,
|
||||
.text-indigo-800 {
|
||||
color: #d8b4fe !important;
|
||||
color: #d8b4fe !important;
|
||||
}
|
||||
|
||||
.border-purple-200,
|
||||
.border-purple-300,
|
||||
.border-indigo-200,
|
||||
.border-indigo-300 {
|
||||
border-color: rgba(216, 180, 254, 0.35) !important;
|
||||
border-color: rgba(216, 180, 254, 0.35) !important;
|
||||
}
|
||||
|
||||
.bg-sky-50,
|
||||
.bg-blue-50 {
|
||||
background-color: rgba(59, 130, 246, 0.16) !important;
|
||||
background-color: rgba(59, 130, 246, 0.16) !important;
|
||||
}
|
||||
|
||||
.text-sky-700,
|
||||
.text-sky-800,
|
||||
.text-blue-700,
|
||||
.text-blue-800 {
|
||||
color: #93c5fd !important;
|
||||
color: #93c5fd !important;
|
||||
}
|
||||
|
||||
.border-sky-200,
|
||||
.border-sky-300,
|
||||
.border-blue-200,
|
||||
.border-blue-300 {
|
||||
border-color: rgba(147, 197, 253, 0.35) !important;
|
||||
border-color: rgba(147, 197, 253, 0.35) !important;
|
||||
}
|
||||
|
||||
/* Form inputs, textareas, and select controls */
|
||||
input:not([type="checkbox"]):not([type="radio"]):not([type="submit"]):not([type="button"]),
|
||||
select,
|
||||
textarea {
|
||||
background-color: #141414 !important;
|
||||
color: #ffffff !important;
|
||||
border-color: #383838 !important;
|
||||
background-color: #141414 !important;
|
||||
color: #ffffff !important;
|
||||
border-color: #383838 !important;
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
|
||||
/* Table header text high contrast */
|
||||
th {
|
||||
color: #cbd5e1 !important;
|
||||
color: #cbd5e1 !important;
|
||||
}
|
||||
|
||||
.hover\:bg-slate-50:hover,
|
||||
.hover\:bg-slate-50\/80:hover,
|
||||
.hover\:bg-slate-100:hover {
|
||||
background-color: #1f1f1f !important;
|
||||
background-color: #1f1f1f !important;
|
||||
}
|
||||
|
||||
.hover\:text-slate-800:hover,
|
||||
.hover\:text-slate-900:hover {
|
||||
color: #ffffff !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Primary actions matching the clean blue/indigo accent */
|
||||
.bg-emerald-600 {
|
||||
background-color: #2563eb !important;
|
||||
background-color: #2563eb !important;
|
||||
}
|
||||
|
||||
.hover\:bg-emerald-500:hover,
|
||||
.hover\:bg-emerald-700:hover {
|
||||
background-color: #1d4ed8 !important;
|
||||
background-color: #1d4ed8 !important;
|
||||
}
|
||||
|
||||
.border-emerald-600 {
|
||||
border-color: #3b82f6 !important;
|
||||
border-color: #3b82f6 !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 3, // 3 minutes default cache
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 3, // 3 minutes default cache
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,9 +2,14 @@ import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
import { loadRuntimeConfig } from './config';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
// Load runtime environment configuration before mounting the application
|
||||
loadRuntimeConfig().finally(() => {
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,270 +1,287 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ShieldCheck,
|
||||
Lock,
|
||||
ArrowRight,
|
||||
Layers,
|
||||
CheckCircle2,
|
||||
Coins,
|
||||
FileSpreadsheet,
|
||||
BarChart3,
|
||||
KeyRound,
|
||||
LogOut,
|
||||
UserCheck,
|
||||
ExternalLink,
|
||||
ChevronRight,
|
||||
ShieldCheck,
|
||||
Lock,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Coins,
|
||||
FileSpreadsheet,
|
||||
BarChart3,
|
||||
KeyRound,
|
||||
LogOut,
|
||||
UserCheck,
|
||||
ExternalLink,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import { BrandLogo } from '../components/ui/BrandLogo';
|
||||
|
||||
export const LandingPage: React.FC = () => {
|
||||
const { isAuthenticated, user, claims, sub, login, logout, authError, isLoading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated, user, claims, sub, login, logout, authError, isLoading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleEnterConsole = async () => {
|
||||
if (isAuthenticated) {
|
||||
navigate('/dashboard');
|
||||
return;
|
||||
}
|
||||
await login();
|
||||
};
|
||||
const handleEnterConsole = async () => {
|
||||
if (isAuthenticated) {
|
||||
navigate('/dashboard');
|
||||
return;
|
||||
}
|
||||
await login();
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await logout();
|
||||
};
|
||||
const handleSignOut = async () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
const tokenUserName = user?.name || (claims.name as string) || (claims.preferred_username as string) || 'Authorized User';
|
||||
const tokenUserEmail = user?.email || (claims.email as string) || (claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) || '';
|
||||
const tokenUserName = user?.name || (claims.name as string) || (claims.preferred_username as string) || 'Authorized User';
|
||||
const tokenUserEmail = user?.email || (claims.email as string) || (claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) || '';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#090d16] text-slate-100 flex flex-col justify-between selection:bg-blue-600 selection:text-white font-sans relative overflow-hidden">
|
||||
{/* Background Graphic Grid & Ambient Glows */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-20"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 1px 1px, rgba(255,255,255,0.15) 1px, transparent 0)`,
|
||||
backgroundSize: '32px 32px',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute -top-40 -left-40 w-96 h-96 bg-blue-600/20 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute top-1/3 -right-40 w-96 h-96 bg-indigo-600/15 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute -bottom-40 left-1/3 w-96 h-96 bg-emerald-600/10 rounded-full blur-3xl pointer-events-none" />
|
||||
return (
|
||||
<div className="min-h-screen bg-[#090d16] text-slate-100 flex flex-col justify-between selection:bg-blue-600 selection:text-white font-sans relative overflow-hidden">
|
||||
{/* Background Graphic Grid & Ambient Glows */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-20"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 1px 1px, rgba(255,255,255,0.15) 1px, transparent 0)`,
|
||||
backgroundSize: '32px 32px',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute -top-40 -left-40 w-96 h-96 bg-blue-600/20 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute top-1/3 -right-40 w-96 h-96 bg-indigo-600/15 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute -bottom-40 left-1/3 w-96 h-96 bg-emerald-600/10 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
{/* Top Header */}
|
||||
<header className="relative z-10 border-b border-slate-800/80 bg-[#0d121f]/80 backdrop-blur-md px-6 py-4 flex items-center justify-between shadow-lg shadow-black/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-xl bg-gradient-to-br from-blue-600 to-indigo-700 flex items-center justify-center font-bold text-white shadow-md shadow-blue-500/20 border border-blue-400/20">
|
||||
<Layers className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-base font-bold tracking-tight text-white flex items-center gap-2">
|
||||
PFM Console
|
||||
<span className="text-[10px] font-semibold uppercase bg-blue-500/10 text-blue-400 border border-blue-500/30 px-2 py-0.5 rounded font-mono">
|
||||
Enterprise STS
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xs text-slate-400">Post-Fund Management, Performance & Impact System</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{isAuthenticated ? (
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
id="header-nav-console-btn"
|
||||
className="flex items-center gap-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 px-3.5 py-1.5 rounded-lg shadow-md shadow-blue-600/20 transition cursor-pointer"
|
||||
>
|
||||
<span>Go to Console</span>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400 font-medium bg-slate-900/60 border border-slate-800 px-3 py-1.5 rounded-full">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||
<span>Duende STS Ready</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="relative z-10 flex-1 max-w-6xl mx-auto w-full px-6 py-12 flex flex-col justify-center">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-center">
|
||||
{/* Left Column: Mission & Highlights */}
|
||||
<div className="lg:col-span-7 space-y-6">
|
||||
<div className="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full bg-blue-500/10 border border-blue-500/30 text-blue-300 text-xs font-semibold">
|
||||
<ShieldCheck className="w-4 h-4 text-blue-400" />
|
||||
Controlled Post-Award to Portfolio Governance
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-extrabold text-white tracking-tight leading-tight">
|
||||
Post Fund Management <br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 via-indigo-300 to-teal-300">
|
||||
Executive Console
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<p className="text-sm sm:text-base text-slate-300 leading-relaxed max-w-2xl font-normal">
|
||||
Empowers Funders, Science Councils, and Development Agencies to enforce rigorous governance,
|
||||
milestone-linked disbursements, evidence verification, and real-time portfolio performance from
|
||||
award intake through final close-out.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2 text-xs">
|
||||
<div className="p-4 rounded-xl bg-slate-900/70 border border-slate-800/80 shadow-md flex items-start gap-3 backdrop-blur-sm">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<strong className="text-white block font-semibold mb-0.5">Award to Contract Enforcement</strong>
|
||||
<span className="text-slate-400 leading-normal">Legal agreements linked directly to approved awards.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-slate-900/70 border border-slate-800/80 shadow-md flex items-start gap-3 backdrop-blur-sm">
|
||||
<Coins className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<strong className="text-white block font-semibold mb-0.5">Controlled Tranche Release</strong>
|
||||
<span className="text-slate-400 leading-normal">Disbursements released only when milestone conditions are verified.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-slate-900/70 border border-slate-800/80 shadow-md flex items-start gap-3 backdrop-blur-sm">
|
||||
<FileSpreadsheet className="w-4 h-4 text-indigo-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<strong className="text-white block font-semibold mb-0.5">Evidence Verification</strong>
|
||||
<span className="text-slate-400 leading-normal">Deliverables require tamper-evident verification.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-slate-900/70 border border-slate-800/80 shadow-md flex items-start gap-3 backdrop-blur-sm">
|
||||
<BarChart3 className="w-4 h-4 text-teal-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<strong className="text-white block font-semibold mb-0.5">Automated Portfolio Roll-Up</strong>
|
||||
<span className="text-slate-400 leading-normal">Project data aggregates automatically to Programmes and Portfolios.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Auth-Aware Box */}
|
||||
<div className="lg:col-span-5">
|
||||
{isAuthenticated ? (
|
||||
/* Auth-Aware Home Guidance Panel */
|
||||
<div className="bg-[#0f1523]/95 border border-emerald-500/30 rounded-2xl p-8 shadow-2xl relative overflow-hidden space-y-6 backdrop-blur-md">
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-emerald-500 via-teal-400 to-blue-500" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-emerald-400 animate-pulse" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-emerald-400">
|
||||
Active Authenticated Session
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono bg-emerald-500/10 text-emerald-300 border border-emerald-500/30 px-2 py-0.5 rounded">
|
||||
STS Token Active
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Identity Information from Token */}
|
||||
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800 space-y-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-blue-600/20 border border-blue-500/40 flex items-center justify-center font-bold text-sm text-blue-400 shrink-0">
|
||||
<UserCheck className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold text-white truncate">{tokenUserName}</p>
|
||||
<p className="text-xs text-slate-400 font-mono truncate">{tokenUserEmail || 'No email in token claims'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sub && (
|
||||
<div className="pt-2 border-t border-slate-800/80 text-[11px] flex items-center justify-between text-slate-400">
|
||||
<span>Token Subject (sub):</span>
|
||||
<span className="font-mono text-slate-300 truncate max-w-[200px]">{sub}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Guidance Message */}
|
||||
<div className="p-4 rounded-xl bg-blue-950/30 border border-blue-800/40 text-xs text-blue-200 leading-relaxed">
|
||||
<p className="font-semibold text-white mb-1">Session Verified</p>
|
||||
You are authenticated through Duende Identity Server. Your token claims are validated and active. You can proceed to the PFM Console to manage projects, reviews, and disbursements, or sign out through the identity server.
|
||||
</div>
|
||||
|
||||
{/* Navigation and Sign Out Buttons */}
|
||||
<div className="space-y-3 pt-1">
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
id="btn-return-to-console"
|
||||
className="w-full py-3.5 px-5 rounded-xl bg-blue-600 hover:bg-blue-500 font-semibold text-sm text-white shadow-lg shadow-blue-600/25 flex items-center justify-center gap-2 transition cursor-pointer"
|
||||
>
|
||||
<span>Return to PFM Console</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
id="btn-logout-identity-server"
|
||||
className="w-full py-2.5 px-4 rounded-xl bg-slate-900 hover:bg-rose-950/40 hover:text-rose-300 text-slate-300 border border-slate-800 hover:border-rose-800/60 font-medium text-xs flex items-center justify-center gap-2 transition cursor-pointer"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
<span>Sign Out via Identity Server</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-slate-800/80 text-[11px] text-slate-500 flex items-center justify-between">
|
||||
<span className="flex items-center gap-1">
|
||||
<KeyRound className="w-3.5 h-3.5 text-slate-400" />
|
||||
Duende STS
|
||||
</span>
|
||||
<span className="font-mono text-slate-400 text-[10px]">Audience: pfm-api-dev</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Unauthenticated Sign In Box */
|
||||
<div className="bg-[#0f1523]/90 border border-slate-800 rounded-2xl p-8 shadow-2xl relative overflow-hidden space-y-6 backdrop-blur-md">
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-blue-500 via-indigo-500 to-teal-400" />
|
||||
|
||||
<div className="text-center space-y-2 pt-2">
|
||||
<div className="mx-auto h-12 w-12 rounded-xl bg-blue-500/10 border border-blue-500/30 flex items-center justify-center text-blue-400 shadow-inner">
|
||||
<Lock className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white tracking-tight">Sign In</h3>
|
||||
</div>
|
||||
|
||||
{authError && (
|
||||
<div className="p-3.5 rounded-xl bg-rose-950/40 border border-rose-800/60 text-rose-300 text-xs leading-relaxed">
|
||||
{authError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={handleEnterConsole}
|
||||
disabled={isLoading}
|
||||
id="btn-enter-pfm-console"
|
||||
className="w-full py-3.5 px-5 rounded-xl bg-blue-600 hover:bg-blue-500 font-semibold text-sm text-white shadow-lg shadow-blue-600/25 flex items-center justify-center gap-2 transition cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Connecting to Identity Server...' : 'Enter PFM Console'}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-slate-800/80 text-[11px] text-slate-500 flex items-center justify-between">
|
||||
<span className="flex items-center gap-1">
|
||||
<KeyRound className="w-3.5 h-3.5 text-slate-400" />
|
||||
Duende STS OIDC
|
||||
</span>
|
||||
<span className="font-mono text-slate-400 text-[10px]">pfm-api-dev</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="relative z-10 border-t border-slate-800/80 bg-[#0d121f]/80 backdrop-blur-md px-6 py-4 text-center text-xs text-slate-500">
|
||||
<p>Post-Fund Management, Performance & Impact System • Enterprise Governance Console</p>
|
||||
</footer>
|
||||
{/* Top Header */}
|
||||
<header className="relative z-10 border-b border-slate-800/80 bg-[#0d121f]/80 backdrop-blur-md px-6 py-4 flex items-center justify-between shadow-lg shadow-black/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<BrandLogo size={40} showGlow animateOnHover />
|
||||
<div>
|
||||
<h1 className="text-base font-bold tracking-tight text-white flex items-center gap-2">
|
||||
PFM Console
|
||||
<span className="text-[10px] font-semibold uppercase bg-blue-500/10 text-blue-400 border border-blue-500/30 px-2 py-0.5 rounded font-mono">
|
||||
Enterprise SSO
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xs text-slate-400">Post-Fund Management, Performance & Impact System</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{isAuthenticated ? (
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
id="header-nav-console-btn"
|
||||
className="flex items-center gap-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 px-3.5 py-1.5 rounded-lg shadow-md shadow-blue-600/20 transition cursor-pointer"
|
||||
>
|
||||
<span>Go to Console</span>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400 font-medium bg-slate-900/60 border border-slate-800 px-3 py-1.5 rounded-full">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||
<span>Single Sign-On Ready</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="relative z-10 flex-1 max-w-6xl mx-auto w-full px-6 py-12 flex flex-col justify-center">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-center">
|
||||
{/* Left Column: Mission & Highlights */}
|
||||
<div className="lg:col-span-7 space-y-6">
|
||||
<div className="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full bg-blue-500/10 border border-blue-500/30 text-blue-300 text-xs font-semibold">
|
||||
<ShieldCheck className="w-4 h-4 text-blue-400" />
|
||||
Controlled Post-Award to Portfolio Governance
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-extrabold text-white tracking-tight leading-tight">
|
||||
Post Fund Management <br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 via-indigo-300 to-teal-300">
|
||||
Executive Console
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<p className="text-sm sm:text-base text-slate-300 leading-relaxed max-w-2xl font-normal">
|
||||
Empowers Funders, Science Councils, and Development Agencies to enforce rigorous governance,
|
||||
milestone-linked disbursements, evidence verification, and real-time portfolio performance from
|
||||
award intake through final close-out.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2 text-xs">
|
||||
<div className="p-4 rounded-xl bg-slate-900/70 border border-slate-800/80 shadow-md flex items-start gap-3 backdrop-blur-sm">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<strong className="text-white block font-semibold mb-0.5">Award to Contract Enforcement</strong>
|
||||
<span className="text-slate-400 leading-normal">Legal agreements linked directly to approved awards.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-slate-900/70 border border-slate-800/80 shadow-md flex items-start gap-3 backdrop-blur-sm">
|
||||
<Coins className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<strong className="text-white block font-semibold mb-0.5">Controlled Tranche Release</strong>
|
||||
<span className="text-slate-400 leading-normal">Disbursements released only when milestone conditions are verified.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-slate-900/70 border border-slate-800/80 shadow-md flex items-start gap-3 backdrop-blur-sm">
|
||||
<FileSpreadsheet className="w-4 h-4 text-indigo-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<strong className="text-white block font-semibold mb-0.5">Evidence Verification</strong>
|
||||
<span className="text-slate-400 leading-normal">Deliverables require tamper-evident verification.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-slate-900/70 border border-slate-800/80 shadow-md flex items-start gap-3 backdrop-blur-sm">
|
||||
<BarChart3 className="w-4 h-4 text-teal-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<strong className="text-white block font-semibold mb-0.5">Automated Portfolio Roll-Up</strong>
|
||||
<span className="text-slate-400 leading-normal">Project data aggregates automatically to Programmes and Portfolios.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Auth-Aware Box */}
|
||||
<div className="lg:col-span-5">
|
||||
{isAuthenticated ? (
|
||||
/* Auth-Aware Home Guidance Panel */
|
||||
<div className="bg-[#0f1523]/95 border border-emerald-500/30 rounded-2xl p-8 shadow-2xl relative overflow-hidden space-y-6 backdrop-blur-md">
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-emerald-500 via-teal-400 to-blue-500" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-emerald-400 animate-pulse" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-emerald-400">
|
||||
Active Authenticated Session
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono bg-emerald-500/10 text-emerald-300 border border-emerald-500/30 px-2 py-0.5 rounded">
|
||||
Session Active
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Identity Information from Token */}
|
||||
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800 space-y-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-blue-600/20 border border-blue-500/40 flex items-center justify-center font-bold text-sm text-blue-400 shrink-0">
|
||||
<UserCheck className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold text-white truncate">{tokenUserName}</p>
|
||||
<p className="text-xs text-slate-400 font-mono truncate">{tokenUserEmail || 'No email in token claims'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sub && (
|
||||
<div className="pt-2 border-t border-slate-800/80 text-[11px] flex items-center justify-between text-slate-400">
|
||||
<span>Token Subject (sub):</span>
|
||||
<span className="font-mono text-slate-300 truncate max-w-[200px]">{sub}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{((claims.tenant as string) || user?.tenant) && (
|
||||
<div className="pt-2 border-t border-slate-800/80 text-[11px] flex items-center justify-between text-slate-400">
|
||||
<span>Assigned Tenant:</span>
|
||||
<span className="font-semibold text-blue-300 truncate max-w-[200px]">
|
||||
{(claims.tenant as string) || user?.tenant}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Guidance Message */}
|
||||
<div className="p-4 rounded-xl bg-blue-950/30 border border-blue-800/40 text-xs text-blue-200 leading-relaxed">
|
||||
<p className="font-semibold text-white mb-1">Session Verified</p>
|
||||
You are authenticated with validated enterprise security credentials. Your token claims are active and verified. You can proceed to the PFM Console to manage projects, reviews, and disbursements, or sign out of your session.
|
||||
</div>
|
||||
|
||||
{/* Navigation and Sign Out Buttons */}
|
||||
<div className="space-y-3 pt-1">
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
id="btn-return-to-console"
|
||||
className="w-full py-3.5 px-5 rounded-xl bg-blue-600 hover:bg-blue-500 font-semibold text-sm text-white shadow-lg shadow-blue-600/25 flex items-center justify-center gap-2 transition cursor-pointer"
|
||||
>
|
||||
<span>Return to PFM Console</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
id="btn-logout-identity-server"
|
||||
className="w-full py-2.5 px-4 rounded-xl bg-slate-900 hover:bg-rose-950/40 hover:text-rose-300 text-slate-300 border border-slate-800 hover:border-rose-800/60 font-medium text-xs flex items-center justify-center gap-2 transition cursor-pointer"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
<span>Sign Out of Session</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-slate-800/80 text-[11px] text-slate-500 flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<KeyRound className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>OAuth2 / OpenID Connect</span>
|
||||
</span>
|
||||
<span className="text-emerald-400 font-medium text-[11px] flex items-center gap-1">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400 inline-block"></span>
|
||||
Verified Session
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Unauthenticated Sign In Box */
|
||||
<div className="bg-[#0f1523]/90 border border-slate-800 rounded-2xl p-8 shadow-2xl relative overflow-hidden space-y-6 backdrop-blur-md">
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-blue-500 via-indigo-500 to-teal-400" />
|
||||
|
||||
<div className="text-center space-y-3 pt-2">
|
||||
<div className="flex justify-center">
|
||||
<BrandLogo size={48} showGlow animateOnHover={false} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-white tracking-tight">Sign In</h3>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Enterprise Identity & Access Management</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{authError && (
|
||||
<div className="p-3.5 rounded-xl bg-rose-950/40 border border-rose-800/60 text-rose-300 text-xs leading-relaxed">
|
||||
{authError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={handleEnterConsole}
|
||||
disabled={isLoading}
|
||||
id="btn-enter-pfm-console"
|
||||
className="w-full py-3.5 px-5 rounded-xl bg-blue-600 hover:bg-blue-500 font-semibold text-sm text-white shadow-lg shadow-blue-600/25 flex items-center justify-center gap-2 transition cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Connecting to Identity Server...' : 'Enter PFM Console'}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-slate-800/80 text-[11px] text-slate-500 flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<KeyRound className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span>Single Sign-On (SSO)</span>
|
||||
</span>
|
||||
<span className="text-slate-400 text-[11px]">Enterprise Security</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="relative z-10 border-t border-slate-800/80 bg-[#0d121f]/80 backdrop-blur-md px-6 py-4 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-slate-500">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<BrandLogo size={20} showGlow={false} animateOnHover={false} />
|
||||
<span className="text-slate-400 font-medium">Post-Fund Management, Performance & Impact System</span>
|
||||
</div>
|
||||
<p className="text-slate-500">Enterprise Governance & Audit Console</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,24 +5,24 @@ export const BASE_API_URL = getApiBaseUrl();
|
||||
|
||||
// Create Axios Instance
|
||||
export const apiClient = axios.create({
|
||||
baseURL: BASE_API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; version=1',
|
||||
Accept: 'application/json; version=1',
|
||||
},
|
||||
timeout: 15000,
|
||||
baseURL: BASE_API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; version=1',
|
||||
Accept: 'application/json; version=1',
|
||||
},
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// Request Interceptor to dynamically set baseURL and attach Bearer Token
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// Dynamically synchronize baseURL from runtime configuration
|
||||
config.baseURL = getApiBaseUrl();
|
||||
const token = localStorage.getItem('pfm_access_token');
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// Dynamically synchronize baseURL from runtime configuration
|
||||
config.baseURL = getApiBaseUrl();
|
||||
const token = localStorage.getItem('pfm_access_token');
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
@@ -1,196 +1,210 @@
|
||||
import Hashids from 'hashids';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from './httpClient';
|
||||
import { queryClient } from '../lib/queryClient';
|
||||
|
||||
const env = (typeof import.meta !== 'undefined' && import.meta.env
|
||||
? import.meta.env
|
||||
: {}) as Record<string, string | undefined>;
|
||||
|
||||
/**
|
||||
* Deterministic fallback encoder for offline/preview sessions
|
||||
* Generates an obfuscated, unique HashID string with salt
|
||||
* Deterministic Hashids configuration matching pfm-api HasherSettings.
|
||||
* Ensures 100% interoperability with the C# Hashids.net NuGet package on the backend.
|
||||
*/
|
||||
function generateDeterministicHashId(id: number | string, context: string): string {
|
||||
const str = `${context}:${id}:pfm-salt-2026`;
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) + hash) + str.charCodeAt(i);
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
export const HASHER_SETTINGS = {
|
||||
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
|
||||
salt:
|
||||
(env.VITE_HASHER_SALT as string) ||
|
||||
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
|
||||
};
|
||||
|
||||
// Singleton Hashids instance initialized with salt & minHashLength
|
||||
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
||||
|
||||
/**
|
||||
* Dynamically reconfigures the deterministic Hashids instance from runtime configuration.
|
||||
*/
|
||||
export function updateHasherSettings(salt?: string, minLength?: number | string): void {
|
||||
let updated = false;
|
||||
if (salt && typeof salt === 'string' && salt.trim() !== '') {
|
||||
HASHER_SETTINGS.salt = salt.trim();
|
||||
updated = true;
|
||||
}
|
||||
if (minLength !== undefined && minLength !== null && minLength !== '') {
|
||||
const parsed = Number(minLength);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
HASHER_SETTINGS.minHashLength = parsed;
|
||||
updated = true;
|
||||
}
|
||||
const positiveHash = Math.abs(hash);
|
||||
const base62Chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
let encoded = '';
|
||||
let num = positiveHash;
|
||||
while (num > 0) {
|
||||
encoded = base62Chars[num % 62] + encoded;
|
||||
num = Math.floor(num / 62);
|
||||
}
|
||||
const prefix = context ? context.slice(0, 3).toLowerCase() : 'id';
|
||||
return `${prefix}_${encoded.padStart(8, 'x')}`;
|
||||
}
|
||||
if (updated) {
|
||||
hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes the backend tools encoding endpoint to retrieve the designated HashID string
|
||||
* Checks whether an incoming string is already a valid Hashid matching the configured salt and min length.
|
||||
* Prevents double-encoding already-hashed route parameters or IDs.
|
||||
*/
|
||||
async function fetchHashIdFromEndpoint(id: number | string, context = 'general'): Promise<string> {
|
||||
if (id === undefined || id === null || id === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
// Attempt standard tools encoding endpoint variations
|
||||
const response = await apiClient.get<string | { hashId?: string; encoded?: string; result?: string; value?: string }>(
|
||||
`/v1/api/tools/encode/${id}`,
|
||||
{
|
||||
params: { context, type: context },
|
||||
timeout: 4000,
|
||||
}
|
||||
);
|
||||
|
||||
if (typeof response.data === 'string' && response.data.trim()) {
|
||||
return response.data.trim();
|
||||
}
|
||||
if (response.data && typeof response.data === 'object') {
|
||||
const candidate = response.data.hashId || response.data.encoded || response.data.result || response.data.value;
|
||||
if (candidate) return candidate;
|
||||
}
|
||||
} catch {
|
||||
// Attempt query param variant
|
||||
try {
|
||||
const response = await apiClient.get<string | { hashId?: string; encoded?: string; result?: string; value?: string }>(
|
||||
'/v1/api/tools/encode',
|
||||
{
|
||||
params: { id, context, type: context },
|
||||
timeout: 4000,
|
||||
}
|
||||
);
|
||||
if (typeof response.data === 'string' && response.data.trim()) {
|
||||
return response.data.trim();
|
||||
}
|
||||
if (response.data && typeof response.data === 'object') {
|
||||
const candidate = response.data.hashId || response.data.encoded || response.data.result || response.data.value;
|
||||
if (candidate) return candidate;
|
||||
}
|
||||
} catch {
|
||||
// Fallback to deterministic HashID generator
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful fallback guaranteeing valid HashID output during preview
|
||||
return generateDeterministicHashId(id, context);
|
||||
function isAlreadyHashId(candidate: string): boolean {
|
||||
if (typeof candidate !== 'string' || candidate.length < HASHER_SETTINGS.minHashLength) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const decoded = hasher.decode(candidate);
|
||||
return Array.isArray(decoded) && decoded.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes the backend tools decoding endpoint
|
||||
* Deterministically encodes a numeric ID or numeric string into a HashID string.
|
||||
* Synchronous and does not perform HTTP network round trips.
|
||||
*/
|
||||
async function fetchDecodedIdFromEndpoint(hashId: string, context = 'general'): Promise<number | string> {
|
||||
if (!hashId) return '';
|
||||
export function encodeHashIdSync(id: number | string | null | undefined): string {
|
||||
if (id === undefined || id === null || id === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// If already a number
|
||||
if (typeof id === 'number') {
|
||||
if (isNaN(id) || !isFinite(id) || id < 0) return '';
|
||||
return hasher.encode(id);
|
||||
}
|
||||
|
||||
const str = String(id).trim();
|
||||
if (!str) return '';
|
||||
|
||||
// Check if string is already a valid HashID
|
||||
if (isAlreadyHashId(str)) {
|
||||
return str;
|
||||
}
|
||||
|
||||
// Check if string is pure digits (e.g. "123")
|
||||
if (/^\d+$/.test(str)) {
|
||||
try {
|
||||
const response = await apiClient.get<{ id?: number | string; decoded?: number | string } | number | string>(
|
||||
`/v1/api/tools/decode/${encodeURIComponent(hashId)}`,
|
||||
{ params: { context }, timeout: 4000 }
|
||||
);
|
||||
if (typeof response.data === 'number' || typeof response.data === 'string') {
|
||||
return response.data;
|
||||
}
|
||||
if (response.data && typeof response.data === 'object') {
|
||||
if (response.data.id !== undefined) return response.data.id;
|
||||
if (response.data.decoded !== undefined) return response.data.decoded;
|
||||
}
|
||||
const num = parseInt(str, 10);
|
||||
if (!isNaN(num) && num >= 0) {
|
||||
return hasher.encode(num);
|
||||
}
|
||||
} catch {
|
||||
// Passthrough or fallback
|
||||
// Fall through
|
||||
}
|
||||
return hashId;
|
||||
}
|
||||
|
||||
// Non-numeric identifiers (UUIDs, codes) returned as-is
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministically decodes a HashID string back to original numeric ID or string.
|
||||
* Synchronous and does not perform HTTP network round trips.
|
||||
*/
|
||||
export function decodeHashIdSync(hashId: string | null | undefined): number | string {
|
||||
if (!hashId) return '';
|
||||
const str = String(hashId).trim();
|
||||
if (!str) return '';
|
||||
|
||||
try {
|
||||
const decoded = hasher.decode(str);
|
||||
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||
return Number(decoded[0]);
|
||||
}
|
||||
} catch {
|
||||
// If decoding fails, return raw string
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
export const toolsService = {
|
||||
/**
|
||||
* Encodes a raw ID to a HashID, cached client-side in TanStack Query for the active session
|
||||
*/
|
||||
async encodeId(id: number | string, context = 'general'): Promise<string> {
|
||||
if (id === undefined || id === null || id === '') return '';
|
||||
/**
|
||||
* Synchronous encoding
|
||||
*/
|
||||
encodeIdSync(id: number | string | null | undefined): string {
|
||||
return encodeHashIdSync(id);
|
||||
},
|
||||
|
||||
const idStr = String(id);
|
||||
return queryClient.fetchQuery({
|
||||
queryKey: ['hashId', context, idStr],
|
||||
queryFn: () => fetchHashIdFromEndpoint(id, context),
|
||||
staleTime: Infinity, // Caches client-side during the active session to eliminate redundant round trips
|
||||
gcTime: 1000 * 60 * 60 * 24, // Keep in memory
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Synchronous decoding
|
||||
*/
|
||||
decodeIdSync(hashId: string | null | undefined): number | string {
|
||||
return decodeHashIdSync(hashId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Decodes a HashID string back to original identifier
|
||||
*/
|
||||
async decodeId(hashId: string, context = 'general'): Promise<number | string> {
|
||||
if (!hashId) return '';
|
||||
/**
|
||||
* Encodes a raw ID to a HashID (returns Promise for backward compatibility with existing callers)
|
||||
*/
|
||||
async encodeId(id: number | string | null | undefined, _context = 'general'): Promise<string> {
|
||||
return encodeHashIdSync(id);
|
||||
},
|
||||
|
||||
return queryClient.fetchQuery({
|
||||
queryKey: ['decodeHashId', context, hashId],
|
||||
queryFn: () => fetchDecodedIdFromEndpoint(hashId, context),
|
||||
staleTime: Infinity,
|
||||
gcTime: 1000 * 60 * 60 * 24,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Decodes a HashID string back to original identifier
|
||||
*/
|
||||
async decodeId(hashId: string | null | undefined, _context = 'general'): Promise<number | string> {
|
||||
return decodeHashIdSync(hashId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Specific convenience resolvers for domain entities
|
||||
*/
|
||||
async resolveProjectId(projectId: number | string): Promise<string> {
|
||||
return this.encodeId(projectId, 'project');
|
||||
},
|
||||
/**
|
||||
* Domain-specific convenience resolvers for entities
|
||||
*/
|
||||
async resolveProjectId(projectId: number | string): Promise<string> {
|
||||
return encodeHashIdSync(projectId);
|
||||
},
|
||||
|
||||
async resolveOrganisationId(organisationId: number | string): Promise<string> {
|
||||
return this.encodeId(organisationId, 'organisation');
|
||||
},
|
||||
async resolveOrganisationId(organisationId: number | string): Promise<string> {
|
||||
return encodeHashIdSync(organisationId);
|
||||
},
|
||||
|
||||
async resolveProgrammeId(programmeId: number | string): Promise<string> {
|
||||
return this.encodeId(programmeId, 'programme');
|
||||
},
|
||||
async resolveProgrammeId(programmeId: number | string): Promise<string> {
|
||||
return encodeHashIdSync(programmeId);
|
||||
},
|
||||
|
||||
async resolvePortfolioId(portfolioId: number | string): Promise<string> {
|
||||
return this.encodeId(portfolioId, 'portfolio');
|
||||
},
|
||||
async resolvePortfolioId(portfolioId: number | string): Promise<string> {
|
||||
return encodeHashIdSync(portfolioId);
|
||||
},
|
||||
|
||||
async resolveAwardId(awardId: number | string): Promise<string> {
|
||||
return this.encodeId(awardId, 'award');
|
||||
},
|
||||
async resolveAwardId(awardId: number | string): Promise<string> {
|
||||
return encodeHashIdSync(awardId);
|
||||
},
|
||||
|
||||
async resolveContractId(contractId: number | string): Promise<string> {
|
||||
return this.encodeId(contractId, 'contract');
|
||||
},
|
||||
async resolveContractId(contractId: number | string): Promise<string> {
|
||||
return encodeHashIdSync(contractId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolves query parameters or path variables encoded as HashIDs
|
||||
* before performing API calls
|
||||
*/
|
||||
async resolveHashedParams<T extends Record<string, any>>(
|
||||
params: T,
|
||||
keysToHash: (keyof T)[]
|
||||
): Promise<T> {
|
||||
const resolved: any = { ...params };
|
||||
for (const key of keysToHash) {
|
||||
const val = params[key];
|
||||
if (val !== undefined && val !== null) {
|
||||
const contextStr = String(key).replace(/Id$/i, '').toLowerCase();
|
||||
resolved[key] = await this.encodeId(val, contextStr);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
},
|
||||
/**
|
||||
* Resolves query parameters or path variables encoded as HashIDs before performing API calls
|
||||
*/
|
||||
async resolveHashedParams<T extends Record<string, any>>(
|
||||
params: T,
|
||||
keysToHash: (keyof T)[]
|
||||
): Promise<T> {
|
||||
const resolved: any = { ...params };
|
||||
for (const key of keysToHash) {
|
||||
const val = params[key];
|
||||
if (val !== undefined && val !== null) {
|
||||
resolved[key] = encodeHashIdSync(val);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
},
|
||||
|
||||
/**
|
||||
* Pre-fetches and primes cache for a collection of IDs
|
||||
*/
|
||||
async primeCache(items: { id: number | string; context: string }[]): Promise<void> {
|
||||
await Promise.all(items.map((item) => this.encodeId(item.id, item.context)));
|
||||
},
|
||||
/**
|
||||
* Compatibility cache primer (now a no-op as Hashids calculation is instantaneous client-side)
|
||||
*/
|
||||
async primeCache(_items: { id: number | string; context: string }[]): Promise<void> {
|
||||
// No-op - instant deterministic computation
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* React Hook for consuming HashIDs with TanStack Query caching in components
|
||||
*/
|
||||
export function useHashId(id?: number | string | null, context = 'general') {
|
||||
return useQuery({
|
||||
queryKey: ['hashId', context, id !== undefined && id !== null ? String(id) : ''],
|
||||
queryFn: () => (id !== undefined && id !== null ? toolsService.encodeId(id, context) : Promise.resolve('')),
|
||||
enabled: id !== undefined && id !== null && id !== '',
|
||||
staleTime: Infinity,
|
||||
});
|
||||
return useQuery({
|
||||
queryKey: ['hashId', context, id !== undefined && id !== null ? String(id) : ''],
|
||||
queryFn: () => Promise.resolve(encodeHashIdSync(id)),
|
||||
enabled: id !== undefined && id !== null && id !== '',
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -1,13 +1,13 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_OIDC_AUTHORITY?: string;
|
||||
readonly VITE_OIDC_AUDIENCE?: string;
|
||||
readonly VITE_OIDC_CLIENT_ID?: string;
|
||||
readonly VITE_OIDC_CLIENT_SECRET?: string;
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
readonly VITE_OIDC_AUTHORITY?: string;
|
||||
readonly VITE_OIDC_AUDIENCE?: string;
|
||||
readonly VITE_OIDC_CLIENT_ID?: string;
|
||||
readonly VITE_OIDC_CLIENT_SECRET?: string;
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user