feat(portal-shell): wire SPA auth state to BFF /me + header login/logout widget
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
This commit is contained in:
@@ -5,6 +5,8 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { provideHttpClient, withFetch } from '@angular/common/http';
|
import { provideHttpClient, withFetch } from '@angular/common/http';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { AUTH_BFF_BASE_URL } from 'feature-auth';
|
||||||
|
import { environment } from '../environments/environment';
|
||||||
import { appRoutes } from './app.routes';
|
import { appRoutes } from './app.routes';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
@@ -18,5 +20,9 @@ export const appConfig: ApplicationConfig = {
|
|||||||
// the W3C `traceparent` header propagated to the BFF
|
// the W3C `traceparent` header propagated to the BFF
|
||||||
// automatically. The legacy XHR backend would short-circuit that.
|
// automatically. The legacy XHR backend would short-circuit that.
|
||||||
provideHttpClient(withFetch()),
|
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 },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { TestBed } from '@angular/core/testing';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { AUTH_BFF_BASE_URL } from 'feature-auth';
|
||||||
import { App } from './app';
|
import { App } from './app';
|
||||||
|
|
||||||
|
const BFF_BASE = 'http://bff.test/api';
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [App],
|
imports: [App],
|
||||||
providers: [provideRouter([])],
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
|
||||||
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -19,5 +29,10 @@ describe('App', () => {
|
|||||||
expect(root.querySelector('app-sidebar')).not.toBeNull();
|
expect(root.querySelector('app-sidebar')).not.toBeNull();
|
||||||
expect(root.querySelector('main#main-content')).not.toBeNull();
|
expect(root.querySelector('main#main-content')).not.toBeNull();
|
||||||
expect(root.querySelector('app-footer')).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' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,13 +70,58 @@
|
|||||||
>
|
>
|
||||||
<lib-icon name="settings" />
|
<lib-icon name="settings" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
@switch (authState().kind) { @case ('loading') {
|
||||||
<span
|
<span
|
||||||
i18n-aria-label="@@header.action.userMenu"
|
aria-hidden="true"
|
||||||
aria-label="User menu (coming soon)"
|
class="ml-1 inline-flex h-9 w-9 items-center justify-center rounded-full bg-gray-200 text-sm font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
…
|
||||||
|
</span>
|
||||||
|
<span class="sr-only" i18n="@@header.userMenu.loading">Checking sign-in status…</span>
|
||||||
|
} @case ('anonymous') {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="signIn()"
|
||||||
|
class="ml-1 inline-flex h-11 items-center gap-2 rounded-full bg-brand-primary-500 px-4 text-sm font-semibold text-white transition-colors hover:bg-brand-primary-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500"
|
||||||
|
data-testid="sign-in-button"
|
||||||
|
i18n="@@header.signIn"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
} @case ('authenticated') { @if (authState(); as state) { @if (state.kind === 'authenticated')
|
||||||
|
{
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
data-testid="user-avatar"
|
||||||
class="ml-1 inline-flex h-9 w-9 items-center justify-center rounded-full bg-brand-primary-500 text-sm font-semibold text-white"
|
class="ml-1 inline-flex h-9 w-9 items-center justify-center rounded-full bg-brand-primary-500 text-sm font-semibold text-white"
|
||||||
>
|
>
|
||||||
?
|
{{ initials() }}
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
class="hidden text-sm font-medium text-gray-700 sm:inline dark:text-gray-200"
|
||||||
|
data-testid="user-displayname"
|
||||||
|
>{{ state.user.displayName }}</span
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="signOut()"
|
||||||
|
class="inline-flex h-11 items-center gap-2 rounded-full px-3 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
|
||||||
|
data-testid="sign-out-button"
|
||||||
|
i18n="@@header.signOut"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
} } } @case ('error') {
|
||||||
|
<span
|
||||||
|
class="ml-1 inline-flex h-9 items-center rounded-full bg-amber-100 px-3 text-sm font-medium text-amber-800 dark:bg-amber-900 dark:text-amber-200"
|
||||||
|
data-testid="auth-error"
|
||||||
|
role="status"
|
||||||
|
i18n="@@header.authError"
|
||||||
|
>
|
||||||
|
Can't reach the server
|
||||||
|
</span>
|
||||||
|
} }
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -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 { TestBed } from '@angular/core/testing';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { AUTH_BFF_BASE_URL, type CurrentUser } from 'feature-auth';
|
||||||
import { Header } from './header';
|
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', () => {
|
describe('Header', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
await TestBed.configureTestingModule({
|
TestBed.resetTestingModule();
|
||||||
imports: [Header],
|
|
||||||
providers: [provideRouter([])],
|
|
||||||
}).compileComponents();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the brand link to /', async () => {
|
it('renders the brand link to /', async () => {
|
||||||
const fixture = TestBed.createComponent(Header);
|
const { fixture } = await setup({ onBootstrap: 'anonymous' });
|
||||||
await fixture.whenStable();
|
|
||||||
const link = fixture.nativeElement.querySelector('a[href="/"]') as HTMLAnchorElement | null;
|
const link = fixture.nativeElement.querySelector('a[href="/"]') as HTMLAnchorElement | null;
|
||||||
expect(link).not.toBeNull();
|
expect(link).not.toBeNull();
|
||||||
expect(link?.textContent?.trim()).toContain('APF Portal');
|
expect(link?.textContent?.trim()).toContain('APF Portal');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes a primary navigation landmark', async () => {
|
it('exposes a primary navigation landmark', async () => {
|
||||||
const fixture = TestBed.createComponent(Header);
|
const { fixture } = await setup({ onBootstrap: 'anonymous' });
|
||||||
await fixture.whenStable();
|
|
||||||
const nav = fixture.nativeElement.querySelector('nav[aria-label="Primary"]');
|
const nav = fixture.nativeElement.querySelector('nav[aria-label="Primary"]');
|
||||||
expect(nav).not.toBeNull();
|
expect(nav).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes a search landmark with a labelled input', async () => {
|
it('exposes a search landmark with a labelled input', async () => {
|
||||||
const fixture = TestBed.createComponent(Header);
|
const { fixture } = await setup({ onBootstrap: 'anonymous' });
|
||||||
await fixture.whenStable();
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
expect(root.querySelector('form[role="search"]')).not.toBeNull();
|
expect(root.querySelector('form[role="search"]')).not.toBeNull();
|
||||||
const input = root.querySelector('input#global-search') as HTMLInputElement | null;
|
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 () => {
|
it('renders the notifications / help / settings action buttons', async () => {
|
||||||
const fixture = TestBed.createComponent(Header);
|
const { fixture } = await setup({ onBootstrap: 'anonymous' });
|
||||||
await fixture.whenStable();
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
expect(root.querySelector('button[aria-label="Notifications"]')).not.toBeNull();
|
expect(root.querySelector('button[aria-label="Notifications"]')).not.toBeNull();
|
||||||
expect(root.querySelector('button[aria-label="Help"]')).not.toBeNull();
|
expect(root.querySelector('button[aria-label="Help"]')).not.toBeNull();
|
||||||
@@ -49,15 +86,12 @@ describe('Header', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('embeds the theme switcher', async () => {
|
it('embeds the theme switcher', async () => {
|
||||||
const fixture = TestBed.createComponent(Header);
|
const { fixture } = await setup({ onBootstrap: 'anonymous' });
|
||||||
await fixture.whenStable();
|
|
||||||
expect(fixture.nativeElement.querySelector('app-theme-switcher')).not.toBeNull();
|
expect(fixture.nativeElement.querySelector('app-theme-switcher')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the logo zone, expanded with wordmark by default', async () => {
|
it('renders the logo zone, expanded with wordmark by default', async () => {
|
||||||
const fixture = TestBed.createComponent(Header);
|
const { fixture } = await setup({ onBootstrap: 'anonymous' });
|
||||||
fixture.detectChanges();
|
|
||||||
await fixture.whenStable();
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
const zone = root.querySelector('.header__logo-zone');
|
const zone = root.querySelector('.header__logo-zone');
|
||||||
expect(zone).not.toBeNull();
|
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 () => {
|
it('collapses the logo zone and hides the wordmark when the sidebar is collapsed', async () => {
|
||||||
const layout = TestBed.inject(LayoutStateService);
|
localStorage.setItem('portal-shell:sidebar-collapsed', '1');
|
||||||
layout.setSidebarCollapsed(true);
|
const { fixture } = await setup({ onBootstrap: 'anonymous' });
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Header);
|
|
||||||
fixture.detectChanges();
|
|
||||||
await fixture.whenStable();
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
const zone = root.querySelector('.header__logo-zone');
|
const zone = root.querySelector('.header__logo-zone');
|
||||||
expect(zone?.classList.contains('header__logo-zone--collapsed')).toBe(true);
|
expect(zone?.classList.contains('header__logo-zone--collapsed')).toBe(true);
|
||||||
const wordmark = root.querySelector('a[href="/"] span');
|
// The collapsed brand link contains the logo image only — no
|
||||||
expect(wordmark).toBeNull();
|
// 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' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { RouterLink } from '@angular/router';
|
||||||
|
import { AuthService, type CurrentUser } from 'feature-auth';
|
||||||
import { Icon } from 'shared-ui';
|
import { Icon } from 'shared-ui';
|
||||||
import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
|
|
||||||
import { LayoutStateService } from 'shared-state';
|
import { LayoutStateService } from 'shared-state';
|
||||||
|
import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-of-page banner.
|
* Top-of-page banner.
|
||||||
@@ -15,9 +16,11 @@ import { LayoutStateService } from 'shared-state';
|
|||||||
* `LayoutStateService`.
|
* `LayoutStateService`.
|
||||||
* - Global search input (placeholder; wired to a real search service
|
* - Global search input (placeholder; wired to a real search service
|
||||||
* once we have content to query).
|
* once we have content to query).
|
||||||
* - Action cluster: notifications, help, settings, and an avatar
|
* - Action cluster: notifications, help, settings, and a user
|
||||||
* placeholder. Real menus are added once the auth flow lands
|
* widget. The widget renders one of three states — loading,
|
||||||
* (ADR-0009).
|
* 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
|
* Lives at app level (not in libs/shared/ui/) on purpose: one consumer
|
||||||
* for now. Promotion when a second app needs it.
|
* for now. Promotion when a second app needs it.
|
||||||
@@ -31,6 +34,35 @@ import { LayoutStateService } from 'shared-state';
|
|||||||
})
|
})
|
||||||
export class Header {
|
export class Header {
|
||||||
private readonly layout = inject(LayoutStateService);
|
private readonly layout = inject(LayoutStateService);
|
||||||
|
private readonly auth = inject(AuthService);
|
||||||
|
|
||||||
protected readonly collapsed = this.layout.sidebarCollapsed;
|
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,9 +48,21 @@
|
|||||||
<source>Settings</source>
|
<source>Settings</source>
|
||||||
<target>Paramètres</target>
|
<target>Paramètres</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="header.action.userMenu" datatype="html">
|
<trans-unit id="header.authError" datatype="html">
|
||||||
<source>User menu (coming soon)</source>
|
<source>Can't reach the server</source>
|
||||||
<target>Menu utilisateur (bientôt disponible)</target>
|
<target>Serveur injoignable</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="header.signIn" datatype="html">
|
||||||
|
<source>Sign in</source>
|
||||||
|
<target>Se connecter</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="header.signOut" datatype="html">
|
||||||
|
<source>Sign out</source>
|
||||||
|
<target>Se déconnecter</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="header.userMenu.loading" datatype="html">
|
||||||
|
<source>Checking sign-in status…</source>
|
||||||
|
<target>Vérification de la session…</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="header.search.label" datatype="html">
|
<trans-unit id="header.search.label" datatype="html">
|
||||||
<source>Search the portal</source>
|
<source>Search the portal</source>
|
||||||
|
|||||||
@@ -7,11 +7,14 @@
|
|||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"],
|
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"],
|
||||||
"references": [
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "../../libs/shared/state"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "../../libs/shared/ui"
|
"path": "../../libs/shared/ui"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../../libs/shared/state"
|
"path": "../../libs/feature/auth"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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<string>('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),
|
||||||
|
});
|
||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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' };
|
||||||
|
}
|
||||||
@@ -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' };
|
||||||
@@ -1 +0,0 @@
|
|||||||
<p>FeatureAuth works!</p>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
import { FeatureAuth } from './feature-auth';
|
|
||||||
|
|
||||||
describe('FeatureAuth', () => {
|
|
||||||
let component: FeatureAuth;
|
|
||||||
let fixture: ComponentFixture<FeatureAuth>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [FeatureAuth],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(FeatureAuth);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 {}
|
|
||||||
Reference in New Issue
Block a user