3b3cd1dc66305de11e40cd7f69f32bbbfae49d10
4 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3b3cd1dc66 |
feat(portal-admin): profile page on /profile guarded by authGuard
PR 2 of 3 from the user-menu chantier. * New lazy-loaded `/profile` route in `apps/portal-admin`. Renders the active admin session's identity card (displayName, username, oid, tid) plus an "App roles" card listing the Entra app-role claims attached to the session — `Portal.Admin` today, additional business roles when the strategic security baseline ADR lands. * `authGuard` (from `feature-auth`) gates the route — anonymous visitors bounce through the BFF's `/api/admin/auth/login`. The template's `@if (user())` then narrows the type so the page can drop the anonymous branch. * The `User Menu`'s Profile entry (PR 1) already points at `/profile` so the link starts working on this surface as soon as this lands. * `CurrentUser.roles` extended with an optional `readonly string[]` — the admin-side `/api/admin/auth/me` already returns the claim; the type now captures it. User-portal `/me` keeps omitting it per ADR-0009 (PR 3 surfaces it via the dedicated capabilities endpoint). * Lazy chunk lands at 5.37 KB raw / 1.57 KB gzip — comfortably under the lazy-chunk budget. |
||
|
|
e90e88ec45 |
feat(portal-admin): user directory viewer screen (#142)
## 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). --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #142 |
||
|
|
d86f3f9663 |
feat(portal-admin): audit log viewer screen (#136)
## Summary Final piece of the **portal-admin chantier** per [ADR-0020](docs/decisions/0020-portal-admin-app.md) §"v1 scope" item 4. The new `/audit` route consumes [`GET /api/admin/audit`](apps/portal-bff/src/admin/admin-audit.controller.ts) (PR #132), renders a filter form + paginated results table, and trips the `admin.audit.query` deterrent on every fetch. Closes the loop from "BFF emits audit events" (PRs #120, #127, #128) through "BFF exposes them via a guarded endpoint" (PR #132) to "an admin can actually read them in a browser". ## What lands ### [`AuditEventsService`](apps/portal-admin/src/app/pages/audit/audit-events.service.ts) Thin `HttpClient` wrapper around `GET /api/admin/audit`. Builds `HttpParams` from the filter shape, **dropping empty strings** (Nest's `ValidationPipe` treats `?foo=` as `foo === ''` and 400s). `providedIn: 'root'` — the audit page is the only consumer in v1. ### [`AuditPage`](apps/portal-admin/src/app/pages/audit/audit.ts) Signal-driven page. State surface: | Signal | Role | | --- | --- | | `eventType`, `actorIdHash`, `audience`, `outcome`, `subjectPrefix`, `createdAtFrom`, `createdAtTo` | One per filter field, bound via `[ngModel]` / `(ngModelChange)`. | | `limit`, `offset` | Pagination. `limit` defaults to **50**, capped at **200** to mirror the BFF's `MAX_LIMIT`. | | `page`, `loading`, `error` | Async result triplet. | | `hasNextPage`, `hasPreviousPage`, `resultRange` | Computed signals for the pagination controls + the "1–50 of 1 234" status line. | UI structure: - **Filter form** — 8 inputs in a responsive auto-fit grid, plus "Apply filters" + "Reset" buttons. Submitting via Enter respects the form action; pressing Reset zeroes every signal. - **Result table** — timestamp (locale-formatted via `Date.toLocaleString`), event type (monospaced), audience + outcome with **color-coded badges** (`success` green / `failure` red / `denied` amber), actor hash + subject stacked, trace id, and a `<details>` disclosure for the JSON payload so the row stays scannable. - **Pagination** — Previous / Next disabled at boundaries. Applying a filter resets offset to 0 so a narrower query never inherits a stale page index. - **States** — explicit loading line, empty state (`"No audit events match the current filters."`), error message. **403 surfaces "you do not have access"**; everything else collapses to a generic retry message so the admin UI doesn't leak BFF internals. ### Routing + i18n - [`app.routes.ts`](apps/portal-admin/src/app/app.routes.ts) — `/audit` lazy-loaded. - Title: `Audit log — APF Portal Admin` (EN) / `Journal d'audit — Administration APF Portal` (FR). The matching `route.audit.title` trans-unit lands in [`messages.fr.xlf`](apps/portal-admin/src/locale/messages.fr.xlf) — required because the admin app's prod build uses `i18nMissingTranslation: "error"`. - The sidebar's "Audit log" link (already shipped in PR #134) is now active end-to-end. ## Implementation notes - **`@RequireMfa` not applied** to the BFF endpoint in v1. The admin surface already sits behind a freshly-MFA'd session (`/api/admin/auth/login` enforces it via Entra CA), and the per-query `admin.audit.query` audit row is the deterrent against fishing expeditions. Adding `@RequireMfa({ freshness: 600 })` is a one-line change when the security review asks — the SPA's `bffUnauthorizedInterceptor` already handles the resulting 401 gracefully. - **Page-size cap mirrors the BFF**. The SPA offers 25 / 50 / 100 / 200 in the dropdown; the BFF `AuditReader.findEvents` clamps `limit` to 200 regardless of what comes over the wire — defense in depth. - **`AdminAuditQuery` interface** uses `?: T | undefined` so callers can build the query object with `undefined` placeholders under `exactOptionalPropertyTypes: true` — the page's `buildFilters()` does exactly that. - **SCSS budget** — 4.98 KB / 5 KB warning. Below the 6 KB error ceiling. The audit page's chrome is intentionally chunky because the table needs distinct visual lanes for six columns; I trimmed the redundant `font-family` stacks and unused `text-transform` / `letter-spacing` declarations to fit. ## Test plan - [x] `pnpm nx test portal-admin` — **30 specs pass** (was 15; +15: AuditEventsService 3, AuditPage 12). - [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean. - [x] AuditEventsService verified to: GET the correct URL, forward populated filters as `HttpParams`, drop empty strings, omit `undefined`. - [x] AuditPage covered: initial fetch on init, result-range string, empty / loading / error states (incl. 403 vs 5xx differentiation), Apply filters resets offset to 0, Reset clears every signal + re-queries, Next disabled when page covers total, Previous disabled at offset 0, outcome-badge variants match row data, payload disclosure only renders for rows with a payload. - [ ] e2e — sign in via `/api/admin/auth/login` (requires the Entra `admin` app role assignment), navigate to `/audit`, expect the most recent `auth.sign_in` / `admin.audit.query` events from earlier sessions, exercise the filter form, observe a new `admin.audit.query` row in `audit.events` per fetch. ## Notes for the reviewer - The error-state branch on 403 surfaces a permission-specific message rather than the generic "Could not load the audit log…". The admin role can be revoked mid-session (Entra-side), and a clear message + a retry path is friendlier than "server error". - Payload rendering uses `<details>` rather than a modal or always-on JSON viewer — the table stays scannable at glance, and an auditor pivoting into a specific row gets the structured detail one click away. No third-party JSON-tree dependency added in v1. - The "Audit log" sidebar entry from PR #134 was a `aria-disabled` placeholder until now; this PR makes it live. The other three v1 modules (CMS, menu management, user list) remain placeholders and will graduate as they land. ## What's next This PR closes the portal-admin chantier. Open follow-ups from the roadmap: - **`DownstreamApiClient` + OBO** ([ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md)) — first real consumer when an Entra-protected business API needs the BFF as a passthrough. - **Strategic security baseline ADR** — RSSI sign-off on ASVS / HDS / GDPR / NIS 2 framing. Currently paused per [CLAUDE.md](CLAUDE.md) §"Repository status". - **CMS pages / menu management / user list** — the other three ADR-0020 v1 admin modules. Each is its own self-contained chantier. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #136 |
||
|
|
d962be838a |
feat(portal-admin): skeleton app per ADR-0020 (#100)
## Summary
First step of the admin track per ADR-0020. Scaffold the second Angular SPA in the workspace alongside `portal-shell`, with the architectural decisions from the v1 ADRs already wired so subsequent feature PRs can drop straight into modules.
## What lands
- **App generated** via `nx g @nx/angular:application` (with the matching e2e project skeleton). Standalone, zoneless-ready, prefix `app`, scss component styles, css root stylesheet, no SSR.
- **Tags** `scope:portal-admin, type:app` — module-boundary lint now treats `portal-admin` distinctly from `portal-shell` and lets it depend only on `scope:portal-admin` + `scope:shared` libs.
- **Tailwind v4 + brand tokens**: `.postcssrc.json`, `@import 'tailwindcss'`, class-based dark variant, and `@import '../../../libs/shared/tokens/src/brand-tokens.css'` — identical visual baseline to portal-shell.
- **i18n config mirrors portal-shell** (per ADR-0019): sourceLocale `en`, target `fr`, baseHref `/{locale}/` per locale, `@angular/localize/init` polyfill, `localize: ["en", "fr"]` on production, `i18nMissingTranslation: "error"` so CI blocks any PR that adds a marker without translating it. Seed [`messages.fr.xlf`](apps/portal-admin/src/locale/messages.fr.xlf) carries the four marked strings used today.
- **Budgets** relaxed to **500 KB gzip initial** per ADR-0020 §"Performance budgets" (vs 300 KB for portal-shell).
- **Skeleton home page** at `/` — `<h1>` + intro paragraph + "Skeleton — coming soon" chip in the brand-accent palette. All marked for i18n.
- **Skip-link landmark** (WCAG 2.4.1) with the same component-scoped scss as portal-shell.
- **Wildcard catch-all route** → bounces unknown paths to home (same defensive pattern as PR #96 on portal-shell).
- **Dev server on port 4300** (vs 4200 for portal-shell) — both can run in parallel.
- **`serve-static` target without `spa: true`** — locale-prefixed routing in production is handled by the reverse proxy (per ADR-0019), same as portal-shell since PR #92.
## CLAUDE.md
Picked up the second app in the **Naming** entry and the **Commands** snippet (`<app>` is now one of `portal-shell`, `portal-admin`, `portal-bff`).
## Verification
- `pnpm exec nx run-many -t lint test build --projects=portal-shell,portal-admin,shared-ui,shared-state,shared-tokens` — green.
- `portal-admin` test: 1 spec (skip-link + main landmark present).
- Production build of `portal-admin`: **62 KB gzip initial per locale** (vs 500 KB budget — plenty of headroom for the upcoming modules).
- Both `dist/apps/portal-admin/browser/{en,fr}/` emit with the right `<html lang>` and `<base href>`. FR bundle contains `"Administration"` / `"Squelette"` / `"bientôt disponible"` — translations applied.
- portal-shell production build unchanged.
## Out of scope (each its own follow-up PR)
- Admin app shell (header / sidebar / footer with "Admin" badge — likely sharing graduated primitives plus admin-specific bits).
- OpenTelemetry tracing setup (`service.name=portal-admin` per ADR-0012).
- `environment.ts` wiring (per ADR-0018) for admin-specific endpoints.
- BFF `AdminModule` + `AdminRoleGuard` + smoke `/api/admin/me` (per ADR-0020 §"Auth").
- First functional module — CMS / menu / users / audit viewer.
## Test plan
- [x] Lint + test + build green across the workspace.
- [x] Per-locale production build emits both folders with correct metadata.
- [ ] Manual: `pnpm exec nx serve portal-admin` boots on `:4300`, smoke page renders.
- [ ] Manual: prod build + `pnpm exec nx run portal-admin:serve-static` → `http://localhost:4300/en/` and `/fr/` show the placeholder with the matching language.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #100
|