[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 './financial.api';
|
||||||
export * from './crm.api';
|
export * from './crm.api';
|
||||||
export * from './projects.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`,
|
ACTIVITIES: `${API_VERSION}/system/activities`,
|
||||||
FOLLOWERS: `${API_VERSION}/system/followers`,
|
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;
|
} as const;
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
export * from './useDebounce';
|
export * from './useDebounce';
|
||||||
export * from './useLocalStorage';
|
export * from './useLocalStorage';
|
||||||
export * from './useMediaQuery';
|
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 './useCompanyStore';
|
||||||
export * from './useUIStore';
|
export * from './useUIStore';
|
||||||
export * from './useNotificationStore';
|
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