Implemented rbac

This commit is contained in:
2026-10-11 11:16:08 +02:00
parent 45a6c0aca0
commit 1142cfd94e
24 changed files with 3033 additions and 432 deletions
+108 -30
View File
@@ -92,8 +92,18 @@ import {
Notification,
NotificationPlatform,
NotificationStatus,
Role,
Claim,
CreateUserRequest,
AddUserClaimRequest,
} from '../types';
import {
encodeIdNumberHex,
encodeEntityId,
toSafeInt64String,
} from '../utils/hashids';
import {
parseFriendlyError,
FriendlyError,
@@ -1364,6 +1374,90 @@ export const updateUserStatus = async (userId: string, status: ActivityStatus |
await apiClient.get(`/v1/api/users/${userId}/status/${status}`);
};
export const createUser = async (
tenantId: number | string,
idNumber: string,
payload: CreateUserRequest
): Promise<void> => {
const encodedTenant = encodeEntityId(tenantId) || String(tenantId);
const encodedIdNumber = encodeIdNumberHex(idNumber);
await apiClient.post(`/v1/api/users/${encodeURIComponent(encodedTenant)}/${encodeURIComponent(encodedIdNumber)}`, {
id: payload.id || null,
userName: payload.userName || null,
email: payload.email || null,
emailConfirmed: payload.emailConfirmed ?? true,
phoneNumber: payload.phoneNumber || null,
phoneNumberConfirmed: payload.phoneNumberConfirmed ?? false,
LockoutEnabled: payload.LockoutEnabled ?? false,
twoFactorEnabled: payload.twoFactorEnabled ?? false,
accessFailedCount: payload.accessFailedCount !== undefined ? Number(payload.accessFailedCount) : 0,
lockoutEnd: payload.lockoutEnd || null,
});
};
import {
fetchAndCacheSystemRoles,
fetchAndCacheRoleClaims,
getSystemClaims,
getCachedSystemRoles,
getCachedRoleClaims,
preloadAndCacheAllRoleClaims,
} from './roleClaimsCache';
export {
getSystemClaims,
getCachedSystemRoles,
getCachedRoleClaims,
preloadAndCacheAllRoleClaims,
};
export const getSystemRoles = async (): Promise<Role[]> => {
return await fetchAndCacheSystemRoles();
};
export const getUserRoles = async (userId: string): Promise<Role[]> => {
const res = await apiClient.get<Role[]>(`/v1/api/users/${userId}/roles`);
return res.data || [];
};
export const assignUserRole = async (userId: string, roleId: string): Promise<void> => {
await apiClient.patch(`/v1/api/users/${userId}/roles/${roleId}`);
};
export const removeUserRole = async (userId: string, roleId: string): Promise<void> => {
await apiClient.delete(`/v1/api/users/${userId}/roles/${roleId}`);
};
export const getRoleClaims = async (roleId: string, roleName?: string): Promise<Claim[]> => {
return await fetchAndCacheRoleClaims(roleId, roleName);
};
export const getUserClaims = async (userId: string): Promise<Claim[]> => {
const res = await apiClient.get<Claim[]>(`/v1/api/users/${userId}/claims`);
return res.data || [];
};
export const addUserClaim = async (payload: AddUserClaimRequest): Promise<void> => {
await apiClient.patch('/v1/api/users/user/claims', {
userId: payload.userId,
claimId: Number(payload.claimId),
claimType: payload.claimType,
claimValue: payload.claimValue,
});
};
export const deleteUserClaim = async (targetUserId: string, claimId: string | number): Promise<void> => {
await apiClient.delete(`/v1/api/users/${targetUserId}/claims/${claimId}`);
};
export const forceLogoutUser = async (userId: string): Promise<void> => {
await apiClient.put(`/v1/api/users/${userId}/session/logout`);
};
export const setUserLockout = async (userId: string, disable: boolean): Promise<void> => {
await apiClient.put(`/v1/api/users/${userId}/session/lockout/${disable}`);
};
import {
syncUserAccount,
ensureUserSynced,
@@ -1421,29 +1515,21 @@ 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);
? toSafeInt64String(payload.organisationId)
: (tenantId !== null ? String(tenantId) : '0');
const body = {
organisationId: hostOrgId,
invitedOrganisationId: toIntegerId(payload.invitedOrganisationId),
awardId: toIntegerId(payload.awardId),
contractId: toIntegerId(payload.contractId),
invitedOrganisationId: toSafeInt64String(payload.invitedOrganisationId),
awardId: toSafeInt64String(payload.awardId),
contractId: toSafeInt64String(payload.contractId),
recipient: payload.recipient,
message: payload.message,
platform: payload.platform !== undefined ? Number(payload.platform) : 1,
};
try {
const res = await apiClient.post<Invite>('/v1/api/communications/invites', body);
return res.data;
} catch (err: any) {
if (err?.response?.status === 404) {
const res = await apiClient.post<Invite>('/v1/api/communications/invite', body);
return res.data;
}
throw err;
}
const res = await apiClient.post<Invite>('/v1/api/communications/invites', body);
return res.data;
};
export const getInvites = async (page = 1, pageSize = 100): Promise<Invite[]> => {
@@ -1451,6 +1537,11 @@ export const getInvites = async (page = 1, pageSize = 100): Promise<Invite[]> =>
return res.data || [];
};
export const getApplicantData = async (applicantUserId: string): Promise<Invite> => {
const res = await apiClient.get<Invite>(`/v1/api/communications/applicants/${applicantUserId}/all`);
return res.data;
};
export const getApplicantInvite = async (applicantUserId: string): Promise<Invite> => {
try {
const res = await apiClient.get<Invite>(`/v1/api/communications/invites/applicants/${applicantUserId}`);
@@ -1481,26 +1572,13 @@ export const getNotifications = async (organisationId?: string | number): Promis
const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId);
try {
const res = await apiClient.get<Notification[]>(
`/v1/api/communications/norifications/${encodeURIComponent(hashedOrgId)}`
`/v1/api/communications/notifications/${encodeURIComponent(hashedOrgId)}`
);
if (res.data && Array.isArray(res.data)) {
return res.data;
}
} catch (err: any) {
if (err?.response?.status === 404) {
try {
const fallbackRes = await apiClient.get<Notification[]>(
`/v1/api/communications/notifications/${encodeURIComponent(hashedOrgId)}`
);
if (fallbackRes.data && Array.isArray(fallbackRes.data)) {
return fallbackRes.data;
}
} catch (fallbackErr) {
console.warn('[getNotifications] Notification fallback route error:', fallbackErr);
}
} else {
console.warn('[getNotifications] API error fetching notifications:', err);
}
console.warn('[getNotifications] API error fetching notifications:', err);
}
return [];
@@ -0,0 +1,426 @@
import { apiClient } from './httpClient';
import { Role, Claim } from '../types';
import { ROLE_CLAIM_DEFINITIONS } from '../auth/rbac';
export const LOCAL_STORAGE_SYSTEM_ROLES_KEY = 'pfm_cache_system_roles';
export const LOCAL_STORAGE_ROLE_CLAIMS_PREFIX = 'pfm_cache_role_claims_';
export const LOCAL_STORAGE_SYSTEM_CLAIMS_KEY = 'pfm_cache_system_claims';
// In-memory runtime caches
let inMemorySystemRoles: Role[] | null = null;
const inMemoryRoleClaims = new Map<string, Claim[]>();
let inMemorySystemClaims: Claim[] | null = null;
// Standard role claim defaults matching PFM Identity Server specification
export const DEFAULT_ROLE_CLAIMS: Record<string, Claim[]> = {
contributor: [
{ claimId: 30, claimType: 'projects_milestones', claimValue: 'view, edit, update' },
{ claimId: 31, claimType: 'grant_financials', claimValue: 'view, edit, update' },
{ claimId: 32, claimType: 'evidence_risks', claimValue: 'view, edit, update' },
{ claimId: 33, claimType: 'beneficiaries', claimValue: 'view, edit, update' },
{ claimId: 34, claimType: 'change_management', claimValue: 'view, edit, update' },
{ claimId: 35, claimType: 'site_visits', claimValue: 'view, edit, update' },
{ claimId: 36, claimType: 'settings_invites', claimValue: 'view, edit, update' },
],
supervisor: [
{ claimId: 20, claimType: 'dashboard', claimValue: 'view, add, export' },
{ claimId: 21, claimType: 'projects_milestones', claimValue: 'view, add, export' },
{ claimId: 22, claimType: 'grant_financials', claimValue: 'view, add, export' },
{ claimId: 23, claimType: 'evidence_risks', claimValue: 'view, add, export' },
{ claimId: 24, claimType: 'beneficiaries', claimValue: 'view, add, export' },
{ claimId: 25, claimType: 'change_management', claimValue: 'view, add, export' },
{ claimId: 26, claimType: 'site_visits', claimValue: 'view, add, export' },
{ claimId: 27, claimType: 'settings_invites', claimValue: 'view, add, export' },
{ claimId: 28, claimType: 'settings_users', claimValue: 'view, add, export' },
],
manager: [
{ claimId: 10, claimType: 'dashboard', claimValue: 'all' },
{ claimId: 11, claimType: 'projects_milestones', claimValue: 'all' },
{ claimId: 12, claimType: 'grant_financials', claimValue: 'all' },
{ claimId: 13, claimType: 'evidence_risks', claimValue: 'all' },
{ claimId: 14, claimType: 'beneficiaries', claimValue: 'all' },
{ claimId: 15, claimType: 'change_management', claimValue: 'all' },
{ claimId: 16, claimType: 'site_visits', claimValue: 'all' },
{ claimId: 17, claimType: 'settings_invites', claimValue: 'all' },
{ claimId: 18, claimType: 'settings_users', claimValue: 'all' },
{ claimId: 19, claimType: 'settings_auditlog', claimValue: 'all' },
{ claimId: 29, claimType: 'settings_notifications', claimValue: 'all' },
{ claimId: 37, claimType: 'tenant_self_service', claimValue: 'all' },
],
executive: [
{ claimId: 1, claimType: 'dashboard', claimValue: 'all' },
{ claimId: 2, claimType: 'governance', claimValue: 'all' },
{ claimId: 3, claimType: 'projects_milestones', claimValue: 'all' },
{ claimId: 4, claimType: 'grant_financials', claimValue: 'all' },
{ claimId: 5, claimType: 'evidence_risks', claimValue: 'all' },
{ claimId: 6, claimType: 'tenant_self_service', claimValue: 'all' },
],
admin: [
{ claimId: 99, claimType: 'unrestricted', claimValue: 'all' },
],
};
/**
* Normalizes claim objects to contain both claimType/claimValue and type/value
*/
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,
};
}
/**
* Retrieve cached system roles from memory or localStorage.
*/
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;
}
/**
* Store system roles into memory and localStorage.
*/
export function setCachedSystemRoles(roles: Role[]): void {
inMemorySystemRoles = roles;
if (typeof window !== 'undefined') {
try {
localStorage.setItem(LOCAL_STORAGE_SYSTEM_ROLES_KEY, JSON.stringify(roles));
} catch {}
}
}
/**
* Retrieve cached role claims for a specific role ID or role name.
*/
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;
}
/**
* Store role claims into memory and localStorage.
*/
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 {}
}
}
/**
* Fetches system roles from backend API, caching the result in memory and localStorage.
*/
export async function fetchAndCacheSystemRoles(): Promise<Role[]> {
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 (err) {
// If request fails, fall back to cached roles
}
const cached = getCachedSystemRoles();
if (cached && cached.length > 0) return cached;
// Fallback defaults if offline / bootstrapping
const defaults: Role[] = [
{ id: '1', name: 'Admin' },
{ id: '2', name: 'Executive' },
{ id: '3', name: 'Supervisor' },
{ id: '4', name: 'Manager' },
{ id: '5', name: 'Contributor' },
];
setCachedSystemRoles(defaults);
return defaults;
}
/**
* Fetches role claims for a role from the API, caching them under role ID and role name.
*/
export async function fetchAndCacheRoleClaims(roleId: string, roleName?: string): Promise<Claim[]> {
const roleIdKey = roleId.toLowerCase().trim();
const roleNameKey = (roleName || '').toLowerCase().trim();
try {
const res = await apiClient.get<Claim[]>(`/v1/api/users/system/roles/${roleId}/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);
return normalized;
}
} catch (err) {
// Network or server error, proceed to cache / defaults fallback
}
// Check cache by role ID
const cachedById = getCachedRoleClaims(roleIdKey);
if (cachedById && cachedById.length > 0) return cachedById;
// Check cache by role name
if (roleNameKey) {
const cachedByName = getCachedRoleClaims(roleNameKey);
if (cachedByName && cachedByName.length > 0) return cachedByName;
}
// Fallback from predefined matrix
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;
}
/**
* Direct API calls for user roles and user claims
*/
export async function getUserRolesFromApi(userId: string): Promise<Role[]> {
try {
const res = await apiClient.get<Role[]>(`/v1/api/users/${userId}/roles`);
return res.data || [];
} catch {
return [];
}
}
export async function getUserClaimsFromApi(userId: string): Promise<Claim[]> {
try {
const res = await apiClient.get<Claim[]>(`/v1/api/users/${userId}/claims`);
return res.data || [];
} catch {
return [];
}
}
/**
* Resolves user roles, role claims, and user claims (including tenant and tenant_id)
* and caches system roles and role claims as a persistent reference.
*/
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;
}> {
const mergedClaims: Record<string, any> = { ...initialClaims };
const resolvedRoles: string[] = [];
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);
// 1. Get user's roles from backend API (Requirement 2)
if (userSub) {
try {
const userRoles = await getUserRolesFromApi(userSub);
if (Array.isArray(userRoles) && userRoles.length > 0) {
userRoles.forEach((r) => {
if (r.name && !resolvedRoles.includes(r.name)) {
resolvedRoles.push(r.name);
}
});
if (resolvedRoles.length > 0) {
primaryRole = resolvedRoles[0];
}
}
} catch {}
}
// Fallback to initial role if API returned empty
if (resolvedRoles.length === 0 && primaryRole) {
resolvedRoles.push(primaryRole);
}
// 2. For each role, retrieve role claims using the endpoint (Requirement 2 & 7)
const allRoleClaims: Claim[] = [];
for (const roleName of resolvedRoles) {
try {
const roleClaims = await fetchAndCacheRoleClaims(roleName, roleName);
if (Array.isArray(roleClaims)) {
roleClaims.forEach((rc) => {
const norm = normalizeClaim(rc);
allRoleClaims.push(norm);
if (norm.claimType) {
mergedClaims[norm.claimType] = norm.claimValue || 'all';
}
});
}
} catch {}
}
if (allRoleClaims.length > 0) {
mergedClaims.role_claims = allRoleClaims;
}
// 3. Retrieve user claims from endpoint (Requirement 3)
if (userSub) {
try {
const userClaims = await getUserClaimsFromApi(userSub);
if (Array.isArray(userClaims) && userClaims.length > 0) {
userClaims.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 = userClaims.map(normalizeClaim);
}
} catch {}
}
// 4. Background preload all system roles & role claims for reference (Requirement 7)
preloadAndCacheAllRoleClaims().catch(() => {});
return {
roles: resolvedRoles,
claims: mergedClaims,
primaryRole,
tenant,
tenant_id,
};
}
/**
* Preloads and caches all system roles and their respective role claims.
*/
export async function preloadAndCacheAllRoleClaims(): Promise<void> {
const roles = await fetchAndCacheSystemRoles();
for (const role of roles) {
await fetchAndCacheRoleClaims(role.id, role.name);
}
}
/**
* Retrieves all system claims in the identity management system.
* Any claim is assignable to an authorized user (with admin being a special case).
*/
export async function getSystemClaims(): Promise<Claim[]> {
if (inMemorySystemClaims && inMemorySystemClaims.length > 0) {
return inMemorySystemClaims;
}
if (typeof window !== 'undefined') {
try {
const raw = localStorage.getItem(LOCAL_STORAGE_SYSTEM_CLAIMS_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) {
inMemorySystemClaims = parsed;
return parsed;
}
}
} catch {}
}
const claimsMap = new Map<string, Claim>();
// 1. Try system claims endpoint directly
try {
const res = await apiClient.get<Claim[]>('/v1/api/users/system/claims');
if (res.data && Array.isArray(res.data)) {
res.data.forEach((c) => {
const norm = normalizeClaim(c);
if (norm.claimType) claimsMap.set(norm.claimType, norm);
});
}
} catch {}
// 2. Aggregate role claims from all system roles
try {
const roles = await fetchAndCacheSystemRoles();
for (const r of roles) {
const claims = await fetchAndCacheRoleClaims(r.id, r.name);
claims.forEach((c) => {
const norm = normalizeClaim(c);
if (norm.claimType && !claimsMap.has(norm.claimType)) {
claimsMap.set(norm.claimType, norm);
}
});
}
} catch {}
// 3. Ensure all standard defined claims from RBAC specification exist
ROLE_CLAIM_DEFINITIONS.forEach((def, index) => {
if (!claimsMap.has(def.claim)) {
claimsMap.set(def.claim, {
claimId: index + 1,
claimType: def.claim,
claimValue: 'all',
type: def.claim,
value: 'all',
});
}
});
const allClaims = Array.from(claimsMap.values());
inMemorySystemClaims = allClaims;
if (typeof window !== 'undefined') {
try {
localStorage.setItem(LOCAL_STORAGE_SYSTEM_CLAIMS_KEY, JSON.stringify(allClaims));
} catch {}
}
return allClaims;
}