[RBAC-004] feat: Add frontend permission integration

Phase 4 frontend implementation:
- permissions.api.ts: API service for GET /api/v1/permissions/me
- usePermissionStore.ts: Zustand store with caching (5min TTL)
- usePermissions.tsx: React hook with auto-fetch on auth
- CanAccess component: Permission-based conditional rendering

Features:
- can(resource, action): Single permission check
- canAny(...codes): OR logic for multiple permissions
- canAll(...codes): AND logic for multiple permissions
- hasRole/hasAnyRole: Role-based checks
- Super admin bypass
- Auto-fetch on authentication
- Cache invalidation on logout

Usage example:
```tsx
const { can, canAny } = usePermissions();
if (!can('invoices', 'create')) return <AccessDenied />;

<CanAccess permission="invoices:delete">
  <DeleteButton />
</CanAccess>
```

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-31 01:57:04 -06:00
parent 1363fbd5f6
commit a3b61b8ae4
7 changed files with 387 additions and 0 deletions

View File

@ -10,3 +10,4 @@ export * from './purchases.api';
export * from './financial.api';
export * from './crm.api';
export * from './projects.api';
export * from './permissions.api';

View File

@ -0,0 +1,35 @@
import api from './axios-instance';
import { API_ENDPOINTS } from '@shared/constants/api-endpoints';
import type { ApiResponse } from '@shared/types/api.types';
/**
* Permission object representing effective user permissions
*/
export interface EffectivePermission {
resource: string;
action: string;
source: 'role' | 'direct';
roleId?: string;
roleName?: string;
}
export interface PermissionsResponse {
permissions: EffectivePermission[];
roles: string[];
}
export const permissionsApi = {
/**
* Get current user's effective permissions
* Endpoint: GET /api/v1/permissions/me
*/
getMyPermissions: async (): Promise<PermissionsResponse> => {
const response = await api.get<ApiResponse<PermissionsResponse>>(
API_ENDPOINTS.PERMISSIONS.ME
);
if (!response.data.success || !response.data.data) {
throw new Error(response.data.error || 'Error al obtener permisos');
}
return response.data.data;
},
};

View File

@ -91,4 +91,12 @@ export const API_ENDPOINTS = {
ACTIVITIES: `${API_VERSION}/system/activities`,
FOLLOWERS: `${API_VERSION}/system/followers`,
},
PERMISSIONS: {
ME: `${API_VERSION}/permissions/me`,
},
ROLES: {
BASE: `${API_VERSION}/roles`,
BY_ID: (id: string) => `${API_VERSION}/roles/${id}`,
PERMISSIONS: (id: string) => `${API_VERSION}/roles/${id}/permissions`,
},
} as const;

View File

@ -1,3 +1,4 @@
export * from './useDebounce';
export * from './useLocalStorage';
export * from './useMediaQuery';
export * from './usePermissions';

View File

@ -0,0 +1,183 @@
import { useEffect, useCallback } from 'react';
import { usePermissionStore } from '@shared/stores/usePermissionStore';
import { useAuthStore } from '@shared/stores/useAuthStore';
/**
* Hook for checking user permissions in React components
*
* @example
* ```tsx
* function MyComponent() {
* const { can, canAny, canAll, isLoading } = usePermissions();
*
* // Check single permission
* if (!can('invoices', 'create')) {
* return <AccessDenied />;
* }
*
* // Check any of multiple permissions
* const canManageInvoices = canAny('invoices:create', 'invoices:update', 'invoices:delete');
*
* // Check all permissions required
* const canProcessPayment = canAll('invoices:read', 'payments:create');
*
* return <InvoiceForm />;
* }
* ```
*/
export function usePermissions() {
const { isAuthenticated } = useAuthStore();
const {
permissions,
roles,
isLoading,
isLoaded,
error,
fetchPermissions,
hasPermission,
hasAnyPermission,
hasAllPermissions,
hasRole,
hasAnyRole,
clearPermissions,
invalidateCache,
} = usePermissionStore();
// Auto-fetch permissions when authenticated and not loaded
useEffect(() => {
if (isAuthenticated && !isLoaded && !isLoading) {
fetchPermissions().catch(console.error);
}
}, [isAuthenticated, isLoaded, isLoading, fetchPermissions]);
// Clear permissions on logout
useEffect(() => {
if (!isAuthenticated) {
clearPermissions();
}
}, [isAuthenticated, clearPermissions]);
/**
* Check if user has a specific permission
* @param resource Resource name (e.g., 'invoices')
* @param action Action name (e.g., 'create')
*/
const can = useCallback(
(resource: string, action: string) => hasPermission(resource, action),
[hasPermission]
);
/**
* Check if user has ANY of the specified permissions
* @param codes Permission codes in format "resource:action"
*/
const canAny = useCallback(
(...codes: string[]) => hasAnyPermission(...codes),
[hasAnyPermission]
);
/**
* Check if user has ALL of the specified permissions
* @param codes Permission codes in format "resource:action"
*/
const canAll = useCallback(
(...codes: string[]) => hasAllPermissions(...codes),
[hasAllPermissions]
);
/**
* Check if user is super_admin (can do anything)
*/
const isSuperAdmin = useCallback(() => roles.includes('super_admin'), [roles]);
/**
* Force refresh permissions from server
*/
const refresh = useCallback(async () => {
invalidateCache();
return fetchPermissions();
}, [invalidateCache, fetchPermissions]);
return {
// State
permissions,
roles,
isLoading,
isLoaded,
error,
// Permission checks
can,
canAny,
canAll,
hasPermission,
hasAnyPermission,
hasAllPermissions,
// Role checks
hasRole,
hasAnyRole,
isSuperAdmin,
// Actions
refresh,
clearPermissions,
};
}
/**
* Type guard component for permission-based rendering
*
* @example
* ```tsx
* <CanAccess permission="invoices:create">
* <CreateInvoiceButton />
* </CanAccess>
*
* <CanAccess permissions={['invoices:read', 'payments:read']} all>
* <FinancialDashboard />
* </CanAccess>
* ```
*/
export interface CanAccessProps {
/** Single permission code (e.g., "invoices:create") */
permission?: string;
/** Multiple permission codes */
permissions?: string[];
/** Require all permissions (default: any) */
all?: boolean;
/** Content to render if access denied */
fallback?: React.ReactNode;
/** Children to render if access granted */
children: React.ReactNode;
}
export function CanAccess({
permission,
permissions,
all = false,
fallback = null,
children,
}: CanAccessProps) {
const { canAny, canAll, hasAnyPermission } = usePermissions();
// Single permission check
if (permission) {
const [resource, action] = permission.includes(':')
? permission.split(':')
: permission.split('.');
const hasAccess = hasAnyPermission(`${resource}:${action}`);
return hasAccess ? <>{children}</> : <>{fallback}</>;
}
// Multiple permissions check
if (permissions && permissions.length > 0) {
const hasAccess = all ? canAll(...permissions) : canAny(...permissions);
return hasAccess ? <>{children}</> : <>{fallback}</>;
}
// No permission specified, render children
return <>{children}</>;
}
export default usePermissions;

