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
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}` };
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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])),
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
<header class="shell">
|
||||
<span class="brand">Basic ERP <small>SAP Business One</small></span>
|
||||
<sbo-theme-switcher />
|
||||
</header>
|
||||
<router-outlet />
|
||||
@@ -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: '' },
|
||||
];
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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('/');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ export interface LoginResponse {
|
||||
token: string;
|
||||
user: AuthUser;
|
||||
company: string;
|
||||
/** Inactividad tras la cual caduca la sesión (el cliente la usa para avisar antes). */
|
||||
sessionTimeoutMinutes: number;
|
||||
}
|
||||
|
||||
/** Respuesta de `GET /api/auth/me`. */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './auth.js';
|
||||
export * from './permissions.js';
|
||||
export * from './sl.js';
|
||||
export * from './menu.js';
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MENU, filterMenu, menuPaths } from './menu.js';
|
||||
|
||||
describe('MENU', () => {
|
||||
it('no repite rutas y todas son absolutas', () => {
|
||||
const paths = menuPaths();
|
||||
expect(new Set(paths).size).toBe(paths.length);
|
||||
expect(paths.every((p) => /^\/[a-z-]+\/[a-z-]+$/.test(p))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterMenu', () => {
|
||||
const ids = (allowed: string[]) => filterMenu(MENU, allowed).map((m) => m.id);
|
||||
|
||||
it('"*" ve todo', () => expect(filterMenu(MENU, ['*'])).toHaveLength(MENU.length));
|
||||
it('sin permisos no ve nada', () => expect(filterMenu(MENU, [])).toEqual([]));
|
||||
|
||||
it('un módulo completo y una pantalla suelta de otro', () => {
|
||||
const result = filterMenu(MENU, ['/ventas/*', '/inventario/articulos']);
|
||||
expect(result.map((m) => m.id)).toEqual(['ventas', 'inventario']);
|
||||
expect(result[1].items.map((i) => i.path)).toEqual(['/inventario/articulos']);
|
||||
});
|
||||
|
||||
it('omite los módulos sin pantallas permitidas', () => {
|
||||
expect(ids(['/socios/clientes'])).toEqual(['socios']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { isPathAllowed } from './permissions.js';
|
||||
|
||||
export interface MenuItem {
|
||||
/** Ruta de la pantalla; también es lo que se otorga en los permisos de menú. */
|
||||
path: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface MenuModule {
|
||||
id: string;
|
||||
label: string;
|
||||
items: readonly MenuItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Menú de la aplicación: única fuente de rutas. El frontend lo dibuja filtrado
|
||||
* por permisos y el backend (registro del proxy SL) usa las mismas rutas.
|
||||
*/
|
||||
export const MENU: readonly MenuModule[] = [
|
||||
{
|
||||
id: 'ventas',
|
||||
label: 'Ventas',
|
||||
items: [
|
||||
{ path: '/ventas/ofertas', label: 'Ofertas de venta' },
|
||||
{ path: '/ventas/ordenes', label: 'Órdenes de venta' },
|
||||
{ path: '/ventas/entrega', label: 'Entregas' },
|
||||
{ path: '/ventas/facturas', label: 'Facturas de venta' },
|
||||
{ path: '/ventas/devoluciones', label: 'Devoluciones' },
|
||||
{ path: '/ventas/notas-credito', label: 'Notas de crédito' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'compras',
|
||||
label: 'Compras',
|
||||
items: [
|
||||
{ path: '/compras/solicitudes', label: 'Solicitudes de compra' },
|
||||
{ path: '/compras/ofertas', label: 'Ofertas de compra' },
|
||||
{ path: '/compras/ordenes', label: 'Órdenes de compra' },
|
||||
{ path: '/compras/recepcion', label: 'Recepción de compra' },
|
||||
{ path: '/compras/facturas', label: 'Facturas de proveedor' },
|
||||
{ path: '/compras/devoluciones', label: 'Devoluciones de compra' },
|
||||
{ path: '/compras/notas-credito', label: 'Notas de crédito de compra' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'inventario',
|
||||
label: 'Inventario',
|
||||
items: [
|
||||
{ path: '/inventario/articulos', label: 'Artículos' },
|
||||
{ path: '/inventario/entradas', label: 'Entrada de inventario' },
|
||||
{ path: '/inventario/salidas', label: 'Salida de inventario' },
|
||||
{ path: '/inventario/transferencias', label: 'Transferencias de stock' },
|
||||
{ path: '/inventario/solicitudes-traslado', label: 'Solicitudes de traslado' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'socios',
|
||||
label: 'Socios de negocio',
|
||||
items: [
|
||||
{ path: '/socios/clientes', label: 'Clientes' },
|
||||
{ path: '/socios/proveedores', label: 'Proveedores' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Deja solo lo que el usuario puede ver; los módulos sin pantallas visibles se omiten. */
|
||||
export function filterMenu(menu: readonly MenuModule[], allowedPaths: readonly string[]): MenuModule[] {
|
||||
return menu
|
||||
.map((m) => ({ ...m, items: m.items.filter((i) => isPathAllowed(allowedPaths, i.path)) }))
|
||||
.filter((m) => m.items.length > 0);
|
||||
}
|
||||
|
||||
export const menuPaths = (menu: readonly MenuModule[] = MENU): string[] => menu.flatMap((m) => m.items.map((i) => i.path));
|
||||
File renamed without changes.
Reference in new issue
Block a user