import { Injectable, signal, computed, inject } from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import { readStoredSession, writeStoredSession, clearStoredSession, StoredSession, } from './auth.session-storage'; export interface CurrentUser { id: string; username: string; role: 'admin' | 'player'; } interface RefreshResponse { accessToken: string; expiresIn: number; user: CurrentUser; } export interface LoginSuccess { ok: true; accessToken: string; expiresIn: number; user: CurrentUser; } export interface LoginFailure { ok: false; status: number; code: string; message: string; } export type LoginResult = LoginSuccess | LoginFailure; export interface RegisterSuccess { ok: true; accessToken: string; expiresIn: number; user: CurrentUser; } export interface RegisterFailure { ok: false; status: number; code: string; message: string; } export type RegisterResult = RegisterSuccess | RegisterFailure; @Injectable({ providedIn: 'root' }) export class AuthService { private http: HttpClient; constructor(http?: HttpClient) { this.http = http ?? inject(HttpClient); } private accessToken = signal(null); private user = signal(null); readonly hydrated = signal(false); private hydrateWaiters: Array<() => void> = []; readonly currentUser = computed(() => this.user()); readonly isAuthenticated = computed(() => !!this.user()); async ensureCsrf(): Promise { try { await firstValueFrom( this.http.get('/api/v1/auth/csrf', { withCredentials: true }), ); } catch { // middleware will still mint the cookie on the next response } } async login(creds: { username: string; password: string }): Promise { await this.ensureCsrf(); try { const res = await firstValueFrom( this.http.post<{ accessToken: string; expiresIn: number; user: CurrentUser }>( '/api/v1/auth/login', creds, { withCredentials: true, observe: 'response' }, ), ); const success: LoginSuccess = { ok: true, accessToken: res.body!.accessToken, expiresIn: res.body!.expiresIn, user: res.body!.user, }; return success; } catch (e) { return mapAuthError(e as HttpErrorResponse); } } async register(creds: { username: string; password: string; passwordConfirm: string; }): Promise { await this.ensureCsrf(); try { const res = await firstValueFrom( this.http.post<{ accessToken: string; expiresIn: number; user: CurrentUser }>( '/api/v1/auth/register', creds, { withCredentials: true, observe: 'response' }, ), ); const success: RegisterSuccess = { ok: true, accessToken: res.body!.accessToken, expiresIn: res.body!.expiresIn, user: res.body!.user, }; return success; } catch (e) { return mapAuthError(e as HttpErrorResponse) as RegisterFailure; } } setSession(token: string, user: CurrentUser): void { this.accessToken.set(token); this.user.set(user); writeStoredSession(this.sessionStorage(), { token, user }); } clear(): void { this.accessToken.set(null); this.user.set(null); clearStoredSession(this.sessionStorage()); } getAccessToken(): string | null { return this.accessToken(); } private sessionStorage(): Storage { return typeof window !== 'undefined' ? window.sessionStorage : (undefined as unknown as Storage); } private restoreFromStorage(): void { const stored = readStoredSession(this.sessionStorage()); if (stored) { this.accessToken.set(stored.token); this.user.set(stored.user as CurrentUser); } } private markHydrated(): void { if (this.hydrated()) return; this.hydrated.set(true); const waiters = this.hydrateWaiters; this.hydrateWaiters = []; for (const resolve of waiters) resolve(); } waitUntilHydrated(): Promise { if (this.hydrated()) return Promise.resolve(); return new Promise((resolve) => { this.hydrateWaiters.push(resolve); }); } async restoreSession(): Promise { this.restoreFromStorage(); try { const res = await firstValueFrom( this.http.post( '/api/v1/auth/refresh', {}, { withCredentials: true }, ), ); if (res && res.accessToken && res.user) { this.setSession(res.accessToken, res.user); this.markHydrated(); return true; } this.clear(); this.markHydrated(); return false; } catch { this.clear(); this.markHydrated(); return false; } } } function mapAuthError(err: HttpErrorResponse): LoginFailure { const body = err.error as { code?: string; message?: string } | null; return { ok: false, status: err.status ?? 0, code: body?.code ?? 'INTERNAL', message: body?.message ?? err.message ?? 'Authentication failed', }; }