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
This commit was merged in pull request #149.
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { CdkMenu, CdkMenuItem, CdkMenuTrigger } from '@angular/cdk/menu';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ViewEncapsulation,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Icon, type IconName } from '../icon/icon';
|
||||
|
||||
/**
|
||||
* Shape of a single row in the user menu — Profile, Settings,
|
||||
* cross-app links, etc. Sign out is NOT modelled here; it is always
|
||||
* rendered as the last row in a separate group and emits the
|
||||
* dedicated `signOut` output.
|
||||
*
|
||||
* Use `routerLink` for internal navigation (Angular `RouterLink`),
|
||||
* `href` for cross-origin links (other apps, external URLs). At most
|
||||
* one of the two should be set.
|
||||
*/
|
||||
export interface UserMenuItem {
|
||||
readonly label: string;
|
||||
readonly icon?: IconName;
|
||||
readonly routerLink?: string;
|
||||
readonly href?: string;
|
||||
readonly disabled?: boolean;
|
||||
/** Right-aligned chip — e.g. `"Soon"` for not-yet-shipped entries. */
|
||||
readonly badge?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated user menu — the gitea/github-style avatar dropdown
|
||||
* docked at the top right of both `portal-shell` and `portal-admin`
|
||||
* headers. Rendered only when the session is `authenticated`; the
|
||||
* loading / anonymous / error widgets stay in each app's header
|
||||
* because they vary by surface (search bar, sign-up button, etc.).
|
||||
*
|
||||
* The component is layout-only: caller passes the principal
|
||||
* (`displayName`, `username`, `initials`) plus a list of `items`
|
||||
* for the navigation rows, and listens for `signOut` to actually
|
||||
* destroy the session.
|
||||
*
|
||||
* Keyboard nav (arrow keys, Home/End, Escape, type-ahead) is
|
||||
* provided by `@angular/cdk/menu` — same primitives the
|
||||
* `ThemeSwitcher` and `LocaleSwitcher` already use, kept consistent
|
||||
* to land familiar interactions across the chrome.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'lib-user-menu',
|
||||
imports: [CdkMenu, CdkMenuItem, CdkMenuTrigger, RouterLink, Icon],
|
||||
templateUrl: './user-menu.html',
|
||||
styleUrl: './user-menu.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
// CDK Menu opens its panel in an overlay portal outside this
|
||||
// component's host. Encapsulated styles would not reach the
|
||||
// overlay; the BEM-prefixed class names below keep selectors
|
||||
// specific enough to avoid leakage. Same pattern as
|
||||
// `LocaleSwitcher` / `ThemeSwitcher`.
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class UserMenu {
|
||||
readonly displayName = input.required<string>();
|
||||
readonly username = input.required<string>();
|
||||
readonly initials = input.required<string>();
|
||||
readonly items = input.required<readonly UserMenuItem[]>();
|
||||
|
||||
/** Localised label for the "Signed in as {username}" panel header. */
|
||||
readonly signedInAsLabel = input<string>('Signed in as');
|
||||
/** Localised label for the trailing Sign out row. */
|
||||
readonly signOutLabel = input<string>('Sign out');
|
||||
/** Localised aria-label for the avatar trigger button. */
|
||||
readonly triggerAriaLabel = input<string>('User menu');
|
||||
|
||||
readonly signOut = output<void>();
|
||||
}
|
||||
Reference in New Issue
Block a user