[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:
parent
1363fbd5f6
commit
a3b61b8ae4
@ -10,3 +10,4 @@ export * from './purchases.api';
|
||||
export * from './financial.api';
|
||||
export * from './crm.api';
|
||||
export * from './projects.api';
|
||||
export * from './permissions.api';
|
||||
|
||||
35
src/services/api/permissions.api.ts
Normal file
35
src/services/api/permissions.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from './useDebounce';
|
||||
export * from './useLocalStorage';
|
||||
export * from './useMediaQuery';
|
||||
export * from './usePermissions';
|
||||
|
||||
183
src/shared/hooks/usePermissions.tsx
Normal file
183
src/shared/hooks/usePermissions.tsx
Normal 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;
|
||||
@ -2,3 +2,4 @@ export * from './useAuthStore';
|
||||
export * from './useCompanyStore';
|
||||
export * from './useUIStore';
|
||||
export * from './useNotificationStore';
|
||||
export * from './usePermissionStore';
|
||||
|
||||
158
src/shared/stores/usePermissionStore.ts
Normal file
158
src/shared/stores/usePermissionStore.ts
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user