feat(web): pantalla de permisos de menú (/admin/permisos)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d900a96740
commit
4d4df17d07
11 files changed
+496
-5
No files matched your search
@@ -56,9 +56,9 @@ Cada menú abre primero una **lista de los últimos documentos** (barra de filtr
|
||||
Hecho (commits en `main`): monorepo · API (config, capa SAP con sesión compartida y cola, login, permisos SQLite, proxy `/api/sl`) · web (temas, login, layout con menú por permisos, `sbo-value-help`, campos y filas compuestas, listas con filtros, ventana/toolbar/pestañas/menús, grilla de líneas, `/dev/componentes`).
|
||||
|
||||
Pendiente, en orden:
|
||||
1. **Paso 9 — formulario de documentos** en `nuevo` / `:docEntry`: cargar el documento, **Crear** / **Actualizar** (control de concurrencia con el `odata.etag` de SAP vía `If-Match`), navegación primero/anterior/siguiente/último, socio de negocio, totales con las tasas de `SalesTaxCodes`, solo lectura si está cerrado/cancelado, "Copiar de/a". Empezar por Órdenes de venta y replicar. Quitar campos vacíos al guardar; `B1S-ReplaceCollectionsOnPatch` para las líneas. Pendiente decidir/ pedir autorización para probar escrituras reales.
|
||||
1. **Paso 9 — formulario de documentos:** hecho para ventas y compras (`document-form.page`, `document-model.ts`: cargar, crear, actualizar con `If-Match`/412, navegación, totales con `SalesTaxCodes`/`PurchaseTaxCodes`, solo lectura), probado solo con SAP simulado y **sin verlo aún en el navegador**. Falta: probar escrituras reales (pedir autorización), "Copiar de/a", botón PDF, fila del flete, toolbar de ancho completo, formularios de inventario y solicitudes de compra.
|
||||
2. Pantallas de maestros (`Items`, `BusinessPartners`: Clientes/Proveedores, Artículos), hoy "no disponible".
|
||||
3. Pantalla web de administración de permisos (el API ya existe: `/api/permissions`).
|
||||
3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`).
|
||||
4. Auditoría (`audit_log` en SQLite; hoy solo líneas `AUDIT` en el log) y `nestjs-cls` para el usuario en los logs.
|
||||
5. Migrar reportes/PDF vía API Gateway (módulo y cola aparte; no registrar credenciales de los `tools/` del mini-erp, traen contraseñas en claro).
|
||||
6. Revisar las vulnerabilidades de `npm audit` y definir una política para `U_WEB_PASS` (texto plano, claves cortas) antes de producción.
|
||||
|
||||
@@ -135,6 +135,10 @@ Lo usan las pantallas con `partner` en su `DocumentScreen` (ventas y compras); e
|
||||
- **Navegación** primero / anterior / siguiente / último por `DocEntry`.
|
||||
- La lógica sin Angular está en `document-model.ts` (totales, payload, validación, navegación) y el acceso HTTP en `core/sl/sl-document.ts`.
|
||||
|
||||
### Permisos de menú (`/admin/permisos`)
|
||||
|
||||
Pantalla del módulo "Administración", solo para quien tiene acceso total (`*`; el API de permisos exige ser administrador). Lista los usuarios web con permisos y, al elegir uno, muestra los módulos y pantallas con casillas (el módulo completo se guarda como `/modulo/*`, las pantallas sueltas como rutas). "Acceso total" guarda `*`; "Administración" no tiene casilla: solo llega con acceso total. Los patrones que no corresponden a ninguna pantalla actual se conservan. Un usuario nuevo se agrega por su `U_WEB_USER` y no existe hasta pulsar Guardar. El servidor impide quitar al último administrador. La conversión casillas ↔ patrones está en `features/admin/permissions-model.ts`.
|
||||
|
||||
### `sbo-list-view` y `sbo-filter-bar`
|
||||
|
||||
Lista con **barra de filtros** y **búsqueda por columna**, igual que el mini-erp:
|
||||
|
||||
@@ -43,10 +43,20 @@ const devRoutes: Routes = [
|
||||
},
|
||||
];
|
||||
|
||||
/** Administración: el permiso de menú `/admin/permisos` solo lo da el acceso total (`*`). */
|
||||
const adminRoutes: Routes = [
|
||||
{
|
||||
path: 'admin/permisos',
|
||||
canActivate: [permissionGuard],
|
||||
data: { path: '/admin/permisos', title: 'Permisos de menú' },
|
||||
loadComponent: () => import('./features/admin/permissions.page').then((m) => m.PermissionsPage),
|
||||
},
|
||||
];
|
||||
|
||||
/** Resto de pantallas del menú (maestros, etc.): aún sin construir. */
|
||||
const documentPaths = new Set(DOCUMENT_SCREENS.map((s) => s.path));
|
||||
const builtPaths = new Set([...DOCUMENT_SCREENS.map((s) => s.path), '/admin/permisos']);
|
||||
const pendingRoutes: Routes = MENU.flatMap((m) => m.items)
|
||||
.filter((item) => !documentPaths.has(item.path))
|
||||
.filter((item) => !builtPaths.has(item.path))
|
||||
.map(
|
||||
(item): Route => ({
|
||||
path: item.path.slice(1),
|
||||
@@ -74,6 +84,7 @@ export const routes: Routes = [
|
||||
},
|
||||
...devRoutes,
|
||||
...documentRoutes,
|
||||
...adminRoutes,
|
||||
...pendingRoutes,
|
||||
],
|
||||
},
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('MenuService', () => {
|
||||
const p = menu.ensureLoaded();
|
||||
ctrl.expectOne('/api/user/menu').flush({ allowedPaths: ['*'] });
|
||||
await p;
|
||||
expect(menu.modules()).toHaveLength(4);
|
||||
expect(menu.modules().map((m) => m.id)).toEqual(['ventas', 'compras', 'inventario', 'socios', 'admin']);
|
||||
});
|
||||
|
||||
it('si falla marca el error y permite reintentar', async () => {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { MenuModule } from '@basic-erp/sap-types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { samePatterns, toPatterns, toSelection, toggleModule, togglePath } from './permissions-model';
|
||||
|
||||
const menu: MenuModule[] = [
|
||||
{ id: 'ventas', label: 'Ventas', items: [{ path: '/ventas/a', label: 'A' }, { path: '/ventas/b', label: 'B' }] },
|
||||
{ id: 'socios', label: 'Socios', items: [{ path: '/socios/c', label: 'C' }] },
|
||||
{ id: 'admin', label: 'Administración', items: [{ path: '/admin/permisos', label: 'Permisos' }] },
|
||||
];
|
||||
|
||||
describe('toSelection', () => {
|
||||
it('un patrón de módulo marca todas sus pantallas', () => {
|
||||
const s = toSelection(['/ventas/*'], menu);
|
||||
expect([...s.paths]).toEqual(['/ventas/a', '/ventas/b']);
|
||||
expect(s.all).toBe(false);
|
||||
});
|
||||
|
||||
it('"*" es acceso total', () => {
|
||||
const s = toSelection(['*'], menu);
|
||||
expect(s.all).toBe(true);
|
||||
expect(s.paths.size).toBe(0);
|
||||
});
|
||||
|
||||
it('conserva los patrones que no corresponden a ninguna pantalla', () => {
|
||||
expect(toSelection(['/ventas/a', '/futuro/*'], menu).extras).toEqual(['/futuro/*']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toPatterns', () => {
|
||||
it('módulo completo → /modulo/*; incompleto → rutas sueltas', () => {
|
||||
let s = toSelection([], menu);
|
||||
s = toggleModule(s, menu[0], true);
|
||||
s = togglePath(s, '/socios/c', true);
|
||||
expect(toPatterns(s, menu)).toEqual(['/ventas/*', '/socios/*']);
|
||||
s = togglePath(s, '/ventas/b', false);
|
||||
expect(toPatterns(s, menu)).toEqual(['/ventas/a', '/socios/*']);
|
||||
});
|
||||
|
||||
it('acceso total ignora lo demás', () => {
|
||||
expect(toPatterns({ all: true, paths: new Set(['/ventas/a']), extras: ['/x/*'] }, menu)).toEqual(['*']);
|
||||
});
|
||||
|
||||
it('ida y vuelta conserva los extras y no otorga Administración', () => {
|
||||
const patterns = ['/ventas/*', '/futuro/*'];
|
||||
expect(toPatterns(toSelection(patterns, menu), menu)).toEqual(patterns);
|
||||
});
|
||||
|
||||
it('sin nada marcado no hay patrones', () => {
|
||||
expect(toPatterns(toSelection([], menu), menu)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('samePatterns', () => {
|
||||
it('compara sin importar el orden', () => {
|
||||
expect(samePatterns(['a', 'b'], ['b', 'a'])).toBe(true);
|
||||
expect(samePatterns(['a'], ['a', 'b'])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { type MenuModule, isPathAllowed } from '@basic-erp/sap-types';
|
||||
|
||||
/** Lo que el administrador marca en pantalla para un usuario. */
|
||||
export interface Selection {
|
||||
/** Acceso total (`*`). */
|
||||
all: boolean;
|
||||
/** Rutas de menú marcadas (incluye las cubiertas por un patrón `/modulo/*`). */
|
||||
paths: ReadonlySet<string>;
|
||||
/** Patrones que no corresponden a ninguna pantalla actual (ej. de una versión futura): se conservan tal cual. */
|
||||
extras: readonly string[];
|
||||
}
|
||||
|
||||
/** Módulos que se otorgan con casillas; "Administración" solo llega con acceso total. */
|
||||
export const grantableModules = (menu: readonly MenuModule[]): readonly MenuModule[] => menu.filter((m) => m.id !== 'admin');
|
||||
|
||||
export function toSelection(patterns: readonly string[], menu: readonly MenuModule[]): Selection {
|
||||
const all = patterns.includes('*');
|
||||
const known = menu.flatMap((m) => m.items.map((i) => i.path));
|
||||
const paths = new Set(known.filter((p) => !all && isPathAllowed(patterns, p)));
|
||||
const extras = patterns.filter((p) => p !== '*' && !known.some((k) => isPathAllowed([p], k)));
|
||||
return { all, paths, extras };
|
||||
}
|
||||
|
||||
/**
|
||||
* Patrones a guardar: `*` si es acceso total; si no, `/modulo/*` cuando están todas las pantallas
|
||||
* del módulo y rutas sueltas en otro caso, más los patrones desconocidos conservados.
|
||||
*/
|
||||
export function toPatterns(sel: Selection, menu: readonly MenuModule[]): string[] {
|
||||
if (sel.all) return ['*'];
|
||||
const out: string[] = [];
|
||||
for (const m of grantableModules(menu)) {
|
||||
const checked = m.items.filter((i) => sel.paths.has(i.path));
|
||||
if (checked.length === m.items.length && m.items.length > 0) out.push(`/${m.id}/*`);
|
||||
else out.push(...checked.map((i) => i.path));
|
||||
}
|
||||
return [...out, ...sel.extras];
|
||||
}
|
||||
|
||||
export function togglePath(sel: Selection, path: string, on: boolean): Selection {
|
||||
const paths = new Set(sel.paths);
|
||||
if (on) paths.add(path);
|
||||
else paths.delete(path);
|
||||
return { ...sel, paths };
|
||||
}
|
||||
|
||||
export function toggleModule(sel: Selection, module: MenuModule, on: boolean): Selection {
|
||||
const paths = new Set(sel.paths);
|
||||
for (const i of module.items) {
|
||||
if (on) paths.add(i.path);
|
||||
else paths.delete(i.path);
|
||||
}
|
||||
return { ...sel, paths };
|
||||
}
|
||||
|
||||
export const samePatterns = (a: readonly string[], b: readonly string[]): boolean =>
|
||||
a.length === b.length && [...a].sort().every((p, i) => p === [...b].sort()[i]);
|
||||
@@ -0,0 +1,28 @@
|
||||
:host { display: block; }
|
||||
h1 { margin: 0 0 4px; font-size: 18px; font-weight: 600; }
|
||||
h2 { margin: 0 0 8px; font-size: 15px; font-weight: 600; }
|
||||
.hint, .quiet { margin: 0 0 12px; color: var(--quiet); }
|
||||
.cols { display: grid; grid-template-columns: minmax(200px, 260px) minmax(0, 1fr); gap: 16px; align-items: start; }
|
||||
@media (max-width: 700px) { .cols { grid-template-columns: 1fr; } }
|
||||
.users, .detail { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius-panel); padding: 10px 12px; }
|
||||
.add { display: flex; gap: 6px; margin-bottom: 8px; }
|
||||
.add input { flex: 1; min-width: 0; }
|
||||
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
|
||||
li > button {
|
||||
width: 100%; text-align: left; padding: 4px 8px; background: transparent; color: var(--ink);
|
||||
border: 1px solid transparent; border-radius: var(--radius); cursor: pointer;
|
||||
}
|
||||
li > button:hover { background: var(--btn-h); }
|
||||
li > button.on { background: var(--btn-h); border-color: var(--line); font-weight: 600; }
|
||||
fieldset { margin: 10px 0 0; border: 1px solid var(--line); border-radius: var(--radius); padding: 6px 10px 8px; }
|
||||
legend { padding: 0 4px; font-weight: 600; }
|
||||
.items { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 4px 12px; }
|
||||
label { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
|
||||
.all { margin-bottom: 4px; font-weight: 600; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; }
|
||||
.danger { color: var(--err); }
|
||||
.msg { margin: 10px 0 0; }
|
||||
.msg.ok { color: var(--ok); }
|
||||
.msg.err { color: var(--err); }
|
||||
.msg.warn { color: var(--warn); }
|
||||
.err { color: var(--err); }
|
||||
@@ -0,0 +1,84 @@
|
||||
<h1>Permisos de menú</h1>
|
||||
<p class="hint">Qué pantallas puede abrir cada usuario web. El acceso total incluye esta pantalla. Un usuario sin permisos no ve ningún menú.</p>
|
||||
|
||||
<div class="cols">
|
||||
<section class="users" aria-label="Usuarios">
|
||||
<form class="add" (submit)="$event.preventDefault(); add()">
|
||||
<input
|
||||
class="sbo-in"
|
||||
placeholder="Usuario web (U_WEB_USER)"
|
||||
aria-label="Usuario web a agregar"
|
||||
[value]="newName()"
|
||||
(input)="newName.set($any($event.target).value)"
|
||||
/>
|
||||
<button class="btn" type="submit" [disabled]="!newName().trim()">Agregar</button>
|
||||
</form>
|
||||
@if (loading()) {
|
||||
<p class="quiet">Cargando…</p>
|
||||
} @else if (loadError()) {
|
||||
<p class="err" role="alert">{{ loadError() }}</p>
|
||||
}
|
||||
<ul>
|
||||
@for (n of names(); track n) {
|
||||
<li>
|
||||
<button type="button" [class.on]="n === current()" (click)="pick(n)">{{ n }}</button>
|
||||
</li>
|
||||
} @empty {
|
||||
@if (!loading() && !loadError()) {
|
||||
<li class="quiet">Aún no hay usuarios con permisos.</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="detail" aria-label="Permisos del usuario">
|
||||
@if (selection(); as sel) {
|
||||
<h2>{{ current() }}</h2>
|
||||
<label class="all">
|
||||
<input type="checkbox" [checked]="sel.all" (change)="toggleAll($any($event.target).checked)" />
|
||||
Acceso total (administrador)
|
||||
</label>
|
||||
|
||||
@if (!sel.all) {
|
||||
@for (m of modules; track m.id) {
|
||||
<fieldset>
|
||||
<legend>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="moduleState(m.id) === 'all'"
|
||||
[indeterminate]="moduleState(m.id) === 'some'"
|
||||
(change)="toggleMod(m.id, $any($event.target).checked)"
|
||||
/>
|
||||
{{ m.label }}
|
||||
</label>
|
||||
</legend>
|
||||
<div class="items">
|
||||
@for (i of m.items; track i.path) {
|
||||
<label>
|
||||
<input type="checkbox" [checked]="has(i.path)" (change)="toggleItem(i.path, $any($event.target).checked)" />
|
||||
{{ i.label }}
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
}
|
||||
@if (sel.extras.length) {
|
||||
<p class="quiet">Otros patrones conservados: {{ sel.extras.join(', ') }}</p>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn main" type="button" [disabled]="saving() || !dirty()" (click)="save()">Guardar</button>
|
||||
<button class="btn" type="button" [disabled]="saving() || !dirty()" (click)="discard()">Descartar cambios</button>
|
||||
<button class="btn danger" type="button" [disabled]="saving()" (click)="remove()">Quitar todos los permisos</button>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="quiet">Elija un usuario de la lista o agregue uno.</p>
|
||||
}
|
||||
|
||||
@if (message(); as m) {
|
||||
<p class="msg" [class]="'msg ' + m.kind" role="status">{{ m.text }}</p>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,96 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { type ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { SessionStore } from '../../core/auth/session.store';
|
||||
import { PermissionsPage } from './permissions.page';
|
||||
|
||||
describe('PermissionsPage', () => {
|
||||
let fixture: ComponentFixture<PermissionsPage>;
|
||||
let ctrl: HttpTestingController;
|
||||
const el = () => fixture.nativeElement as HTMLElement;
|
||||
const tick = async () => {
|
||||
fixture.detectChanges();
|
||||
await new Promise((r) => setTimeout(r));
|
||||
fixture.detectChanges();
|
||||
};
|
||||
const click = async (selector: string, index = 0) => {
|
||||
(el().querySelectorAll(selector)[index] as HTMLElement).click();
|
||||
await tick();
|
||||
};
|
||||
const byText = (selector: string, text: string) =>
|
||||
[...el().querySelectorAll<HTMLElement>(selector)].find((e) => e.textContent?.trim().startsWith(text))!;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PermissionsPage],
|
||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||
}).compileComponents();
|
||||
TestBed.inject(SessionStore); // sin sesión: solo necesitamos que exista
|
||||
ctrl = TestBed.inject(HttpTestingController);
|
||||
fixture = TestBed.createComponent(PermissionsPage);
|
||||
await tick();
|
||||
ctrl.expectOne('/api/permissions').flush([
|
||||
{ webUser: 'ana', paths: ['/ventas/*', '/socios/clientes'] },
|
||||
{ webUser: 'luis', paths: ['*'] },
|
||||
]);
|
||||
await tick();
|
||||
});
|
||||
|
||||
it('lista los usuarios con permisos', () => {
|
||||
expect([...el().querySelectorAll('.users li button')].map((b) => b.textContent?.trim())).toEqual(['ana', 'luis']);
|
||||
});
|
||||
|
||||
it('al elegir un usuario marca sus pantallas (el módulo completo por /ventas/*)', async () => {
|
||||
await click('.users li button', 0);
|
||||
const checked = [...el().querySelectorAll<HTMLInputElement>('fieldset .items input')].filter((i) => i.checked);
|
||||
expect(checked.length).toBe(7); // 6 de ventas + Clientes
|
||||
expect(el().querySelector<HTMLButtonElement>('.actions .main')!.disabled).toBe(true); // sin cambios
|
||||
});
|
||||
|
||||
it('el acceso total oculta las casillas y no ofrece Administración como casilla', async () => {
|
||||
await click('.users li button', 1);
|
||||
expect(el().querySelector('fieldset')).toBeNull();
|
||||
await click('.users li button', 0);
|
||||
expect(byText('legend label', 'Administración')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('guarda los patrones resultantes con PUT', async () => {
|
||||
await click('.users li button', 0);
|
||||
const inv = byText('legend label', 'Inventario').querySelector('input')!;
|
||||
inv.click();
|
||||
await tick();
|
||||
expect(el().querySelector<HTMLButtonElement>('.actions .main')!.disabled).toBe(false);
|
||||
await click('.actions .main');
|
||||
const req = ctrl.expectOne((r) => r.method === 'PUT');
|
||||
expect(req.request.url).toBe('/api/permissions/ana');
|
||||
expect(req.request.body).toEqual({ paths: ['/ventas/*', '/inventario/*', '/socios/clientes'] });
|
||||
req.flush({ webUser: 'ana', paths: [] });
|
||||
await tick();
|
||||
ctrl.expectOne('/api/permissions').flush([{ webUser: 'ana', paths: ['/ventas/*', '/inventario/*', '/socios/clientes'] }]);
|
||||
await tick();
|
||||
expect(el().querySelector('.msg')?.textContent).toContain('Permisos de ana guardados');
|
||||
});
|
||||
|
||||
it('muestra el error del servidor, por ejemplo al quitar el último administrador', async () => {
|
||||
await click('.users li button', 1);
|
||||
await click('.actions .danger');
|
||||
ctrl.expectOne((r) => r.method === 'DELETE' && r.url === '/api/permissions/luis').flush(
|
||||
{ message: 'No se puede quitar al último administrador' },
|
||||
{ status: 409, statusText: 'Conflict' },
|
||||
);
|
||||
await tick();
|
||||
expect(el().querySelector('.msg.err')?.textContent).toContain('último administrador');
|
||||
});
|
||||
|
||||
it('agregar un usuario lo selecciona sin guardar hasta pulsar Guardar', async () => {
|
||||
const input = el().querySelector<HTMLInputElement>('.add input')!;
|
||||
input.value = 'marta';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
await tick();
|
||||
await click('.add .btn');
|
||||
expect([...el().querySelectorAll('.users li button')].map((b) => b.textContent?.trim())).toContain('marta');
|
||||
expect(el().querySelector('.detail h2')?.textContent).toBe('marta');
|
||||
ctrl.expectNone((r) => r.method === 'PUT');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { HttpClient, httpResource } from '@angular/common/http';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { MENU, type UserPermissions } from '@basic-erp/sap-types';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { API_URL, apiErrorMessage } from '../../core/api';
|
||||
import { SessionStore } from '../../core/auth/session.store';
|
||||
import { MenuService } from '../../core/menu/menu.service';
|
||||
import {
|
||||
type Selection,
|
||||
grantableModules,
|
||||
samePatterns,
|
||||
toPatterns,
|
||||
toSelection,
|
||||
toggleModule,
|
||||
togglePath,
|
||||
} from './permissions-model';
|
||||
|
||||
/**
|
||||
* Administración de permisos de menú por usuario web (solo acceso total): lista de usuarios con
|
||||
* permisos y, al elegir uno, las pantallas que puede abrir. Los cambios se guardan juntos.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'sbo-permissions-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './permissions.page.html',
|
||||
styleUrl: './permissions.page.css',
|
||||
})
|
||||
export class PermissionsPage {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly session = inject(SessionStore);
|
||||
private readonly menuService = inject(MenuService);
|
||||
|
||||
protected readonly modules = grantableModules(MENU);
|
||||
private readonly users = httpResource<UserPermissions[]>(() => `${API_URL}/permissions`);
|
||||
|
||||
/** Usuarios con permisos guardados, más los recién agregados que aún no se guardaron. */
|
||||
private readonly added = signal<readonly string[]>([]);
|
||||
protected readonly names = computed(() => {
|
||||
const saved = this.users.hasValue() ? this.users.value().map((u) => u.webUser) : [];
|
||||
return [...saved, ...this.added().filter((n) => !saved.includes(n))].sort((a, b) => a.localeCompare(b));
|
||||
});
|
||||
protected readonly loading = computed(() => this.users.isLoading());
|
||||
protected readonly loadError = computed(() => (this.users.error() ? apiErrorMessage(this.users.error()) : ''));
|
||||
|
||||
protected readonly current = signal<string | null>(null);
|
||||
protected readonly selection = signal<Selection | null>(null);
|
||||
protected readonly newName = signal('');
|
||||
protected readonly saving = signal(false);
|
||||
protected readonly message = signal<{ kind: 'ok' | 'err' | 'warn'; text: string } | null>(null);
|
||||
|
||||
private savedPatterns(name: string): string[] {
|
||||
return (this.users.hasValue() ? this.users.value() : []).find((u) => u.webUser === name)?.paths ?? [];
|
||||
}
|
||||
|
||||
protected readonly dirty = computed(() => {
|
||||
const name = this.current();
|
||||
const sel = this.selection();
|
||||
return !!name && !!sel && !samePatterns(toPatterns(sel, MENU), this.savedPatterns(name));
|
||||
});
|
||||
|
||||
protected pick(name: string): void {
|
||||
this.current.set(name);
|
||||
this.selection.set(toSelection(this.savedPatterns(name), MENU));
|
||||
this.message.set(null);
|
||||
}
|
||||
|
||||
protected add(): void {
|
||||
const name = this.newName().trim();
|
||||
if (!name) return;
|
||||
if (!this.names().some((n) => n.toLowerCase() === name.toLowerCase())) this.added.update((a) => [...a, name]);
|
||||
this.newName.set('');
|
||||
this.pick(this.names().find((n) => n.toLowerCase() === name.toLowerCase()) ?? name);
|
||||
}
|
||||
|
||||
protected toggleAll(on: boolean): void {
|
||||
this.selection.update((s) => s && { ...s, all: on });
|
||||
}
|
||||
|
||||
protected toggleItem(path: string, on: boolean): void {
|
||||
this.selection.update((s) => s && togglePath(s, path, on));
|
||||
}
|
||||
|
||||
protected toggleMod(id: string, on: boolean): void {
|
||||
const mod = this.modules.find((m) => m.id === id);
|
||||
if (mod) this.selection.update((s) => s && toggleModule(s, mod, on));
|
||||
}
|
||||
|
||||
protected moduleState(id: string): 'all' | 'some' | 'none' {
|
||||
const mod = this.modules.find((m) => m.id === id);
|
||||
const sel = this.selection();
|
||||
if (!mod || !sel) return 'none';
|
||||
const n = mod.items.filter((i) => sel.paths.has(i.path)).length;
|
||||
return n === 0 ? 'none' : n === mod.items.length ? 'all' : 'some';
|
||||
}
|
||||
|
||||
protected has(path: string): boolean {
|
||||
return this.selection()?.paths.has(path) ?? false;
|
||||
}
|
||||
|
||||
protected discard(): void {
|
||||
const name = this.current();
|
||||
if (name) this.pick(name);
|
||||
}
|
||||
|
||||
protected async save(): Promise<void> {
|
||||
const name = this.current();
|
||||
const sel = this.selection();
|
||||
if (!name || !sel || this.saving()) return;
|
||||
this.saving.set(true);
|
||||
try {
|
||||
await firstValueFrom(this.http.put(`${API_URL}/permissions/${encodeURIComponent(name)}`, { paths: toPatterns(sel, MENU) }));
|
||||
this.afterChange(name);
|
||||
this.message.set({ kind: 'ok', text: `Permisos de ${name} guardados.` });
|
||||
} catch (e) {
|
||||
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
|
||||
} finally {
|
||||
this.saving.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async remove(): Promise<void> {
|
||||
const name = this.current();
|
||||
if (!name || this.saving()) return;
|
||||
this.saving.set(true);
|
||||
try {
|
||||
await firstValueFrom(this.http.delete(`${API_URL}/permissions/${encodeURIComponent(name)}`));
|
||||
this.added.update((a) => a.filter((n) => n !== name));
|
||||
this.current.set(null);
|
||||
this.selection.set(null);
|
||||
this.afterChange(null);
|
||||
this.message.set({ kind: 'ok', text: `Se quitaron todos los permisos de ${name}.` });
|
||||
} catch (e) {
|
||||
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
|
||||
} finally {
|
||||
this.saving.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Recarga la lista; si cambió el menú del propio administrador, también el menú lateral. */
|
||||
private afterChange(name: string | null): void {
|
||||
this.users.reload();
|
||||
this.added.update((a) => a.filter((n) => n !== name));
|
||||
if (name === null || name === this.session.user()?.webUser) {
|
||||
this.menuService.reset();
|
||||
void this.menuService.ensureLoaded();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,12 @@ export const MENU: readonly MenuModule[] = [
|
||||
{ path: '/socios/proveedores', label: 'Proveedores' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'admin',
|
||||
label: 'Administración',
|
||||
// Solo acceso total ("*"): el API de permisos exige ser administrador.
|
||||
items: [{ path: '/admin/permisos', label: 'Permisos de menú' }],
|
||||
},
|
||||
];
|
||||
|
||||
/** Deja solo lo que el usuario puede ver; los módulos sin pantallas visibles se omiten. */
|
||||
|
||||
Reference in new issue
Block a user