import { Injectable, signal, computed } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, of, catchError, map, tap } from 'rxjs'; import { AuthSession, AuthStatus, WebSessionStart } from '../models/auth.model'; import { environment } from '../../environments/environment'; const WEB_SESSION_COOKIE = 'webSessionID'; const WEB_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60; @Injectable({ providedIn: 'root' }) export class AuthService { private sessionSignal = signal(null); private statusSignal = signal('unknown'); private showLoginSignal = signal(false); /** Current auth session */ readonly session = this.sessionSignal.asReadonly(); /** Current auth status */ readonly status = this.statusSignal.asReadonly(); /** Whether user is fully authenticated */ readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated'); /** Whether to show login dialog */ readonly showLoginDialog = this.showLoginSignal.asReadonly(); /** Display name of authenticated user */ readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null); private readonly authApiUrl = environment.authApiUrl; private sessionCheckTimer?: ReturnType; constructor(private http: HttpClient) { // On init, check existing session via cookie this.checkSession(); } /** Check the current webSessionID cookie against the auth backend. */ checkSession(): void { const webSessionID = this.getStoredWebSessionID(); if (!webSessionID) { this.clearAuthState('unauthenticated'); return; } this.statusSignal.set('checking'); this.checkSessionOnce(webSessionID).subscribe(session => { if (!session?.active) { this.clearAuthState('unauthenticated'); } }); } /** Check session without updating internal state (for polling) */ checkSessionOnce(webSessionID = this.getStoredWebSessionID()): Observable { if (!webSessionID) { return of(null); } return this.http.get>( `${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}` ).pipe( map(response => this.normalizeWebSession(response, webSessionID)), tap(session => { if (session?.active) { this.activateSession(session); } }), catchError(() => of(null)) ); } /** * Called after user completes Telegram login. */ onTelegramLoginComplete(): void { this.hideLogin(); if (!this.isAuthenticated()) { this.checkSession(); } } /** Generate the Telegram login URL for bot-based auth */ getTelegramLoginUrl(webSessionID = this.generateGuid()): string { const botUsername = (environment as Record)['telegramBot'] as string || 'DexarSupport_bot'; return `https://t.me/${botUsername}?start=${encodeURIComponent(webSessionID)}`; } /** Get QR code data URL for Telegram login */ getTelegramQrUrl(): string { return this.getTelegramLoginUrl(); } /** Create a backend web session and return the Telegram start link for it. */ createWebSession(): Observable { const webSessionID = this.generateGuid(); return this.http.post>( `${this.authApiUrl}/users/sessions`, { webSessionID }, { headers: { WebSessionID: webSessionID } } ).pipe( map(response => { const responseWebSessionID = this.extractSessionId(response, webSessionID); return { webSessionID: responseWebSessionID, url: this.getTelegramLoginUrl(responseWebSessionID), }; }) ); } /** Show login dialog (called when user tries to pay without being logged in) */ requestLogin(): void { this.showLoginSignal.set(true); } /** Hide login dialog */ hideLogin(): void { this.showLoginSignal.set(false); } /** Logout — clears session on backend and locally */ logout(): void { const webSessionID = this.sessionSignal()?.sessionId || this.getStoredWebSessionID(); if (!webSessionID) { this.clearAuthState('unauthenticated'); return; } this.http.delete(`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`, { headers: { WebSessionID: webSessionID } }).pipe( catchError(() => of(null)) ).subscribe(() => { this.clearAuthState('unauthenticated'); }); } private activateSession(session: AuthSession): void { this.sessionSignal.set(session); this.statusSignal.set('authenticated'); this.setStoredWebSessionID(session.sessionId); this.scheduleSessionRefresh(session.expires); } private clearAuthState(status: AuthStatus): void { this.sessionSignal.set(null); this.statusSignal.set(status); this.clearStoredWebSessionID(); this.clearSessionRefresh(); } /** Schedule a session re-check before it expires */ private scheduleSessionRefresh(expiresAt: string): void { this.clearSessionRefresh(); const expiresMs = new Date(expiresAt).getTime(); const nowMs = Date.now(); // Re-check 60 seconds before expiry, minimum 30s from now const refreshIn = Number.isFinite(expiresMs) ? Math.max(expiresMs - nowMs - 60_000, 30_000) : WEB_SESSION_COOKIE_MAX_AGE_SECONDS * 1000; this.sessionCheckTimer = setTimeout(() => { this.checkSession(); }, refreshIn); } private clearSessionRefresh(): void { if (this.sessionCheckTimer) { clearTimeout(this.sessionCheckTimer); this.sessionCheckTimer = undefined; } } private normalizeWebSession(response: Record | null, fallbackSessionId: string): AuthSession | null { if (!response) { return null; } const user = this.asRecord(this.readFirst(response, ['user', 'User', 'telegramUser', 'TelegramUser'])) ?? response; const status = this.readFirst(response, [ 'status', 'Status', 'active', 'Active', 'loggedIn', 'LoggedIn', 'isLoggedIn', 'IsLoggedIn', 'authenticated', 'Authenticated' ]); const active = this.isActiveStatus(status); const sessionId = this.extractSessionId(response, fallbackSessionId); const username = this.readString(this.readFirst(user, ['username', 'Username'])) ?? this.readString(this.readFirst(response, ['username', 'Username'])); const firstName = this.readString(this.readFirst(user, ['firstName', 'first_name', 'FirstName', 'First_name'])); const lastName = this.readString(this.readFirst(user, ['lastName', 'last_name', 'LastName', 'Last_name'])); const fullName = [firstName, lastName].filter(Boolean).join(' '); const explicitDisplayName = this.readString(this.readFirst(response, ['displayName', 'DisplayName', 'name', 'Name'])) ?? this.readString(this.readFirst(user, ['displayName', 'DisplayName', 'name', 'Name'])) const displayName = explicitDisplayName ?? username ?? (fullName || 'Telegram User'); const telegramUserId = this.readNumber(this.readFirst(user, ['userId','telegramUserId', 'telegramUserID', 'TelegramUserID', 'id', 'ID'])) ?? this.readNumber(this.readFirst(response, ['userId', 'telegramUserId', 'telegramUserID', 'TelegramUserID', 'userID', 'UserID', 'UserId'])) ?? null; const expiresAt = this.readString(this.readFirst(response, ['expiresAt', 'ExpiresAt', 'expires', 'Expires'])) ?? new Date(Date.now() + WEB_SESSION_COOKIE_MAX_AGE_SECONDS * 1000).toISOString(); return { sessionId, userId: telegramUserId, username, displayName, active, expires: expiresAt, }; } private extractSessionId(response: Record | null, fallbackSessionId: string): string { if (!response) { return fallbackSessionId; } return this.readString(this.readFirst(response, [ 'webSessionID', 'WebSessionID', 'webSessionId', 'sessionID', 'SessionID', 'sessionId', 'id', 'ID' ])) ?? fallbackSessionId; } private readFirst(source: Record, keys: string[]): unknown { for (const key of keys) { if (Object.prototype.hasOwnProperty.call(source, key)) { return source[key]; } } return undefined; } private readString(value: unknown): string | null { if (typeof value === 'string' && value.trim()) { return value; } if (typeof value === 'number' || typeof value === 'bigint') { return value.toString(); } return null; } private readNumber(value: unknown): number | null { if (typeof value === 'number' && Number.isFinite(value)) { return value; } if (typeof value === 'string') { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } return null; } private asRecord(value: unknown): Record | null { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : null; } private isActiveStatus(status: unknown): boolean { if (status === true || status === 1) { return true; } if (typeof status !== 'string') { return false; } return ['true', '1', 'active', 'authenticated', 'confirmed', 'success', 'logged_in'].includes(status.toLowerCase()); } private generateGuid(): string { if (globalThis.crypto?.randomUUID) { return globalThis.crypto.randomUUID(); } const bytes = new Uint8Array(16); if (globalThis.crypto?.getRandomValues) { globalThis.crypto.getRandomValues(bytes); } else { for (let index = 0; index < bytes.length; index++) { bytes[index] = Math.floor(Math.random() * 256); } } bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')); return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`; } private getStoredWebSessionID(): string | null { if (typeof document === 'undefined') { return null; } const cookie = document.cookie .split('; ') .find(row => row.startsWith(`${WEB_SESSION_COOKIE}=`)); if (!cookie) { return null; } try { return decodeURIComponent(cookie.substring(WEB_SESSION_COOKIE.length + 1)); } catch { return null; } } private setStoredWebSessionID(webSessionID: string): void { if (typeof document === 'undefined') { return; } const secure = typeof window !== 'undefined' && window.location.protocol === 'https:' ? '; Secure' : ''; document.cookie = `${WEB_SESSION_COOKIE}=${encodeURIComponent(webSessionID)}; Max-Age=${WEB_SESSION_COOKIE_MAX_AGE_SECONDS}; Path=/; SameSite=Lax${secure}`; } private clearStoredWebSessionID(): void { if (typeof document === 'undefined') { return; } document.cookie = `${WEB_SESSION_COOKIE}=; Max-Age=0; Path=/; SameSite=Lax`; } }