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`);

View File

@@ -15,15 +15,12 @@ import time
from odoo.http import request, Response
from ..roles import ROLE_RANKS
TTL_SECONDS = 60 * 60 * 12 # 12 horas
SECRET_KEY = 'skeen.frontend.secret'
RANK = {
'lectura': 10,
'medico': 50,
'recepcion': 50,
'admin': 100,
}
RANK = ROLE_RANKS
def _cors_headers():

View File

@@ -6,6 +6,7 @@ import base64
import json
from datetime import date, datetime, timedelta
from .auth import sign_token, current_user, require_role, verify_token
from ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles
def json_response(data, status=200):
@@ -100,7 +101,7 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'login y name son obligatorios'}, 400)
if len(password) < 8:
return json_response({'status': 'error', 'message': 'La contraseña debe tener al menos 8 caracteres'}, 400)
if role not in ('admin', 'recepcion', 'medico', 'lectura'):
if role not in ROLE_KEYS:
return json_response({'status': 'error', 'message': 'Rol inválido'}, 400)
User = request.env['skeen.frontend.user'].sudo()
if User.search([('login', '=', login)], limit=1):
@@ -123,7 +124,7 @@ class SkeenFrontendController(http.Controller):
if 'name' in data:
vals['name'] = data['name']
if 'role' in data:
if data['role'] not in ('admin', 'recepcion', 'medico', 'lectura'):
if data['role'] not in ROLE_KEYS:
return json_response({'status': 'error', 'message': 'Rol inválido'}, 400)
vals['role'] = data['role']
if 'active' in data:
@@ -159,6 +160,43 @@ class SkeenFrontendController(http.Controller):
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Permisos de menú por rol (matriz dinámica)
# ============================================================
@http.route('/skeen/frontend/v1/roles/permissions', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def roles_permissions_get(self, **kw):
try:
return json_response({
'status': 'success',
'matrix': get_menu_roles(request.env),
'roles': [{'key': k, 'label': l} for k, l in ROLE_SELECTION],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/roles/permissions', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('admin')
def roles_permissions_put(self, **kw):
try:
data = _parse_json_body()
matrix = data.get('matrix')
if not isinstance(matrix, dict):
return json_response({'status': 'error', 'message': 'matrix debe ser un objeto {href: [roles]}'}, 400)
clean = {}
for href, roles in matrix.items():
if not isinstance(href, str):
return json_response({'status': 'error', 'message': 'Las llaves de matrix deben ser strings (href)'}, 400)
if not isinstance(roles, list) or any(not isinstance(r, str) or r not in ROLE_KEYS for r in roles):
return json_response({'status': 'error', 'message': f'Roles inválidos para {href}'}, 400)
clean[href] = list(dict.fromkeys(roles))
if 'admin' not in clean.get('/configuracion', []):
return json_response({'status': 'error', 'message': "No se puede quitar '/configuracion' al rol admin"}, 400)
request.env['ir.config_parameter'].sudo().set_param(MENU_ROLES_KEY, json.dumps(clean))
return json_response({'status': 'success', 'matrix': clean})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Dashboard
# ============================================================

View File

@@ -5,6 +5,8 @@ import json
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
from ..roles import ROLE_SELECTION, ROLE_RANKS
try:
from werkzeug.security import check_password_hash, generate_password_hash
except Exception: # pragma: no cover
@@ -21,12 +23,7 @@ class SkeenFrontendUser(models.Model):
login = fields.Char(string='Usuario', required=True, index=True)
name = fields.Char(string='Nombre', required=True)
password_hash = fields.Char(string='Hash de contraseña', required=True)
role = fields.Selection([
('admin', 'Administrador'),
('recepcion', 'Recepción'),
('medico', 'Médico'),
('lectura', 'Solo lectura'),
], string='Rol', default='recepcion', required=True)
role = fields.Selection(ROLE_SELECTION, string='Rol', default='recepcion', required=True)
active = fields.Boolean(string='Activo', default=True)
must_change_password = fields.Boolean(string='Debe cambiar contraseña', default=False)
last_login = fields.Datetime(string='Último acceso', readonly=True)
@@ -58,12 +55,7 @@ class SkeenFrontendUser(models.Model):
def role_rank(self):
"""Jerarquía simple para permisos: mayor número = más acceso."""
self.ensure_one()
return {
'lectura': 10,
'medico': 50,
'recepcion': 50,
'admin': 100,
}.get(self.role, 0)
return ROLE_RANKS.get(self.role, 0)
def _allowed_menus_list(self):
"""Devuelve la lista de hrefs permitidos o None (usar defaults por rol)"""

View File

@@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
"""Roles del frontend React y matriz de permisos de menú por rol.
Módulo sin dependencias de Odoo para poder importarse tanto desde modelos
como desde controladores sin problemas de orden de carga.
La matriz se persiste en ir.config_parameter 'skeen.menu_roles' como JSON
{href: [roles...]}. Si no existe, se usan los defaults de DEFAULT_MENU_ROLES
(mismos que MENU_ROLES hardcodeado del frontend) y los 6 roles nuevos heredan
el mismo set de secciones que 'lectura'.
"""
import json
ROLE_SELECTION = [
('admin', 'Administrador'),
('medico', 'Médico'),
('recepcion', 'Recepción'),
('enfermeria', 'Enfermería'),
('cosmetologia', 'Cosmetología'),
('psicologa', 'Psicóloga'),
('farmacia', 'Farmacia'),
('entrenador', 'Entrenador'),
('marketing', 'Marketing'),
('lectura', 'Lectura'),
]
ROLE_KEYS = tuple(k for k, _ in ROLE_SELECTION)
ROLE_RANKS = {
'admin': 100,
'medico': 50,
'recepcion': 50,
'enfermeria': 50,
'cosmetologia': 50,
'psicologa': 50,
'farmacia': 50,
'entrenador': 10,
'marketing': 10,
'lectura': 10,
}
MENU_ROLES_KEY = 'skeen.menu_roles'
# Defaults = MENU_ROLES de src/lib/auth.tsx (4 roles originales).
DEFAULT_MENU_ROLES = {
'/': ['admin', 'recepcion', 'medico', 'lectura'],
'/agenda': ['admin', 'recepcion', 'medico'],
'/pacientes': ['admin', 'recepcion', 'medico', 'lectura'],
'/pos': ['admin', 'recepcion'],
'/visitas': ['admin', 'recepcion', 'medico'],
'/consultas': ['admin', 'recepcion', 'medico'],
'/expedientes': ['admin', 'recepcion', 'medico'],
'/medicos': ['admin', 'recepcion'],
'/servicios': ['admin', 'recepcion', 'medico', 'lectura'],
'/productos': ['admin', 'recepcion', 'lectura'],
'/ventas': ['admin', 'recepcion'],
'/pagos': ['admin', 'recepcion'],
'/monedero': ['admin', 'recepcion', 'lectura'],
'/inventario': ['admin', 'recepcion', 'lectura'],
'/cortes': ['admin', 'recepcion'],
'/reportes': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=adeudos': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=comisiones': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=horas-agenda': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=paquetes': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=vendedores': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=concentrado': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=recomendaciones': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=exportar': ['admin', 'recepcion', 'lectura'],
'/configuracion': ['admin'],
'/cumpleanos': ['admin', 'recepcion'],
'/wacrm/messages': ['admin', 'recepcion'],
'/wacrm/leads': ['admin', 'recepcion'],
}
# Roles nuevos que por default ven lo mismo que 'lectura'.
READ_ONLY_NEW_ROLES = ('enfermeria', 'cosmetologia', 'psicologa', 'farmacia', 'entrenador', 'marketing')
def default_menu_roles():
"""Matriz por default: MENU_ROLES + roles nuevos donde esté 'lectura'."""
matrix = {}
for href, roles in DEFAULT_MENU_ROLES.items():
r = list(roles)
if 'lectura' in roles:
r.extend(nr for nr in READ_ONLY_NEW_ROLES if nr not in r)
matrix[href] = r
return matrix
def get_menu_roles(env):
"""Matriz vigente: la guardada en ir.config_parameter o los defaults."""
raw = env['ir.config_parameter'].sudo().get_param(MENU_ROLES_KEY)
if raw:
try:
val = json.loads(raw)
if isinstance(val, dict):
return val
except Exception:
pass
return default_menu_roles()