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
+114
View File
@@ -0,0 +1,114 @@
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable, computed, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { AUTH_BFF_BASE_URL, AUTH_NAVIGATOR } from './auth.config';
import type { AuthState, CurrentUser } from './auth.types';
/**
* SPA-side authentication state, sourced from the BFF's
* `GET /api/auth/me` (ADR-0009 / ADR-0010). Acts as the single point
* of truth for "is the user signed in?" — every consumer (header
* widget, future route guards, downstream API gates) reads from the
* `state` / `currentUser` signals.
*
* Auto-bootstraps on first injection: the constructor fires an
* unawaited `refresh()`, so consuming components see `{kind:
* 'loading'}` briefly, then either `authenticated` or `anonymous`.
*
* `login()` and `logout()` perform full-page navigations to the BFF
* routes — the SPA never holds tokens (per ADR-0009), so a browser
* redirect through the Entra round-trip is the canonical path.
*/
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL);
private readonly navigate = inject(AUTH_NAVIGATOR);
private readonly _state = signal<AuthState>({ kind: 'loading' });
readonly state = this._state.asReadonly();
/**
* Convenience: the user payload when authenticated, `null`
* otherwise. Lets templates write `@if (currentUser(); as user)`
* without unwrapping the discriminated state by hand.
*/
readonly currentUser = computed<CurrentUser | null>(() => {
const s = this._state();
return s.kind === 'authenticated' ? s.user : null;
});
/** True only while the very first /me round-trip is in flight. */
readonly isLoading = computed(() => this._state().kind === 'loading');
constructor() {
// Fire-and-forget so the constructor stays synchronous —
// Angular DI runs `providedIn: 'root'` services on first inject,
// which happens during app boot; awaiting here would push the
// first render behind the network round-trip.
void this.refresh();
}
/**
* Re-fetch `/auth/me`. Called automatically on first injection;
* consumers can call it again to refresh after a flow that may
* have changed the session (login redirect return, MFA step-up).
* Resolves once the new state is committed.
*/
async refresh(): Promise<void> {
try {
const user = await firstValueFrom(this.http.get<CurrentUser>(this.meUrl));
this._state.set({ kind: 'authenticated', user });
} catch (err) {
this._state.set(toErrorState(err));
}
}
/**
* Initiate sign-in by navigating to the BFF's `/auth/login`. The
* BFF builds the Entra authorize URL and 302s the browser; the
* round-trip lands on `/auth/callback` which writes the session
* and redirects back to the SPA.
*
* The navigation goes through the injected {@link AUTH_NAVIGATOR}
* function — `window.location.assign` in production, a `vi.fn()`
* in specs.
*/
login(): void {
this.navigate(this.loginUrl);
}
/**
* Initiate sign-out by navigating to the BFF's `/auth/logout`,
* which destroys the session, clears the cookie, and 302s through
* Entra's RP-initiated logout — single sign-out per ADR-0009.
*/
logout(): void {
this.navigate(this.logoutUrl);
}
get meUrl(): string {
return `${this.bffBaseUrl}/auth/me`;
}
get loginUrl(): string {
return `${this.bffBaseUrl}/auth/login`;
}
get logoutUrl(): string {
return `${this.bffBaseUrl}/auth/logout`;
}
}
function toErrorState(err: unknown): AuthState {
// The BFF returns 401 with `{error: 'unauthenticated'}` when no
// session is on the request. Anything else (network failure,
// 5xx, malformed response) lands in the explicit `error` state so
// the UI can distinguish "please sign in" from "can't reach the
// server right now".
if (err instanceof HttpErrorResponse && err.status === 401) {
return { kind: 'anonymous' };
}
return { kind: 'error' };
}