2026-08-18 00:52:00 +04:00
|
|
|
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<AuthSession | null>(null);
|
|
|
|
|
private statusSignal = signal<AuthStatus>('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<typeof setTimeout>;
|
|
|
|
|
|
|
|
|
|
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<AuthSession | null> {
|
|
|
|
|
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<WebSessionStart> {
|
|
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-21 08:08:42 +04:00
|
|
|
/** Accept a session returned by credentials or an external provider. */
|
|
|
|
|
acceptSession(session: AuthSession): void {
|
|
|
|
|
this.activateSession(session);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 00:52:00 +04:00
|
|
|
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`;
|
|
|
|
|
}
|
|
|
|
|
}
|