feat(admin): add /admin/users/:oid/scopes screen + endpoints (ADR-0026 PR 2b) #236
Reference in New Issue
Block a user
Delete Branch "feat/adr-0026-pr2b-admin-scopes-screen"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
ADR-0026 PR 2b (second half of PR 2). Ships the operator surface for the
@RequireScopestack — the admin Angular screen at/admin/users/:oid/scopeslets 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:
What lands
BFF (under
apps/portal-bff/src/)admin/user-scopes.dto.tsGrantUserScopeDto(class-validator on kind + value + ISO expiresAt) + response shapes (UserScopeDto,UserScopesPageDto).admin/user-scopes.service.tsresolveUserByOid(404 when no portal User),list(UserScoperows 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/api/admin/users/:oid/scopeswith@RequireAdmin.GET(no audit, read),POST(auditadmin.scope_granted),DELETE :scopeId(auditadmin.scope_revoked, 204 on success).admin/user-scopes.service.spec.ts+admin/user-scopes.controller.spec.tsadmin/admin.module.tsUserScopesController+UserScopesService.audit/audit.types.ts+audit/audit.service.tsAdminScopeGrantedInput/AdminScopeRevokedInputtypes +adminScopeGranted/adminScopeRevokedmethods onAuditWriter.subject = 'user:<oid>'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/)app.routes.ts/users/:oid/scopes(lazy-loaded).pages/user-scopes/user-scopes.service.ts+.service.spec.tslist/grant/revoke). Same shape convention asAdminUsersService.pages/user-scopes/user-scopes.ts+.html+.scss+.spec.tsRevokeper row; "Grant a new scope" form with kind dropdown + conditional value input + optionalexpiresAt. 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.htmlManage scopeslink per row (RouterLink to the new page), witharia-labelcarrying the user's displayName.pages/users/users.ts+users.spec.tsRouterLinkimport +provideRouter([])in the spec fixture (was missing — the route addition exposed it).Key choices
:oidin the URL, notUser.id. The existing/admin/userslist (ADR-0020) keys on Entraoidand 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 translatesoid → User.idinternally viaresolveUserByOid.resolveUserByOidmisses, the BFF throwsNotFoundException("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 theUserrow only exists post-sign-in OR post-seed).etablissement:0330800013must match an existingStructure.code;delegation:33must matchDelegation.code;region:75must matchRegion.code. The lookup is a singlefindUniqueper 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 byprisma/seed.ts) and'self-signin'(Person source only). ADR-0029's syncs will add'pleiades'/'acteurs-plus'.aria-labelledbyon the form sections,role="status"/aria-live="polite"for load + error feedback,role="alert"for submit errors,min-height: 44pxon every input/button per the touch-target rule,aria-labelon the Manage-scopes link carrying the displayName for screen readers, conditionalaria-required+aria-describedbyon the value input.window.confirmbefore 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
node scripts/check-catalogue-drift.mjs— clean (4 / 24 / 7 / 3).pnpm exec nx lint portal-bff— 0 errors (13 warnings, all pre-existing).pnpm exec nx lint portal-admin— 0 errors (3 warnings, all pre-existing).pnpm exec nx test portal-bff— full suite passes (now includes the 2 newuser-scopesspec files).pnpm exec nx test portal-admin— 71 tests passing (added 8 foruser-scopes; updatedusers.spec.tsto provide a router after the RouterLink import).Test plan — remaining (manual on dev DB)
admin@apfrd...(the only persona withPortal.Admin) onportal-admin. Navigate to/admin/users. ClickManage scopesfordirecteur-bordeaux. The page shows one rowetablissement:0330800013(from the seed).delegation, value33. Submit. New row appears; audit rowadmin.scope_grantedlands inaudit.events.etablissement, valuexyz): server returns 400, form shows the message.etablissement:0330800013again ondirecteur-bordeaux): server returns 400 with "already granted".admin.scope_revokedlands.collaborateur-simple) and try/admin/users/.../scopes→ 403 + friendly error.validateValueForKindswitch, the SPA'stranslateErrormapping, the a11y attributes on the form, the audit ordering in the controller methods.Notes for the reviewer
Structure.code/Delegation.code/Region.codeby hand (with a hint string under the field). Defensive validation catches typos. A future polish PR could addGET /api/admin/scope-vocabularyreturning all three lists at once + replace the value input with a typeahead dropdown.UserScopesPageinterface toUserScopesPayload. Discovered the collision with the component class during the first test run. The component keeps theUserScopesPagename per Angular convention; the payload type now reads as what it is.UserScope.sourceis 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 coverUserScope.sourceis a small follow-up — defensible to do once ADR-0029's upstream-sync values are landing.What's next
ADR-0025's
@RequireScopestack 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 onvm-gitlab(groups, branch protection, Renovate reconfig, deploy keys). The first PR-shaped output from that phase is the Renovate config update.Backend ------- New REST surface at /api/admin/users/:oid/scopes guarded by @RequireAdmin (Portal.Admin privilege per ADR-0020). Three endpoints: GET list a user's scopes with the linked User + Person info (no audit on reads) POST grant a new scope - validates kind via SCOPE_KINDS catalogue, validates value against Structure.code / Delegation.code / Region.code for value-bearing kinds, rejects non-empty values for valueless kinds, translates Prisma P2002 to a 400 with "already granted"; emits admin.scope_granted (blocking audit per ADR-0013) DELETE :scopeId 404-protected against cross-user revocation; emits admin.scope_revoked with the resolved tuple at the moment of deletion Two new audit types (AdminScopeGrantedInput / AdminScopeRevokedInput) + two AuditWriter methods. subject = "user:<oid>" so an auditor pivots on the target of the change. UserScope rows created here use source = "admin-ui" (distinct from seed-written "seed" and Person-only "self-signin"). Frontend -------- New Angular page at /admin/users/:oid/scopes (lazy-loaded). Lists current scopes in a table with per-row Revoke buttons; "Grant a new scope" form with kind dropdown + conditional value input + optional expiresAt datetime. Friendly error mapping: 403 -> "no admin access", 404 -> "User not found, has this persona ever signed in or been seeded?", server messages forwarded. A11y per ADR-0016: aria-labelledby on form sections, role="status" / aria-live="polite" for load/error feedback, role="alert" for submit errors, min-height 44px on every input/button, aria-label on the Manage-scopes link carrying the user's displayName, conditional aria-required + aria-describedby on the value input. "Manage scopes" link added to each row of the existing /admin/users list, navigating to the new page via the user's Entra oid. Local: drift gate 4/24/7/3 clean; portal-bff lint 0 err; portal-admin lint 0 err; both test suites green (portal-admin 71 tests, +8 for user-scopes). Closes the ADR-0026 trilogy alongside PRs #232 and #233. ADR-0025's @RequireScope stack is now end-to-end live for the test tenant.