feat(portal-shell): wire SPA auth state to BFF /me + header login/logout widget (#113)
CI / check (push) Successful in 3m3s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m41s
CI / a11y (push) Successful in 1m44s
CI / perf (push) Successful in 4m24s

## Summary

First user-visible piece of the auth track. The portal-shell SPA now consumes the BFF auth surface (`/api/auth/me`, `/api/auth/login`, `/api/auth/logout`) and the header reflects sign-in state.

- `libs/feature/auth` ships an `AuthService` that fetches `/auth/me` on first injection, holds a signal-backed `AuthState` (`loading` / `anonymous` / `authenticated` / `error`) and exposes `currentUser` + `isLoading` computed signals plus `login()` / `logout()` / `refresh()` methods.
- The header's right-side widget renders four states: a sign-in button when anonymous, the user avatar (initials, `JD` for "Jane Doe") + display name + sign-out button when authenticated, a loading dot before `/me` resolves, and a "Can't reach the server" chip on non-401 failures.
- `login()` / `logout()` go through an injected `AUTH_NAVIGATOR` token whose default calls `window.location.assign(url)`. Specs override it with `vi.fn()` — no `window.location` mocking required.

## Notable choices

**Auto-bootstrap on construction, not via `provideAppInitializer`.** The service fires `/me` from its constructor (unawaited) so consuming components transition through the explicit `loading` state. Blocking app boot on the round-trip would push the first paint behind the network call — bad for TTFB, especially on slow links. The header handles `loading` as a first-class state.

**Discriminated `AuthState` over flat fields.** A single source of truth (`state()`) with four `kind`s lets templates `switch` and narrow automatically. `currentUser` and `isLoading` are computed conveniences but never out of sync with `state`.

**`AUTH_BFF_BASE_URL` + `AUTH_NAVIGATOR` injection tokens.** Decouples the lib from the host's `environment.ts` shape and keeps tests free of `window.location` redefinition (which jsdom resists across multiple specs in the same file — first redefine works, second throws "Cannot redefine property"). The host wires both in `app.config.ts`.

**Curated public user type.** `CurrentUser` mirrors the BFF's `/me` response (`oid`, `tid`, `username`, `displayName`) — no `amr`, no internal claims. The shape lives in `auth.types.ts` so feature code can import it without depending on Angular HTTP details.

**Distinct `error` state.** A network failure / 5xx surfaces a different UI than "not signed in" — "Can't reach the server" chip vs. sign-in button. Avoids the trap of treating any `/me` failure as "anonymous".

## Out of scope (next PRs)

- Route guards (protecting routes from anonymous users). For now the header is the only consumer.
- Auto-refresh of the session before idle timeout.
- HTTP interceptor that redirects to `/auth/login` on a 401 from any other BFF call.
- Per-locale styling polish on the new header strings.

## Test plan

- [x] `pnpm nx test feature-auth` → **8/8 pass**.
- [x] `pnpm nx test portal-shell` → **32/32 pass** (was 27 before).
- [x] `pnpm nx lint portal-shell feature-auth` → clean.
- [x] `pnpm nx build portal-shell` → main bundle 488 kB raw / 129.94 kB transfer; well under the 300 kB gzip budget from ADR-0017.
- [ ] Manual smoke once the BFF is up:
  - [ ] Anonymous landing → header shows "Sign in".
  - [ ] Click "Sign in" → BFF /login → Entra → callback → SPA lands with avatar + display name in the header.
  - [ ] Click "Sign out" → BFF /logout → Entra logout → back at SPA, header back to "Sign in".

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #113
This commit was merged in pull request #113.
This commit is contained in:
2026-05-12 20:11:34 +02:00
parent 0464ce3ac8
commit 9a9faf9a31
16 changed files with 529 additions and 72 deletions
+6
View File
@@ -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 },
],
};
+16 -1
View File
@@ -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' });
});
});
@@ -70,13 +70,58 @@
>
<lib-icon name="settings" />
</button>
@switch (authState().kind) { @case ('loading') {
<span
i18n-aria-label="@@header.action.userMenu"
aria-label="User menu (coming soon)"
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"
>
</span>
<span class="sr-only" i18n="@@header.userMenu.loading">Checking sign-in status…</span>
} @case ('anonymous') {
<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"
data-testid="sign-in-button"
i18n="@@header.signIn"
>
Sign in
</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>
} } } @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"
data-testid="auth-error"
role="status"
i18n="@@header.authError"
>
Can't reach the server
</span>
} }
</nav>
</div>
</header>
@@ -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' });
});
});
});
@@ -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();
}
+15 -3
View File
@@ -48,9 +48,21 @@
<source>Settings</source>
<target>Paramètres</target>
</trans-unit>
<trans-unit id="header.action.userMenu" datatype="html">
<source>User menu (coming soon)</source>
<target>Menu utilisateur (bientôt disponible)</target>
<trans-unit id="header.authError" datatype="html">
<source>Can't reach the server</source>
<target>Serveur injoignable</target>
</trans-unit>
<trans-unit id="header.signIn" datatype="html">
<source>Sign in</source>
<target>Se connecter</target>
</trans-unit>
<trans-unit id="header.signOut" datatype="html">
<source>Sign out</source>
<target>Se déconnecter</target>
</trans-unit>
<trans-unit id="header.userMenu.loading" datatype="html">
<source>Checking sign-in status…</source>
<target>Vérification de la session…</target>
</trans-unit>
<trans-unit id="header.search.label" datatype="html">
<source>Search the portal</source>
+4 -1
View File
@@ -7,11 +7,14 @@
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"],
"references": [
{
"path": "../../libs/shared/state"
},
{
"path": "../../libs/shared/ui"
},
{
"path": "../../libs/shared/state"
"path": "../../libs/feature/auth"
}
]
}