From 092ccb7bda947cfd1ed64087fe016b6b5db33805 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Tue, 12 May 2026 20:10:33 +0200 Subject: [PATCH] feat(portal-shell): wire SPA auth state to BFF /me + header login/logout widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/portal-shell/src/app/app.config.ts | 6 + apps/portal-shell/src/app/app.spec.ts | 17 ++- .../src/app/components/header/header.html | 51 ++++++- .../src/app/components/header/header.spec.ts | 130 ++++++++++++++---- .../src/app/components/header/header.ts | 42 +++++- apps/portal-shell/src/locale/messages.fr.xlf | 18 ++- apps/portal-shell/tsconfig.app.json | 5 +- libs/feature/auth/src/index.ts | 4 +- libs/feature/auth/src/lib/auth.config.ts | 28 ++++ .../feature/auth/src/lib/auth.service.spec.ts | 125 +++++++++++++++++ libs/feature/auth/src/lib/auth.service.ts | 114 +++++++++++++++ libs/feature/auth/src/lib/auth.types.ts | 30 ++++ .../src/lib/feature-auth/feature-auth.css | 0 .../src/lib/feature-auth/feature-auth.html | 1 - .../src/lib/feature-auth/feature-auth.spec.ts | 21 --- .../auth/src/lib/feature-auth/feature-auth.ts | 9 -- 16 files changed, 529 insertions(+), 72 deletions(-) create mode 100644 libs/feature/auth/src/lib/auth.config.ts create mode 100644 libs/feature/auth/src/lib/auth.service.spec.ts create mode 100644 libs/feature/auth/src/lib/auth.service.ts create mode 100644 libs/feature/auth/src/lib/auth.types.ts delete mode 100644 libs/feature/auth/src/lib/feature-auth/feature-auth.css delete mode 100644 libs/feature/auth/src/lib/feature-auth/feature-auth.html delete mode 100644 libs/feature/auth/src/lib/feature-auth/feature-auth.spec.ts delete mode 100644 libs/feature/auth/src/lib/feature-auth/feature-auth.ts diff --git a/apps/portal-shell/src/app/app.config.ts b/apps/portal-shell/src/app/app.config.ts index 25b4de3..c07fd1e 100644 --- a/apps/portal-shell/src/app/app.config.ts +++ b/apps/portal-shell/src/app/app.config.ts @@ -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 }, ], }; diff --git a/apps/portal-shell/src/app/app.spec.ts b/apps/portal-shell/src/app/app.spec.ts index b3ee85f..e71b08d 100644 --- a/apps/portal-shell/src/app/app.spec.ts +++ b/apps/portal-shell/src/app/app.spec.ts @@ -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' }); }); }); diff --git a/apps/portal-shell/src/app/components/header/header.html b/apps/portal-shell/src/app/components/header/header.html index 7ac510c..9f7621f 100644 --- a/apps/portal-shell/src/app/components/header/header.html +++ b/apps/portal-shell/src/app/components/header/header.html @@ -70,13 +70,58 @@ > + + @switch (authState().kind) { @case ('loading') { + Checking sign-in status… + } @case ('anonymous') { + + } @case ('authenticated') { @if (authState(); as state) { @if (state.kind === 'authenticated') + { + + + + } } } @case ('error') { + + Can't reach the server + + } } diff --git a/apps/portal-shell/src/app/components/header/header.spec.ts b/apps/portal-shell/src/app/components/header/header.spec.ts index 244d6dc..5400e46 100644 --- a/apps/portal-shell/src/app/components/header/header.spec.ts +++ b/apps/portal-shell/src/app/components/header/header.spec.ts @@ -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' }); + }); }); }); diff --git a/apps/portal-shell/src/app/components/header/header.ts b/apps/portal-shell/src/app/components/header/header.ts index 2240519..8150d7a 100644 --- a/apps/portal-shell/src/app/components/header/header.ts +++ b/apps/portal-shell/src/app/components/header/header.ts @@ -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(); } diff --git a/apps/portal-shell/src/locale/messages.fr.xlf b/apps/portal-shell/src/locale/messages.fr.xlf index 4811945..a6fbc19 100644 --- a/apps/portal-shell/src/locale/messages.fr.xlf +++ b/apps/portal-shell/src/locale/messages.fr.xlf @@ -48,9 +48,21 @@ Settings Paramètres - - User menu (coming soon) - Menu utilisateur (bientôt disponible) + + Can't reach the server + Serveur injoignable + + + Sign in + Se connecter + + + Sign out + Se déconnecter + + + Checking sign-in status… + Vérification de la session… Search the portal diff --git a/apps/portal-shell/tsconfig.app.json b/apps/portal-shell/tsconfig.app.json index c951182..804362f 100644 --- a/apps/portal-shell/tsconfig.app.json +++ b/apps/portal-shell/tsconfig.app.json @@ -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" } ] } diff --git a/libs/feature/auth/src/index.ts b/libs/feature/auth/src/index.ts index 563880e..c952564 100644 --- a/libs/feature/auth/src/index.ts +++ b/libs/feature/auth/src/index.ts @@ -1 +1,3 @@ -export * from './lib/feature-auth/feature-auth'; +export { AUTH_BFF_BASE_URL, AUTH_NAVIGATOR } from './lib/auth.config'; +export { AuthService } from './lib/auth.service'; +export type { AuthState, CurrentUser } from './lib/auth.types'; diff --git a/libs/feature/auth/src/lib/auth.config.ts b/libs/feature/auth/src/lib/auth.config.ts new file mode 100644 index 0000000..a8507e0 --- /dev/null +++ b/libs/feature/auth/src/lib/auth.config.ts @@ -0,0 +1,28 @@ +import { InjectionToken } from '@angular/core'; + +/** + * BFF API base URL the AuthService prepends to every backend call + * (`${base}/auth/me`, `${base}/auth/login`, …). Provided by the host + * application from its per-environment config (`environment.ts`, + * per ADR-0018) so the lib stays decoupled from the app's + * environment file shape. + * + * Wiring lives in the host's `ApplicationConfig`: + * + * { provide: AUTH_BFF_BASE_URL, useValue: environment.bffApiBaseUrl } + */ +export const AUTH_BFF_BASE_URL = new InjectionToken('AUTH_BFF_BASE_URL'); + +/** + * Indirection over `window.location.assign(...)` used by `login()` + * and `logout()`. Injecting the navigator lets specs assert the + * outbound URL without having to redefine `window.location` (which + * jsdom resists across multiple tests in the same file). + * + * The default implementation in `provideAuth()` calls + * `window.location.assign(url)`. Tests can override with a `vi.fn()`. + */ +export const AUTH_NAVIGATOR = new InjectionToken<(url: string) => void>('AUTH_NAVIGATOR', { + providedIn: 'root', + factory: () => (url: string) => window.location.assign(url), +}); diff --git a/libs/feature/auth/src/lib/auth.service.spec.ts b/libs/feature/auth/src/lib/auth.service.spec.ts new file mode 100644 index 0000000..7aac605 --- /dev/null +++ b/libs/feature/auth/src/lib/auth.service.spec.ts @@ -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; +} + +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' }); + }); + }); +}); diff --git a/libs/feature/auth/src/lib/auth.service.ts b/libs/feature/auth/src/lib/auth.service.ts new file mode 100644 index 0000000..cc8043a --- /dev/null +++ b/libs/feature/auth/src/lib/auth.service.ts @@ -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({ 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(() => { + 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 { + try { + const user = await firstValueFrom(this.http.get(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' }; +} diff --git a/libs/feature/auth/src/lib/auth.types.ts b/libs/feature/auth/src/lib/auth.types.ts new file mode 100644 index 0000000..0cfa6c4 --- /dev/null +++ b/libs/feature/auth/src/lib/auth.types.ts @@ -0,0 +1,30 @@ +/** + * Curated SPA-facing view of the session user, served by the BFF at + * `GET /api/auth/me`. Mirrors the `PublicUser` shape the BFF returns + * (apps/portal-bff/src/auth/auth.controller.ts) — internal claims + * like `amr` stay server-side and never reach the SPA. + */ +export interface CurrentUser { + readonly oid: string; + readonly tid: string; + readonly username: string; + readonly displayName: string; +} + +/** + * The four observable states of authentication on the SPA, modelled + * as a discriminated union so consumers can `switch` on `kind` and + * the type narrows automatically. + * + * - `loading` — first /me call hasn't returned yet. + * - `anonymous` — BFF answered 401, no session cookie or expired. + * - `authenticated` — BFF returned the user payload. + * - `error` — network failure, 5xx, or unexpected shape. Distinct + * from `anonymous` so the UI can surface a different message + * ("can't reach server" vs. "please sign in"). + */ +export type AuthState = + | { readonly kind: 'loading' } + | { readonly kind: 'anonymous' } + | { readonly kind: 'authenticated'; readonly user: CurrentUser } + | { readonly kind: 'error' }; diff --git a/libs/feature/auth/src/lib/feature-auth/feature-auth.css b/libs/feature/auth/src/lib/feature-auth/feature-auth.css deleted file mode 100644 index e69de29..0000000 diff --git a/libs/feature/auth/src/lib/feature-auth/feature-auth.html b/libs/feature/auth/src/lib/feature-auth/feature-auth.html deleted file mode 100644 index 9a8ac28..0000000 --- a/libs/feature/auth/src/lib/feature-auth/feature-auth.html +++ /dev/null @@ -1 +0,0 @@ -

FeatureAuth works!

diff --git a/libs/feature/auth/src/lib/feature-auth/feature-auth.spec.ts b/libs/feature/auth/src/lib/feature-auth/feature-auth.spec.ts deleted file mode 100644 index 85c3885..0000000 --- a/libs/feature/auth/src/lib/feature-auth/feature-auth.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FeatureAuth } from './feature-auth'; - -describe('FeatureAuth', () => { - let component: FeatureAuth; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [FeatureAuth], - }).compileComponents(); - - fixture = TestBed.createComponent(FeatureAuth); - component = fixture.componentInstance; - await fixture.whenStable(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/libs/feature/auth/src/lib/feature-auth/feature-auth.ts b/libs/feature/auth/src/lib/feature-auth/feature-auth.ts deleted file mode 100644 index f9b0971..0000000 --- a/libs/feature/auth/src/lib/feature-auth/feature-auth.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'lib-feature-auth', - imports: [], - templateUrl: './feature-auth.html', - styleUrl: './feature-auth.css', -}) -export class FeatureAuth {}