feat(web): login, sesión, interceptor, guards y layout con menú por permisos

- Login contra POST /api/auth/login; SessionStore en localStorage con el
  timeout deslizante que informa el servidor (sessionTimeoutMinutes).
- Interceptor: Bearer en /api, renueva actividad y, ante 401, cierra la
  sesión y vuelve al login recordando la ruta (solo rutas internas).
- Guards: sesión, invitado y permiso por pantalla (data.path). Corrige que
  inject() no puede usarse tras un await en permissionGuard.
- Layout con barra superior (empresa, usuario, tema, salir) y menú lateral
  filtrado por GET /api/user/menu; plegable y con botón en pantallas angostas.
- Menú único en libs/sap-types (MENU, filterMenu); el backend verifica por
  test que las rutas de permiso de sus recursos existan en él.
- Una ruta por pantalla del menú; las no construidas muestran "no disponible".
- Verificado en Chrome headless contra el backend y SAP reales: redirección
  sin sesión, menú de 20 pantallas, navegación, plegado, tema oscuro y
  persistencia, vista móvil, salir, token inválido y credenciales malas.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-13 19:18:05 -05:00
1 parent f1156929c7
commit 4c8c499a72
36 files changed
+1291 -52

No files matched your search

+2
View File
@@ -16,6 +16,8 @@ export interface LoginResponse {
token: string;
user: AuthUser;
company: string;
/** Inactividad tras la cual caduca la sesión (el cliente la usa para avisar antes). */
sessionTimeoutMinutes: number;
}
/** Respuesta de `GET /api/auth/me`. */
+1
View File
@@ -1,3 +1,4 @@
export * from './auth.js';
export * from './permissions.js';
export * from './sl.js';
export * from './menu.js';
+26
View File
@@ -0,0 +1,26 @@
import { MENU, filterMenu, menuPaths } from './menu.js';
describe('MENU', () => {
it('no repite rutas y todas son absolutas', () => {
const paths = menuPaths();
expect(new Set(paths).size).toBe(paths.length);
expect(paths.every((p) => /^\/[a-z-]+\/[a-z-]+$/.test(p))).toBe(true);
});
});
describe('filterMenu', () => {
const ids = (allowed: string[]) => filterMenu(MENU, allowed).map((m) => m.id);
it('"*" ve todo', () => expect(filterMenu(MENU, ['*'])).toHaveLength(MENU.length));
it('sin permisos no ve nada', () => expect(filterMenu(MENU, [])).toEqual([]));
it('un módulo completo y una pantalla suelta de otro', () => {
const result = filterMenu(MENU, ['/ventas/*', '/inventario/articulos']);
expect(result.map((m) => m.id)).toEqual(['ventas', 'inventario']);
expect(result[1].items.map((i) => i.path)).toEqual(['/inventario/articulos']);
});
it('omite los módulos sin pantallas permitidas', () => {
expect(ids(['/socios/clientes'])).toEqual(['socios']);
});
});
+73
View File
@@ -0,0 +1,73 @@
import { isPathAllowed } from './permissions.js';
export interface MenuItem {
/** Ruta de la pantalla; también es lo que se otorga en los permisos de menú. */
path: string;
label: string;
}
export interface MenuModule {
id: string;
label: string;
items: readonly MenuItem[];
}
/**
* Menú de la aplicación: única fuente de rutas. El frontend lo dibuja filtrado
* por permisos y el backend (registro del proxy SL) usa las mismas rutas.
*/
export const MENU: readonly MenuModule[] = [
{
id: 'ventas',
label: 'Ventas',
items: [
{ path: '/ventas/ofertas', label: 'Ofertas de venta' },
{ path: '/ventas/ordenes', label: 'Órdenes de venta' },
{ path: '/ventas/entrega', label: 'Entregas' },
{ path: '/ventas/facturas', label: 'Facturas de venta' },
{ path: '/ventas/devoluciones', label: 'Devoluciones' },
{ path: '/ventas/notas-credito', label: 'Notas de crédito' },
],
},
{
id: 'compras',
label: 'Compras',
items: [
{ path: '/compras/solicitudes', label: 'Solicitudes de compra' },
{ path: '/compras/ofertas', label: 'Ofertas de compra' },
{ path: '/compras/ordenes', label: 'Órdenes de compra' },
{ path: '/compras/recepcion', label: 'Recepción de compra' },
{ path: '/compras/facturas', label: 'Facturas de proveedor' },
{ path: '/compras/devoluciones', label: 'Devoluciones de compra' },
{ path: '/compras/notas-credito', label: 'Notas de crédito de compra' },
],
},
{
id: 'inventario',
label: 'Inventario',
items: [
{ path: '/inventario/articulos', label: 'Artículos' },
{ path: '/inventario/entradas', label: 'Entrada de inventario' },
{ path: '/inventario/salidas', label: 'Salida de inventario' },
{ path: '/inventario/transferencias', label: 'Transferencias de stock' },
{ path: '/inventario/solicitudes-traslado', label: 'Solicitudes de traslado' },
],
},
{
id: 'socios',
label: 'Socios de negocio',
items: [
{ path: '/socios/clientes', label: 'Clientes' },
{ path: '/socios/proveedores', label: 'Proveedores' },
],
},
];
/** Deja solo lo que el usuario puede ver; los módulos sin pantallas visibles se omiten. */
export function filterMenu(menu: readonly MenuModule[], allowedPaths: readonly string[]): MenuModule[] {
return menu
.map((m) => ({ ...m, items: m.items.filter((i) => isPathAllowed(allowedPaths, i.path)) }))
.filter((m) => m.items.length > 0);
}
export const menuPaths = (menu: readonly MenuModule[] = MENU): string[] => menu.flatMap((m) => m.items.map((i) => i.path));