From 4d4df17d07475a5af7f162ecc72da5bc91e87286 Mon Sep 17 00:00:00 2001 From: George Date: Tue, 13 Oct 2026 22:17:50 -0500 Subject: [PATCH] =?UTF-8?q?feat(web):=20pantalla=20de=20permisos=20de=20me?= =?UTF-8?q?n=C3=BA=20(/admin/permisos)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 4 +- apps/web/README.md | 4 + apps/web/src/app/app.routes.ts | 15 +- .../src/app/core/menu/menu.service.spec.ts | 2 +- .../features/admin/permissions-model.spec.ts | 58 +++++++ .../app/features/admin/permissions-model.ts | 56 +++++++ .../app/features/admin/permissions.page.css | 28 ++++ .../app/features/admin/permissions.page.html | 84 ++++++++++ .../features/admin/permissions.page.spec.ts | 96 ++++++++++++ .../app/features/admin/permissions.page.ts | 148 ++++++++++++++++++ libs/sap-types/src/menu.ts | 6 + 11 files changed, 496 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/app/features/admin/permissions-model.spec.ts create mode 100644 apps/web/src/app/features/admin/permissions-model.ts create mode 100644 apps/web/src/app/features/admin/permissions.page.css create mode 100644 apps/web/src/app/features/admin/permissions.page.html create mode 100644 apps/web/src/app/features/admin/permissions.page.spec.ts create mode 100644 apps/web/src/app/features/admin/permissions.page.ts diff --git a/CLAUDE.md b/CLAUDE.md index 93d9f14..3f96032 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/apps/web/README.md b/apps/web/README.md index cf34bcd..1e2fb11 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -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: diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index f9f84e8..0399cd0 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -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, ], }, diff --git a/apps/web/src/app/core/menu/menu.service.spec.ts b/apps/web/src/app/core/menu/menu.service.spec.ts index 9d70b6c..96c50d8 100644 --- a/apps/web/src/app/core/menu/menu.service.spec.ts +++ b/apps/web/src/app/core/menu/menu.service.spec.ts @@ -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 () => { diff --git a/apps/web/src/app/features/admin/permissions-model.spec.ts b/apps/web/src/app/features/admin/permissions-model.spec.ts new file mode 100644 index 0000000..d983564 --- /dev/null +++ b/apps/web/src/app/features/admin/permissions-model.spec.ts @@ -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); + }); +}); diff --git a/apps/web/src/app/features/admin/permissions-model.ts b/apps/web/src/app/features/admin/permissions-model.ts new file mode 100644 index 0000000..95a1dd6 --- /dev/null +++ b/apps/web/src/app/features/admin/permissions-model.ts @@ -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; + /** 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]); diff --git a/apps/web/src/app/features/admin/permissions.page.css b/apps/web/src/app/features/admin/permissions.page.css new file mode 100644 index 0000000..50c8f5d --- /dev/null +++ b/apps/web/src/app/features/admin/permissions.page.css @@ -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); } diff --git a/apps/web/src/app/features/admin/permissions.page.html b/apps/web/src/app/features/admin/permissions.page.html new file mode 100644 index 0000000..d3c1dc0 --- /dev/null +++ b/apps/web/src/app/features/admin/permissions.page.html @@ -0,0 +1,84 @@ +

Permisos de menú

+

Qué pantallas puede abrir cada usuario web. El acceso total incluye esta pantalla. Un usuario sin permisos no ve ningún menú.

+ +
+
+
+ + +
+ @if (loading()) { +

Cargando…

+ } @else if (loadError()) { + + } +
    + @for (n of names(); track n) { +
  • + +
  • + } @empty { + @if (!loading() && !loadError()) { +
  • Aún no hay usuarios con permisos.
  • + } + } +
+
+ +
+ @if (selection(); as sel) { +

{{ current() }}

+ + + @if (!sel.all) { + @for (m of modules; track m.id) { +
+ + + +
+ @for (i of m.items; track i.path) { + + } +
+
+ } + @if (sel.extras.length) { +

Otros patrones conservados: {{ sel.extras.join(', ') }}

+ } + } + +
+ + + +
+ } @else { +

Elija un usuario de la lista o agregue uno.

+ } + + @if (message(); as m) { +

{{ m.text }}

+ } +
+
diff --git a/apps/web/src/app/features/admin/permissions.page.spec.ts b/apps/web/src/app/features/admin/permissions.page.spec.ts new file mode 100644 index 0000000..eec681b --- /dev/null +++ b/apps/web/src/app/features/admin/permissions.page.spec.ts @@ -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; + 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(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('fieldset .items input')].filter((i) => i.checked); + expect(checked.length).toBe(7); // 6 de ventas + Clientes + expect(el().querySelector('.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('.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('.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'); + }); +}); diff --git a/apps/web/src/app/features/admin/permissions.page.ts b/apps/web/src/app/features/admin/permissions.page.ts new file mode 100644 index 0000000..ce1536d --- /dev/null +++ b/apps/web/src/app/features/admin/permissions.page.ts @@ -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(() => `${API_URL}/permissions`); + + /** Usuarios con permisos guardados, más los recién agregados que aún no se guardaron. */ + private readonly added = signal([]); + 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(null); + protected readonly selection = signal(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 { + 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 { + 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(); + } + } +} diff --git a/libs/sap-types/src/menu.ts b/libs/sap-types/src/menu.ts index b50b6af..7352a60 100644 --- a/libs/sap-types/src/menu.ts +++ b/libs/sap-types/src/menu.ts @@ -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. */