diff --git a/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx b/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx index 6be0a35..46330d2 100644 --- a/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx +++ b/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx @@ -14,6 +14,7 @@ import { } from '../services/userSyncService'; import { AccountActivationModal } from '../components/auth/AccountActivationModal'; import { SessionExpiredModal } from '../components/auth/SessionExpiredModal'; +import { encodeHashIdSync } from '../services/toolsService'; import { onSessionExpired, triggerSessionExpired, @@ -30,6 +31,7 @@ export interface AuthUserProfile { role: string; organisationName?: string; tenant?: string; + tenant_id?: number; avatarUrl?: string; } @@ -39,6 +41,8 @@ interface AuthContextType { user: AuthUserProfile | null; sub: string; claims: UserClaims; + tenantId: number | null; + hashedTenantId: string | null; accessToken: string | null; isAuthenticated: 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_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 { if (typeof window !== 'undefined') { return localStorage.getItem(LOCAL_STORAGE_SUB_KEY) || ''; @@ -119,6 +192,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => const [user, setUser] = useState(null); const [sub, setSub] = useState(''); const [claims, setClaims] = useState({}); + const [tenantId, setTenantId] = useState(() => { + return extractTenantId( + getCurrentUserClaims(), + typeof window !== 'undefined' ? localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY) : null + ); + }); const [accessToken, setAccessToken] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSynced, setIsSynced] = useState(() => isUserSyncedInBrowser()); @@ -128,6 +207,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => const [isSessionExpired, setIsSessionExpired] = useState(false); const [sessionExpiredReason, setSessionExpiredReason] = useState(null); + const hashedTenantId = tenantId !== null && tenantId !== undefined ? encodeHashIdSync(tenantId) : null; + // Subscribe to global HTTP 401 session expiration events useEffect(() => { 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)); const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || undefined; + const parsedTenantId = extractTenantId(mergedClaims, token); + setTenantId(parsedTenantId); const profile: AuthUserProfile = { 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'), organisationName: (mergedClaims.organisation as string) || (mergedClaims.org as string) || undefined, tenant: tenantClaim, + tenant_id: parsedTenantId ?? undefined, }; setUser(profile); @@ -281,6 +365,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => setSub(storedSub); } + const storedTenantId = extractTenantId(parsedClaims, storedToken); + setTenantId(storedTenantId); + let resolvedUser: AuthUserProfile | null = null; if (storedUser) { try { @@ -288,6 +375,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => if (!parsedUser.tenant && parsedClaims.tenant) { parsedUser.tenant = parsedClaims.tenant; } + if (parsedUser.tenant_id === undefined && storedTenantId !== null) { + parsedUser.tenant_id = storedTenantId; + } setUser(parsedUser); resolvedUser = parsedUser; } catch {} @@ -402,6 +492,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => setUser(null); setSub(''); setClaims({}); + setTenantId(null); setAccessToken(null); setIsSynced(false); setIsPendingActivation(false); @@ -424,6 +515,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => user, sub, claims, + tenantId, + hashedTenantId, accessToken, isAuthenticated: !!accessToken, isLoading, diff --git a/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx b/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx index d56c6bb..3c13685 100644 --- a/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx +++ b/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx @@ -63,7 +63,7 @@ const navItems = [ ]; 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 [userMenuOpen, setUserMenuOpen] = useState(false); const navigate = useNavigate(); @@ -219,15 +219,25 @@ export const AppLayout: React.FC = () => {
Tenant: {tenantName} + {hashedTenantId && ( + + {hashedTenantId} + + )} diff --git a/PostFundManagement.ConsoleUI/src/services/api.ts b/PostFundManagement.ConsoleUI/src/services/api.ts index fc423e8..4c187d0 100644 --- a/PostFundManagement.ConsoleUI/src/services/api.ts +++ b/PostFundManagement.ConsoleUI/src/services/api.ts @@ -1,7 +1,13 @@ import axios, { AxiosError } from 'axios'; import { apiClient } from './httpClient'; import { toolsService } from './toolsService'; -import { getCurrentUserSub } from '../auth/AuthProvider'; +import { + getCurrentUserSub, + getCurrentUserTenantId, + getCurrentUserHashedTenantId, +} from '../auth/AuthProvider'; + +export { getCurrentUserTenantId, getCurrentUserHashedTenantId }; import { Dashboard, Organisation, @@ -144,8 +150,16 @@ export const getOrganisations = async (page = 1, pageSize = 100): Promise => { - const hashedOrgId = await toolsService.resolveOrganisationId(organisationId); +export const getOrganisation = async (organisationId?: string | number): Promise => { + 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(`/v1/api/governance/organisations/${hashedOrgId}`); return normalizeOrganisation(res.data); }; @@ -171,8 +185,16 @@ export const updateOrganisation = async (payload: UpdateOrganisationRequest): Pr return res.data; }; -export const getPortfolios = async (organisationId: string | number): Promise => { - const hashedOrgId = await toolsService.resolveOrganisationId(organisationId); +export const getPortfolios = async (organisationId?: string | number): Promise => { + 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(`/v1/api/governance/organisations/${hashedOrgId}/portfolio`); return res.data || []; }; @@ -214,10 +236,16 @@ export const createPortfolio = async (payload: CreatePortfolioRequest): Promise< if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) { 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', { name: payload.name, description: payload.description, - organisationId: toIntegerId(payload.organisationId), + organisationId: orgId, ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000', }); return res.data; @@ -481,8 +509,14 @@ export const getAward = async (awardId: string | number): Promise => { }; export const createAward = async (payload: CreateAwardRequest): Promise => { + 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', { - organisationId: toIntegerId(payload.organisationId), + organisationId: orgId, programmeId: toIntegerId(payload.programmeId), amount: Number(payload.amount), }); @@ -888,7 +922,36 @@ export const getUsers = async ( pageSize = 100, filter: UserFilterRequest = {} ): Promise => { - const res = await apiClient.post(`/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 = { + ...filter, + }; + if (orgId !== null && orgId !== undefined) { + requestBody.organisationId = orgId; + } + + // HashID query parameter for query string protection + const queryParams: Record = {}; + if (orgId !== null && orgId !== undefined) { + const hashedOrgId = await toolsService.encodeId(orgId); + if (hashedOrgId) { + queryParams.organisationId = hashedOrgId; + } + } + + const res = await apiClient.post( + `/v1/api/users/${page}/${pageSize}`, + requestBody, + { params: Object.keys(queryParams).length > 0 ? queryParams : undefined } + ); const list = res.data || []; registerUserEmails(list); return list; @@ -981,8 +1044,14 @@ export const syncIntegration = async (force = true): Promise => { // 8. Communications & Invites APIs // ========================================== export const sendInvite = async (payload: InviteRequest): Promise => { + 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('/v1/api/communications/invite', { - organisationId: toIntegerId(payload.organisationId), + organisationId: hostOrgId, invitedOrganisationId: toIntegerId(payload.invitedOrganisationId), awardId: toIntegerId(payload.awardId), contractId: toIntegerId(payload.contractId), diff --git a/PostFundManagement.ConsoleUI/src/services/userSyncService.ts b/PostFundManagement.ConsoleUI/src/services/userSyncService.ts index af04a7d..9d452ea 100644 --- a/PostFundManagement.ConsoleUI/src/services/userSyncService.ts +++ b/PostFundManagement.ConsoleUI/src/services/userSyncService.ts @@ -20,6 +20,8 @@ export interface UserSyncProfile { name?: string | null; role?: string | null; tenant?: string | null; + tenant_id?: number | string | null; + organisationId?: number | string | null; organisationName?: string | null; } @@ -199,6 +201,19 @@ 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, @@ -206,6 +221,8 @@ async function executeBackendSync( name: profile?.name || undefined, role: profile?.role || undefined, tenant: profile?.tenant || undefined, + tenant_id: parsedTenantId, + organisationId: resolvedOrgId, }; const candidateEndpoints = USER_SYNC_CANDIDATE_ENDPOINTS; diff --git a/PostFundManagement.ConsoleUI/src/types.ts b/PostFundManagement.ConsoleUI/src/types.ts index be6bb85..07a0a96 100644 --- a/PostFundManagement.ConsoleUI/src/types.ts +++ b/PostFundManagement.ConsoleUI/src/types.ts @@ -261,6 +261,8 @@ export interface User { name?: string; organisationId?: number | string; organisationName?: string; + tenant?: string; + tenant_id?: number; } export type SystemUser = User; @@ -286,6 +288,7 @@ export interface UserFilterRequest { email?: string | null; sortBy?: string | null; sortDescending?: boolean; + organisationId?: number | string | null; } export interface Organisation {