# Login API Integration — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Wire the Angular login page to the ROLAC C# auth API (`POST /api/auth/login`, `/refresh`, `/logout`) using secure in-memory token storage and an auto-refreshing HTTP interceptor. **Architecture:** `AuthService` owns all auth state in two `BehaviorSubject`s (access token + user); it never touches `localStorage`. An `HttpInterceptorFn` attaches the Bearer token and silently retries on `401` via `/api/auth/refresh`. An `APP_INITIALIZER` calls `refresh()` on startup to restore sessions from the HttpOnly `rolac_rt` cookie. **Tech Stack:** Angular 20 standalone, Karma/Jasmine, `HttpClientTestingModule`, `HttpTestingController` --- ## File Map | File | Action | |------|--------| | `src/app/shared/services/auth.service.ts` | Full rewrite | | `src/app/shared/services/auth.service.spec.ts` | Create (new tests) | | `src/app/core/interceptors/auth.interceptor.ts` | Update | | `src/app/core/interceptors/auth.interceptor.spec.ts` | Create (new tests) | | `src/app/app.config.ts` | Add `APP_INITIALIZER` | --- ## Task 1: Write failing tests for `AuthService` **Files:** - Create: `src/app/shared/services/auth.service.spec.ts` - [ ] **Step 1.1 — Create the spec file** Create `src/app/shared/services/auth.service.spec.ts` with the full contents below. Every test will fail (or error) because the current service has the wrong interfaces and API calls. ```typescript import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { AuthService, LoginResultType, UserInfo } from './auth.service'; import { ApiConfigService } from '../../core/services/api-config.service'; const MOCK_USER: UserInfo = { id: 'user-123', email: 'test@example.com', roles: ['Admin'], languagePreference: 'en' }; const MOCK_API_RESPONSE = { accessToken: 'mock-access-token', expiresIn: 900, user: MOCK_USER }; describe('AuthService', () => { let service: AuthService; let httpMock: HttpTestingController; let apiConfig: ApiConfigService; beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [AuthService, ApiConfigService] }); service = TestBed.inject(AuthService); httpMock = TestBed.inject(HttpTestingController); apiConfig = TestBed.inject(ApiConfigService); }); afterEach(() => { httpMock.verify(); }); // ── login() ──────────────────────────────────────────────────────────────── describe('login()', () => { it('should POST to /api/auth/login with email and password', () => { service.login({ email: 'test@example.com', password: 'secret' }).subscribe(); const req = httpMock.expectOne(`${apiConfig.authUrl}/login`); expect(req.request.method).toBe('POST'); expect(req.request.body).toEqual({ email: 'test@example.com', password: 'secret' }); expect(req.request.withCredentials).toBeTrue(); req.flush(MOCK_API_RESPONSE); }); it('should return LoginResultType.Success and store token + user on 200', () => { let result: any; service.login({ email: 'test@example.com', password: 'secret' }).subscribe(r => result = r); httpMock.expectOne(`${apiConfig.authUrl}/login`).flush(MOCK_API_RESPONSE); expect(result.result).toBe(LoginResultType.Success); expect(result.responseData).toEqual(MOCK_USER); expect(service.getToken()).toBe('mock-access-token'); expect(service.getCurrentUser()).toEqual(MOCK_USER); expect(service.isAuthenticated()).toBeTrue(); }); it('should return LoginResultType.InvalidCredentials on 401', () => { let result: any; service.login({ email: 'bad@example.com', password: 'wrong' }).subscribe(r => result = r); httpMock.expectOne(`${apiConfig.authUrl}/login`).flush( { message: 'Invalid credentials' }, { status: 401, statusText: 'Unauthorized' } ); expect(result.result).toBe(LoginResultType.InvalidCredentials); expect(service.getToken()).toBeNull(); expect(service.isAuthenticated()).toBeFalse(); }); it('should return LoginResultType.Error on non-401 HTTP error', () => { let result: any; service.login({ email: 'test@example.com', password: 'secret' }).subscribe(r => result = r); httpMock.expectOne(`${apiConfig.authUrl}/login`).flush( { message: 'Server error' }, { status: 500, statusText: 'Internal Server Error' } ); expect(result.result).toBe(LoginResultType.Error); }); }); // ── refresh() ────────────────────────────────────────────────────────────── describe('refresh()', () => { it('should POST to /api/auth/refresh with withCredentials', () => { service.refresh().subscribe(); const req = httpMock.expectOne(`${apiConfig.authUrl}/refresh`); expect(req.request.method).toBe('POST'); expect(req.request.withCredentials).toBeTrue(); req.flush(MOCK_API_RESPONSE); }); it('should return true and update token + user on 200', () => { let result: boolean | undefined; service.refresh().subscribe(r => result = r); httpMock.expectOne(`${apiConfig.authUrl}/refresh`).flush(MOCK_API_RESPONSE); expect(result).toBeTrue(); expect(service.getToken()).toBe('mock-access-token'); expect(service.getCurrentUser()).toEqual(MOCK_USER); }); it('should return false and leave state unchanged on 401', () => { let result: boolean | undefined; service.refresh().subscribe(r => result = r); httpMock.expectOne(`${apiConfig.authUrl}/refresh`).flush( { message: 'Refresh token expired' }, { status: 401, statusText: 'Unauthorized' } ); expect(result).toBeFalse(); expect(service.getToken()).toBeNull(); expect(service.isAuthenticated()).toBeFalse(); }); }); // ── logout() ─────────────────────────────────────────────────────────────── describe('logout()', () => { it('should clear token and user from memory immediately', () => { // Seed state service['accessToken$'].next('some-token'); service['currentUser$'].next(MOCK_USER); service.logout(); httpMock.expectOne(`${apiConfig.authUrl}/logout`).flush(null, { status: 204, statusText: 'No Content' }); expect(service.getToken()).toBeNull(); expect(service.getCurrentUser()).toBeNull(); expect(service.isAuthenticated()).toBeFalse(); }); it('should POST to /api/auth/logout with withCredentials', () => { service.logout(); const req = httpMock.expectOne(`${apiConfig.authUrl}/logout`); expect(req.request.method).toBe('POST'); expect(req.request.withCredentials).toBeTrue(); req.flush(null, { status: 204, statusText: 'No Content' }); }); it('should not throw if the logout API call fails', () => { expect(() => { service.logout(); httpMock.expectOne(`${apiConfig.authUrl}/logout`).flush( { message: 'Server error' }, { status: 500, statusText: 'Internal Server Error' } ); }).not.toThrow(); }); }); // ── initializeFromRefreshToken() ─────────────────────────────────────────── describe('initializeFromRefreshToken()', () => { it('should resolve even when refresh returns 401 (does not block bootstrap)', async () => { const promise = service.initializeFromRefreshToken(); httpMock.expectOne(`${apiConfig.authUrl}/refresh`).flush( { message: 'No cookie' }, { status: 401, statusText: 'Unauthorized' } ); await expectAsync(promise).toBeResolved(); }); it('should resolve and authenticate user when refresh succeeds', async () => { const promise = service.initializeFromRefreshToken(); httpMock.expectOne(`${apiConfig.authUrl}/refresh`).flush(MOCK_API_RESPONSE); await expectAsync(promise).toBeResolved(); expect(service.isAuthenticated()).toBeTrue(); }); }); // ── setCurrentUser() / getCurrentUser() ──────────────────────────────────── describe('setCurrentUser()', () => { it('should update currentUser$ and mark authenticated', () => { service.setCurrentUser(MOCK_USER); expect(service.getCurrentUser()).toEqual(MOCK_USER); expect(service.isAuthenticated()).toBeTrue(); }); }); // ── redirect URL helpers ──────────────────────────────────────────────────── describe('redirect URL helpers', () => { it('should default redirect to /dashboard', () => { expect(service.getRedirectUrl()).toBe('/dashboard'); }); it('should store and return a custom redirect URL', () => { service.setRedirectUrl('/members'); expect(service.getRedirectUrl()).toBe('/members'); }); }); }); ``` - [ ] **Step 1.2 — Run tests to confirm they fail** ``` cd E:\VSProject\ROLAC\APP ng test --include=src/app/shared/services/auth.service.spec.ts --watch=false ``` Expected output: Multiple FAILED specs — type errors and/or wrong API URLs. If the test runner starts and reports failures (not compilation errors), proceed. If there are import errors for `UserInfo` or `ApiLoginResponse`, that is expected and confirms the tests are ahead of the implementation. - [ ] **Step 1.3 — Commit the failing tests** ``` git add src/app/shared/services/auth.service.spec.ts git commit -m "test: add failing specs for AuthService login API integration" ``` --- ## Task 2: Rewrite `auth.service.ts` **Files:** - Modify: `src/app/shared/services/auth.service.ts` - [ ] **Step 2.1 — Replace the entire file** Replace `src/app/shared/services/auth.service.ts` with: ```typescript import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { BehaviorSubject, Observable, of } from 'rxjs'; import { catchError, map, tap } from 'rxjs/operators'; import { ApiConfigService } from '../../core/services/api-config.service'; // ── Public interfaces ───────────────────────────────────────────────────────── /** Matches the C# UserInfo DTO exactly. */ export interface UserInfo { id: string; email: string; roles: string[]; languagePreference: string; } /** Matches the C# LoginResponse DTO exactly. */ export interface ApiLoginResponse { accessToken: string; expiresIn: number; user: UserInfo; } export interface LoginCredentials { email: string; password: string; /** Reserved for future MFA support — ignored by the current API. */ mfaCode?: string; } export enum LoginResultType { Success = 'Success', /** Kept dormant — the current API has no MFA endpoint. */ MfaRequired = 'MfaRequired', InvalidCredentials = 'InvalidCredentials', Error = 'Error' } export interface LoginResult { result: LoginResultType; responseData?: UserInfo; message?: string; } export interface TokenVerificationResult { isValid: boolean; /** Constructed from JWT claims when using secret-link login. */ user?: UserInfo; message?: string; expiresAt?: Date; requiresMfa?: boolean; } // ── Service ─────────────────────────────────────────────────────────────────── @Injectable({ providedIn: 'root' }) export class AuthService { /** In-memory only — never written to localStorage. */ private accessToken$ = new BehaviorSubject(null); private currentUser$ = new BehaviorSubject(null); /** Observable stream of the current user (null = not authenticated). */ public currentUser = this.currentUser$.asObservable(); private redirectUrl = '/dashboard'; constructor( private http: HttpClient, private apiConfig: ApiConfigService ) {} // ── Auth API calls ────────────────────────────────────────────────────── /** * Authenticate with email + password. * On success, stores the access token and user in memory and returns * LoginResultType.Success. Never throws — errors are mapped to LoginResult. */ login(credentials: LoginCredentials): Observable { return this.http.post( `${this.apiConfig.authUrl}/login`, { email: credentials.email, password: credentials.password }, { withCredentials: true } ).pipe( tap(response => { this.accessToken$.next(response.accessToken); this.currentUser$.next(response.user); }), map(response => ({ result: LoginResultType.Success, responseData: response.user } as LoginResult)), catchError(error => { if (error.status === 401) { return of({ result: LoginResultType.InvalidCredentials, message: error.error?.message || 'Invalid email or password' } as LoginResult); } return of({ result: LoginResultType.Error, message: error.error?.message || 'An error occurred during login' } as LoginResult); }) ); } /** * Silently exchange the HttpOnly `rolac_rt` cookie for a new access token. * Returns true on success, false if the cookie is absent or expired. * Never throws. */ refresh(): Observable { return this.http.post( `${this.apiConfig.authUrl}/refresh`, {}, { withCredentials: true } ).pipe( tap(response => { this.accessToken$.next(response.accessToken); this.currentUser$.next(response.user); }), map(() => true), catchError(() => of(false)) ); } /** * Clears in-memory auth state immediately, then fires a fire-and-forget * POST to revoke the server-side refresh token cookie. */ logout(): void { this.accessToken$.next(null); this.currentUser$.next(null); this.http.post( `${this.apiConfig.authUrl}/logout`, {}, { withCredentials: true } ).pipe( catchError(() => of(null)) ).subscribe(); } /** * Called by APP_INITIALIZER on every page load. * Attempts to restore the session via the refresh token cookie. * Always resolves — never rejects — so it cannot block app bootstrap. */ initializeFromRefreshToken(): Promise { return new Promise(resolve => { this.refresh().subscribe(() => resolve()); }); } // ── State accessors ───────────────────────────────────────────────────── getToken(): string | null { return this.accessToken$.value; } isAuthenticated(): boolean { return this.currentUser$.value !== null; } getCurrentUser(): UserInfo | null { return this.currentUser$.value; } /** * Manually set the current user — used by the MFA success callback * and the secret-link token flow. */ setCurrentUser(user: UserInfo): void { this.currentUser$.next(user); } setRedirectUrl(url: string): void { this.redirectUrl = url; } getRedirectUrl(): string { return this.redirectUrl; } // ── Secret-link token helpers (unchanged logic, updated types) ─────────── /** * Verifies a JWT token received as a URL parameter (secret-link login). * Performs local verification only — no API call. * Constructs a UserInfo from the JWT claims (id, email, roles, * languagePreference). */ verifySecretLinkToken(token: string): Observable { try { const tokenData = this.parseJwtToken(token); if (!tokenData) { return of({ isValid: false, message: 'Invalid token format' }); } if (this.isTokenExpired(token)) { return of({ isValid: false, message: 'This link has expired. Please request a new one.' }); } const user: UserInfo = { id: tokenData.userId || tokenData.sub || tokenData.id || '', email: tokenData.email || tokenData.email_address || '', roles: Array.isArray(tokenData.roles) ? tokenData.roles : tokenData.role ? [tokenData.role] : [], languagePreference: tokenData.languagePreference || 'en' }; return of({ isValid: true, user, message: 'Token verified successfully. MFA required.', expiresAt: tokenData.exp ? new Date(tokenData.exp * 1000) : undefined, requiresMfa: true }); } catch (error) { return of({ isValid: false, message: 'Invalid or corrupted token' }); } } /** Returns true if the JWT's `exp` claim is in the past. */ isTokenExpired(token: string): boolean { try { const payload = JSON.parse(atob(token.split('.')[1])); return payload.exp < Math.floor(Date.now() / 1000); } catch { return true; } } private parseJwtToken(token: string): any | null { try { const parts = token.split('.'); if (parts.length !== 3) return null; const decoded = atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')); return JSON.parse(decoded); } catch { return null; } } } ``` - [ ] **Step 2.2 — Run the `AuthService` tests and confirm they all pass** ``` cd E:\VSProject\ROLAC\APP ng test --include=src/app/shared/services/auth.service.spec.ts --watch=false ``` Expected: All specs PASS. Fix any failures before moving on. - [ ] **Step 2.3 — Commit** ``` git add src/app/shared/services/auth.service.ts git commit -m "feat: rewrite AuthService to use ROLAC auth API with in-memory token storage" ``` --- ## Task 3: Write failing tests for `AuthInterceptor` **Files:** - Create: `src/app/core/interceptors/auth.interceptor.spec.ts` - [ ] **Step 3.1 — Create the spec file** Create `src/app/core/interceptors/auth.interceptor.spec.ts`: ```typescript import { TestBed } from '@angular/core/testing'; import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { Router } from '@angular/router'; import { of } from 'rxjs'; import { authInterceptor } from './auth.interceptor'; import { AuthService } from '../../shared/services/auth.service'; import { ApiConfigService } from '../services/api-config.service'; describe('authInterceptor', () => { let http: HttpClient; let httpMock: HttpTestingController; let authServiceSpy: jasmine.SpyObj; let routerSpy: jasmine.SpyObj; let apiConfig: ApiConfigService; beforeEach(() => { authServiceSpy = jasmine.createSpyObj('AuthService', [ 'getToken', 'refresh', 'logout' ]); routerSpy = jasmine.createSpyObj('Router', ['navigate']); TestBed.configureTestingModule({ providers: [ provideHttpClient(withInterceptors([authInterceptor])), provideHttpClientTesting(), { provide: AuthService, useValue: authServiceSpy }, { provide: Router, useValue: routerSpy }, ApiConfigService ] }); http = TestBed.inject(HttpClient); httpMock = TestBed.inject(HttpTestingController); apiConfig = TestBed.inject(ApiConfigService); }); afterEach(() => httpMock.verify()); // ── Token attachment ──────────────────────────────────────────────────────── describe('token attachment', () => { it('should add Authorization header when token exists', () => { authServiceSpy.getToken.and.returnValue('test-token'); http.get(`${apiConfig.getBaseUrl()}/members`).subscribe(); const req = httpMock.expectOne(`${apiConfig.getBaseUrl()}/members`); expect(req.request.headers.get('Authorization')).toBe('Bearer test-token'); req.flush({}); }); it('should NOT add Authorization header when no token', () => { authServiceSpy.getToken.and.returnValue(null); http.get(`${apiConfig.getBaseUrl()}/members`).subscribe(); const req = httpMock.expectOne(`${apiConfig.getBaseUrl()}/members`); expect(req.request.headers.has('Authorization')).toBeFalse(); req.flush({}); }); it('should NOT add Authorization header to /auth/login', () => { authServiceSpy.getToken.and.returnValue('test-token'); http.post(`${apiConfig.authUrl}/login`, {}).subscribe(); const req = httpMock.expectOne(`${apiConfig.authUrl}/login`); expect(req.request.headers.has('Authorization')).toBeFalse(); req.flush({}); }); it('should NOT add Authorization header to /auth/refresh', () => { authServiceSpy.getToken.and.returnValue('test-token'); http.post(`${apiConfig.authUrl}/refresh`, {}).subscribe(); const req = httpMock.expectOne(`${apiConfig.authUrl}/refresh`); expect(req.request.headers.has('Authorization')).toBeFalse(); req.flush({}); }); it('should NOT add Authorization header to /auth/logout', () => { authServiceSpy.getToken.and.returnValue('test-token'); http.post(`${apiConfig.authUrl}/logout`, {}).subscribe(); const req = httpMock.expectOne(`${apiConfig.authUrl}/logout`); expect(req.request.headers.has('Authorization')).toBeFalse(); req.flush({}); }); it('should NOT add Authorization header to external URLs', () => { authServiceSpy.getToken.and.returnValue('test-token'); http.get('https://other-domain.com/api/data').subscribe(); const req = httpMock.expectOne('https://other-domain.com/api/data'); expect(req.request.headers.has('Authorization')).toBeFalse(); req.flush({}); }); }); // ── 401 auto-refresh ──────────────────────────────────────────────────────── describe('401 auto-refresh', () => { it('should refresh and retry original request on first 401', () => { // First call returns old token (initial request); second returns new token (retry) authServiceSpy.getToken.and.returnValues('old-token', 'new-token'); authServiceSpy.refresh.and.returnValue(of(true)); let responseData: any; http.get(`${apiConfig.getBaseUrl()}/members`).subscribe(r => responseData = r); // First attempt → 401 const first = httpMock.expectOne(`${apiConfig.getBaseUrl()}/members`); first.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' }); // Retry after refresh const retry = httpMock.expectOne(`${apiConfig.getBaseUrl()}/members`); expect(retry.request.headers.get('Authorization')).toBe('Bearer new-token'); expect(retry.request.headers.get('X-Retry')).toBe('true'); retry.flush({ data: 'ok' }); expect(responseData).toEqual({ data: 'ok' }); expect(authServiceSpy.refresh).toHaveBeenCalledTimes(1); expect(authServiceSpy.logout).not.toHaveBeenCalled(); }); it('should logout and navigate to /login when refresh fails on 401', () => { authServiceSpy.getToken.and.returnValue('old-token'); authServiceSpy.refresh.and.returnValue(of(false)); http.get(`${apiConfig.getBaseUrl()}/members`).subscribe({ error: () => {} // swallow expected error }); httpMock.expectOne(`${apiConfig.getBaseUrl()}/members`).flush( { message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' } ); expect(authServiceSpy.logout).toHaveBeenCalledTimes(1); expect(routerSpy.navigate).toHaveBeenCalledWith(['/login']); }); it('should NOT retry again if the retry request also gets 401 (prevents loop)', () => { authServiceSpy.getToken.and.returnValue('token'); authServiceSpy.refresh.and.returnValue(of(true)); http.get(`${apiConfig.getBaseUrl()}/members`).subscribe({ error: () => {} }); // First request → 401 httpMock.expectOne(`${apiConfig.getBaseUrl()}/members`).flush( { message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' } ); // Retry also gets 401 — should not spawn another request httpMock.expectOne(`${apiConfig.getBaseUrl()}/members`).flush( { message: 'Still unauthorized' }, { status: 401, statusText: 'Unauthorized' } ); // No third request httpMock.expectNone(`${apiConfig.getBaseUrl()}/members`); expect(authServiceSpy.logout).toHaveBeenCalledTimes(1); expect(routerSpy.navigate).toHaveBeenCalledWith(['/login']); }); }); }); ``` - [ ] **Step 3.2 — Run tests to confirm they fail** ``` cd E:\VSProject\ROLAC\APP ng test --include=src/app/core/interceptors/auth.interceptor.spec.ts --watch=false ``` Expected: Multiple failures — the current interceptor uses old paths (`/Auth/login`, `/Token/Create`) and has no auto-refresh logic. Proceed once failures are confirmed. - [ ] **Step 3.3 — Commit the failing tests** ``` git add src/app/core/interceptors/auth.interceptor.spec.ts git commit -m "test: add failing specs for authInterceptor auto-refresh and path matching" ``` --- ## Task 4: Update `auth.interceptor.ts` **Files:** - Modify: `src/app/core/interceptors/auth.interceptor.ts` - [ ] **Step 4.1 — Replace the entire file** Replace `src/app/core/interceptors/auth.interceptor.ts` with: ```typescript import { HttpInterceptorFn, HttpErrorResponse, HttpRequest } from '@angular/common/http'; import { inject } from '@angular/core'; import { catchError, switchMap, throwError } from 'rxjs'; import { AuthService } from '../../shared/services/auth.service'; import { Router } from '@angular/router'; import { ApiConfigService } from '../services/api-config.service'; /** * Functional HTTP interceptor that: * 1. Attaches `Authorization: Bearer ` to every request destined for * the ROLAC API (except public auth endpoints). * 2. On a 401 response, silently calls POST /api/auth/refresh and retries * the original request once with the new token. * 3. If the refresh also fails, logs the user out and redirects to /login. */ export const authInterceptor: HttpInterceptorFn = (req, next) => { const authService = inject(AuthService); const apiConfig = inject(ApiConfigService); const router = inject(Router); // Attach token to qualifying requests const request = attachToken(req, authService, apiConfig); return next(request).pipe( catchError((error: HttpErrorResponse) => { // Only intercept 401s — and only for the first attempt (no X-Retry header) if (error.status === 401 && !req.headers.has('X-Retry')) { return authService.refresh().pipe( switchMap(success => { if (success) { // Retry with the fresh token; mark as retry to prevent loops const retryReq = req.clone({ setHeaders: { Authorization: `Bearer ${authService.getToken()}`, 'X-Retry': 'true' } }); return next(retryReq); } // Refresh failed — session is gone authService.logout(); router.navigate(['/login']); return throwError(() => error); }) ); } // Second 401 (retry was already attempted) or non-401 error if (error.status === 401) { authService.logout(); router.navigate(['/login']); } return throwError(() => error); }) ); }; /** * Returns a cloned request with the Bearer token header if: * - The request URL targets the ROLAC API base URL, AND * - The endpoint is not a public auth endpoint, AND * - A token is currently held in memory. */ function attachToken( req: HttpRequest, authService: AuthService, apiConfig: ApiConfigService ): HttpRequest { const token = authService.getToken(); if (!token || !shouldAddToken(apiConfig, req)) return req; return req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }); } /** Public auth paths that must never carry an access token. */ const PUBLIC_AUTH_PATHS = ['/auth/login', '/auth/refresh', '/auth/logout']; function shouldAddToken(apiConfig: ApiConfigService, req: HttpRequest): boolean { if (!req.url.startsWith(apiConfig.getBaseUrl())) return false; return !PUBLIC_AUTH_PATHS.some(path => req.url.includes(path)); } ``` - [ ] **Step 4.2 — Run the interceptor tests and confirm they all pass** ``` cd E:\VSProject\ROLAC\APP ng test --include=src/app/core/interceptors/auth.interceptor.spec.ts --watch=false ``` Expected: All specs PASS. Fix any failures before moving on. - [ ] **Step 4.3 — Run all tests to check for regressions** ``` cd E:\VSProject\ROLAC\APP ng test --watch=false ``` Expected: All specs PASS across both spec files. - [ ] **Step 4.4 — Commit** ``` git add src/app/core/interceptors/auth.interceptor.ts git commit -m "feat: update authInterceptor with correct auth paths and auto-refresh on 401" ``` --- ## Task 5: Add `APP_INITIALIZER` to `app.config.ts` **Files:** - Modify: `src/app/app.config.ts` - [ ] **Step 5.1 — Update `app.config.ts`** Replace `src/app/app.config.ts` with: ```typescript import { ApplicationConfig, APP_INITIALIZER } from '@angular/core'; import { provideRouter } from '@angular/router'; import { provideAnimations } from '@angular/platform-browser/animations'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { routes } from './app.routes'; import { authInterceptor } from './core/interceptors/auth.interceptor'; import { AuthService } from './shared/services/auth.service'; export const appConfig: ApplicationConfig = { providers: [ provideRouter(routes), provideAnimations(), provideHttpClient(withInterceptors([authInterceptor])), { provide: APP_INITIALIZER, useFactory: (authService: AuthService) => () => authService.initializeFromRefreshToken(), deps: [AuthService], multi: true } ] }; ``` - [ ] **Step 5.2 — Build to check for compile errors** ``` cd E:\VSProject\ROLAC\APP ng build --configuration=development ``` Expected: Build succeeds with no errors (warnings about bundle size are OK). - [ ] **Step 5.3 — Run all tests one final time** ``` cd E:\VSProject\ROLAC\APP ng test --watch=false ``` Expected: All specs PASS. - [ ] **Step 5.4 — Commit** ``` git add src/app/app.config.ts git commit -m "feat: add APP_INITIALIZER to restore session from refresh token cookie on page load" ``` --- ## Task 6: Smoke-test against the running API > This task is manual and requires both the Angular dev server and the ROLAC API to be running. - [ ] **Step 6.1 — Start the API and Angular dev server** ``` # Terminal 1 — API (adjust path if needed) cd E:\VSProject\ROLAC\API dotnet run --project ROLAC.API # Terminal 2 — Angular cd E:\VSProject\ROLAC\APP ng serve ``` - [ ] **Step 6.2 — Test happy-path login** 1. Open `http://localhost:4200/login` 2. Click the login button to show the form 3. Enter valid credentials and submit 4. ✅ Should redirect to `/dashboard` 5. Open DevTools → Application → Cookies: confirm `rolac_rt` HttpOnly cookie is present 6. Open DevTools → Application → Local Storage: confirm **no** `currentUser` key (we removed localStorage) - [ ] **Step 6.3 — Test invalid credentials** 1. Enter wrong email/password 2. ✅ Should show "Invalid email or password" error inline — no redirect - [ ] **Step 6.4 — Test session restore on page reload** 1. While logged in, hard-reload the page (`Ctrl+Shift+R`) 2. ✅ Should stay on `/dashboard` (not redirect to `/login`) 3. ✅ Token is back in memory (check Network tab — subsequent API calls carry `Authorization: Bearer ...`) - [ ] **Step 6.5 — Commit smoke-test confirmation note** ``` git commit --allow-empty -m "chore: smoke-tested login API integration against live API" ``` --- ## Summary of changes | File | What changed | |------|-------------| | `auth.service.ts` | Full rewrite: new interfaces (`UserInfo`, `ApiLoginResponse`), POSTs JSON to `/api/auth/login`, `refresh()`, `logout()` API calls, in-memory BehaviorSubject storage | | `auth.service.spec.ts` | New: full test coverage for login/refresh/logout/init | | `auth.interceptor.ts` | Fixed public paths, added `switchMap` auto-refresh on 401 with X-Retry sentinel | | `auth.interceptor.spec.ts` | New: coverage for token attachment, refresh flow, loop prevention | | `app.config.ts` | Added `APP_INITIALIZER` for session restore on page load |