From 281fb34588204a4baf86a7cee7e88af9dea5ae91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20Alcaraz=20Salazar?= Date: Fri, 14 Aug 2026 08:47:27 +0000 Subject: [PATCH] Roles ampliados (10) y matriz de permisos por rol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/components/RolesPanel.tsx | 132 ++++++++++++++++++ frontend/src/components/UsuariosPanel.tsx | 2 +- frontend/src/lib/auth.tsx | 52 ++++++- frontend/src/pages/Configuracion.tsx | 8 ++ frontend/src/services/odoo.ts | 12 +- .../skeen_whatsapp/controllers/auth.py | 9 +- .../skeen_whatsapp/controllers/frontend.py | 42 +++++- .../skeen_whatsapp/models/frontend_user.py | 16 +-- odoo-addons/skeen_whatsapp/roles.py | 102 ++++++++++++++ 9 files changed, 346 insertions(+), 29 deletions(-) create mode 100644 frontend/src/components/RolesPanel.tsx create mode 100644 odoo-addons/skeen_whatsapp/roles.py diff --git a/frontend/src/components/RolesPanel.tsx b/frontend/src/components/RolesPanel.tsx new file mode 100644 index 0000000..346f91e --- /dev/null +++ b/frontend/src/components/RolesPanel.tsx @@ -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; + +const RolesPanel: FC = () => { + const [matrix, setMatrix] = useState(null); + const [saved, setSaved] = useState(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 ; + } + + return ( +
+
+

+ + Aplica a todos los usuarios del rol al recargar su sesión. Los permisos personales de un usuario van encima. +

+
+ {dirty && Cambios sin guardar} + +
+
+ +
+ + + + + {ROLES.map((r) => ( + + ))} + + + + {MENU_ITEMS.map((item) => ( + + + {ROLES.map((role) => { + const locked = item.href === '/configuracion' && role === 'admin'; + const checked = (matrix[item.href] || []).includes(role); + return ( + + ); + })} + + ))} + +
Sección{ROLE_LABELS[r]}
+ {item.label} + + 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 && } +
+
+
+ ); +}; + +export default RolesPanel; diff --git a/frontend/src/components/UsuariosPanel.tsx b/frontend/src/components/UsuariosPanel.tsx index dafc6b1..63ea3b9 100644 --- a/frontend/src/components/UsuariosPanel.tsx +++ b/frontend/src/components/UsuariosPanel.tsx @@ -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) => { diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx index e61960a..1df2e3f 100644 --- a/frontend/src/lib/auth.tsx +++ b/frontend/src/lib/auth.tsx @@ -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 = { - 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(null); const [token, setToken] = useState(() => 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 | 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 => { 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 = { 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', }; diff --git a/frontend/src/pages/Configuracion.tsx b/frontend/src/pages/Configuracion.tsx index d0865ec..58e2709 100644 --- a/frontend/src/pages/Configuracion.tsx +++ b/frontend/src/pages/Configuracion.tsx @@ -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 = () => { + ) : tab === 'roles' ? ( + + + + + ) : tab === 'recetas' ? ( diff --git a/frontend/src/services/odoo.ts b/frontend/src/services/odoo.ts index 33cb6f5..79b11d8 100644 --- a/frontend/src/services/odoo.ts +++ b/frontend/src/services/odoo.ts @@ -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; 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): Promise<{ status: string; matrix: Record; message?: string }> { + const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/roles/permissions`, { matrix }); + return data; + }, + // Health async healthCheck(): Promise { const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/health`); diff --git a/odoo-addons/skeen_whatsapp/controllers/auth.py b/odoo-addons/skeen_whatsapp/controllers/auth.py index db63d34..8dd3ab0 100644 --- a/odoo-addons/skeen_whatsapp/controllers/auth.py +++ b/odoo-addons/skeen_whatsapp/controllers/auth.py @@ -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(): diff --git a/odoo-addons/skeen_whatsapp/controllers/frontend.py b/odoo-addons/skeen_whatsapp/controllers/frontend.py index dc18f28..2a4e214 100644 --- a/odoo-addons/skeen_whatsapp/controllers/frontend.py +++ b/odoo-addons/skeen_whatsapp/controllers/frontend.py @@ -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 # ============================================================ diff --git a/odoo-addons/skeen_whatsapp/models/frontend_user.py b/odoo-addons/skeen_whatsapp/models/frontend_user.py index e46a396..19e3bad 100644 --- a/odoo-addons/skeen_whatsapp/models/frontend_user.py +++ b/odoo-addons/skeen_whatsapp/models/frontend_user.py @@ -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)""" diff --git a/odoo-addons/skeen_whatsapp/roles.py b/odoo-addons/skeen_whatsapp/roles.py new file mode 100644 index 0000000..f62ea9b --- /dev/null +++ b/odoo-addons/skeen_whatsapp/roles.py @@ -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()