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:
1 parent
f1156929c7
commit
4c8c499a72
36 files changed
+1291
-52
No files matched your search
@@ -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,3 +1,4 @@
|
||||
export * from './auth.js';
|
||||
export * from './permissions.js';
|
||||
export * from './sl.js';
|
||||
export * from './menu.js';
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
Reference in new issue
Block a user