Commit Graph

4 Commits

Author SHA1 Message Date
Julien Gautier 9247e5e02e feat(portal-shell): authGuard + BFF http interceptors + /profile demo route
CI / commits (pull_request) Successful in 1m29s
CI / scan (pull_request) Successful in 1m39s
CI / check (pull_request) Successful in 1m50s
CI / a11y (pull_request) Successful in 55s
CI / perf (pull_request) Successful in 2m22s
brings the spa auth track to the level of polish the bff surface
deserves. before this pr the header reflected sign-in state but
there were no protected routes and no global handling of session
drift; this pr lands one guard, two interceptors, and a demo
consumer that exercises the loop end-to-end.

  authGuard (CanActivateFn):
    gates routes on AuthService.state. waits out the bootstrap
    `loading` state (filter+firstValueFrom on toObservable(state)),
    allows when `authenticated`, redirects via auth.login() (full-
    page navigation to the bff's /auth/login → entra round-trip)
    when `anonymous` or `error`. error → same redirect as anonymous:
    the bff-side login screen surfaces diagnostics more usefully
    than a generic spa outage page would.

  bffCredentialsInterceptor:
    flips withCredentials: true on every request whose url starts
    with AUTH_BFF_BASE_URL. replaces the per-call flag added in
    #114 with a single point of truth; future bff calls inherit it
    automatically.

  bffUnauthorizedInterceptor:
    calls AuthService.refresh() when a bff route (other than
    /auth/me) answers 401. keeps the spa state in sync after
    server-side session destruction (absolute-timeout, manual
    revoke, idle-ttl expiry). /me is deliberately excluded —
    refresh() itself calls /me, a 401 there would loop.

    notable: the interceptor injects Injector and resolves
    AuthService lazily inside catchError. injecting it eagerly at
    the top would re-enter di while AuthService's own constructor
    is still firing the bootstrap /me through this same
    interceptor, raising a circular-construction error that
    swallowed the request before the testing backend could record
    it. standard angular pattern for "interceptor depends on a
    service that uses HttpClient".

  /profile demo route:
    first real consumer of authGuard. lazy-loaded angular component
    that renders the curated CurrentUser payload (displayName,
    username, oid, tid). exercises the full loop guard → bff /me
    → spa render.

removed the per-call withCredentials: true from AuthService.refresh()
— the credentials interceptor handles it uniformly now. the spec
that pinned the per-call flag moved to
bff-credentials.interceptor.spec.ts where it belongs.

i18n: 6 new message ids + route.profile.title shipped with fr
translations in messages.fr.xlf.

19/19 feature-auth + 34/34 portal-shell + 123/123 portal-bff under
the clean-env ci repro (env -u redis_url … etc.). bundle main is
492 kb raw / 131 kb transfer — well under the 300 kb gzip budget
per adr-0017.

out of scope, landing in follow-ups:
- a real user-profile feature (settings, preferences, etc.)
- showing the auth-loading state on the route the guard blocks
  (today the user sees the previous route until /me resolves)
2026-05-13 00:26:04 +02:00
julien 0e4f0fc611 fix(portal-shell): send withCredentials on /me so the session cookie crosses SPA→BFF in dev (#114)
CI / check (push) Successful in 2m32s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m17s
CI / a11y (push) Successful in 1m8s
CI / perf (push) Successful in 2m49s
## Summary

Manual smoke after PR #113 surfaced a dev-only bug: after `/auth/callback` the BFF correctly sets the `portal_session` cookie and redirects to the SPA, but the SPA's next call to `/api/auth/me` comes back **401 with no `cookie:` header at all**. The user lands "back at the portal" but the header still shows "Sign in".

**Root cause.** Angular's `HttpClient` via `withFetch()` inherits `fetch`'s default `credentials: 'same-origin'`. In dev, `localhost:4200` (SPA) → `localhost:3000` (BFF) is cross-origin (different ports), so the browser drops the session cookie on the way out. SameSite=Lax is a red herring: both URLs share the registrable domain, so the cookie is still same-site — what was missing was opting the fetch into credentials.

**Fix.** Per-call `withCredentials: true` on the /me request. Only /me needs cookies today; login/logout are full-page navigations through `window.location`, which the browser hydrates with cookies regardless. A global `HttpInterceptor` will be the right abstraction once other authenticated BFF endpoints exist — premature for one consumer.

**BFF side was already correct.** `enableCors({ credentials: true })` in `main.ts`. Nothing to change.

A new spec pins `withCredentials === true` on the /me request so a future refactor can't silently drop the flag and reintroduce the bug.

## Test plan

- [x] `pnpm nx test feature-auth` → **9/9 pass** (was 8 before; +1 spec pinning the credentials flag).
- [x] `pnpm nx test portal-shell` → **32/32 pass**.
- [x] `pnpm nx lint feature-auth portal-shell` → clean.
- [x] `pnpm nx build portal-shell` → clean.
- [ ] Manual smoke against the running BFF: anonymous landing → click "Sign in" → Entra → callback → SPA lands with avatar + display name in the header (the very last step that failed before this fix).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #114
2026-05-12 22:35:10 +02:00
julien 9a9faf9a31 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
2026-05-12 20:11:34 +02:00
Julien Gautier 8de19320c5 chore: generate shared and feature libs with module boundaries per ADR-0003
Generate the four phase-4 libraries:

- libs/shared/tokens (project name shared-tokens) - plain TS lib via
  @nx/js:library; will host the a11y design tokens (palette,
  contrast tiers, spacing, motion) once Tailwind lands in phase 5;
  consumable by both apps; tagged scope:shared, type:shared.
- libs/shared/util (shared-util) - plain TS lib for cross-cutting
  utility code; tagged scope:shared, type:shared.
- libs/shared/ui (shared-ui) - Angular standalone library that will
  host the spartan-ng components copy-pasted in phase 5; Angular-only
  so tagged scope:portal-shell, type:shared. unitTestRunner=
  vitest-analog because vitest-angular requires a buildable lib.
- libs/feature/auth (feature-auth) - placeholder Angular standalone
  feature lib to demonstrate the type:feature pattern; tagged
  scope:portal-shell, type:feature.

@nx/enforce-module-boundaries depConstraints replaced (root
eslint.config.mjs) with the rules from ADR-0003:

  scope:portal-shell -> scope:portal-shell, scope:shared
  scope:portal-bff   -> scope:portal-bff,   scope:shared
  scope:shared       -> scope:shared
  type:app           -> type:feature, type:shared
  type:feature       -> type:feature, type:shared
  type:shared        -> type:shared

This forbids portal-shell from importing portal-bff code (and vice
versa) and prevents shared libs from depending on feature libs.

Project names follow the convention of ADR-0003 (feature-<name> /
shared-<scope>) by passing --name explicitly to the generator; the Nx
22 default takes only the last directory segment.

Sanity check: pnpm nx run-many -t lint and -t test pass for the 8
projects (4 apps/e2e + 4 libs).

Side effects from the generators: tsconfig.base.json paths populated
with the lib import aliases; nx.json gains vite/playwright plugin
entries; .gitignore picks up vitest.config.*.timestamp* (Vitest temp
files); package.json gains @analogjs/vitest-angular and related
devDeps; pnpm-lock.yaml regenerated. Two eslint-disable comments in
portal-bff-e2e support files were trimmed by lint --fix - those files
already lint clean without the directive.
2026-04-30 17:37:29 +02:00