Implemented partnerui
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user