frontend_nyla/src/hooks/usePermissions.ts

195 lines
5.4 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from 'react';
import { useApiRequest } from './useApi';
import {
fetchPermissions as fetchPermissionsApi,
type PermissionLevel,
type UserPermissions,
type PermissionContext
} from '../api/permissionApi';
// Re-export types for backward compatibility
export type { PermissionLevel, UserPermissions, PermissionContext };
// Permission cache interface
interface PermissionCache {
[key: string]: UserPermissions;
}
// Operation type for permission checks
export type PermissionOperation = 'read' | 'create' | 'update' | 'delete';
/**
* Hook for managing RBAC permissions
* Provides centralized permission checking with caching
*/
export const usePermissions = () => {
const [cache, setCache] = useState<PermissionCache>({});
const cacheRef = useRef<PermissionCache>({});
const [loading, setLoading] = useState(false);
const pendingRequests = useRef<Map<string, Promise<UserPermissions>>>(new Map());
const { request } = useApiRequest();
// Keep cacheRef in sync with cache state
useEffect(() => {
cacheRef.current = cache;
}, [cache]);
/**
* Generate a cache key for a permission check
*/
const getPermissionKey = (context: PermissionContext, item?: string): string => {
return `${context}:${item || 'null'}`;
};
/**
* Retry function with exponential backoff for 429 errors
*/
const retryWithBackoff = async (
fn: () => Promise<any>,
maxRetries = 3,
baseDelay = 1000
): Promise<any> => {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error: any) {
if (error.response?.status === 429 && attempt < maxRetries - 1) {
const delay = baseDelay * Math.pow(2, attempt);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
};
/**
* Check permissions for a given context and item
* Returns full UserPermissions object
* Checks cache first, then fetches from backend if not cached
*/
const checkPermission = useCallback(async (
context: PermissionContext,
item?: string
): Promise<UserPermissions> => {
const key = getPermissionKey(context, item);
// Check cache first using ref to avoid stale closures
if (cacheRef.current[key]) {
return cacheRef.current[key];
}
// Check if there's already a pending request for this key
if (pendingRequests.current.has(key)) {
return pendingRequests.current.get(key)!;
}
// Create new request
const requestPromise = (async () => {
setLoading(true);
try {
// Use retry logic for 429 errors
// Note: We wrap the API call in retry logic since useApiRequest doesn't handle 429 retries
const permissions = await retryWithBackoff(async () => {
try {
return await fetchPermissionsApi(request, context, item);
} catch (error: any) {
// If useApiRequest throws, we need to check if it's a 429
// For now, we'll let the retry logic handle it
throw error;
}
});
// Update cache after fetching from backend
setCache(prev => {
const newCache = { ...prev, [key]: permissions };
cacheRef.current = newCache;
return newCache;
});
return permissions;
} catch (error: any) {
// Only log non-429 errors to avoid spam
if (error.response?.status !== 429) {
console.error('Error checking permissions:', error);
}
// Return cached value if available, otherwise default (no access)
const cached = cacheRef.current[key];
if (cached) {
return cached;
}
return {
view: false,
read: 'n' as PermissionLevel,
create: 'n' as PermissionLevel,
update: 'n' as PermissionLevel,
delete: 'n' as PermissionLevel,
};
} finally {
pendingRequests.current.delete(key);
setLoading(false);
}
})();
// Store pending request to prevent duplicates
pendingRequests.current.set(key, requestPromise);
return requestPromise;
}, [request]);
/**
* Check if user has permission for a specific operation
* Returns true if user has any level of permission (not 'n')
*/
const hasPermission = useCallback(async (
context: PermissionContext,
item: string,
operation?: PermissionOperation
): Promise<boolean> => {
const permissions = await checkPermission(context, item);
if (!permissions.view) {
return false;
}
if (context === 'DATA' && operation) {
const level = permissions[operation];
return level !== 'n';
}
return true;
}, [checkPermission]);
/**
* Check if user can view a resource
* Returns true if permissions.view is true
*/
const canView = useCallback(async (
context: PermissionContext,
item: string
): Promise<boolean> => {
const permissions = await checkPermission(context, item);
return permissions.view;
}, [checkPermission]);
/**
* Clear the permission cache
* Useful when user permissions change or after logout
*/
const clearCache = useCallback(() => {
setCache({});
cacheRef.current = {};
pendingRequests.current.clear();
}, []);
return {
checkPermission,
hasPermission,
canView,
loading,
clearCache,
};
};