Roles ampliados (10) y matriz de permisos por rol

- Roles: admin, médico, recepción, enfermería, cosmetología, psicóloga,
  farmacia, entrenador, marketing + lectura (compatibilidad)
- Matriz de permisos por rol persistida en Odoo (ir.config_parameter)
  y editable en Configuración → Roles (secciones × roles)
- Prioridad: permisos personales del usuario > matriz por rol > defaults
- Protección: admin siempre conserva /configuracion
This commit is contained in:
2026-08-14 08:47:27 +00:00
parent be42974030
commit 281fb34588
9 changed files with 346 additions and 29 deletions

View File

@@ -0,0 +1,132 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Save, Info, Lock } from 'lucide-react';
import { Button, Skeleton, toast } from './ui';
import { odooApi, type FrontendRole } from '../services/odoo';
import { ROLE_LABELS } from '../lib/auth';
import { MENU_ITEMS } from '../lib/menu-items';
const ROLES = Object.keys(ROLE_LABELS) as FrontendRole[];
type Matrix = Record<string, FrontendRole[]>;
const RolesPanel: FC = () => {
const [matrix, setMatrix] = useState<Matrix | null>(null);
const [saved, setSaved] = useState<Matrix | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getRolePermissions();
if (res.status === 'success') {
setMatrix(res.matrix);
setSaved(res.matrix);
} else {
toast.error(res.message || 'No se pudieron cargar los permisos');
}
} catch (err) {
toast.error('Error al cargar permisos por rol');
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const dirty = matrix !== null && saved !== null && JSON.stringify(matrix) !== JSON.stringify(saved);
const toggle = (href: string, role: FrontendRole) => {
if (!matrix) return;
if (href === '/configuracion' && role === 'admin') return; // siempre requerido
const current = matrix[href] || [];
const next = current.includes(role) ? current.filter((r) => r !== role) : [...current, role];
setMatrix({ ...matrix, [href]: next });
};
const save = async () => {
if (!matrix) return;
try {
setSaving(true);
const res = await odooApi.updateRolePermissions(matrix);
if (res.status === 'success') {
setMatrix(res.matrix);
setSaved(res.matrix);
toast.success('Permisos por rol guardados');
} else {
toast.error(res.message || 'No se pudieron guardar los permisos');
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al guardar permisos');
console.error(err);
} finally {
setSaving(false);
}
};
if (loading || !matrix) {
return <Skeleton count={6} className="h-10 w-full" />;
}
return (
<div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4">
<p className="text-sm text-theme-muted flex items-start gap-2 max-w-2xl">
<Info size={16} className="mt-0.5 shrink-0" />
Aplica a todos los usuarios del rol al recargar su sesión. Los permisos personales de un usuario van encima.
</p>
<div className="flex items-center gap-3 shrink-0">
{dirty && <span className="text-xs font-medium text-amber-600">Cambios sin guardar</span>}
<Button onClick={save} loading={saving} disabled={!dirty}>
<Save size={16} className="mr-2" />
Guardar
</Button>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-3 py-2 sticky left-0 bg-theme-bg">Sección</th>
{ROLES.map((r) => (
<th key={r} className="px-2 py-2 text-center font-medium">{ROLE_LABELS[r]}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{MENU_ITEMS.map((item) => (
<tr key={item.href} className="hover:bg-theme-bg">
<td className="px-3 py-2 font-medium text-theme-heading whitespace-nowrap sticky left-0 bg-theme-surface">
{item.label}
</td>
{ROLES.map((role) => {
const locked = item.href === '/configuracion' && role === 'admin';
const checked = (matrix[item.href] || []).includes(role);
return (
<td key={role} className="px-2 py-2 text-center">
<input
type="checkbox"
checked={checked}
disabled={locked}
onChange={() => toggle(item.href, role)}
title={locked ? 'El admin siempre requiere Configuración' : `${item.label}${ROLE_LABELS[role]}`}
className="rounded border-theme-border-strong disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
/>
{locked && <Lock size={10} className="inline ml-1 text-theme-muted" />}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
export default RolesPanel;

View File

@@ -17,7 +17,7 @@ import { ROLE_LABELS, useAuth } from '../lib/auth';
import { MENU_ITEMS } from '../lib/menu-items';
const roleOptions: { value: FrontendRole; label: string }[] = (
['admin', 'recepcion', 'medico', 'lectura'] as FrontendRole[]
Object.keys(ROLE_LABELS) as FrontendRole[]
).map((r) => ({ value: r, label: ROLE_LABELS[r] }));
const roleBadge = (role: FrontendRole) => {

View File

@@ -4,12 +4,19 @@ import { odooApi, type FrontendUser, type FrontendRole } from '../services/odoo'
const TOKEN_KEY = 'skeen_token';
const USER_KEY = 'skeen_user';
const MENU_ROLES_KEY = 'skeen_menu_roles';
const ROLE_RANK: Record<FrontendRole, number> = {
lectura: 10,
admin: 100,
medico: 50,
recepcion: 50,
admin: 100,
enfermeria: 50,
cosmetologia: 50,
psicologa: 50,
farmacia: 50,
entrenador: 10,
marketing: 10,
lectura: 10,
};
// Visibilidad del menú por ruta. Si una ruta no está listada, se muestra a todos los roles.
@@ -60,12 +67,35 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
const [user, setUser] = useState<FrontendUser | null>(null);
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
const [loading, setLoading] = useState(true);
// Matriz dinámica de permisos por rol (cache en localStorage; fallback a MENU_ROLES)
const [menuRoles, setMenuRoles] = useState<Record<string, FrontendRole[]> | null>(() => {
try {
const cached = localStorage.getItem(MENU_ROLES_KEY);
return cached ? JSON.parse(cached) : null;
} catch {
return null;
}
});
const refreshMenuRoles = useCallback(async () => {
try {
const res = await odooApi.getRolePermissions();
if (res.status === 'success' && res.matrix) {
setMenuRoles(res.matrix);
localStorage.setItem(MENU_ROLES_KEY, JSON.stringify(res.matrix));
}
} catch {
// fallback: MENU_ROLES hardcodeado
}
}, []);
const clear = useCallback(() => {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
localStorage.removeItem(MENU_ROLES_KEY);
setToken(null);
setUser(null);
setMenuRoles(null);
}, []);
// Validar token al arrancar
@@ -87,6 +117,7 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
if (res.status === 'success') {
setUser(res.user);
localStorage.setItem(USER_KEY, JSON.stringify(res.user));
refreshMenuRoles();
} else {
clear();
}
@@ -94,7 +125,7 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
.catch(() => { if (alive) clear(); })
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [clear]);
}, [clear, refreshMenuRoles]);
const login = async (login: string, password: string): Promise<FrontendUser> => {
const res = await odooApi.login(login, password);
@@ -105,6 +136,7 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
localStorage.setItem(USER_KEY, JSON.stringify(res.user));
setToken(res.token);
setUser(res.user);
refreshMenuRoles();
return res.user;
};
@@ -120,11 +152,11 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
const canSee = (href: string): boolean => {
if (!user) return false;
// Permisos personalizados: lista no vacía manda sobre los defaults por rol
// Permisos personalizados: lista no vacía manda sobre la matriz por rol
if (user.allowed_menus && user.allowed_menus.length > 0) {
return user.allowed_menus.includes(href);
}
const allowed = MENU_ROLES[href];
const allowed = (menuRoles || MENU_ROLES)[href];
if (!allowed) return true;
return allowed.includes(user.role);
};
@@ -144,7 +176,13 @@ export const useAuth = (): AuthContextValue => {
export const ROLE_LABELS: Record<FrontendRole, string> = {
admin: 'Administrador',
recepcion: 'Recepción',
medico: 'Médico',
lectura: 'Solo lectura',
recepcion: 'Recepción',
enfermeria: 'Enfermería',
cosmetologia: 'Cosmetología',
psicologa: 'Psicóloga',
farmacia: 'Farmacia',
entrenador: 'Entrenador',
marketing: 'Marketing',
lectura: 'Lectura',
};

View File

@@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router-dom';
import { Phone, MapPin, Mail, Globe, Save, Building2, ArrowRightLeft } from 'lucide-react';
import Layout from '../components/Layout';
import UsuariosPanel from '../components/UsuariosPanel';
import RolesPanel from '../components/RolesPanel';
import RecetasPanel from '../components/RecetasPanel';
import CatalogoPanel from '../components/CatalogoPanel';
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
@@ -12,6 +13,7 @@ import { odooApi } from '../services/odoo';
const TABS = [
{ key: 'clinica', label: 'Clínica' },
{ key: 'usuarios', label: 'Usuarios' },
{ key: 'roles', label: 'Roles' },
{ key: 'recetas', label: 'Recetas' },
{ key: 'catalogos', label: 'Catálogos' },
] as const;
@@ -134,6 +136,12 @@ const Configuracion: FC = () => {
<UsuariosPanel />
</Card.Body>
</Card>
) : tab === 'roles' ? (
<Card>
<Card.Body>
<RolesPanel />
</Card.Body>
</Card>
) : tab === 'recetas' ? (
<Card>
<Card.Body>

View File

@@ -476,7 +476,7 @@ export interface WacrmLead {
created_at: string | null;
}
export type FrontendRole = 'admin' | 'recepcion' | 'medico' | 'lectura';
export type FrontendRole = 'admin' | 'medico' | 'recepcion' | 'enfermeria' | 'cosmetologia' | 'psicologa' | 'farmacia' | 'entrenador' | 'marketing' | 'lectura';
export interface FrontendUser {
id: number;
@@ -783,6 +783,16 @@ export const odooApi = {
return data;
},
async getRolePermissions(): Promise<{ status: string; matrix: Record<string, FrontendRole[]>; roles: { key: FrontendRole; label: string }[]; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/roles/permissions`);
return data;
},
async updateRolePermissions(matrix: Record<string, FrontendRole[]>): Promise<{ status: string; matrix: Record<string, FrontendRole[]>; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/roles/permissions`, { matrix });
return data;
},
// Health
async healthCheck(): Promise<unknown> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/health`);