diff --git a/src/services/api/index.ts b/src/services/api/index.ts index 05d44a9..bb947f4 100644 --- a/src/services/api/index.ts +++ b/src/services/api/index.ts @@ -10,3 +10,4 @@ export * from './purchases.api'; export * from './financial.api'; export * from './crm.api'; export * from './projects.api'; +export * from './permissions.api'; diff --git a/src/services/api/permissions.api.ts b/src/services/api/permissions.api.ts new file mode 100644 index 0000000..134a387 --- /dev/null +++ b/src/services/api/permissions.api.ts @@ -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 => { + const response = await api.get>( + 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; + }, +}; diff --git a/src/shared/constants/api-endpoints.ts b/src/shared/constants/api-endpoints.ts index 0bb0113..8c56650 100644 --- a/src/shared/constants/api-endpoints.ts +++ b/src/shared/constants/api-endpoints.ts @@ -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; diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index 70597d6..22d3190 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -1,3 +1,4 @@ export * from './useDebounce'; export * from './useLocalStorage'; export * from './useMediaQuery'; +export * from './usePermissions'; diff --git a/src/shared/hooks/usePermissions.tsx b/src/shared/hooks/usePermissions.tsx new file mode 100644 index 0000000..5aea6ea --- /dev/null +++ b/src/shared/hooks/usePermissions.tsx @@ -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 ; + * } + * + * // 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 ; + * } + * ``` + */ +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 + * + * + * + * + * + * + * + * ``` + */ +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; diff --git a/src/shared/stores/index.ts b/src/shared/stores/index.ts index ce2edf4..4e5a809 100644 --- a/src/shared/stores/index.ts +++ b/src/shared/stores/index.ts @@ -2,3 +2,4 @@ export * from './useAuthStore'; export * from './useCompanyStore'; export * from './useUIStore'; export * from './useNotificationStore'; +export * from './usePermissionStore'; diff --git a/src/shared/stores/usePermissionStore.ts b/src/shared/stores/usePermissionStore.ts new file mode 100644 index 0000000..61e6b16 --- /dev/null +++ b/src/shared/stores/usePermissionStore.ts @@ -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; + 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()( + 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