import { Injectable, signal, computed, inject } from '@angular/core'; import { Observable, tap } from 'rxjs'; import { AuthSession, AuthStatus, WebSessionStart } from './models/session.model'; import { TelegramSessionApiService } from './telegram-session-api.service'; const WEB_SESSION_COOKIE = 'webSessionID'; const WEB_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60; /** Customer-facing Telegram QR/session auth. Distinct storage/state from AdminAuthService by design. */ @Injectable({ providedIn: 'root' }) export class AuthService { private readonly api = inject(TelegramSessionApiService); 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 sessionCheckTimer?: ReturnType; constructor() { // 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 beyond activating on success (used for polling). */ checkSessionOnce(webSessionID = this.getStoredWebSessionID()): Observable { return this.api.checkSessionOnce(webSessionID).pipe( tap(session => { if (session?.active) { this.activateSession(session); } }) ); } /** * 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: string): string { return this.api.getBotLoginUrl(webSessionID); } /** Generate a Telegram app deep link for mobile login without opening a browser tab. */ getTelegramAppLoginUrl(webSessionID: string): string { return this.api.getBotAppLoginUrl(webSessionID); } /** Create a backend web session and return the Telegram start link for it. */ createWebSession(): Observable { return this.api.createSession(); } /** 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.api.logout(webSessionID).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 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`; } }