feat(portal-shell): wire SPA auth state to BFF /me + header login/logout widget #113

Merged
julien merged 1 commits from feat/portal-shell/auth-me-header-state into main 2026-05-12 20:11:35 +02:00
Owner

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 kinds 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

  • pnpm nx test feature-auth8/8 pass.
  • pnpm nx test portal-shell32/32 pass (was 27 before).
  • pnpm nx lint portal-shell feature-auth → clean.
  • 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".
## 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: - [x] Anonymous landing → header shows "Sign in". - [x] 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".
julien added 1 commit 2026-05-12 20:11:24 +02:00
feat(portal-shell): wire SPA auth state to BFF /me + header login/logout widget
CI / scan (pull_request) Successful in 3m18s
CI / commits (pull_request) Successful in 3m19s
CI / check (pull_request) Successful in 3m31s
CI / a11y (pull_request) Successful in 2m28s
CI / perf (pull_request) Successful in 6m24s
092ccb7bda
first user-visible piece of the auth track. portal-shell now consumes
the bff auth surface and the header reflects sign-in state.

libs/feature/auth replaces the empty nx scaffold with an AuthService
that:
  - fetches /api/auth/me on first injection (unawaited so the first
    paint isn't blocked behind the round-trip).
  - holds a signal-backed discriminated AuthState — loading /
    anonymous / authenticated / error — so templates switch on .kind
    and the type narrows automatically.
  - exposes currentUser + isLoading computed signals on top.
  - provides login() / logout() / refresh() methods. navigation
    goes through an injected AUTH_NAVIGATOR token whose default
    calls window.location.assign(url) — tests substitute a vi.fn()
    instead of redefining window.location (which jsdom resists
    across multiple specs in the same file).

distinct error state vs. anonymous: a network failure surfaces a
"Can't reach the server" chip; a 401 surfaces the sign-in button.
treating any /me failure as anonymous would silently hide outages.

curated CurrentUser type mirrors the bff's /me response (oid, tid,
username, displayName) — no amr, no internal claims. consumers
import it without pulling in angular http types.

the host wires AUTH_BFF_BASE_URL from environment.ts (per adr-0018)
and AUTH_NAVIGATOR is providedIn:'root' with the window.location
default, so apps don't need to touch it. tsconfig.app.json picks up
the new lib reference via `nx sync`.

header user-widget renders four states with i18n message ids
(header.signIn / header.signOut / header.userMenu.loading /
header.authError). avatar shows the user's initials computed from
displayName ("JD" for "Jane Doe"); display name shows on sm: and
up so the rail stays compact on narrow viewports.

out of scope (next prs):
- route guards
- auto-refresh before idle timeout
- http interceptor that redirects to /auth/login on bff 401s
julien merged commit 9a9faf9a31 into main 2026-05-12 20:11:35 +02:00
julien deleted branch feat/portal-shell/auth-me-header-state 2026-05-12 20:11:37 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#113