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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user