From 8f58e15f0fc8af9da226002e5c2596b48dba4fcf Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 23 Oct 2026 00:58:47 +0200 Subject: [PATCH] Implemented partnerui --- PostFundManagement.PartnerUI/src/App.tsx | 45 +- .../src/auth/AuthProvider.tsx | 983 +++++-- PostFundManagement.PartnerUI/src/auth/rbac.ts | 323 ++ .../auth/AccountActivationModal.tsx | 269 ++ .../auth/NonPartnerAccessDeniedModal.tsx | 179 ++ .../components/auth/SessionExpiredModal.tsx | 182 ++ .../components/dashboard/DashboardPage.tsx | 2180 +++++++++++++- .../src/components/dashboard/ProjectCard.tsx | 8 +- .../src/components/ui/BrandLogo.tsx | 33 + .../src/components/ui/Toast.tsx | 378 +++ .../src/hooks/useInAppNotifications.ts | 357 +++ .../src/lib/formatters.ts | 683 +++++ .../src/lib/markdownUtils.ts | 100 + .../src/lib/queryClient.ts | 18 +- .../src/services/api/endpoints.ts | 23 +- .../src/services/api/index.ts | 2601 +++++++++++++++++ .../src/services/api/models.ts | 695 +---- .../src/services/httpClient.ts | 98 +- .../src/services/notificationRulesService.ts | 281 ++ .../src/services/roleClaimsCache.ts | 440 +++ .../src/services/sessionExpiration.ts | 81 +- .../src/services/tenantLockoutService.ts | 164 ++ .../src/services/toolsService.ts | 157 +- .../src/services/userSyncService.ts | 220 +- PostFundManagement.PartnerUI/src/types.ts | 1145 ++++++++ .../src/utils/apiErrorParser.ts | 565 ++++ .../src/utils/hashids.ts | 191 ++ 27 files changed, 11416 insertions(+), 983 deletions(-) create mode 100644 PostFundManagement.PartnerUI/src/auth/rbac.ts create mode 100644 PostFundManagement.PartnerUI/src/components/auth/AccountActivationModal.tsx create mode 100644 PostFundManagement.PartnerUI/src/components/auth/NonPartnerAccessDeniedModal.tsx create mode 100644 PostFundManagement.PartnerUI/src/components/auth/SessionExpiredModal.tsx create mode 100644 PostFundManagement.PartnerUI/src/components/ui/BrandLogo.tsx create mode 100644 PostFundManagement.PartnerUI/src/components/ui/Toast.tsx create mode 100644 PostFundManagement.PartnerUI/src/hooks/useInAppNotifications.ts create mode 100644 PostFundManagement.PartnerUI/src/lib/formatters.ts create mode 100644 PostFundManagement.PartnerUI/src/lib/markdownUtils.ts create mode 100644 PostFundManagement.PartnerUI/src/services/notificationRulesService.ts create mode 100644 PostFundManagement.PartnerUI/src/services/roleClaimsCache.ts create mode 100644 PostFundManagement.PartnerUI/src/services/tenantLockoutService.ts create mode 100644 PostFundManagement.PartnerUI/src/types.ts create mode 100644 PostFundManagement.PartnerUI/src/utils/apiErrorParser.ts create mode 100644 PostFundManagement.PartnerUI/src/utils/hashids.ts diff --git a/PostFundManagement.PartnerUI/src/App.tsx b/PostFundManagement.PartnerUI/src/App.tsx index 0329788..c795cf9 100644 --- a/PostFundManagement.PartnerUI/src/App.tsx +++ b/PostFundManagement.PartnerUI/src/App.tsx @@ -2,29 +2,10 @@ import React from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { QueryClientProvider } from '@tanstack/react-query'; import { queryClient } from './lib/queryClient'; -import { AuthProvider, useAuth } from './auth/AuthProvider'; +import { AuthProvider, ProtectedRoute, useAuth } from './auth/AuthProvider'; +import { ToastProvider } from './components/ui/Toast'; import { LandingPage } from './components/landing/LandingPage'; import { DashboardPage } from './components/dashboard/DashboardPage'; -import { LaserDivider } from './components/layout/LaserDivider'; - -function AuthenticatedLayout() { - return ( -
- {/* Fixed-width vertical navigation pane on the left */} - - - {/* Laser beam divider with central light source */} - - - {/* Content pane occupying the rest of the screen */} -
- -
-
- ); -} function AppRoutes() { const { isAuthenticated, isLoading, login } = useAuth(); @@ -55,16 +36,14 @@ function AppRoutes() { - ) : ( - - ) + + + } /> } + element={} /> ); @@ -73,11 +52,13 @@ function AppRoutes() { export default function App() { return ( - - - - - + + + + + + + ); } diff --git a/PostFundManagement.PartnerUI/src/auth/AuthProvider.tsx b/PostFundManagement.PartnerUI/src/auth/AuthProvider.tsx index 0c841af..2a495e9 100644 --- a/PostFundManagement.PartnerUI/src/auth/AuthProvider.tsx +++ b/PostFundManagement.PartnerUI/src/auth/AuthProvider.tsx @@ -1,9 +1,30 @@ import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react'; -import { UserManager, WebStorageStateStore } from 'oidc-client-ts'; -import { getSecuritySettings, loadRuntimeConfig } from '../config'; +import { UserManager, WebStorageStateStore, User as OidcUser } from 'oidc-client-ts'; +import { Navigate, useLocation } from 'react-router-dom'; +import { getSecuritySettings, loadRuntimeConfig, onConfigChanged } from '../config'; import { parseJwtClaims } from './jwt'; -import { syncUserAccount, clearUserSyncInBrowser, isActivationPending } from '../services/userSyncService'; -import { onSessionExpired, clearSessionAndRedirectToLanding } from '../services/sessionExpiration'; +import { BrandLogo } from '../components/ui/BrandLogo'; +import { + syncUserAccount, + isUserSyncedInBrowser, + isActivationPending, + getActivationPendingReason, + clearUserSyncInBrowser, + UserSyncResult, +} from '../services/userSyncService'; +import { AccountActivationModal } from '../components/auth/AccountActivationModal'; +import { NonPartnerAccessDeniedModal } from '../components/auth/NonPartnerAccessDeniedModal'; +import { SessionExpiredModal } from '../components/auth/SessionExpiredModal'; +import { encodeHashIdSync } from '../services/toolsService'; +import { verifyTenantLockout } from '../services/tenantLockoutService'; +import { + onSessionExpired, + triggerSessionExpired, + clearSessionAndRedirectToLanding, + cancelSessionExpiration, + dismissSubordinateModals, +} from '../services/sessionExpiration'; +import { resolveUserRolesAndClaims } from '../services/roleClaimsCache'; export interface AuthUserProfile { id: string; @@ -11,244 +32,866 @@ export interface AuthUserProfile { name: string; email: string; role: string; - tenant: string; - tenant_id: number | string; + organisationName?: string; + tenant?: string; + tenant_id?: number; + avatarUrl?: string; } +export type UserClaims = Record; + interface AuthContextType { user: AuthUserProfile | null; sub: string; - claims: Record; + claims: UserClaims; + tenantId: number | null; + hashedTenantId: string | null; + tenantName: string | null; accessToken: string | null; + roles: string[]; + isPartner: boolean; + canManageTenants: boolean; isAuthenticated: boolean; isLoading: boolean; + isSynced: boolean; isPendingActivation: boolean; + activationError: string | null; + isSessionExpired: boolean; + triggerSessionExpired: (reason?: string) => void; login: () => Promise; logout: () => Promise; + syncUserSession: (force?: boolean) => Promise; + authError: string | null; } -const AuthContext = createContext(undefined); +export const AuthContext = createContext(undefined); + +export const LOCAL_STORAGE_TOKEN_KEY = 'pfm_access_token'; +export const LOCAL_STORAGE_USER_KEY = 'pfm_auth_user'; +export const LOCAL_STORAGE_SUB_KEY = 'pfm_user_sub'; +export const LOCAL_STORAGE_CLAIMS_KEY = 'pfm_user_claims'; + +export function extractUserRoles( + claims?: UserClaims | null, + user?: AuthUserProfile | null, + token?: string | null +): string[] { + const rolesSet = new Set(); + + const processRoles = (raw: any) => { + if (!raw) return; + if (Array.isArray(raw)) { + raw.forEach((r) => { + if (typeof r === 'string' && r.trim()) rolesSet.add(r.trim()); + }); + } else if (typeof raw === 'string' && raw.trim()) { + if (raw.includes(',')) { + raw.split(',').forEach((r) => { + if (r.trim()) rolesSet.add(r.trim()); + }); + } else { + rolesSet.add(raw.trim()); + } + } + }; + + if (claims) { + processRoles(claims.roles); + processRoles(claims.role); + processRoles(claims['roles']); + processRoles(claims['role']); + processRoles(claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role']); + processRoles(claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role']); + processRoles(claims['realm_access']?.roles); + processRoles(claims['resource_access']?.roles); + } + + if (token) { + try { + const tokenClaims = parseJwtClaims(token); + processRoles(tokenClaims.roles); + processRoles(tokenClaims.role); + processRoles(tokenClaims['roles']); + processRoles(tokenClaims['role']); + processRoles(tokenClaims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role']); + processRoles(tokenClaims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role']); + } catch {} + } + + if (user?.role && typeof user.role === 'string') { + processRoles(user.role); + } + if ((user as any)?.roles) { + processRoles((user as any).roles); + } + + return Array.from(rolesSet); +} /** - * Derives the user profile strictly from JWT token claims. - * If name, email, tenant, or tenant_id are not present, returns null (user is signed out). + * Checks whether the user has the 'Partner' role. + * On PartnerUI, ONLY users with the 'Partner' role are permitted. */ -export function deriveUserProfileFromTokenClaims(claims: Record, rawSub?: string): AuthUserProfile | null { - if (!claims || typeof claims !== 'object' || Object.keys(claims).length === 0) { - return null; +export function hasPartnerRole(roles: string[]): boolean { + return Array.isArray(roles) && roles.some((r) => r.toLowerCase().trim() === 'partner'); +} + +export function canManageTenants(_tenantId?: number | string | null, _roles?: string[]): boolean { + // PartnerUI does not permit tenant administration + return false; +} + +export function extractTenantId(claims?: UserClaims | null, token?: string | null): number | null { + if (claims) { + const raw = + claims.tenant_id ?? + (claims as any).tenantId ?? + claims['tenant_id'] ?? + claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/tenantid']; + if (raw !== undefined && raw !== null && raw !== '') { + const parsed = Number(raw); + if (!isNaN(parsed) && isFinite(parsed)) return parsed; + } + } + if (token) { + try { + const tokenClaims = parseJwtClaims(token); + const raw = tokenClaims.tenant_id ?? tokenClaims.tenantId ?? tokenClaims['tenant_id']; + if (raw !== undefined && raw !== null && raw !== '') { + const parsed = Number(raw); + if (!isNaN(parsed) && isFinite(parsed)) return parsed; + } + } catch {} + } + if (typeof window !== 'undefined') { + try { + const storedClaimsRaw = localStorage.getItem(LOCAL_STORAGE_CLAIMS_KEY); + if (storedClaimsRaw) { + const parsedClaims = JSON.parse(storedClaimsRaw); + const raw = parsedClaims.tenant_id ?? parsedClaims.tenantId ?? parsedClaims['tenant_id']; + if (raw !== undefined && raw !== null && raw !== '') { + const parsed = Number(raw); + if (!isNaN(parsed) && isFinite(parsed)) return parsed; + } + } + const storedToken = localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY); + if (storedToken) { + const tokenClaims = parseJwtClaims(storedToken); + const raw = tokenClaims.tenant_id ?? tokenClaims.tenantId ?? tokenClaims['tenant_id']; + if (raw !== undefined && raw !== null && raw !== '') { + const parsed = Number(raw); + if (!isNaN(parsed) && isFinite(parsed)) return parsed; + } + } + const storedUser = localStorage.getItem(LOCAL_STORAGE_USER_KEY); + if (storedUser) { + const parsedUser = JSON.parse(storedUser); + const raw = parsedUser.tenant_id ?? parsedUser.tenantId; + if (raw !== undefined && raw !== null && raw !== '') { + const parsed = Number(raw); + if (!isNaN(parsed) && isFinite(parsed)) return parsed; + } + } + } catch {} + } + return null; +} + +export function extractTenantClaimValues( + claims?: UserClaims | null, + token?: string | null +): { + tenantId: number | string | null; + tenant: string | null; +} { + let tenantId: number | string | null = null; + let tenant: string | null = null; + + if (claims) { + const rawId = + claims.tenant_id ?? + (claims as any).tenantId ?? + claims['tenant_id'] ?? + claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/tenantid']; + if (rawId !== undefined && rawId !== null && rawId !== '') { + const parsed = Number(rawId); + tenantId = !isNaN(parsed) && isFinite(parsed) ? parsed : String(rawId).trim(); + } + + const rawTenant = + claims.tenant ?? + claims['tenant'] ?? + claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/tenant'] ?? + claims.tenant_name ?? + (claims as any).tenantName ?? + claims.org; + if (rawTenant !== undefined && rawTenant !== null && rawTenant !== '') { + if (typeof rawTenant === 'string') { + tenant = rawTenant.trim(); + } else if (typeof rawTenant === 'object' && rawTenant.name) { + tenant = String(rawTenant.name).trim(); + } + } } - // Derive name - const name = - claims.name || - claims.given_name || - claims.preferred_username || - claims.nickname || - (claims.first_name && claims.last_name ? `${claims.first_name} ${claims.last_name}` : undefined); - - // Derive email - const email = - claims.email || - claims.upn || - claims.email_address || - claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']; - - // Derive tenant (tenant name) - const tenant = - claims.tenant || - claims.tenant_name || - claims.tenantName || - claims.organisation || - claims.org || - claims.organization; - - // Derive tenant_id - const rawTenantId = - claims.tenant_id ?? - claims.tenantId ?? - claims.tenantid ?? - claims.org_id ?? - claims.organisation_id ?? - claims.organization_id ?? - claims.tid; - - // If any required property is missing, user is signed out - if (!name || !email || !tenant || rawTenantId === undefined || rawTenantId === null || String(rawTenantId).trim() === '') { - return null; + if (token && (!tenantId || !tenant)) { + try { + const tokenClaims = parseJwtClaims(token); + if (!tenantId) { + const rawId = + tokenClaims.tenant_id ?? + tokenClaims.tenantId ?? + tokenClaims['tenant_id'] ?? + tokenClaims['http://schemas.microsoft.com/ws/2008/06/identity/claims/tenantid']; + if (rawId !== undefined && rawId !== null && rawId !== '') { + const parsed = Number(rawId); + tenantId = !isNaN(parsed) && isFinite(parsed) ? parsed : String(rawId).trim(); + } + } + if (!tenant) { + const rawTenant = + tokenClaims.tenant ?? + tokenClaims['tenant'] ?? + tokenClaims['http://schemas.microsoft.com/ws/2008/06/identity/claims/tenant'] ?? + tokenClaims.tenant_name ?? + (tokenClaims as any).tenantName ?? + tokenClaims.org; + if (rawTenant !== undefined && rawTenant !== null && rawTenant !== '') { + if (typeof rawTenant === 'string') { + tenant = rawTenant.trim(); + } else if (typeof rawTenant === 'object' && rawTenant.name) { + tenant = String(rawTenant.name).trim(); + } + } + } + } catch {} } - const sub = claims.sub || rawSub || String(rawTenantId); - const role = - claims.role || - (Array.isArray(claims.roles) ? claims.roles[0] : claims.roles) || - 'Partner'; + return { tenantId, tenant }; +} - return { - id: String(sub), - sub: String(sub), - name: String(name).trim(), - email: String(email).trim(), - role: String(role).trim(), - tenant: String(tenant).trim(), - tenant_id: typeof rawTenantId === 'number' ? rawTenantId : String(rawTenantId).trim(), - }; +export function getCurrentUserTenantId(): number | null { + return extractTenantId( + getCurrentUserClaims(), + typeof window !== 'undefined' ? localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY) : null + ); +} + +export function getCurrentUserHashedTenantId(): string | null { + const tid = getCurrentUserTenantId(); + if (tid === null || tid === undefined) return null; + return encodeHashIdSync(tid); +} + +export function getCurrentUserSub(): string { + 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 {}; + } + } + } + return {}; +} + +let userManagerInstance: UserManager | null = null; + +export function resetUserManager(): void { + userManagerInstance = null; +} + +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(null); - const [accessToken, setAccessToken] = useState(() => { - return typeof window !== 'undefined' ? localStorage.getItem('pfm_access_token') : null; + const [sub, setSub] = useState(''); + const [claims, setClaims] = useState({}); + const [tenantId, setTenantId] = useState(() => { + return extractTenantId( + getCurrentUserClaims(), + typeof window !== 'undefined' ? localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY) : null + ); }); - const [claims, setClaims] = useState>({}); - const [isLoading, setIsLoading] = useState(true); - const [userManager, setUserManager] = useState(null); + const [tenantName, setTenantName] = useState(() => { + const claims = getCurrentUserClaims(); + const token = typeof window !== 'undefined' ? localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY) : null; + const { tenant } = extractTenantClaimValues(claims, token); + return tenant; + }); + const [accessToken, setAccessToken] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSynced, setIsSynced] = useState(() => isUserSyncedInBrowser()); + const [isPendingActivation, setIsPendingActivation] = useState(() => isActivationPending()); + const [activationError, setActivationError] = useState(() => getActivationPendingReason()); + const [authError, setAuthError] = useState(null); + const [isSessionExpired, setIsSessionExpired] = useState(false); + const [sessionExpiredReason, setSessionExpiredReason] = useState(null); + + const hashedTenantId = tenantId !== null && tenantId !== undefined ? encodeHashIdSync(tenantId) : null; useEffect(() => { - let active = true; + const unsubscribe = onSessionExpired((reason) => { + if (isPendingActivation || isActivationPending()) { + return; + } + const token = typeof window !== 'undefined' ? localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY) : null; + if (token || accessToken) { + setSessionExpiredReason(reason || 'Session expired or token invalidated (HTTP 401)'); + setIsSessionExpired(true); + } + }); - async function initOidc() { + return () => { + unsubscribe(); + }; + }, [accessToken, isPendingActivation]); + + useEffect(() => { + if (isPendingActivation) { + setIsSessionExpired(false); + setSessionExpiredReason(null); + cancelSessionExpiration(); + dismissSubordinateModals(); + } + }, [isPendingActivation]); + + useEffect(() => { + if (isSessionExpired) { + dismissSubordinateModals(); + } + }, [isSessionExpired]); + + const handleSessionExpiredDismiss = () => { + setIsSessionExpired(false); + setSessionExpiredReason(null); + clearSessionAndRedirectToLanding(); + }; + + useEffect(() => { + const initAuth = async () => { try { await loadRuntimeConfig(); - const sec = getSecuritySettings(); - const authorityUrl = sec.Authority.replace(/\/+$/, ''); - const dashboardRedirectUri = `${window.location.origin}/dashboard`; + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.has('code') && urlParams.has('state')) { + const mgr = getUserManager(); + const oidcUser: OidcUser | undefined = await mgr.signinCallback(); - const mgr = new UserManager({ - authority: authorityUrl, - client_id: sec.ClientId, - client_secret: sec.ClientSecret, - redirect_uri: dashboardRedirectUri, - post_logout_redirect_uri: window.location.origin, - response_type: 'code', - scope: sec.Scope || 'openid profile email roles pfm-api-dev', - userStore: new WebStorageStateStore({ store: window.localStorage }), - metadata: { - issuer: authorityUrl, - authorization_endpoint: `${authorityUrl}/connect/authorize`, - token_endpoint: `${authorityUrl}/connect/token`, - userinfo_endpoint: `${authorityUrl}/connect/userinfo`, - end_session_endpoint: `${authorityUrl}/connect/endsession`, - jwks_uri: `${authorityUrl}/.well-known/openid-configuration/jwks`, - }, - }); + if (oidcUser && oidcUser.access_token) { + const token = oidcUser.access_token; + setAccessToken(token); + localStorage.setItem(LOCAL_STORAGE_TOKEN_KEY, token); - if (!active) return; - setUserManager(mgr); + const decodedTokenClaims = parseJwtClaims(token); + const mergedClaims: UserClaims = { + ...decodedTokenClaims, + ...oidcUser.profile, + }; - // 1. Process OIDC redirect code if returning from STS - if (window.location.search.includes('code=')) { - try { - const oidcUser = await mgr.signinCallback(); - if (oidcUser && oidcUser.access_token) { - const tokenClaims = parseJwtClaims(oidcUser.access_token); - const combinedClaims = { ...tokenClaims, ...(oidcUser.profile || {}) }; - const derivedProfile = deriveUserProfileFromTokenClaims(combinedClaims, oidcUser.profile?.sub); + const userSub = (oidcUser.profile?.sub || mergedClaims.sub || '') as string; + setSub(userSub); + setClaims(mergedClaims); - if (derivedProfile) { - localStorage.setItem('pfm_access_token', oidcUser.access_token); - localStorage.setItem('pfm_user_sub', derivedProfile.sub); - localStorage.setItem('pfm_auth_user', JSON.stringify(derivedProfile)); - localStorage.setItem('pfm_user_claims', JSON.stringify(combinedClaims)); + localStorage.setItem(LOCAL_STORAGE_SUB_KEY, userSub); + localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims)); - setAccessToken(oidcUser.access_token); - setClaims(combinedClaims); - setUser(derivedProfile); + const tenantClaimValues = extractTenantClaimValues(mergedClaims, token); + const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || tenantClaimValues.tenant || undefined; + const parsedTenantId = extractTenantId(mergedClaims, token); + setTenantId(parsedTenantId); + + const initialRoles = extractUserRoles(mergedClaims, null, token); + const initialPrimaryRole = + initialRoles.find((r) => r.toLowerCase().trim() === 'partner') || + initialRoles[0] || + (oidcUser.profile.role as string) || + (mergedClaims.role as string) || + ''; + + const profile: AuthUserProfile = { + id: userSub || 'user-1', + sub: userSub, + name: oidcUser.profile.name || oidcUser.profile.preferred_username || (mergedClaims.name as string) || 'Partner User', + email: oidcUser.profile.email || (mergedClaims.email as string) || '', + role: initialPrimaryRole, + organisationName: (mergedClaims.organisation as string) || (mergedClaims.org as string) || undefined, + tenant: tenantClaim, + tenant_id: parsedTenantId ?? undefined, + }; + + setUser(profile); + localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile)); + + window.history.replaceState({}, document.title, window.location.pathname); + + const tenantLockoutResult = await verifyTenantLockout( + parsedTenantId ?? tenantClaimValues.tenantId, + tenantClaim + ); + + if (!tenantLockoutResult.isActive) { + setIsPendingActivation(true); + setIsSynced(false); + setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found'); + setIsLoading(false); + return; + } + + if (tenantLockoutResult.tenant?.name) { + profile.tenant = tenantLockoutResult.tenant.name; + setTenantName(tenantLockoutResult.tenant.name); + localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile)); + } else if (tenantClaim) { + setTenantName(tenantClaim); + } + + try { + const syncResult = await syncUserAccount(userSub, token, profile, true); + if (syncResult.isUnauthorized) { + await logout(); + window.location.replace('/'); + return; + } + if (syncResult.isActivationPending) { + setIsPendingActivation(true); + setIsSynced(false); + setActivationError(syncResult.errorMessage || null); + } else if (syncResult.success) { + setIsPendingActivation(false); + setIsSynced(true); + setActivationError(null); try { - await syncUserAccount(derivedProfile.sub, oidcUser.access_token, derivedProfile); + const resolved = await resolveUserRolesAndClaims(userSub, mergedClaims, profile); + if (resolved.primaryRole) { + profile.role = resolved.primaryRole; + } + if (resolved.tenant) { + profile.tenant = resolved.tenant; + setTenantName(resolved.tenant); + } + if (resolved.tenant_id !== undefined) { + profile.tenant_id = resolved.tenant_id; + setTenantId(resolved.tenant_id); + } + setUser({ ...profile }); + setClaims({ ...resolved.claims }); + localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile)); + localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims)); } catch {} - } else { - clearUserSyncInBrowser(); - localStorage.removeItem('pfm_access_token'); - localStorage.removeItem('pfm_auth_user'); - setAccessToken(null); - setUser(null); } + } catch (syncErr: any) { + if (syncErr?.response?.status === 401) { + await logout(); + window.location.replace('/'); + return; + } + setIsPendingActivation(true); + setIsSynced(false); + setActivationError(syncErr?.message || 'Account activation pending'); } - window.history.replaceState({}, document.title, window.location.pathname); - } catch (err) { - console.error('[Auth] Callback error:', err); - } - } else { - // 2. Evaluate existing stored token - const storedToken = localStorage.getItem('pfm_access_token'); - if (storedToken) { - const tokenClaims = parseJwtClaims(storedToken); - const derivedProfile = deriveUserProfileFromTokenClaims(tokenClaims); - if (derivedProfile) { - setAccessToken(storedToken); - setClaims(tokenClaims); - setUser(derivedProfile); - localStorage.setItem('pfm_auth_user', JSON.stringify(derivedProfile)); - } else { - clearUserSyncInBrowser(); - localStorage.removeItem('pfm_access_token'); - localStorage.removeItem('pfm_auth_user'); - setAccessToken(null); - setUser(null); - } - } else { - setUser(null); - setAccessToken(null); + setIsLoading(false); + return; } } - } catch (err) { - console.warn('[Auth] OIDC initialization notice:', err); - setUser(null); - setAccessToken(null); - } finally { - if (active) setIsLoading(false); - } - } - initOidc(); - const unsubSession = onSessionExpired(() => clearSessionAndRedirectToLanding()); - return () => { - active = false; - unsubSession(); + 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); + } + + const storedTenantId = extractTenantId(parsedClaims, storedToken); + setTenantId(storedTenantId); + const tenantClaimValues = extractTenantClaimValues(parsedClaims, storedToken); + const storedTenantClaim = (parsedClaims.tenant as string) || tenantClaimValues.tenant || undefined; + + let resolvedUser: AuthUserProfile | null = null; + if (storedUser) { + try { + const parsedUser = JSON.parse(storedUser); + if (!parsedUser.tenant && parsedClaims.tenant) { + parsedUser.tenant = parsedClaims.tenant; + } + if (parsedUser.tenant_id === undefined && storedTenantId !== null) { + parsedUser.tenant_id = storedTenantId; + } + setUser(parsedUser); + resolvedUser = parsedUser; + } catch {} + } + + if (storedTenantClaim) { + setTenantName(storedTenantClaim); + } + + setIsLoading(false); + + (async () => { + try { + const tenantLockoutResult = await verifyTenantLockout( + storedTenantId ?? tenantClaimValues.tenantId, + storedTenantClaim + ); + + if (!tenantLockoutResult.isActive) { + setIsPendingActivation(true); + setIsSynced(false); + setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found'); + return; + } + + if (tenantLockoutResult.tenant?.name) { + if (resolvedUser) { + resolvedUser.tenant = tenantLockoutResult.tenant.name; + setUser({ ...resolvedUser }); + localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser)); + } + setTenantName(tenantLockoutResult.tenant.name); + } + + const wasPending = isActivationPending(); + setIsPendingActivation(wasPending); + setActivationError(getActivationPendingReason()); + + const syncResult = await syncUserAccount( + storedSub || '', + storedToken, + resolvedUser || undefined, + false + ); + + if (syncResult.isUnauthorized) { + await logout(); + return; + } + + if (syncResult.isActivationPending) { + setIsPendingActivation(true); + setIsSynced(false); + setActivationError(syncResult.errorMessage || null); + return; + } + + if (syncResult.success) { + setIsPendingActivation(false); + setIsSynced(true); + setActivationError(null); + + const resolved = await resolveUserRolesAndClaims( + storedSub || '', + parsedClaims, + resolvedUser || {} + ); + if (resolvedUser) { + if (resolved.primaryRole) resolvedUser.role = resolved.primaryRole; + if (resolved.tenant) { + resolvedUser.tenant = resolved.tenant; + setTenantName(resolved.tenant); + } + if (resolved.tenant_id !== undefined) { + resolvedUser.tenant_id = resolved.tenant_id; + setTenantId(resolved.tenant_id); + } + setUser({ ...resolvedUser }); + localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser)); + } + setClaims({ ...resolved.claims }); + localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims)); + } + } catch (syncErr: any) { + if (syncErr?.response?.status === 401) { + await logout(); + return; + } + setIsPendingActivation(true); + setIsSynced(false); + setActivationError(syncErr?.message || 'Account activation pending'); + } + })(); + + return; + } + } catch (err: unknown) { + console.warn('OIDC initialization notice:', err); + setAuthError(err instanceof Error ? err.message : 'Auth initialization notice'); + } finally { + setIsLoading(false); + } }; + + initAuth(); }, []); + const syncUserSession = async (force = true): Promise => { + try { + const tenantClaimValues = extractTenantClaimValues(claims, accessToken); + const currentTenantId = tenantId ?? tenantClaimValues.tenantId; + const currentTenantClaim = (claims.tenant as string) || user?.tenant || tenantClaimValues.tenant || undefined; + + const tenantLockoutResult = await verifyTenantLockout(currentTenantId, currentTenantClaim); + if (!tenantLockoutResult.isActive) { + const msg = tenantLockoutResult.errorMessage || 'Tenant is not Active. Lockout active.'; + setIsPendingActivation(true); + setIsSynced(false); + setActivationError(msg); + return { + success: false, + isActivationPending: true, + errorMessage: msg, + }; + } + + const result = await syncUserAccount(sub, accessToken, user || undefined, force); + if (result.isUnauthorized) { + await logout(); + return result; + } + if (result.success) { + setIsPendingActivation(false); + setIsSynced(true); + setActivationError(null); + } else if (result.isActivationPending) { + setIsPendingActivation(true); + setIsSynced(false); + setActivationError(result.errorMessage || null); + } + return result; + } catch (err: any) { + if (err?.response?.status === 401) { + await logout(); + return { success: false, isUnauthorized: true }; + } + const msg = err?.message || 'Sync failed'; + setIsPendingActivation(true); + setIsSynced(false); + setActivationError(msg); + return { success: false, isActivationPending: true, errorMessage: msg }; + } + }; + const login = async () => { - if (userManager) { - try { - await userManager.signinRedirect({ - redirect_uri: `${window.location.origin}/dashboard`, - }); - } catch (err) { - console.error('[Auth] Redirect error:', err); + try { + setAuthError(null); + await loadRuntimeConfig(); + resetUserManager(); + const mgr = getUserManager(); + await mgr.signinRedirect(); + } catch (err: unknown) { + console.warn('OIDC Redirect encountered:', err); + 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'); } } }; const logout = async () => { - clearUserSyncInBrowser(); - clearSessionAndRedirectToLanding(); + 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); + clearUserSyncInBrowser(); + setUser(null); + setSub(''); + setClaims({}); + setTenantId(null); + setAccessToken(null); + setIsSynced(false); + setIsPendingActivation(false); + setActivationError(null); + + try { + const mgr = getUserManager(); + await mgr.signoutRedirect(); + } catch { + // Local state cleared + } + } catch (err) { + console.error('Logout error:', err); + } }; + const roles = React.useMemo(() => { + return extractUserRoles(claims, user, accessToken); + }, [claims, user, accessToken]); + + const isPartner = React.useMemo(() => { + return hasPartnerRole(roles); + }, [roles]); + return ( {children} + ); }; export const useAuth = () => { - const ctx = useContext(AuthContext); - if (!ctx) throw new Error('useAuth must be used within AuthProvider'); - return ctx; + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; + +/** + * Route Guard for PartnerUI: + * 1. Redirects unauthenticated users to '/' + * 2. Strictly enforces that ONLY users with the 'Partner' role are permitted on PartnerUI. + * Any other role (Admin, Executive, Supervisor, Manager, Contributor, etc.) is blocked with NonPartnerAccessDeniedModal. + * 3. Renders AccountActivationModal if the partner account is pending administrator activation. + */ +export const ProtectedRoute: React.FC<{ children: ReactNode }> = ({ children }) => { + const { + isAuthenticated, + isLoading, + isPendingActivation, + isPartner, + roles, + user, + claims, + sub, + logout, + syncUserSession, + activationError, + } = useAuth(); + const location = useLocation(); + + if (isLoading) { + return ( +
+
+
+ +
+
+

