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

+6 -1
View File
@@ -24,7 +24,12 @@ export class AuthController {
@HttpCode(200)
async login(@Body(new ZodValidationPipe(loginSchema)) body: LoginDto, @Req() req: AuthenticatedRequest) {
const { token, user } = await this.auth.login(body.username, body.password, req.ip ?? 'desconocida');
return { token, user, company: this.config.get('SAPB1_SL_COMPANYDB') };
return {
token,
user,
company: this.config.get('SAPB1_SL_COMPANYDB'),
sessionTimeoutMinutes: this.config.get('SESSION_TIMEOUT_MINUTES'),
};
}
@Post('logout')
+10
View File
@@ -1,4 +1,5 @@
import { BadRequestException } from '@nestjs/common';
import { menuPaths } from '@basic-erp/sap-types';
import { SL_RESOURCES, SL_RESOURCE_MAP } from './sl-resources.js';
import { entityPath, getQuerySchema, listQuerySchema, pickSlHeaders } from './sl-request.js';
@@ -14,6 +15,15 @@ describe('registro de recursos', () => {
}
});
it('todas las rutas de permiso existen en el menú compartido', () => {
const known = new Set(menuPaths());
for (const r of SL_RESOURCES) {
for (const path of [r.read, r.write].flat()) {
if (path !== null) expect(known.has(path), `${r.name}: ${path}`).toBe(true);
}
}
});
it('incluye los recursos pedidos', () => {
for (const n of ['Quotations', 'Orders', 'PurchaseOrders', 'StockTransfers', 'InventoryGenEntries', 'InventoryGenExits', 'InventoryTransferRequests']) {
expect(SL_RESOURCE_MAP.has(n), n).toBe(true);
+1
View File
@@ -24,6 +24,7 @@ describe('Auth (e2e, SAP simulado)', () => {
it('flujo completo: login -> me -> ruta protegida -> logout -> 401', async () => {
const res = await login('ana', 'secreto').expect(200);
expect(res.body.user).toMatchObject({ webUser: 'ana', empId: 7, firstName: 'ana' });
expect(res.body.sessionTimeoutMinutes).toBe(30);
expect(JSON.stringify(res.body)).not.toContain('secreto');
const auth = { Authorization: `Bearer ${res.body.token}` };
+7
View File
@@ -32,3 +32,10 @@ Tokens: `--app --shell --shell-ink --shell-quiet --win --panel --line --line-sof
## Tipos compartidos
`@basic-erp/sap-types` (`libs/sap-types`): usuario, respuestas de la API y el evaluador de permisos de menú, los mismos que usa el backend.
## Autenticación y rutas
- `core/auth`: `SessionStore` (sesión en localStorage, compartida entre pestañas, con timeout deslizante según el servidor), `AuthService` (login/logout/expire), interceptor (Bearer en `/api`, renueva actividad y atiende los 401) y guards (`authGuard`, `guestGuard`, `permissionGuard`).
- `core/menu`: `MenuService` carga `GET /api/user/menu` una vez por sesión; el menú visible sale de `MENU` (`@basic-erp/sap-types`) filtrado por permisos.
- `app.routes.ts` genera una ruta por pantalla del menú; las que aún no existen muestran "no disponible" (`ComingSoonPage`). Para construir una pantalla, se reemplaza su `loadComponent`.
- Un 401 en cualquier llamada cierra la sesión local y lleva al login recordando la ruta (`?returnUrl=`, solo rutas internas).
+8 -2
View File
@@ -1,7 +1,13 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { type ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { authInterceptor } from './core/auth/auth.interceptor';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)],
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
],
};
-21
View File
@@ -1,21 +0,0 @@
.shell {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 16px;
background: var(--shell);
color: var(--shell-ink);
}
.brand {
font-weight: 600;
font-size: 14px;
letter-spacing: 0.2px;
}
.brand small {
font-weight: 400;
color: var(--shell-quiet);
margin-left: 8px;
}
sbo-theme-switcher {
margin-left: auto;
}
-5
View File
@@ -1,5 +0,0 @@
<header class="shell">
<span class="brand">Basic ERP <small>SAP Business One</small></span>
<sbo-theme-switcher />
</header>
<router-outlet />
+32 -2
View File
@@ -1,3 +1,33 @@
import { Routes } from '@angular/router';
import type { Routes } from '@angular/router';
import { MENU } from '@basic-erp/sap-types';
import { authGuard, guestGuard, menuLoadedGuard, permissionGuard } from './core/auth/guards';
export const routes: Routes = [];
/** Una ruta por pantalla del menú; las que aún no existen muestran "no disponible". */
const screens: Routes = MENU.flatMap((m) => m.items).map((item) => ({
path: item.path.slice(1),
canActivate: [permissionGuard],
data: { path: item.path, title: item.label },
loadComponent: () => import('./features/shared/coming-soon.page').then((m) => m.ComingSoonPage),
}));
export const routes: Routes = [
{
path: 'login',
canActivate: [guestGuard],
loadComponent: () => import('./features/login/login.page').then((m) => m.LoginPage),
},
{
path: '',
canActivate: [authGuard, menuLoadedGuard],
loadComponent: () => import('./layout/main-layout').then((m) => m.MainLayout),
children: [
{ path: '', pathMatch: 'full', loadComponent: () => import('./features/home/home.page').then((m) => m.HomePage) },
{
path: 'sin-acceso',
loadComponent: () => import('./features/shared/no-access.page').then((m) => m.NoAccessPage),
},
...screens,
],
},
{ path: '**', redirectTo: '' },
];
-15
View File
@@ -1,15 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { describe, expect, it } from 'vitest';
import { App } from './app';
describe('App', () => {
it('muestra la barra superior con el selector de tema', async () => {
await TestBed.configureTestingModule({ imports: [App], providers: [provideRouter([])] }).compileComponents();
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const el = fixture.nativeElement as HTMLElement;
expect(el.querySelector('.brand')?.textContent).toContain('Basic ERP');
expect(el.querySelector('sbo-theme-switcher select')).toBeTruthy();
});
});
+8 -6
View File
@@ -1,12 +1,14 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ThemeSwitcher } from './core/theme/theme-switcher';
import { ThemeService } from './core/theme/theme.service';
@Component({
selector: 'app-root',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterOutlet, ThemeSwitcher],
templateUrl: './app.html',
styleUrl: './app.css',
imports: [RouterOutlet],
template: '<router-outlet />',
})
export class App {}
export class App {
// Instanciar el servicio aplica el tema guardado también en la pantalla de login.
protected readonly theme = inject(ThemeService);
}
+22
View File
@@ -0,0 +1,22 @@
import { HttpErrorResponse } from '@angular/common/http';
import { describe, expect, it } from 'vitest';
import { apiErrorMessage, safeReturnUrl } from './api';
const http = (status: number, error?: unknown) => new HttpErrorResponse({ status, error });
describe('apiErrorMessage', () => {
it('usa el mensaje del backend', () => expect(apiErrorMessage(http(401, { message: 'Usuario o contraseña incorrectos' }))).toBe('Usuario o contraseña incorrectos'));
it('sin conexión', () => expect(apiErrorMessage(http(0))).toBe('No se pudo conectar con el servidor'));
it('429 informa los minutos de espera', () => expect(apiErrorMessage(http(429, { retryAfterSeconds: 600 }))).toContain('10 min'));
it('sin mensaje usa el código', () => expect(apiErrorMessage(http(500, null))).toBe('Error 500'));
it('error desconocido', () => expect(apiErrorMessage(new Error('x'))).toBe('Ocurrió un error inesperado'));
});
describe('safeReturnUrl', () => {
it('acepta rutas internas', () => expect(safeReturnUrl('/ventas/ordenes?x=1')).toBe('/ventas/ordenes?x=1'));
it('rechaza redirecciones abiertas y el login', () => {
for (const bad of ['//evil.com', 'https://evil.com', 'javascript:alert(1)', '/login', null, undefined, '']) {
expect(safeReturnUrl(bad), String(bad)).toBe('/');
}
});
});
+21
View File
@@ -0,0 +1,21 @@
import { HttpErrorResponse } from '@angular/common/http';
import type { ApiError } from '@basic-erp/sap-types';
/** Base de la API; en desarrollo `ng serve` la redirige al backend (proxy.conf.json). */
export const API_URL = '/api';
/** Mensaje legible para el usuario a partir de un error HTTP del backend. */
export function apiErrorMessage(err: unknown): string {
if (!(err instanceof HttpErrorResponse)) return 'Ocurrió un error inesperado';
if (err.status === 0) return 'No se pudo conectar con el servidor';
const body = err.error as Partial<ApiError> & { retryAfterSeconds?: number };
if (err.status === 429 && body?.retryAfterSeconds) {
return `Demasiados intentos fallidos. Intente de nuevo en ${Math.ceil(body.retryAfterSeconds / 60)} min.`;
}
return typeof body?.message === 'string' ? body.message : `Error ${err.status}`;
}
/** Solo rutas internas: evita redirecciones abiertas con `?returnUrl=`. */
export function safeReturnUrl(url: string | null | undefined): string {
return url && url.startsWith('/') && !url.startsWith('//') && !url.startsWith('/login') ? url : '/';
}
@@ -0,0 +1,84 @@
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { Router, provideRouter } from '@angular/router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MenuService } from '../menu/menu.service';
import { authInterceptor } from './auth.interceptor';
import { SessionStore } from './session.store';
describe('authInterceptor', () => {
let http: HttpClient;
let ctrl: HttpTestingController;
let session: SessionStore;
let navigate: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
providers: [provideRouter([]), provideHttpClient(withInterceptors([authInterceptor])), provideHttpClientTesting()],
});
http = TestBed.inject(HttpClient);
ctrl = TestBed.inject(HttpTestingController);
session = TestBed.inject(SessionStore);
navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
session.set({
token: 'tok',
company: 'DB',
sessionTimeoutMinutes: 30,
user: { webUser: 'ana', empId: 1, firstName: 'Ana', lastName: 'P' },
});
});
it('añade el Bearer a las llamadas a la API', () => {
http.get('/api/sl/Items').subscribe();
const req = ctrl.expectOne('/api/sl/Items');
expect(req.request.headers.get('Authorization')).toBe('Bearer tok');
req.flush({});
});
it('no envía el token al login ni a otros orígenes', () => {
http.post('/api/auth/login', {}).subscribe();
expect(ctrl.expectOne('/api/auth/login').request.headers.has('Authorization')).toBe(false);
http.get('https://otro.com/x').subscribe();
expect(ctrl.expectOne('https://otro.com/x').request.headers.has('Authorization')).toBe(false);
});
it('ante un 401 cierra la sesión y va al login', () => {
http.get('/api/sl/Items').subscribe({ error: () => undefined });
ctrl.expectOne('/api/sl/Items').flush({ message: 'Sesión expirada' }, { status: 401, statusText: 'Unauthorized' });
expect(session.isLoggedIn()).toBe(false);
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate.mock.calls[0][0]).toEqual(['/login']);
});
it('un 401 del login (credenciales malas) no es "sesión expirada"', () => {
http.post('/api/auth/login', {}).subscribe({ error: () => undefined });
ctrl.expectOne('/api/auth/login').flush({}, { status: 401, statusText: 'Unauthorized' });
expect(session.isLoggedIn()).toBe(true);
expect(navigate).not.toHaveBeenCalled();
});
it('otros errores (403, 500) no cierran la sesión', () => {
for (const status of [403, 500]) {
http.get('/api/sl/Items').subscribe({ error: () => undefined });
ctrl.expectOne('/api/sl/Items').flush({}, { status, statusText: 'x' });
}
expect(session.isLoggedIn()).toBe(true);
expect(navigate).not.toHaveBeenCalled();
});
it('varios 401 simultáneos navegan una sola vez', () => {
for (let i = 0; i < 3; i++) http.get('/api/sl/Items').subscribe({ error: () => undefined });
for (const r of ctrl.match('/api/sl/Items')) r.flush({}, { status: 401, statusText: 'x' });
expect(navigate).toHaveBeenCalledTimes(1);
});
it('al cerrar sesión se reinicia el menú cargado', () => {
const menu = TestBed.inject(MenuService);
menu.allowedPaths.set(['*']);
http.get('/api/x').subscribe({ error: () => undefined });
ctrl.expectOne('/api/x').flush({}, { status: 401, statusText: 'x' });
expect(menu.allowedPaths()).toBeNull();
});
});
@@ -0,0 +1,30 @@
import { HttpErrorResponse, type HttpInterceptorFn, HttpResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, tap, throwError } from 'rxjs';
import { API_URL } from '../api';
import { AuthService } from './auth.service';
import { SessionStore } from './session.store';
/** Añade el token a las llamadas a la API, renueva la actividad y atiende los 401. */
export const authInterceptor: HttpInterceptorFn = (req, next) => {
if (!req.url.startsWith(API_URL)) return next(req);
const session = inject(SessionStore);
const auth = inject(AuthService);
const isLogin = req.url === `${API_URL}/auth/login`;
const token = session.token();
const outgoing =
token && !isLogin && !req.headers.has('Authorization')
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
: req;
return next(outgoing).pipe(
tap((event) => {
if (event instanceof HttpResponse) session.touch();
}),
catchError((err: unknown) => {
if (err instanceof HttpErrorResponse && err.status === 401 && !isLogin && token) auth.expire();
return throwError(() => err);
}),
);
};
@@ -0,0 +1,48 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Router } from '@angular/router';
import type { LoginRequest, LoginResponse } from '@basic-erp/sap-types';
import { firstValueFrom } from 'rxjs';
import { API_URL } from '../api';
import { MenuService } from '../menu/menu.service';
import { SessionStore } from './session.store';
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private readonly session = inject(SessionStore);
private readonly menu = inject(MenuService);
private readonly router = inject(Router);
async login(credentials: LoginRequest): Promise<void> {
const res = await firstValueFrom(this.http.post<LoginResponse>(`${API_URL}/auth/login`, credentials));
this.menu.reset();
this.session.set(res);
}
/** Cierre voluntario: avisa al backend (sin esperar su respuesta) y vuelve al login. */
async logout(): Promise<void> {
const token = this.session.token();
this.end();
if (token) {
// la sesión local ya se cerró: el token se envía explícitamente
await firstValueFrom(
this.http.post(`${API_URL}/auth/logout`, {}, { headers: { Authorization: `Bearer ${token}` } }),
).catch(() => undefined);
}
await this.router.navigate(['/login']);
}
/** La sesión caducó o el backend la rechazó (401): limpiar y pedir login recordando dónde estaba. */
expire(): void {
if (!this.session.isLoggedIn()) return;
const returnUrl = this.router.url;
this.end();
void this.router.navigate(['/login'], { queryParams: returnUrl && returnUrl !== '/' ? { returnUrl } : {} });
}
private end(): void {
this.session.clear();
this.menu.reset();
}
}
+70
View File
@@ -0,0 +1,70 @@
import { TestBed } from '@angular/core/testing';
import { type ActivatedRouteSnapshot, Router, type RouterStateSnapshot, UrlTree, provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { MenuService } from '../menu/menu.service';
import { authGuard, guestGuard, permissionGuard } from './guards';
import { SessionStore } from './session.store';
const run = <T>(fn: () => T) => TestBed.runInInjectionContext(fn);
const state = (url: string) => ({ url }) as RouterStateSnapshot;
const route = (path: string) => ({ data: { path } }) as unknown as ActivatedRouteSnapshot;
describe('guards', () => {
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()],
});
});
const login = () =>
TestBed.inject(SessionStore).set({
token: 't',
company: 'DB',
sessionTimeoutMinutes: 30,
user: { webUser: 'a', empId: 1, firstName: 'A', lastName: 'B' },
});
it('authGuard deja pasar con sesión', () => {
login();
expect(run(() => authGuard(route(''), state('/ventas/ordenes')))).toBe(true);
});
it('authGuard sin sesión manda al login recordando la ruta', () => {
const tree = run(() => authGuard(route(''), state('/ventas/ordenes'))) as UrlTree;
expect(TestBed.inject(Router).serializeUrl(tree)).toBe('/login?returnUrl=%2Fventas%2Fordenes');
});
it('authGuard no recuerda la raíz', () => {
const tree = run(() => authGuard(route(''), state('/'))) as UrlTree;
expect(TestBed.inject(Router).serializeUrl(tree)).toBe('/login');
});
it('guestGuard saca del login a quien ya tiene sesión', () => {
expect(run(() => guestGuard(route(''), state('/login')))).toBe(true);
login();
expect(run(() => guestGuard(route(''), state('/login'))) instanceof UrlTree).toBe(true);
});
it('permissionGuard permite o manda a "sin acceso" según el menú', async () => {
login();
const ctrl = TestBed.inject(HttpTestingController);
const allowed = run(() => permissionGuard(route('/ventas/ordenes'), state('/ventas/ordenes'))) as Promise<unknown>;
ctrl.expectOne('/api/user/menu').flush({ allowedPaths: ['/ventas/*'] });
expect(await allowed).toBe(true);
const denied = (await run(() => permissionGuard(route('/compras/ordenes'), state('/compras/ordenes')))) as UrlTree;
expect(TestBed.inject(Router).serializeUrl(denied)).toBe('/sin-acceso'); // menú ya cargado: sin nueva petición
});
it('permissionGuard niega si no se pudo cargar el menú', async () => {
login();
const ctrl = TestBed.inject(HttpTestingController);
const result = run(() => permissionGuard(route('/ventas/ordenes'), state('/ventas/ordenes'))) as Promise<unknown>;
ctrl.expectOne('/api/user/menu').flush({}, { status: 500, statusText: 'x' });
expect((await result) instanceof UrlTree).toBe(true);
expect(TestBed.inject(MenuService).failed()).toBe(true);
});
});
+30
View File
@@ -0,0 +1,30 @@
import { inject } from '@angular/core';
import { type CanActivateFn, Router } from '@angular/router';
import { MenuService } from '../menu/menu.service';
import { SessionStore } from './session.store';
/** Exige sesión; si no hay, va al login recordando la ruta pedida. */
export const authGuard: CanActivateFn = (_route, state) => {
if (inject(SessionStore).isLoggedIn()) return true;
const queryParams = state.url && state.url !== '/' ? { returnUrl: state.url } : {};
return inject(Router).createUrlTree(['/login'], { queryParams });
};
/** El login no se muestra a quien ya tiene sesión. */
export const guestGuard: CanActivateFn = () =>
inject(SessionStore).isLoggedIn() ? inject(Router).createUrlTree(['/']) : true;
/** Carga los permisos de menú antes de pintar el layout. */
export const menuLoadedGuard: CanActivateFn = async () => {
await inject(MenuService).ensureLoaded();
return true;
};
/** Exige que la ruta (`data.path`) esté permitida para el usuario. */
export const permissionGuard: CanActivateFn = async (route) => {
// inject() solo funciona antes del primer await
const menu = inject(MenuService);
const router = inject(Router);
await menu.ensureLoaded();
return menu.can(route.data['path'] as string) ? true : router.createUrlTree(['/sin-acceso']);
};
@@ -0,0 +1,68 @@
import { TestBed } from '@angular/core/testing';
import type { LoginResponse } from '@basic-erp/sap-types';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SessionStore } from './session.store';
const login: LoginResponse = {
token: 'tok',
company: 'DB',
sessionTimeoutMinutes: 30,
user: { webUser: 'ana', empId: 7, firstName: 'Ana', lastName: 'Pérez' },
};
const make = () => {
TestBed.resetTestingModule();
return TestBed.inject(SessionStore);
};
describe('SessionStore', () => {
beforeEach(() => {
localStorage.clear();
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T10:00:00Z'));
});
afterEach(() => vi.useRealTimers());
it('sin sesión guardada no hay usuario', () => {
const s = make();
expect(s.isLoggedIn()).toBe(false);
expect(s.token()).toBeNull();
});
it('guarda la sesión y la recupera al recargar', () => {
make().set(login);
const reloaded = make();
expect(reloaded.isLoggedIn()).toBe(true);
expect(reloaded.token()).toBe('tok');
expect(reloaded.fullName()).toBe('Ana Pérez');
expect(reloaded.company()).toBe('DB');
});
it('descarta la sesión inactiva más tiempo que el timeout del servidor', () => {
make().set(login);
vi.advanceTimersByTime(31 * 60_000);
expect(make().isLoggedIn()).toBe(false);
expect(localStorage.getItem('erp_session')).toBeNull();
});
it('touch renueva la actividad (con límite de escrituras) y mantiene viva la sesión', () => {
const s = make();
s.set(login);
vi.advanceTimersByTime(20 * 60_000);
s.touch();
vi.advanceTimersByTime(20 * 60_000); // 40 min desde el login, 20 desde la última actividad
expect(make().isLoggedIn()).toBe(true);
});
it('clear cierra la sesión', () => {
const s = make();
s.set(login);
s.clear();
expect(s.isLoggedIn()).toBe(false);
expect(make().isLoggedIn()).toBe(false);
});
it('ignora datos corruptos', () => {
localStorage.setItem('erp_session', '{no es json');
expect(make().isLoggedIn()).toBe(false);
});
});
@@ -0,0 +1,79 @@
import { Injectable, computed, signal } from '@angular/core';
import type { AuthUser, LoginResponse } from '@basic-erp/sap-types';
interface StoredSession {
token: string;
user: AuthUser;
company: string;
timeoutMinutes: number;
lastActivity: number;
}
const STORAGE_KEY = 'erp_session';
/** Para no escribir en localStorage en cada petición. */
const TOUCH_EVERY_MS = 15_000;
/** Sesión web: se comparte entre pestañas (el backend admite una sesión por usuario). */
@Injectable({ providedIn: 'root' })
export class SessionStore {
private readonly state = signal<StoredSession | null>(this.load());
readonly token = computed(() => this.state()?.token ?? null);
readonly user = computed(() => this.state()?.user ?? null);
readonly company = computed(() => this.state()?.company ?? '');
readonly isLoggedIn = computed(() => this.state() !== null);
readonly fullName = computed(() => {
const u = this.state()?.user;
return u ? `${u.firstName} ${u.lastName}`.trim() || u.webUser : '';
});
set(login: LoginResponse): void {
this.save({
token: login.token,
user: login.user,
company: login.company,
timeoutMinutes: login.sessionTimeoutMinutes,
lastActivity: Date.now(),
});
}
clear(): void {
this.state.set(null);
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// almacenamiento bloqueado
}
}
/** Marca actividad: renueva el timeout deslizante del lado del cliente. */
touch(): void {
const current = this.state();
if (current && Date.now() - current.lastActivity > TOUCH_EVERY_MS) {
this.save({ ...current, lastActivity: Date.now() });
}
}
private save(session: StoredSession): void {
this.state.set(session);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
} catch {
// la sesión vale solo en memoria
}
}
private load(): StoredSession | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const s = JSON.parse(raw) as StoredSession;
const valid = typeof s.token === 'string' && s.user && typeof s.lastActivity === 'number';
if (valid && Date.now() - s.lastActivity <= s.timeoutMinutes * 60_000) return s;
localStorage.removeItem(STORAGE_KEY);
} catch {
// dato corrupto o almacenamiento bloqueado
}
return null;
}
}
@@ -0,0 +1,62 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { MenuService } from './menu.service';
describe('MenuService', () => {
let menu: MenuService;
let ctrl: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
menu = TestBed.inject(MenuService);
ctrl = TestBed.inject(HttpTestingController);
});
it('carga una sola vez aunque se pida varias veces a la vez', async () => {
const calls = [menu.ensureLoaded(), menu.ensureLoaded(), menu.ensureLoaded()];
ctrl.expectOne('/api/user/menu').flush({ allowedPaths: ['/ventas/*'] });
await Promise.all(calls);
await menu.ensureLoaded(); // ya cargado: sin petición nueva
ctrl.verify();
});
it('arma el menú visible según los permisos', async () => {
const p = menu.ensureLoaded();
ctrl.expectOne('/api/user/menu').flush({ allowedPaths: ['/ventas/ordenes', '/socios/*'] });
await p;
expect(menu.modules().map((m) => m.id)).toEqual(['ventas', 'socios']);
expect(menu.modules()[0].items.map((i) => i.path)).toEqual(['/ventas/ordenes']);
expect(menu.can('/ventas/ordenes')).toBe(true);
expect(menu.can('/ventas/facturas')).toBe(false);
});
it('"*" (administrador) ve todo', async () => {
const p = menu.ensureLoaded();
ctrl.expectOne('/api/user/menu').flush({ allowedPaths: ['*'] });
await p;
expect(menu.modules()).toHaveLength(4);
});
it('si falla marca el error y permite reintentar', async () => {
const p = menu.ensureLoaded();
ctrl.expectOne('/api/user/menu').flush({}, { status: 500, statusText: 'x' });
await p;
expect(menu.failed()).toBe(true);
expect(menu.modules()).toEqual([]);
const retry = menu.ensureLoaded();
ctrl.expectOne('/api/user/menu').flush({ allowedPaths: ['*'] });
await retry;
expect(menu.failed()).toBe(false);
});
it('reset olvida los permisos', async () => {
const p = menu.ensureLoaded();
ctrl.expectOne('/api/user/menu').flush({ allowedPaths: ['*'] });
await p;
menu.reset();
expect(menu.can('/ventas/ordenes')).toBe(false);
});
});
@@ -0,0 +1,41 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, computed, inject, signal } from '@angular/core';
import { MENU, type UserMenuResponse, filterMenu, isPathAllowed } from '@basic-erp/sap-types';
import { firstValueFrom } from 'rxjs';
import { API_URL } from '../api';
/** Menú permitido para el usuario (`GET /api/user/menu`), cargado una vez por sesión. */
@Injectable({ providedIn: 'root' })
export class MenuService {
private readonly http = inject(HttpClient);
private loading?: Promise<void>;
readonly allowedPaths = signal<string[] | null>(null);
readonly failed = signal(false);
readonly modules = computed(() => {
const allowed = this.allowedPaths();
return allowed ? filterMenu(MENU, allowed) : [];
});
ensureLoaded(): Promise<void> {
if (this.allowedPaths()) return Promise.resolve();
this.loading ??= firstValueFrom(this.http.get<UserMenuResponse>(`${API_URL}/user/menu`))
.then((r) => {
this.allowedPaths.set(r.allowedPaths);
this.failed.set(false);
})
.catch(() => this.failed.set(true))
.finally(() => (this.loading = undefined));
return this.loading;
}
can(path: string): boolean {
const allowed = this.allowedPaths();
return allowed !== null && isPathAllowed(allowed, path);
}
reset(): void {
this.allowedPaths.set(null);
this.failed.set(false);
}
}
@@ -0,0 +1,36 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { SessionStore } from '../../core/auth/session.store';
import { MenuService } from '../../core/menu/menu.service';
@Component({
selector: 'sbo-home-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink],
template: `
<h1>Bienvenido, {{ session.user()?.firstName }}</h1>
<p class="sub">{{ session.company() }}</p>
@for (m of menu.modules(); track m.id) {
<section>
<h2>{{ m.label }}</h2>
<div class="links">
@for (i of m.items; track i.path) {
<a [routerLink]="i.path">{{ i.label }}</a>
}
</div>
</section>
}
`,
styles: `
h1 { margin: 0; font-size: 18px; font-weight: 600; }
.sub { margin: 2px 0 16px; color: var(--quiet); }
h2 { margin: 12px 0 6px; font-size: 13px; color: var(--quiet); text-transform: uppercase; letter-spacing: .4px; }
.links { display: flex; flex-wrap: wrap; gap: 6px; }
a { padding: 4px 12px; background: var(--panel); border: 1px solid var(--line); border-radius: 3px; color: var(--ink); text-decoration: none; }
a:hover { background: var(--row-sel); border-color: var(--focus); }
`,
})
export class HomePage {
protected readonly session = inject(SessionStore);
protected readonly menu = inject(MenuService);
}
@@ -0,0 +1,93 @@
.login {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: 100vh;
background: var(--win);
}
.brand-panel {
background: var(--shell);
color: var(--shell-ink);
padding: 24px 32px;
}
.brand {
font-weight: 600;
font-size: 16px;
}
.brand small {
display: block;
margin-top: 2px;
font-weight: 400;
font-size: 12px;
color: var(--shell-quiet);
}
.form-panel {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.box {
width: 100%;
max-width: 320px;
display: grid;
gap: 6px;
}
h1 {
margin: 0;
font-size: 22px;
font-weight: 400;
}
.sub {
margin: 0 0 12px;
color: var(--quiet);
font-size: 12px;
}
label {
margin-top: 6px;
font-size: 12px;
color: var(--quiet);
}
.in {
height: 30px;
width: 100%;
padding: 2px 8px;
border: 1px solid var(--field-b);
border-radius: 2px;
background: var(--field);
}
.in:focus {
border-color: var(--focus);
outline: none;
box-shadow: 0 0 0 1px var(--focus);
}
.btn {
margin-top: 14px;
height: 32px;
border: 1px solid var(--btn-b);
border-radius: 3px;
background: var(--btn);
cursor: pointer;
}
.btn.main {
border-color: var(--focus);
background: var(--focus);
color: #fff;
font-weight: 600;
}
.btn:disabled {
opacity: 0.5;
cursor: default;
}
.error {
padding: 8px 10px;
border: 1px solid var(--err);
background: var(--err-bg);
color: var(--err);
font-size: 12px;
}
@media (max-width: 800px) {
.login {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
}
@@ -0,0 +1,44 @@
<div class="login">
<aside class="brand-panel">
<div class="brand">Basic ERP <small>SAP Business One</small></div>
</aside>
<main class="form-panel">
<form class="box" (submit)="submit($event)">
<h1>Iniciar sesión</h1>
<p class="sub">Acceso con su usuario web</p>
@if (error()) {
<div class="error" role="alert">{{ error() }}</div>
}
<label for="username">Usuario</label>
<input
id="username"
name="username"
class="in"
type="text"
autocomplete="username"
required
autofocus
[value]="username()"
(input)="username.set($any($event.target).value)"
/>
<label for="password">Contraseña</label>
<input
id="password"
name="password"
class="in"
type="password"
autocomplete="current-password"
required
[value]="password()"
(input)="password.set($any($event.target).value)"
/>
<button class="btn main" type="submit" [disabled]="loading() || !username().trim() || !password()">
{{ loading() ? 'Conectando…' : 'Iniciar sesión' }}
</button>
</form>
</main>
</div>
@@ -0,0 +1,93 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, provideRouter } from '@angular/router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SessionStore } from '../../core/auth/session.store';
import { LoginPage } from './login.page';
describe('LoginPage', () => {
let fixture: ComponentFixture<LoginPage>;
let el: HTMLElement;
let ctrl: HttpTestingController;
let navigateByUrl: ReturnType<typeof vi.spyOn>;
const type = (selector: string, value: string) => {
const input = el.querySelector<HTMLInputElement>(selector)!;
input.value = value;
input.dispatchEvent(new Event('input'));
};
const submit = () => el.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true }));
const button = () => el.querySelector<HTMLButtonElement>('button[type=submit]')!;
beforeEach(async () => {
localStorage.clear();
await TestBed.configureTestingModule({
imports: [LoginPage],
providers: [
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
{ provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: new Map([['returnUrl', '/ventas/ordenes']]) } } },
],
}).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
navigateByUrl = vi.spyOn(TestBed.inject(Router), 'navigateByUrl').mockResolvedValue(true);
fixture = TestBed.createComponent(LoginPage);
el = fixture.nativeElement;
fixture.detectChanges();
});
it('el botón está deshabilitado hasta completar usuario y contraseña', async () => {
expect(button().disabled).toBe(true);
type('#username', 'ana');
type('#password', 'secreto');
fixture.detectChanges();
expect(button().disabled).toBe(false);
});
it('login correcto guarda la sesión y vuelve a la ruta pedida', async () => {
type('#username', ' ana ');
type('#password', 'secreto');
fixture.detectChanges();
submit();
const req = ctrl.expectOne('/api/auth/login');
expect(req.request.body).toEqual({ username: 'ana', password: 'secreto' });
req.flush({
token: 'tok',
company: 'DB',
sessionTimeoutMinutes: 30,
user: { webUser: 'ana', empId: 1, firstName: 'Ana', lastName: 'P' },
});
await fixture.whenStable();
expect(TestBed.inject(SessionStore).isLoggedIn()).toBe(true);
expect(navigateByUrl).toHaveBeenCalledWith('/ventas/ordenes');
});
it('credenciales inválidas muestran el mensaje y vacían la contraseña', async () => {
type('#username', 'ana');
type('#password', 'mala');
fixture.detectChanges();
submit();
ctrl.expectOne('/api/auth/login').flush({ message: 'Usuario o contraseña incorrectos' }, { status: 401, statusText: 'x' });
await fixture.whenStable();
fixture.detectChanges();
expect(el.querySelector('.error')?.textContent).toContain('Usuario o contraseña incorrectos');
expect(el.querySelector<HTMLInputElement>('#password')!.value).toBe('');
expect(TestBed.inject(SessionStore).isLoggedIn()).toBe(false);
expect(navigateByUrl).not.toHaveBeenCalled();
});
it('no envía dos veces mientras conecta', async () => {
type('#username', 'ana');
type('#password', 'x');
fixture.detectChanges();
submit();
submit();
ctrl.expectOne('/api/auth/login'); // una sola
ctrl.verify();
});
});
@@ -0,0 +1,37 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { apiErrorMessage, safeReturnUrl } from '../../core/api';
import { AuthService } from '../../core/auth/auth.service';
@Component({
selector: 'sbo-login-page',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './login.page.html',
styleUrl: './login.page.css',
})
export class LoginPage {
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
protected readonly username = signal('');
protected readonly password = signal('');
protected readonly loading = signal(false);
protected readonly error = signal('');
protected async submit(event: Event): Promise<void> {
event.preventDefault();
if (this.loading()) return;
this.loading.set(true);
this.error.set('');
try {
await this.auth.login({ username: this.username().trim(), password: this.password() });
await this.router.navigateByUrl(safeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl')));
} catch (err) {
this.error.set(apiErrorMessage(err));
this.password.set('');
} finally {
this.loading.set(false);
}
}
}
@@ -0,0 +1,19 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
/** Pantalla aún no implementada: se reemplaza ruta por ruta a medida que se construyen. */
@Component({
selector: 'sbo-coming-soon-page',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>{{ title }}</h1>
<p>Esta pantalla aún no está disponible.</p>
`,
styles: `
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
p { margin: 0; color: var(--quiet); }
`,
})
export class ComingSoonPage {
protected readonly title = inject(ActivatedRoute).snapshot.data['title'] as string;
}
@@ -0,0 +1,18 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'sbo-no-access-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink],
template: `
<h1>Sin acceso</h1>
<p>No tiene permisos para esta pantalla. <a routerLink="/">Volver al inicio</a></p>
`,
styles: `
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
p { margin: 0; color: var(--quiet); }
a { color: var(--focus); }
`,
})
export class NoAccessPage {}
+133
View File
@@ -0,0 +1,133 @@
.app {
display: flex;
flex-direction: column;
height: 100vh;
}
.shell {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 12px;
background: var(--shell);
color: var(--shell-ink);
flex: none;
}
.brand {
color: inherit;
text-decoration: none;
font-weight: 600;
font-size: 14px;
}
.brand small {
font-weight: 400;
margin-left: 8px;
color: var(--shell-quiet);
}
.sp {
flex: 1;
}
.user {
font-size: 12px;
color: var(--shell-quiet);
}
.out,
.burger {
background: transparent;
color: var(--shell-ink);
border: 1px solid var(--shell-quiet);
border-radius: 3px;
padding: 2px 10px;
cursor: pointer;
}
.burger {
display: none;
padding: 2px 8px;
}
.out:hover,
.burger:hover {
background: rgba(255, 255, 255, 0.08);
}
.body {
display: flex;
flex: 1;
min-height: 0;
}
.side {
width: 224px;
flex: none;
overflow-y: auto;
background: var(--panel);
border-right: 1px solid var(--line);
padding: 6px 0;
}
.mod {
display: block;
width: 100%;
text-align: left;
padding: 5px 12px;
background: none;
border: none;
font-weight: 600;
cursor: pointer;
}
.mod:hover {
background: var(--btn-h);
}
.caret {
display: inline-block;
width: 14px;
color: var(--quiet);
}
ul {
list-style: none;
margin: 0;
padding: 0;
}
li a {
display: block;
padding: 4px 12px 4px 28px;
color: var(--ink);
text-decoration: none;
}
li a:hover {
background: var(--btn-h);
}
li a.active {
background: var(--row-sel);
border-left: 3px solid var(--focus);
padding-left: 25px;
font-weight: 600;
}
.note {
margin: 8px 12px;
color: var(--quiet);
font-size: 12px;
}
.note.err {
color: var(--err);
}
.content {
flex: 1;
min-width: 0;
overflow: auto;
padding: 16px;
}
@media (max-width: 900px) {
.burger {
display: inline-block;
}
.user {
display: none;
}
.side {
display: none;
position: absolute;
top: 38px;
bottom: 0;
z-index: 30;
box-shadow: var(--shadow-pop);
}
.side.open {
display: block;
}
}
+46
View File
@@ -0,0 +1,46 @@
<div class="app">
<header class="shell">
<button class="burger" type="button" aria-label="Mostrar u ocultar el menú" (click)="sidebarOpen.set(!sidebarOpen())">
☰
</button>
<a class="brand" routerLink="/">Basic ERP <small>{{ session.company() }}</small></a>
<span class="sp"></span>
<sbo-theme-switcher />
<span class="user">{{ session.fullName() }}</span>
<button class="out" type="button" (click)="logout()">Salir</button>
</header>
<div class="body">
<nav class="side" [class.open]="sidebarOpen()" aria-label="Menú principal">
@for (m of menu.modules(); track m.id) {
<section>
<button class="mod" type="button" [attr.aria-expanded]="isOpen(m.id)" (click)="toggle(m.id)">
<span class="caret">{{ isOpen(m.id) ? '▾' : '▸' }}</span>{{ m.label }}
</button>
@if (isOpen(m.id)) {
<ul>
@for (i of m.items; track i.path) {
<li>
<a [routerLink]="i.path" routerLinkActive="active" (click)="sidebarOpen.set(false)">{{ i.label }}</a>
</li>
}
</ul>
}
</section>
} @empty {
@if (menu.failed()) {
<p class="note err">
No se pudo cargar el menú.
<button type="button" (click)="retryMenu()">Reintentar</button>
</p>
} @else {
<p class="note">No tiene pantallas asignadas. Solicite acceso al administrador.</p>
}
}
</nav>
<main class="content">
<router-outlet />
</main>
</div>
</div>
+43
View File
@@ -0,0 +1,43 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { AuthService } from '../core/auth/auth.service';
import { SessionStore } from '../core/auth/session.store';
import { MenuService } from '../core/menu/menu.service';
import { ThemeSwitcher } from '../core/theme/theme-switcher';
/** Marco de la aplicación: barra superior, menú lateral filtrado por permisos y contenido. */
@Component({
selector: 'sbo-main-layout',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterOutlet, RouterLink, RouterLinkActive, ThemeSwitcher],
templateUrl: './main-layout.html',
styleUrl: './main-layout.css',
})
export class MainLayout {
private readonly auth = inject(AuthService);
protected readonly session = inject(SessionStore);
protected readonly menu = inject(MenuService);
protected readonly sidebarOpen = signal(false);
private readonly collapsed = signal<ReadonlySet<string>>(new Set());
protected isOpen(moduleId: string): boolean {
return !this.collapsed().has(moduleId);
}
protected toggle(moduleId: string): void {
this.collapsed.update((s) => {
const next = new Set(s);
if (!next.delete(moduleId)) next.add(moduleId);
return next;
});
}
protected retryMenu(): void {
void this.menu.ensureLoaded();
}
protected logout(): void {
void this.auth.logout();
}
}
+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));
File renamed without changes.