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
@@ -0,0 +1,125 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { AUTH_BFF_BASE_URL, AUTH_NAVIGATOR } from './auth.config';
import { AuthService } from './auth.service';
import type { CurrentUser } from './auth.types';
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',
};
interface Fixture {
service: AuthService;
http: HttpTestingController;
navigate: ReturnType<typeof vi.fn>;
}
function setup(): Fixture {
const navigate = vi.fn();
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
{ provide: AUTH_NAVIGATOR, useValue: navigate },
AuthService,
],
});
const service = TestBed.inject(AuthService);
const http = TestBed.inject(HttpTestingController);
return { service, http, navigate };
}
describe('AuthService', () => {
afterEach(() => {
TestBed.resetTestingModule();
});
describe('bootstrap fetch', () => {
it('starts in the loading state before /me resolves', () => {
const { service, http } = setup();
expect(service.state().kind).toBe('loading');
expect(service.isLoading()).toBe(true);
expect(service.currentUser()).toBeNull();
// Drain the in-flight bootstrap request to keep the controller clean.
http.expectOne(ME_URL).flush({}, { status: 401, statusText: 'Unauthorized' });
});
it('transitions to authenticated when /me returns the user', async () => {
const { service, http } = setup();
http.expectOne(ME_URL).flush(USER);
// Let the awaited firstValueFrom commit the state.
await Promise.resolve();
expect(service.state()).toEqual({ kind: 'authenticated', user: USER });
expect(service.currentUser()).toEqual(USER);
expect(service.isLoading()).toBe(false);
});
it('transitions to anonymous on 401', async () => {
const { service, http } = setup();
http
.expectOne(ME_URL)
.flush({ error: 'unauthenticated' }, { status: 401, statusText: 'Unauthorized' });
await Promise.resolve();
expect(service.state().kind).toBe('anonymous');
expect(service.currentUser()).toBeNull();
});
it('transitions to error on a non-401 failure (network / 5xx / malformed)', async () => {
const { service, http } = setup();
http.expectOne(ME_URL).flush('boom', { status: 500, statusText: 'Internal Server Error' });
await Promise.resolve();
expect(service.state().kind).toBe('error');
expect(service.currentUser()).toBeNull();
});
});
describe('refresh()', () => {
it('can be called again after the bootstrap fetch and re-fetches /me', async () => {
const { service, http } = setup();
http.expectOne(ME_URL).flush(USER);
await Promise.resolve();
expect(service.state().kind).toBe('authenticated');
const promise = service.refresh();
http
.expectOne(ME_URL)
.flush({ error: 'unauthenticated' }, { status: 401, statusText: 'Unauthorized' });
await promise;
expect(service.state().kind).toBe('anonymous');
});
});
describe('URL accessors', () => {
it('derives me / login / logout URLs from the injected base', () => {
const { service, http } = setup();
expect(service.meUrl).toBe(`${BFF_BASE}/auth/me`);
expect(service.loginUrl).toBe(`${BFF_BASE}/auth/login`);
expect(service.logoutUrl).toBe(`${BFF_BASE}/auth/logout`);
http.expectOne(ME_URL).flush({}, { status: 401, statusText: 'Unauthorized' });
});
});
describe('login() / logout()', () => {
it('navigates to /auth/login via AUTH_NAVIGATOR', () => {
const { service, http, navigate } = setup();
service.login();
expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/auth/login`);
http.expectOne(ME_URL).flush({}, { status: 401, statusText: 'Unauthorized' });
});
it('navigates to /auth/logout via AUTH_NAVIGATOR', () => {
const { service, http, navigate } = setup();
service.logout();
expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/auth/logout`);
http.expectOne(ME_URL).flush({}, { status: 401, statusText: 'Unauthorized' });
});
});
});