RBAC Improvements
Caching improvements Maker-Checker Implementation
This commit is contained in:
@@ -5,33 +5,64 @@ 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';
|
||||
export const LOCAL_STORAGE_RBAC_VERSION_KEY = 'pfm_rbac_cache_version';
|
||||
export const CURRENT_RBAC_CACHE_VERSION = 'v2.1';
|
||||
|
||||
/**
|
||||
* Invalidates and purges legacy cached claims if the schema version differs.
|
||||
*/
|
||||
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) {
|
||||
// Clear legacy claims and caches
|
||||
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 {}
|
||||
}
|
||||
|
||||
// In-memory runtime caches
|
||||
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; // 5 minutes
|
||||
|
||||
// Initial check on load
|
||||
ensureFreshRbacCache();
|
||||
|
||||
// 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' },
|
||||
{ claimId: 30, claimType: 'projects_milestones', claimValue: 'view, add' },
|
||||
{ claimId: 31, claimType: 'grant_financials', claimValue: 'view, add' },
|
||||
{ claimId: 32, claimType: 'evidence_risks', claimValue: 'view, add' },
|
||||
{ claimId: 33, claimType: 'beneficiaries', claimValue: 'view, add' },
|
||||
{ claimId: 34, claimType: 'change_management', claimValue: 'view, add' },
|
||||
{ claimId: 35, claimType: 'site_visits', claimValue: 'view, add' },
|
||||
{ claimId: 36, claimType: 'settings_invites', claimValue: 'view, add' },
|
||||
],
|
||||
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' },
|
||||
{ claimId: 20, claimType: 'dashboard', claimValue: 'view, export' },
|
||||
{ claimId: 21, claimType: 'projects_milestones', claimValue: 'view, add, update, export' },
|
||||
{ claimId: 22, claimType: 'grant_financials', claimValue: 'view, add, update, export' },
|
||||
{ claimId: 23, claimType: 'evidence_risks', claimValue: 'view, add, update, export' },
|
||||
{ claimId: 24, claimType: 'beneficiaries', claimValue: 'view, add, update, export' },
|
||||
{ claimId: 25, claimType: 'change_management', claimValue: 'view, add, update, export' },
|
||||
{ claimId: 26, claimType: 'site_visits', claimValue: 'view, add, update, export' },
|
||||
{ claimId: 27, claimType: 'settings_invites', claimValue: 'view, add, update, export' },
|
||||
{ claimId: 28, claimType: 'settings_users', claimValue: 'view, add, update, export' },
|
||||
],
|
||||
manager: [
|
||||
{ claimId: 10, claimType: 'dashboard', claimValue: 'all' },
|
||||
@@ -154,8 +185,14 @@ export function setCachedRoleClaims(roleKey: string, claims: Claim[]): void {
|
||||
|
||||
/**
|
||||
* Fetches system roles from backend API, caching the result in memory and localStorage.
|
||||
* Checks cache first to avoid redundant network round trips.
|
||||
*/
|
||||
export async function fetchAndCacheSystemRoles(): Promise<Role[]> {
|
||||
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) {
|
||||
@@ -181,30 +218,69 @@ export async function fetchAndCacheSystemRoles(): Promise<Role[]> {
|
||||
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}$/;
|
||||
|
||||
/**
|
||||
* Fetches role claims for a role from the API, caching them under role ID and role name.
|
||||
* Uses cache-first strategy to eliminate sequential network waterfalls on startup.
|
||||
*/
|
||||
export async function fetchAndCacheRoleClaims(roleId: string, roleName?: string): Promise<Claim[]> {
|
||||
export async function fetchAndCacheRoleClaims(
|
||||
roleId: string,
|
||||
roleName?: string,
|
||||
forceRefresh = false
|
||||
): 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;
|
||||
// 1. Check in-memory and localStorage cache first
|
||||
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;
|
||||
}
|
||||
} catch (err) {
|
||||
// Network or server error, proceed to cache / defaults fallback
|
||||
}
|
||||
|
||||
// Check cache by role ID
|
||||
// 2. Resolve actual GUID if roleId is not formatted as a GUID
|
||||
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 (err) {
|
||||
// Network or server error, proceed to cache / defaults fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Re-check cache by role ID or name
|
||||
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;
|
||||
@@ -218,30 +294,118 @@ export async function fetchAndCacheRoleClaims(roleId: string, roleName?: string)
|
||||
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 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_cache_user_roles_${key}`);
|
||||
localStorage.removeItem(`pfm_cache_user_claims_${key}`);
|
||||
} catch {}
|
||||
}
|
||||
} else {
|
||||
inMemoryUserRoles.clear();
|
||||
inMemoryUserClaims.clear();
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i);
|
||||
if (k && (k.startsWith('pfm_cache_user_roles_') || k.startsWith('pfm_cache_user_claims_'))) {
|
||||
keysToRemove.push(k);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach((k) => localStorage.removeItem(k));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserClaimsFromApi(userId: string): Promise<Claim[]> {
|
||||
/**
|
||||
* Direct API calls for user roles and user claims with session-level caching
|
||||
*/
|
||||
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_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_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_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`);
|
||||
return res.data || [];
|
||||
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_cache_user_claims_${key}`, JSON.stringify(claims));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return claims;
|
||||
} catch {
|
||||
return [];
|
||||
return inMemoryUserClaims.get(key)?.claims || [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves user roles, role claims, and user claims (including tenant and tenant_id)
|
||||
* and caches system roles and role claims as a persistent reference.
|
||||
* concurrently using Promise.allSettled and caches system roles and claims.
|
||||
*/
|
||||
export async function resolveUserRolesAndClaims(
|
||||
userSub: string,
|
||||
@@ -254,27 +418,30 @@ export async function resolveUserRolesAndClaims(
|
||||
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);
|
||||
|
||||
// 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];
|
||||
}
|
||||
// 1. Concurrently fetch user's roles and user's claims in parallel
|
||||
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) => {
|
||||
if (r.name && !resolvedRoles.includes(r.name)) {
|
||||
resolvedRoles.push(r.name);
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
if (resolvedRoles.length > 0) {
|
||||
primaryRole = resolvedRoles[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to initial role if API returned empty
|
||||
@@ -282,55 +449,57 @@ export async function resolveUserRolesAndClaims(
|
||||
resolvedRoles.push(primaryRole);
|
||||
}
|
||||
|
||||
// 2. For each role, retrieve role claims using the endpoint (Requirement 2 & 7)
|
||||
// 2. Concurrently fetch role claims for all resolved roles in parallel
|
||||
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 {}
|
||||
}
|
||||
const roleClaimsPromises = resolvedRoles.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 || 'all';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
// 3. Process user claims
|
||||
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;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
if (norm.claimType) {
|
||||
mergedClaims[norm.claimType] = norm.claimValue;
|
||||
}
|
||||
});
|
||||
mergedClaims.user_claims = claimsResult.value.map(normalizeClaim);
|
||||
}
|
||||
|
||||
// 4. Background preload all system roles & role claims for reference (Requirement 7)
|
||||
// 4. Background preload all system roles & role claims for reference (non-blocking)
|
||||
preloadAndCacheAllRoleClaims().catch(() => {});
|
||||
|
||||
return {
|
||||
@@ -343,13 +512,13 @@ export async function resolveUserRolesAndClaims(
|
||||
}
|
||||
|
||||
/**
|
||||
* Preloads and caches all system roles and their respective role claims.
|
||||
* Preloads and caches all system roles and their respective role claims concurrently.
|
||||
*/
|
||||
export async function preloadAndCacheAllRoleClaims(): Promise<void> {
|
||||
const roles = await fetchAndCacheSystemRoles();
|
||||
for (const role of roles) {
|
||||
await fetchAndCacheRoleClaims(role.id, role.name);
|
||||
}
|
||||
await Promise.allSettled(
|
||||
roles.map((role) => fetchAndCacheRoleClaims(role.id, role.name))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user