# 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<string | null>(null);
    private currentUser$ = new BehaviorSubject<UserInfo | null>(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<LoginResult> {
        return this.http.post<ApiLoginResponse>(
            `${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<boolean> {
        return this.http.post<ApiLoginResponse>(
            `${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<void> {
        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<TokenVerificationResult> {
        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<AuthService>;
  let routerSpy: jasmine.SpyObj<Router>;
  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 <token>` 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<unknown>,
    authService: AuthService,
    apiConfig: ApiConfigService
): HttpRequest<unknown> {
    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<unknown>): 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 |
