From 4c8c499a7288bfe36be6d7d09250cdf98f6a5577 Mon Sep 17 00:00:00 2001 From: George Date: Tue, 13 Oct 2026 19:18:05 -0500 Subject: [PATCH] =?UTF-8?q?feat(web):=20login,=20sesi=C3=B3n,=20intercepto?= =?UTF-8?q?r,=20guards=20y=20layout=20con=20men=C3=BA=20por=20permisos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/api/src/auth/auth.controller.ts | 7 +- apps/api/src/sl/sl-request.spec.ts | 10 ++ apps/api/test/auth.e2e-spec.ts | 1 + apps/web/README.md | 7 + apps/web/src/app/app.config.ts | 10 +- apps/web/src/app/app.css | 21 --- apps/web/src/app/app.html | 5 - apps/web/src/app/app.routes.ts | 34 ++++- apps/web/src/app/app.spec.ts | 15 -- apps/web/src/app/app.ts | 14 +- apps/web/src/app/core/api.spec.ts | 22 +++ apps/web/src/app/core/api.ts | 21 +++ .../app/core/auth/auth.interceptor.spec.ts | 84 +++++++++++ .../web/src/app/core/auth/auth.interceptor.ts | 30 ++++ apps/web/src/app/core/auth/auth.service.ts | 48 +++++++ apps/web/src/app/core/auth/guards.spec.ts | 70 +++++++++ apps/web/src/app/core/auth/guards.ts | 30 ++++ .../src/app/core/auth/session.store.spec.ts | 68 +++++++++ apps/web/src/app/core/auth/session.store.ts | 79 +++++++++++ .../src/app/core/menu/menu.service.spec.ts | 62 ++++++++ apps/web/src/app/core/menu/menu.service.ts | 41 ++++++ apps/web/src/app/features/home/home.page.ts | 36 +++++ .../web/src/app/features/login/login.page.css | 93 ++++++++++++ .../src/app/features/login/login.page.html | 44 ++++++ .../src/app/features/login/login.page.spec.ts | 93 ++++++++++++ apps/web/src/app/features/login/login.page.ts | 37 +++++ .../app/features/shared/coming-soon.page.ts | 19 +++ .../src/app/features/shared/no-access.page.ts | 18 +++ apps/web/src/app/layout/main-layout.css | 133 ++++++++++++++++++ apps/web/src/app/layout/main-layout.html | 46 ++++++ apps/web/src/app/layout/main-layout.ts | 43 ++++++ libs/sap-types/src/auth.ts | 2 + libs/sap-types/src/index.ts | 1 + libs/sap-types/src/menu.spec.ts | 26 ++++ libs/sap-types/src/menu.ts | 73 ++++++++++ ...ferta de ventas.html => oferta_venta.html} | 0 36 files changed, 1291 insertions(+), 52 deletions(-) delete mode 100644 apps/web/src/app/app.css delete mode 100644 apps/web/src/app/app.html delete mode 100644 apps/web/src/app/app.spec.ts create mode 100644 apps/web/src/app/core/api.spec.ts create mode 100644 apps/web/src/app/core/api.ts create mode 100644 apps/web/src/app/core/auth/auth.interceptor.spec.ts create mode 100644 apps/web/src/app/core/auth/auth.interceptor.ts create mode 100644 apps/web/src/app/core/auth/auth.service.ts create mode 100644 apps/web/src/app/core/auth/guards.spec.ts create mode 100644 apps/web/src/app/core/auth/guards.ts create mode 100644 apps/web/src/app/core/auth/session.store.spec.ts create mode 100644 apps/web/src/app/core/auth/session.store.ts create mode 100644 apps/web/src/app/core/menu/menu.service.spec.ts create mode 100644 apps/web/src/app/core/menu/menu.service.ts create mode 100644 apps/web/src/app/features/home/home.page.ts create mode 100644 apps/web/src/app/features/login/login.page.css create mode 100644 apps/web/src/app/features/login/login.page.html create mode 100644 apps/web/src/app/features/login/login.page.spec.ts create mode 100644 apps/web/src/app/features/login/login.page.ts create mode 100644 apps/web/src/app/features/shared/coming-soon.page.ts create mode 100644 apps/web/src/app/features/shared/no-access.page.ts create mode 100644 apps/web/src/app/layout/main-layout.css create mode 100644 apps/web/src/app/layout/main-layout.html create mode 100644 apps/web/src/app/layout/main-layout.ts create mode 100644 libs/sap-types/src/menu.spec.ts create mode 100644 libs/sap-types/src/menu.ts rename tools/{Cotizador Aberio · Oferta de ventas.html => oferta_venta.html} (100%) diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 097f5ec..f191e2f 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -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') diff --git a/apps/api/src/sl/sl-request.spec.ts b/apps/api/src/sl/sl-request.spec.ts index 4512405..5c4096f 100644 --- a/apps/api/src/sl/sl-request.spec.ts +++ b/apps/api/src/sl/sl-request.spec.ts @@ -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); diff --git a/apps/api/test/auth.e2e-spec.ts b/apps/api/test/auth.e2e-spec.ts index bbaeffb..9d92e47 100644 --- a/apps/api/test/auth.e2e-spec.ts +++ b/apps/api/test/auth.e2e-spec.ts @@ -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}` }; diff --git a/apps/web/README.md b/apps/web/README.md index f88544a..fc202c9 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -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). diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index 2261369..81f4aed 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -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])), + ], }; diff --git a/apps/web/src/app/app.css b/apps/web/src/app/app.css deleted file mode 100644 index b69369d..0000000 --- a/apps/web/src/app/app.css +++ /dev/null @@ -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; -} diff --git a/apps/web/src/app/app.html b/apps/web/src/app/app.html deleted file mode 100644 index 89a057b..0000000 --- a/apps/web/src/app/app.html +++ /dev/null @@ -1,5 +0,0 @@ -
- Basic ERP SAP Business One - -
- diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index dc39edb..4a93b1a 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -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: '' }, +]; diff --git a/apps/web/src/app/app.spec.ts b/apps/web/src/app/app.spec.ts deleted file mode 100644 index ee04b7f..0000000 --- a/apps/web/src/app/app.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/apps/web/src/app/app.ts b/apps/web/src/app/app.ts index fbf965b..cf2757d 100644 --- a/apps/web/src/app/app.ts +++ b/apps/web/src/app/app.ts @@ -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: '', }) -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); +} diff --git a/apps/web/src/app/core/api.spec.ts b/apps/web/src/app/core/api.spec.ts new file mode 100644 index 0000000..09bef5d --- /dev/null +++ b/apps/web/src/app/core/api.spec.ts @@ -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('/'); + } + }); +}); diff --git a/apps/web/src/app/core/api.ts b/apps/web/src/app/core/api.ts new file mode 100644 index 0000000..310f76c --- /dev/null +++ b/apps/web/src/app/core/api.ts @@ -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 & { 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 : '/'; +} diff --git a/apps/web/src/app/core/auth/auth.interceptor.spec.ts b/apps/web/src/app/core/auth/auth.interceptor.spec.ts new file mode 100644 index 0000000..9d99c60 --- /dev/null +++ b/apps/web/src/app/core/auth/auth.interceptor.spec.ts @@ -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; + + 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(); + }); +}); diff --git a/apps/web/src/app/core/auth/auth.interceptor.ts b/apps/web/src/app/core/auth/auth.interceptor.ts new file mode 100644 index 0000000..7c94878 --- /dev/null +++ b/apps/web/src/app/core/auth/auth.interceptor.ts @@ -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); + }), + ); +}; diff --git a/apps/web/src/app/core/auth/auth.service.ts b/apps/web/src/app/core/auth/auth.service.ts new file mode 100644 index 0000000..5271d50 --- /dev/null +++ b/apps/web/src/app/core/auth/auth.service.ts @@ -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 { + const res = await firstValueFrom(this.http.post(`${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 { + 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(); + } +} diff --git a/apps/web/src/app/core/auth/guards.spec.ts b/apps/web/src/app/core/auth/guards.spec.ts new file mode 100644 index 0000000..e29e51e --- /dev/null +++ b/apps/web/src/app/core/auth/guards.spec.ts @@ -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 = (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; + 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; + ctrl.expectOne('/api/user/menu').flush({}, { status: 500, statusText: 'x' }); + expect((await result) instanceof UrlTree).toBe(true); + expect(TestBed.inject(MenuService).failed()).toBe(true); + }); +}); diff --git a/apps/web/src/app/core/auth/guards.ts b/apps/web/src/app/core/auth/guards.ts new file mode 100644 index 0000000..22234a2 --- /dev/null +++ b/apps/web/src/app/core/auth/guards.ts @@ -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']); +}; diff --git a/apps/web/src/app/core/auth/session.store.spec.ts b/apps/web/src/app/core/auth/session.store.spec.ts new file mode 100644 index 0000000..68422f8 --- /dev/null +++ b/apps/web/src/app/core/auth/session.store.spec.ts @@ -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); + }); +}); diff --git a/apps/web/src/app/core/auth/session.store.ts b/apps/web/src/app/core/auth/session.store.ts new file mode 100644 index 0000000..3d09ddd --- /dev/null +++ b/apps/web/src/app/core/auth/session.store.ts @@ -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(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; + } +} diff --git a/apps/web/src/app/core/menu/menu.service.spec.ts b/apps/web/src/app/core/menu/menu.service.spec.ts new file mode 100644 index 0000000..9d70b6c --- /dev/null +++ b/apps/web/src/app/core/menu/menu.service.spec.ts @@ -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); + }); +}); diff --git a/apps/web/src/app/core/menu/menu.service.ts b/apps/web/src/app/core/menu/menu.service.ts new file mode 100644 index 0000000..9f8a0a0 --- /dev/null +++ b/apps/web/src/app/core/menu/menu.service.ts @@ -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; + + readonly allowedPaths = signal(null); + readonly failed = signal(false); + readonly modules = computed(() => { + const allowed = this.allowedPaths(); + return allowed ? filterMenu(MENU, allowed) : []; + }); + + ensureLoaded(): Promise { + if (this.allowedPaths()) return Promise.resolve(); + this.loading ??= firstValueFrom(this.http.get(`${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); + } +} diff --git a/apps/web/src/app/features/home/home.page.ts b/apps/web/src/app/features/home/home.page.ts new file mode 100644 index 0000000..b46970b --- /dev/null +++ b/apps/web/src/app/features/home/home.page.ts @@ -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: ` +

Bienvenido, {{ session.user()?.firstName }}

+

{{ session.company() }}

+ @for (m of menu.modules(); track m.id) { +
+

{{ m.label }}

+ +
+ } + `, + 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); +} diff --git a/apps/web/src/app/features/login/login.page.css b/apps/web/src/app/features/login/login.page.css new file mode 100644 index 0000000..ef0330e --- /dev/null +++ b/apps/web/src/app/features/login/login.page.css @@ -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; + } +} diff --git a/apps/web/src/app/features/login/login.page.html b/apps/web/src/app/features/login/login.page.html new file mode 100644 index 0000000..d0c002b --- /dev/null +++ b/apps/web/src/app/features/login/login.page.html @@ -0,0 +1,44 @@ + diff --git a/apps/web/src/app/features/login/login.page.spec.ts b/apps/web/src/app/features/login/login.page.spec.ts new file mode 100644 index 0000000..534edbd --- /dev/null +++ b/apps/web/src/app/features/login/login.page.spec.ts @@ -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; + let el: HTMLElement; + let ctrl: HttpTestingController; + let navigateByUrl: ReturnType; + + const type = (selector: string, value: string) => { + const input = el.querySelector(selector)!; + input.value = value; + input.dispatchEvent(new Event('input')); + }; + const submit = () => el.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true })); + const button = () => el.querySelector('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('#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(); + }); +}); diff --git a/apps/web/src/app/features/login/login.page.ts b/apps/web/src/app/features/login/login.page.ts new file mode 100644 index 0000000..37b54e5 --- /dev/null +++ b/apps/web/src/app/features/login/login.page.ts @@ -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 { + 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); + } + } +} diff --git a/apps/web/src/app/features/shared/coming-soon.page.ts b/apps/web/src/app/features/shared/coming-soon.page.ts new file mode 100644 index 0000000..639b5c5 --- /dev/null +++ b/apps/web/src/app/features/shared/coming-soon.page.ts @@ -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: ` +

{{ title }}

+

Esta pantalla aún no está disponible.

+ `, + 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; +} diff --git a/apps/web/src/app/features/shared/no-access.page.ts b/apps/web/src/app/features/shared/no-access.page.ts new file mode 100644 index 0000000..35c3814 --- /dev/null +++ b/apps/web/src/app/features/shared/no-access.page.ts @@ -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: ` +

Sin acceso

+

No tiene permisos para esta pantalla. Volver al inicio

+ `, + 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 {} diff --git a/apps/web/src/app/layout/main-layout.css b/apps/web/src/app/layout/main-layout.css new file mode 100644 index 0000000..c61fec3 --- /dev/null +++ b/apps/web/src/app/layout/main-layout.css @@ -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; + } +} diff --git a/apps/web/src/app/layout/main-layout.html b/apps/web/src/app/layout/main-layout.html new file mode 100644 index 0000000..85b2ef7 --- /dev/null +++ b/apps/web/src/app/layout/main-layout.html @@ -0,0 +1,46 @@ +
+
+ + Basic ERP {{ session.company() }} + + + {{ session.fullName() }} + +
+ +
+ + +
+ +
+
+
diff --git a/apps/web/src/app/layout/main-layout.ts b/apps/web/src/app/layout/main-layout.ts new file mode 100644 index 0000000..261b600 --- /dev/null +++ b/apps/web/src/app/layout/main-layout.ts @@ -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>(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(); + } +} diff --git a/libs/sap-types/src/auth.ts b/libs/sap-types/src/auth.ts index 82c1d34..2663255 100644 --- a/libs/sap-types/src/auth.ts +++ b/libs/sap-types/src/auth.ts @@ -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`. */ diff --git a/libs/sap-types/src/index.ts b/libs/sap-types/src/index.ts index 143e8c1..27ffb98 100644 --- a/libs/sap-types/src/index.ts +++ b/libs/sap-types/src/index.ts @@ -1,3 +1,4 @@ export * from './auth.js'; export * from './permissions.js'; export * from './sl.js'; +export * from './menu.js'; diff --git a/libs/sap-types/src/menu.spec.ts b/libs/sap-types/src/menu.spec.ts new file mode 100644 index 0000000..f69ccef --- /dev/null +++ b/libs/sap-types/src/menu.spec.ts @@ -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']); + }); +}); diff --git a/libs/sap-types/src/menu.ts b/libs/sap-types/src/menu.ts new file mode 100644 index 0000000..b50b6af --- /dev/null +++ b/libs/sap-types/src/menu.ts @@ -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)); diff --git a/tools/Cotizador Aberio · Oferta de ventas.html b/tools/oferta_venta.html similarity index 100% rename from tools/Cotizador Aberio · Oferta de ventas.html rename to tools/oferta_venta.html