From 928ed0cdc2eed69d0d71e6c0f8dd8e085cd18f62 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Tue, 26 May 2026 15:54:21 +0200 Subject: [PATCH] feat(admin): add /admin/users/:oid/scopes screen + endpoints (ADR-0026 PR 2b) (#236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary ADR-0026 PR 2b (second half of PR 2). Ships the operator surface for the `@RequireScope` stack — the admin Angular screen at `/admin/users/:oid/scopes` lets an admin list / grant / revoke scopes for an existing portal User. Both write paths emit blocking audit rows per ADR-0013 (`admin.scope_granted` / `admin.scope_revoked`). Closes the ADR-0026 trilogy: | PR | Status | Effect | | --- | --- | --- | | ADR-0026 PR 1 (#232) | merged | Person + User + UserScope schema + provisioner + drift gate Person.source + PrincipalBuilder real UUIDs. | | ADR-0026 PR 2a (#233) | merged | PrismaScopeResolver replaces stub + prisma/seed.ts populates 19 personas. | | **ADR-0026 PR 2b (this)** | proposed | Admin scope-management screen — operator surface. | ## What lands ### BFF (under `apps/portal-bff/src/`) | File | Change | | --- | --- | | `admin/user-scopes.dto.ts` | **New**. `GrantUserScopeDto` (class-validator on kind + value + ISO expiresAt) + response shapes (`UserScopeDto`, `UserScopesPageDto`). | | `admin/user-scopes.service.ts` | **New**. `resolveUserByOid` (404 when no portal User), `list` (`UserScope` rows joined to User+Person), `grant` (validates value against ADR-0027 tables for value-bearing kinds, rejects non-empty for valueless, P2002 → 400), `revoke` (404-protected — won't leak scope-belongs-to-other-user). | | `admin/user-scopes.controller.ts` | **New** REST surface at `/api/admin/users/:oid/scopes` with `@RequireAdmin`. `GET` (no audit, read), `POST` (audit `admin.scope_granted`), `DELETE :scopeId` (audit `admin.scope_revoked`, 204 on success). | | `admin/user-scopes.service.spec.ts` + `admin/user-scopes.controller.spec.ts` | **New** specs — 15 cases across resolve / list / grant (value-bearing + valueless + duplicate) / revoke / audit semantics. | | `admin/admin.module.ts` | Registers `UserScopesController` + `UserScopesService`. | | `audit/audit.types.ts` + `audit/audit.service.ts` | New `AdminScopeGrantedInput` / `AdminScopeRevokedInput` types + `adminScopeGranted` / `adminScopeRevoked` methods on `AuditWriter`. `subject = 'user:'` so an auditor pivots on the target of the change; payload carries the resolved tuple at the moment of the write (the revoke payload survives the row's deletion). | ### SPA (under `apps/portal-admin/src/app/`) | File | Change | | --- | --- | | `app.routes.ts` | New route `/users/:oid/scopes` (lazy-loaded). | | `pages/user-scopes/user-scopes.service.ts` + `.service.spec.ts` | **New** thin HttpClient wrapper (`list` / `grant` / `revoke`). Same shape convention as `AdminUsersService`. | | `pages/user-scopes/user-scopes.ts` + `.html` + `.scss` + `.spec.ts` | **New** page component. Signals-based + OnPush. Lists current scopes in a table with `Revoke` per row; "Grant a new scope" form with kind dropdown + conditional value input + optional `expiresAt`. Friendly error mapping (403 → "no admin access", 404 → "User not found, has this persona ever signed in or been seeded?", server messages forwarded). | | `pages/users/users.html` | New `Manage scopes` link per row (RouterLink to the new page), with `aria-label` carrying the user's displayName. | | `pages/users/users.ts` + `users.spec.ts` | Adds `RouterLink` import + `provideRouter([])` in the spec fixture (was missing — the route addition exposed it). | ## Key choices - **`:oid` in the URL, not `User.id`.** The existing `/admin/users` list (ADR-0020) keys on Entra `oid` and the new screen is its child — linking from the list to the scopes page without an intermediate UUID lookup is the simplest UX. The BFF translates `oid → User.id` internally via `resolveUserByOid`. - **404 carries a hint.** When `resolveUserByOid` misses, the BFF throws `NotFoundException("No portal User found for Entra oid ${oid}.")` and the SPA translates the 403/404 status to friendly French-English text. The hint mentions "has this persona ever signed in or been seeded?" — a common operator confusion (the user-directory list shows oids that signed in but the `User` row only exists post-sign-in OR post-seed). - **Audit before write commit, write after audit success.** Standard ADR-0013 posture. Order in the controller: service call (which writes the DB row) → audit (blocking). If audit fails, the DB row exists but no audit — a known v1 cost for non-rollback flows. The simpler alternative (audit before write) would emit ghost audit rows for failed writes; we picked the cleaner direction. - **Value-bearing validation against ADR-0027 tables, not catalogue.** `etablissement:0330800013` must match an existing `Structure.code`; `delegation:33` must match `Delegation.code`; `region:75` must match `Region.code`. The lookup is a single `findUnique` per kind; rejection raises 400 with the offending value in the message. Valueless kinds (`self` / `siege` / `unrestricted`) reject any non-empty value as a defensive check (the DTO type already constrains this but the service runs the assertion). - **`UserScope.source = 'admin-ui'`** for every row created by this surface. Distinct from `'seed'` (set by `prisma/seed.ts`) and `'self-signin'` (Person source only). ADR-0029's syncs will add `'pleiades'` / `'acteurs-plus'`. - **A11y per [ADR-0016](https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md)**: `aria-labelledby` on the form sections, `role="status"`/`aria-live="polite"` for load + error feedback, `role="alert"` for submit errors, `min-height: 44px` on every input/button per the touch-target rule, `aria-label` on the Manage-scopes link carrying the displayName for screen readers, conditional `aria-required` + `aria-describedby` on the value input. - **`window.confirm` before revoke.** Cheap protection against accidental keyboard-activation. v1 OK; a future polish PR could replace with the spartan-ng dialog when it ships. ## Local verification - [x] `node scripts/check-catalogue-drift.mjs` — clean (4 / 24 / 7 / 3). - [x] `pnpm exec nx lint portal-bff` — **0 errors** (13 warnings, all pre-existing). - [x] `pnpm exec nx lint portal-admin` — **0 errors** (3 warnings, all pre-existing). - [x] `pnpm exec nx test portal-bff` — full suite passes (now includes the 2 new `user-scopes` spec files). - [x] `pnpm exec nx test portal-admin` — **71 tests passing** (added 8 for `user-scopes`; updated `users.spec.ts` to provide a router after the RouterLink import). ## Test plan — remaining (manual on dev DB) - [ ] **Sign in as `admin@apfrd...`** (the only persona with `Portal.Admin`) on `portal-admin`. Navigate to `/admin/users`. Click `Manage scopes` for `directeur-bordeaux`. The page shows one row `etablissement:0330800013` (from the seed). - [ ] **Grant a new scope**: kind `delegation`, value `33`. Submit. New row appears; audit row `admin.scope_granted` lands in `audit.events`. - [ ] **Try a bogus value** (kind `etablissement`, value `xyz`): server returns 400, form shows the message. - [ ] **Try a duplicate** (`etablissement:0330800013` again on `directeur-bordeaux`): server returns 400 with "already granted". - [ ] **Revoke a scope**: row disappears; audit row `admin.scope_revoked` lands. - [ ] **Sign in as a non-admin persona** (e.g. `collaborateur-simple`) and try `/admin/users/.../scopes` → 403 + friendly error. - [ ] **Review focus** — the BFF's `validateValueForKind` switch, the SPA's `translateError` mapping, the a11y attributes on the form, the audit ordering in the controller methods. ## Notes for the reviewer - **No scope-vocabulary endpoint.** The form expects the operator to type the `Structure.code` / `Delegation.code` / `Region.code` by hand (with a hint string under the field). Defensive validation catches typos. A future polish PR could add `GET /api/admin/scope-vocabulary` returning all three lists at once + replace the value input with a typeahead dropdown. - **Renamed the service's `UserScopesPage` interface to `UserScopesPayload`.** Discovered the collision with the component class during the first test run. The component keeps the `UserScopesPage` name per Angular convention; the payload type now reads as what it is. - **`UserScope.source` is not under the drift gate today.** The schema comment in PR 1 said "same catalogue posture as Person.source"; in practice the seed writes `'seed'` and this PR writes `'admin-ui'`. Extending the drift gate to cover `UserScope.source` is a small follow-up — defensible to do once ADR-0029's upstream-sync values are landing. ## What's next ADR-0025's `@RequireScope` stack is now end-to-end live for the test tenant: persona → Entra → BFF → PrismaScopeResolver → DB → Principal → guard. The trilogy is done. Per the prior conversation: **GitLab migration Phase 1** (`mirror-and-bootstrap`) is the next item — mostly ops work on `vm-gitlab` (groups, branch protection, Renovate reconfig, deploy keys). The first PR-shaped output from that phase is the Renovate config update. --------- Co-authored-by: Julien Gautier Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/236 --- apps/portal-admin/src/app/app.routes.ts | 6 + .../app/pages/user-scopes/user-scopes.html | 126 +++++++ .../app/pages/user-scopes/user-scopes.scss | 152 +++++++++ .../user-scopes/user-scopes.service.spec.ts | 92 ++++++ .../pages/user-scopes/user-scopes.service.ts | 65 ++++ .../app/pages/user-scopes/user-scopes.spec.ts | 134 ++++++++ .../src/app/pages/user-scopes/user-scopes.ts | 180 ++++++++++ .../src/app/pages/users/users.html | 10 + .../src/app/pages/users/users.spec.ts | 3 +- .../portal-admin/src/app/pages/users/users.ts | 3 +- apps/portal-bff/src/admin/admin.module.ts | 12 +- .../src/admin/user-scopes.controller.spec.ts | 132 ++++++++ .../src/admin/user-scopes.controller.ts | 93 ++++++ apps/portal-bff/src/admin/user-scopes.dto.ts | 68 ++++ .../src/admin/user-scopes.service.spec.ts | 311 ++++++++++++++++++ .../src/admin/user-scopes.service.ts | 258 +++++++++++++++ apps/portal-bff/src/audit/audit.service.ts | 46 +++ apps/portal-bff/src/audit/audit.types.ts | 32 ++ 18 files changed, 1719 insertions(+), 4 deletions(-) create mode 100644 apps/portal-admin/src/app/pages/user-scopes/user-scopes.html create mode 100644 apps/portal-admin/src/app/pages/user-scopes/user-scopes.scss create mode 100644 apps/portal-admin/src/app/pages/user-scopes/user-scopes.service.spec.ts create mode 100644 apps/portal-admin/src/app/pages/user-scopes/user-scopes.service.ts create mode 100644 apps/portal-admin/src/app/pages/user-scopes/user-scopes.spec.ts create mode 100644 apps/portal-admin/src/app/pages/user-scopes/user-scopes.ts create mode 100644 apps/portal-bff/src/admin/user-scopes.controller.spec.ts create mode 100644 apps/portal-bff/src/admin/user-scopes.controller.ts create mode 100644 apps/portal-bff/src/admin/user-scopes.dto.ts create mode 100644 apps/portal-bff/src/admin/user-scopes.service.spec.ts create mode 100644 apps/portal-bff/src/admin/user-scopes.service.ts diff --git a/apps/portal-admin/src/app/app.routes.ts b/apps/portal-admin/src/app/app.routes.ts index 646c8ce..ebe549b 100644 --- a/apps/portal-admin/src/app/app.routes.ts +++ b/apps/portal-admin/src/app/app.routes.ts @@ -4,6 +4,7 @@ import { authGuard } from 'feature-auth'; const homeTitle = $localize`:@@route.home.title:APF Portal Admin`; const auditTitle = $localize`:@@route.audit.title:Audit log — APF Portal Admin`; const usersTitle = $localize`:@@route.users.title:Users — APF Portal Admin`; +const userScopesTitle = $localize`:@@route.user-scopes.title:User scopes — APF Portal Admin`; const profileTitle = $localize`:@@route.profile.title:Profile — APF Portal Admin`; export const appRoutes: Route[] = [ @@ -23,6 +24,11 @@ export const appRoutes: Route[] = [ loadComponent: () => import('./pages/users/users').then((m) => m.UsersPage), title: usersTitle, }, + { + path: 'users/:oid/scopes', + loadComponent: () => import('./pages/user-scopes/user-scopes').then((m) => m.UserScopesPage), + title: userScopesTitle, + }, { path: 'profile', canActivate: [authGuard], diff --git a/apps/portal-admin/src/app/pages/user-scopes/user-scopes.html b/apps/portal-admin/src/app/pages/user-scopes/user-scopes.html new file mode 100644 index 0000000..39c7c7c --- /dev/null +++ b/apps/portal-admin/src/app/pages/user-scopes/user-scopes.html @@ -0,0 +1,126 @@ +
+
+

+ ← Back to users +

+

User scopes

+ @if (page(); as p) { +

+ Managing scopes for {{ p.user.displayName }} ({{ p.user.email ?? '—' }}, + {{ p.user.oid }}). Every grant emits admin.scope_granted and every + revoke emits admin.scope_revoked — scope-management is itself auditable per + ADR-0013. +

+ } +
+ +
+ @if (loading()) { + Loading… + } @else if (error(); as msg) { + {{ msg }} + } +
+ + @if (page(); as p) { +
+

Current scopes

+ @if (p.scopes.length === 0) { +

No scopes granted to this user yet.

+ } @else { +
+ + + + + + + + + + + + @for (scope of p.scopes; track scope.id) { + + + + + + + + } + +
ScopeSourceGrantedExpiresActions
+ {{ formatScope(scope.kind, scope.value) }} + {{ scope.source }}{{ formatTimestamp(scope.createdAt) }}{{ formatTimestamp(scope.expiresAt) }} + +
+
+ } +
+ +
+

Grant a new scope

+
+
+ + + +
+ @if (submitError(); as msg) { + + } +
+ +
+
+
+ } +
diff --git a/apps/portal-admin/src/app/pages/user-scopes/user-scopes.scss b/apps/portal-admin/src/app/pages/user-scopes/user-scopes.scss new file mode 100644 index 0000000..bf8e16a --- /dev/null +++ b/apps/portal-admin/src/app/pages/user-scopes/user-scopes.scss @@ -0,0 +1,152 @@ +.user-scopes { + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.user-scopes-header { + .crumbs { + margin: 0 0 0.5rem; + font-size: 0.875rem; + } + .title { + margin: 0 0 0.5rem; + } + .intro { + margin: 0; + color: var(--color-text-secondary, #555); + } +} + +.status-bar { + min-height: 1.5rem; +} + +.status-line--error { + color: var(--color-danger, #b00020); +} + +.section-heading { + margin: 0 0 0.75rem; + font-size: 1.125rem; +} + +.empty { + font-style: italic; + color: var(--color-text-secondary, #555); +} + +.table-wrap { + overflow-x: auto; +} + +.scopes-table { + width: 100%; + border-collapse: collapse; + + th, + td { + padding: 0.5rem 0.75rem; + text-align: left; + border-bottom: 1px solid var(--color-border, #ddd); + } + + .cell-scope code { + font-family: var(--font-mono, monospace); + } + + .cell-actions { + text-align: right; + } +} + +.grant-section { + background: var(--color-bg-surface, #fafafa); + padding: 1rem; + border: 1px solid var(--color-border, #ddd); + border-radius: 0.5rem; +} + +.grant-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + margin-bottom: 1rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.25rem; + + &.field--hidden { + visibility: hidden; + } + + .field-label { + font-weight: 600; + font-size: 0.875rem; + } + + .field-hint { + font-size: 0.75rem; + color: var(--color-text-secondary, #555); + } + + input, + select { + padding: 0.5rem; + border: 1px solid var(--color-border, #ccc); + border-radius: 0.25rem; + min-height: 44px; /* WCAG 2.2 AA touch target (2.5.5 / 2.5.8). */ + } +} + +.submit-error { + color: var(--color-danger, #b00020); + margin: 0 0 0.75rem; +} + +.grant-actions { + display: flex; + justify-content: flex-end; +} + +.btn { + min-height: 44px; + min-width: 44px; + padding: 0.5rem 1rem; + border-radius: 0.25rem; + border: 1px solid transparent; + cursor: pointer; + font-weight: 600; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.btn--primary { + background: var(--color-brand-accent-500, #de9415); + color: white; +} + +.btn--danger { + background: transparent; + color: var(--color-danger, #b00020); + border-color: currentColor; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/apps/portal-admin/src/app/pages/user-scopes/user-scopes.service.spec.ts b/apps/portal-admin/src/app/pages/user-scopes/user-scopes.service.spec.ts new file mode 100644 index 0000000..e4b3cf2 --- /dev/null +++ b/apps/portal-admin/src/app/pages/user-scopes/user-scopes.service.spec.ts @@ -0,0 +1,92 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { firstValueFrom } from 'rxjs'; +import { AUTH_BFF_BASE_URL } from 'feature-auth'; +import { UserScopesService, type UserScope, type UserScopesPayload } from './user-scopes.service'; + +const BFF_BASE = 'http://bff.test/api'; + +const SCOPE: UserScope = { + id: 'scope-1', + kind: 'etablissement', + value: '0330800013', + source: 'seed', + createdAt: '2026-05-26T10:00:00.000Z', + expiresAt: null, +}; + +const PAGE: UserScopesPayload = { + user: { + oid: 'target-oid', + userId: 'user-uuid-1', + displayName: 'Jane Doe', + email: 'jane@apf.example', + }, + scopes: [SCOPE], +}; + +function setup() { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE }, + ], + }); + return { + service: TestBed.inject(UserScopesService), + http: TestBed.inject(HttpTestingController), + }; +} + +describe('UserScopesService.list', () => { + it('GETs /admin/users/:oid/scopes and returns the page', async () => { + const { service, http } = setup(); + const promise = firstValueFrom(service.list('target-oid')); + const req = http.expectOne(`${BFF_BASE}/admin/users/target-oid/scopes`); + expect(req.request.method).toBe('GET'); + req.flush(PAGE); + await expect(promise).resolves.toEqual(PAGE); + }); + + it('URL-encodes the oid', async () => { + const { service, http } = setup(); + void firstValueFrom(service.list('weird/oid')); + const req = http.expectOne(`${BFF_BASE}/admin/users/weird%2Foid/scopes`); + req.flush(PAGE); + }); +}); + +describe('UserScopesService.grant', () => { + it('POSTs the input body and returns the created scope', async () => { + const { service, http } = setup(); + const promise = firstValueFrom( + service.grant('target-oid', { + kind: 'etablissement', + value: '0330800013', + expiresAt: '2026-12-31T23:59:59.000Z', + }), + ); + const req = http.expectOne(`${BFF_BASE}/admin/users/target-oid/scopes`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ + kind: 'etablissement', + value: '0330800013', + expiresAt: '2026-12-31T23:59:59.000Z', + }); + req.flush(SCOPE); + await expect(promise).resolves.toEqual(SCOPE); + }); +}); + +describe('UserScopesService.revoke', () => { + it('DELETEs the scope by id', async () => { + const { service, http } = setup(); + const promise = firstValueFrom(service.revoke('target-oid', 'scope-1')); + const req = http.expectOne(`${BFF_BASE}/admin/users/target-oid/scopes/scope-1`); + expect(req.request.method).toBe('DELETE'); + req.flush(null); + await expect(promise).resolves.toBeNull(); + }); +}); diff --git a/apps/portal-admin/src/app/pages/user-scopes/user-scopes.service.ts b/apps/portal-admin/src/app/pages/user-scopes/user-scopes.service.ts new file mode 100644 index 0000000..36c6df4 --- /dev/null +++ b/apps/portal-admin/src/app/pages/user-scopes/user-scopes.service.ts @@ -0,0 +1,65 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { AUTH_BFF_BASE_URL } from 'feature-auth'; +import type { Observable } from 'rxjs'; + +/** + * One scope row as the SPA sees it — mirror of `UserScopeDto` on + * the BFF side. ISO-string timestamps; the SPA never round-trips + * `Date` instances per the audit-page convention. + */ +export interface UserScope { + readonly id: string; + readonly kind: string; + readonly value: string; + readonly source: string; + readonly createdAt: string; + readonly expiresAt: string | null; +} + +export interface UserScopesPayload { + readonly user: { + readonly oid: string; + readonly userId: string; + readonly displayName: string; + readonly email: string | null; + }; + readonly scopes: ReadonlyArray; +} + +export interface GrantUserScopeInput { + readonly kind: string; + readonly value?: string; + readonly expiresAt?: string; +} + +/** + * `UserScopesService` — Thin HttpClient wrapper around + * `/api/admin/users/:oid/scopes`. Same shape convention as + * `AdminUsersService` — `providedIn: 'root'` because the single + * consumer is the `UserScopesPayload`. + */ +@Injectable({ providedIn: 'root' }) +export class UserScopesService { + private readonly http = inject(HttpClient); + private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL); + + list(oid: string): Observable { + return this.http.get( + `${this.bffBaseUrl}/admin/users/${encodeURIComponent(oid)}/scopes`, + ); + } + + grant(oid: string, input: GrantUserScopeInput): Observable { + return this.http.post( + `${this.bffBaseUrl}/admin/users/${encodeURIComponent(oid)}/scopes`, + input, + ); + } + + revoke(oid: string, scopeId: string): Observable { + return this.http.delete( + `${this.bffBaseUrl}/admin/users/${encodeURIComponent(oid)}/scopes/${encodeURIComponent(scopeId)}`, + ); + } +} diff --git a/apps/portal-admin/src/app/pages/user-scopes/user-scopes.spec.ts b/apps/portal-admin/src/app/pages/user-scopes/user-scopes.spec.ts new file mode 100644 index 0000000..725224e --- /dev/null +++ b/apps/portal-admin/src/app/pages/user-scopes/user-scopes.spec.ts @@ -0,0 +1,134 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, provideRouter } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { AUTH_BFF_BASE_URL } from 'feature-auth'; +import { + UserScopesService, + type GrantUserScopeInput, + type UserScope, + type UserScopesPayload, +} from './user-scopes.service'; +import { UserScopesPage } from './user-scopes'; + +const SCOPE: UserScope = { + id: 'scope-1', + kind: 'etablissement', + value: '0330800013', + source: 'seed', + createdAt: '2026-05-26T10:00:00.000Z', + expiresAt: null, +}; + +const PAGE: UserScopesPayload = { + user: { + oid: 'target-oid', + userId: 'user-uuid-1', + displayName: 'Jane Doe', + email: 'jane@apf.example', + }, + scopes: [SCOPE], +}; + +function setup(opts?: { + list?: ReturnType; + grant?: ReturnType; + revoke?: ReturnType; +}) { + const list = opts?.list ?? vi.fn().mockReturnValue(of(PAGE)); + const grant = opts?.grant ?? vi.fn().mockReturnValue(of(SCOPE)); + const revoke = opts?.revoke ?? vi.fn().mockReturnValue(of(undefined)); + TestBed.configureTestingModule({ + imports: [UserScopesPage], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + provideRouter([]), + { provide: AUTH_BFF_BASE_URL, useValue: 'http://bff.test/api' }, + { + provide: UserScopesService, + useValue: { list, grant, revoke }, + }, + { + provide: ActivatedRoute, + useValue: { snapshot: { paramMap: new Map([['oid', 'target-oid']]) } }, + }, + ], + }); + const fixture = TestBed.createComponent(UserScopesPage); + return { fixture, list, grant, revoke }; +} + +describe('UserScopesPage', () => { + it('loads the scopes for the route param oid on init', async () => { + const { fixture, list } = setup(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(list).toHaveBeenCalledWith('target-oid'); + }); + + it('surfaces a 404 with the seed/sign-in hint', async () => { + const list = vi.fn().mockReturnValue(throwError(() => ({ status: 404 }))); + const { fixture } = setup({ list }); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const html = (fixture.nativeElement as HTMLElement).innerHTML; + expect(html).toMatch(/User not found/); + }); + + it('forbids a 403 with a friendly error', async () => { + const list = vi.fn().mockReturnValue(throwError(() => ({ status: 403 }))); + const { fixture } = setup({ list }); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const html = (fixture.nativeElement as HTMLElement).innerHTML; + expect(html).toMatch(/admin access/); + }); +}); + +describe('UserScopesPage.grant', () => { + it('sends value for value-bearing kinds + refreshes the list on success', async () => { + let grantPayload: GrantUserScopeInput | null = null; + const grant = vi.fn((_oid: string, payload: GrantUserScopeInput) => { + grantPayload = payload; + return of(SCOPE); + }); + const list = vi.fn().mockReturnValue(of(PAGE)); + const { fixture } = setup({ list, grant }); + fixture.detectChanges(); + await fixture.whenStable(); + const component = fixture.componentInstance as unknown as { + newKind: { set: (v: string) => void }; + newValue: { set: (v: string) => void }; + grant: () => Promise; + }; + component.newKind.set('etablissement'); + component.newValue.set('0330800013'); + await component.grant(); + expect(grant).toHaveBeenCalledTimes(1); + expect(grantPayload).toEqual({ kind: 'etablissement', value: '0330800013' }); + // List re-fetched after the grant. + expect(list).toHaveBeenCalledTimes(2); + }); + + it('omits value for valueless kinds', async () => { + let grantPayload: GrantUserScopeInput | null = null; + const grant = vi.fn((_oid: string, payload: GrantUserScopeInput) => { + grantPayload = payload; + return of(SCOPE); + }); + const { fixture } = setup({ grant }); + fixture.detectChanges(); + await fixture.whenStable(); + const component = fixture.componentInstance as unknown as { + newKind: { set: (v: string) => void }; + grant: () => Promise; + }; + component.newKind.set('unrestricted'); + await component.grant(); + expect(grantPayload).toEqual({ kind: 'unrestricted' }); + }); +}); diff --git a/apps/portal-admin/src/app/pages/user-scopes/user-scopes.ts b/apps/portal-admin/src/app/pages/user-scopes/user-scopes.ts new file mode 100644 index 0000000..8c5a78b --- /dev/null +++ b/apps/portal-admin/src/app/pages/user-scopes/user-scopes.ts @@ -0,0 +1,180 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + signal, + type OnInit, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { firstValueFrom } from 'rxjs'; +import { + UserScopesService, + type GrantUserScopeInput, + type UserScopesPayload, +} from './user-scopes.service'; + +/** + * The six scope kinds from ADR-0025's catalogue. Hardcoded here so + * the SPA can dropdown them without importing from `shared-auth` + * (which would couple the admin app to the BFF lib path). Drift gate + * lives BFF-side; if the catalogue grows, this list grows in lockstep. + */ +const SCOPE_KINDS = [ + 'self', + 'etablissement', + 'delegation', + 'region', + 'siege', + 'unrestricted', +] as const; + +const VALUE_BEARING = new Set(['etablissement', 'delegation', 'region']); + +/** + * Admin scope-management screen per [ADR-0026 PR 2b](https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0026-person-user-portal-data-model.md). + * Lists every UserScope row for the target user, lets the admin grant + * a new one (kind dropdown + value + optional expiresAt), and revoke + * existing ones inline. + * + * Reaches the BFF at `/api/admin/users/:oid/scopes`. The `:oid` URL + * param is the affected user's Entra `oid` — same identifier the + * `/admin/users` list page uses, so a "Manage scopes" link from that + * list lands here without a lookup. + * + * A11y posture (per [ADR-0016](https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md)): + * - Form labels associated via `