feat(shared-ui): user menu dropdown + integration on portal-shell + portal-admin
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").
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user