diff --git a/apps/portal-admin/src/app/components/header/header.html b/apps/portal-admin/src/app/components/header/header.html
index 9c5a49a..fd304d4 100644
--- a/apps/portal-admin/src/app/components/header/header.html
+++ b/apps/portal-admin/src/app/components/header/header.html
@@ -27,9 +27,17 @@
Sign in
} @case ('authenticated') { @if (authState(); as state) { @if (state.kind === 'authenticated') {
- {{ initials() }}
- {{ state.user.displayName }}
-
+
} } } }
diff --git a/apps/portal-admin/src/app/components/header/header.scss b/apps/portal-admin/src/app/components/header/header.scss
index 2df1e4a..062c91d 100644
--- a/apps/portal-admin/src/app/components/header/header.scss
+++ b/apps/portal-admin/src/app/components/header/header.scss
@@ -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 {
diff --git a/apps/portal-admin/src/app/components/header/header.spec.ts b/apps/portal-admin/src/app/components/header/header.spec.ts
index d18f83f..5c31fc2 100644
--- a/apps/portal-admin/src/app/components/header/header.spec.ts
+++ b/apps/portal-admin/src/app/components/header/header.spec.ts
@@ -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(
+ '[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('.btn--secondary')
- ?.click();
+ const trigger = (fixture.nativeElement as HTMLElement).querySelector(
+ '[data-testid="user-menu"] button[aria-haspopup="menu"]',
+ );
+ trigger?.click();
+ fixture.detectChanges();
+ await fixture.whenStable();
+ document.body.querySelector('.user-menu__item--sign-out')?.click();
expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/admin/auth/logout`);
});
});
diff --git a/apps/portal-admin/src/app/components/header/header.ts b/apps/portal-admin/src/app/components/header/header.ts
index aa6f4a1..9eaf2ea 100644
--- a/apps/portal-admin/src/app/components/header/header.ts
+++ b/apps/portal-admin/src/app/components/header/header.ts
@@ -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();
}
diff --git a/apps/portal-shell/src/app/components/header/header.html b/apps/portal-shell/src/app/components/header/header.html
index 9f7621f..65748f7 100644
--- a/apps/portal-shell/src/app/components/header/header.html
+++ b/apps/portal-shell/src/app/components/header/header.html
@@ -74,7 +74,7 @@
@switch (authState().kind) { @case ('loading') {
…
@@ -83,7 +83,7 @@
} @case ('authenticated') { @if (authState(); as state) { @if (state.kind === 'authenticated')
{
-
- {{ initials() }}
-
- {{ state.user.displayName }}
-
+
} } } @case ('error') {
{
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(
+ '[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' });
});
diff --git a/apps/portal-shell/src/app/components/header/header.ts b/apps/portal-shell/src/app/components/header/header.ts
index 8150d7a..d6f3519 100644
--- a/apps/portal-shell/src/app/components/header/header.ts
+++ b/apps/portal-shell/src/app/components/header/header.ts
@@ -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();
}
diff --git a/apps/portal-shell/src/locale/messages.fr.xlf b/apps/portal-shell/src/locale/messages.fr.xlf
index 9d81cf3..23e0e41 100644
--- a/apps/portal-shell/src/locale/messages.fr.xlf
+++ b/apps/portal-shell/src/locale/messages.fr.xlf
@@ -64,6 +64,30 @@
Checking sign-in status…
Vérification de la session…
+
+
+
+
+
+
+ Soon
+ Bientôt
+