feat(portal-admin): user directory viewer screen #142

Merged
julien merged 1 commits from feat/portal-admin-users-viewer into main 2026-05-14 20:15:22 +02:00
Owner

Summary

Final PR of the portal-admin User-list chantier per ADR-0020 §"v1 scope — User list (read-only)". Ships the SPA viewer at /users that consumes GET /api/admin/users (PR #141) and renders a filter form + paginated table mirroring the audit viewer's shape (PR #136). The full chantier is now closed: schema + sign-in upsert (#140) → read endpoint (#141) → this viewer.

What lands

AdminUsersService

Thin HttpClient wrapper around GET /api/admin/users. Drops empty-string filter values (Nest's ValidationPipe rejects ?foo= as foo === ''). providedIn: 'root' — the users page is the single v1 consumer.

UsersPage

Signal-driven page mirroring the /audit viewer:

Knob State
username, displayName, audience, lastSeenAtFrom, lastSeenAtTo One signal per filter.
limit, offset Pagination. Default 50, cap 200 to match the BFF's MAX_LIMIT.
page, loading, error Async triplet.
hasNextPage, hasPreviousPage, resultRange Computed for the pagination controls + the "1–50 of 137" status line.

UI:

  • Filter form — username (prefix), displayName (contains), audience enum, lastSeenAt range (datetime-local inputs → ISO), page-size selector. Apply + Reset.
  • Result table — displayName, username, audience badge, firstSeen, lastSeen (locale-formatted), oid (monospaced).
  • Pagination — Previous / Next disabled at boundaries. Offset resets to 0 on Apply Filters / Reset.
  • States — explicit loading, empty ("No users match the current filters."), error. 403 surfaces "you do not have access"; everything else collapses to a generic retry message — same posture as the audit viewer.

Routing + i18n + sidebar

  • /users route lazy-loaded in app.routes.ts with route.users.title i18n marker.
  • messages.fr.xlf updated with the French translation (Utilisateurs — Administration APF Portal); the prod build's i18nMissingTranslation=error policy would fail otherwise.
  • AdminSidebar "User list" entry promoted from aria-disabled placeholder ("Soon" badge) to a live RouterLink at /users. The matching spec is updated.

Notes for the reviewer

  • The shape mirrors AuditPage (#136) intentionally — same signal layout, same flush dance in the spec, same 403/5xx error split, same datetime-local-to-ISO conversion. A future contributor coming from one viewer lands familiar code on the other.
  • Each viewer ships as a separate lazy chunk (users = 3.76 KB gzip, audit = 4.35 KB gzip). No shared filter-form lib promoted yet — the duplication is mechanical and small (~120 LOC each), and ADR-0020's next admin modules (CMS, menu management) may diverge enough that premature factoring would be expensive to undo.
  • No sign-in counts column. The BFF endpoint exposes the directory data only (firstSeenAt / lastSeenAt). Joining sign-in counts from audit.events via HashUserIdService is deferred until admin demand justifies the extra query per row.

Test plan

  • pnpm nx test portal-admin45 specs pass (was 30; +15: AdminUsersService 3, UsersPage 11, sidebar 1).
  • pnpm exec nx affected -t format:check lint test build --base=origin/main — clean.
  • Build emits the lazy users chunk at 14.59 KB raw / 3.76 KB gzip — comfortably under the per-chunk lazy budget.
  • e2e — pending Entra admin role assignment + at least a couple of sign-in events to populate the directory. Once both exist: sign in via /api/admin/auth/login, navigate to /users, see the directory populate with each row's displayName + lastSeen, exercise the filters, observe admin.users.query rows in audit.events per fetch.

What's next — portal-admin v1 chantiers

This PR closes User list. Remaining ADR-0020 v1 modules:

  • Menu management — CRUD on cms.menu_items; GET /api/me/menu consumed by portal-shell + /api/admin/menu admin CRUD. Medium chantier.
  • CMS pages — multi-locale editorial content (cms.pages with slug + locale + body markdown), admin editor screen, portal-shell consumer for the rendering. Bigger chantier.

Out of immediate scope:

  • The strategic security baseline ADR (paused awaiting RSSI input on ASVS / HDS / GDPR / NIS 2).
  • Per-integration downstream ADRs (the strategy layer from #137 + #138 + #139 is ready to be assembled around the first real consumer).
## Summary Final PR of the **portal-admin User-list chantier** per [ADR-0020](docs/decisions/0020-portal-admin-app.md) §"v1 scope — User list (read-only)". Ships the SPA viewer at `/users` that consumes [`GET /api/admin/users`](apps/portal-bff/src/admin/admin-users.controller.ts) (PR #141) and renders a filter form + paginated table mirroring the audit viewer's shape (PR #136). The full chantier is now closed: schema + sign-in upsert (#140) → read endpoint (#141) → this viewer. ## What lands ### [`AdminUsersService`](apps/portal-admin/src/app/pages/users/admin-users.service.ts) Thin `HttpClient` wrapper around `GET /api/admin/users`. Drops empty-string filter values (Nest's `ValidationPipe` rejects `?foo=` as `foo === ''`). `providedIn: 'root'` — the users page is the single v1 consumer. ### [`UsersPage`](apps/portal-admin/src/app/pages/users/users.ts) Signal-driven page mirroring the `/audit` viewer: | Knob | State | | --- | --- | | `username`, `displayName`, `audience`, `lastSeenAtFrom`, `lastSeenAtTo` | One signal per filter. | | `limit`, `offset` | Pagination. Default 50, cap 200 to match the BFF's `MAX_LIMIT`. | | `page`, `loading`, `error` | Async triplet. | | `hasNextPage`, `hasPreviousPage`, `resultRange` | Computed for the pagination controls + the "1–50 of 137" status line. | UI: - **Filter form** — username (prefix), displayName (contains), audience enum, lastSeenAt range (`datetime-local` inputs → ISO), page-size selector. Apply + Reset. - **Result table** — displayName, username, audience badge, firstSeen, lastSeen (locale-formatted), oid (monospaced). - **Pagination** — Previous / Next disabled at boundaries. Offset resets to 0 on Apply Filters / Reset. - **States** — explicit loading, empty (`"No users match the current filters."`), error. **403 surfaces "you do not have access"**; everything else collapses to a generic retry message — same posture as the audit viewer. ### Routing + i18n + sidebar - `/users` route lazy-loaded in [`app.routes.ts`](apps/portal-admin/src/app/app.routes.ts) with `route.users.title` i18n marker. - [`messages.fr.xlf`](apps/portal-admin/src/locale/messages.fr.xlf) updated with the French translation (`Utilisateurs — Administration APF Portal`); the prod build's `i18nMissingTranslation=error` policy would fail otherwise. - [`AdminSidebar`](apps/portal-admin/src/app/components/sidebar/sidebar.ts) "User list" entry promoted from `aria-disabled` placeholder ("Soon" badge) to a live `RouterLink` at `/users`. The matching spec is updated. ## Notes for the reviewer - The shape mirrors `AuditPage` (#136) intentionally — same signal layout, same flush dance in the spec, same 403/5xx error split, same datetime-local-to-ISO conversion. A future contributor coming from one viewer lands familiar code on the other. - Each viewer ships as a separate lazy chunk (`users` = 3.76 KB gzip, `audit` = 4.35 KB gzip). No shared filter-form lib promoted yet — the duplication is mechanical and small (~120 LOC each), and ADR-0020's next admin modules (CMS, menu management) may diverge enough that premature factoring would be expensive to undo. - No sign-in counts column. The BFF endpoint exposes the directory data only (`firstSeenAt` / `lastSeenAt`). Joining sign-in counts from `audit.events` via `HashUserIdService` is deferred until admin demand justifies the extra query per row. ## Test plan - [x] `pnpm nx test portal-admin` — **45 specs pass** (was 30; +15: AdminUsersService 3, UsersPage 11, sidebar 1). - [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean. - [x] Build emits the lazy `users` chunk at **14.59 KB raw / 3.76 KB gzip** — comfortably under the per-chunk lazy budget. - [ ] e2e — pending Entra `admin` role assignment + at least a couple of sign-in events to populate the directory. Once both exist: sign in via `/api/admin/auth/login`, navigate to `/users`, see the directory populate with each row's displayName + lastSeen, exercise the filters, observe `admin.users.query` rows in `audit.events` per fetch. ## What's next — portal-admin v1 chantiers This PR closes **User list**. Remaining ADR-0020 v1 modules: - **Menu management** — CRUD on `cms.menu_items`; `GET /api/me/menu` consumed by portal-shell + `/api/admin/menu` admin CRUD. Medium chantier. - **CMS pages** — multi-locale editorial content (`cms.pages` with slug + locale + body markdown), admin editor screen, portal-shell consumer for the rendering. Bigger chantier. Out of immediate scope: - The strategic security baseline ADR (paused awaiting RSSI input on ASVS / HDS / GDPR / NIS 2). - Per-integration downstream ADRs (the strategy layer from #137 + #138 + #139 is ready to be assembled around the first real consumer).
julien added 1 commit 2026-05-14 20:13:23 +02:00
feat(portal-admin): user directory viewer screen
CI / scan (pull_request) Successful in 2m39s
CI / commits (pull_request) Successful in 3m1s
CI / check (pull_request) Successful in 3m2s
CI / a11y (pull_request) Successful in 2m28s
CI / perf (pull_request) Successful in 5m52s
00c7170d86
Final PR of the portal-admin User-list chantier per ADR-0020 §"v1
scope — User list (read-only)". Ships the SPA viewer at /users that
consumes GET /api/admin/users (PR #141) and renders a filter form
+ paginated table mirroring the audit viewer's shape (PR #136).

What lands

- AdminUsersService (apps/portal-admin/src/app/pages/users/
  admin-users.service.ts): thin HttpClient wrapper around
  GET /api/admin/users. Drops empty-string filter values (Nest's
  ValidationPipe rejects ?foo= as ''). providedIn 'root' — the
  users page is the single v1 consumer.

- UsersPage component (apps/portal-admin/src/app/pages/users/
  users.ts): signal-driven page composing:
  - Filter form: username (prefix), displayName (contains),
    audience enum, lastSeenAt range (datetime-local → ISO),
    page-size selector (25 / 50 / 100 / 200 matching BFF MAX_LIMIT).
  - Result table: displayName, username, audience badge, firstSeen,
    lastSeen (locale-formatted), oid (monospaced).
  - Pagination: previous/next disabled at boundaries; offset
    resets to 0 on Apply Filters or Reset.
  - States: loading line, empty state, error with 403 vs 5xx
    differentiation matching the audit viewer's posture.

Route + sidebar

- /users route lazy-loaded in app.routes.ts with the
  route.users.title i18n marker. messages.fr.xlf updated with the
  French translation ('Utilisateurs — Administration APF Portal');
  the prod build's i18nMissingTranslation=error policy fails
  otherwise.
- AdminSidebar 'User list' entry promoted from aria-disabled
  placeholder ('Soon' badge) to a live RouterLink at /users. The
  matching spec is updated.

Tests: +15 specs (AdminUsersService 3 covering URL params + empty
+ undefined; UsersPage 11 covering initial fetch, result range,
empty/error states, filter forwarding, Apply offset reset, Reset,
pagination boundaries, display badges; sidebar 1 for the promoted
link). 45 portal-admin specs total.

Build verified: 14.59 KB raw / 3.76 KB gzip for the lazy users
chunk — well under any per-chunk budget. The /audit chunk
remains 18.16 KB / 4.35 KB; the two viewers ship as separate
lazy bundles since each pulls its own service and components.

Chantier portal-admin User-list closed — 3 PRs total (140 schema
+ write, 141 read endpoint, this PR for the viewer).
julien merged commit e90e88ec45 into main 2026-05-14 20:15:22 +02:00
julien deleted branch feat/portal-admin-users-viewer 2026-05-14 20:15:25 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#142