feat(web): login, sesión, interceptor, guards y layout con menú por permisos
- Login contra POST /api/auth/login; SessionStore en localStorage con el timeout deslizante que informa el servidor (sessionTimeoutMinutes). - Interceptor: Bearer en /api, renueva actividad y, ante 401, cierra la sesión y vuelve al login recordando la ruta (solo rutas internas). - Guards: sesión, invitado y permiso por pantalla (data.path). Corrige que inject() no puede usarse tras un await en permissionGuard. - Layout con barra superior (empresa, usuario, tema, salir) y menú lateral filtrado por GET /api/user/menu; plegable y con botón en pantallas angostas. - Menú único en libs/sap-types (MENU, filterMenu); el backend verifica por test que las rutas de permiso de sus recursos existan en él. - Una ruta por pantalla del menú; las no construidas muestran "no disponible". - Verificado en Chrome headless contra el backend y SAP reales: redirección sin sesión, menú de 20 pantallas, navegación, plegado, tema oscuro y persistencia, vista móvil, salir, token inválido y credenciales malas. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f1156929c7
commit
4c8c499a72
36 files changed
+1291
-52
No files matched your search
@@ -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 {}
|
||||
Reference in new issue
Block a user