4ce22d6d056a51dbe3b5e8ea7bfa0e8ecae5a381
13 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0435fec10a |
feat(portal-admin): jaeger deep link on trace_id + actor-pivot on actor_id_hash (#166)
## Summary
Turns the audit-log table's `trace_id` and `actor_id_hash` columns from inert text into the two pivots an investigator actually needs:
- **trace_id** → Jaeger deep link (opens in a new tab). Closes the "join audit + traces by trace_id" loop from [ADR-0012](docs/decisions/0012-observability-pino-opentelemetry.md) / [ADR-0013](docs/decisions/0013-audit-trail-separated-postgres-append-only.md) without any new BFF surface.
- **actor_id_hash** → click to refilter the table on that single actor. "Show me everything else this user did" stays in the page; no copy-paste loop.
## What lands
### Trace-id deep link to Jaeger
[`audit.html`](apps/portal-admin/src/app/pages/audit/audit.html#L162-L173) — the cell becomes an `<a target="_blank" rel="noopener noreferrer">` pointing at `${environment.jaegerBaseUrl}/trace/<traceId>`. Anonymous events (`traceId === null`) keep the dash placeholder.
[`environment.ts`](apps/portal-admin/src/environments/environment.ts) gains `jaegerBaseUrl`. Dev defaults to `http://localhost:16686` (matches the compose `observability` profile from `infra/local/dev.compose.yml`). Per-env replacement picks up whatever trace backend the future infrastructure ADR settles on — Tempo, Grafana Cloud, on-prem Jaeger; the SPA-side wiring doesn't care.
### Actor-pivot click
[`audit.html`](apps/portal-admin/src/app/pages/audit/audit.html#L146-L160) — non-null `actorIdHash` becomes a `<button>` styled to read inline like the hash text (`.actor-hash--clickable`: button reset + dotted-underline hover + brand-colored focus ring). Click → `filterByActor(hash)` sets the existing `actorIdHash` filter signal, resets offset to 0, and re-runs the query. Each pivot still emits its own `admin.audit.query` audit row server-side (per [ADR-0020](docs/decisions/0020-portal-admin-app.md)) so the drill is itself auditable.
Anonymous rows keep the `(anonymous)` plain-text rendering — there's no useful filter value to pivot on.
## Why not inline-expand Pino log lines under the row
Considered, deferred. The BFF's Pino output goes to **stdout only** today; standing up a queryable log aggregator (Loki, OpenSearch, …) is a separate infrastructure chantier with its own ADR. The Jaeger jump-off carries ~99 % of the investigator's needs anyway — the trace already contains span attributes (`db.statement`, `http.status_code`, exception events) for the same request scope; Pino lines on top of that would be redundant for most investigations.
When the log aggregator does land, the inline-expand model can come back as a follow-up: `GET /api/admin/logs?traceId=<id>` + an expand affordance on the same row. The current Jaeger anchor and the future inline-logs would naturally coexist (different drills, both surfaced on the same `trace_id`).
## Notes for the reviewer
- **Why a `<button>` for the actor cell rather than an `<a>`?** The action is an in-page filter change, not a navigation. Buttons keep keyboard activation (Enter / Space), don't pollute browser history, and screen readers announce "Filter the table on hash(jane), button" rather than a misleading link role.
- **Why the dotted-underline hover for the actor, but solid-underline for trace?** Different affordances. The trace anchor is a permanent link to an external resource (Jaeger UI), so the solid underline matches the universal "link" convention. The actor button is an inline pivot that *mutates state* — the dotted underline + hover-fill conveys "this does something subtle within the page" without screaming "link".
- **CSS guardrails preserved**: focus rings on both elements, brand-color tokens (light + dark), tap targets meet the AAA 44×44 minimum (the button reset preserves the line-height + the `cell-actor` padding ≥ 12 px on each side).
- **No new i18n strings.** `title` attributes are hover hints, not screen-reader-essential — the underlying hash + traceId are the actual semantic content. The "(anonymous)" string and the dash placeholder were already in the template.
- **No BFF change.** This whole PR is SPA-side only. The audit endpoint already returns `traceId` and `actorIdHash` in every row.
## Test plan
- [x] `pnpm nx run-many -t lint test build --projects=portal-admin` — green.
- [x] **54 portal-admin specs pass** (was 50; +4 for the four new behaviours).
- [x] Lazy `audit` chunk: 18.26 → ~18.5 KB raw / 4.44 KB gzip — comfortably under the per-chunk budget.
- [ ] **Manual smoke**:
- Sign in to portal-admin with `Portal.Admin` → open `/audit`.
- Click any trace_id → new tab opens at `http://localhost:16686/trace/<id>` (assuming `./infra/local/dev.sh up observability` is running so Jaeger is up).
- Anonymous rows show `—` for trace, `(anonymous)` (plain text, not clickable) for actor.
- Click any non-anonymous actor hash → the table refreshes filtered on that hash, the "Actor id hash" filter input above shows the same value, page jumps to offset 0.
- Tab through a row: timestamp / event are plain text; outcome badge skipped (not interactive); actor button gets focus ring; trace link gets focus ring; payload `<details>` summary gets focus ring.
## Follow-ups (optional)
- When the log aggregator ADR lands, extend the trace cell to also offer an inline-expand of Pino lines for that trace. Jaeger anchor stays as the primary affordance.
- A similar treatment on the `/users` page (clicking a row's `oid` to "show me this user's audit trail") is the natural sibling. Defer until there's an investigator workflow that asks for it — premature otherwise.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #166
|
||
|
|
6f26bcdd65 |
feat(shared-ui): move the role label from the portal-shell sidebar into the user menu (#165)
## Summary
Moves the "Role: …" widget from the bottom of the `portal-shell` sidebar into the user-menu panel header. Result: the role chip appears only when the reader is authenticated (the user menu only renders in that state), removing the noise of "Role: Anonymous" on signed-out traffic and de-duplicating the surface that already carries displayName / username / capability-gated actions.
```
Before: sidebar bottom → "Role: Anonymous" (always rendered, even signed-out)
After: user-menu panel → • <role chip> (only when authenticated)
```
## What lands
### Shared component — [`UserMenu`](libs/shared/ui/src/lib/user-menu/) gets an optional `role` input
When set, a small brand-tinted pill (`data-testid="user-menu-role"`) renders inside the panel header below the username. When undefined, the entire chip is omitted — keeps backwards compat with any future caller that doesn't carry a role concept.
```ts
readonly role = input<string | undefined>(undefined);
```
Visually echoes the `.role-chip` already used on the admin `/profile` page (rounded brand-tinted pill), tightened for the menu header density and `align-self: flex-start` so the pill doesn't stretch.
### Portal-shell
- **Header** ([header.ts](apps/portal-shell/src/app/components/header/header.ts)) gains a `roleLabel` computed that derives `Administrator` / `User` from `CapabilitiesService.canAccessAdmin` — the same logic that previously lived in the sidebar. The template binds it as `[role]="roleLabel()"` on `<lib-user-menu>`.
- **Sidebar** ([sidebar.ts](apps/portal-shell/src/app/components/sidebar/sidebar.ts), [sidebar.html](apps/portal-shell/src/app/components/sidebar/sidebar.html)) loses the role widget entirely: HTML block, `roleLabel` + `roleAriaLabel` computeds, and the `AuthService` + `CapabilitiesService` injections (the sidebar no longer needs them).
- **Sidebar spec** drops its three "role label" tests, grows a guard test asserting `data-testid="sidebar-role"` no longer exists, and reverts to the pre-#151 setup (no Http testing infrastructure — the sidebar no longer fires `/auth/me` or `/me/capabilities`).
- **Header spec** gains two assertions for the role chip inside the open menu panel (`Administrator` when `canAccessAdmin: true`, `User` otherwise).
### Portal-admin
- **Header** ([header.ts](apps/portal-admin/src/app/components/header/header.ts)) passes a **hardcoded `'Administrator'`** through `[role]`. Every reader who reaches the admin app already carries `Portal.Admin` (it's the `AdminRoleGuard` precondition for `/api/admin/*`); no need to re-derive. No i18n marks per ADR-0020's source-locale-only chrome.
### i18n
Five translation units gone from [`messages.fr.xlf`](apps/portal-shell/src/locale/messages.fr.xlf):
```
sidebar.role.anonymous
sidebar.role.administrator
sidebar.role.user
sidebar.role.aria
sidebar.role.label
```
Replaced by two new units under a generic prefix (the new owners are the user menu in either app, not the sidebar):
```
common.role.administrator
common.role.user
```
The `Anonymous` string is gone too — the chip is hidden on anonymous traffic, the label served no purpose without the user menu rendering it.
## Notes for the reviewer
- **Why a generic `common.role.*` prefix rather than `userMenu.role.*`?** The strings are reusable in any context that needs a curated role display (profile page header in a future PR, an audit log "actor role" column, …). Generic prefix avoids the next rename when a second consumer lands.
- **Why hardcode "Administrator" for portal-admin rather than reading `roles`?** The admin SPA's `CurrentUser.roles` carries the raw Entra role string (`Portal.Admin`). Displaying that in the menu header would leak the internal nomenclature into the UI — fine for the `/profile` page (it's explicit context there), wrong for the casual menu glance. The hardcoded label keeps the menu consistent with portal-shell ("Administrator" in both surfaces). If a non-admin role ever reaches portal-admin (the guard rejects it today, but the surface could evolve), this is the one place to revisit.
- **No CapabilitiesService dependency on portal-admin.** The admin app doesn't import the service — its session already exposes `roles` on `/api/admin/auth/me`, and the chip is unconditional. Keeps the admin bundle untouched by the user-portal capabilities plumbing.
- **A11y check.** The role chip is plain text inside the menu panel; the menu panel itself carries `role="menu"` + `aria-label`. The chip doesn't need an extra label — it reads "Administrator" / "User" in context after the displayName and username, which conveys the meaning. No focus state needed (not interactive).
## Test plan
- [x] `pnpm nx run-many -t lint test build --projects=shared-ui,portal-shell,portal-admin` — green.
- `shared-ui` — **10 specs pass** (was 8; +2 for the role chip).
- `portal-shell` — **43 specs pass** (header +2, sidebar -3 + 1 guard = -2 net, balanced to +0 on the total → 43 unchanged).
- `portal-admin` — **50 specs pass** (no spec edits; the `[role]` binding is exercised by the existing user-menu spec via the shared component).
- [x] `pnpm nx build portal-shell --configuration=production` — i18n-strict prod build passes; confirms the xlf cleanup + new `common.role.*` keys are consistent with usage.
- [ ] **Manual smoke**:
- **portal-shell anonymous**: sidebar bottom shows only the collapse toggle (no role line). Click "Sign in" → land back signed in, sidebar bottom unchanged.
- **portal-shell signed in (non-admin)**: avatar → open menu → header shows `Signed in as / displayName / username / [User]` chip.
- **portal-shell signed in (admin)**: same, chip reads `Administrator`, and the menu carries the `Open Portal Admin` row above Sign out.
- **portal-admin signed in**: avatar → open menu → `[Administrator]` chip regardless of which admin user signed in.
- Tabbing across the sidebar bottom no longer pauses on a non-interactive `<p>` element — directly hits the collapse toggle button.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #165
|
||
|
|
035ea7c748 |
fix(portal-admin): adr refs point at gitea, not the madr template repo (#152)
## Summary Two ADR references in `portal-admin` page intros (`/audit`, `/users`) linked to `https://github.com/adr/madr` — the **MADR template repository**, not our ADRs. Copy-paste artefact from the original authoring of those pages. Both anchors now resolve to the actual ADR file on Gitea, so a reviewer who clicks lands on the right document. | Page | Anchor | Was | Now | | --- | --- | --- | --- | | [audit.html](apps/portal-admin/src/app/pages/audit/audit.html) | ADR-0013 | `github.com/adr/madr` | [Gitea `0013-audit-trail-…`](https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0013-audit-trail-separated-postgres-append-only.md) | | [users.html](apps/portal-admin/src/app/pages/users/users.html) | ADR-0020 | `github.com/adr/madr` | [Gitea `0020-portal-admin-app`](https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0020-portal-admin-app.md) | ## Why no in-app ADR viewer A natural follow-up question — "could we render the ADRs inside portal-admin?" — was considered and **rejected** for v1: - **Audience mismatch.** ADR-0020 §"Audience is disjoint" frames portal-admin around APF internal staff doing CMS / audit / user-directory work. ADRs are dev / architecture artefacts mentioning Prisma, Redis, MSAL Node, OBO trade-offs — content that doesn't help an operator and blurs the line between "ops tool" and "internal tech doc". - **Architecture cost.** A live viewer would require a Markdown-rendering pipeline on the SPA, BFF↔filesystem coupling (or a build-time embedding that breaks the auto-update intent), inter-ADR link rewriting, and English-only fallback in an otherwise i18n-capable app. - **Better alternative exists.** If discoverability of `docs/**/*.md` becomes a real need, a separate static-site (MkDocs Material / Docusaurus / VitePress) deployed on `docs.portal.apf.fr` with a CI hook on `docs/` changes is the battle-tested path. That's the chantier slot — a separate ADR + setup, not bolt-on inside portal-admin. ## Test plan - [x] `pnpm nx run-many -t lint test build --projects=portal-admin` — clean, 50 specs pass, lazy chunks unchanged. - [ ] Visual smoke: open `/audit` and `/users` in portal-admin, click the inline ADR badge, confirm the Gitea ADR page loads in a new tab. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #152 |
||
|
|
ee51efb688 |
feat(portal): /api/me/capabilities + cross-app menu links + real role label (#151)
## Summary PR 3 of 3 — final piece of the user-menu / profile / cross-app chantier. Closes the loop with the BFF capabilities endpoint, symmetric cross-app entries in both user menus, and a real role label in place of the hardcoded "Anonymous" widget on the portal-shell sidebar. | PR | Périmètre | | --- | --- | | PR 1 ✅ | Shared `UserMenu` dropdown + integration. | | PR 2 ✅ | `/profile` pages on both apps. | | **PR 3 (this one)** | `GET /api/me/capabilities` + real sidebar role label + cross-app menu entries. | ## What lands ### BFF — `GET /api/me/capabilities` New [`MeModule`](apps/portal-bff/src/me/me.module.ts) wiring a single endpoint: ```ts GET /api/me/capabilities → { canAccessAdmin: boolean } ``` Resolved against the user-portal session ([`portal_session`](apps/portal-bff/src/me/me.controller.ts) — the path-routed session middleware in `main.ts` already maps `/api/me/*` to that session). Returns 401 if no session is present, consistent with `/api/auth/me`. 5 specs cover the four state combinations + a regression-fence asserting the curated view never leaks the raw `roles` array. ### ADR-0009 amendment [`docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md`](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md) — new **"Curated public view"** section codifies the design stance the user picked when we agreed the staging: > The `/auth/me` payload exposes a deliberately narrow projection of the session: `oid`, `tid`, `username`, `displayName`. **The raw `roles` claim is _not_ part of `/auth/me`** — it stays server-side […]. The SPA derives binary UX hints from a dedicated companion endpoint […]. The shape is intentional: the SPA can never reconstruct the raw role names from the curated view, so introducing additional internal-only roles […] does not widen the SPA-side surface. The routes table grows a row for `/me/capabilities`. ### Portal-shell — capabilities-driven UI - **New service** [`CapabilitiesService`](apps/portal-shell/src/app/services/capabilities.service.ts) (app-local, not in `feature-auth` — the admin app has its own roles channel via `/api/admin/auth/me` and would never use this). Signals: `capabilities`, `canAccessAdmin`. Fires `GET /me/capabilities` reactively via an `effect` that watches `auth.currentUser()`. Anonymous sessions short-circuit to the all-false default without a fetch — the BFF would 401 anyway. - **Header** ([`header.ts`](apps/portal-shell/src/app/components/header/header.ts)) — `userMenuItems` is now a `computed`. Profile + Settings stay unconditional; **"Open Portal Admin"** appears only when `canAccessAdmin()` flips true, with `href = environment.adminAppUrl`. Per ADR-0020 the two SPAs live on distinct origins, so this is a raw cross-origin anchor, not a routerLink. - **Sidebar** ([`sidebar.ts`](apps/portal-shell/src/app/components/sidebar/sidebar.ts) + [`sidebar.html`](apps/portal-shell/src/app/components/sidebar/sidebar.html)) — the hardcoded `Role: Anonymous` widget is replaced by a derived computed: - `Anonymous` when no session. - `Administrator` when `canAccessAdmin()` is true. - `User` otherwise (signed-in, no admin). The aria-label gains a `role` placeholder so screen readers hear the live value. ### Portal-admin — symmetric cross-app entry - **Header** ([`header.ts`](apps/portal-admin/src/app/components/header/header.ts)) — adds an unconditional `Open Portal Shell` row pointing at `environment.shellAppUrl`. Anyone able to reach portal-admin can reach portal-shell, so no capabilities check needed; admins always benefit from a one-click jump back to the end-user surface. ### Environments `adminAppUrl` and `shellAppUrl` added to the respective [`environment.ts`](apps/portal-shell/src/environments/environment.ts) files (dev defaults: `:4300` for admin, `:4200` for shell). Per-env siblings (staging / prod) will override the host once they exist, per ADR-0018. ### i18n | Key | EN source | FR target | | --- | --- | --- | | `header.userMenu.openAdmin` | Open Portal Admin | Ouvrir Administration APF Portal | | `sidebar.role.administrator` | Administrator | Administrateur | | `sidebar.role.user` | User | Utilisateur | | `sidebar.role.aria` | reshaped with `{role}` placeholder | reshaped likewise | Admin-side strings stay in English source per ADR-0020. ## Notes for the reviewer - **Why `CapabilitiesService` in the app, not in `feature-auth`?** Only `portal-shell` will ever call `/api/me/capabilities` — the admin SPA hits `/api/admin/auth/me` which already returns `roles`. Putting the service in `feature-auth` would publish a tree-shakable `providedIn: 'root'` injectable that ships in both bundles. Keeping it app-local makes the boundary explicit. - **Why a `computed` for `userMenuItems` rather than mutating an array in an `effect`?** Signals + computed = single source of truth. The shared `UserMenu` re-renders automatically when the items list changes (whenever capabilities flips). Less ceremony than maintaining a `WritableSignal<UserMenuItem[]>`. - **Why the `flushPendingEffects` test helper?** Zoneless apps rely on the signals scheduler to dispatch `effect()` callbacks via micro-task scheduling. `fixture.detectChanges() + whenStable()` once is not enough: the chain is `meReq.flush()` → `_state.set()` → effect scheduled → effect fires → `http.get()` queued. The helper loops 4× to give the scheduler enough rounds to settle before `expectOne(CAPABILITIES_URL)` looks up the request. - **Why no test for the dev URL values?** `environment.ts` is config that gets swapped at build time per ADR-0018; the values themselves are environmental. Asserting the dev value in a test would lock in a port (4200/4300) that's separately configured in `project.json`. ## Test plan - [x] `pnpm nx test portal-bff` — **401 specs pass** (was 396, +5 for `MeController`). - [x] `pnpm nx test portal-shell` — **40 specs pass** (was 35, +5: 3 sidebar role-label + 2 header admin-link). - [x] `pnpm nx run-many -t lint test build --projects=portal-shell,portal-admin,portal-bff,shared-ui,shared-state,feature-auth` — 18/18 tasks green, including the i18n-strict `portal-shell:build:production`. - [ ] Manual smoke (with a `Portal.Admin`-assigned account): - Sign in on portal-shell → sidebar reads `Role: Administrator`, user menu lists `Open Portal Admin` at the bottom (right above the Sign-out separator), clicking the link opens `localhost:4300`. - Sign in on portal-admin → user menu lists `Open Portal Shell`, clicking opens `localhost:4200`. - Sign in on portal-shell with a non-admin account → sidebar reads `Role: User`, `Open Portal Admin` is absent. - Sign out → sidebar reads `Role: Anonymous`, the menu collapses to its anonymous-state Sign-in button. ## What's next Chantier closed. The user-menu shape is now stable; further entries (notifications inbox, theme override, locale switcher inside the menu rather than the footer) plug into the existing `items` API without re-shaping the component. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #151 |
||
|
|
1160771061 |
feat(portal-admin): profile page on /profile guarded by authGuard (#150)
## Summary PR 2 of 3 from the user-menu / profile / cross-app-link chantier. Lands a `/profile` page on `portal-admin` so the Profile entry the user menu wired in PR 1 stops 404-ing. Portal-shell already had a demo `/profile` route that fits this slot — left as-is rather than churned. | PR | Périmètre | | --- | --- | | PR 1 ✅ | Shared `UserMenu` dropdown + integration. | | **PR 2 (this one)** | `/profile` page on `portal-admin`; `CurrentUser.roles` typed (the BFF already returns it). | | PR 3 | `/api/me/capabilities` + sidebar role widget + cross-app links. | ## What lands ### New page — [`apps/portal-admin/src/app/pages/profile/`](apps/portal-admin/src/app/pages/profile/) Lazy-loaded at `/profile`, guarded by `authGuard` (from `feature-auth`). Two cards: - **Identity** — displayName, username, Entra `oid`, tenant `tid`. The two ids are monospaced + break-anywhere so they don't push the layout on narrow viewports. - **App roles** — chips listing every Entra app-role claim the session carries (`Portal.Admin` for v1, future business roles once ADR-0011 step-up lands its real consumers). Hidden entirely when the `roles` array is empty so anonymous-then-promoted accounts don't see a "no roles" affordance that doesn't help anyone. The `@if (user())` template guard narrows the type so the page is single-branch — `authGuard` already blocked anonymous traffic upstream. Lazy chunk lands at **5.37 KB raw / 1.57 KB gzip** — comfortably under the per-chunk lazy budget. ### Route — [`apps/portal-admin/src/app/app.routes.ts`](apps/portal-admin/src/app/app.routes.ts) ```ts { path: 'profile', canActivate: [authGuard], loadComponent: () => import('./pages/profile/profile').then((m) => m.ProfilePage), title: profileTitle, } ``` `route.profile.title` added to [`messages.fr.xlf`](apps/portal-admin/src/locale/messages.fr.xlf) (FR target retained for parity with the other route titles, even though portal-admin doesn't expose a locale switcher in v1 per ADR-0020). ### Type — [`libs/feature/auth/src/lib/auth.types.ts`](libs/feature/auth/src/lib/auth.types.ts) ```ts export interface CurrentUser { … /** Optional; present on `/api/admin/auth/me`, omitted on `/api/auth/me`. */ readonly roles?: readonly string[]; } ``` `/api/admin/auth/me` already returns `roles` (PR #129), but `CurrentUser` was discarding it through the typed deserialization. Marking the field optional captures the existing BFF response without surfacing the claim on the user portal — ADR-0009's "curated public view" stays intact on `/api/auth/me`, which simply doesn't populate it. ## Notes for the reviewer - **Why not refresh `portal-shell`'s `/profile` too?** The existing demo page already has the same shape (displayName, username, oid, tid) and is functionally fine. Refreshing for parity would be incidental scope creep against PR 2's goal (unblock the Profile menu entry on the admin surface). PR 3 may revisit when it adds the role display widget. - **Why no `User profile` entry in the admin sidebar?** The user menu already exposes Profile and ADR-0020's v1 sidebar catalogue is "modules", not "self-service shortcuts". Adding a sidebar entry would duplicate the user-menu access path and break the rule that the sidebar maps to admin business modules. - **Why a single English-source page, not FR-translated content?** Same posture as the audit + users pages: per ADR-0020 §"No locale switcher in v1", admin chrome stays in source locale. Route titles are i18n-marked because the prod build's `i18nMissingTranslation=error` policy would fail otherwise — the page body text doesn't need to be. - **Why not show roles on the portal-shell side too?** That's PR 3, gated on the `/api/me/capabilities` design (the user picked the dedicated capabilities endpoint over `roles`-on-user-/me). Adding any role-related rendering here would pre-empt that choice. ## Test plan - [x] `pnpm nx test portal-admin` — **50 specs pass** (was 46, +4 for the new `ProfilePage` spec). - [x] `pnpm nx run-many -t lint test build --projects=portal-shell,portal-admin,shared-ui,shared-state,feature-auth` — 15/15 tasks green, including the i18n-strict `portal-shell:build:production`. - [ ] Manual smoke — sign in on portal-admin, click the avatar → Profile entry, confirm: identity card populated, App roles card shows `Portal.Admin` (if you have the role assigned), Settings entry of the user menu still greyed with the Soon badge. ## What's next PR 3 lands the cross-app machinery — `GET /api/me/capabilities` endpoint, real role surfacing on the user-portal sidebar widget (`Anonymous` → `Authenticated` / role label, no more hardcode), and the symmetric "Open Portal Admin" / "Open Portal Shell" entries in each app's user menu, gated on the capabilities response. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #150 |
||
|
|
a8ead65ea8 |
feat(shared-ui): user menu dropdown + integration on portal-shell + portal-admin (#149)
## Summary
PR 1 of 3 from the user-menu / profile / cross-app-link chantier per the agreed staging:
| PR | Périmètre |
| --- | --- |
| **PR 1 (this one)** | Shared `UserMenu` dropdown component + integration on `portal-shell` and `portal-admin`; anonymous Sign-in button moves to `rounded-md`. |
| PR 2 | `/profile` pages on both apps. |
| PR 3 | `/api/me/capabilities` endpoint + real role surfacing on the sidebar widget + cross-app links in the menu (both directions). |
Lands the gitea / github-shaped avatar dropdown so admins and end users get the familiar "Signed in as / Profile / Settings / Sign out" pattern. Future entries (cross-app link, role-based visibility) plug into the same `items` input without touching the shared component.
## What lands
### Shared component — [`libs/shared/ui/src/lib/user-menu/`](libs/shared/ui/src/lib/user-menu/)
```html
<lib-user-menu
[displayName]="state.user.displayName"
[username]="state.user.username"
[initials]="initials()"
[items]="userMenuItems"
[signedInAsLabel]="…"
[signOutLabel]="…"
[triggerAriaLabel]="…"
(signOut)="signOut()"
/>
```
- Avatar trigger (initials in a rounded square + chevron-down hint), CDK `cdkMenuTriggerFor` opening the panel in an overlay portal. Same primitives `ThemeSwitcher` and `LocaleSwitcher` already use — keyboard nav, focus management and Escape-to-close come for free.
- Panel layout: "Signed in as" small-caps header → `displayName` → `username` → separator → caller-supplied `items` → separator → dedicated Sign out row at the bottom.
- `UserMenuItem` shape supports `routerLink` (intra-app) **and** `href` (cross-app / external) so PR 3's "Open Portal Admin" entry can land without re-shaping the API.
- Disabled items render as `aria-disabled` rows with a right-aligned badge — same Soon-chip pattern the admin sidebar already uses for not-yet-shipped entries.
- Sign out is **not** an item — it's a hardcoded row that emits a `signOut` output. Avoids special-casing item types and keeps the destructive action visually + structurally distinct.
- Avatar background is driven by `--user-menu-avatar-bg` / `--user-menu-avatar-fg` CSS custom properties so each host header can re-skin without forking the component (portal-admin uses translucent white over the brand-primary-600 header; portal-shell keeps the default brand-primary-500).
### Portal-shell integration — [`apps/portal-shell/src/app/components/header/`](apps/portal-shell/src/app/components/header/)
- Authenticated state in [header.html](apps/portal-shell/src/app/components/header/header.html) swaps the inline avatar + display name + Sign-out button for `<lib-user-menu>`.
- Anonymous Sign-in button moves from `rounded-full` → `rounded-md` per the reference image. Loading + error chips follow for visual consistency with the new square-ish avatar.
- 6 new strings in [`messages.fr.xlf`](apps/portal-shell/src/locale/messages.fr.xlf): `header.userMenu.{profile,settings,signedInAs,signOut,trigger.aria}` + `common.badge.soon`.
### Portal-admin integration — [`apps/portal-admin/src/app/components/header/`](apps/portal-admin/src/app/components/header/)
- Same swap, leaner: the admin header keeps its inline `.btn--primary` / `.btn--secondary` for anonymous + error states (no search bar / notification cluster, per ADR-0020), only the authenticated state goes through the menu.
- Overrides `--user-menu-avatar-bg` / `--user-menu-avatar-fg` in [`header.scss`](apps/portal-admin/src/app/components/header/header.scss) under `.auth-widget lib-user-menu` so the avatar reads on the brand-primary-600 background. No FR labels (no admin locale in v1 per ADR-0020).
## Notes for the reviewer
- **Why `Sign out` outside the `items` array?** It's the only destructive action in the panel + always present + emits an event rather than navigates. Keeping it special-cased lets the `items` API stay tight (`{ label, icon?, routerLink? | href?, disabled?, badge? }`) and gives each app a single typed entry point (`signOut` output) rather than a brittle `items[].action === 'sign-out'` discriminator.
- **Why `data-testid="user-menu"` on the component host?** The shared spec already covers panel internals; each app's spec needs a top-level handle to reach the trigger without coupling to the avatar CSS class. Same pattern as the existing `data-testid="sign-in-button"`.
- **Profile entry points at `/profile` on both apps in this PR.** Portal-shell already has a demo `/profile` route, so the link works; portal-admin will 404 until PR 2 lands the actual page. Interim cost is acceptable given PR 2 lands directly behind this.
- **No ADR for the component.** It's a UI primitive in `libs/shared/ui` — same tier as `Icon`. Promotion criteria, dark-mode, a11y, and i18n all follow the existing ADRs already in force (ADR-0004 + 0016 + 0019).
## Test plan
- [x] `pnpm nx test shared-ui` — **8 specs pass** (was 3, +5 for `UserMenu`).
- [x] `pnpm nx test portal-shell` — **35 specs pass** (was 34, +1 for the new "opens menu" assertion).
- [x] `pnpm nx test portal-admin` — **46 specs pass** (was 45, +1 for the new "opens menu" assertion).
- [x] `pnpm nx run-many -t lint test build --projects=portal-shell,portal-admin,shared-ui,shared-state,feature-auth` — 15/15 tasks green, including the i18n-strict `portal-shell:build:production`.
- [ ] Manual smoke — sign in on portal-shell, confirm the avatar lives at the top right with the dropdown opening on click / Enter / ArrowDown, Profile navigates to `/profile`, Settings appears greyed with a "Soon" badge, Sign out triggers the BFF logout. Repeat on portal-admin (the avatar background should pick the translucent-white override over the brand-primary-600 header).
## What's next
PR 2 picks up directly: builds a real `/profile` page on each app, both reading from `feature-auth`'s `currentUser()` signal.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #149
|
||
|
|
e3e7800ecb |
feat(portal-admin): header brand logo + sidebar background covers full column (#148)
## Summary
Two small portal-admin polish items so the admin shell stops looking like a scaffold:
1. **APF mark in the header**, before the `APF Portal` wordmark — same lock-up portal-shell already ships.
2. **Sidebar gray background + right border now cover the entire column**, not just the rail of nav links.
## What lands
### Header — brand logo
[`apps/portal-admin/src/app/components/header/header.html`](apps/portal-admin/src/app/components/header/header.html) — `<img>` slipped in before the wordmark inside the `.brand` flex row:
```html
<img src="logos/apf-logo.svg" alt="" aria-hidden="true" width="28" height="28" class="brand-logo" />
```
The image is **decorative**: `alt=""` + `aria-hidden="true"`. The accessible name of the brand link is already carried by `APF Portal` + the `Admin` badge — adding a non-empty `alt` here would just produce noise for screen-reader users (WCAG 1.1.1 / Decorative images).
Sized at 28×28 (1.75rem) to keep the lock-up proportionate to the admin header's compact 3.5rem height — portal-shell uses 36×36 in a 4rem header. The new `.brand-logo` SCSS rule pins `display: block; flex-shrink: 0; height/width: 1.75rem` so the SVG can't be squashed by other header content.
### Sidebar — full-column chrome
[`apps/portal-admin/src/app/components/sidebar/sidebar.scss`](apps/portal-admin/src/app/components/sidebar/sidebar.scss) — the gray background and right border move from the inner `<nav class="admin-sidebar">` to `:host` (i.e. the `<app-admin-sidebar>` element itself). Result: the chrome covers the whole flex column, not just the area the link list occupies.
Three things had to move together for the visual to land:
- **`background-color` + `border-right`** — what the user asked for.
- **`width: 16rem` + `flex: 0 0 16rem`** — the host is the direct flex child of `.shell-body`; sizing must live on the flex item, not on a grandchild.
- **`display: flex; flex-direction: column`** — Angular custom elements default to `display: inline`. Without an explicit display, background and border don't render on the host, regardless of the parent's `align-items: stretch`.
The inner `<nav>` now keeps just its content concerns: `padding` for the list breathing room, `overflow-y: auto` for the scroll, and `flex: 1 1 auto` so it grows to fill the host envelope.
Dark-mode variants follow the same move — `:host-context(.dark)` swaps the host's background + border-color directly.
### Assets
`apps/portal-admin/public/logos/{apf-logo.svg, apf-portal.svg}` mirror the portal-shell `public/logos/` layout; Angular's build pipeline picks them up at `/logos/*` per the standard static-asset convention.
## Test plan
- [x] `pnpm nx test portal-admin` — **45 specs pass**, unchanged. Header spec asserts on `.brand-wordmark` + `.brand-badge` (untouched); sidebar spec asserts on the `<nav>` landmark + RouterLinks (also untouched).
- [x] `pnpm nx lint portal-admin` — clean.
- [x] `pnpm nx build portal-admin` — clean; lazy chunks unchanged (`audit` 4.35 KB gzip, `users` 3.76 KB gzip).
- [ ] Visual smoke in dev — sign in, confirm the APF mark renders to the left of the wordmark, then navigate to `/audit` and `/users` and confirm the gray background + right border extend the full height of the sidebar column.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #148
|
||
|
|
6120471b66 |
chore(workspace): tsconfig composite refs + axe-linter false-positive config (#147)
## Summary
Three editor-noise sources flagged by the VS Code TypeScript service + the Deque axe Linter extension, each tamed at the right layer. No runtime behaviour change.
| Source | Fix |
| --- | --- |
| **TS6306** — Referenced project `libs/{shared/ui,shared/state,feature/auth}` must have `composite: true`. Nx 22's lib generator doesn't emit `composite`; modern VS Code TS service flags it. | Add `composite: true` to each lib's `tsconfig.lib.json`, let `nx sync` redirect consumer references in `apps/portal-{shell,admin}/tsconfig.app.json` to point at the `.lib.json` directly. |
| **TS6504** — `moduleResolution: "node"` / `"node10"` deprecated, removed in TS 7.0. Two hits on the BFF tsconfigs. | Add `ignoreDeprecations: "5.0"` on `apps/portal-bff/tsconfig.{app,spec}.json` — the opt-out knob the diagnostic itself suggests. A proper migration to `nodenext`/`node16` is a separate chantier. |
| **axe-core/list (WCAG 1.3.1)** — `<ul>` "must only directly contain `<li>`, `<script>`, or `<template>`" — fires on Angular 17+ `@for` blocks inside lists. Pure static-linter limitation; rendered DOM is fine. | New `.axe-linter.yml` at repo root: `global-disable: [list]`. |
## What lands
### `composite: true` on lib `.lib.json`
[`libs/shared/ui/tsconfig.lib.json`](libs/shared/ui/tsconfig.lib.json), [`libs/shared/state/tsconfig.lib.json`](libs/shared/state/tsconfig.lib.json), [`libs/feature/auth/tsconfig.lib.json`](libs/feature/auth/tsconfig.lib.json) get `composite: true` added. `nx sync` then automatically rewrites consumer references:
```diff
- "path": "../../libs/shared/ui"
+ "path": "../../libs/shared/ui/tsconfig.lib.json"
```
in [`apps/portal-shell/tsconfig.app.json`](apps/portal-shell/tsconfig.app.json) and [`apps/portal-admin/tsconfig.app.json`](apps/portal-admin/tsconfig.app.json). Semantically cleaner — the app references the lib's actual compile config (which produces the `.d.ts` it consumes), not the lib's solution-style root tsconfig.
**Earlier attempt — composite on the solution `tsconfig.json` — silently broke `vitest`**: the Angular Vite plugin chokes on a composite project with `files: []` / `include: []` and falls through, leaving spec files loaded but tests not registered (`"No test suite found in file"`). Moving `composite` to `.lib.json` (the project that actually has inputs) fixes the contract without poking the plugin.
### `ignoreDeprecations: "5.0"`
[`apps/portal-bff/tsconfig.app.json`](apps/portal-bff/tsconfig.app.json) and [`apps/portal-bff/tsconfig.spec.json`](apps/portal-bff/tsconfig.spec.json) — silences `Option 'moduleResolution=node10' is deprecated and will stop functioning in TypeScript 7.0`. The diagnostic suggests `"6.0"` as the value, but TS 5.9 (our pinned version) only accepts `"5.0"`; using `"6.0"` results in `TS5103: Invalid value for '--ignoreDeprecations'` and breaks every spec. `"5.0"` is the current-gen accepted value.
The deprecation is real — TS 7.0 will drop both `"node"` and `"node10"` `moduleResolution` modes. The migration target is `moduleResolution: "nodenext"` paired with matching `module: "nodenext"`, but that interacts non-trivially with Nest's CommonJS pipeline and the BFF's import semantics. Out of scope for a drive-by fix; we'll handle it as a dedicated chantier when TS 7.0 lands on the roadmap.
### `.axe-linter.yml`
New file at repo root:
```yaml
global-disable:
- list
```
The Deque axe Linter VS Code extension reads `.axe-linter.yml` at workspace root. The `list` rule (WCAG 1.3.1) fires false positives on Angular 17+ control-flow syntax — `@for (item of list; ...) { <li>… }` looks like a non-`<li>` child of `<ul>` to a static HTML scanner. The Angular compiler erases those tokens at build time; the rendered DOM is compliant. CI accessibility coverage is provided by `axe-playwright` per [ADR-0016 §"Tooling"](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md) — it runs against the rendered DOM and is unaffected by this disable.
## Notes for the reviewer
- **Why not `composite: true` on every lib?** Per CLAUDE.md "no premature abstractions" — `libs/shared/{tokens,util}` are not currently referenced by any `tsconfig.app.json`, so they don't trigger TS6306. Adding `composite` to them would be future-proofing without a current consumer. When a consumer reference is added, the same one-line fix lands then.
- **Why not migrate `moduleResolution` properly?** The BFF runs on Nest's CommonJS pipeline; `nodenext` brings stricter ESM resolution (`.js` extensions in imports, package `exports` map enforcement) that ripples through. Not a 5-minute change. The `ignoreDeprecations` knob is the textbook defer mechanism for exactly this case.
- **Why disable `list` globally rather than per-file?** The rule's false-positive pattern (`<ul><@for>` / `<ol><@for>`) applies workspace-wide; we use `@for` consistently across `portal-shell` + `portal-admin`. Per-file disables would multiply as new templates land. axe-playwright remains the authoritative check on the rule.
## Test plan
- [x] `pnpm nx run-many -t lint test build --projects=portal-shell,portal-admin,portal-bff,shared-ui,shared-state,feature-auth` — 18/18 tasks pass. **517 specs green** across the affected projects.
- [x] `pnpm nx sync:check` — workspace in sync after the changes; running `sync` again is a no-op.
- [ ] Editor smoke — reopen the workspace in VS Code: the TS6306 errors on lib `tsconfig.json` files should be gone, the two `moduleResolution=node10` deprecation lines on BFF tsconfigs should be silenced, and the `list` rule under `sidebar.html` (`portal-admin`) should no longer surface.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #147
|
||
|
|
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 |
||
|
|
40741ce326 |
feat(portal-admin): spa auth wiring + admin shell skeleton (#134)
## Summary Phase-3a step per [ADR-0020](docs/decisions/0020-portal-admin-app.md) §"Confirmation" item 4 (entry route + admin shell). Wires the existing [`feature-auth`](libs/feature/auth) library against the distinct admin OIDC routes (`/api/admin/auth/*`) the BFF exposes since PR #129, ships a lean header/sidebar/footer chrome with an "Admin" badge so an internal user can never mistake the surface for portal-shell, and gives the landing page a self-test panel that confirms the auth chain end-to-end as soon as an `admin` Entra role gets assigned. ## What lands ### Lib change — `AUTH_PATH_PREFIX` injection token [`libs/feature/auth`](libs/feature/auth/src/lib/auth.config.ts) gains one injection token. AuthService composes URLs as `${bffBaseUrl}${pathPrefix}/{me,login,logout}` instead of hard-coding `/auth/...`. Default factory returns `/auth`, so portal-shell-shaped consumers keep working without an explicit provider — no churn on existing call sites. Admin hosts override with `/admin/auth`. The interceptors (`bffCredentialsInterceptor`, `csrfInterceptor`, `bffUnauthorizedInterceptor`) are **unchanged** — they only care about the BFF base URL, not the path prefix. ### portal-admin wiring - [environment.ts](apps/portal-admin/src/environments/environment.ts) — same shape as portal-shell, same BFF base URL (both SPAs talk to one BFF per ADR-0020 §"Where does the admin app live"). Same CSRF cookie name in v1. - [app.config.ts](apps/portal-admin/src/app/app.config.ts) — `HttpClient` with the standard interceptor chain (credentials → csrf → unauthorized), `AUTH_BFF_BASE_URL` from env, `AUTH_PATH_PREFIX = '/admin/auth'`, `AUTH_CSRF_COOKIE_NAME` from env. ### Admin shell - **[AdminHeader](apps/portal-admin/src/app/components/header/header.ts)** — APF wordmark + persistent "Admin" badge + auth widget. No global search / notifications / help cluster: admins land on tabular workloads, not a discovery dashboard (ADR-0020 §"UX style is data-dense"). - **[AdminSidebar](apps/portal-admin/src/app/components/sidebar/sidebar.ts)** — static menu listing the four ADR-0020 v1 modules. Audit log is a live router link (target of the next PR); the others are `aria-disabled` placeholders with a "Soon" badge so the navigation shape is visible even before they ship. - **[AdminFooter](apps/portal-admin/src/app/components/footer/footer.ts)** — copyright + persistent "Admin surface" tag. Stays in view for long workloads where the header has scrolled off. ### Home — auth self-test panel [apps/portal-admin/src/app/pages/home/](apps/portal-admin/src/app/pages/home/): - Signed-in: shows `displayName`, `username`, `tenant`, `oid` in a monospaced detail list. - Anonymous: "No admin session detected" + a "Sign in via Entra" button that delegates to `AuthService.login()` → 302 through `/api/admin/auth/login`. - Error: "Could not reach the BFF" + retry button. - Roadmap list quoting ADR-0020's v1 catalogue. ## Known limitations (v1, documented) - **CSRF cookie shared between surfaces.** Both portals issue `portal_csrf` on session creation. A user with both portals open will see overwrites on the second sign-in; mutating actions on the first surface will then 403 until refresh. Splitting to `portal_admin_csrf` is a follow-up if the pattern becomes common. - **Shell chrome strings are plain English.** The admin app's `$localize` plumbing is wired (matches portal-shell), but adding markers to the shell here would require regenerating `messages.fr.xlf` and `i18nMissingTranslation=error` fails the prod build on every gap. Full admin i18n is its own follow-up. - **`LayoutStateService` not yet consumed.** No collapse toggle in v1. Theme preference still threads through because both apps read the same `localStorage` key — toggle in portal-shell, see it honoured in portal-admin. - **`ci:perf` only runs against portal-shell.** Admin perf budgets are enforced at build time by `apps/portal-admin/project.json` (`maximumError == maximumWarning` per ADR-0020's relaxed thresholds: 500 KB initial JS / 1.5 MB initial total). Adding admin to `pnpm ci:perf`'s gzip + Lighthouse chain is a follow-up. ## Test plan - [x] `pnpm nx test feature-auth` — **29 specs pass** (was 28; +1 for the `AUTH_PATH_PREFIX` override). - [x] `pnpm nx test portal-admin` — **15 specs pass** (was 1; +14: AdminHeader 5, AdminSidebar 3, AdminFooter 2, Home 4, App 1 expanded). - [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean. - [x] Gzip-budget script run manually against `dist/apps/portal-admin/browser`: 86.65 KB initial / 300 KB budget, 4.46 KB CSS / 150 KB budget, 2.15 KB largest lazy / 100 KB per-chunk budget. Both `en` + `fr` bundles checked thanks to PR #133's multi-locale detection. - [ ] e2e — pending an Entra `admin` role assignment. Once available: `pnpm nx serve portal-admin`, visit `http://localhost:4300`, see "No admin session detected", click "Sign in via Entra", complete the round-trip, land back on `/` with the signed-in payload + `portal_admin_session` cookie set. ## Notes for the reviewer - `AdminHeader` is intentionally narrower than `Header` from portal-shell — no shared base class. The two are likely to evolve in different directions (admin-specific banner with system-status badges, audit-trail link, etc.) and a premature abstraction would be expensive to undo. - `AdminSidebar`'s "Soon" badge is a deliberate signal — without it, an admin who clicked a placeholder link and got a route-not-found would assume the app is broken. The badge sets expectations. - All shell components use `:host-context(.dark)` for dark-mode SCSS instead of `:host(.dark)` — same pattern as portal-shell, since the `.dark` class lives on `<html>` outside the component's view encapsulation. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #134 |
||
|
|
9443a52bb7 |
chore(brand): swap header logo to a real svg + ship favicons for portal-admin (#106)
## Summary Two related brand-asset cleanups, bundled per request: ### portal-shell — header logo - Replace [`apf-small.png`](apps/portal-shell/public/logos/) (a 7.6 KB raster we'd extracted from the original PNG-in-SVG and re-encoded through sharp) with [`apf-logo.svg`](apps/portal-shell/public/logos/apf-logo.svg) — an actual 1024×1024 vector export (2.1 KB). Sharper at any density, smaller payload, no rasterisation artefacts when zoomed. - [`header.html`](apps/portal-shell/src/app/components/header/header.html) swaps `<img src=…>` accordingly. - The wide-format `apf-portal.svg` stays in place for future surfaces (login splash, etc.). ### portal-admin — favicons + PWA manifest Mirrors what PR #84 did for `portal-shell`: - Copy the six favicon images (`favicon.ico`, `favicon.svg`, `favicon-96x96.png`, `apple-touch-icon.png`, `web-app-manifest-{192,512}.png`) into [`apps/portal-admin/public/favicons/`](apps/portal-admin/public/favicons/). Single visual identity across the two apps. - Customise [`site.webmanifest`](apps/portal-admin/public/favicons/site.webmanifest) for admin: `name: "APF Portal Admin"`, `short_name: "Admin"`. Everything else (icons, theme-color, display) stays identical to portal-shell's manifest. - Wire the `<link>` block + `<meta name="theme-color">` in [`apps/portal-admin/src/index.html`](apps/portal-admin/src/index.html). - Remove the obsolete top-level `apps/portal-admin/public/favicon.ico` — now under `favicons/`, served via `<link rel="shortcut icon">`. ## Decision worth flagging **Assets duplicated rather than shared via a lib.** Both apps ship their own copy of the 7 favicon files (~120 KB binary each). The alternative — a `shared-assets` (or extended `shared-tokens`) lib with the assets glob copied into each app's `dist/` at build time — is the architecturally tidier path, but introduces a build-config change with no real payoff at our scale. Revisit if a third surface (e.g., a future static landing page) ends up needing the same set. ## Verification - `nx run-many -t lint test build --projects=portal-shell,portal-admin` — green. - Both `dist/apps/{portal-shell,portal-admin}/browser/{en,fr}/favicons/` ship the seven expected files. - Admin's emitted `site.webmanifest` carries `"name": "APF Portal Admin"`. - Admin's emitted `index.html` carries the full `<link>` block + `theme-color` meta. - Portal-shell ships `apf-logo.svg` in its `logos/` folder per locale; `apf-small.png` is gone. ## Test plan - [x] Lint + test + build green. - [x] Built outputs spot-checked (assets ship, manifest text correct, index.html wired). - [ ] Manual: `nx serve portal-shell` → header shows the new SVG logo crisp at 1x / 2x / 3x DPR. - [ ] Manual: `nx serve portal-admin` → tab favicon visible, dev tools → Application → Manifest shows "APF Portal Admin" with no errors. - [ ] Manual: install portal-admin as a PWA from a Chromium browser → the install dialog reads "Install APF Portal Admin", home-screen icon uses the same family as portal-shell. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #106 |
||
|
|
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
|