Test 2 reoslutions #86

Merged
khwezi merged 1 commits from garages3 into main 2026-09-20 15:26:35 +02:00
5 changed files with 205 additions and 13 deletions
Showing only changes of commit 2d0f2b494a - Show all commits
@@ -14,6 +14,7 @@ import {
} from '../services/userSyncService'; } from '../services/userSyncService';
import { AccountActivationModal } from '../components/auth/AccountActivationModal'; import { AccountActivationModal } from '../components/auth/AccountActivationModal';
import { SessionExpiredModal } from '../components/auth/SessionExpiredModal'; import { SessionExpiredModal } from '../components/auth/SessionExpiredModal';
import { encodeHashIdSync } from '../services/toolsService';
import { import {
onSessionExpired, onSessionExpired,
triggerSessionExpired, triggerSessionExpired,
@@ -30,6 +31,7 @@ export interface AuthUserProfile {
role: string; role: string;
organisationName?: string; organisationName?: string;
tenant?: string; tenant?: string;
tenant_id?: number;
avatarUrl?: string; avatarUrl?: string;
} }
@@ -39,6 +41,8 @@ interface AuthContextType {
user: AuthUserProfile | null; user: AuthUserProfile | null;
sub: string; sub: string;
claims: UserClaims; claims: UserClaims;
tenantId: number | null;
hashedTenantId: string | null;
accessToken: string | null; accessToken: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
@@ -60,6 +64,75 @@ export const LOCAL_STORAGE_USER_KEY = 'pfm_auth_user';
export const LOCAL_STORAGE_SUB_KEY = 'pfm_user_sub'; export const LOCAL_STORAGE_SUB_KEY = 'pfm_user_sub';
export const LOCAL_STORAGE_CLAIMS_KEY = 'pfm_user_claims'; export const LOCAL_STORAGE_CLAIMS_KEY = 'pfm_user_claims';
export function extractTenantId(claims?: UserClaims | null, token?: string | null): number | null {
if (claims) {
const raw =
claims.tenant_id ??
(claims as any).tenantId ??
claims['tenant_id'] ??
claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/tenantid'];
if (raw !== undefined && raw !== null && raw !== '') {
const parsed = Number(raw);
if (!isNaN(parsed) && isFinite(parsed)) return parsed;
}
}
if (token) {
try {
const tokenClaims = parseJwtClaims(token);
const raw = tokenClaims.tenant_id ?? tokenClaims.tenantId ?? tokenClaims['tenant_id'];
if (raw !== undefined && raw !== null && raw !== '') {
const parsed = Number(raw);
if (!isNaN(parsed) && isFinite(parsed)) return parsed;
}
} catch {}
}
if (typeof window !== 'undefined') {
try {
const storedClaimsRaw = localStorage.getItem(LOCAL_STORAGE_CLAIMS_KEY);
if (storedClaimsRaw) {
const parsedClaims = JSON.parse(storedClaimsRaw);
const raw = parsedClaims.tenant_id ?? parsedClaims.tenantId ?? parsedClaims['tenant_id'];
if (raw !== undefined && raw !== null && raw !== '') {
const parsed = Number(raw);
if (!isNaN(parsed) && isFinite(parsed)) return parsed;
}
}
const storedToken = localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY);
if (storedToken) {
const tokenClaims = parseJwtClaims(storedToken);
const raw = tokenClaims.tenant_id ?? tokenClaims.tenantId ?? tokenClaims['tenant_id'];
if (raw !== undefined && raw !== null && raw !== '') {
const parsed = Number(raw);
if (!isNaN(parsed) && isFinite(parsed)) return parsed;
}
}
const storedUser = localStorage.getItem(LOCAL_STORAGE_USER_KEY);
if (storedUser) {
const parsedUser = JSON.parse(storedUser);
const raw = parsedUser.tenant_id ?? parsedUser.tenantId;
if (raw !== undefined && raw !== null && raw !== '') {
const parsed = Number(raw);
if (!isNaN(parsed) && isFinite(parsed)) return parsed;
}
}
} catch {}
}
return null;
}
export function getCurrentUserTenantId(): number | null {
return extractTenantId(
getCurrentUserClaims(),
typeof window !== 'undefined' ? localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY) : null
);
}
export function getCurrentUserHashedTenantId(): string | null {
const tid = getCurrentUserTenantId();
if (tid === null || tid === undefined) return null;
return encodeHashIdSync(tid);
}
export function getCurrentUserSub(): string { export function getCurrentUserSub(): string {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
return localStorage.getItem(LOCAL_STORAGE_SUB_KEY) || ''; return localStorage.getItem(LOCAL_STORAGE_SUB_KEY) || '';
@@ -119,6 +192,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const [user, setUser] = useState<AuthUserProfile | null>(null); const [user, setUser] = useState<AuthUserProfile | null>(null);
const [sub, setSub] = useState<string>(''); const [sub, setSub] = useState<string>('');
const [claims, setClaims] = useState<UserClaims>({}); const [claims, setClaims] = useState<UserClaims>({});
const [tenantId, setTenantId] = useState<number | null>(() => {
return extractTenantId(
getCurrentUserClaims(),
typeof window !== 'undefined' ? localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY) : null
);
});
const [accessToken, setAccessToken] = useState<string | null>(null); const [accessToken, setAccessToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [isSynced, setIsSynced] = useState<boolean>(() => isUserSyncedInBrowser()); const [isSynced, setIsSynced] = useState<boolean>(() => isUserSyncedInBrowser());
@@ -128,6 +207,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const [isSessionExpired, setIsSessionExpired] = useState<boolean>(false); const [isSessionExpired, setIsSessionExpired] = useState<boolean>(false);
const [sessionExpiredReason, setSessionExpiredReason] = useState<string | null>(null); const [sessionExpiredReason, setSessionExpiredReason] = useState<string | null>(null);
const hashedTenantId = tenantId !== null && tenantId !== undefined ? encodeHashIdSync(tenantId) : null;
// Subscribe to global HTTP 401 session expiration events // Subscribe to global HTTP 401 session expiration events
useEffect(() => { useEffect(() => {
const unsubscribe = onSessionExpired((reason) => { const unsubscribe = onSessionExpired((reason) => {
@@ -203,6 +284,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims)); localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims));
const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || undefined; const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || undefined;
const parsedTenantId = extractTenantId(mergedClaims, token);
setTenantId(parsedTenantId);
const profile: AuthUserProfile = { const profile: AuthUserProfile = {
id: userSub || 'user-1', id: userSub || 'user-1',
@@ -212,6 +295,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
role: (oidcUser.profile.role as string) || (mergedClaims.role as string) || (Array.isArray(mergedClaims.roles) ? mergedClaims.roles[0] : 'Programme Sponsor'), role: (oidcUser.profile.role as string) || (mergedClaims.role as string) || (Array.isArray(mergedClaims.roles) ? mergedClaims.roles[0] : 'Programme Sponsor'),
organisationName: (mergedClaims.organisation as string) || (mergedClaims.org as string) || undefined, organisationName: (mergedClaims.organisation as string) || (mergedClaims.org as string) || undefined,
tenant: tenantClaim, tenant: tenantClaim,
tenant_id: parsedTenantId ?? undefined,
}; };
setUser(profile); setUser(profile);
@@ -281,6 +365,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setSub(storedSub); setSub(storedSub);
} }
const storedTenantId = extractTenantId(parsedClaims, storedToken);
setTenantId(storedTenantId);
let resolvedUser: AuthUserProfile | null = null; let resolvedUser: AuthUserProfile | null = null;
if (storedUser) { if (storedUser) {
try { try {
@@ -288,6 +375,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
if (!parsedUser.tenant && parsedClaims.tenant) { if (!parsedUser.tenant && parsedClaims.tenant) {
parsedUser.tenant = parsedClaims.tenant; parsedUser.tenant = parsedClaims.tenant;
} }
if (parsedUser.tenant_id === undefined && storedTenantId !== null) {
parsedUser.tenant_id = storedTenantId;
}
setUser(parsedUser); setUser(parsedUser);
resolvedUser = parsedUser; resolvedUser = parsedUser;
} catch {} } catch {}
@@ -402,6 +492,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setUser(null); setUser(null);
setSub(''); setSub('');
setClaims({}); setClaims({});
setTenantId(null);
setAccessToken(null); setAccessToken(null);
setIsSynced(false); setIsSynced(false);
setIsPendingActivation(false); setIsPendingActivation(false);
@@ -424,6 +515,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
user, user,
sub, sub,
claims, claims,
tenantId,
hashedTenantId,
accessToken, accessToken,
isAuthenticated: !!accessToken, isAuthenticated: !!accessToken,
isLoading, isLoading,
@@ -63,7 +63,7 @@ const navItems = [
]; ];
export const AppLayout: React.FC = () => { export const AppLayout: React.FC = () => {
const { user, claims, sub, logout } = useAuth(); const { user, claims, sub, tenantId, hashedTenantId, logout } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
const [userMenuOpen, setUserMenuOpen] = useState(false); const [userMenuOpen, setUserMenuOpen] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
@@ -219,15 +219,25 @@ export const AppLayout: React.FC = () => {
<div className="hidden sm:flex items-center gap-2 text-xs font-medium text-slate-300"> <div className="hidden sm:flex items-center gap-2 text-xs font-medium text-slate-300">
<span className="text-slate-400">Tenant:</span> <span className="text-slate-400">Tenant:</span>
<span <span
className="bg-slate-900/80 text-slate-200 px-2.5 py-1 rounded-lg border border-slate-800 flex items-center gap-1.5 font-medium shadow-xs max-w-[280px] truncate" className="bg-slate-900/80 text-slate-200 px-2.5 py-1 rounded-lg border border-slate-800 flex items-center gap-1.5 font-medium shadow-xs max-w-[320px] truncate"
title={tenantName} title={`${tenantName}${hashedTenantId ? ` (Partition: ${hashedTenantId})` : ''}`}
> >
<Database className="w-3.5 h-3.5 text-blue-400 shrink-0" /> <Database className="w-3.5 h-3.5 text-blue-400 shrink-0" />
<span className="truncate">{tenantName}</span> <span className="truncate">{tenantName}</span>
{hashedTenantId && (
<span
className="text-[10px] font-mono text-blue-400 bg-blue-950/60 px-1.5 py-0.5 rounded border border-blue-800/60 shrink-0"
title={`Tenant Partition HashID: ${hashedTenantId}`}
>
{hashedTenantId}
</span>
)}
</span> </span>
<HelpTooltip <HelpTooltip
title="Tenant Context" title="Tenant Context"
content="Identifies the multi-tenant organization partition and database boundary within the national PFM environment." content={`Identifies the multi-tenant organization partition and database boundary within the national PFM environment.${
hashedTenantId ? ` Tenant Partition (HashID): ${hashedTenantId}` : ''
}`}
size="sm" size="sm"
placement="bottom" placement="bottom"
/> />
@@ -1,7 +1,13 @@
import axios, { AxiosError } from 'axios'; import axios, { AxiosError } from 'axios';
import { apiClient } from './httpClient'; import { apiClient } from './httpClient';
import { toolsService } from './toolsService'; import { toolsService } from './toolsService';
import { getCurrentUserSub } from '../auth/AuthProvider'; import {
getCurrentUserSub,
getCurrentUserTenantId,
getCurrentUserHashedTenantId,
} from '../auth/AuthProvider';
export { getCurrentUserTenantId, getCurrentUserHashedTenantId };
import { import {
Dashboard, Dashboard,
Organisation, Organisation,
@@ -144,8 +150,16 @@ export const getOrganisations = async (page = 1, pageSize = 100): Promise<Organi
return list.map(normalizeOrganisation); return list.map(normalizeOrganisation);
}; };
export const getOrganisation = async (organisationId: string | number): Promise<Organisation> => { export const getOrganisation = async (organisationId?: string | number): Promise<Organisation> => {
const hashedOrgId = await toolsService.resolveOrganisationId(organisationId); const targetOrgId =
organisationId !== undefined && organisationId !== '' && organisationId !== null
? organisationId
: getCurrentUserTenantId();
if (targetOrgId === undefined || targetOrgId === null || targetOrgId === '') {
throw new Error('Organisation ID is required');
}
const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId);
const res = await apiClient.get<any>(`/v1/api/governance/organisations/${hashedOrgId}`); const res = await apiClient.get<any>(`/v1/api/governance/organisations/${hashedOrgId}`);
return normalizeOrganisation(res.data); return normalizeOrganisation(res.data);
}; };
@@ -171,8 +185,16 @@ export const updateOrganisation = async (payload: UpdateOrganisationRequest): Pr
return res.data; return res.data;
}; };
export const getPortfolios = async (organisationId: string | number): Promise<Portfolio[]> => { export const getPortfolios = async (organisationId?: string | number): Promise<Portfolio[]> => {
const hashedOrgId = await toolsService.resolveOrganisationId(organisationId); const targetOrgId =
organisationId !== undefined && organisationId !== '' && organisationId !== null
? organisationId
: getCurrentUserTenantId();
if (targetOrgId === undefined || targetOrgId === null || targetOrgId === '') {
return [];
}
const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId);
const res = await apiClient.get<Portfolio[]>(`/v1/api/governance/organisations/${hashedOrgId}/portfolio`); const res = await apiClient.get<Portfolio[]>(`/v1/api/governance/organisations/${hashedOrgId}/portfolio`);
return res.data || []; return res.data || [];
}; };
@@ -214,10 +236,16 @@ export const createPortfolio = async (payload: CreatePortfolioRequest): Promise<
if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) { if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) {
ownerId = getUserIdByEmail(ownerId) || userSub || ownerId; ownerId = getUserIdByEmail(ownerId) || userSub || ownerId;
} }
const tenantId = getCurrentUserTenantId();
const orgId =
payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null
? toIntegerId(payload.organisationId)
: (tenantId !== null ? tenantId : 0);
const res = await apiClient.post('/v1/api/governance/organisations/portfolio', { const res = await apiClient.post('/v1/api/governance/organisations/portfolio', {
name: payload.name, name: payload.name,
description: payload.description, description: payload.description,
organisationId: toIntegerId(payload.organisationId), organisationId: orgId,
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000', ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
}); });
return res.data; return res.data;
@@ -481,8 +509,14 @@ export const getAward = async (awardId: string | number): Promise<Award> => {
}; };
export const createAward = async (payload: CreateAwardRequest): Promise<Award> => { export const createAward = async (payload: CreateAwardRequest): Promise<Award> => {
const tenantId = getCurrentUserTenantId();
const orgId =
payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null
? toIntegerId(payload.organisationId)
: (tenantId !== null ? tenantId : 0);
const res = await apiClient.post('/v1/api/grants/awards', { const res = await apiClient.post('/v1/api/grants/awards', {
organisationId: toIntegerId(payload.organisationId), organisationId: orgId,
programmeId: toIntegerId(payload.programmeId), programmeId: toIntegerId(payload.programmeId),
amount: Number(payload.amount), amount: Number(payload.amount),
}); });
@@ -888,7 +922,36 @@ export const getUsers = async (
pageSize = 100, pageSize = 100,
filter: UserFilterRequest = {} filter: UserFilterRequest = {}
): Promise<User[]> => { ): Promise<User[]> => {
const res = await apiClient.post<User[]>(`/v1/api/users/${page}/${pageSize}`, filter); const tenantId = getCurrentUserTenantId();
// Unless otherwise specified by the user, ensure tenant_id is passed into endpoints where organisationId is required for system activities
const orgId =
filter.organisationId !== undefined
? (filter.organisationId !== null && filter.organisationId !== ''
? toIntegerId(filter.organisationId)
: null)
: tenantId;
const requestBody: Record<string, any> = {
...filter,
};
if (orgId !== null && orgId !== undefined) {
requestBody.organisationId = orgId;
}
// HashID query parameter for query string protection
const queryParams: Record<string, any> = {};
if (orgId !== null && orgId !== undefined) {
const hashedOrgId = await toolsService.encodeId(orgId);
if (hashedOrgId) {
queryParams.organisationId = hashedOrgId;
}
}
const res = await apiClient.post<User[]>(
`/v1/api/users/${page}/${pageSize}`,
requestBody,
{ params: Object.keys(queryParams).length > 0 ? queryParams : undefined }
);
const list = res.data || []; const list = res.data || [];
registerUserEmails(list); registerUserEmails(list);
return list; return list;
@@ -981,8 +1044,14 @@ export const syncIntegration = async (force = true): Promise<void> => {
// 8. Communications & Invites APIs // 8. Communications & Invites APIs
// ========================================== // ==========================================
export const sendInvite = async (payload: InviteRequest): Promise<Invite> => { export const sendInvite = async (payload: InviteRequest): Promise<Invite> => {
const tenantId = getCurrentUserTenantId();
const hostOrgId =
payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null
? toIntegerId(payload.organisationId)
: (tenantId !== null ? tenantId : 0);
const res = await apiClient.post<Invite>('/v1/api/communications/invite', { const res = await apiClient.post<Invite>('/v1/api/communications/invite', {
organisationId: toIntegerId(payload.organisationId), organisationId: hostOrgId,
invitedOrganisationId: toIntegerId(payload.invitedOrganisationId), invitedOrganisationId: toIntegerId(payload.invitedOrganisationId),
awardId: toIntegerId(payload.awardId), awardId: toIntegerId(payload.awardId),
contractId: toIntegerId(payload.contractId), contractId: toIntegerId(payload.contractId),
@@ -20,6 +20,8 @@ export interface UserSyncProfile {
name?: string | null; name?: string | null;
role?: string | null; role?: string | null;
tenant?: string | null; tenant?: string | null;
tenant_id?: number | string | null;
organisationId?: number | string | null;
organisationName?: string | null; organisationName?: string | null;
} }
@@ -199,6 +201,19 @@ async function executeBackendSync(
userSub?: string, userSub?: string,
profile?: UserSyncProfile profile?: UserSyncProfile
): Promise<{ endpoint: string; data?: any }> { ): 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 = { const payload = {
id: userSub || profile?.id || profile?.sub, id: userSub || profile?.id || profile?.sub,
sub: userSub || profile?.sub, sub: userSub || profile?.sub,
@@ -206,6 +221,8 @@ async function executeBackendSync(
name: profile?.name || undefined, name: profile?.name || undefined,
role: profile?.role || undefined, role: profile?.role || undefined,
tenant: profile?.tenant || undefined, tenant: profile?.tenant || undefined,
tenant_id: parsedTenantId,
organisationId: resolvedOrgId,
}; };
const candidateEndpoints = USER_SYNC_CANDIDATE_ENDPOINTS; const candidateEndpoints = USER_SYNC_CANDIDATE_ENDPOINTS;
@@ -261,6 +261,8 @@ export interface User {
name?: string; name?: string;
organisationId?: number | string; organisationId?: number | string;
organisationName?: string; organisationName?: string;
tenant?: string;
tenant_id?: number;
} }
export type SystemUser = User; export type SystemUser = User;
@@ -286,6 +288,7 @@ export interface UserFilterRequest {
email?: string | null; email?: string | null;
sortBy?: string | null; sortBy?: string | null;
sortDescending?: boolean; sortDescending?: boolean;
organisationId?: number | string | null;
} }
export interface Organisation { export interface Organisation {