diff --git a/apps/portal-shell/src/app/app.config.ts b/apps/portal-shell/src/app/app.config.ts
index 25b4de3..c07fd1e 100644
--- a/apps/portal-shell/src/app/app.config.ts
+++ b/apps/portal-shell/src/app/app.config.ts
@@ -5,6 +5,8 @@ import {
} from '@angular/core';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { provideRouter } from '@angular/router';
+import { AUTH_BFF_BASE_URL } from 'feature-auth';
+import { environment } from '../environments/environment';
import { appRoutes } from './app.routes';
export const appConfig: ApplicationConfig = {
@@ -18,5 +20,9 @@ export const appConfig: ApplicationConfig = {
// the W3C `traceparent` header propagated to the BFF
// automatically. The legacy XHR backend would short-circuit that.
provideHttpClient(withFetch()),
+ // `feature-auth` is environment-agnostic and reads the BFF base
+ // URL from this token — provided once per app from `environment.ts`
+ // (per ADR-0018).
+ { provide: AUTH_BFF_BASE_URL, useValue: environment.bffApiBaseUrl },
],
};
diff --git a/apps/portal-shell/src/app/app.spec.ts b/apps/portal-shell/src/app/app.spec.ts
index b3ee85f..e71b08d 100644
--- a/apps/portal-shell/src/app/app.spec.ts
+++ b/apps/portal-shell/src/app/app.spec.ts
@@ -1,12 +1,22 @@
+import { provideHttpClient } from '@angular/common/http';
+import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
+import { AUTH_BFF_BASE_URL } from 'feature-auth';
import { App } from './app';
+const BFF_BASE = 'http://bff.test/api';
+
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
- providers: [provideRouter([])],
+ providers: [
+ provideRouter([]),
+ provideHttpClient(),
+ provideHttpClientTesting(),
+ { provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
+ ],
}).compileComponents();
});
@@ -19,5 +29,10 @@ describe('App', () => {
expect(root.querySelector('app-sidebar')).not.toBeNull();
expect(root.querySelector('main#main-content')).not.toBeNull();
expect(root.querySelector('app-footer')).not.toBeNull();
+ // Drain the bootstrap /me request fired by the Header's AuthService.
+ const httpCtrl = TestBed.inject(HttpTestingController);
+ httpCtrl
+ .expectOne(`${BFF_BASE}/auth/me`)
+ .flush({}, { status: 401, statusText: 'Unauthorized' });
});
});
diff --git a/apps/portal-shell/src/app/components/header/header.html b/apps/portal-shell/src/app/components/header/header.html
index 7ac510c..9f7621f 100644
--- a/apps/portal-shell/src/app/components/header/header.html
+++ b/apps/portal-shell/src/app/components/header/header.html
@@ -70,13 +70,58 @@
>
+
+ @switch (authState().kind) { @case ('loading') {
+ …
+
+ Checking sign-in status…
+ } @case ('anonymous') {
+
+ } @case ('authenticated') { @if (authState(); as state) { @if (state.kind === 'authenticated')
+ {
+
- ?
+ {{ initials() }}
+ {{ state.user.displayName }}
+
+ } } } @case ('error') {
+
+ Can't reach the server
+
+ } }
diff --git a/apps/portal-shell/src/app/components/header/header.spec.ts b/apps/portal-shell/src/app/components/header/header.spec.ts
index 244d6dc..5400e46 100644
--- a/apps/portal-shell/src/app/components/header/header.spec.ts
+++ b/apps/portal-shell/src/app/components/header/header.spec.ts
@@ -1,35 +1,73 @@
+import { provideHttpClient } from '@angular/common/http';
+import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
+import { AUTH_BFF_BASE_URL, type CurrentUser } from 'feature-auth';
import { Header } from './header';
-import { LayoutStateService } from 'shared-state';
+
+const BFF_BASE = 'http://bff.test/api';
+const ME_URL = `${BFF_BASE}/auth/me`;
+
+const USER: CurrentUser = {
+ oid: 'user-oid',
+ tid: 'tenant-id',
+ username: 'jane.doe@apf.example',
+ displayName: 'Jane Doe',
+};
+
+async function setup(opts?: { onBootstrap?: 'authenticated' | 'anonymous' | 'error' | 'leave' }) {
+ TestBed.configureTestingModule({
+ imports: [Header],
+ providers: [
+ provideRouter([]),
+ provideHttpClient(),
+ provideHttpClientTesting(),
+ { provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
+ ],
+ });
+ const fixture = TestBed.createComponent(Header);
+ const httpCtrl = TestBed.inject(HttpTestingController);
+ // The bootstrap /me fires synchronously in AuthService's
+ // constructor — match and resolve it according to the test's
+ // intent. `leave` keeps the loading state visible.
+ const intent = opts?.onBootstrap ?? 'authenticated';
+ if (intent !== 'leave') {
+ const req = httpCtrl.expectOne(ME_URL);
+ if (intent === 'authenticated') {
+ req.flush(USER);
+ } else if (intent === 'anonymous') {
+ req.flush({ error: 'unauthenticated' }, { status: 401, statusText: 'Unauthorized' });
+ } else {
+ req.flush('boom', { status: 500, statusText: 'Internal Server Error' });
+ }
+ await Promise.resolve();
+ }
+ fixture.detectChanges();
+ await fixture.whenStable();
+ return { fixture, httpCtrl };
+}
describe('Header', () => {
- beforeEach(async () => {
+ beforeEach(() => {
localStorage.clear();
- await TestBed.configureTestingModule({
- imports: [Header],
- providers: [provideRouter([])],
- }).compileComponents();
+ TestBed.resetTestingModule();
});
it('renders the brand link to /', async () => {
- const fixture = TestBed.createComponent(Header);
- await fixture.whenStable();
+ const { fixture } = await setup({ onBootstrap: 'anonymous' });
const link = fixture.nativeElement.querySelector('a[href="/"]') as HTMLAnchorElement | null;
expect(link).not.toBeNull();
expect(link?.textContent?.trim()).toContain('APF Portal');
});
it('exposes a primary navigation landmark', async () => {
- const fixture = TestBed.createComponent(Header);
- await fixture.whenStable();
+ const { fixture } = await setup({ onBootstrap: 'anonymous' });
const nav = fixture.nativeElement.querySelector('nav[aria-label="Primary"]');
expect(nav).not.toBeNull();
});
it('exposes a search landmark with a labelled input', async () => {
- const fixture = TestBed.createComponent(Header);
- await fixture.whenStable();
+ const { fixture } = await setup({ onBootstrap: 'anonymous' });
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('form[role="search"]')).not.toBeNull();
const input = root.querySelector('input#global-search') as HTMLInputElement | null;
@@ -40,8 +78,7 @@ describe('Header', () => {
});
it('renders the notifications / help / settings action buttons', async () => {
- const fixture = TestBed.createComponent(Header);
- await fixture.whenStable();
+ const { fixture } = await setup({ onBootstrap: 'anonymous' });
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('button[aria-label="Notifications"]')).not.toBeNull();
expect(root.querySelector('button[aria-label="Help"]')).not.toBeNull();
@@ -49,15 +86,12 @@ describe('Header', () => {
});
it('embeds the theme switcher', async () => {
- const fixture = TestBed.createComponent(Header);
- await fixture.whenStable();
+ const { fixture } = await setup({ onBootstrap: 'anonymous' });
expect(fixture.nativeElement.querySelector('app-theme-switcher')).not.toBeNull();
});
it('renders the logo zone, expanded with wordmark by default', async () => {
- const fixture = TestBed.createComponent(Header);
- fixture.detectChanges();
- await fixture.whenStable();
+ const { fixture } = await setup({ onBootstrap: 'anonymous' });
const root = fixture.nativeElement as HTMLElement;
const zone = root.querySelector('.header__logo-zone');
expect(zone).not.toBeNull();
@@ -66,16 +100,58 @@ describe('Header', () => {
});
it('collapses the logo zone and hides the wordmark when the sidebar is collapsed', async () => {
- const layout = TestBed.inject(LayoutStateService);
- layout.setSidebarCollapsed(true);
-
- const fixture = TestBed.createComponent(Header);
- fixture.detectChanges();
- await fixture.whenStable();
+ localStorage.setItem('portal-shell:sidebar-collapsed', '1');
+ const { fixture } = await setup({ onBootstrap: 'anonymous' });
const root = fixture.nativeElement as HTMLElement;
const zone = root.querySelector('.header__logo-zone');
expect(zone?.classList.contains('header__logo-zone--collapsed')).toBe(true);
- const wordmark = root.querySelector('a[href="/"] span');
- expect(wordmark).toBeNull();
+ // The collapsed brand link contains the logo image only — no
+ // wordmark span. We assert by inspecting the link's direct
+ // children.
+ const wordmarkSpan = root.querySelector('a[href="/"] > span');
+ expect(wordmarkSpan).toBeNull();
+ });
+
+ describe('user widget', () => {
+ it('renders a Sign in button when the BFF says anonymous', async () => {
+ const { fixture } = await setup({ onBootstrap: 'anonymous' });
+ const root = fixture.nativeElement as HTMLElement;
+ const btn = root.querySelector('[data-testid="sign-in-button"]') as HTMLButtonElement | null;
+ expect(btn).not.toBeNull();
+ expect(btn?.textContent?.trim()).toBe('Sign in');
+ expect(root.querySelector('[data-testid="user-avatar"]')).toBeNull();
+ });
+
+ it('renders the user avatar + display name + Sign out button when authenticated', async () => {
+ const { fixture } = await setup({ onBootstrap: 'authenticated' });
+ const root = fixture.nativeElement as HTMLElement;
+ const avatar = root.querySelector('[data-testid="user-avatar"]');
+ expect(avatar?.textContent?.trim()).toBe('JD');
+ expect(root.querySelector('[data-testid="user-displayname"]')?.textContent?.trim()).toBe(
+ 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();
+ });
+
+ it('shows the error chip when /me fails with a non-401', async () => {
+ const { fixture } = await setup({ onBootstrap: 'error' });
+ const root = fixture.nativeElement as HTMLElement;
+ const chip = root.querySelector('[data-testid="auth-error"]');
+ expect(chip).not.toBeNull();
+ expect(chip?.getAttribute('role')).toBe('status');
+ });
+
+ it('shows the loading placeholder before /me resolves', async () => {
+ 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();
+ // 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 2240519..8150d7a 100644
--- a/apps/portal-shell/src/app/components/header/header.ts
+++ b/apps/portal-shell/src/app/components/header/header.ts
@@ -1,8 +1,9 @@
-import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+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 { ThemeSwitcher } from '../theme-switcher/theme-switcher';
import { LayoutStateService } from 'shared-state';
+import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
/**
* Top-of-page banner.
@@ -15,9 +16,11 @@ import { LayoutStateService } from 'shared-state';
* `LayoutStateService`.
* - Global search input (placeholder; wired to a real search service
* once we have content to query).
- * - Action cluster: notifications, help, settings, and an avatar
- * placeholder. Real menus are added once the auth flow lands
- * (ADR-0009).
+ * - Action cluster: notifications, help, settings, and a user
+ * widget. The widget renders one of three states — loading,
+ * anonymous (a "Sign in" button), authenticated (avatar with
+ * initials + display name + "Sign out" button) — driven by
+ * `AuthService` (ADR-0009).
*
* Lives at app level (not in libs/shared/ui/) on purpose: one consumer
* for now. Promotion when a second app needs it.
@@ -31,6 +34,35 @@ import { LayoutStateService } from 'shared-state';
})
export class Header {
private readonly layout = inject(LayoutStateService);
+ private readonly auth = inject(AuthService);
protected readonly collapsed = this.layout.sidebarCollapsed;
+
+ protected readonly authState = this.auth.state;
+
+ protected readonly initials = computed(() => {
+ const user = this.auth.currentUser();
+ return user ? initialsFor(user) : '';
+ });
+
+ protected signIn(): void {
+ this.auth.login();
+ }
+
+ protected signOut(): void {
+ this.auth.logout();
+ }
+}
+
+function initialsFor(user: CurrentUser): string {
+ const name = (user.displayName || user.username).trim();
+ if (!name) {
+ return '?';
+ }
+ const parts = name.split(/\s+/).filter(Boolean).slice(0, 2);
+ const letters = parts.map((p) => p.charAt(0).toUpperCase()).join('');
+ // Fallback to first two chars of the trimmed string when the
+ // split produced an empty array (single-word names with no
+ // whitespace already covered by parts[0]).
+ return letters || name.slice(0, 2).toUpperCase();
}
diff --git a/apps/portal-shell/src/locale/messages.fr.xlf b/apps/portal-shell/src/locale/messages.fr.xlf
index 4811945..a6fbc19 100644
--- a/apps/portal-shell/src/locale/messages.fr.xlf
+++ b/apps/portal-shell/src/locale/messages.fr.xlf
@@ -48,9 +48,21 @@
Settings
Paramètres
-