Files
SKEEN-Proyecto/frontend/src/lib/auth.tsx

334 lines
13 KiB
TypeScript

import { createContext, useContext, useEffect, useState, useCallback } from 'react';
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<string, number> = {
admin: 100,
medico: 50,
recepcion: 50,
enfermeria: 50,
cosmetologia: 50,
psicologa: 50,
farmacia: 50,
gerencia: 50,
// marketing pasó a 50: puede agendar citas y escribir en el WACRM
marketing: 50,
lectura: 10,
};
// Visibilidad del menú por ruta. Si una ruta no está listada, se muestra a todos los roles.
// 'gerencia' (gerencia de la clínica) ve toda la operación excepto
// /configuracion (solo admin del sistema), /mis-ventas, /cumpleanos y /wacrm/*.
const MENU_ROLES: Record<string, FrontendRole[]> = {
'/' : ['admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia', 'psicologa', 'farmacia', 'gerencia', 'marketing', 'lectura'],
'/agenda': ['admin', 'recepcion', 'medico', 'gerencia', 'cosmetologia', 'marketing'],
'/mis-ventas': ['medico'],
'/pacientes': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia', 'marketing'],
'/pos': ['admin', 'recepcion', 'gerencia'],
'/visitas': ['admin', 'recepcion', 'medico', 'gerencia', 'marketing'],
'/consultas': ['admin', 'recepcion', 'medico', 'gerencia'],
'/medicos': ['admin', 'recepcion', 'gerencia'],
'/servicios': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
'/paquetes': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia', 'cosmetologia'],
// Membresías (apagado por defecto: la sección solo se pinta con
// membresias.activo=true). Se lista aquí para que canSee la reconozca.
'/membresias': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
// Liquidaciones de comisiones (solo se pinta con comisiones.activo=true).
'/comisiones/liquidaciones': ['admin', 'recepcion', 'gerencia', 'medico'],
'/productos': ['admin', 'recepcion', 'lectura', 'gerencia'], // deprecated: redirige a /inventario
'/ventas': ['admin', 'recepcion', 'gerencia'],
'/tickets': ['admin', 'gerencia'],
'/pagos': ['admin', 'recepcion', 'gerencia'],
'/monedero': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/inventario': ['admin', 'recepcion', 'lectura', 'gerencia'],
// Compras (apagado por defecto: la sección solo se pinta con
// compras.activo=true). Se lista aquí para que canSee la reconozca.
'/compras': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/cortes': ['admin', 'recepcion', 'gerencia'],
'/reportes': ['admin', 'recepcion', 'lectura', 'gerencia'],
// Tablero de dirección: solo dueño/administración y marketing (el médico no).
'/direccion': ['admin', 'gerencia', 'marketing'],
'/reportes?tab=adeudos': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=comisiones': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=horas-agenda': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=paquetes': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=membresias': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=vendedores': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=concentrado': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=recomendaciones': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=compras': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=margen': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=exportar': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/configuracion': ['admin', 'gerencia'],
'/cumpleanos': ['admin', 'recepcion', 'gerencia', 'marketing'],
// Campañas y satisfacción (apagadas por defecto: solo se pintan con
// campanas.activo / encuestas.activo). Se listan aquí para canSee.
'/campanas': ['admin', 'recepcion', 'gerencia', 'marketing'],
'/satisfaccion': ['admin', 'recepcion', 'gerencia', 'medico'],
'/wacrm/messages': ['admin', 'recepcion', 'gerencia', 'marketing'],
'/wacrm/leads': ['admin', 'recepcion', 'gerencia', 'marketing'],
// El chat se lista explícitamente para dejar fuera a marketing (antes, al no
// estar listado, lo veía cualquier rol).
'/chat': ['admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia', 'psicologa', 'farmacia', 'gerencia', 'lectura'],
};
// Si /auth/me no responde en este tiempo (red lenta o caída), se muestra la
// pantalla de reintento en lugar del spinner eterno ("pantalla en blanco").
const BOOT_TIMEOUT_MS = 20000;
interface AuthContextValue {
user: FrontendUser | null;
token: string | null;
loading: boolean;
// Error de arranque (servidor no responde): la app muestra Reintentar
// en lugar de quedarse cargando para siempre.
bootError: string | null;
retryBoot: () => void;
login: (login: string, password: string) => Promise<FrontendUser>;
logout: () => Promise<void>;
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;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
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);
const [bootError, setBootError] = useState<string | null>(null);
// Cada reintento de arranque re-ejecuta la validación del token.
const [bootAttempt, setBootAttempt] = useState(0);
// 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;
}
});
// Brand de la clínica: se hidrata del caché para no parpadear y se refresca
// con /brand en el arranque (mismo patrón que menuRoles).
const [brand, setBrandState] = useState<ClinicBrand>(() => {
try {
const cached = localStorage.getItem(BRAND_KEY_LS);
return cached ? { ...BRAND_DEFAULT, ...JSON.parse(cached) } : BRAND_DEFAULT;
} catch {
return BRAND_DEFAULT;
}
});
const setBrand = useCallback((b: ClinicBrand) => {
setBrandState(b);
localStorage.setItem(BRAND_KEY_LS, JSON.stringify(b));
// Título de la pestaña sigue al brand configurado.
if (typeof document !== 'undefined' && b.nombre) {
document.title = b.nombre;
}
}, []);
// Colores de marca en runtime: solo pisan el tema si la clínica los configuró.
useEffect(() => {
applyBrandColors(brand);
}, [brand]);
const refreshBrand = useCallback(async () => {
try {
const res = await odooApi.getBrand();
if (res.status === 'success' && res.brand) setBrand(res.brand);
} catch {
// fallback: BRAND_DEFAULT / caché
}
}, [setBrand]);
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
useEffect(() => {
let alive = true;
let timer: number | undefined;
const t = localStorage.getItem(TOKEN_KEY);
if (!t) {
setLoading(false);
return;
}
// Hidratar usuario cacheado para evitar parpadeo
const cached = localStorage.getItem(USER_KEY);
if (cached) {
try { setUser(JSON.parse(cached)); } catch { /* ignore */ }
}
// Red de seguridad: si el servidor no responde (red lenta/caída del
// túnel), mostrar pantalla de reintento en vez del spinner eterno.
timer = window.setTimeout(() => {
if (!alive) return;
setBootError('El servidor no responde. Revisa tu conexión e intenta de nuevo.');
setLoading(false);
}, BOOT_TIMEOUT_MS);
odooApi.me()
.then((res) => {
if (!alive) return;
if (res.status === 'success') {
setUser(res.user);
localStorage.setItem(USER_KEY, JSON.stringify(res.user));
refreshMenuRoles();
refreshBrand();
} else {
clear();
}
setBootError(null);
})
.catch((err) => {
if (!alive) return;
// Solo se descarta la sesión si el servidor contestó (token inválido,
// 401). Si la red falló se conserva el token para el reintento.
if (err?.response) clear();
else setBootError('No se pudo conectar con el servidor. Revisa tu conexión e intenta de nuevo.');
})
.finally(() => {
if (timer) window.clearTimeout(timer);
if (alive) setLoading(false);
});
return () => {
alive = false;
if (timer) window.clearTimeout(timer);
};
}, [clear, refreshMenuRoles, bootAttempt]);
const retryBoot = useCallback(() => {
setBootError(null);
setLoading(true);
setBootAttempt((n) => n + 1);
}, []);
const login = async (login: string, password: string): Promise<FrontendUser> => {
const res = await odooApi.login(login, password);
if (res.status !== 'success' || !res.token) {
throw new Error(res.message || 'Credenciales inválidas');
}
localStorage.setItem(TOKEN_KEY, res.token);
localStorage.setItem(USER_KEY, JSON.stringify(res.user));
setToken(res.token);
setUser(res.user);
refreshMenuRoles();
refreshBrand();
return res.user;
};
const logout = async () => {
try { await odooApi.logout(); } catch { /* ignore */ }
clear();
};
const hasRole = (min: FrontendRole): boolean => {
if (!user) return false;
return (ROLE_RANK[user.role] ?? 0) >= (ROLE_RANK[min] ?? 0);
};
const canSee = (href: string): boolean => {
if (!user) return false;
// Resolución de permiso: primero href exacto (con query), luego por
// prefijo recortando el último segmento (/pacientes/PAT-123 -> /pacientes).
// '/' no matchea subrutas: solo aterriza por canSee('/'). Rutas que no
// matchean nada = visibles para todos.
const segments = href.split('?')[0].split('/').filter(Boolean);
const candidates = [href, ...segments.map((_, i) => '/' + segments.slice(0, segments.length - i).join('/'))].filter((c) => c !== '/' || href === '/');
// Permisos personalizados: lista no vacía manda sobre la matriz por rol
if (user.allowed_menus && user.allowed_menus.length > 0) {
for (const cand of candidates) {
if (user.allowed_menus.includes(cand)) return true;
}
return false;
}
const matrix = menuRoles || MENU_ROLES;
for (const cand of candidates) {
const allowed = matrix[cand];
if (allowed) return allowed.includes(user.role);
}
return true;
};
// Acciones dentro de la Agenda. El backend resuelve la herencia del rol y
// manda la lista efectiva en user.agenda_perms; el fallback por rango cubre
// la sesión cacheada anterior al despliegue.
const canAgenda = (perm: AgendaPerm): boolean => {
if (!user) return false;
const todas: AgendaPerm[] = ['ver', 'agendar', 'editar', 'eliminar', 'visitas'];
const perms = user.agenda_perms
?? (ROLE_RANK[user.role] >= ROLE_RANK.recepcion ? todas : ['ver']);
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, can, brand, setBrand }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = (): AuthContextValue => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth debe usarse dentro de AuthProvider');
return ctx;
};
export const ROLE_LABELS: Record<string, string> = {
admin: 'Administrador',
medico: 'Médico',
recepcion: 'Recepción',
enfermeria: 'Enfermería',
cosmetologia: 'Cosmetología',
psicologa: 'Psicóloga',
farmacia: 'Farmacia',
gerencia: 'Gerencia',
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;