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

@@ -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 {}