Added partner app

This commit is contained in:
2026-09-28 16:13:42 +02:00
parent 242a4c6fbb
commit 7d510d9fbc
36 changed files with 5920 additions and 0 deletions
@@ -0,0 +1,54 @@
import axios, { InternalAxiosRequestConfig } from 'axios';
import { getApiBaseUrl } from '../config';
import { triggerSessionExpired, isUserSyncEndpoint, isTenantEndpoint } from './sessionExpiration';
import { isUserSyncedInBrowser, isActivationPending, syncUserAccount } from './userSyncService';
export const apiClient = axios.create({
baseURL: getApiBaseUrl(),
headers: {
'Content-Type': 'application/json; version=1',
Accept: 'application/json; version=1',
},
timeout: 20000,
});
apiClient.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
config.baseURL = getApiBaseUrl();
const token = typeof window !== 'undefined' ? localStorage.getItem('pfm_access_token') : null;
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
const isLifecycleRequest = isUserSyncEndpoint(config.url) || isTenantEndpoint(config.url);
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') || '') : '';
if (!isUserSyncedInBrowser(storedSub, token)) {
const syncRes = await syncUserAccount(storedSub, token);
if (!syncRes.success) {
return Promise.reject(new Error(syncRes.errorMessage || 'User account sync pending activation'));
}
}
}
return config;
},
(error) => Promise.reject(error)
);
apiClient.interceptors.response.use(
(response) => 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).';
triggerSessionExpired(detailMsg, requestUrl);
}
}
return Promise.reject(error);
}
);
@@ -0,0 +1,64 @@
import { queryClient } from '../lib/queryClient';
export const LOCAL_STORAGE_TOKEN_KEY = 'pfm_access_token';
export const LOCAL_STORAGE_USER_KEY = 'pfm_auth_user';
export const LOCAL_STORAGE_SUB_KEY = 'pfm_user_sub';
export const LOCAL_STORAGE_CLAIMS_KEY = 'pfm_user_claims';
export const LOCAL_STORAGE_USER_SYNC_KEY = 'pfm_user_sync_record';
export const LOCAL_STORAGE_ACTIVATION_PENDING_KEY = 'pfm_user_pending_activation';
export const LOCAL_STORAGE_ACTIVATION_REASON_KEY = 'pfm_user_pending_activation_reason';
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');
}
export function isTenantEndpoint(url?: string): boolean {
if (!url) return false;
return url.toLowerCase().includes('/governance/tenants');
}
type SessionExpiredListener = (reason?: string) => void;
const listeners = new Set<SessionExpiredListener>();
let hasTriggeredSessionExpired = false;
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;
const hasExistingToken = typeof window !== 'undefined' && !!localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY);
if (!hasExistingToken) return;
hasTriggeredSessionExpired = true;
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('pfm:session-expired', { detail: { reason: reason || 'HTTP 401 Unauthorized' } }));
}
listeners.forEach((cb) => cb(reason));
}
export function onSessionExpired(listener: SessionExpiredListener): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
export function resetSessionExpiredState(): void {
hasTriggeredSessionExpired = false;
}
export function clearSessionAndRedirectToLanding(): void {
if (typeof window === 'undefined') return;
try {
localStorage.removeItem(LOCAL_STORAGE_TOKEN_KEY);
localStorage.removeItem(LOCAL_STORAGE_USER_KEY);
localStorage.removeItem(LOCAL_STORAGE_SUB_KEY);
localStorage.removeItem(LOCAL_STORAGE_CLAIMS_KEY);
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 {}
resetSessionExpiredState();
window.location.replace('/');
}
@@ -0,0 +1,98 @@
import Hashids from 'hashids';
import { useQuery } from '@tanstack/react-query';
export const HASHER_SETTINGS = {
minHashLength: 11,
salt: '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() !== '') {
HASHER_SETTINGS.salt = salt.trim();
updated = true;
}
if (minLength !== undefined && minLength !== null && minLength !== '') {
const parsed = Number(minLength);
if (!isNaN(parsed) && parsed > 0) {
HASHER_SETTINGS.minHashLength = parsed;
updated = true;
}
}
if (updated) {
hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
}
}
export function encodeHashIdSync(id: number | string | null | undefined): string {
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);
}
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 (/^-?\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 {}
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);
const str = String(hashId).trim();
if (/^-?\d+$/.test(str)) {
const parsed = parseInt(str, 10);
return isNaN(parsed) ? 0 : parsed;
}
try {
const decoded = hasher.decode(str);
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); },
};
export function useHashId(id?: number | string | null, context = 'general') {
return useQuery({
queryKey: ['hashId', context, id !== undefined && id !== null ? String(id) : ''],
queryFn: () => Promise.resolve(encodeHashIdSync(id)),
enabled: id !== undefined && id !== null && id !== '',
staleTime: Infinity,
});
}
@@ -0,0 +1,156 @@
import { apiClient } from './httpClient';
export const LOCAL_STORAGE_USER_SYNC_KEY = 'pfm_user_sync_record';
export const LOCAL_STORAGE_ACTIVATION_PENDING_KEY = 'pfm_user_pending_activation';
export const LOCAL_STORAGE_ACTIVATION_REASON_KEY = 'pfm_user_pending_activation_reason';
export interface UserSyncRecord {
sub: string;
syncedAt: number;
syncedEndpoint: string;
tokenSnippet: string;
status: 'synced';
userData?: any;
}
export interface UserSyncProfile {
id?: string;
sub?: string;
email?: string | null;
name?: string | null;
role?: string | null;
tenant?: string | null;
tenant_id?: number | string | null;
organisationId?: number | string | null;
}
export interface UserSyncResult {
success: boolean;
isUnauthorized?: boolean;
isActivationPending?: boolean;
endpoint?: string;
data?: any;
errorMessage?: string;
}
let inFlightSyncPromise: Promise<UserSyncResult> | null = null;
export function isActivationPending(): boolean {
try { return localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY) === 'true'; } catch { return false; }
}
export function getActivationPendingReason(): string | null {
try { return localStorage.getItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY); } catch { return null; }
}
export function setActivationPending(pending: boolean, reason?: string): void {
try {
if (pending) {
localStorage.setItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY, 'true');
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 {}
}
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 (!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;
return true;
} catch { return false; }
}
export function saveUserSyncInBrowser(sub: string, token: string | null | undefined, endpoint: string, userData?: any): void {
try {
const record: UserSyncRecord = {
sub: sub || 'authenticated_user',
syncedAt: Date.now(),
syncedEndpoint: endpoint,
tokenSnippet: token ? token.slice(-16) : '',
status: 'synced',
userData,
};
localStorage.setItem(LOCAL_STORAGE_USER_SYNC_KEY, JSON.stringify(record));
} catch {}
}
export function clearUserSyncInBrowser(): void {
try {
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY);
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
} catch {}
}
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 }> {
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,
};
let lastError: any = null;
for (const endpoint of USER_SYNC_CANDIDATE_ENDPOINTS) {
try {
const 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;
}
}
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> {
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' };
}
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 };
} catch (err: any) {
const status = err?.response?.status;
if (status === 401) {
clearUserSyncInBrowser();
setActivationPending(false);
return { success: false, isUnauthorized: true, errorMessage: 'Session expired (HTTP 401).' };
}
const serverMsg = err?.response?.data?.detail || err?.response?.data?.message || 'Account activation pending.';
setActivationPending(true, serverMsg);
return { success: false, isUnauthorized: false, isActivationPending: true, errorMessage: serverMsg };
} finally {
inFlightSyncPromise = null;
}
})();
return inFlightSyncPromise;
}