import * as i0 from '@angular/core'; import { InjectionToken, makeEnvironmentProviders, inject, Injectable, signal, computed, isDevMode, input, booleanAttribute, output, DestroyRef, Component } from '@angular/core'; import { HttpHeaders, HttpClient, HttpErrorResponse } from '@angular/common/http'; import { form, required, FormField } from '@angular/forms/signals'; import * as QRCode from 'qrcode'; import { map, of, catchError, tap, throwError, timer, switchMap, Observable, finalize } from 'rxjs'; import { Router } from '@angular/router'; /** Base URL for the auth backend, e.g. `https://api.example.com`. Provide from the consuming app's environment config. */ const AUTH_API_URL = new InjectionToken('@marketplaces/auth AUTH_API_URL'); /** Telegram bot username used to build QR/deep-link login URLs. Optional — falls back to a default if not provided. */ const TELEGRAM_BOT_USERNAME = new InjectionToken('@marketplaces/auth TELEGRAM_BOT_USERNAME'); const MARKETPLACES_AUTH_CONFIG = new InjectionToken('@marketplaces/auth config'); function provideMarketplacesAuth(config) { const normalized = { ...config, apiUrl: config.apiUrl.replace(/\/$/, ''), credentialsPath: config.credentialsPath ?? '/auth/credentials/login', yandexStartPath: config.yandexStartPath ?? '/auth/yandex/sessions', yandexSessionPath: config.yandexSessionPath ?? '/auth/yandex/sessions', pollIntervalMs: config.pollIntervalMs ?? 1500, }; return makeEnvironmentProviders([ { provide: MARKETPLACES_AUTH_CONFIG, useValue: normalized }, { provide: AUTH_API_URL, useValue: normalized.apiUrl }, ...(normalized.telegramBotUsername ? [{ provide: TELEGRAM_BOT_USERNAME, useValue: normalized.telegramBotUsername }] : []), ]); } const MARKETPLACE_DOMAIN_HEADER = 'X-Marketplace-Domain'; function normalizeMarketplaceDomain(domain) { return domain.trim().toLowerCase().replace(/\.$/, ''); } class AuthMarketplaceContext { constructor() { this.config = inject(MARKETPLACES_AUTH_CONFIG); } domain() { const configured = this.config.marketplaceDomain; const domain = typeof configured === 'function' ? configured() : configured ?? (typeof location === 'undefined' ? '' : location.hostname); return normalizeMarketplaceDomain(domain); } headers(extra) { const domain = this.domain(); if (!domain) throw new Error('Marketplace domain cannot be resolved'); return new HttpHeaders({ [MARKETPLACE_DOMAIN_HEADER]: domain, ...extra }); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthMarketplaceContext, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthMarketplaceContext, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthMarketplaceContext, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); /** RFC4122 v4-ish GUID, using crypto when available. Shared by customer and admin session creation. */ function generateGuid() { 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('')}`; } const SESSION_MAX_AGE_SECONDS = 60 * 60; const DEFAULT_TELEGRAM_BOT_USERNAME = 'DexarSupport_bot'; /** * The one Telegram QR/session API (`{authApiUrl}/users/sessions`). Customer * login (AuthService) and admin login (AdminAuthService) both call this same * service against this same endpoint - there is no separate admin backend. * This class only does the HTTP call + response normalization; it holds no * session state and writes no cookies, so each caller manages its own * storage/signals independently on top of it. */ class TelegramSessionApiService { constructor() { this.http = inject(HttpClient); this.authApiUrl = inject(AUTH_API_URL); this.telegramBotUsername = inject(TELEGRAM_BOT_USERNAME, { optional: true }); this.marketplaceContext = inject(AuthMarketplaceContext); } createSession() { const webSessionID = generateGuid(); return this.http.post(`${this.authApiUrl}/users/sessions`, { webSessionID }, { headers: this.marketplaceContext.headers({ WebSessionID: webSessionID }) }).pipe(map(response => { const responseWebSessionID = this.extractSessionId(response, webSessionID); return { webSessionID: responseWebSessionID, url: this.getBotLoginUrl(responseWebSessionID), }; })); } checkSessionOnce(webSessionID) { if (!webSessionID) { return of(null); } return this.http.get(`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`, { headers: this.marketplaceContext.headers() }).pipe(map(response => this.normalizeWebSession(response, webSessionID)), catchError(() => of(null))); } logout(webSessionID) { return this.http.delete(`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`, { headers: this.marketplaceContext.headers({ WebSessionID: webSessionID }) }).pipe(catchError(() => of(null))); } getBotLoginUrl(webSessionID) { return `https://t.me/${this.getBotUsername()}?start=${encodeURIComponent(webSessionID)}`; } getBotAppLoginUrl(webSessionID) { return `tg://resolve?domain=${encodeURIComponent(this.getBotUsername())}&start=${encodeURIComponent(webSessionID)}`; } getBotUsername() { return this.telegramBotUsername || DEFAULT_TELEGRAM_BOT_USERNAME; } normalizeWebSession(response, fallbackSessionId) { 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() + SESSION_MAX_AGE_SECONDS * 1000).toISOString(); return { sessionId, userId: telegramUserId, username, displayName, active, expires: expiresAt }; } extractSessionId(response, fallbackSessionId) { if (!response) { return fallbackSessionId; } return this.readString(this.readFirst(response, [ 'webSessionID', 'WebSessionID', 'webSessionId', 'sessionID', 'SessionID', 'sessionId', 'id', 'ID' ])) ?? fallbackSessionId; } readFirst(source, keys) { for (const key of keys) { if (Object.prototype.hasOwnProperty.call(source, key)) { return source[key]; } } return undefined; } readString(value) { if (typeof value === 'string' && value.trim()) { return value; } if (typeof value === 'number' || typeof value === 'bigint') { return value.toString(); } return null; } readNumber(value) { 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; } asRecord(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null; } isActiveStatus(status) { 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()); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: TelegramSessionApiService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: TelegramSessionApiService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: TelegramSessionApiService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); /** * Admin login uses the exact same Telegram QR/session API as the customer * login (TelegramSessionApiService) - there is no separate admin backend * endpoint, and none should be invented client-side. Only the *storage* is * kept separate from AuthService, so an admin QR scan never authenticates * the customer session or vice versa: distinct cookie name, distinct * signals, distinct guard/interceptor. * * Since the session API itself has no concept of "admin", the frontend * cannot tell an admin Telegram session from a regular one. Actual admin * authorization must be enforced server-side when admin API calls are made * with the resulting session id - the frontend only decides where to * *store* the result. */ const ADMIN_SESSION_COOKIE = 'adminSessionID'; const ADMIN_TOKEN_STORAGE_KEY = 'adminToken'; const ADMIN_REFRESH_STORAGE_KEY = 'adminRefreshToken'; const ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60; class AdminAuthService { constructor() { this.api = inject(TelegramSessionApiService); this.sessionSignal = signal(null, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "sessionSignal" }] : /* istanbul ignore next */ [])); this.statusSignal = signal('unknown', /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "statusSignal" }] : /* istanbul ignore next */ [])); this.showLoginSignal = signal(false, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "showLoginSignal" }] : /* istanbul ignore next */ [])); this.session = this.sessionSignal.asReadonly(); this.status = this.statusSignal.asReadonly(); this.isAuthenticated = computed(() => this.statusSignal() === 'authenticated', /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "isAuthenticated" }] : /* istanbul ignore next */ [])); this.showLoginDialog = this.showLoginSignal.asReadonly(); this.displayName = computed(() => this.sessionSignal()?.displayName ?? null, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "displayName" }] : /* istanbul ignore next */ [])); this.checkSession(); } checkSession() { const webSessionID = this.getStoredAdminSessionID(); if (!webSessionID) { this.clearAuthState('unauthenticated'); return; } this.statusSignal.set('checking'); this.checkSessionOnce(webSessionID).subscribe(session => { if (!session?.active) { this.clearAuthState('unauthenticated'); } }); } /** Check session without mutating internal state beyond activating on success (used for polling). */ checkSessionOnce(webSessionID = this.getStoredAdminSessionID()) { return this.api.checkSessionOnce(webSessionID).pipe(tap(session => { if (session?.active) { this.activateSession(session); } })); } /** Create a backend web session - identical call to the customer login (TelegramSessionApiService.createSession). */ createWebSession() { return this.api.createSession(); } getAdminAppLoginUrl(webSessionID) { return this.api.getBotAppLoginUrl(webSessionID); } onLoginComplete() { this.hideLogin(); if (!this.isAuthenticated()) { this.checkSession(); } } requestLogin() { this.showLoginSignal.set(true); } /** * Dev-only shortcut for local testing without a reachable Telegram/session * backend: fabricates a local session and activates it directly, skipping * the QR flow entirely. No-ops in production builds (checked via Angular's * isDevMode() at runtime, not just build-time, so it is safe even if this * code ships). Never call this from anywhere reachable in a production build. */ devBypassLogin() { if (!isDevMode()) { return; } this.hideLogin(); this.activateSession({ sessionId: `dev-bypass-${Date.now()}`, userId: 0, username: 'dev-admin', displayName: 'Dev Admin (local bypass)', active: true, expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(), }); } hideLogin() { this.showLoginSignal.set(false); } logout() { const webSessionID = this.sessionSignal()?.sessionId || this.getStoredAdminSessionID(); if (!webSessionID) { this.clearAuthState('unauthenticated'); return; } this.api.logout(webSessionID).subscribe(() => this.clearAuthState('unauthenticated')); } /** Accept a session/tokens returned by credentials or an external provider. */ acceptSession(session, token, refreshToken) { this.activateSession(session); if (token && refreshToken) this.setAdminTokens(token, refreshToken); } /** JWT pair storage, reserved for once the backend issues admin access/refresh tokens. Unused until then. */ getAdminToken() { return typeof localStorage === 'undefined' ? null : localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY); } setAdminTokens(token, refreshToken) { if (typeof localStorage === 'undefined') { return; } localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, token); localStorage.setItem(ADMIN_REFRESH_STORAGE_KEY, refreshToken); } clearAdminTokens() { if (typeof localStorage === 'undefined') { return; } localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY); localStorage.removeItem(ADMIN_REFRESH_STORAGE_KEY); } activateSession(session) { this.sessionSignal.set(session); this.statusSignal.set('authenticated'); this.setStoredAdminSessionID(session.sessionId); this.scheduleSessionRefresh(session.expires); } clearAuthState(status) { this.sessionSignal.set(null); this.statusSignal.set(status); this.clearStoredAdminSessionID(); this.clearAdminTokens(); this.clearSessionRefresh(); } scheduleSessionRefresh(expiresAt) { this.clearSessionRefresh(); const expiresMs = new Date(expiresAt).getTime(); const nowMs = Date.now(); const refreshIn = Number.isFinite(expiresMs) ? Math.max(expiresMs - nowMs - 60_000, 30_000) : ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS * 1000; this.sessionCheckTimer = setTimeout(() => this.checkSession(), refreshIn); } clearSessionRefresh() { if (this.sessionCheckTimer) { clearTimeout(this.sessionCheckTimer); this.sessionCheckTimer = undefined; } } getStoredAdminSessionID() { if (typeof document === 'undefined') { return null; } const cookie = document.cookie.split('; ').find(row => row.startsWith(`${ADMIN_SESSION_COOKIE}=`)); if (!cookie) { return null; } try { return decodeURIComponent(cookie.substring(ADMIN_SESSION_COOKIE.length + 1)); } catch { return null; } } setStoredAdminSessionID(webSessionID) { if (typeof document === 'undefined') { return; } const secure = typeof window !== 'undefined' && window.location.protocol === 'https:' ? '; Secure' : ''; document.cookie = `${ADMIN_SESSION_COOKIE}=${encodeURIComponent(webSessionID)}; Max-Age=${ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS}; Path=/; SameSite=Strict${secure}`; } clearStoredAdminSessionID() { if (typeof document === 'undefined') { return; } document.cookie = `${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; SameSite=Strict`; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AdminAuthService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AdminAuthService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AdminAuthService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [] }); 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. */ let AuthService$1 = class AuthService { constructor() { this.api = inject(TelegramSessionApiService); this.sessionSignal = signal(null, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "sessionSignal" }] : /* istanbul ignore next */ [])); this.statusSignal = signal('unknown', /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "statusSignal" }] : /* istanbul ignore next */ [])); this.showLoginSignal = signal(false, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "showLoginSignal" }] : /* istanbul ignore next */ [])); /** Current auth session */ this.session = this.sessionSignal.asReadonly(); /** Current auth status */ this.status = this.statusSignal.asReadonly(); /** Whether user is fully authenticated */ this.isAuthenticated = computed(() => this.statusSignal() === 'authenticated', /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "isAuthenticated" }] : /* istanbul ignore next */ [])); /** Whether to show login dialog */ this.showLoginDialog = this.showLoginSignal.asReadonly(); /** Display name of authenticated user */ this.displayName = computed(() => this.sessionSignal()?.displayName ?? null, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "displayName" }] : /* istanbul ignore next */ [])); // On init, check existing session via cookie this.checkSession(); } /** Check the current webSessionID cookie against the auth backend. */ checkSession() { 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()) { return this.api.checkSessionOnce(webSessionID).pipe(tap(session => { if (session?.active) { this.activateSession(session); } })); } /** * Called after user completes Telegram login. */ onTelegramLoginComplete() { this.hideLogin(); if (!this.isAuthenticated()) { this.checkSession(); } } /** Generate the Telegram login URL for bot-based auth */ getTelegramLoginUrl(webSessionID) { return this.api.getBotLoginUrl(webSessionID); } /** Generate a Telegram app deep link for mobile login without opening a browser tab. */ getTelegramAppLoginUrl(webSessionID) { return this.api.getBotAppLoginUrl(webSessionID); } /** Create a backend web session and return the Telegram start link for it. */ createWebSession() { return this.api.createSession(); } /** Show login dialog (called when user tries to pay without being logged in) */ requestLogin() { this.showLoginSignal.set(true); } /** Hide login dialog */ hideLogin() { this.showLoginSignal.set(false); } /** Logout — clears session on backend and locally */ logout() { const webSessionID = this.sessionSignal()?.sessionId || this.getStoredWebSessionID(); if (!webSessionID) { this.clearAuthState('unauthenticated'); return; } this.api.logout(webSessionID).subscribe(() => { this.clearAuthState('unauthenticated'); }); } /** Accept a session returned by credentials or an external provider. */ acceptSession(session) { this.activateSession(session); } activateSession(session) { this.sessionSignal.set(session); this.statusSignal.set('authenticated'); this.setStoredWebSessionID(session.sessionId); this.scheduleSessionRefresh(session.expires); } clearAuthState(status) { this.sessionSignal.set(null); this.statusSignal.set(status); this.clearStoredWebSessionID(); this.clearSessionRefresh(); } /** Schedule a session re-check before it expires */ scheduleSessionRefresh(expiresAt) { 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); } clearSessionRefresh() { if (this.sessionCheckTimer) { clearTimeout(this.sessionCheckTimer); this.sessionCheckTimer = undefined; } } getStoredWebSessionID() { 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; } } setStoredWebSessionID(webSessionID) { 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}`; } clearStoredWebSessionID() { if (typeof document === 'undefined') { return; } document.cookie = `${WEB_SESSION_COOKIE}=; Max-Age=0; Path=/; SameSite=Lax`; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthService, providedIn: 'root' }); } }; i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthService$1, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [] }); const MARKETPLACES_AUTH_GATEWAY = new InjectionToken('@marketplaces/auth gateway', { providedIn: 'root', factory: () => inject(HttpMarketplacesAuthGateway) }); class HttpMarketplacesAuthGateway { constructor() { this.http = inject(HttpClient); this.config = inject(MARKETPLACES_AUTH_CONFIG); this.context = inject(AuthMarketplaceContext); this.customerAuth = inject(AuthService$1); this.adminAuth = inject(AdminAuthService); } startQr(mode) { return mode === 'admin' ? this.adminAuth.createWebSession() : this.customerAuth.createWebSession(); } checkQr(mode, attemptId) { return mode === 'admin' ? this.adminAuth.checkSessionOnce(attemptId) : this.customerAuth.checkSessionOnce(attemptId); } loginWithCredentials(mode, credentials) { return this.http.post(this.url(this.config.credentialsPath), { ...credentials, mode }, { headers: this.context.headers(), }).pipe(map(result => this.accept(mode, { ...result, method: 'credentials', mode })), catchError(error => throwError(() => this.failure('credentials', error)))); } startYandex(mode, returnUrl) { return this.http.post(this.url(this.config.yandexStartPath), { provider: 'yandex', mode, returnUrl, }, { headers: this.context.headers() }).pipe(catchError(error => throwError(() => this.failure('yandex', error)))); } checkYandex(mode, attemptId) { return this.http.get(`${this.url(this.config.yandexSessionPath)}/${encodeURIComponent(attemptId)}`, { headers: this.context.headers() }).pipe(map(result => result ? this.accept(mode, { ...result, method: 'yandex', mode }) : null), catchError((error) => error.status === 404 || error.status === 202 ? of(null) : throwError(() => this.failure('yandex', error)))); } accept(mode, result) { if (mode === 'admin') this.adminAuth.acceptSession(result.session, result.accessToken, result.refreshToken); else this.customerAuth.acceptSession(result.session); return result; } url(path = '') { return `${this.config.apiUrl}${path.startsWith('/') ? path : `/${path}`}`; } failure(method, cause) { const response = cause instanceof HttpErrorResponse ? cause : null; return { method, code: response?.status === 401 ? 'invalid_credentials' : 'backend', message: response?.error?.message || response?.message || 'Authentication failed', cause, }; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: HttpMarketplacesAuthGateway, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: HttpMarketplacesAuthGateway, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: HttpMarketplacesAuthGateway, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); class MarketplacesAuthComponent { constructor() { this.qr = input(false, { ...(ngDevMode ? { debugName: "qr" } : /* istanbul ignore next */ {}), transform: booleanAttribute }); this.credentials = input(false, { ...(ngDevMode ? { debugName: "credentials" } : /* istanbul ignore next */ {}), transform: booleanAttribute }); this.yandex = input(false, { ...(ngDevMode ? { debugName: "yandex" } : /* istanbul ignore next */ {}), transform: booleanAttribute }); this.mode = input('customer', /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "mode" }] : /* istanbul ignore next */ [])); this.title = input('Вход', /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ [])); this.authenticated = output(); this.authError = output(); this.cancelled = output(); this.method = signal(null, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "method" }] : /* istanbul ignore next */ [])); this.busy = signal(false, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ [])); this.error = signal(null, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ [])); this.qrImage = signal(null, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "qrImage" }] : /* istanbul ignore next */ [])); this.externalUrl = signal(null, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "externalUrl" }] : /* istanbul ignore next */ [])); this.credentialsModel = signal({ login: '', password: '' }, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "credentialsModel" }] : /* istanbul ignore next */ [])); this.credentialsForm = form(this.credentialsModel, path => { required(path.login, { message: 'Введите логин' }); required(path.password, { message: 'Введите пароль' }); }); this.gateway = inject(MARKETPLACES_AUTH_GATEWAY); this.config = inject(MARKETPLACES_AUTH_CONFIG); inject(DestroyRef).onDestroy(() => this.poll?.unsubscribe()); queueMicrotask(() => this.select(this.qr() ? 'qr' : this.credentials() ? 'credentials' : this.yandex() ? 'yandex' : null)); } select(method) { this.poll?.unsubscribe(); this.busy.set(false); this.error.set(null); this.method.set(method); } startQr() { this.begin(); this.gateway.startQr(this.mode()).subscribe({ next: attempt => void this.prepareQr(attempt.url, attempt.webSessionID).catch(cause => this.fail('qr', cause)), error: cause => this.fail('qr', cause), }); } loginWithCredentials(event) { event.preventDefault(); if (this.credentialsForm().invalid()) return; this.begin(); this.gateway.loginWithCredentials(this.mode(), this.credentialsModel()).subscribe({ next: result => this.finish(result), error: cause => this.fail('credentials', cause), }); } startYandex() { this.begin(); const returnUrl = typeof location === 'undefined' ? '' : location.href; this.gateway.startYandex(this.mode(), returnUrl).subscribe({ next: attempt => { const popup = typeof window === 'undefined' ? null : window.open(attempt.authorizationUrl, 'mp-yandex-auth', 'popup,width=520,height=720'); if (!popup) { this.fail('yandex', { method: 'yandex', code: 'popup_blocked', message: 'Браузер заблокировал окно Яндекса' }); return; } this.pollForYandex(attempt.attemptId); }, error: cause => this.fail('yandex', cause), }); } pollForQr(attemptId) { this.poll?.unsubscribe(); this.poll = timer(0, this.config.pollIntervalMs ?? 1500).pipe(switchMap(() => this.gateway.checkQr(this.mode(), attemptId))) .subscribe({ next: session => { if (session?.active) this.finish({ method: 'qr', mode: this.mode(), session }); }, error: cause => this.fail('qr', cause) }); } async prepareQr(url, attemptId) { this.externalUrl.set(url); this.qrImage.set(await QRCode.toDataURL(url, { width: 320, margin: 1 })); this.pollForQr(attemptId); } pollForYandex(attemptId) { this.poll?.unsubscribe(); this.poll = timer(0, this.config.pollIntervalMs ?? 1500).pipe(switchMap(() => this.gateway.checkYandex(this.mode(), attemptId))) .subscribe({ next: result => { if (result) this.finish(result); }, error: cause => this.fail('yandex', cause) }); } begin() { this.poll?.unsubscribe(); this.error.set(null); this.busy.set(true); } finish(result) { this.poll?.unsubscribe(); this.busy.set(false); this.authenticated.emit(result); } fail(method, cause) { this.poll?.unsubscribe(); this.busy.set(false); const failure = this.isFailure(cause) ? cause : { method, code: 'backend', message: 'Не удалось выполнить вход', cause }; this.error.set(failure); this.authError.emit(failure); } isFailure(value) { return !!value && typeof value === 'object' && 'code' in value && 'message' in value; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: MarketplacesAuthComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: MarketplacesAuthComponent, isStandalone: true, selector: "mp-auth, marketplaces-auth", inputs: { qr: { classPropertyName: "qr", publicName: "qr", isSignal: true, isRequired: false, transformFunction: null }, credentials: { classPropertyName: "credentials", publicName: "credentials", isSignal: true, isRequired: false, transformFunction: null }, yandex: { classPropertyName: "yandex", publicName: "yandex", isSignal: true, isRequired: false, transformFunction: null }, mode: { classPropertyName: "mode", publicName: "mode", isSignal: true, isRequired: false, transformFunction: null }, title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { authenticated: "authenticated", authError: "authError", cancelled: "cancelled" }, ngImport: i0, template: `

{{ title() }}

@if (qr()) { } @if (credentials()) { } @if (yandex()) { }
@if (method() === 'credentials') {
} @if (method() === 'qr') { @if (qrImage()) { QR-код для входа } } @if (method() === 'yandex') { } @if (busy()) {

Ожидаем подтверждение…

} @if (error()) { }
`, isInline: true, styles: [":host{display:block}.mp-auth{font:inherit;color:inherit;display:grid;gap:1rem;max-width:25rem}h2,p{margin:0}.methods{display:flex;gap:.5rem;flex-wrap:wrap}.methods button{background:transparent;color:inherit}button,input{font:inherit;border:1px solid #c7c7c7;border-radius:.65rem;padding:.7rem .9rem}button{cursor:pointer}.active,button[type=submit]{background:#111;color:#fff;border-color:#111}button:disabled{opacity:.55;cursor:wait}form{display:grid;gap:.8rem}label{display:grid;gap:.35rem}img{display:block;width:min(15rem,100%);height:auto;border-radius:.75rem}.error{color:#b42318}\n"], dependencies: [{ kind: "directive", type: FormField, selector: "[formField]", inputs: ["formField"], exportAs: ["formField"] }] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: MarketplacesAuthComponent, decorators: [{ type: Component, args: [{ selector: 'mp-auth, marketplaces-auth', standalone: true, imports: [FormField], template: `

{{ title() }}

@if (qr()) { } @if (credentials()) { } @if (yandex()) { }
@if (method() === 'credentials') {
} @if (method() === 'qr') { @if (qrImage()) { QR-код для входа } } @if (method() === 'yandex') { } @if (busy()) {

Ожидаем подтверждение…

} @if (error()) { }
`, styles: [":host{display:block}.mp-auth{font:inherit;color:inherit;display:grid;gap:1rem;max-width:25rem}h2,p{margin:0}.methods{display:flex;gap:.5rem;flex-wrap:wrap}.methods button{background:transparent;color:inherit}button,input{font:inherit;border:1px solid #c7c7c7;border-radius:.65rem;padding:.7rem .9rem}button{cursor:pointer}.active,button[type=submit]{background:#111;color:#fff;border-color:#111}button:disabled{opacity:.55;cursor:wait}form{display:grid;gap:.8rem}label{display:grid;gap:.35rem}img{display:block;width:min(15rem,100%);height:auto;border-radius:.75rem}.error{color:#b42318}\n"] }] }], ctorParameters: () => [], propDecorators: { qr: [{ type: i0.Input, args: [{ isSignal: true, alias: "qr", required: false }] }], credentials: [{ type: i0.Input, args: [{ isSignal: true, alias: "credentials", required: false }] }], yandex: [{ type: i0.Input, args: [{ isSignal: true, alias: "yandex", required: false }] }], mode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: false }] }], title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], authenticated: [{ type: i0.Output, args: ["authenticated"] }], authError: [{ type: i0.Output, args: ["authError"] }], cancelled: [{ type: i0.Output, args: ["cancelled"] }] } }); /** Guards `/admin/**`-style routes. Never shares state with the customer auth guard/service. */ const adminAuthGuard = () => { const adminAuth = inject(AdminAuthService); if (adminAuth.isAuthenticated()) { return true; } adminAuth.requestLogin(); return false; }; /** Backend paths that require an active AdminWebSessionID. Adjust to match your API surface if consuming this outside marketplaces. */ const ADMIN_GATED_PATH_SEGMENTS = ['/admin/', '/backoffice/', '/builder/', '/media/']; /** * Attaches admin session/token headers only to admin API requests. Scoped to * admin-gated paths so it never touches customer requests and never reads * the customer AuthService's session. */ const adminAuthHeadersInterceptor = (req, next) => { const isAdminRequest = ADMIN_GATED_PATH_SEGMENTS.some(segment => req.url.includes(segment)); if (!isAdminRequest) { return next(req); } const adminAuth = inject(AdminAuthService); const session = adminAuth.session(); const token = adminAuth.getAdminToken(); let headers = req.headers; if (session?.sessionId) { headers = headers.set('AdminWebSessionID', session.sessionId); } if (token) { headers = headers.set('Authorization', `Bearer ${token}`); } return next(req.clone({ headers })); }; /** Maps a backend error envelope's `error.code` to the client's AuthErrorCode screens. Only codes with a dedicated screen are mapped; anything else falls back to the HTTP-status-derived code via authErrorCodeFromStatus. */ const BACKEND_ERROR_CODE_MAP = { TOKEN_EXPIRED: 'session-expired', INVALID_SIGNATURE: 'invalid-signature', UNAUTHENTICATED: 'unauthorized', FORBIDDEN: 'forbidden', SERVICE_UNAVAILABLE: 'backend-unavailable', }; function authErrorCodeFromBackendCode(code) { return typeof code === 'string' ? BACKEND_ERROR_CODE_MAP[code] : undefined; } /** Maps a backend HTTP status to the AuthErrorCode screen it should route to. */ function authErrorCodeFromStatus(status) { switch (status) { case 401: return 'unauthorized'; case 403: return 'forbidden'; case 0: return 'backend-unavailable'; default: return status >= 500 ? 'backend-unavailable' : 'unauthorized'; } } /** * Thin HTTP client for the Ed25519 admin auth endpoints. These endpoints may * not exist on every backend yet - calling them before the backend ships * 404s or connection-errors, which AuthService maps to the * `backend-unavailable` error screen. No mock/fake responses are fabricated * here; this is real HttpClient wiring against the real contract. */ class AuthApiService { constructor() { this.http = inject(HttpClient); this.baseUrl = `${inject(AUTH_API_URL)}/api/admin/auth`; } requestChallenge() { return this.http.get(`${this.baseUrl}/challenge`); } verifySignature(request) { return this.http.post(`${this.baseUrl}/verify`, request); } refresh(request) { return this.http.post(`${this.baseUrl}/refresh`, request); } logout(refreshToken) { return this.http.post(`${this.baseUrl}/logout`, { refreshToken }); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthApiService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthApiService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthApiService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); /** * Manages the browser-local Ed25519 keypair used to sign admin auth * challenges. Real WebCrypto Ed25519 (RFC 8032 support landed in evergreen * browsers) - not a placeholder. The private key is generated * non-extractable and kept only in IndexedDB as a CryptoKey handle; it is * never serialized, never sent anywhere, and cannot be exported by design. * * Registering `publicKey` with an admin's account (associating it with a * role) is a backend-side, out-of-band operation (e.g. an Owner approving a * new admin's public key) - entirely outside this frontend's scope. */ const DB_NAME = 'admin-auth-ed25519'; const DB_VERSION = 1; const STORE_NAME = 'keypair'; const KEY_RECORD_ID = 'device-keypair'; class Ed25519KeypairService { constructor() { this.cached = null; } isSupported() { return typeof crypto !== 'undefined' && !!crypto.subtle && typeof indexedDB !== 'undefined'; } /** Returns the device's Ed25519 keypair, generating and persisting one on first use. */ async getOrCreateKeyPair() { if (!this.isSupported()) { throw new Error('Ed25519 is not supported in this browser (requires WebCrypto + IndexedDB).'); } const existing = await this.loadFromStore(); if (existing) { this.cached = existing; return { publicKeyBase64: existing.publicKeyBase64 }; } const generated = await this.generateAndPersist(); this.cached = generated; return { publicKeyBase64: generated.publicKeyBase64 }; } async sign(message) { const keyPair = this.cached ?? (await this.loadFromStore()); if (!keyPair) { throw new Error('No Ed25519 keypair available - call getOrCreateKeyPair() first.'); } const signatureBuffer = await crypto.subtle.sign('Ed25519', keyPair.privateKey, new TextEncoder().encode(message)); return this.toBase64(new Uint8Array(signatureBuffer)); } /** Discards the local keypair (e.g. "forget this device"). A new keypair on next login requires re-registration with the backend. */ async clear() { this.cached = null; const db = await this.openDatabase(); await new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readwrite'); tx.objectStore(STORE_NAME).delete(KEY_RECORD_ID); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } async generateAndPersist() { const keyPair = (await crypto.subtle.generateKey({ name: 'Ed25519' }, false, ['sign', 'verify'])); const publicKeyRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey); const publicKeyBase64 = this.toBase64(new Uint8Array(publicKeyRaw)); const record = { id: KEY_RECORD_ID, publicKey: keyPair.publicKey, privateKey: keyPair.privateKey, publicKeyBase64 }; const db = await this.openDatabase(); await new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readwrite'); tx.objectStore(STORE_NAME).put(record); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); return record; } async loadFromStore() { const db = await this.openDatabase(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readonly'); const request = tx.objectStore(STORE_NAME).get(KEY_RECORD_ID); request.onsuccess = () => resolve(request.result ?? null); request.onerror = () => reject(request.error); }); } openDatabase() { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { if (!request.result.objectStoreNames.contains(STORE_NAME)) { request.result.createObjectStore(STORE_NAME, { keyPath: 'id' }); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } toBase64(bytes) { let binary = ''; for (const byte of bytes) { binary += String.fromCharCode(byte); } return btoa(binary); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: Ed25519KeypairService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: Ed25519KeypairService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: Ed25519KeypairService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); /** * Client-side JWT *decoding* only - never verification. The signature is * meaningless to check here because the frontend has no trusted key to check * it against; verifying a JWT's signature is the backend's job on every * request. This service exists purely so the UI can read `role`/`exp` for * display and route-gating UX (e.g. "session expires in 4m"). */ class JwtService { decode(token) { const parts = token.split('.'); if (parts.length !== 3) { return null; } try { const payload = this.base64UrlDecode(parts[1]); const claims = JSON.parse(payload); return this.isJwtClaims(claims) ? claims : null; } catch { return null; } } isExpired(claims, skewSeconds = 0) { return claims.exp * 1000 <= Date.now() + skewSeconds * 1000; } isJwtClaims(value) { if (!value || typeof value !== 'object') { return false; } const claims = value; return typeof claims.sub === 'string' && typeof claims.role === 'string' && typeof claims.exp === 'number'; } base64UrlDecode(input) { const base64 = input.replace(/-/g, '+').replace(/_/g, '/').padEnd(input.length + ((4 - (input.length % 4)) % 4), '='); return decodeURIComponent(escape(atob(base64))); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: JwtService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: JwtService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: JwtService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); const TOKEN_STORAGE_KEY = 'ed25519AdminToken'; const REFRESH_STORAGE_KEY = 'ed25519AdminRefreshToken'; /** Refresh this long before actual expiry, so a request never races an expiring token. */ const REFRESH_SKEW_MS = 60_000; /** * Holds the Ed25519-flow JWT/refresh-token pair and derived claims. Separate * from the telegram module's AdminAuthService (Telegram-session state) by * design - the two auth mechanisms are not merged until both ship on the * same backend and a migration decision is made. */ class SessionService { constructor() { this.jwt = new JwtService(); this.tokenSignal = signal(null, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "tokenSignal" }] : /* istanbul ignore next */ [])); this.refreshTokenSignal = signal(null, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "refreshTokenSignal" }] : /* istanbul ignore next */ [])); this.claimsSignal = signal(null, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "claimsSignal" }] : /* istanbul ignore next */ [])); this.statusSignal = signal('unknown', /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "statusSignal" }] : /* istanbul ignore next */ [])); this.token = this.tokenSignal.asReadonly(); this.claims = this.claimsSignal.asReadonly(); this.status = this.statusSignal.asReadonly(); this.isAuthenticated = computed(() => this.statusSignal() === 'authenticated', /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "isAuthenticated" }] : /* istanbul ignore next */ [])); this.role = computed(() => this.claimsSignal()?.role ?? null, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "role" }] : /* istanbul ignore next */ [])); } /** Called once by AuthService on init to wire up the refresh trigger without a circular DI dependency. */ onRefreshDue(callback) { this.refreshCallback = callback; } /** Restores session state from persisted storage. Returns true if a (possibly expired) session was found. */ restore() { this.statusSignal.set('restoring'); const token = this.readStorage(TOKEN_STORAGE_KEY); const refreshToken = this.readStorage(REFRESH_STORAGE_KEY); if (!token || !refreshToken) { this.statusSignal.set('unauthenticated'); return false; } const claims = this.jwt.decode(token); if (!claims) { this.clear(); return false; } this.tokenSignal.set(token); this.refreshTokenSignal.set(refreshToken); this.claimsSignal.set(claims); if (this.jwt.isExpired(claims)) { this.statusSignal.set('expired'); } else { this.statusSignal.set('authenticated'); this.scheduleRefresh(claims); } return true; } activate(tokens) { const claims = this.jwt.decode(tokens.token); if (!claims) { throw new Error('Received a malformed JWT from the auth backend.'); } this.tokenSignal.set(tokens.token); this.refreshTokenSignal.set(tokens.refreshToken); this.claimsSignal.set(claims); this.statusSignal.set('authenticated'); this.writeStorage(TOKEN_STORAGE_KEY, tokens.token); this.writeStorage(REFRESH_STORAGE_KEY, tokens.refreshToken); this.scheduleRefresh(claims); } getRefreshToken() { return this.refreshTokenSignal(); } markExpired() { this.statusSignal.set('expired'); this.clearRefreshTimer(); } clear() { this.tokenSignal.set(null); this.refreshTokenSignal.set(null); this.claimsSignal.set(null); this.statusSignal.set('unauthenticated'); this.removeStorage(TOKEN_STORAGE_KEY); this.removeStorage(REFRESH_STORAGE_KEY); this.clearRefreshTimer(); } scheduleRefresh(claims) { this.clearRefreshTimer(); const expiresInMs = claims.exp * 1000 - Date.now(); const refreshInMs = Math.max(expiresInMs - REFRESH_SKEW_MS, 5_000); this.refreshTimer = setTimeout(() => this.refreshCallback?.(), refreshInMs); } clearRefreshTimer() { if (this.refreshTimer) { clearTimeout(this.refreshTimer); this.refreshTimer = undefined; } } readStorage(key) { return typeof localStorage === 'undefined' ? null : localStorage.getItem(key); } writeStorage(key, value) { if (typeof localStorage !== 'undefined') { localStorage.setItem(key, value); } } removeStorage(key) { if (typeof localStorage !== 'undefined') { localStorage.removeItem(key); } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: SessionService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: SessionService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: SessionService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); /** * Orchestrates the Ed25519 challenge/response admin auth flow end to end: * * GET /api/admin/auth/challenge -> { nonce } * sign(nonce) with local Ed25519 key -> signature * POST /api/admin/auth/verify -> { token, refreshToken } * * This is the lowest-level orchestrator; components should go through * AuthFacade rather than calling this directly. Exported from the package * barrel as `Ed25519AuthService` to avoid colliding with the telegram * module's `AuthService`. */ class AuthService { constructor() { this.api = inject(AuthApiService); this.keypair = inject(Ed25519KeypairService); this.session = inject(SessionService); this.loginPhaseSignal = signal('idle', /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "loginPhaseSignal" }] : /* istanbul ignore next */ [])); this.lastErrorSignal = signal(null, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "lastErrorSignal" }] : /* istanbul ignore next */ [])); this.loginPhase = this.loginPhaseSignal.asReadonly(); this.lastError = this.lastErrorSignal.asReadonly(); this.session.onRefreshDue(() => this.refresh().subscribe()); } /** Restores a persisted session on app bootstrap. Call once from an APP_INITIALIZER or root component. */ restoreSession() { this.session.restore(); } login() { this.lastErrorSignal.set(null); this.loginPhaseSignal.set('requesting-challenge'); return this.api.requestChallenge().pipe(switchMap(challenge => this.signChallenge(challenge.nonce).pipe(switchMap(({ publicKeyBase64, signature }) => { this.loginPhaseSignal.set('verifying'); return this.api.verifySignature({ publicKey: publicKeyBase64, signature, nonce: challenge.nonce }); }))), tap(tokens => { this.session.activate(tokens); this.loginPhaseSignal.set('done'); }), catchError(error => this.handleAuthError(error, 'invalid-signature'))); } refresh() { const refreshToken = this.session.getRefreshToken(); if (!refreshToken) { this.session.markExpired(); return throwError(() => this.toAuthError({ code: 'session-expired', message: 'No refresh token available.' })); } return this.api.refresh({ refreshToken }).pipe(tap(tokens => this.session.activate(tokens)), catchError(error => this.handleAuthError(error, 'session-expired', () => this.session.markExpired()))); } logout() { const refreshToken = this.session.getRefreshToken(); this.session.clear(); if (!refreshToken) { return new Observable(subscriber => { subscriber.next(); subscriber.complete(); }); } return this.api.logout(refreshToken).pipe(catchError(() => throwError(() => null))); } signChallenge(nonce) { this.loginPhaseSignal.set('signing'); return new Observable(subscriber => { this.keypair .getOrCreateKeyPair() .then(({ publicKeyBase64 }) => this.keypair.sign(nonce).then(signature => { subscriber.next({ publicKeyBase64, signature }); subscriber.complete(); })) .catch(error => subscriber.error(error)); }); } handleAuthError(error, fallbackCode, onError) { onError?.(); return throwError(() => this.toAuthError(this.toAuthErrorShape(error, fallbackCode))); } toAuthErrorShape(error, fallbackCode) { if (error instanceof HttpErrorResponse) { const bodyCode = error.error?.error?.code; const code = authErrorCodeFromBackendCode(bodyCode) ?? authErrorCodeFromStatus(error.status); return { code, message: error.message, status: error.status }; } if (error instanceof Error) { return { code: fallbackCode, message: error.message }; } return { code: fallbackCode, message: 'Unknown authentication error.' }; } toAuthError(error) { this.lastErrorSignal.set(error); return error; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [] }); const ROLE_PERMISSIONS = { Owner: ['backoffice.read', 'backoffice.write', 'builder.read', 'builder.write', 'users.manage', 'settings.manage'], Administrator: ['backoffice.read', 'backoffice.write', 'builder.read', 'builder.write', 'users.manage'], Editor: ['backoffice.read', 'backoffice.write', 'builder.read', 'builder.write'], Support: ['backoffice.read'], ReadOnly: ['backoffice.read', 'builder.read'] }; /** * Derives the current admin's permission set from their JWT `role` claim. * UI-only gate (hide/disable) - the backend must independently enforce * every mutation server-side. */ class PermissionService { constructor() { this.session = inject(SessionService); this.permissions = computed(() => { const role = this.session.role(); return role ? ROLE_PERMISSIONS[role] : []; }, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "permissions" }] : /* istanbul ignore next */ [])); } has(permission) { return this.permissions().includes(permission); } hasAny(permissions) { return permissions.some(permission => this.has(permission)); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: PermissionService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: PermissionService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: PermissionService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); /** * Public surface for components/pages. Components should depend on this, * not on AuthService/SessionService/PermissionService directly, so the * orchestration details (which service owns what) can change without * touching UI code. */ class AuthFacade { constructor() { this.auth = inject(AuthService); this.session = inject(SessionService); this.permissions = inject(PermissionService); this.router = inject(Router); this.isAuthenticated = this.session.isAuthenticated; this.status = this.session.status; this.role = this.session.role; this.loginPhase = this.auth.loginPhase; this.lastError = this.auth.lastError; } restoreSession() { this.auth.restoreSession(); } login(onSuccessRedirectTo) { this.auth.login().subscribe({ next: () => { if (onSuccessRedirectTo) { this.router.navigateByUrl(onSuccessRedirectTo); } }, error: () => { const code = this.auth.lastError()?.code ?? 'unauthorized'; this.router.navigate(['/admin-login/error', code]); } }); } logout(redirectTo = '/admin-login') { this.auth .logout() .pipe(finalize(() => this.router.navigateByUrl(redirectTo))) .subscribe({ error: () => undefined }); } can(permission) { return this.permissions.has(permission); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthFacade, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthFacade, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthFacade, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); class Ed25519VerificationService { } /** * Default DI binding for Ed25519VerificationService until the backend ships * the real challenge/verify endpoints. Intentionally fails closed (throws) * rather than pretending to verify anything, so accidental use in a login * path is loud instead of silently accepting unsigned sessions. */ class NoopEd25519VerificationService { requestChallenge() { return throwError(() => new Error('Ed25519 challenge endpoint is not yet available from the backend.')); } verify(_response) { return throwError(() => new Error('Ed25519 verification endpoint is not yet available from the backend.')); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: NoopEd25519VerificationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: NoopEd25519VerificationService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: NoopEd25519VerificationService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); // @marketplaces/auth — public API barrel. // Two independent auth mechanisms, per ADR-0001 (marketplaces repo: // docs/context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md): // - telegram/ — live Telegram QR/session auth (customer + admin) // - ed25519/ — future Ed25519 challenge/response admin auth (backend not shipped yet) // Provide AUTH_API_URL (and optionally TELEGRAM_BOT_USERNAME) from the consuming app's config. /** * Generated bundle index. Do not edit. */ export { AUTH_API_URL, AdminAuthService, AuthApiService, AuthFacade, AuthMarketplaceContext, AuthService$1 as AuthService, AuthService as Ed25519AuthService, Ed25519KeypairService, Ed25519VerificationService, HttpMarketplacesAuthGateway, JwtService, MARKETPLACES_AUTH_CONFIG, MARKETPLACES_AUTH_GATEWAY, MARKETPLACE_DOMAIN_HEADER, MarketplacesAuthComponent, NoopEd25519VerificationService, PermissionService, ROLE_PERMISSIONS, SessionService, TELEGRAM_BOT_USERNAME, TelegramSessionApiService, adminAuthGuard, adminAuthHeadersInterceptor, authErrorCodeFromBackendCode, authErrorCodeFromStatus, normalizeMarketplaceDomain, provideMarketplacesAuth }; //# sourceMappingURL=marketplaces-auth.mjs.map