import { apiClient } from './httpClient'; import { Role, Claim } from '../types'; import { ROLE_CLAIM_DEFINITIONS } from '../auth/rbac'; export const LOCAL_STORAGE_SYSTEM_ROLES_KEY = 'pfm_cache_system_roles'; export const LOCAL_STORAGE_ROLE_CLAIMS_PREFIX = 'pfm_cache_role_claims_'; export const LOCAL_STORAGE_SYSTEM_CLAIMS_KEY = 'pfm_cache_system_claims'; export const LOCAL_STORAGE_RBAC_VERSION_KEY = 'pfm_rbac_cache_version'; export const CURRENT_RBAC_CACHE_VERSION = 'v2.2'; /** * Invalidates and purges legacy cached claims if the schema version differs. */ export function ensureFreshRbacCache(): void { if (typeof window === 'undefined') return; try { const version = localStorage.getItem(LOCAL_STORAGE_RBAC_VERSION_KEY); if (version !== CURRENT_RBAC_CACHE_VERSION) { // Clear legacy claims and caches const keysToRemove: string[] = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key && (key.startsWith(LOCAL_STORAGE_ROLE_CLAIMS_PREFIX) || key === 'pfm_user_claims')) { keysToRemove.push(key); } } keysToRemove.forEach((k) => localStorage.removeItem(k)); inMemoryRoleClaims.clear(); localStorage.setItem(LOCAL_STORAGE_RBAC_VERSION_KEY, CURRENT_RBAC_CACHE_VERSION); } } catch {} } // In-memory runtime caches let inMemorySystemRoles: Role[] | null = null; const inMemoryRoleClaims = new Map(); let inMemorySystemClaims: Claim[] | null = null; const inMemoryUserRoles = new Map(); const inMemoryUserClaims = new Map(); const USER_RBAC_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes // Initial check on load ensureFreshRbacCache(); // Standard role claim defaults matching PFM Identity Server specification export const DEFAULT_ROLE_CLAIMS: Record = { contributor: [ { claimId: 30, claimType: 'projects_milestones', claimValue: 'view, add' }, { claimId: 31, claimType: 'grant_financials', claimValue: 'view, add' }, { claimId: 32, claimType: 'evidence_risks', claimValue: 'view, add' }, { claimId: 33, claimType: 'beneficiaries', claimValue: 'view, add' }, { claimId: 34, claimType: 'change_management', claimValue: 'view, add' }, { claimId: 35, claimType: 'site_visits', claimValue: 'view, add' }, { claimId: 36, claimType: 'settings_invites', claimValue: 'view, add' }, ], supervisor: [ { claimId: 20, claimType: 'dashboard', claimValue: 'view, export' }, { claimId: 38, claimType: 'compliance', claimValue: 'view, export' }, { claimId: 21, claimType: 'projects_milestones', claimValue: 'view, add, update, export' }, { claimId: 22, claimType: 'grant_financials', claimValue: 'view, add, update, export' }, { claimId: 23, claimType: 'evidence_risks', claimValue: 'view, add, update, export' }, { claimId: 24, claimType: 'beneficiaries', claimValue: 'view, add, update, export' }, { claimId: 25, claimType: 'change_management', claimValue: 'view, add, update, export' }, { claimId: 26, claimType: 'site_visits', claimValue: 'view, add, update, export' }, { claimId: 27, claimType: 'settings_invites', claimValue: 'view, add, update, export' }, { claimId: 28, claimType: 'settings_users', claimValue: 'view, add, update, export' }, ], manager: [ { claimId: 10, claimType: 'dashboard', claimValue: 'all' }, { claimId: 39, claimType: 'compliance', claimValue: 'all' }, { claimId: 11, claimType: 'projects_milestones', claimValue: 'all' }, { claimId: 12, claimType: 'grant_financials', claimValue: 'all' }, { claimId: 13, claimType: 'evidence_risks', claimValue: 'all' }, { claimId: 14, claimType: 'beneficiaries', claimValue: 'all' }, { claimId: 15, claimType: 'change_management', claimValue: 'all' }, { claimId: 16, claimType: 'site_visits', claimValue: 'all' }, { claimId: 17, claimType: 'settings_invites', claimValue: 'all' }, { claimId: 18, claimType: 'settings_users', claimValue: 'all' }, { claimId: 19, claimType: 'settings_auditlog', claimValue: 'all' }, { claimId: 29, claimType: 'settings_notifications', claimValue: 'all' }, { claimId: 37, claimType: 'tenant_self_service', claimValue: 'all' }, ], executive: [ { claimId: 1, claimType: 'dashboard', claimValue: 'all' }, { claimId: 40, claimType: 'compliance', claimValue: 'all' }, { claimId: 2, claimType: 'governance', claimValue: 'all' }, { claimId: 3, claimType: 'projects_milestones', claimValue: 'all' }, { claimId: 4, claimType: 'grant_financials', claimValue: 'all' }, { claimId: 5, claimType: 'evidence_risks', claimValue: 'all' }, { claimId: 6, claimType: 'tenant_self_service', claimValue: 'all' }, ], admin: [ { claimId: 99, claimType: 'unrestricted', claimValue: 'all' }, ], }; /** * Normalizes claim objects to contain both claimType/claimValue and type/value */ export function normalizeClaim(c: any): Claim { const claimType = c.claimType || c.type || c.name || ''; const claimValue = c.claimValue || c.value || 'all'; const claimId = c.claimId ?? c.id ?? undefined; return { ...c, type: claimType, value: claimValue, claimType, claimValue, claimId, }; } /** * Retrieve cached system roles from memory or localStorage. */ export function getCachedSystemRoles(): Role[] | null { if (inMemorySystemRoles && inMemorySystemRoles.length > 0) { return inMemorySystemRoles; } if (typeof window !== 'undefined') { try { const raw = localStorage.getItem(LOCAL_STORAGE_SYSTEM_ROLES_KEY); if (raw) { const parsed = JSON.parse(raw); if (Array.isArray(parsed) && parsed.length > 0) { inMemorySystemRoles = parsed; return parsed; } } } catch {} } return null; } /** * Store system roles into memory and localStorage. */ export function setCachedSystemRoles(roles: Role[]): void { inMemorySystemRoles = roles; if (typeof window !== 'undefined') { try { localStorage.setItem(LOCAL_STORAGE_SYSTEM_ROLES_KEY, JSON.stringify(roles)); } catch {} } } /** * Retrieve cached role claims for a specific role ID or role name. */ export function getCachedRoleClaims(roleKey: string): Claim[] | null { const normalizedKey = roleKey.toLowerCase().trim(); if (inMemoryRoleClaims.has(normalizedKey)) { return inMemoryRoleClaims.get(normalizedKey)!; } if (typeof window !== 'undefined') { try { const raw = localStorage.getItem(`${LOCAL_STORAGE_ROLE_CLAIMS_PREFIX}${normalizedKey}`); if (raw) { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { const normalized = parsed.map(normalizeClaim); inMemoryRoleClaims.set(normalizedKey, normalized); return normalized; } } } catch {} } return null; } /** * Store role claims into memory and localStorage. */ export function setCachedRoleClaims(roleKey: string, claims: Claim[]): void { const normalizedKey = roleKey.toLowerCase().trim(); const normalized = claims.map(normalizeClaim); inMemoryRoleClaims.set(normalizedKey, normalized); if (typeof window !== 'undefined') { try { localStorage.setItem( `${LOCAL_STORAGE_ROLE_CLAIMS_PREFIX}${normalizedKey}`, JSON.stringify(normalized) ); } catch {} } } /** * Fetches system roles from backend API, caching the result in memory and localStorage. * Checks cache first to avoid redundant network round trips. */ export async function fetchAndCacheSystemRoles(forceRefresh = false): Promise { if (!forceRefresh) { const cached = getCachedSystemRoles(); if (cached && cached.length > 0) return cached; } try { const res = await apiClient.get('/v1/api/users/system/roles'); if (res.data && Array.isArray(res.data) && res.data.length > 0) { setCachedSystemRoles(res.data); return res.data; } } catch (err) { // If request fails, fall back to cached roles } const cached = getCachedSystemRoles(); if (cached && cached.length > 0) return cached; // Fallback defaults if offline / bootstrapping const defaults: Role[] = [ { id: '1', name: 'Admin' }, { id: '2', name: 'Executive' }, { id: '3', name: 'Supervisor' }, { id: '4', name: 'Manager' }, { id: '5', name: 'Contributor' }, ]; setCachedSystemRoles(defaults); return defaults; } const GUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; /** * Fetches role claims for a role from the API, caching them under role ID and role name. * Uses cache-first strategy to eliminate sequential network waterfalls on startup. */ export async function fetchAndCacheRoleClaims( roleId: string, roleName?: string, forceRefresh = false ): Promise { const roleIdKey = roleId.toLowerCase().trim(); const roleNameKey = (roleName || '').toLowerCase().trim(); // 1. Check in-memory and localStorage cache first if (!forceRefresh) { const cachedById = getCachedRoleClaims(roleIdKey); if (cachedById && cachedById.length > 0) return cachedById; if (roleNameKey) { const cachedByName = getCachedRoleClaims(roleNameKey); if (cachedByName && cachedByName.length > 0) return cachedByName; } } // 2. Resolve actual GUID if roleId is not formatted as a GUID let effectiveGuid = GUID_REGEX.test(roleId.trim()) ? roleId.trim() : null; if (!effectiveGuid) { const cachedRoles = getCachedSystemRoles(); if (cachedRoles && cachedRoles.length > 0) { const match = cachedRoles.find( (r) => (r.name && r.name.toLowerCase() === roleNameKey) || (r.name && r.name.toLowerCase() === roleIdKey) || String(r.id).toLowerCase() === roleIdKey ); if (match && match.id && GUID_REGEX.test(String(match.id).trim())) { effectiveGuid = String(match.id).trim(); } } } if (effectiveGuid) { try { const res = await apiClient.get(`/v1/api/users/system/roles/${effectiveGuid}/claims`); if (res.data && Array.isArray(res.data) && res.data.length > 0) { const normalized = res.data.map(normalizeClaim); setCachedRoleClaims(roleIdKey, normalized); if (roleNameKey) setCachedRoleClaims(roleNameKey, normalized); if (effectiveGuid.toLowerCase() !== roleIdKey) { setCachedRoleClaims(effectiveGuid.toLowerCase(), normalized); } return normalized; } } catch (err) { // Network or server error, proceed to cache / defaults fallback } } // Re-check cache by role ID or name const cachedById = getCachedRoleClaims(roleIdKey); if (cachedById && cachedById.length > 0) return cachedById; if (roleNameKey) { const cachedByName = getCachedRoleClaims(roleNameKey); if (cachedByName && cachedByName.length > 0) return cachedByName; } // Fallback from predefined matrix const fallback = (roleNameKey && DEFAULT_ROLE_CLAIMS[roleNameKey]) || DEFAULT_ROLE_CLAIMS[roleIdKey] || []; const normalized = fallback.map(normalizeClaim); setCachedRoleClaims(roleIdKey, normalized); if (roleNameKey) setCachedRoleClaims(roleNameKey, normalized); return normalized; } export function clearCachedUserRbac(userId?: string): void { if (userId) { const key = userId.toLowerCase().trim(); inMemoryUserRoles.delete(key); inMemoryUserClaims.delete(key); if (typeof window !== 'undefined') { try { localStorage.removeItem(`pfm_cache_user_roles_${key}`); localStorage.removeItem(`pfm_cache_user_claims_${key}`); } catch {} } } else { inMemoryUserRoles.clear(); inMemoryUserClaims.clear(); if (typeof window !== 'undefined') { try { const keysToRemove: string[] = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && (k.startsWith('pfm_cache_user_roles_') || k.startsWith('pfm_cache_user_claims_'))) { keysToRemove.push(k); } } keysToRemove.forEach((k) => localStorage.removeItem(k)); } catch {} } } } /** * Direct API calls for user roles and user claims with session-level caching */ export async function getUserRolesFromApi(userId: string, forceRefresh = false): Promise { const key = userId.toLowerCase().trim(); if (!forceRefresh) { const cached = inMemoryUserRoles.get(key); if (cached && Date.now() - cached.timestamp < USER_RBAC_CACHE_TTL_MS) { return cached.roles; } if (typeof window !== 'undefined') { try { const raw = localStorage.getItem(`pfm_cache_user_roles_${key}`); if (raw) { const parsed = JSON.parse(raw); if (Array.isArray(parsed) && parsed.length > 0) { inMemoryUserRoles.set(key, { roles: parsed, timestamp: Date.now() }); return parsed; } } } catch {} } } try { const res = await apiClient.get(`/v1/api/users/${userId}/roles`); const roles = res.data || []; if (roles.length > 0) { inMemoryUserRoles.set(key, { roles, timestamp: Date.now() }); if (typeof window !== 'undefined') { try { localStorage.setItem(`pfm_cache_user_roles_${key}`, JSON.stringify(roles)); } catch {} } } return roles; } catch { return inMemoryUserRoles.get(key)?.roles || []; } } export async function getUserClaimsFromApi(userId: string, forceRefresh = false): Promise { const key = userId.toLowerCase().trim(); if (!forceRefresh) { const cached = inMemoryUserClaims.get(key); if (cached && Date.now() - cached.timestamp < USER_RBAC_CACHE_TTL_MS) { return cached.claims; } if (typeof window !== 'undefined') { try { const raw = localStorage.getItem(`pfm_cache_user_claims_${key}`); if (raw) { const parsed = JSON.parse(raw); if (Array.isArray(parsed) && parsed.length > 0) { const normalized = parsed.map(normalizeClaim); inMemoryUserClaims.set(key, { claims: normalized, timestamp: Date.now() }); return normalized; } } } catch {} } } try { const res = await apiClient.get(`/v1/api/users/${userId}/claims`); const claims = (res.data || []).map(normalizeClaim); if (claims.length > 0) { inMemoryUserClaims.set(key, { claims, timestamp: Date.now() }); if (typeof window !== 'undefined') { try { localStorage.setItem(`pfm_cache_user_claims_${key}`, JSON.stringify(claims)); } catch {} } } return claims; } catch { return inMemoryUserClaims.get(key)?.claims || []; } } /** * Resolves user roles, role claims, and user claims (including tenant and tenant_id) * concurrently using Promise.allSettled and caches system roles and claims. */ export async function resolveUserRolesAndClaims( userSub: string, initialClaims: Record = {}, currentProfile: { role?: string; tenant?: string; tenant_id?: number } = {} ): Promise<{ roles: string[]; claims: Record; primaryRole: string; tenant?: string; tenant_id?: number; }> { ensureFreshRbacCache(); const mergedClaims: Record = { ...initialClaims }; const resolvedRoles: string[] = []; let userRoleObjects: Role[] = []; let primaryRole = currentProfile.role || (initialClaims.role as string) || ''; let tenant = currentProfile.tenant || (initialClaims.tenant as string) || undefined; let tenant_id = currentProfile.tenant_id ?? (initialClaims.tenant_id ? Number(initialClaims.tenant_id) : undefined); // 1. Concurrently fetch user's roles and user's claims in parallel const [rolesResult, claimsResult] = await Promise.allSettled([ userSub ? getUserRolesFromApi(userSub) : Promise.resolve([]), userSub ? getUserClaimsFromApi(userSub) : Promise.resolve([]), ]); if (rolesResult.status === 'fulfilled' && Array.isArray(rolesResult.value) && rolesResult.value.length > 0) { userRoleObjects = rolesResult.value; rolesResult.value.forEach((r) => { if (r.name && !resolvedRoles.includes(r.name)) { resolvedRoles.push(r.name); } }); if (resolvedRoles.length > 0) { primaryRole = resolvedRoles[0]; } } // Fallback to initial role if API returned empty if (resolvedRoles.length === 0 && primaryRole) { resolvedRoles.push(primaryRole); } // 2. Concurrently fetch role claims for all resolved roles in parallel const allRoleClaims: Claim[] = []; const roleClaimsPromises = resolvedRoles.map(async (roleName) => { const matchedRoleObj = userRoleObjects.find( (r) => r.name && r.name.toLowerCase() === roleName.toLowerCase() ); const roleIdToPass = matchedRoleObj?.id ? String(matchedRoleObj.id) : roleName; return fetchAndCacheRoleClaims(roleIdToPass, roleName); }); const roleClaimsResults = await Promise.allSettled(roleClaimsPromises); roleClaimsResults.forEach((res) => { if (res.status === 'fulfilled' && Array.isArray(res.value)) { res.value.forEach((rc) => { const norm = normalizeClaim(rc); allRoleClaims.push(norm); if (norm.claimType) { mergedClaims[norm.claimType] = norm.claimValue || 'all'; } }); } }); if (allRoleClaims.length > 0) { mergedClaims.role_claims = allRoleClaims; } // 3. Process user claims if (claimsResult.status === 'fulfilled' && Array.isArray(claimsResult.value) && claimsResult.value.length > 0) { claimsResult.value.forEach((c) => { const norm = normalizeClaim(c); const type = (norm.claimType || '').toLowerCase().trim(); const val = norm.claimValue; if (type === 'tenant' && val) { tenant = String(val); mergedClaims.tenant = String(val); } else if ((type === 'tenant_id' || type === 'tenantid') && val !== undefined && val !== null) { const parsed = Number(val); if (!isNaN(parsed) && isFinite(parsed)) { tenant_id = parsed; mergedClaims.tenant_id = parsed; } } if (norm.claimType) { mergedClaims[norm.claimType] = norm.claimValue; } }); mergedClaims.user_claims = claimsResult.value.map(normalizeClaim); } // 4. Background preload all system roles & role claims for reference (non-blocking) preloadAndCacheAllRoleClaims().catch(() => {}); return { roles: resolvedRoles, claims: mergedClaims, primaryRole, tenant, tenant_id, }; } /** * Preloads and caches all system roles and their respective role claims concurrently. */ export async function preloadAndCacheAllRoleClaims(): Promise { const roles = await fetchAndCacheSystemRoles(); await Promise.allSettled( roles.map((role) => fetchAndCacheRoleClaims(role.id || role.Id || '', role.name || role.Name || '')) ); } /** * Retrieves all system claims in the identity management system. * Any claim is assignable to an authorized user (with admin being a special case). */ export async function getSystemClaims(): Promise { if (inMemorySystemClaims && inMemorySystemClaims.length > 0) { return inMemorySystemClaims; } if (typeof window !== 'undefined') { try { const raw = localStorage.getItem(LOCAL_STORAGE_SYSTEM_CLAIMS_KEY); if (raw) { const parsed = JSON.parse(raw); if (Array.isArray(parsed) && parsed.length > 0) { inMemorySystemClaims = parsed; return parsed; } } } catch {} } const claimsMap = new Map(); // 1. Try system claims endpoint directly try { const res = await apiClient.get('/v1/api/users/system/claims'); if (res.data && Array.isArray(res.data)) { res.data.forEach((c) => { const norm = normalizeClaim(c); if (norm.claimType) claimsMap.set(norm.claimType, norm); }); } } catch {} // 2. Aggregate role claims from all system roles try { const roles = await fetchAndCacheSystemRoles(); for (const r of roles) { const claims = await fetchAndCacheRoleClaims(r.id || r.Id || '', r.name || r.Name || ''); claims.forEach((c) => { const norm = normalizeClaim(c); if (norm.claimType && !claimsMap.has(norm.claimType)) { claimsMap.set(norm.claimType, norm); } }); } } catch {} // 3. Ensure all standard defined claims from RBAC specification exist ROLE_CLAIM_DEFINITIONS.forEach((def, index) => { if (!claimsMap.has(def.claim)) { claimsMap.set(def.claim, { claimId: index + 1, claimType: def.claim, claimValue: 'all', type: def.claim, value: 'all', }); } }); const allClaims = Array.from(claimsMap.values()); inMemorySystemClaims = allClaims; if (typeof window !== 'undefined') { try { localStorage.setItem(LOCAL_STORAGE_SYSTEM_CLAIMS_KEY, JSON.stringify(allClaims)); } catch {} } return allClaims; }