import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable, computed, inject, signal } from '@angular/core'; import { Observable, finalize, shareReplay, tap, throwError } from 'rxjs'; import { AuthTokenResponse, LoginRequest, PublicUser, PublicUserSearchResult, RegisterRequest, RegisterResponse, TaskDigestPreference, } from './auth.models'; const ACCESS_TOKEN_KEY = 'listify.accessToken'; const REFRESH_TOKEN_KEY = 'listify.refreshToken'; const USER_KEY = 'listify.user'; @Injectable({ providedIn: 'root' }) export class AuthService { private readonly http = inject(HttpClient); private readonly apiUrl = '/api/auth'; private readonly userSignal = signal(this.readStoredUser()); private refreshRequest$: Observable | null = null; readonly user = this.userSignal.asReadonly(); readonly isAuthenticated = computed(() => Boolean(this.userSignal())); login(credentials: LoginRequest): Observable { return this.http .post(`${this.apiUrl}/login`, credentials) .pipe(tap((response) => this.storeSession(response))); } startSsoLogin(): void { if (typeof window !== 'undefined') { window.location.href = `${this.apiUrl}/sso/login`; } } completeSsoLogin(response: AuthTokenResponse): void { this.storeSession(response); } exchangeSsoCode(code: string, state: string): Observable { return this.http .post(`${this.apiUrl}/sso/exchange`, { code, state }) .pipe(tap((response) => this.storeSession(response))); } register(data: RegisterRequest): Observable { return this.http.post(`${this.apiUrl}/register`, data); } loadCurrentUser(): Observable { return this.http.get(`${this.apiUrl}/me`).pipe(tap((user) => this.storeUser(user))); } searchUsers(query: string): Observable { const params = new HttpParams().set('q', query); return this.http.get(`${this.apiUrl}/users/search`, { params, }); } updateOnboardingCompleted(completed: boolean): Observable { return this.http .patch(`${this.apiUrl}/me/onboarding`, { completed }) .pipe(tap((user) => this.storeUser(user))); } updateTaskDigestPreference(preference: TaskDigestPreference): Observable { return this.http .patch(`${this.apiUrl}/me/task-digest`, { preference }) .pipe(tap((user) => this.storeUser(user))); } accessToken(): string | null { return this.storage?.getItem(ACCESS_TOKEN_KEY) ?? null; } refreshToken(): string | null { return this.storage?.getItem(REFRESH_TOKEN_KEY) ?? null; } refreshSession(): Observable { const refreshToken = this.refreshToken(); if (!refreshToken) { return throwError(() => new Error('Refresh token is missing.')); } this.refreshRequest$ ??= this.http .post(`${this.apiUrl}/refresh`, { refreshToken }) .pipe( tap((response) => this.storeSession(response)), finalize(() => { this.refreshRequest$ = null; }), shareReplay({ bufferSize: 1, refCount: true }), ); return this.refreshRequest$; } logout(): void { this.storage?.removeItem(ACCESS_TOKEN_KEY); this.storage?.removeItem(REFRESH_TOKEN_KEY); this.storage?.removeItem(USER_KEY); this.userSignal.set(null); } private storeSession(response: AuthTokenResponse): void { this.storage?.setItem(ACCESS_TOKEN_KEY, response.accessToken); this.storage?.setItem(REFRESH_TOKEN_KEY, response.refreshToken); this.storeUser(response.user); } private storeUser(user: PublicUser): void { this.storage?.setItem(USER_KEY, JSON.stringify(user)); this.userSignal.set(user); } private readStoredUser(): PublicUser | null { const rawUser = this.storage?.getItem(USER_KEY); if (!rawUser) { return null; } try { return JSON.parse(rawUser) as PublicUser; } catch { this.storage?.removeItem(USER_KEY); return null; } } private get storage(): Storage | null { return typeof window === 'undefined' ? null : window.localStorage; } }