Verifying Partner Portal Session...

+
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + // Requirement 2: RBAC on PartnerUI only functions for Partner roles, no other roles are permitted on this app. + if (!isPartner && !hasPartnerRole(roles)) { + return ( + + ); + } + + if (isPendingActivation) { + return ( + + ); + } + + return <>{children}; }; diff --git a/PostFundManagement.PartnerUI/src/auth/rbac.ts b/PostFundManagement.PartnerUI/src/auth/rbac.ts new file mode 100644 index 0000000..fecf1dc --- /dev/null +++ b/PostFundManagement.PartnerUI/src/auth/rbac.ts @@ -0,0 +1,323 @@ +import React, { useMemo } from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from './AuthProvider'; + +export type RoleClaim = + | 'dashboard' + | 'governance' + | 'projects_milestones' + | 'grant_financials' + | 'evidence_risks' + | 'beneficiaries' + | 'change_management' + | 'site_visits' + | 'compliance' + | 'settings_invites' + | 'settings_users' + | 'settings_auditlog' + | 'settings_notifications' + | 'tenant_self_service' + | 'tenant_management' + | 'unrestricted'; + +export type ClaimAction = 'view' | 'add' | 'update' | 'export' | 'all'; + +export interface RoleClaimDefinition { + claim: RoleClaim; + label: string; + description: string; +} + +export const ROLE_CLAIM_DEFINITIONS: RoleClaimDefinition[] = [ + { claim: 'dashboard', label: 'Partner Dashboard', description: 'Overview of ongoing projects, funding awards, and inbox' }, + { claim: 'governance', label: 'Governance & Structure', description: 'Holding entities, investment portfolios, and statutory programmes' }, + { claim: 'projects_milestones', label: 'Projects & Milestones', description: 'Work breakdown structure, scheduled milestones, and progress' }, + { claim: 'grant_financials', label: 'Grant & Financials', description: 'Funding awards, budget line allocations, and disbursements' }, + { claim: 'evidence_risks', label: 'M&E, Evidence & Risks', description: 'Evidence submission and risk registers' }, + { claim: 'beneficiaries', label: 'Beneficiaries', description: 'Target demographics, compliance tracking, and outcomes' }, + { claim: 'change_management', label: 'Change Management', description: 'Contractual change requests, issues, and enactments' }, + { claim: 'site_visits', label: 'Site Visits', description: 'Physical field inspection reports and inspector findings' }, + { claim: 'compliance', label: 'Compliance', description: 'Statutory compliance, outcome fulfillment, and risk monitoring' }, + { claim: 'settings_invites', label: 'Settings Invites', description: 'Statutory onboarding invitations and applicant notifications' }, + { claim: 'settings_users', label: 'Settings Users', description: 'Direct user account administration and role memberships' }, + { claim: 'settings_auditlog', label: 'Settings Audit Log', description: 'System-wide activity ledger and telemetry records' }, + { claim: 'settings_notifications', label: 'Notifications & Messages', description: 'In-app and email communications with the funding organisation' }, + { claim: 'tenant_self_service', label: 'Tenant Self-Service', description: 'Tenant configuration parameters and self-managed sub-users' }, + { claim: 'tenant_management', label: 'Tenant Management', description: 'Multi-tenant partition directory and cross-tenant isolation' }, + { claim: 'unrestricted', label: 'Unrestricted Access', description: 'Full system-wide control' }, +]; + +export const CLAIM_ACTION_OPTIONS: { action: ClaimAction; label: string; description: string }[] = [ + { action: 'view', label: 'view', description: 'Can view records based on role' }, + { action: 'add', label: 'add', description: 'Can create records or give feedback; change non-approve statuses' }, + { action: 'update', label: 'update', description: 'Can use approve status change in dropdowns' }, + { action: 'export', label: 'export', description: 'Can click export buttons (Spreadsheet/PDF)' }, + { action: 'all', label: 'all', description: 'Can do everything' }, +]; + +/** + * RBAC on PartnerUI ONLY functions for Partner roles. + * No other roles (Admin, Executive, Supervisor, Manager, Contributor) are permitted on this app. + */ +export const ROLE_CLAIM_MAP: Record = { + partner: [ + 'dashboard', + 'projects_milestones', + 'grant_financials', + 'evidence_risks', + 'beneficiaries', + 'change_management', + 'site_visits', + 'compliance', + 'settings_notifications', + ], +}; + +/** + * Checks whether the user's roles include the mandatory 'Partner' role. + * On PartnerUI, ONLY users with the 'Partner' role are permitted. + */ +export function isPartnerRoleOnlyPermitted(roles: string[] = []): boolean { + return Array.isArray(roles) && roles.some((r) => r.toLowerCase().trim() === 'partner'); +} + +/** + * Resolves action capability set given a claim value string for a Partner user. + */ +export function resolveClaimActionSet( + claimValue: string | null | undefined, + roleName?: string +): Set { + const actions = new Set(); + if (!claimValue && !roleName) return actions; + + const rawVal = (claimValue || '').toLowerCase().trim(); + const role = (roleName || '').toLowerCase().trim(); + + // Only Partner role is recognized in PartnerUI + if (role && role !== 'partner') { + return actions; + } + + if (rawVal) { + if (rawVal === 'all' || rawVal === 'unrestricted' || rawVal === 'true' || rawVal === '1') { + actions.add('all'); + actions.add('view'); + actions.add('add'); + actions.add('update'); + actions.add('export'); + return actions; + } + if (rawVal === 'none' || rawVal === 'restricted' || rawVal === 'false' || rawVal === '0') { + return actions; + } + + const tokens = rawVal.split(',').map((t) => t.trim()); + for (const val of tokens) { + if (val === 'all') { + actions.add('all'); + actions.add('view'); + actions.add('add'); + actions.add('update'); + actions.add('export'); + } else if (val === 'view' || val === 'read') { + actions.add('view'); + } else if (val === 'add' || val === 'edit' || val === 'write' || val === 'create') { + actions.add('view'); + actions.add('add'); + } else if (val === 'update' || val === 'approve' || val === 'verify') { + actions.add('view'); + actions.add('add'); + actions.add('update'); + } else if (val === 'export') { + actions.add('view'); + actions.add('export'); + } + } + return actions; + } + + // Default fallback for Partner role: view, add (submit evidence, log activities, send messages, execute contract), export + if (role === 'partner') { + actions.add('view'); + actions.add('add'); + actions.add('export'); + } + + return actions; +} + +/** + * Helper to extract explicit claim value from varied dictionary structures + */ +export function extractExplicitClaimValue( + claims: Record = {}, + claim: RoleClaim +): string | null { + if (!claims || typeof claims !== 'object') return null; + + if (claims[claim] !== undefined && claims[claim] !== null) { + return String(claims[claim]).trim(); + } + if (claims[`role_claim:${claim}`] !== undefined) { + return String(claims[`role_claim:${claim}`]).trim(); + } + if (claims[`claim:${claim}`] !== undefined) { + return String(claims[`claim:${claim}`]).trim(); + } + + const noUnderscore = claim.replace(/_/g, ''); + if (claims[noUnderscore] !== undefined) { + return String(claims[noUnderscore]).trim(); + } + + const arrayCandidates = [ + claims.claims, + claims.UserClaims, + claims.user_claims, + claims.role_claims, + ]; + for (const arr of arrayCandidates) { + if (Array.isArray(arr)) { + for (const item of arr) { + if (typeof item === 'object' && item !== null) { + const type = (item.type || item.claimType || item.name || '').toLowerCase().trim(); + if (type === claim || type === noUnderscore) { + return String(item.value || item.claimValue || 'all').trim(); + } + } else if (typeof item === 'string') { + const [type, val] = item.split(':').map((s) => s.trim().toLowerCase()); + if (type === claim || type === noUnderscore) { + return val || 'all'; + } + } + } + } + } + + return null; +} + +/** + * Evaluates whether a user with given roles and token claims has permission for a specific claim and action. + * + * CRITICAL RULE FOR PARTNER UI: + * RBAC on PartnerUI ONLY functions for Partner roles. No other roles are permitted on this app. + */ +export function evaluateUserPermission( + roles: string[] = [], + claims: Record = {}, + claim: RoleClaim, + action: ClaimAction = 'view' +): boolean { + // Security Gate: Only users with the 'Partner' role are permitted on PartnerUI. + const isPartner = isPartnerRoleOnlyPermitted(roles); + if (!isPartner) return false; + + // 1. Check if Partner user has explicit claim in claims dictionary + const rawClaimValue = extractExplicitClaimValue(claims, claim); + if (rawClaimValue !== null && rawClaimValue !== '') { + const actionSet = resolveClaimActionSet(rawClaimValue, 'partner'); + return actionSet.has('all') || actionSet.has(action); + } + + // 2. Check Partner base role mappings + const allowedClaims = ROLE_CLAIM_MAP['partner']; + if (allowedClaims && allowedClaims.includes(claim)) { + const actionSet = resolveClaimActionSet(undefined, 'partner'); + if (actionSet.has('all') || actionSet.has(action)) { + return true; + } + } + + return false; +} + +/** + * Evaluates Maker-Checker separation of duties constraint. + */ +export function isRecordMaker( + createdBy?: string | null, + currentUserId?: string | null, + currentUserSub?: string | null, + currentUserEmail?: string | null +): boolean { + if (!createdBy) return false; + const creator = createdBy.trim().toLowerCase(); + if (currentUserId && creator === currentUserId.trim().toLowerCase()) return true; + if (currentUserSub && creator === currentUserSub.trim().toLowerCase()) return true; + if (currentUserEmail && creator === currentUserEmail.trim().toLowerCase()) return true; + return false; +} + +/** + * React hook exposing RBAC evaluation helpers for PartnerUI components. + * Enforces that only the Partner role has permissions in this application. + */ +export function useRbac() { + const { roles, claims, user, sub } = useAuth(); + + const permissions = useMemo(() => { + const isPartner = isPartnerRoleOnlyPermitted(roles); + + const canAccess = (claim: RoleClaim, action: ClaimAction = 'view'): boolean => { + if (!isPartner) return false; + return evaluateUserPermission(roles, claims, claim, action); + }; + + const canView = (claim: RoleClaim): boolean => canAccess(claim, 'view'); + const canAdd = (claim: RoleClaim): boolean => canAccess(claim, 'add'); + const canApprove = (claim: RoleClaim): boolean => canAccess(claim, 'update'); + const canExport = (claim: RoleClaim): boolean => canAccess(claim, 'export'); + + const isMaker = (createdBy?: string | null): boolean => { + return isRecordMaker(createdBy, user?.id, sub, user?.email); + }; + + const canApproveRecord = (claim: RoleClaim, createdBy?: string | null): boolean => { + if (!canApprove(claim)) return false; + return !isMaker(createdBy); + }; + + const getApprovalRestrictionReason = (claim: RoleClaim, createdBy?: string | null): string | null => { + if (!isPartner) return 'Partner role required'; + if (!canApprove(claim)) return 'Requires approval permission from the funding organisation'; + if (isMaker(createdBy)) return 'Maker-Checker policy: Record creators cannot approve their own records'; + return null; + }; + + return { + isPartner, + isUnrestricted: false, + canAccess, + canView, + canAdd, + canApprove, + canExport, + isMaker, + canApproveRecord, + getApprovalRestrictionReason, + }; + }, [roles, claims, user, sub]); + + return permissions; +} + +/** + * Route protection component for claim-restricted screens in PartnerUI. + */ +export const ClaimProtectedRoute: React.FC<{ + claim: RoleClaim; + action?: ClaimAction; + children: React.ReactNode; +}> = ({ claim, action = 'view', children }) => { + const { canAccess } = useRbac(); + const allowed = canAccess(claim, action); + + if (!allowed) { + return React.createElement(Navigate, { to: '/dashboard', replace: true }); + } + + return React.createElement(React.Fragment, null, children); +}; + diff --git a/PostFundManagement.PartnerUI/src/components/auth/AccountActivationModal.tsx b/PostFundManagement.PartnerUI/src/components/auth/AccountActivationModal.tsx new file mode 100644 index 0000000..7c86149 --- /dev/null +++ b/PostFundManagement.PartnerUI/src/components/auth/AccountActivationModal.tsx @@ -0,0 +1,269 @@ +import React, { useState, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { useNavigate } from 'react-router-dom'; +import { + ShieldAlert, + RefreshCw, + LogOut, + UserCheck, + CheckCircle2, + AlertTriangle, + ArrowLeft, + Mail, + Building2, + Fingerprint, +} from 'lucide-react'; +import { BrandLogo } from '../ui/BrandLogo'; +import { AuthUserProfile, UserClaims } from '../../auth/AuthProvider'; +import { cancelSessionExpiration, dismissSubordinateModals } from '../../services/sessionExpiration'; + +interface AccountActivationModalProps { + user: AuthUserProfile | null; + claims: UserClaims; + sub: string; + onSync: (force?: boolean) => Promise<{ success: boolean; isUnauthorized?: boolean; errorMessage?: string }>; + onLogout: () => Promise; + initialError?: string | null; +} + +export const AccountActivationModal: React.FC = ({ + user, + claims, + sub, + onSync, + onLogout, + initialError, +}) => { + const navigate = useNavigate(); + const [isSyncing, setIsSyncing] = useState(false); + const [errorMessage, setErrorMessage] = useState(initialError || null); + const [syncAttemptCount, setSyncAttemptCount] = useState(0); + + useEffect(() => { + if (initialError !== undefined) { + setErrorMessage(initialError); + } + }, [initialError]); + + useEffect(() => { + cancelSessionExpiration(); + dismissSubordinateModals(); + }, []); + + const displayName = + user?.name || + (claims.name as string) || + (claims.preferred_username as string) || + 'Registered Partner'; + + const displayEmail = + user?.email || + (claims.email as string) || + (claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) || + ''; + + const tenant = user?.tenant || (claims.tenant as string) || (claims.org as string) || 'Partner Organisation'; + const role = user?.role || (claims.role as string) || 'Partner'; + + const handleSyncClick = async () => { + if (isSyncing) return; + setIsSyncing(true); + setErrorMessage(null); + + try { + const result = await onSync(true); + + if (result.isUnauthorized) { + await onLogout(); + navigate('/', { replace: true }); + return; + } + + if (!result.success) { + setSyncAttemptCount((prev) => prev + 1); + setErrorMessage( + result.errorMessage || + 'Your partner account is not yet enabled. Please ask your funding administrator to activate your account.' + ); + } + } catch (err: unknown) { + const errObj = err as { response?: { status?: number }; message?: string }; + if (errObj?.response?.status === 401) { + await onLogout(); + navigate('/', { replace: true }); + return; + } + setErrorMessage( + errObj?.message || + 'Account activation check encountered an error. Please confirm with your administrator and try again.' + ); + } finally { + setIsSyncing(false); + } + }; + + const handleSignOut = async () => { + await onLogout(); + navigate('/', { replace: true }); + }; + + const handleNavigateLanding = () => { + navigate('/'); + }; + + const modalContent = ( +
+
e.stopPropagation()} + > +
+ +
+
+
+
+ + + + + +
+
+ + Access Restricted • Activation Pending + +