View File

@ -2,3 +2,4 @@ export * from './useAuthStore';
export * from './useCompanyStore';
export * from './useUIStore';
export * from './useNotificationStore';
export * from './usePermissionStore';

View File

@ -0,0 +1,158 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { permissionsApi, type EffectivePermission } from '@services/api/permissions.api';
interface PermissionState {
// State
permissions: EffectivePermission[];
roles: string[];
isLoading: boolean;
isLoaded: boolean;
error: string | null;
lastFetchedAt: number | null;
// Actions
fetchPermissions: () => Promise<void>;
hasPermission: (resource: string, action: string) => boolean;
hasAnyPermission: (...codes: string[]) => boolean;
hasAllPermissions: (...codes: string[]) => boolean;
hasRole: (role: string) => boolean;
hasAnyRole: (...roles: string[]) => boolean;
clearPermissions: () => void;
invalidateCache: () => void;
}
// Cache TTL: 5 minutes (same as backend)
const CACHE_TTL_MS = 5 * 60 * 1000;
/**
* Parse permission code format "resource:action" or "resource.action"
*/
function parsePermissionCode(code: string): { resource: string; action: string } {
const separator = code.includes(':') ? ':' : '.';
const parts = code.split(separator);
return { resource: parts[0] || '', action: parts[1] || '' };
}
export const usePermissionStore = create<PermissionState>()(
persist(
(set, get) => ({
// Initial state
permissions: [],
roles: [],
isLoading: false,
isLoaded: false,
error: null,
lastFetchedAt: null,
// Actions
fetchPermissions: async () => {
const state = get();
// Check if cache is still valid
if (
state.isLoaded &&
state.lastFetchedAt &&
Date.now() - state.lastFetchedAt < CACHE_TTL_MS
) {
return;
}
set({ isLoading: true, error: null });
try {
const response = await permissionsApi.getMyPermissions();
set({
permissions: response.permissions,
roles: response.roles,
isLoading: false,
isLoaded: true,
lastFetchedAt: Date.now(),
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Error al obtener permisos';
set({ error: message, isLoading: false });
throw error;
}
},
hasPermission: (resource: string, action: string) => {
const { permissions, roles } = get();
// Super admin bypass
if (roles.includes('super_admin')) {
return true;
}
return permissions.some(p => p.resource === resource && p.action === action);
},
hasAnyPermission: (...codes: string[]) => {
const { hasPermission, roles } = get();
// Super admin bypass
if (roles.includes('super_admin')) {
return true;
}
return codes.some(code => {
const { resource, action } = parsePermissionCode(code);
return hasPermission(resource, action);
});
},
hasAllPermissions: (...codes: string[]) => {
const { hasPermission, roles } = get();
// Super admin bypass
if (roles.includes('super_admin')) {
return true;
}
return codes.every(code => {
const { resource, action } = parsePermissionCode(code);
return hasPermission(resource, action);
});
},
hasRole: (role: string) => {
const { roles } = get();
return roles.includes(role) || roles.includes('super_admin');
},
hasAnyRole: (...requiredRoles: string[]) => {
const { roles } = get();
if (roles.includes('super_admin')) {
return true;
}
return requiredRoles.some(role => roles.includes(role));
},
clearPermissions: () => {
set({
permissions: [],
roles: [],
isLoaded: false,
lastFetchedAt: null,
error: null,
});
},
invalidateCache: () => {
set({ lastFetchedAt: null });
},
}),
{
name: 'permissions-storage',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
permissions: state.permissions,
roles: state.roles,
isLoaded: state.isLoaded,
lastFetchedAt: state.lastFetchedAt,
}),
}
)
);
// Note: Use the usePermissions hook from '@shared/hooks' for components
// usePermissionStore is the underlying Zustand store