feat(shared-ui): user menu dropdown + integration on portal-shell + portal-admin #149

Merged
julien merged 1 commits from feat/shared-user-menu-and-integration into main 2026-05-15 15:23:40 +02:00
Owner

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/

<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 → displayNameusername → 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/

  • Authenticated state in header.html swaps the inline avatar + display name + Sign-out button for <lib-user-menu>.
  • Anonymous Sign-in button moves from rounded-fullrounded-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: header.userMenu.{profile,settings,signedInAs,signOut,trigger.aria} + common.badge.soon.

Portal-admin integration — 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 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

  • pnpm nx test shared-ui8 specs pass (was 3, +5 for UserMenu).
  • pnpm nx test portal-shell35 specs pass (was 34, +1 for the new "opens menu" assertion).
  • pnpm nx test portal-admin46 specs pass (was 45, +1 for the new "opens menu" assertion).
  • 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.

## 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.
julien added 1 commit 2026-05-15 15:23:02 +02:00
feat(shared-ui): user menu dropdown + integration on portal-shell + portal-admin
CI / commits (pull_request) Successful in 3m7s
CI / scan (pull_request) Successful in 3m7s
CI / check (pull_request) Successful in 4m17s
CI / a11y (pull_request) Successful in 1m39s
CI / perf (pull_request) Successful in 4m36s
5f5e6d8e35
PR 1 of 3 from the user-menu / profile / cross-app-link chantier.

* New `UserMenu` standalone component in `libs/shared/ui`. Avatar
  trigger + CDK menu panel (gitea / github shape) — "Signed in as"
  header, configurable rows (Profile, Settings — marked Soon),
  separator, dedicated Sign out row at the bottom. Keyboard nav,
  ARIA, and overlay portal handled by `@angular/cdk/menu`, same
  primitives the theme + locale switchers already use.

* Each app's header consumes the shared component for the
  authenticated state. The loading / anonymous / error widgets stay
  app-local because they diverge by surface (portal-shell has a
  search bar + notification cluster, portal-admin is leaner).

* Portal-shell anonymous Sign-in button moves from `rounded-full` to
  `rounded-md`. The loading + error chips follow for visual
  consistency with the new square-ish avatar.

* Portal-admin overrides the shared `--user-menu-avatar-bg` CSS
  custom property to a translucent white so the avatar reads on
  the brand-primary-600 header background.

Out of scope for this PR (covered by PR 2 + 3 of the chantier):
profile pages, real roles surfaced on portal-shell, cross-app
links between the two surfaces. The user menu's `items` input is
designed to absorb those additions without touching the component.

i18n: 6 new strings added to `messages.fr.xlf` for portal-shell.
portal-admin labels stay in English (no FR locale in the admin
app's v1 per ADR-0020 §"No locale switcher in v1").
julien merged commit a8ead65ea8 into main 2026-05-15 15:23:40 +02:00
julien deleted branch feat/shared-user-menu-and-integration 2026-05-15 15:23:43 +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#149