+ {errorMessage?.toLowerCase().includes('tenant') + ? 'Organisation Account Inactive' + : 'Partner Account Pending Activation'} +

+
+
+ + +
+
+ +
+
+
+ + Signed In Successfully +
+

+ You have signed in successfully, but your partner account is waiting to be activated by the funding administrator. +

+

+ Once your administrator confirms your account is active, click{' '} + Check Account Status below to open your dashboard. +

+
+ +
+
+ Your Partner Profile + + Pending Activation + +
+ +
+
+ +
+ Name + {displayName} +
+
+ +
+ +
+ Email + {displayEmail || 'Not specified'} +
+
+ +
+ +
+ Organisation / Role + + {tenant} ({role}) + +
+
+ +
+ +
+ Account ID + {sub || 'SSO-Principal'} +
+
+
+
+ + {errorMessage && ( +
+ +
+

Account Not Activated Yet

+

{errorMessage}

+ {syncAttemptCount > 0 && ( +

+ Checked #{syncAttemptCount} at {new Date().toLocaleTimeString()} +

+ )} +
+
+ )} +
+ +
+
+ + + +
+ + +
+
+
+ ); + + return typeof document !== 'undefined' + ? createPortal(modalContent, document.body) + : modalContent; +}; + diff --git a/PostFundManagement.PartnerUI/src/components/auth/NonPartnerAccessDeniedModal.tsx b/PostFundManagement.PartnerUI/src/components/auth/NonPartnerAccessDeniedModal.tsx new file mode 100644 index 0000000..cf67728 --- /dev/null +++ b/PostFundManagement.PartnerUI/src/components/auth/NonPartnerAccessDeniedModal.tsx @@ -0,0 +1,179 @@ +import React, { useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { useNavigate } from 'react-router-dom'; +import { + ShieldAlert, + LogOut, + Ban, + Mail, + Building2, + Fingerprint, + Lock, +} from 'lucide-react'; +import { BrandLogo } from '../ui/BrandLogo'; +import { AuthUserProfile, UserClaims } from '../../auth/AuthProvider'; +import { cancelSessionExpiration, dismissSubordinateModals } from '../../services/sessionExpiration'; + +interface NonPartnerAccessDeniedModalProps { + user: AuthUserProfile | null; + claims: UserClaims; + sub: string; + roles: string[]; + onLogout: () => Promise; +} + +/** + * Blocks any authenticated user who does NOT hold the 'Partner' role. + * RBAC on PartnerUI strictly permits only Partner roles; no other roles are allowed. + */ +export const NonPartnerAccessDeniedModal: React.FC = ({ + user, + claims, + sub, + roles, + onLogout, +}) => { + const navigate = useNavigate(); + + useEffect(() => { + cancelSessionExpiration(); + dismissSubordinateModals(); + }, []); + + const displayName = + user?.name || + (claims.name as string) || + (claims.preferred_username as string) || + 'Authenticated User'; + + const displayEmail = + user?.email || + (claims.email as string) || + (claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) || + ''; + + const tenant = user?.tenant || (claims.tenant as string) || (claims.org as string) || 'Statutory Enterprise'; + const assignedRolesDisplay = roles.length > 0 ? roles.join(', ') : user?.role || (claims.role as string) || 'Unassigned'; + + const handleLogoutClick = async () => { + await onLogout(); + navigate('/', { replace: true }); + }; + + const modalContent = ( +
+
+
+ +
+
+
+ +
+
+
+ + + Partner Role Required + + Security Policy +
+

+ Access Denied: Partner Accounts Only +

+

+ This application is exclusively reserved for users assigned the Partner role. Your account role ({assignedRolesDisplay}) is not permitted on the Partner Portal. +

+
+
+ +
+
+ Account Name + {displayName} +
+ + {displayEmail && ( +
+ + + Email + + {displayEmail} +
+ )} + +
+ + + Assigned Role(s) + + + {assignedRolesDisplay} + +
+ + {tenant && ( +
+ + + Organisation / Tenant + + {tenant} +
+ )} + + {sub && ( +
+ + + Subject Key + + {sub} +
+ )} +
+ +
+

+ + Portal Separation Notice +

+

+ Internal staff, executives, programme managers, and administrators must sign in through the PFM Management Console. Only external grant recipients and project partners with the Partner role are permitted to use this portal. +

+
+ +
+ +
+
+ +
+
+ + PFM Partner Portal +
+ Role Security Gate +
+
+
+ ); + + return typeof document !== 'undefined' + ? createPortal(modalContent, document.body) + : modalContent; +}; + diff --git a/PostFundManagement.PartnerUI/src/components/auth/SessionExpiredModal.tsx b/PostFundManagement.PartnerUI/src/components/auth/SessionExpiredModal.tsx new file mode 100644 index 0000000..ca1c6eb --- /dev/null +++ b/PostFundManagement.PartnerUI/src/components/auth/SessionExpiredModal.tsx @@ -0,0 +1,182 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { ShieldAlert, Clock, ArrowRight, X, AlertTriangle, LogOut } from 'lucide-react'; +import { clearSessionAndRedirectToLanding, dismissSubordinateModals } from '../../services/sessionExpiration'; + +interface SessionExpiredModalProps { + isOpen: boolean; + onDismiss?: () => void; + reason?: string | null; + countdownSeconds?: number; +} + +export const SessionExpiredModal: React.FC = ({ + isOpen, + onDismiss, + reason, + countdownSeconds = 8, +}) => { + const [timeLeftMs, setTimeLeftMs] = useState(countdownSeconds * 1000); + const [isPaused, setIsPaused] = useState(false); + const totalDurationMs = countdownSeconds * 1000; + const isClearingRef = useRef(false); + + useEffect(() => { + if (isOpen) { + dismissSubordinateModals(); + } + }, [isOpen]); + + useEffect(() => { + const handleCancelled = () => { + setTimeLeftMs(countdownSeconds * 1000); + setIsPaused(false); + isClearingRef.current = false; + }; + window.addEventListener('pfm:session-expired-cancelled', handleCancelled); + return () => { + window.removeEventListener('pfm:session-expired-cancelled', handleCancelled); + }; + }, [countdownSeconds]); + + useEffect(() => { + if (!isOpen) { + setTimeLeftMs(countdownSeconds * 1000); + setIsPaused(false); + isClearingRef.current = false; + return; + } + + const intervalMs = 50; + const interval = setInterval(() => { + setTimeLeftMs((prev) => { + if (isPaused) return prev; + const next = prev - intervalMs; + if (next <= 0) { + clearInterval(interval); + handleAction(); + return 0; + } + return next; + }); + }, intervalMs); + + return () => clearInterval(interval); + }, [isOpen, isPaused, countdownSeconds]); + + const handleAction = () => { + if (isClearingRef.current) return; + isClearingRef.current = true; + + if (onDismiss) { + onDismiss(); + } else { + clearSessionAndRedirectToLanding(); + } + }; + + if (!isOpen) return null; + + const secondsRemaining = Math.max(0, Math.ceil(timeLeftMs / 1000)); + const progressPercent = Math.max(0, Math.min(100, (timeLeftMs / totalDurationMs) * 100)); + + const modalContent = ( +
setIsPaused(true)} + onMouseLeave={() => setIsPaused(false)} + role="dialog" + aria-modal="true" + aria-labelledby="session-expired-title" + > +
+
+ +
+
+
+ +
+
+
+

+ Session Expired +

+ + HTTP 401 + +
+

Please sign in again to continue

+
+
+ + +
+ +
+

+ Your active working session has ended for security. To protect your information, please return to the home page and sign in again. +

+ {reason && ( +
+ + {reason} +
+ )} +
+ +
+
+ + + Returning to home page in{' '} + {secondsRemaining}s + + {isPaused && ( + + Paused on hover + + )} +
+ +
+
+
+
+ +
+ +
+
+
+ ); + + return typeof document !== 'undefined' + ? createPortal(modalContent, document.body) + : modalContent; +}; + diff --git a/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx b/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx index 4e7e647..c885008 100644 --- a/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx +++ b/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx @@ -1,5 +1,2179 @@ -import React from 'react'; +import React, { useState, useMemo } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Search, + Bell, + LogOut, + Check, + Calendar, + FileText, + DollarSign, + Upload, + Send, + ChevronDown, + ShieldCheck, + MessageSquare, + FileCheck2, + X, + Plus, + ExternalLink, + CheckCircle2, + RefreshCw, + Briefcase, + Award as AwardIcon, +} from 'lucide-react'; +import { useAuth } from '../../auth/AuthProvider'; +import { useRbac } from '../../auth/rbac'; +import { useToast } from '../ui/Toast'; +import { PfmPartnerLogo } from '../common/PfmPartnerLogo'; +import { useInAppNotifications } from '../../hooks/useInAppNotifications'; +import { + getApplicantData, + getMilestones, + getProjectEvidence, + getActivities, + getDisbursements, + uploadEvidenceFile, + createActivity, + createDisbursement, + createMilestone, + uploadContractFile, + executeContract, + getContractSecureLink, + getEvidenceSecureLink, +} from '../../services/api'; +import { + ApplicantData, + Project, + Milestone, + Evidence, + Activity, + Disbursement, + Contract, + Award, + Organisation, + ApprovalStatus, + ActivityStatus, + ContractStatus, + EvidenceType, + Notification, + NotificationPlatform, + NotificationStatus, +} from '../../types'; +import { formatZAR } from '../../lib/formatters'; -export const DashboardPage: React.FC = () => { - return null; +interface EnrichedPartnerProject { + project: Project; + award: Award | null; + contract: Contract | null; + fundingOrganisation: Organisation | null; + invitedOrganisation: Organisation | null; + milestones: Milestone[]; + evidence: Evidence[]; + activities: Activity[]; + disbursements: Disbursement[]; +} + +type NavTab = 'dashboard' | 'messages' | 'contracts' | 'documents'; +type SortOption = 'date_added' | 'grant_amount' | 'days_left' | 'name'; + +const EVIDENCE_TYPE_LABELS: Record = { + [EvidenceType.AttendanceRegister]: 'Attendance Register / Progress Report', + [EvidenceType.BankStatement]: 'Bank Statement / Financial Statement', + [EvidenceType.CompletionCertificate]: 'Completion Certificate', + [EvidenceType.FinancialInvoiceOrReceipt]: 'Invoice / Receipt', + [EvidenceType.GeotaggedPhoto]: 'Geotagged Site Photograph', + [EvidenceType.InspectionReport]: 'Inspection / Audit Report', + [EvidenceType.MeetingMinutes]: 'Meeting Minutes', + [EvidenceType.PayrollOrStipendRegister]: 'Payroll / Stipend Register', + [EvidenceType.ProcurementDocument]: 'Procurement Document', + [EvidenceType.TechnicalSpecificationOrDesign]: 'Deliverable Proof / Technical Design', + [EvidenceType.VideoRecording]: 'Video Recording', + [EvidenceType.Other]: 'Supporting Document', }; + +function formatShortDate(dateStr?: string | null): string { + if (!dateStr) return 'TBD'; + const d = new Date(dateStr); + if (isNaN(d.getTime())) return 'TBD'; + return d.toLocaleDateString('en-ZA', { day: 'numeric', month: 'short' }); +} + +function formatFullDate(dateStr?: string | null): string { + if (!dateStr) return 'Not set'; + const d = new Date(dateStr); + if (isNaN(d.getTime())) return 'Not set'; + return d.toLocaleDateString('en-ZA', { day: 'numeric', month: 'short', year: 'numeric' }); +} + +function calculateDaysLeft(endDateStr?: string | null): number { + if (!endDateStr) return 90; + const end = new Date(endDateStr).getTime(); + if (isNaN(end)) return 90; + const diff = Math.ceil((end - Date.now()) / (1000 * 60 * 60 * 24)); + return Math.max(0, diff); +} + +function getInitials(name?: string | null): string { + if (!name) return 'PF'; + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) { + return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); + } + return name.slice(0, 2).toUpperCase(); +} + +export function DashboardPage() { + const { user, sub, logout } = useAuth(); + const { canAdd } = useRbac(); + const { success: showSuccessToast, error: showErrorToast, info: showInfoToast } = useToast(); + const queryClient = useQueryClient(); + + const applicantUserId = sub || user?.id || ''; + + // Navigation & UI state + const [activeTab, setActiveTab] = useState('dashboard'); + const [searchQuery, setSearchQuery] = useState(''); + const [sortBy, setSortBy] = useState('date_added'); + const [showBellDropdown, setShowBellDropdown] = useState(false); + + // Modals state + const [selectedProjectItem, setSelectedProjectItem] = useState(null); + const [projectModalTab, setProjectModalTab] = useState<'milestones' | 'evidence' | 'payments' | 'activities' | 'contract'>('milestones'); + const [contactTarget, setContactTarget] = useState<{ + organisation: Organisation | null; + projectTitle?: string; + recipientEmail?: string; + recipientId?: string; + } | null>(null); + const [selectedNotification, setSelectedNotification] = useState(null); + + // Forms inside Project Details Modal + const [uploadingEvidence, setUploadingEvidence] = useState(false); + const [selectedMilestoneForUpload, setSelectedMilestoneForUpload] = useState(''); + const [selectedEvidenceType, setSelectedEvidenceType] = useState(EvidenceType.FinancialInvoiceOrReceipt); + const [evidenceFile, setEvidenceFile] = useState(null); + + const [paymentAmount, setPaymentAmount] = useState(''); + const [paymentMilestoneId, setPaymentMilestoneId] = useState(''); + + const [activityTitle, setActivityTitle] = useState(''); + const [activityCategory] = useState('Implementation'); + const [activityAmount, setActivityAmount] = useState(''); + const [activityDescription, setActivityDescription] = useState(''); + + const [newMilestoneName, setNewMilestoneName] = useState(''); + const [newMilestoneDueAt, setNewMilestoneDueAt] = useState(''); + + const [contractFile, setContractFile] = useState(null); + const [signatureRef, setSignatureRef] = useState(''); + + // Contact / Message form state + const [messageSubject, setMessageSubject] = useState(''); + const [messageBody, setMessageBody] = useState(''); + + // In-App Notifications & Inbox hook + const { + inboxNotifications, + unreadCount, + top5Unread, + markAsRead, + markAllAsRead, + sendMessage, + isSending, + } = useInAppNotifications(); + + // ============================================================================ + // PRIMARY STARTING API CALL: GetIApplicantDataEndpoint + // GET /v1/api/communications/applicants/{applicantUserId:Guid}/all + // ============================================================================ + const { + data: applicantDataList = [], + isLoading: isLoadingApplicantData, + isFetching: isRefreshingApplicantData, + refetch: refetchApplicantData, + } = useQuery({ + queryKey: ['partner-applicant-data', applicantUserId], + queryFn: () => getApplicantData(applicantUserId), + enabled: Boolean(applicantUserId), + staleTime: 30_000, + }); + + // Extract all unique projects, contracts, awards, and organisations from ApplicantData[] + const partnerSummary = useMemo(() => { + const projectsMap = new Map(); + + const contractsMap = new Map(); + + const awardsList: Award[] = []; + let primaryInvitedOrg: Organisation | null = null; + let primaryFundingOrg: Organisation | null = null; + + for (const record of applicantDataList) { + if (record.invitedOrganisation && !primaryInvitedOrg) { + primaryInvitedOrg = record.invitedOrganisation; + } + if (record.fundingOrganisation && !primaryFundingOrg) { + primaryFundingOrg = record.fundingOrganisation; + } + + const primaryAward = record.awards && record.awards.length > 0 ? record.awards[0] : null; + if (record.awards) { + for (const aw of record.awards) { + if (!awardsList.some(x => String(x.id) === String(aw.id))) { + awardsList.push(aw); + } + } + } + + if (record.contract && record.contract.id) { + contractsMap.set(String(record.contract.id), { + contract: record.contract, + award: primaryAward, + fundingOrganisation: record.fundingOrganisation ?? null, + invitedOrganisation: record.invitedOrganisation ?? null, + }); + } + + if (record.projects && record.projects.length > 0) { + for (const proj of record.projects) { + if (!proj || !proj.id) continue; + const matchingAward = + record.awards?.find(a => String(a.programmeId) === String(proj.programmeId)) || + primaryAward; + projectsMap.set(String(proj.id), { + project: proj, + award: matchingAward, + contract: record.contract ?? null, + fundingOrganisation: record.fundingOrganisation ?? null, + invitedOrganisation: record.invitedOrganisation ?? null, + }); + } + } + } + + return { + projectEntries: Array.from(projectsMap.values()), + contractEntries: Array.from(contractsMap.values()), + awardsList, + primaryInvitedOrg, + primaryFundingOrg, + }; + }, [applicantDataList]); + + const projectIdsKey = useMemo( + () => partnerSummary.projectEntries.map(e => String(e.project.id)).join(','), + [partnerSummary.projectEntries] + ); + + // Downstream queries using the IDs provided by GetIApplicantDataEndpoint + const { data: projectDetailsMap = {} } = useQuery< + Record< + string, + { + milestones: Milestone[]; + evidence: Evidence[]; + activities: Activity[]; + disbursements: Disbursement[]; + } + > + >({ + queryKey: ['partner-project-details', projectIdsKey], + queryFn: async () => { + const entries = partnerSummary.projectEntries; + if (entries.length === 0) return {}; + + let allDisbursements: Disbursement[] = []; + try { + allDisbursements = await getDisbursements(1, 100); + } catch { + allDisbursements = []; + } + + const results = await Promise.all( + entries.map(async ({ project }) => { + const pid = project.id; + const [milestones, evidence, activities] = await Promise.all([ + getMilestones(pid).catch(() => [] as Milestone[]), + getProjectEvidence(pid).catch(() => [] as Evidence[]), + getActivities(pid).catch(() => [] as Activity[]), + ]); + + const projectDisbursements = allDisbursements.filter( + d => String(d.projectId) === String(pid) + ); + + return [ + String(pid), + { + milestones, + evidence, + activities, + disbursements: projectDisbursements, + }, + ] as const; + }) + ); + + return Object.fromEntries(results); + }, + enabled: partnerSummary.projectEntries.length > 0, + staleTime: 30_000, + }); + + // Combine into EnrichedPartnerProject[] + const enrichedProjects: EnrichedPartnerProject[] = useMemo(() => { + return partnerSummary.projectEntries.map(entry => { + const details = projectDetailsMap[String(entry.project.id)] || { + milestones: [], + evidence: [], + activities: [], + disbursements: [], + }; + return { + ...entry, + ...details, + }; + }); + }, [partnerSummary.projectEntries, projectDetailsMap]); + + // Filter and sort projects for the "Ongoing Projects" section + const filteredProjects = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + const list = enrichedProjects.filter(item => { + if (!q) return true; + const titleMatch = (item.project.name || '').toLowerCase().includes(q); + const descMatch = (item.project.description || '').toLowerCase().includes(q); + const funderMatch = (item.fundingOrganisation?.name || '').toLowerCase().includes(q); + return titleMatch || descMatch || funderMatch; + }); + + return [...list].sort((a, b) => { + if (sortBy === 'name') { + return (a.project.name || '').localeCompare(b.project.name || ''); + } + if (sortBy === 'grant_amount') { + const valA = a.contract?.totalValue || a.award?.amount || 0; + const valB = b.contract?.totalValue || b.award?.amount || 0; + return valB - valA; + } + if (sortBy === 'days_left') { + return ( + calculateDaysLeft(a.project.endedAt || a.contract?.expiresAt) - + calculateDaysLeft(b.project.endedAt || b.contract?.expiresAt) + ); + } + // Default: 'date_added' (newest first) + const timeA = new Date(a.project.createdAt || 0).getTime(); + const timeB = new Date(b.project.createdAt || 0).getTime(); + return timeB - timeA; + }); + }, [enrichedProjects, searchQuery, sortBy]); + + // Calculate Overview 2x2 metrics matching the template + const overviewMetrics = useMemo(() => { + let runningProjects = 0; + let completedProjects = 0; + let pendingPayment = 0; + let totalPayment = 0; + + for (const item of enrichedProjects) { + const st = Number(item.project.status); + const allMilestonesVerified = + item.milestones.length > 0 && + item.milestones.every(m => Number(m.status) === ApprovalStatus.Approved); + + if (st === ActivityStatus.Inactive || allMilestonesVerified) { + completedProjects += 1; + } else { + runningProjects += 1; + } + + for (const d of item.disbursements) { + const dStatus = Number(d.status); + if (dStatus === ApprovalStatus.Approved) { + totalPayment += Number(d.amount || 0); + } else if ( + dStatus === ApprovalStatus.Pending || + dStatus === ApprovalStatus.Review + ) { + pendingPayment += Number(d.amount || 0); + } + } + } + + // If no disbursements have been recorded yet, reflect total awarded/contracted grant value in Total Payment + if (totalPayment === 0 && pendingPayment === 0) { + const totalAwarded = partnerSummary.awardsList.reduce( + (sum, a) => sum + Number(a.amount || 0), + 0 + ); + const totalContracted = partnerSummary.contractEntries.reduce( + (sum, c) => sum + Number(c.contract.totalValue || 0), + 0 + ); + totalPayment = totalContracted || totalAwarded; + } + + return { + runningProjects, + completedProjects, + pendingPayment, + totalPayment, + }; + }, [enrichedProjects, partnerSummary]); + + // Keep selectedProjectItem synchronized when queries refetch + const activeProjectModalItem = useMemo(() => { + if (!selectedProjectItem) return null; + return ( + enrichedProjects.find( + x => String(x.project.id) === String(selectedProjectItem.project.id) + ) || selectedProjectItem + ); + }, [selectedProjectItem, enrichedProjects]); + + // ============================================================================ + // MUTATIONS (Simple Partner Actions with Toast Rules) + // ============================================================================ + + const uploadEvidenceMutation = useMutation({ + mutationFn: async () => { + if (!activeProjectModalItem || !evidenceFile) { + throw new Error('Please choose a file to upload.'); + } + setUploadingEvidence(true); + const formData = new FormData(); + formData.append('file', evidenceFile); + formData.append('projectId', String(activeProjectModalItem.project.id)); + if (selectedMilestoneForUpload) { + formData.append('milestoneId', selectedMilestoneForUpload); + } + formData.append('evidenceType', String(selectedEvidenceType)); + return await uploadEvidenceFile(formData); + }, + onSuccess: () => { + setUploadingEvidence(false); + setEvidenceFile(null); + showSuccessToast('Document Submitted', 'Document uploaded and linked to your project.'); + queryClient.invalidateQueries({ queryKey: ['partner-project-details'] }); + }, + onError: (err) => { + setUploadingEvidence(false); + showErrorToast(err); + }, + }); + + const requestPaymentMutation = useMutation({ + mutationFn: async () => { + if (!activeProjectModalItem) throw new Error('No project selected.'); + const amt = Number(paymentAmount); + if (!amt || amt <= 0) throw new Error('Please enter a valid payment amount greater than zero.'); + return await createDisbursement({ + projectId: activeProjectModalItem.project.id, + milestoneId: paymentMilestoneId ? paymentMilestoneId : null, + amount: amt, + }); + }, + onSuccess: () => { + setPaymentAmount(''); + setPaymentMilestoneId(''); + showSuccessToast('Payment Requested', 'Your payment request has been submitted for review.'); + queryClient.invalidateQueries({ queryKey: ['partner-project-details'] }); + }, + onError: (err) => showErrorToast(err), + }); + + const logActivityMutation = useMutation({ + mutationFn: async () => { + if (!activeProjectModalItem) throw new Error('No project selected.'); + if (!activityTitle.trim()) throw new Error('Please enter a short title for this update.'); + return await createActivity({ + projectId: activeProjectModalItem.project.id, + category: activityCategory, + amount: Number(activityAmount) || 0, + title: activityTitle.trim(), + description: activityDescription.trim(), + performedAt: new Date().toISOString(), + }); + }, + onSuccess: () => { + setActivityTitle(''); + setActivityAmount(''); + setActivityDescription(''); + showSuccessToast('Update Saved', 'Project progress update logged.'); + queryClient.invalidateQueries({ queryKey: ['partner-project-details'] }); + }, + onError: (err) => showErrorToast(err), + }); + + const addMilestoneMutation = useMutation({ + mutationFn: async () => { + if (!activeProjectModalItem) throw new Error('No project selected.'); + if (!newMilestoneName.trim() || !newMilestoneDueAt) { + throw new Error('Please provide both a milestone name and due date.'); + } + return await createMilestone({ + projectId: activeProjectModalItem.project.id, + name: newMilestoneName.trim(), + dueAt: newMilestoneDueAt, + priority: 1, + }); + }, + onSuccess: () => { + setNewMilestoneName(''); + setNewMilestoneDueAt(''); + showSuccessToast('Milestone Added', 'Milestone added to your project schedule.'); + queryClient.invalidateQueries({ queryKey: ['partner-project-details'] }); + }, + onError: (err) => showErrorToast(err), + }); + + const signContractMutation = useMutation({ + mutationFn: async (contract: Contract) => { + if (contractFile) { + await uploadContractFile(contract.id, contractFile); + } + await executeContract({ + contractId: contract.id, + externalSignatureId: signatureRef.trim() || `SIG-${Date.now()}`, + signedDocumentUrl: contract.signedDocumentUrl || `s3://contracts/${contract.id}/signed.pdf`, + }); + }, + onSuccess: () => { + setContractFile(null); + setSignatureRef(''); + showSuccessToast('Agreement Executed', 'Contract signed and submitted.'); + queryClient.invalidateQueries({ queryKey: ['partner-applicant-data'] }); + }, + onError: (err) => showErrorToast(err), + }); + + const handleOpenSecureFile = async (type: 'contract' | 'evidence', id: string | number) => { + try { + const link = + type === 'contract' + ? await getContractSecureLink(id) + : await getEvidenceSecureLink(id); + if (link) { + window.open(link, '_blank', 'noopener,noreferrer'); + } else { + showInfoToast('File Unavailable', 'No file URL is attached to this record yet.'); + } + } catch (err) { + showErrorToast(err); + } + }; + + const handleSendContactMessage = async (e: React.FormEvent) => { + e.preventDefault(); + if (!messageSubject.trim() || !messageBody.trim()) { + showInfoToast('Missing Fields', 'Please enter both a subject and message.'); + return; + } + + try { + const targetOrg = contactTarget?.organisation || partnerSummary.primaryFundingOrg; + const recipientEmail = + contactTarget?.recipientEmail || + targetOrg?.email || + 'support@pfm.gov.za'; + const recipientId = + contactTarget?.recipientId || + targetOrg?.userId || + applicantUserId; + + await sendMessage({ + recipientId: String(recipientId), + recipientEmail, + recipientName: targetOrg?.name || 'Funding Administrator', + subject: messageSubject.trim(), + message: messageBody.trim(), + platform: NotificationPlatform.InApp, + organisationId: targetOrg?.id || 1, + }); + + showSuccessToast('Message Sent', 'Your message has been sent to the funding team.'); + setMessageSubject(''); + setMessageBody(''); + setContactTarget(null); + } catch (err) { + showErrorToast(err); + } + }; + + const openContactModalForProject = (item: EnrichedPartnerProject) => { + setContactTarget({ + organisation: item.fundingOrganisation || partnerSummary.primaryFundingOrg, + projectTitle: item.project.name || 'Grant Project', + recipientEmail: item.fundingOrganisation?.email || undefined, + recipientId: item.fundingOrganisation?.userId || undefined, + }); + setMessageSubject(`Enquiry regarding ${item.project.name || 'Project'}`); + setMessageBody(''); + }; + + return ( +
+ {/* Subtle Ambient Background Glow honoring PartnerUI's emerald/teal/cyan palette */} +
+
+
+
+
+ + {/* ===================================================================== + TOP NAVIGATION BAR (Matches Uploaded Template + Dark Emerald Theme) + ===================================================================== */} +
+
+ {/* Left: Brand Logo & Navigation Links */} +
+ + + +
+ + {/* Right: Search Input, Notification Bell, Partner Profile & Sign Out */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search projects..." + className="w-full pl-10 pr-4 py-2 rounded-xl bg-slate-900/90 border border-white/[0.08] text-sm text-slate-100 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/30 transition-all" + /> + {searchQuery && ( + + )} +
+ + + + {/* Notification Bell */} +
+ + + {showBellDropdown && ( +
+
+
+ Notifications + {unreadCount > 0 && ( + + {unreadCount} unread + + )} +
+ {unreadCount > 0 && ( + + )} +
+
+ {top5Unread.length === 0 ? ( +
+ You are all caught up! No new notifications. +
+ ) : ( + top5Unread.map(item => ( + + )) + )} +
+ +
+ )} +
+ + {/* User Avatar & Sign Out */} +
+
+ {getInitials(user?.name || user?.email)} +
+
+

+ {user?.name || user?.email || 'Partner User'} +

+

Partner Role

+
+ +
+
+
+ + {/* Mobile Navigation Bar */} +
+ {( + [ + { id: 'dashboard', label: 'Dashboard' }, + { id: 'messages', label: `Messages (${unreadCount})` }, + { id: 'contracts', label: 'Contracts' }, + { id: 'documents', label: 'Documents' }, + ] as { id: NavTab; label: string }[] + ).map(tab => ( + + ))} +
+
+ + {/* ===================================================================== + MAIN CONTENT AREA + ===================================================================== */} +
+ {isLoadingApplicantData ? ( +
+
+
+

Loading your Partner Workspace...

+

+ Retrieving your projects, funding awards, contracts, and milestones. +

+
+
+ ) : activeTab === 'dashboard' ? ( + /* ================================================================= + DASHBOARD VIEW — 2-COLUMN LAYOUT BASED ON UPLOADED TEMPLATE + Left (~2/3): "Ongoing Projects" + "Sort by date added" + Cards + Right (~1/3): "Overview" (2x2 Grid) + "Inbox" + ================================================================= */ +
+ {/* LEFT COLUMN: ONGOING PROJECTS (8 cols on xl) */} +
+ {/* Section Header */} +
+
+

+ Ongoing Projects +

+

+ Track your grant milestones, submit proof documents, and request payments simply. +

+
+ + {/* Sort Selector matching "Sort by date added v" in template */} +
+
+ + +
+
+
+ + {/* Project Cards List */} + {filteredProjects.length === 0 ? ( +
+
+ +
+
+

+ {searchQuery ? 'No matching projects found' : 'No active projects assigned yet'} +

+

+ {searchQuery + ? 'Try clearing your search filter to see all your funded projects.' + : 'Once your funding organisation registers your approved grant award and project, it will appear right here automatically.'} +

+
+ {partnerSummary.contractEntries.length > 0 && ( + + )} +
+ ) : ( +
+ {filteredProjects.map(item => { + const { + project, + award, + contract, + fundingOrganisation, + milestones, + evidence, + disbursements, + } = item; + + const grantAmount = contract?.totalValue || award?.amount || 0; + const disbursedAmount = disbursements + .filter(d => Number(d.status) === ApprovalStatus.Approved) + .reduce((sum, d) => sum + Number(d.amount || 0), 0); + const daysLeft = calculateDaysLeft(project.endedAt || contract?.expiresAt); + const docsSubmitted = evidence.length; + + // Build 5-node stepper matching the template: + // [Project start] --- [Milestone 1] --- [Milestone 2] --- [Milestone 3] --- [Milestone 4] + const sortedMilestones = [...milestones].sort( + (a, b) => new Date(a.dueAt || 0).getTime() - new Date(b.dueAt || 0).getTime() + ); + + const stepperNodes = [ + { + id: 'start', + label: 'Project start', + dateLabel: formatShortDate(project.startedAt || contract?.effectiveAt || project.createdAt), + state: 'completed' as 'completed' | 'current' | 'upcoming', + }, + ...[0, 1, 2, 3].map(idx => { + const m = sortedMilestones[idx]; + if (!m) { + return { + id: `m-placeholder-${idx}`, + label: `Milestone ${idx + 1}`, + dateLabel: 'Planned', + state: 'upcoming' as const, + }; + } + const mStatus = Number(m.status); + const isDone = mStatus === ApprovalStatus.Approved; + const isCurrent = + !isDone && + (mStatus === ApprovalStatus.Review || + idx === 0 || + Number(sortedMilestones[idx - 1]?.status) === ApprovalStatus.Approved); + + return { + id: String(m.id), + label: m.name || `Milestone ${idx + 1}`, + dateLabel: formatShortDate(m.dueAt), + state: isDone ? ('completed' as const) : isCurrent ? ('current' as const) : ('upcoming' as const), + }; + }), + ]; + + const funderName = + fundingOrganisation?.name || + partnerSummary.primaryFundingOrg?.name || + 'Funding Organisation'; + + return ( +
+
+ {/* LEFT PART OF CARD (9 cols): Title, 4 Metrics Row, Horizontal Milestone Stepper */} +
+ {/* Project Title & Description */} +
+
+

+ {project.name || 'Funded Project'} +

+ + {Number(project.status) === ActivityStatus.Inactive ? 'Completed' : 'Active Grant'} + +
+ {project.description && ( +

+ {project.description} +

+ )} +
+ + {/* 4 Metric Items Row (Matching template's 4 icon+value+sublabel columns) */} +
+ {/* Metric 1: Total Grant */} +
+
+ +
+
+

+ {formatZAR(grantAmount)} +

+

Awarded Grant

+
+
+ + {/* Metric 2: Paid / Disbursed */} +
+
+ +
+
+

+ {formatZAR(disbursedAmount)} +

+

Funds Received

+
+
+ + {/* Metric 3: Days Left */} +
+
+ +
+
+

+ {daysLeft} days +

+

Days left

+
+
+ + {/* Metric 4: Documents / Deliverables Submitted */} +
+
+ +
+
+

+ {docsSubmitted} docs +

+

Proof uploaded

+
+
+
+ + {/* Horizontal Milestone Stepper (Matching template's Project start -> Milestone 1..4) */} +
+
+ {stepperNodes.map((node, idx) => { + const isLast = idx === stepperNodes.length - 1; + const nextNode = stepperNodes[idx + 1]; + const lineCompleted = + node.state === 'completed' && + (nextNode?.state === 'completed' || nextNode?.state === 'current'); + + return ( +
+ {/* Node Circle + Connecting Line */} +
+ {node.state === 'completed' ? ( +
+ +
+ ) : node.state === 'current' ? ( +
+
+
+ ) : ( +
+ )} + + {!isLast && ( +
+ )} +
+ + {/* Node Title & Date */} +
+

+ {node.label} +

+

+ {node.dateLabel} +

+
+
+ ); + })} +
+
+
+ + {/* RIGHT PART OF CARD (3 cols): Funder Info + "Contact" & "View Details" Buttons */} +
+ {/* Funder Profile Block */} +
+
+ {getInitials(funderName)} +
+
+

+ {funderName} +

+

+ {fundingOrganisation?.email || 'Funding Partner'} +

+
+ + Verified Grantor +
+
+
+ + {/* Stacked Action Buttons: Contact & View Details */} +
+ + +
+
+
+
+ ); + })} +
+ )} +
+ + {/* RIGHT COLUMN: OVERVIEW (2x2 GRID) + INBOX (4 cols on xl) */} +
+ {/* 1. OVERVIEW CARD */} +
+

Overview

+
+
+ {/* Top-Left: Running Projects */} +
+

+ {overviewMetrics.runningProjects} +

+

Running Projects

+
+ + {/* Top-Right: Pending Payment */} +
+

+ {formatZAR(overviewMetrics.pendingPayment)} +

+

Pending Payment

+
+
+ +
+ {/* Bottom-Left: Completed Projects */} +
+

+ {overviewMetrics.completedProjects} +

+

Completed Projects

+
+ + {/* Bottom-Right: Total Payment */} +
+

+ {formatZAR(overviewMetrics.totalPayment)} +

+

Total Payment

+
+
+
+
+ + {/* 2. INBOX CARD */} +
+
+
+

Inbox

+ {unreadCount > 0 && ( + + {unreadCount} + + )} +
+ +
+ +
+ {inboxNotifications.length === 0 ? ( +
+
+ +
+
+

Your inbox is empty

+

+ Messages from your funding administrator will appear here. +

+
+ +
+ ) : ( + inboxNotifications.slice(0, 6).map(msg => { + const isUnread = Number(msg.status) !== NotificationStatus.Read; + const senderLabel = + msg.destination?.split('|')[0]?.replace('To:', '').trim() || + partnerSummary.primaryFundingOrg?.name || + 'Funding Office'; + + return ( + + ); + }) + )} +
+
+
+
+ ) : activeTab === 'messages' ? ( + /* ================================================================= + MESSAGES TAB — Simple Non-Technical Partner Inbox & Compose + ================================================================= */ +
+
+
+

Messages & Communications

+

+ Direct communication with your funding programme officers. +

+
+ +
+ +
+ {inboxNotifications.length === 0 ? ( +
+ No messages in your inbox yet. +
+ ) : ( + inboxNotifications.map(msg => { + const isUnread = Number(msg.status) !== NotificationStatus.Read; + return ( +
{ + markAsRead(msg.id); + setSelectedNotification(msg); + }} + className="p-5 hover:bg-white/[0.03] transition-colors flex items-start justify-between gap-4 cursor-pointer" + > +
+
+ {getInitials(msg.subject || 'PFM')} +
+
+
+

+ {msg.subject || 'Programme Notification'} +

+ {isUnread && ( + + New + + )} +
+

{msg.message}

+
+
+ + {formatFullDate(msg.createdAt)} + +
+ ); + }) + )} +
+
+ ) : activeTab === 'contracts' ? ( + /* ================================================================= + CONTRACTS & GRANTS TAB — View & Sign Contracts Simply + ================================================================= */ +
+
+

Contracts & Approved Grants

+

+ Review your approved funding awards and sign or download your grant agreements. +

+
+ +
+ {partnerSummary.contractEntries.length === 0 ? ( +
+ No contracts have been issued to your organisation yet. +
+ ) : ( + partnerSummary.contractEntries.map(({ contract, award, fundingOrganisation }) => { + const isExecuted = Number(contract.status) === ContractStatus.Executed; + const contractVer = (contract as any).version || 1; + return ( +
+
+
+ + Agreement v{contractVer} + + + {isExecuted ? 'Signed & Executed' : 'Action Required: Review & Sign'} + +
+

+ {fundingOrganisation?.name || 'Grant Funding Agreement'} +

+
+
+

Contract Value

+

+ {formatZAR(contract.totalValue || award?.amount || 0)} +

+
+
+

Valid Period

+

+ {formatShortDate(contract.effectiveAt)} – {formatFullDate(contract.expiresAt)} +

+
+
+
+ +
+ + {!isExecuted && canAdd('grant_financials') && ( + + )} +
+
+ ); + }) + )} +
+
+ ) : ( + /* ================================================================= + DELIVERABLES & DOCUMENTS TAB — All Uploaded Evidence Across Projects + ================================================================= */ +
+
+

Deliverables & Proof Documents

+

+ All reports, invoices, and milestone proof files submitted across your projects. +

+
+ +
+ {enrichedProjects.flatMap(p => + p.evidence.map(ev => ({ ev, project: p.project })) + ).length === 0 ? ( +
+

+ No documents uploaded yet. Open any project on your Dashboard and click "View Details" to upload proof documents. +

+
+ ) : ( +
+ {enrichedProjects + .flatMap(p => p.evidence.map(ev => ({ ev, project: p.project }))) + .map(({ ev, project }) => { + const evType = Number((ev as any).evidenceType ?? ev.type); + const evDate = (ev as any).submittedAt ?? ev.createdAt; + return ( +
+
+
+ +
+
+

+ {EVIDENCE_TYPE_LABELS[evType] || 'Supporting Document'}{' '} + + (v{ev.version || 1}) + +

+

+ Project: {project.name} • Submitted {formatFullDate(evDate)} +

+
+
+ +
+ ); + })} +
+ )} +
+
+ )} +
+ + {/* ===================================================================== + MODAL 1: PROJECT WORKSPACE ("View Details" button on Project Card) + Simple, non-technical tabs for Milestones, Proof Upload, Payments, Updates + ===================================================================== */} + {activeProjectModalItem && ( +
+
+ {/* Modal Header */} +
+
+
+ + Project Workspace + + + Funder: {activeProjectModalItem.fundingOrganisation?.name || 'Funding Partner'} + +
+

+ {activeProjectModalItem.project.name} +

+ {activeProjectModalItem.project.description && ( +

+ {activeProjectModalItem.project.description} +

+ )} +
+ +
+ + {/* Simple Step-by-Step Tabs */} +
+ {( + [ + { id: 'milestones', label: `1. Milestones (${activeProjectModalItem.milestones.length})` }, + { id: 'evidence', label: `2. Upload Proof (${activeProjectModalItem.evidence.length})` }, + { id: 'payments', label: `3. Request Payment (${activeProjectModalItem.disbursements.length})` }, + { id: 'activities', label: `4. Progress Updates (${activeProjectModalItem.activities.length})` }, + { id: 'contract', label: '5. Grant Agreement' }, + ] as const + ).map(t => ( + + ))} +
+ + {/* Modal Body */} +
+ {projectModalTab === 'milestones' && ( +
+
+

Project Milestones

+ {activeProjectModalItem.milestones.length === 0 ? ( +

+ No milestones have been added to this project yet. You can add one below. +

+ ) : ( +
+ {activeProjectModalItem.milestones.map((m, idx) => { + const isVerified = Number(m.status) === ApprovalStatus.Approved; + return ( +
+
+
+ {isVerified ? : idx + 1} +
+
+

{m.name}

+

+ Due: {formatFullDate(m.dueAt)} +

+
+
+
+ + {isVerified ? 'Verified' : 'In Progress'} + + +
+
+ ); + })} +
+ )} +
+ + {canAdd('projects_milestones') && ( +
+

+ Add a New Milestone +

+
+ setNewMilestoneName(e.target.value)} + placeholder="Milestone title (e.g. Phase 1 Delivery)" + className="sm:col-span-2 px-3.5 py-2 rounded-xl bg-slate-950 border border-white/10 text-xs text-white" + /> + setNewMilestoneDueAt(e.target.value)} + className="px-3.5 py-2 rounded-xl bg-slate-950 border border-white/10 text-xs text-white" + /> +
+ +
+ )} +
+ )} + + {projectModalTab === 'evidence' && ( +
+ {/* Simple Upload Box */} +
+
+ +

+ Upload Supporting Document or Proof +

+
+

+ Attach invoices, progress reports, photos, or certificates to prove milestone completion. +

+
+
+ + +
+
+ + +
+
+ +
+ setEvidenceFile(e.target.files?.[0] || null)} + className="text-xs text-slate-300 file:mr-3 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-xs file:font-semibold file:bg-emerald-500/15 file:text-emerald-300 hover:file:bg-emerald-500/25" + /> + +
+
+ + {/* Existing Evidence List */} +
+

+ Submitted Documents ({activeProjectModalItem.evidence.length}) +

+ {activeProjectModalItem.evidence.length === 0 ? ( +

+ No documents uploaded for this project yet. +

+ ) : ( + activeProjectModalItem.evidence.map(ev => { + const evType = Number((ev as any).evidenceType ?? ev.type); + const evDate = (ev as any).submittedAt ?? ev.createdAt; + return ( +
+
+ +
+

+ {EVIDENCE_TYPE_LABELS[evType] || 'Document'} (v{ev.version || 1}) +

+

+ Uploaded {formatFullDate(evDate)} +

+
+
+ +
+ ); + }) + )} +
+
+ )} + + {projectModalTab === 'payments' && ( +
+ {/* Request Payment Box */} +
+

Request a Grant Payment Drawdown

+

+ Select the completed milestone and enter the amount you are requesting. +

+
+
+ + setPaymentAmount(e.target.value)} + placeholder="e.g. 50000" + className="w-full px-3.5 py-2 rounded-xl bg-slate-950 border border-white/10 text-xs text-white" + /> +
+
+ + +
+
+ +
+ + {/* Payment History */} +
+

+ Payment Requests & Disbursements +

+ {activeProjectModalItem.disbursements.length === 0 ? ( +

+ No payment requests recorded for this project yet. +

+ ) : ( + activeProjectModalItem.disbursements.map(d => { + const isApproved = Number(d.status) === ApprovalStatus.Approved; + return ( +
+
+

+ {formatZAR(d.amount)} +

+

+ Requested {formatFullDate(d.createdAt)} +

+
+ + {isApproved ? 'Approved & Paid' : 'Pending Review'} + +
+ ); + }) + )} +
+
+ )} + + {projectModalTab === 'activities' && ( +
+
+

Post a Quick Progress Update

+
+ setActivityTitle(e.target.value)} + placeholder="What did your team accomplish?" + className="sm:col-span-2 px-3.5 py-2 rounded-xl bg-slate-950 border border-white/10 text-xs text-white" + /> + setActivityAmount(e.target.value)} + placeholder="Spend amount (ZAR, optional)" + className="px-3.5 py-2 rounded-xl bg-slate-950 border border-white/10 text-xs text-white" + /> +
+