feat(shared-ui): user menu dropdown + integration on portal-shell + portal-admin (#149)
CI / scan (push) Successful in 2m28s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m16s
CI / a11y (push) Successful in 1m43s
CI / perf (push) Successful in 3m58s

## 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:
2026-05-15 15:23:39 +02:00
parent e3e7800ecb
commit a8ead65ea8
14 changed files with 638 additions and 47 deletions
@@ -27,9 +27,17 @@
Sign in
</button>
} @case ('authenticated') { @if (authState(); as state) { @if (state.kind === 'authenticated') {
<span class="avatar" [attr.aria-label]="state.user.displayName">{{ initials() }}</span>
<span class="auth-name">{{ state.user.displayName }}</span>
<button type="button" class="btn btn--secondary" (click)="signOut()">Sign out</button>
<lib-user-menu
[displayName]="state.user.displayName"
[username]="state.user.username"
[initials]="initials()"
[items]="userMenuItems"
[signedInAsLabel]="signedInAsLabel"
[signOutLabel]="signOutLabel"
[triggerAriaLabel]="triggerAriaLabel(state.user.displayName)"
(signOut)="signOut()"
data-testid="user-menu"
/>
} } } }
</div>
</header>
@@ -57,6 +57,15 @@
display: flex;
align-items: center;
gap: 0.5rem;
// The admin header sits on `--color-brand-primary-600`; a brand
// primary avatar inside the user menu trigger would barely read.
// Override the shared avatar tokens to a translucent white block
// matching the inline avatar this widget replaced.
lib-user-menu {
--user-menu-avatar-bg: rgba(255, 255, 255, 0.2);
--user-menu-avatar-fg: #ffffff;
}
}
.auth-status--loading {
@@ -67,12 +67,26 @@ describe('AdminHeader', () => {
expect(button?.textContent?.trim()).toBe('Sign in');
});
it('shows the user widget with initials when authenticated', async () => {
it('shows the user menu trigger with initials when authenticated', async () => {
const { fixture } = await setup({ onBootstrap: 'authenticated' });
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('.avatar')?.textContent?.trim()).toBe('AS');
expect(root.querySelector('.auth-name')?.textContent).toContain('Admin Smith');
expect(root.querySelector('.btn--secondary')?.textContent?.trim()).toBe('Sign out');
const menu = root.querySelector('[data-testid="user-menu"]');
expect(menu).not.toBeNull();
expect(menu?.querySelector('.user-menu__avatar')?.textContent?.trim()).toBe('AS');
});
it('opens the menu and exposes displayName + Sign out inside the panel', async () => {
const { fixture } = await setup({ onBootstrap: 'authenticated' });
const trigger = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>(
'[data-testid="user-menu"] button[aria-haspopup="menu"]',
);
trigger?.click();
fixture.detectChanges();
await fixture.whenStable();
const panel = document.body.querySelector('.user-menu__panel');
expect(panel).not.toBeNull();
expect(panel?.querySelector('.user-menu__header-name')?.textContent).toContain('Admin Smith');
expect(panel?.querySelector('.user-menu__item--sign-out')).not.toBeNull();
});
it('navigates to the admin login URL on Sign in click', async () => {
@@ -83,11 +97,15 @@ describe('AdminHeader', () => {
expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/admin/auth/login`);
});
it('navigates to the admin logout URL on Sign out click', async () => {
it('navigates to the admin logout URL on Sign out click inside the menu panel', async () => {
const { fixture, navigate } = await setup({ onBootstrap: 'authenticated' });
(fixture.nativeElement as HTMLElement)
.querySelector<HTMLButtonElement>('.btn--secondary')
?.click();
const trigger = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>(
'[data-testid="user-menu"] button[aria-haspopup="menu"]',
);
trigger?.click();
fixture.detectChanges();
await fixture.whenStable();
document.body.querySelector<HTMLButtonElement>('.user-menu__item--sign-out')?.click();
expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/admin/auth/logout`);
});
});
@@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { AuthService, type CurrentUser } from 'feature-auth';
import { UserMenu, type UserMenuItem } from 'shared-ui';
/**
* Admin-portal header. Deliberately leaner than the user-portal
@@ -26,7 +27,7 @@ import { AuthService, type CurrentUser } from 'feature-auth';
*/
@Component({
selector: 'app-admin-header',
imports: [RouterLink],
imports: [RouterLink, UserMenu],
templateUrl: './header.html',
styleUrl: './header.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -41,6 +42,22 @@ export class AdminHeader {
return user ? initialsFor(user) : '';
});
// Static menu rows. Same shape as the portal-shell menu so a user
// who learned one finds the other familiar; the cross-app
// "Open Portal Shell" entry lands with PR 3 once the role +
// environment plumbing is in place.
protected readonly userMenuItems: readonly UserMenuItem[] = [
{ label: 'Profile', icon: 'user', routerLink: '/profile' },
{ label: 'Settings', icon: 'settings', disabled: true, badge: 'Soon' },
];
protected readonly signedInAsLabel = 'Signed in as';
protected readonly signOutLabel = 'Sign out';
protected triggerAriaLabel(displayName: string): string {
return `User menu — signed in as ${displayName}`;
}
protected signIn(): void {
this.auth.login();
}
@@ -74,7 +74,7 @@
@switch (authState().kind) { @case ('loading') {
<span
aria-hidden="true"
class="ml-1 inline-flex h-9 w-9 items-center justify-center rounded-full bg-gray-200 text-sm font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-300"
class="ml-1 inline-flex h-9 w-9 items-center justify-center rounded-md bg-gray-200 text-sm font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-300"
>
</span>
@@ -83,7 +83,7 @@
<button
type="button"
(click)="signIn()"
class="ml-1 inline-flex h-11 items-center gap-2 rounded-full bg-brand-primary-500 px-4 text-sm font-semibold text-white transition-colors hover:bg-brand-primary-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500"
class="ml-1 inline-flex h-11 items-center gap-2 rounded-md bg-brand-primary-500 px-4 text-sm font-semibold text-white transition-colors hover:bg-brand-primary-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500"
data-testid="sign-in-button"
i18n="@@header.signIn"
>
@@ -91,30 +91,20 @@
</button>
} @case ('authenticated') { @if (authState(); as state) { @if (state.kind === 'authenticated')
{
<span
aria-hidden="true"
data-testid="user-avatar"
class="ml-1 inline-flex h-9 w-9 items-center justify-center rounded-full bg-brand-primary-500 text-sm font-semibold text-white"
>
{{ initials() }}
</span>
<span
class="hidden text-sm font-medium text-gray-700 sm:inline dark:text-gray-200"
data-testid="user-displayname"
>{{ state.user.displayName }}</span
>
<button
type="button"
(click)="signOut()"
class="inline-flex h-11 items-center gap-2 rounded-full px-3 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
data-testid="sign-out-button"
i18n="@@header.signOut"
>
Sign out
</button>
<lib-user-menu
[displayName]="state.user.displayName"
[username]="state.user.username"
[initials]="initials()"
[items]="userMenuItems"
[signedInAsLabel]="signedInAsLabel"
[signOutLabel]="signOutLabel"
[triggerAriaLabel]="triggerAriaLabel(state.user.displayName)"
(signOut)="signOut()"
data-testid="user-menu"
/>
} } } @case ('error') {
<span
class="ml-1 inline-flex h-9 items-center rounded-full bg-amber-100 px-3 text-sm font-medium text-amber-800 dark:bg-amber-900 dark:text-amber-200"
class="ml-1 inline-flex h-9 items-center rounded-md bg-amber-100 px-3 text-sm font-medium text-amber-800 dark:bg-amber-900 dark:text-amber-200"
data-testid="auth-error"
role="status"
i18n="@@header.authError"
@@ -122,19 +122,33 @@ describe('Header', () => {
expect(root.querySelector('[data-testid="user-avatar"]')).toBeNull();
});
it('renders the user avatar + display name + Sign out button when authenticated', async () => {
it('renders the user menu trigger with initials when authenticated', async () => {
const { fixture } = await setup({ onBootstrap: 'authenticated' });
const root = fixture.nativeElement as HTMLElement;
const avatar = root.querySelector('[data-testid="user-avatar"]');
// The avatar + dropdown live inside the shared `lib-user-menu`;
// we assert at the trigger boundary and let the shared spec
// cover panel internals (UserMenu.spec.ts).
const menu = root.querySelector('[data-testid="user-menu"]');
expect(menu).not.toBeNull();
const avatar = menu?.querySelector('.user-menu__avatar');
expect(avatar?.textContent?.trim()).toBe('JD');
expect(root.querySelector('[data-testid="user-displayname"]')?.textContent?.trim()).toBe(
expect(root.querySelector('[data-testid="sign-in-button"]')).toBeNull();
});
it('opens the menu and exposes displayName + Sign out inside the panel', async () => {
const { fixture } = await setup({ onBootstrap: 'authenticated' });
const trigger = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>(
'[data-testid="user-menu"] button[aria-haspopup="menu"]',
);
trigger?.click();
fixture.detectChanges();
await fixture.whenStable();
const panel = document.body.querySelector('.user-menu__panel');
expect(panel).not.toBeNull();
expect(panel?.querySelector('.user-menu__header-name')?.textContent).toContain(
USER.displayName,
);
const signOut = root.querySelector(
'[data-testid="sign-out-button"]',
) as HTMLButtonElement | null;
expect(signOut).not.toBeNull();
expect(root.querySelector('[data-testid="sign-in-button"]')).toBeNull();
expect(panel?.querySelector('.user-menu__item--sign-out')).not.toBeNull();
});
it('shows the error chip when /me fails with a non-401', async () => {
@@ -149,7 +163,7 @@ describe('Header', () => {
const { fixture, httpCtrl } = await setup({ onBootstrap: 'leave' });
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('[data-testid="sign-in-button"]')).toBeNull();
expect(root.querySelector('[data-testid="user-avatar"]')).toBeNull();
expect(root.querySelector('[data-testid="user-menu"]')).toBeNull();
// Drain the still-pending request so the controller stays clean.
httpCtrl.expectOne(ME_URL).flush({}, { status: 401, statusText: 'Unauthorized' });
});
@@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { AuthService, type CurrentUser } from 'feature-auth';
import { Icon } from 'shared-ui';
import { Icon, UserMenu, type UserMenuItem } from 'shared-ui';
import { LayoutStateService } from 'shared-state';
import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
@@ -27,7 +27,7 @@ import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
*/
@Component({
selector: 'app-header',
imports: [RouterLink, Icon, ThemeSwitcher],
imports: [RouterLink, Icon, ThemeSwitcher, UserMenu],
templateUrl: './header.html',
styleUrl: './header.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -45,6 +45,30 @@ export class Header {
return user ? initialsFor(user) : '';
});
// Static menu rows for the authenticated user. Sign out is NOT in
// this list — it's the dedicated row at the bottom of the panel,
// emitted via the menu's `signOut` output.
protected readonly userMenuItems: readonly UserMenuItem[] = [
{
label: $localize`:@@header.userMenu.profile:Profile`,
icon: 'user',
routerLink: '/profile',
},
{
label: $localize`:@@header.userMenu.settings:Settings`,
icon: 'settings',
disabled: true,
badge: $localize`:@@common.badge.soon:Soon`,
},
];
protected readonly signedInAsLabel = $localize`:@@header.userMenu.signedInAs:Signed in as`;
protected readonly signOutLabel = $localize`:@@header.userMenu.signOut:Sign out`;
protected triggerAriaLabel(displayName: string): string {
return $localize`:@@header.userMenu.trigger.aria:User menu — signed in as ${displayName}:displayName:`;
}
protected signIn(): void {
this.auth.login();
}
@@ -64,6 +64,30 @@
<source>Checking sign-in status…</source>
<target>Vérification de la session…</target>
</trans-unit>
<trans-unit id="header.userMenu.profile" datatype="html">
<source>Profile</source>
<target>Profil</target>
</trans-unit>
<trans-unit id="header.userMenu.settings" datatype="html">
<source>Settings</source>
<target>Paramètres</target>
</trans-unit>
<trans-unit id="header.userMenu.signedInAs" datatype="html">
<source>Signed in as</source>
<target>Connecté en tant que</target>
</trans-unit>
<trans-unit id="header.userMenu.signOut" datatype="html">
<source>Sign out</source>
<target>Se déconnecter</target>
</trans-unit>
<trans-unit id="header.userMenu.trigger.aria" datatype="html">
<source>User menu — signed in as <x id="displayName" equiv-text="displayName"/></source>
<target>Menu utilisateur — connecté en tant que <x id="displayName" equiv-text="displayName"/></target>
</trans-unit>
<trans-unit id="common.badge.soon" datatype="html">
<source>Soon</source>
<target>Bientôt</target>
</trans-unit>
<trans-unit id="header.search.label" datatype="html">
<source>Search the portal</source>
<target>Rechercher dans le portail</target>