Implemented partnerui

This commit is contained in:
2026-10-23 00:58:47 +02:00
parent 9ed210613e
commit 8f58e15f0f
27 changed files with 11416 additions and 983 deletions
@@ -14,8 +14,8 @@ export function hashParam(id: number | string | null | undefined): string {
// Users & Integration Endpoints
// ----------------------------------------------------------------------
export const usersApi = {
getUser: (userId: string | number) =>
apiClient.get<Models.User>(`/v1/api/users/${hashParam(userId)}`),
getUser: (userId: string) =>
apiClient.get<Models.User>(`/v1/api/users/${userId}`),
getUsersPaged: (page: number, pageSize: number, filter?: Models.UserFilterRequest) =>
apiClient.post<Models.User[]>(`/v1/api/users/${page}/${pageSize}`, filter || {}),
@@ -23,8 +23,8 @@ export const usersApi = {
syncIntegration: (payload: any) =>
apiClient.post<any>('/v1/api/integration/sync', payload),
updateUserStatus: (userId: string | number, status: string | number) =>
apiClient.get<any>(`/v1/api/users/${hashParam(userId)}/status/${status}`),
updateUserStatus: (userId: string, status: string | number) =>
apiClient.get<any>(`/v1/api/users/${userId}/status/${status}`),
};
// ----------------------------------------------------------------------
@@ -72,7 +72,7 @@ export const projectsApi = {
// Outcomes
createOutcome: (payload: Models.CreateOutcomeRequest) =>
apiClient.post<Models.Outcome>('/v1/api/projects/beneficiaris/outcomes', payload),
apiClient.post<Models.Outcome>('/v1/api/projects/beneficiaries/outcomes', payload),
updateOutcome: (payload: Models.UpdateOutcomeRequest) =>
apiClient.put<Models.Outcome>('/v1/api/projects/outcomes', payload),
@@ -403,14 +403,17 @@ export const communicationsApi = {
getInvitesPaged: (page: number, pageSize: number) =>
apiClient.get<Models.Invite[]>(`/v1/api/communications/invites/${page}/${pageSize}`),
getApplicantInvites: (applicantUserId: string | number) =>
apiClient.get<Models.Invite[]>(`/v1/api/communications/invites/${hashParam(applicantUserId)}`),
getApplicantData: (applicantUserId: string) =>
apiClient.get<Models.ApplicantData[]>(`/v1/api/communications/applicants/${applicantUserId}/all`),
getApplicantInvite: (applicantUserId: string | number) =>
apiClient.get<Models.Invite>(`/v1/api/communications/invites/applicants/${hashParam(applicantUserId)}`),
getApplicantInvites: (applicantUserId: string) =>
apiClient.get<Models.Invite[]>(`/v1/api/communications/invites/${applicantUserId}`),
getApplicantInvite: (applicantUserId: string) =>
apiClient.get<Models.Invite>(`/v1/api/communications/invites/applicants/${applicantUserId}`),
getNotifications: (organisationId: string | number) =>
apiClient.get<Models.Notification[]>(`/v1/api/communications/norifications/${hashParam(organisationId)}`),
apiClient.get<Models.Notification[]>(`/v1/api/communications/notifications/${hashParam(organisationId)}`),
};
// Consolidated export
File diff suppressed because it is too large Load Diff
@@ -1,694 +1 @@
/**
* OpenAPI TypeScript Models for PostFundManagement.Api v1
* Synchronized from https://api.ifri.khongisa.co.za/openapi/v1/
*/
export interface ProblemDetails {
type?: string | null;
title?: string | null;
status?: number | string | null;
detail?: string | null;
instance?: string | null;
[key: string]: any;
}
export type ActivityStatus = 0 | 1 | 2 | 3 | 4 | 'Planned' | 'InProgress' | 'Completed' | 'Delayed' | 'Cancelled' | number | string;
export type ApprovalStatus = 0 | 1 | 2 | 3 | 'Draft' | 'PendingApproval' | 'Approved' | 'Rejected' | number | string;
export type ContractStatus = 0 | 1 | 2 | 3 | 4 | 'Draft' | 'Active' | 'Executed' | 'Terminated' | 'Expired' | number | string;
export type EvidenceType = 0 | 1 | 2 | 3 | 4 | 5 | 'Document' | 'Invoice' | 'Receipt' | 'SitePhoto' | 'Report' | 'Other' | number | string;
export type IndustrySector = number | string;
export type NotificationPlatform = 0 | 1 | 2 | 'Email' | 'SMS' | 'InApp' | number | string;
export type NotificationStatus = 0 | 1 | 2 | 3 | 'Queued' | 'Sent' | 'Failed' | 'Delivered' | number | string;
export type OrganisationType = 0 | 1 | 2 | 3 | 'Grantee' | 'Funder' | 'Partner' | 'Auditor' | number | string;
export type Priority = 0 | 1 | 2 | 3 | 'Low' | 'Medium' | 'High' | 'Critical' | number | string;
export type ProjectType = 0 | 1 | 2 | 3 | 'Grant' | 'Loan' | 'Equity' | 'Hybrid' | number | string;
export type RiskImpact = 0 | 1 | 2 | 3 | 'Low' | 'Medium' | 'High' | 'Critical' | number | string;
export type RiskLikelihood = 0 | 1 | 2 | 3 | 'Rare' | 'Unlikely' | 'Possible' | 'Likely' | number | string;
export type UnitOfMeasure = number | string;
export interface Pagination {
page?: number;
pageSize?: number;
totalRecords?: number;
totalPages?: number;
}
export interface User {
id?: string;
sub?: string;
email?: string;
name?: string;
role?: string;
tenant?: string;
tenant_id?: number | string;
status?: string | number;
createdAt?: string;
updatedAt?: string;
[key: string]: any;
}
export interface UserFilterRequest {
email?: string;
name?: string;
role?: string;
status?: string;
}
export interface Activity {
id?: string | number;
projectId?: string | number;
title?: string;
description?: string;
status?: ActivityStatus;
startDate?: string;
endDate?: string;
progressPercentage?: number;
[key: string]: any;
}
export interface CreateActivityRequest {
projectId: string | number;
title: string;
description?: string;
startDate?: string;
endDate?: string;
status?: ActivityStatus;
}
export interface Beneficiary {
id?: string | number;
projectId?: string | number;
name?: string;
identificationNumber?: string;
contactNumber?: string;
email?: string;
location?: string;
gender?: string;
age?: number;
[key: string]: any;
}
export interface CreateBeneficiaryRequest {
projectId: string | number;
name: string;
identificationNumber?: string;
contactNumber?: string;
email?: string;
location?: string;
gender?: string;
age?: number;
}
export interface UpdateBeneficiaryRequest {
id: string | number;
projectId?: string | number;
name?: string;
identificationNumber?: string;
contactNumber?: string;
email?: string;
location?: string;
gender?: string;
age?: number;
}
export interface Outcome {
id?: string | number;
beneficiaryId?: string | number;
projectId?: string | number;
title?: string;
description?: string;
targetMetric?: string;
achievedMetric?: string;
dateRecorded?: string;
[key: string]: any;
}
export interface CreateOutcomeRequest {
beneficiaryId?: string | number;
projectId?: string | number;
title: string;
description?: string;
targetMetric?: string;
achievedMetric?: string;
dateRecorded?: string;
}
export interface UpdateOutcomeRequest {
id: string | number;
title?: string;
description?: string;
targetMetric?: string;
achievedMetric?: string;
}
export interface ChangeRequest {
id?: string | number;
projectId?: string | number;
reason?: string;
proposedChanges?: string;
requestedBy?: string;
status?: ApprovalStatus;
requestDate?: string;
approvalDate?: string;
[key: string]: any;
}
export interface CreateChangeRequest {
projectId: string | number;
reason: string;
proposedChanges: string;
}
export interface UpdateChangeRequest {
id: string | number;
reason?: string;
proposedChanges?: string;
status?: ApprovalStatus;
}
export interface ComplianceItem {
id?: string | number;
projectId?: string | number;
title?: string;
description?: string;
isCompliant?: boolean;
dueDate?: string;
verifiedAt?: string;
verifiedBy?: string;
[key: string]: any;
}
export interface CreateComplianceItemRequest {
projectId: string | number;
title: string;
description?: string;
dueDate?: string;
}
export interface UpdateComplianceItemRequest {
id: string | number;
title?: string;
description?: string;
isCompliant?: boolean;
dueDate?: string;
}
export interface Issue {
id?: string | number;
projectId?: string | number;
title?: string;
description?: string;
priority?: Priority;
status?: string;
reportedBy?: string;
resolvedAt?: string;
[key: string]: any;
}
export interface CreateIssueRequest {
projectId: string | number;
title: string;
description?: string;
priority?: Priority;
}
export interface UpdateIssueRequest {
id: string | number;
title?: string;
description?: string;
priority?: Priority;
status?: string;
}
export interface Milestone {
id?: string | number;
projectId?: string | number;
title?: string;
description?: string;
dueDate?: string;
amount?: number;
status?: ApprovalStatus;
completionPercentage?: number;
[key: string]: any;
}
export interface CreateMilestoneRequest {
projectId: string | number;
title: string;
description?: string;
dueDate?: string;
amount?: number;
}
export interface UpdateMilestoneRequest {
id: string | number;
title?: string;
description?: string;
dueDate?: string;
amount?: number;
status?: ApprovalStatus;
completionPercentage?: number;
}
export interface Project {
id?: string | number;
programmeId?: string | number;
organisationId?: string | number;
name?: string;
code?: string;
description?: string;
type?: ProjectType;
startDate?: string;
endDate?: string;
budget?: number;
status?: string;
[key: string]: any;
}
export interface CreateProjectRequest {
programmeId: string | number;
organisationId: string | number;
name: string;
code?: string;
description?: string;
type?: ProjectType;
startDate?: string;
endDate?: string;
budget?: number;
}
export interface UpdateProjectRequest {
id: string | number;
name?: string;
description?: string;
budget?: number;
startDate?: string;
endDate?: string;
}
export interface UpdateProjectStatusRequest {
projectId: string | number;
status: string | number;
}
export interface SiteVisit {
id?: string | number;
projectId?: string | number;
visitDate?: string;
visitorName?: string;
findings?: string;
recommendations?: string;
[key: string]: any;
}
export interface CreateSiteVisitRequest {
projectId: string | number;
visitDate: string;
visitorName: string;
findings?: string;
recommendations?: string;
}
export interface UpdateSiteVisitRequest {
id: string | number;
visitDate?: string;
visitorName?: string;
findings?: string;
recommendations?: string;
}
export interface Award {
id?: string | number;
projectId?: string | number;
amount?: number;
awardDate?: string;
conditions?: string;
[key: string]: any;
}
export interface CreateAwardRequest {
projectId: string | number;
amount: number;
awardDate?: string;
conditions?: string;
}
export interface Budget {
id?: string | number;
projectId?: string | number;
category?: string;
allocatedAmount?: number;
spentAmount?: number;
notes?: string;
[key: string]: any;
}
export interface CreateBudgetRequest {
projectId: string | number;
category: string;
allocatedAmount: number;
notes?: string;
}
export interface UpdateBudgetRequest {
id: string | number;
category?: string;
allocatedAmount?: number;
spentAmount?: number;
notes?: string;
}
export interface Contract {
id?: string | number;
projectId?: string | number;
title?: string;
contractNumber?: string;
status?: ContractStatus;
signedDate?: string;
expiryDate?: string;
value?: number;
[key: string]: any;
}
export interface CreateContractRequest {
projectId: string | number;
title: string;
contractNumber?: string;
signedDate?: string;
expiryDate?: string;
value?: number;
}
export interface UpdateContractRequest {
id: string | number;
title?: string;
status?: ContractStatus;
expiryDate?: string;
value?: number;
}
export interface ExecuteContractRequest {
contractId: string | number;
executionDate: string;
notes?: string;
}
export interface Instrument {
id?: string | number;
programmeId?: string | number;
name?: string;
type?: string;
description?: string;
terms?: string;
[key: string]: any;
}
export interface CreateInstrumentRequest {
programmeId: string | number;
name: string;
type?: string;
description?: string;
terms?: string;
}
export interface UpdateInstrumentRequest {
id: string | number;
name?: string;
type?: string;
description?: string;
terms?: string;
}
export interface Organisation {
id?: string | number;
name?: string;
registrationNumber?: string;
type?: OrganisationType;
email?: string;
phone?: string;
address?: string;
[key: string]: any;
}
export interface CreateOrganisationRequest {
name: string;
registrationNumber?: string;
type?: OrganisationType;
email?: string;
phone?: string;
address?: string;
}
export interface UpdateOrganisationRequest {
id: string | number;
name?: string;
registrationNumber?: string;
type?: OrganisationType;
email?: string;
phone?: string;
address?: string;
}
export interface Portfolio {
id?: string | number;
organisationId?: string | number;
name?: string;
description?: string;
totalValue?: number;
[key: string]: any;
}
export interface CreatePortfolioRequest {
organisationId: string | number;
name: string;
description?: string;
}
export interface UpdatePortfolioRequest {
id: string | number;
name?: string;
description?: string;
}
export interface Programme {
id?: string | number;
portfolioId?: string | number;
name?: string;
description?: string;
budget?: number;
startDate?: string;
endDate?: string;
[key: string]: any;
}
export interface CreateProgrammeRequest {
portfolioId: string | number;
name: string;
description?: string;
budget?: number;
startDate?: string;
endDate?: string;
}
export interface UpdateProgrammeRequest {
id: string | number;
name?: string;
description?: string;
budget?: number;
}
export interface Tenant {
id?: string | number;
name?: string;
code?: string;
isActive?: boolean;
createdDate?: string;
[key: string]: any;
}
export interface CreateTenantRequest {
name: string;
code?: string;
isActive?: boolean;
}
export interface UpdateTenantRequest {
id: string | number;
name?: string;
code?: string;
isActive?: boolean;
}
export interface Dashboard {
totalProjects?: number;
totalCommittedCapital?: number;
totalDisbursedCapital?: number;
activeMilestonesCount?: number;
complianceRate?: number;
recentActivities?: any[];
[key: string]: any;
}
export interface AuditLog {
id?: string | number;
entityName?: string;
entityId?: string | number;
action?: string;
timestamp?: string;
performedBy?: string;
details?: string;
error?: string | null;
isError?: boolean;
[key: string]: any;
}
export interface Disbursement {
id?: string | number;
projectId?: string | number;
milestoneId?: string | number;
amount?: number;
disbursementDate?: string;
status?: ApprovalStatus;
referenceNumber?: string;
[key: string]: any;
}
export interface CreateDisbursementRequest {
projectId: string | number;
milestoneId?: string | number;
amount: number;
referenceNumber?: string;
}
export interface UpdateDisbursementRequest {
id: string | number;
amount?: number;
status?: ApprovalStatus;
referenceNumber?: string;
}
export interface Risk {
id?: string | number;
projectId?: string | number;
title?: string;
description?: string;
likelihood?: RiskLikelihood;
impact?: RiskImpact;
mitigationPlan?: string;
status?: string;
[key: string]: any;
}
export interface CreateRiskRequest {
projectId: string | number;
title: string;
description?: string;
likelihood?: RiskLikelihood;
impact?: RiskImpact;
mitigationPlan?: string;
}
export interface UpdateRiskRequest {
id: string | number;
title?: string;
description?: string;
likelihood?: RiskLikelihood;
impact?: RiskImpact;
mitigationPlan?: string;
status?: string;
}
export interface Rule {
id?: string | number;
programmeId?: string | number;
name?: string;
expression?: string;
description?: string;
isActive?: boolean;
[key: string]: any;
}
export interface CreateRuleRequest {
programmeId: string | number;
name: string;
expression: string;
description?: string;
isActive?: boolean;
}
export interface UpdateRuleRequest {
id: string | number;
name?: string;
expression?: string;
description?: string;
isActive?: boolean;
}
export interface Evidence {
id?: string | number;
projectId?: string | number;
milestoneId?: string | number;
title?: string;
description?: string;
type?: EvidenceType;
fileUrl?: string;
fileName?: string;
fileSize?: number;
uploadedAt?: string;
verified?: boolean;
[key: string]: any;
}
export interface UpdateEvidenceRequest {
id: string | number;
title?: string;
description?: string;
type?: EvidenceType;
verified?: boolean;
}
export interface Indicator {
id?: string | number;
projectId?: string | number;
name?: string;
description?: string;
targetValue?: number;
actualValue?: number;
unit?: UnitOfMeasure;
[key: string]: any;
}
export interface CreateIndicatorRequest {
projectId: string | number;
name: string;
description?: string;
targetValue: number;
unit?: UnitOfMeasure;
}
export interface Invite {
id?: string | number;
email?: string;
role?: string;
organisationId?: string | number;
status?: string;
expiresAt?: string;
[key: string]: any;
}
export interface InviteRequest {
email: string;
role?: string;
organisationId?: string | number;
}
export interface Notification {
id?: string | number;
organisationId?: string | number;
platform?: NotificationPlatform;
status?: NotificationStatus;
message?: string;
recipient?: string;
sentAt?: string;
[key: string]: any;
}
export * from '../../types';
@@ -3,29 +3,95 @@ import { getApiBaseUrl } from '../config';
import { triggerSessionExpired, isUserSyncEndpoint, isTenantEndpoint } from './sessionExpiration';
import { isUserSyncedInBrowser, isActivationPending, syncUserAccount } from './userSyncService';
export const BASE_API_URL = getApiBaseUrl();
const MUTATION_EPOCH_STORAGE_KEY = 'pfm_mutation_epoch';
let activeMutationEpoch: number = (() => {
if (typeof window !== 'undefined') {
try {
const stored = sessionStorage.getItem(MUTATION_EPOCH_STORAGE_KEY);
if (stored) {
const parsed = Number(stored);
if (!isNaN(parsed) && parsed > 0) return parsed;
}
} catch {}
}
return Date.now();
})();
export function getMutationEpoch(): number {
return activeMutationEpoch;
}
export function bumpMutationEpoch(): number {
activeMutationEpoch = Date.now();
if (typeof window !== 'undefined') {
try {
sessionStorage.setItem(MUTATION_EPOCH_STORAGE_KEY, String(activeMutationEpoch));
} catch {}
}
return activeMutationEpoch;
}
export const apiClient = axios.create({
baseURL: getApiBaseUrl(),
baseURL: BASE_API_URL,
headers: {
'Content-Type': 'application/json; version=1',
Accept: 'application/json; version=1',
},
timeout: 20000,
timeout: 15000,
});
apiClient.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
config.baseURL = getApiBaseUrl();
const token = typeof window !== 'undefined' ? localStorage.getItem('pfm_access_token') : null;
const token = localStorage.getItem('pfm_access_token');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
const isLifecycleRequest = isUserSyncEndpoint(config.url) || isTenantEndpoint(config.url);
const isExternal =
config.url?.startsWith('http://') ||
config.url?.startsWith('https://') ||
config.url?.includes('amazonaws.com') ||
config.url?.includes('garage');
if (config.method?.toUpperCase() === 'GET' && !isExternal) {
const headers = config.headers || {};
const hasNoCacheHeader =
headers['Cache-Control'] === 'no-cache' ||
headers['cache-control'] === 'no-cache' ||
headers['Pragma'] === 'no-cache';
if (hasNoCacheHeader) {
if (config.params instanceof URLSearchParams) {
config.params.set('_t', String(Date.now()));
} else {
config.params = config.params || {};
config.params._t = Date.now();
}
} else {
if (config.params instanceof URLSearchParams) {
if (!config.params.has('_v') && !config.params.has('_t')) {
config.params.set('_v', String(getMutationEpoch()));
}
} else {
config.params = config.params || {};
if (!config.params._v && !config.params._t) {
config.params._v = getMutationEpoch();
}
}
}
}
if (token && !isLifecycleRequest) {
if (isActivationPending()) {
return Promise.reject(new Error('User account is pending administrator activation'));
}
const storedSub = typeof window !== 'undefined' ? (localStorage.getItem('pfm_user_sub') || '') : '';
const storedSub = localStorage.getItem('pfm_user_sub') || '';
if (!isUserSyncedInBrowser(storedSub, token)) {
const syncRes = await syncUserAccount(storedSub, token);
if (!syncRes.success) {
@@ -33,20 +99,40 @@ apiClient.interceptors.request.use(
}
}
}
return config;
},
(error) => Promise.reject(error)
);
apiClient.interceptors.response.use(
(response) => response,
(response) => {
const method = response.config?.method?.toLowerCase();
if (method && ['post', 'put', 'patch', 'delete'].includes(method)) {
bumpMutationEpoch();
}
return response;
},
(error) => {
if (error?.response?.status === 401) {
const requestUrl = error.config?.url;
const isLifecycleEndpoint = isUserSyncEndpoint(requestUrl) || isTenantEndpoint(requestUrl);
if (!isLifecycleEndpoint) {
const detailMsg = error?.response?.data?.detail || error?.response?.data?.message || 'Session expired (HTTP 401).';
const detailMsg =
error?.response?.data?.detail ||
error?.response?.data?.message ||
'Authentication session expired or security token invalidated (HTTP 401).';
triggerSessionExpired(detailMsg, requestUrl);
} else {
if (typeof window !== 'undefined') {
localStorage.removeItem('pfm_access_token');
localStorage.removeItem('pfm_access_user');
localStorage.removeItem('pfm_user_sync_record');
localStorage.removeItem('pfm_user_pending_activation');
localStorage.removeItem('pfm_user_pending_activation_reason');
}
}
}
return Promise.reject(error);
@@ -0,0 +1,281 @@
import {
NotificationPlatform,
NotificationStatus,
ApprovalStatus,
ActivityStatus,
ContractStatus,
ComplianceStatus,
User,
Invite,
CreateNotificationRequest,
} from '../types';
import { apiClient } from './httpClient';
import {
getCurrentUserSub,
getCurrentUserClaims,
getCurrentUserTenantId,
} from '../auth/AuthProvider';
export const CONSOLE_PORTAL_URL = 'https://console.ifri.khongisa.co.za/';
export const PARTNER_PORTAL_URL = 'https://partner.ifri.khongisa.co.za/';
export async function dispatchNotification(payload: CreateNotificationRequest): Promise<void> {
const tenantId = getCurrentUserTenantId();
const orgId =
payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null
? Number(payload.organisationId)
: (tenantId !== null ? tenantId : 1);
await apiClient.post('/v1/api/communications/notifications', {
organisationId: orgId,
recipient: payload.recipient,
platform: payload.platform,
subject: payload.subject || null,
destination: payload.destination || 'In-App User Inbox',
message: payload.message,
status: payload.status !== undefined ? payload.status : undefined,
});
}
export async function fetchDirectoryUsers(page = 1, pageSize = 200): Promise<User[]> {
try {
const res = await apiClient.post<User[]>(`/v1/api/users/${page}/${pageSize}`, {});
return res.data || [];
} catch (err) {
console.warn('[NotificationRules] Failed to fetch users directory:', err);
return [];
}
}
export function isApprovedOrActiveStatus(status: number | string | undefined | null): boolean {
if (status === undefined || status === null) return false;
const numStatus = Number(status);
if (
numStatus === ApprovalStatus.Approved ||
numStatus === ActivityStatus.Active ||
numStatus === ContractStatus.Active ||
numStatus === ContractStatus.Executed ||
numStatus === ComplianceStatus.Met ||
numStatus === ComplianceStatus.Exceeded
) {
return true;
}
const strStatus = String(status).toLowerCase().trim();
return (
strStatus === 'approved' ||
strStatus === 'active' ||
strStatus === 'executed' ||
strStatus === 'met' ||
strStatus === 'exceeded' ||
strStatus === 'verified'
);
}
export function getApprovedOrActiveStatusLabel(status: number | string | undefined | null): string {
if (status === undefined || status === null) return 'Active';
const numStatus = Number(status);
if (numStatus === ApprovalStatus.Approved) return 'Approved';
if (numStatus === ActivityStatus.Active) return 'Active';
if (numStatus === ContractStatus.Active) return 'Active';
if (numStatus === ComplianceStatus.Met) return 'Compliant (Met)';
const str = String(status);
return str.charAt(0).toUpperCase() + str.slice(1);
}
export async function getTenantManagersAndExecutives(): Promise<User[]> {
try {
const tenantId = getCurrentUserTenantId();
const users = await fetchDirectoryUsers(1, 200);
const managersAndExecutives = users.filter((u) => {
const roleStr = String(u.role || '').toLowerCase();
const isManagerOrExec =
roleStr.includes('manager') ||
roleStr.includes('executive') ||
roleStr.includes('director') ||
roleStr === '2' ||
roleStr === '4';
const matchesTenant =
!tenantId || !u.tenant_id || Number(u.tenant_id) === Number(tenantId);
return isManagerOrExec && matchesTenant;
});
if (managersAndExecutives.length > 0) {
return managersAndExecutives;
}
const elevatedUsers = users.filter((u) => {
const roleStr = String(u.role || '').toLowerCase();
return (
roleStr.includes('admin') ||
roleStr.includes('supervisor') ||
roleStr === '1' ||
roleStr === '3'
);
});
if (elevatedUsers.length > 0) {
return elevatedUsers;
}
} catch (err) {
console.warn('[NotificationRules] Directory lookup failed, using authenticated identity fallback:', err);
}
const currentSub = getCurrentUserSub();
const currentClaims = getCurrentUserClaims();
if (currentSub) {
return [
{
id: currentSub,
email: (currentClaims.email as string) || (currentClaims.preferred_username as string) || 'manager@pfm.org.za',
status: ActivityStatus.Active,
lastLoginAt: null,
role: 'Manager',
name: (currentClaims.name as string) || 'Tenant Manager',
},
];
}
return [];
}
export interface RecordStatusChangeNotificationParams {
recordType: string;
recordId: string | number;
newStatus: number | string;
title?: string;
previousStatus?: number | string;
}
export async function notifyTenantManagersAndExecutivesOnRecordStatusChange(
params: RecordStatusChangeNotificationParams
): Promise<void> {
const { recordType, recordId, newStatus, title } = params;
if (recordType.toLowerCase() === 'user' || recordType.toLowerCase() === 'users') {
return;
}
if (!isApprovedOrActiveStatus(newStatus)) {
return;
}
const statusLabel = getApprovedOrActiveStatusLabel(newStatus);
const orgId = getCurrentUserTenantId() || 1;
const managersAndExecutives = await getTenantManagersAndExecutives();
if (managersAndExecutives.length === 0) {
return;
}
const recordDescriptor = title
? `${recordType} "${title}" (#${recordId})`
: `${recordType} #${recordId}`;
const inAppSubject = `[${statusLabel}] ${recordType} #${recordId} is now ${statusLabel}`;
const inAppMessage = `### Statutory Record Update\n\nThe record **${recordDescriptor}** has officially transitioned to **${statusLabel}** status.\n\n- **Record Category:** ${recordType}\n- **Identifier:** #${recordId}\n- **Updated Status:** ${statusLabel}\n- **Timestamp:** ${new Date().toLocaleString()}`;
const emailSubject = `[PFM Governance Alert] ${recordType} #${recordId} Status Transition: ${statusLabel}`;
const emailMessage = `### Formal Statutory Notification\n\nThis is an automated notification to Tenant Managers and Executives regarding an approved record status update.\n\n**Record Details:**\n- **Category:** ${recordType}\n- **Reference Identifier:** #${recordId}\n${title ? `- **Title:** ${title}\n` : ''}- **New Lifecycle Status:** **${statusLabel}**\n- **Effective Timestamp:** ${new Date().toUTCString()}`;
for (const recipient of managersAndExecutives) {
if (!recipient.id) continue;
const recipientEmail = recipient.email || 'manager@pfm.org.za';
try {
await dispatchNotification({
organisationId: orgId,
recipient: recipient.id,
platform: NotificationPlatform.InApp,
subject: inAppSubject,
destination: recipientEmail,
message: inAppMessage,
status: NotificationStatus.Delivered,
});
} catch {}
try {
await dispatchNotification({
organisationId: orgId,
recipient: recipient.id,
platform: NotificationPlatform.Email,
subject: emailSubject,
destination: `To: ${recipientEmail} | From: PFM Automated Governance System`,
message: emailMessage,
status: NotificationStatus.Queued,
});
} catch {}
}
}
export interface InviteApprovedNotificationParams {
invite: Partial<Invite> & {
id: number | string;
recipient: string;
invitedOrganisationId?: number | string;
organisationId?: number | string;
awardId?: number | string;
contractId?: number | string;
message?: string;
};
partnerUser?: User | null;
partnerEmail?: string;
}
export async function notifyInviteApproved(
params: InviteApprovedNotificationParams
): Promise<void> {
const { invite, partnerUser, partnerEmail } = params;
if (!invite.recipient) return;
const orgId = invite.invitedOrganisationId || invite.organisationId || getCurrentUserTenantId() || 1;
const targetEmail = partnerEmail || partnerUser?.email || invite.recipient;
try {
await dispatchNotification({
organisationId: orgId,
recipient: invite.recipient,
platform: NotificationPlatform.InApp,
subject: 'Onboarding Invitation Approved: Access to Partner Portal Granted',
destination: targetEmail,
message: `Your statutory onboarding invitation has been formally Approved. Access the Partner Portal at ${PARTNER_PORTAL_URL}.`,
status: NotificationStatus.Delivered,
});
} catch {}
}
export interface NewUserCreatedNotificationParams {
userId: string;
email: string;
isPartner: boolean;
roleName?: string;
tenantId?: number | string | null;
}
export async function notifyNewUserCreated(
params: NewUserCreatedNotificationParams
): Promise<void> {
const { userId, email, isPartner, tenantId } = params;
if (!userId || !email) return;
const orgId = tenantId ? Number(tenantId) : (getCurrentUserTenantId() || 1);
const targetUrl = isPartner ? PARTNER_PORTAL_URL : CONSOLE_PORTAL_URL;
try {
await dispatchNotification({
organisationId: orgId,
recipient: userId,
platform: NotificationPlatform.InApp,
subject: 'Welcome to PFM - Your Account Has Been Created',
destination: email,
message: `Your user account has been created. Please sign in at ${targetUrl}.`,
status: NotificationStatus.Delivered,
});
} catch {}
}
@@ -0,0 +1,440 @@
import { apiClient } from './httpClient';
import { Role, Claim } from '../types';
import { ROLE_CLAIM_DEFINITIONS } from '../auth/rbac';
export const LOCAL_STORAGE_SYSTEM_ROLES_KEY = 'pfm_partner_cache_system_roles';
export const LOCAL_STORAGE_ROLE_CLAIMS_PREFIX = 'pfm_partner_cache_role_claims_';
export const LOCAL_STORAGE_SYSTEM_CLAIMS_KEY = 'pfm_partner_cache_system_claims';
export const LOCAL_STORAGE_RBAC_VERSION_KEY = 'pfm_partner_rbac_cache_version';
export const CURRENT_RBAC_CACHE_VERSION = 'v2.2-partner';
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) {
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 {}
}
let inMemorySystemRoles: Role[] | null = null;
const inMemoryRoleClaims = new Map<string, Claim[]>();
let inMemorySystemClaims: Claim[] | null = null;
const inMemoryUserRoles = new Map<string, { roles: Role[]; timestamp: number }>();
const inMemoryUserClaims = new Map<string, { claims: Claim[]; timestamp: number }>();
const USER_RBAC_CACHE_TTL_MS = 5 * 60 * 1000;
ensureFreshRbacCache();
// PartnerUI RBAC only functions for Partner roles
export const DEFAULT_ROLE_CLAIMS: Record<string, Claim[]> = {
partner: [
{ claimId: 50, claimType: 'dashboard', claimValue: 'view, export' },
{ claimId: 51, claimType: 'projects_milestones', claimValue: 'view, add, export' },
{ claimId: 52, claimType: 'grant_financials', claimValue: 'view, add, export' },
{ claimId: 53, claimType: 'evidence_risks', claimValue: 'view, add, export' },
{ claimId: 54, claimType: 'beneficiaries', claimValue: 'view, add, export' },
{ claimId: 55, claimType: 'change_management', claimValue: 'view, add, export' },
{ claimId: 56, claimType: 'site_visits', claimValue: 'view, export' },
{ claimId: 57, claimType: 'compliance', claimValue: 'view, add, export' },
{ claimId: 58, claimType: 'settings_notifications', claimValue: 'view, add' },
],
};
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,
};
}
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;
}
export function setCachedSystemRoles(roles: Role[]): void {
inMemorySystemRoles = roles;
if (typeof window !== 'undefined') {
try {
localStorage.setItem(LOCAL_STORAGE_SYSTEM_ROLES_KEY, JSON.stringify(roles));
} catch {}
}
}
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;
}
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 {}
}
}
export async function fetchAndCacheSystemRoles(forceRefresh = false): Promise<Role[]> {
if (!forceRefresh) {
const cached = getCachedSystemRoles();
if (cached && cached.length > 0) return cached;
}
try {
const res = await apiClient.get<Role[]>('/v1/api/users/system/roles');
if (res.data && Array.isArray(res.data) && res.data.length > 0) {
setCachedSystemRoles(res.data);
return res.data;
}
} catch {}
const cached = getCachedSystemRoles();
if (cached && cached.length > 0) return cached;
const defaults: Role[] = [{ id: '6', name: 'Partner' }];
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}$/;
export async function fetchAndCacheRoleClaims(
roleId: string,
roleName?: string,
forceRefresh = false
): Promise<Claim[]> {
const roleIdKey = roleId.toLowerCase().trim();
const roleNameKey = (roleName || '').toLowerCase().trim();
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;
}
}
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<Claim[]>(`/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 {}
}
const cachedById = getCachedRoleClaims(roleIdKey);
if (cachedById && cachedById.length > 0) return cachedById;
if (roleNameKey) {
const cachedByName = getCachedRoleClaims(roleNameKey);
if (cachedByName && cachedByName.length > 0) return cachedByName;
}
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_partner_cache_user_roles_${key}`);
localStorage.removeItem(`pfm_partner_cache_user_claims_${key}`);
} catch {}
}
} else {
inMemoryUserRoles.clear();
inMemoryUserClaims.clear();
}
}
export async function getUserRolesFromApi(userId: string, forceRefresh = false): Promise<Role[]> {
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_partner_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<Role[]>(`/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_partner_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<Claim[]> {
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_partner_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<Claim[]>(`/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_partner_cache_user_claims_${key}`, JSON.stringify(claims));
} catch {}
}
}
return claims;
} catch {
return inMemoryUserClaims.get(key)?.claims || [];
}
}
export async function resolveUserRolesAndClaims(
userSub: string,
initialClaims: Record<string, any> = {},
currentProfile: { role?: string; tenant?: string; tenant_id?: number } = {}
): Promise<{
roles: string[];
claims: Record<string, any>;
primaryRole: string;
tenant?: string;
tenant_id?: number;
}> {
ensureFreshRbacCache();
const mergedClaims: Record<string, any> = { ...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);
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) => {
const rName = r.name || r.Name;
if (rName && !resolvedRoles.includes(rName)) {
resolvedRoles.push(rName);
}
});
if (resolvedRoles.length > 0) {
primaryRole = resolvedRoles[0];
}
}
if (resolvedRoles.length === 0 && primaryRole) {
resolvedRoles.push(primaryRole);
}
const allRoleClaims: Claim[] = [];
const roleClaimsPromises = resolvedRoles
.filter((roleName) => roleName.toLowerCase().trim() === 'partner')
.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 || 'view, add, export';
}
});
}
});
if (allRoleClaims.length > 0) {
mergedClaims.role_claims = allRoleClaims;
}
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);
}
return {
roles: resolvedRoles,
claims: mergedClaims,
primaryRole,
tenant,
tenant_id,
};
}
export async function preloadAndCacheAllRoleClaims(): Promise<void> {
const roles = await fetchAndCacheSystemRoles();
await Promise.allSettled(
roles.map((role) => fetchAndCacheRoleClaims(role.id || role.Id || '', role.name || role.Name || ''))
);
}
export async function getSystemClaims(): Promise<Claim[]> {
if (inMemorySystemClaims && inMemorySystemClaims.length > 0) {
return inMemorySystemClaims;
}
const claimsMap = new Map<string, Claim>();
ROLE_CLAIM_DEFINITIONS.forEach((def, index) => {
claimsMap.set(def.claim, {
claimId: index + 1,
claimType: def.claim,
claimValue: 'view, add, export',
type: def.claim,
value: 'view, add, export',
});
});
const allClaims = Array.from(claimsMap.values());
inMemorySystemClaims = allClaims;
return allClaims;
}
@@ -11,36 +11,86 @@ export const LOCAL_STORAGE_ACTIVATION_REASON_KEY = 'pfm_user_pending_activation_
export function isUserSyncEndpoint(url?: string): boolean {
if (!url) return false;
const clean = url.toLowerCase().split('?')[0];
return clean.includes('/users/sync') || clean.includes('/integration/sync') || clean.includes('/user/sync');
return (
clean.includes('/users/sync') ||
clean.includes('/integration/sync') ||
clean.includes('/user/sync') ||
clean.endsWith('/sync') ||
clean === '/sync'
);
}
export function isTenantEndpoint(url?: string): boolean {
if (!url) return false;
return url.toLowerCase().includes('/governance/tenants');
const clean = url.toLowerCase().split('?')[0];
return clean.includes('/governance/tenants');
}
type SessionExpiredListener = (reason?: string) => void;
const listeners = new Set<SessionExpiredListener>();
let hasTriggeredSessionExpired = false;
export function dismissSubordinateModals(): void {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('pfm:dismiss-subordinates'));
}
}
export function cancelSessionExpiration(): void {
hasTriggeredSessionExpired = false;
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('pfm:session-expired-cancelled'));
}
}
export function isSessionExpiredState(): boolean {
return hasTriggeredSessionExpired;
}
export function triggerSessionExpired(reason?: string, requestUrl?: string): void {
if (requestUrl && isUserSyncEndpoint(requestUrl)) return;
if (typeof window !== 'undefined' && localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY)) return;
if (hasTriggeredSessionExpired) return;
if (requestUrl && isUserSyncEndpoint(requestUrl)) {
return;
}
if (typeof window !== 'undefined' && localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY)) {
return;
}
if (hasTriggeredSessionExpired) {
return;
}
const hasExistingToken = typeof window !== 'undefined' && !!localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY);
if (!hasExistingToken) return;
if (!hasExistingToken) {
return;
}
hasTriggeredSessionExpired = true;
dismissSubordinateModals();
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('pfm:session-expired', { detail: { reason: reason || 'HTTP 401 Unauthorized' } }));
window.dispatchEvent(
new CustomEvent('pfm:session-expired', {
detail: { reason: reason || 'HTTP 401 Unauthorized' },
})
);
}
listeners.forEach((cb) => cb(reason));
listeners.forEach((callback) => {
try {
callback(reason);
} catch (err) {
console.warn('[SessionExpiration] Callback execution error:', err);
}
});
}
export function onSessionExpired(listener: SessionExpiredListener): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
return () => {
listeners.delete(listener);
};
}
export function resetSessionExpiredState(): void {
@@ -49,6 +99,7 @@ export function resetSessionExpiredState(): void {
export function clearSessionAndRedirectToLanding(): void {
if (typeof window === 'undefined') return;
try {
localStorage.removeItem(LOCAL_STORAGE_TOKEN_KEY);
localStorage.removeItem(LOCAL_STORAGE_USER_KEY);
@@ -57,8 +108,16 @@ export function clearSessionAndRedirectToLanding(): void {
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY);
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
} catch {}
try { queryClient.clear(); } catch {}
} catch (err) {
console.warn('[SessionExpiration] Storage cleanup warning:', err);
}
try {
queryClient.clear();
} catch (err) {
console.warn('[SessionExpiration] QueryClient clear warning:', err);
}
resetSessionExpiredState();
window.location.replace('/');
}
@@ -0,0 +1,164 @@
import { getTenant } from './api';
import { ActivityStatus, Tenant } from '../types';
import { setActivationPending } from './userSyncService';
export interface TenantLockoutResult {
isActive: boolean;
tenant: Tenant | null;
errorMessage?: string;
statusCode?: number;
}
let cachedTenantLockout: {
key: string;
result: TenantLockoutResult;
timestamp: number;
} | null = null;
const LOCKOUT_CACHE_TTL_MS = 60 * 1000;
export function clearTenantLockoutCache(): void {
cachedTenantLockout = null;
}
export function isStatusActive(status: any): boolean {
if (status === undefined || status === null || status === '') {
return false;
}
if (status === ActivityStatus.Active || status === 1 || status === '1') {
return true;
}
if (typeof status === 'string' && status.trim().toLowerCase() === 'active') {
return true;
}
if (status === true) {
return true;
}
return false;
}
export function getStatusLabel(status: any): string {
if (status === ActivityStatus.Active || status === 1 || String(status).toLowerCase() === 'active') {
return 'Active';
}
if (status === ActivityStatus.Inactive || status === 2 || String(status).toLowerCase() === 'inactive') {
return 'Inactive';
}
if (status === ActivityStatus.Disabled || status === 3 || String(status).toLowerCase() === 'disabled') {
return 'Disabled';
}
if (String(status).toLowerCase() === 'suspended') {
return 'Suspended';
}
if (status === 0 || String(status).toLowerCase() === 'pending') {
return 'Pending';
}
if (status !== undefined && status !== null && status !== '') {
return String(status);
}
return 'Not Specified';
}
export async function verifyTenantLockout(
tenantId?: number | string | null,
tenantClaim?: string | null,
forceRefresh = false
): Promise<TenantLockoutResult> {
const primaryId =
tenantId !== undefined && tenantId !== null && tenantId !== ''
? tenantId
: tenantClaim !== undefined && tenantClaim !== null && tenantClaim !== ''
? tenantClaim
: null;
if (primaryId === null || primaryId === undefined || primaryId === '') {
const errorMsg =
'Tenant identification claims (tenant / tenant_id) not found in credentials. Access is restricted.';
setActivationPending(true, errorMsg);
return {
isActive: false,
tenant: null,
errorMessage: errorMsg,
};
}
const cacheKey = String(primaryId).trim().toLowerCase();
if (!forceRefresh && cachedTenantLockout && cachedTenantLockout.key === cacheKey) {
if (
cachedTenantLockout.result.isActive &&
Date.now() - cachedTenantLockout.timestamp < LOCKOUT_CACHE_TTL_MS
) {
return cachedTenantLockout.result;
}
}
let tenantRes: Tenant | null = null;
let lastError: any = null;
try {
tenantRes = await getTenant(primaryId);
} catch (err: any) {
lastError = err;
tenantRes = null;
}
if (!tenantRes && tenantClaim && String(tenantClaim) !== String(primaryId)) {
try {
tenantRes = await getTenant(tenantClaim);
lastError = null;
} catch (err: any) {
lastError = err;
tenantRes = null;
}
}
if (!tenantRes || (typeof tenantRes === 'object' && Object.keys(tenantRes).length === 0)) {
const serverMsg =
lastError?.response?.data?.detail ||
lastError?.response?.data?.message ||
lastError?.message ||
'No tenant record returned';
const errorMsg = `Tenant record could not be retrieved (${serverMsg}). Account is locked out.`;
setActivationPending(true, errorMsg);
return {
isActive: false,
tenant: null,
errorMessage: errorMsg,
statusCode: lastError?.response?.status,
};
}
const tenantObj: Tenant = (tenantRes as any)?.data || (tenantRes as any)?.result || tenantRes;
const rawStatus =
(tenantObj as any)?.status ??
(tenantObj as any)?.Status ??
(tenantObj as any)?.activityStatus ??
(tenantObj as any)?.ActivityStatus;
const active = isStatusActive(rawStatus);
if (!active) {
const statusLabel = getStatusLabel(rawStatus);
const tenantName = tenantObj?.name || String(primaryId);
const errorMsg = `Tenant account "${tenantName}" is not Active (Status: ${statusLabel}). Account is locked out.`;
setActivationPending(true, errorMsg);
return {
isActive: false,
tenant: tenantObj,
errorMessage: errorMsg,
};
}
const successResult: TenantLockoutResult = {
isActive: true,
tenant: tenantObj,
};
cachedTenantLockout = {
key: cacheKey,
result: successResult,
timestamp: Date.now(),
};
return successResult;
}
@@ -1,16 +1,26 @@
import Hashids from 'hashids';
import { useQuery } from '@tanstack/react-query';
const env = (typeof import.meta !== 'undefined' && import.meta.env
? import.meta.env
: {}) as Record<string, string | undefined>;
/**
* Deterministic Hashids configuration matching pfm-api HasherSettings.
* Ensures 100% interoperability with the C# Hashids.net NuGet package on the backend.
*/
export const HASHER_SETTINGS = {
minHashLength: 11,
salt: 'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
salt:
(env.VITE_HASHER_SALT as string) ||
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
};
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
export function updateHasherSettings(salt?: string, minLength?: number | string): void {
let updated = false;
if (salt && salt.trim() !== '') {
if (salt && typeof salt === 'string' && salt.trim() !== '') {
HASHER_SETTINGS.salt = salt.trim();
updated = true;
}
@@ -26,43 +36,78 @@ export function updateHasherSettings(salt?: string, minLength?: number | string)
}
}
function isAlreadyHashId(candidate: string): boolean {
if (typeof candidate !== 'string' || candidate.length < HASHER_SETTINGS.minHashLength) {
return false;
}
try {
const decoded = hasher.decode(candidate);
return Array.isArray(decoded) && decoded.length > 0;
} catch {
return false;
}
}
export function encodeHashIdSync(id: number | string | null | undefined): string {
if (id === undefined || id === null || id === '') return '';
if (id === undefined || id === null || id === '') {
return '';
}
if (typeof id === 'number') {
if (isNaN(id) || !isFinite(id) || id < 0) return '';
return hasher.encode(id);
}
const str = String(id).trim();
if (!str) return '';
try {
const decoded = hasher.decode(str);
if (Array.isArray(decoded) && decoded.length > 0) return str;
} catch {}
if (/^\d+$/.test(str)) {
const num = parseInt(str, 10);
if (!isNaN(num) && num >= 0) return hasher.encode(num);
if (isAlreadyHashId(str)) {
return str;
}
if (/^\d+$/.test(str)) {
try {
const num = parseInt(str, 10);
if (!isNaN(num) && num >= 0) {
return hasher.encode(num);
}
} catch {
// Fall through
}
}
return str;
}
export function decodeHashIdSync(hashId: string | number | null | undefined): number | string {
if (hashId === undefined || hashId === null || hashId === '') return '';
if (typeof hashId === 'number') return hashId;
const str = String(hashId).trim();
if (!str) return '';
if (/^-?\d+$/.test(str)) {
const parsed = parseInt(str, 10);
return isNaN(parsed) ? str : parsed;
}
try {
const decoded = hasher.decode(str);
if (Array.isArray(decoded) && decoded.length > 0) return Number(decoded[0]);
} catch {}
if (Array.isArray(decoded) && decoded.length > 0) {
return Number(decoded[0]);
}
} catch {
// If decoding fails, return raw string
}
return str;
}
export function decodeToNumberSync(hashId: number | string | null | undefined): number {
if (!hashId) return 0;
if (typeof hashId === 'number') return isNaN(hashId) ? 0 : Math.trunc(hashId);
if (hashId === null || hashId === undefined || hashId === '') return 0;
if (typeof hashId === 'number') {
return isNaN(hashId) ? 0 : Math.trunc(hashId);
}
const str = String(hashId).trim();
if (/^-?\d+$/.test(str)) {
const parsed = parseInt(str, 10);
@@ -70,22 +115,84 @@ export function decodeToNumberSync(hashId: number | string | null | undefined):
}
try {
const decoded = hasher.decode(str);
if (Array.isArray(decoded) && decoded.length > 0) return Number(decoded[0]);
if (Array.isArray(decoded) && decoded.length > 0) {
return Number(decoded[0]);
}
} catch {}
const fallback = Number(str);
return isNaN(fallback) ? 0 : Math.trunc(fallback);
}
export const toolsService = {
encodeIdSync: encodeHashIdSync,
decodeIdSync: decodeHashIdSync,
decodeToNumberSync,
async encodeId(id: number | string | null | undefined) { return encodeHashIdSync(id); },
async decodeId(hashId: string | null | undefined) { return decodeHashIdSync(hashId); },
async resolveProjectId(id: number | string) { return encodeHashIdSync(id); },
async resolveOrganisationId(id: number | string) { return encodeHashIdSync(id); },
async resolveActivityId(id: number | string) { return encodeHashIdSync(id); },
async resolveMilestoneId(id: number | string) { return encodeHashIdSync(id); },
encodeIdSync(id: number | string | null | undefined): string {
return encodeHashIdSync(id);
},
decodeIdSync(hashId: string | number | null | undefined): number | string {
return decodeHashIdSync(hashId);
},
decodeToNumberSync(hashId: number | string | null | undefined): number {
return decodeToNumberSync(hashId);
},
async encodeId(id: number | string | null | undefined, _context = 'general'): Promise<string> {
return encodeHashIdSync(id);
},
async decodeId(hashId: string | null | undefined, _context = 'general'): Promise<number | string> {
return decodeHashIdSync(hashId);
},
async resolveProjectId(projectId: number | string): Promise<string> {
return encodeHashIdSync(projectId);
},
async resolveOrganisationId(organisationId: number | string): Promise<string> {
return encodeHashIdSync(organisationId);
},
async resolveProgrammeId(programmeId: number | string): Promise<string> {
return encodeHashIdSync(programmeId);
},
async resolvePortfolioId(portfolioId: number | string): Promise<string> {
return encodeHashIdSync(portfolioId);
},
async resolveAwardId(awardId: number | string): Promise<string> {
return encodeHashIdSync(awardId);
},
async resolveContractId(contractId: number | string): Promise<string> {
return encodeHashIdSync(contractId);
},
async resolveActivityId(activityId: number | string): Promise<string> {
return encodeHashIdSync(activityId);
},
async resolveMilestoneId(milestoneId: number | string): Promise<string> {
return encodeHashIdSync(milestoneId);
},
async resolveHashedParams<T extends Record<string, any>>(
params: T,
keysToHash: (keyof T)[]
): Promise<T> {
const resolved: any = { ...params };
for (const key of keysToHash) {
const val = params[key];
if (val !== undefined && val !== null) {
resolved[key] = encodeHashIdSync(val);
}
}
return resolved;
},
async primeCache(_items: { id: number | string; context: string }[]): Promise<void> {
// No-op - instant deterministic computation
},
};
export function useHashId(id?: number | string | null, context = 'general') {
@@ -22,6 +22,7 @@ export interface UserSyncProfile {
tenant?: string | null;
tenant_id?: number | string | null;
organisationId?: number | string | null;
organisationName?: string | null;
}
export interface UserSyncResult {
@@ -34,41 +35,94 @@ export interface UserSyncResult {
}
let inFlightSyncPromise: Promise<UserSyncResult> | null = null;
let inMemorySyncRecord: UserSyncRecord | null = null;
let inMemoryActivationPending: boolean | null = null;
let inMemoryActivationReason: string | null = null;
export function isActivationPending(): boolean {
try { return localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY) === 'true'; } catch { return false; }
if (inMemoryActivationPending !== null) {
return inMemoryActivationPending;
}
try {
const val = localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY) === 'true';
inMemoryActivationPending = val;
return val;
} catch {
return false;
}
}
export function getActivationPendingReason(): string | null {
try { return localStorage.getItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY); } catch { return null; }
if (inMemoryActivationReason !== null) {
return inMemoryActivationReason;
}
try {
const reason = localStorage.getItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
inMemoryActivationReason = reason;
return reason;
} catch {
return null;
}
}
export function setActivationPending(pending: boolean, reason?: string): void {
inMemoryActivationPending = pending;
inMemoryActivationReason = reason || null;
try {
if (pending) {
localStorage.setItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY, 'true');
if (reason) localStorage.setItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY, reason);
if (reason) {
localStorage.setItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY, reason);
}
} else {
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY);
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
}
} catch {}
} catch (err) {
console.warn('[UserSync] Failed to update activation flag:', err);
}
}
export function isUserSyncedInBrowser(currentSub?: string, currentToken?: string | null): boolean {
try {
if (isActivationPending()) return false;
const raw = localStorage.getItem(LOCAL_STORAGE_USER_SYNC_KEY);
if (!raw) return false;
const record: UserSyncRecord = JSON.parse(raw);
if (isActivationPending()) {
return false;
}
if (!inMemorySyncRecord) {
const raw = localStorage.getItem(LOCAL_STORAGE_USER_SYNC_KEY);
if (!raw) return false;
const parsed: UserSyncRecord = JSON.parse(raw);
if (!parsed || parsed.status !== 'synced') return false;
inMemorySyncRecord = parsed;
}
const record = inMemorySyncRecord;
if (!record || record.status !== 'synced') return false;
if (currentSub && record.sub && record.sub !== currentSub) return false;
if (currentToken && record.tokenSnippet && record.tokenSnippet !== currentToken.slice(-16)) return false;
if (currentSub && record.sub && record.sub !== currentSub) {
return false;
}
if (currentToken && record.tokenSnippet) {
const snippet = currentToken.slice(-16);
if (record.tokenSnippet !== snippet) {
return false;
}
}
return true;
} catch { return false; }
} catch {
return false;
}
}
export function saveUserSyncInBrowser(sub: string, token: string | null | undefined, endpoint: string, userData?: any): void {
export function saveUserSyncInBrowser(
sub: string,
token: string | null | undefined,
endpoint: string,
userData?: any
): void {
try {
const record: UserSyncRecord = {
sub: sub || 'authenticated_user',
@@ -76,13 +130,19 @@ export function saveUserSyncInBrowser(sub: string, token: string | null | undefi
syncedEndpoint: endpoint,
tokenSnippet: token ? token.slice(-16) : '',
status: 'synced',
userData,
userData: userData || undefined,
};
inMemorySyncRecord = record;
localStorage.setItem(LOCAL_STORAGE_USER_SYNC_KEY, JSON.stringify(record));
} catch {}
} catch (err) {
console.warn('[UserSync] Failed to write sync record to localStorage:', err);
}
}
export function clearUserSyncInBrowser(): void {
inMemorySyncRecord = null;
inMemoryActivationPending = null;
inMemoryActivationReason = null;
try {
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY);
@@ -90,63 +150,161 @@ export function clearUserSyncInBrowser(): void {
} catch {}
}
export function clearSuccessfulSyncRecord(): void {
inMemorySyncRecord = null;
try {
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
} catch {}
}
export function getUserSyncRecord(): UserSyncRecord | null {
try {
const raw = localStorage.getItem(LOCAL_STORAGE_USER_SYNC_KEY);
if (!raw) return null;
return JSON.parse(raw) as UserSyncRecord;
} catch {
return null;
}
}
export const USER_SYNC_CANDIDATE_ENDPOINTS = [
'/v1/api/integration/sync',
'/v1/api/users/sync',
'/v1/api/user/sync',
];
async function executeBackendSync(userSub?: string, profile?: UserSyncProfile): Promise<{ endpoint: string; data?: any }> {
export function isUserSyncEndpoint(url?: string): boolean {
if (!url) return false;
const clean = url.toLowerCase().split('?')[0];
return (
clean.includes('/integration/sync') ||
clean.includes('/users/sync') ||
clean.includes('/user/sync') ||
clean.endsWith('/sync') ||
clean === '/sync'
);
}
async function executeBackendSync(
userSub?: string,
profile?: UserSyncProfile
): Promise<{ endpoint: string; data?: any }> {
const rawTenantId = profile?.tenant_id ?? (profile as any)?.tenantId;
const parsedTenantId =
rawTenantId !== undefined && rawTenantId !== null && rawTenantId !== ''
? Number(rawTenantId)
: undefined;
const resolvedOrgId =
parsedTenantId !== undefined
? parsedTenantId
: profile?.organisationId !== undefined && profile?.organisationId !== null && profile?.organisationId !== ''
? Number(profile.organisationId)
: undefined;
const payload = {
id: userSub || profile?.id || profile?.sub,
sub: userSub || profile?.sub,
email: profile?.email || undefined,
name: profile?.name || undefined,
role: profile?.role || undefined,
tenant_id: profile?.tenant_id ? Number(profile.tenant_id) : undefined,
organisationId: profile?.organisationId ? Number(profile.organisationId) : undefined,
tenant: profile?.tenant || undefined,
tenant_id: parsedTenantId,
organisationId: resolvedOrgId,
};
let lastError: any = null;
for (const endpoint of USER_SYNC_CANDIDATE_ENDPOINTS) {
try {
const res = await apiClient.post(endpoint, payload);
let res;
if (endpoint === '/v1/api/integration/sync') {
try {
res = await apiClient.post(endpoint, payload);
} catch (postErr: any) {
const errStatus = postErr?.response?.status;
if (errStatus === 400 || errStatus === 415 || errStatus === 422) {
res = await apiClient.post(endpoint, {});
} else {
throw postErr;
}
}
} else {
res = await apiClient.post(endpoint, payload);
}
return { endpoint, data: res?.data };
} catch (err: any) {
lastError = err;
const status = err?.response?.status;
if (status === 401 || status === 403 || status === 423) throw err;
if (status === 401 || status === 403 || status === 423) {
throw err;
}
}
}
throw lastError || new Error('Failed to reach user sync endpoint');
}
export async function syncUserAccount(userSub?: string, token?: string | null, profile?: UserSyncProfile, force = false): Promise<UserSyncResult> {
export async function syncUserAccount(
userSub?: string,
token?: string | null,
profile?: UserSyncProfile,
force = false
): Promise<UserSyncResult> {
const effectiveToken = token || (typeof window !== 'undefined' ? localStorage.getItem('pfm_access_token') : null);
const effectiveSub = userSub || (typeof window !== 'undefined' ? localStorage.getItem('pfm_user_sub') || '' : '');
if (!force && isUserSyncedInBrowser(effectiveSub, effectiveToken) && !isActivationPending()) {
return { success: true, endpoint: '/v1/api/users/sync' };
return {
success: true,
endpoint: getUserSyncRecord()?.syncedEndpoint || '/v1/api/users/sync',
data: getUserSyncRecord()?.userData,
};
}
if (inFlightSyncPromise) return inFlightSyncPromise;
if (inFlightSyncPromise) {
return inFlightSyncPromise;
}
inFlightSyncPromise = (async (): Promise<UserSyncResult> => {
try {
const result = await executeBackendSync(effectiveSub, profile);
setActivationPending(false);
saveUserSyncInBrowser(effectiveSub, effectiveToken, result.endpoint, result.data);
return { success: true, endpoint: result.endpoint, data: result.data };
return {
success: true,
endpoint: result.endpoint,
data: result.data,
};
} catch (err: any) {
const status = err?.response?.status;
if (status === 401) {
clearUserSyncInBrowser();
setActivationPending(false);
return { success: false, isUnauthorized: true, errorMessage: 'Session expired (HTTP 401).' };
return {
success: false,
isUnauthorized: true,
errorMessage: 'Authentication session expired or unauthorized (HTTP 401).',
};
}
const serverMsg = err?.response?.data?.detail || err?.response?.data?.message || 'Account activation pending.';
clearSuccessfulSyncRecord();
const serverMsg =
err?.response?.data?.detail ||
err?.response?.data?.message ||
err?.response?.data?.error ||
err?.message ||
'Account is not yet enabled by the administrator.';
setActivationPending(true, serverMsg);
return { success: false, isUnauthorized: false, isActivationPending: true, errorMessage: serverMsg };
return {
success: false,
isUnauthorized: false,
isActivationPending: true,
errorMessage: serverMsg,
};
} finally {
inFlightSyncPromise = null;
}
@@ -154,3 +312,13 @@ export async function syncUserAccount(userSub?: string, token?: string | null, p
return inFlightSyncPromise;
}
export async function ensureUserSynced(
userSub?: string,
token?: string | null,
profile?: UserSyncProfile,
force = false
): Promise<boolean> {
const result = await syncUserAccount(userSub, token, profile, force);
return result.success;
}