import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { BehaviorSubject, Observable, of } from 'rxjs'; import { catchError, filter, finalize, map, shareReplay, take, tap } from 'rxjs/operators'; import { ApiConfigService } from '../../core/services/api-config.service'; import { ModuleActions } from '../../core/models/permission.model'; // ── Public interfaces ───────────────────────────────────────────────────────── /** Matches the C# MemberInfo DTO exactly. */ export interface MemberInfo { id: number; nickName: string | null; firstName_en: string; lastName_en: string; firstName_zh: string | null; lastName_zh: string | null; } /** Matches the C# UserInfo DTO exactly. */ export interface UserInfo { id: string; email: string; roles: string[]; languagePreference: string; /** * Effective permissions, keyed by camelCased module name (server uses a * camelCase dictionary-key policy). Absent for legacy/secret-link tokens. */ permissions?: Record; /** * The church member linked to this account, or absent for admin-only * accounts and accounts whose member record was deleted. Flows through * login, refresh, and /me so the greeting survives a page reload. */ memberInfo?: MemberInfo; } /** 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; } /** Matches the C# ValidateInvitationResult DTO. */ export interface ValidateInvitationResult { valid: boolean; expired: boolean; memberName?: string; email?: string; } export interface TokenVerificationResult { isValid: boolean; /** Constructed from JWT claims when using secret-link login. */ user?: UserInfo; /** The raw JWT from the URL — use as the access token for this session. */ accessToken?: string; message?: string; expiresAt?: Date; requiresMfa?: boolean; } // ── Service ─────────────────────────────────────────────────────────────────── @Injectable({ providedIn: 'root' }) export class AuthService { /** * In-memory only — never written to localStorage. * Non-private intentionally: unit tests seed state via these subjects directly. * Production code must use getToken(), getCurrentUser(), and setCurrentUser(). */ accessToken$ = new BehaviorSubject(null); currentUser$ = new BehaviorSubject(null); /** Observable stream of the current user (null = not authenticated). */ public currentUser = this.currentUser$.asObservable(); private readonly sessionReady$ = new BehaviorSubject(false); private refreshInFlight$: Observable | null = null; 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 { if (!this.refreshInFlight$) { this.refreshInFlight$ = 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)), finalize(() => { this.refreshInFlight$ = null; }), shareReplay(1) ); } return this.refreshInFlight$; } /** * Changes the current user's password. Sends the cookie so the server can * keep the current session alive while revoking the user's other sessions. * Emits void on success (204); errors propagate so the caller can show the * server message. */ changePassword(currentPassword: string, newPassword: string): Observable { return this.http.post( `${this.apiConfig.authUrl}/change-password`, { currentPassword, newPassword }, { withCredentials: true } ); } /** * Checks whether an invitation token is still usable (anonymous). Used by the * public "set your password" page to decide what to show before the member types. */ validateInvitation(token: string): Observable { return this.http.get( `${this.apiConfig.authUrl}/invitation/validate`, { params: { token } } ); } /** * Consumes an invitation: sets the password and logs the member in. On success the * server returns a normal login payload, so we store the access token + user (and the * refresh cookie is set server-side) exactly like login(). Errors propagate to the caller. */ acceptInvitation(token: string, newPassword: string): Observable { return this.http.post( `${this.apiConfig.authUrl}/accept-invitation`, { token, newPassword }, { withCredentials: true } ).pipe( tap(response => { this.accessToken$.next(response.accessToken); this.currentUser$.next(response.user); }), map(response => response.user) ); } /** * 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().pipe( finalize(() => { this.sessionReady$.next(true); resolve(); }) ).subscribe(); }); } /** Resolves once startup session restore has finished (success or failure). */ whenSessionReady(): Observable { if (this.sessionReady$.value) { return of(true); } return this.sessionReady$.pipe(filter(Boolean), take(1)); } // ── State accessors ───────────────────────────────────────────────────── getToken(): string | null { return this.accessToken$.value; } isAuthenticated(): boolean { return this.currentUser$.value !== null && this.getToken() !== 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; } } }