fase 2.1: roles editables (modelo skeen.role) y permisos por accion (43 claves, 9 grupos) con enforcement en backend y gating en frontend; editor de roles/permisos/menus en Configuracion

This commit is contained in:
2026-10-11 07:32:29 +00:00
parent 75736967ef
commit 28a690aa0f
24 changed files with 1637 additions and 157 deletions

View File

@@ -2,13 +2,14 @@ import { createContext, useContext, useEffect, useState, useCallback } from 'rea
import type { FC, ReactNode } from 'react';
import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm, type ClinicBrand, BRAND_DEFAULT } from '../services/odoo';
import { applyBrandColors } from './brand-colors';
import { defaultPermFor } from './permissions';
const TOKEN_KEY = 'skeen_token';
const USER_KEY = 'skeen_user';
const MENU_ROLES_KEY = 'skeen_menu_roles';
const BRAND_KEY_LS = 'skeen_clinic_brand';
const ROLE_RANK: Record<FrontendRole, number> = {
const ROLE_RANK: Record<string, number> = {
admin: 100,
medico: 50,
recepcion: 50,
@@ -78,6 +79,9 @@ interface AuthContextValue {
hasRole: (min: FrontendRole) => boolean;
canSee: (href: string) => boolean;
canAgenda: (perm: AgendaPerm) => boolean;
// Permiso por acción (catálogo skeen_whatsapp/permissions.py). La UI oculta
// o deshabilita según esto; el backend sigue siendo la autoridad.
can: (permKey: string) => boolean;
// Brand de la clínica (white-label). BRAND_DEFAULT hasta que /brand responde.
brand: ClinicBrand;
setBrand: (b: ClinicBrand) => void;
@@ -271,8 +275,18 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
return perms.includes(perm);
};
// Permisos por acción. El backend manda user.perms en /auth/me y /auth/login;
// si no viniera (sesión cacheada previa al despliegue) se usa el espejo de
// defaults de lib/permissions.ts. El admin siempre puede.
const can = (permKey: string): boolean => {
if (!user) return false;
if (user.role === 'admin') return true;
if (user.perms && typeof user.perms[permKey] === 'boolean') return user.perms[permKey];
return defaultPermFor(user.role, permKey);
};
return (
<AuthContext.Provider value={{ user, token, loading, bootError, retryBoot, login, logout, hasRole, canSee, canAgenda, brand, setBrand }}>
<AuthContext.Provider value={{ user, token, loading, bootError, retryBoot, login, logout, hasRole, canSee, canAgenda, can, brand, setBrand }}>
{children}
</AuthContext.Provider>
);
@@ -284,7 +298,7 @@ export const useAuth = (): AuthContextValue => {
return ctx;
};
export const ROLE_LABELS: Record<FrontendRole, string> = {
export const ROLE_LABELS: Record<string, string> = {
admin: 'Administrador',
medico: 'Médico',
recepcion: 'Recepción',
@@ -296,3 +310,7 @@ export const ROLE_LABELS: Record<FrontendRole, string> = {
marketing: 'Marketing',
lectura: 'Lectura',
};
/** Nombre visible de un rol: el configurado (si se conoce) o el código. */
export const roleLabel = (role: string, name?: string): string =>
name || ROLE_LABELS[role] || role;

View File

@@ -0,0 +1,75 @@
// Espejo de skeen_whatsapp/permissions.py. Sirve de respaldo mientras /auth/me
// no ha respondido o hay una sesión cacheada de antes del despliegue, y para
// que el editor de roles muestre algo si /permisos falla. El backend es la
// autoridad: si algo no cuadra, la UI esconde de más, nunca de menos.
export const PERM_KEYS: string[] = [
'agenda.ver', 'agenda.agendar', 'agenda.editar', 'agenda.eliminar',
'agenda.mover', 'agenda.visitas', 'agenda.todas', 'agenda.bloquear',
'pacientes.ver', 'pacientes.expediente', 'pacientes.crear', 'pacientes.editar',
'pacientes.eliminar', 'pacientes.exportar', 'pacientes.saldos',
'visitas.ver', 'visitas.crear', 'visitas.editar', 'visitas.eliminar', 'visitas.archivos',
'ventas.vender', 'ventas.cobrar', 'ventas.descuentos', 'ventas.cancelar',
'ventas.devolver', 'ventas.reimprimir',
'inventario.ver', 'inventario.ajustar', 'inventario.mover', 'inventario.costos',
'servicios.ver', 'servicios.editar',
'reportes.ver', 'reportes.exportar', 'caja.ver', 'caja.cerrar',
'whatsapp.ver', 'whatsapp.responder', 'whatsapp.asignar',
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
];
const W = ['admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia',
'psicologa', 'farmacia', 'gerencia', 'marketing'];
const WN = W.filter((r) => r !== 'marketing');
const MONEY = W.filter((r) => r !== 'medico' && r !== 'marketing');
const AG = ['admin', 'gerencia'];
const AD = ['admin'];
const ALL = [...W, 'lectura'];
const READ_PERM_KEYS = new Set([
'agenda.ver', 'agenda.todas',
'pacientes.ver', 'pacientes.expediente', 'pacientes.exportar', 'pacientes.saldos',
'visitas.ver',
'inventario.ver',
'servicios.ver',
'reportes.ver', 'reportes.exportar', 'caja.ver',
'ventas.reimprimir',
'whatsapp.ver',
]);
const DEFAULT_PERM_ROLES: Record<string, string[]> = {
'agenda.ver': ALL, 'agenda.agendar': W, 'agenda.editar': W, 'agenda.eliminar': W,
'agenda.mover': W, 'agenda.visitas': W, 'agenda.todas': W, 'agenda.bloquear': W,
'pacientes.ver': ALL, 'pacientes.expediente': ALL, 'pacientes.crear': W,
'pacientes.editar': W, 'pacientes.eliminar': W, 'pacientes.exportar': ALL,
'pacientes.saldos': ALL,
'visitas.ver': ALL, 'visitas.crear': W, 'visitas.editar': W,
'visitas.eliminar': W, 'visitas.archivos': W,
'ventas.vender': MONEY, 'ventas.cobrar': WN, 'ventas.descuentos': MONEY,
'ventas.cancelar': AG, 'ventas.devolver': MONEY, 'ventas.reimprimir': ALL,
'inventario.ver': ALL, 'inventario.ajustar': WN, 'inventario.mover': WN,
'inventario.costos': WN,
'servicios.ver': ALL, 'servicios.editar': AD,
'reportes.ver': ALL, 'reportes.exportar': ALL, 'caja.ver': ALL, 'caja.cerrar': MONEY,
'whatsapp.ver': W, 'whatsapp.responder': W, 'whatsapp.asignar': W,
'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD,
};
export const BASE_ROLES = ['admin', 'medico', 'recepcion', 'enfermeria',
'cosmetologia', 'psicologa', 'farmacia', 'gerencia', 'marketing', 'lectura'];
export const DEFAULT_PERMS_BY_ROLE: Record<string, Record<string, boolean>> =
Object.fromEntries(BASE_ROLES.map((role) => [
role,
Object.fromEntries(PERM_KEYS.map((key) => [
key,
role === 'lectura' ? READ_PERM_KEYS.has(key) : (DEFAULT_PERM_ROLES[key] || []).includes(role),
])),
]));
/** Default de un permiso para un rol (incluye roles personalizados = lectura). */
export const defaultPermFor = (role: string, key: string): boolean => {
const base = BASE_ROLES.includes(role) ? role : 'lectura';
return DEFAULT_PERMS_BY_ROLE[base]?.[key]
?? (READ_PERM_KEYS.has(key) && base === 'lectura');
};