1358 lines
67 KiB
JavaScript
1358 lines
67 KiB
JavaScript
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: `
|
||
<section class="mp-auth" aria-labelledby="mp-auth-title">
|
||
<h2 id="mp-auth-title">{{ title() }}</h2>
|
||
<div class="methods" role="tablist" aria-label="Способ входа">
|
||
@if (qr()) { <button type="button" [class.active]="method() === 'qr'" (click)="select('qr')">QR</button> }
|
||
@if (credentials()) { <button type="button" [class.active]="method() === 'credentials'" (click)="select('credentials')">Логин</button> }
|
||
@if (yandex()) { <button type="button" [class.active]="method() === 'yandex'" (click)="select('yandex')">Яндекс</button> }
|
||
</div>
|
||
@if (method() === 'credentials') {
|
||
<form (submit)="loginWithCredentials($event)">
|
||
<label>Логин<input autocomplete="username" [formField]="credentialsForm.login" /></label>
|
||
<label>Пароль<input type="password" autocomplete="current-password" [formField]="credentialsForm.password" /></label>
|
||
<button type="submit" [disabled]="busy() || credentialsForm().invalid()">Войти</button>
|
||
</form>
|
||
}
|
||
@if (method() === 'qr') {
|
||
@if (qrImage()) { <a [href]="externalUrl()!" target="_blank" rel="noopener"><img [src]="qrImage()!" alt="QR-код для входа" /></a> }
|
||
<button type="button" [disabled]="busy()" (click)="startQr()">{{ qrImage() ? 'Обновить QR' : 'Получить QR' }}</button>
|
||
}
|
||
@if (method() === 'yandex') { <button type="button" [disabled]="busy()" (click)="startYandex()">Войти через Яндекс</button> }
|
||
@if (busy()) { <p role="status">Ожидаем подтверждение…</p> }
|
||
@if (error()) { <p class="error" role="alert">{{ error()!.message }}</p> }
|
||
</section>
|
||
`, 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: `
|
||
<section class="mp-auth" aria-labelledby="mp-auth-title">
|
||
<h2 id="mp-auth-title">{{ title() }}</h2>
|
||
<div class="methods" role="tablist" aria-label="Способ входа">
|
||
@if (qr()) { <button type="button" [class.active]="method() === 'qr'" (click)="select('qr')">QR</button> }
|
||
@if (credentials()) { <button type="button" [class.active]="method() === 'credentials'" (click)="select('credentials')">Логин</button> }
|
||
@if (yandex()) { <button type="button" [class.active]="method() === 'yandex'" (click)="select('yandex')">Яндекс</button> }
|
||
</div>
|
||
@if (method() === 'credentials') {
|
||
<form (submit)="loginWithCredentials($event)">
|
||
<label>Логин<input autocomplete="username" [formField]="credentialsForm.login" /></label>
|
||
<label>Пароль<input type="password" autocomplete="current-password" [formField]="credentialsForm.password" /></label>
|
||
<button type="submit" [disabled]="busy() || credentialsForm().invalid()">Войти</button>
|
||
</form>
|
||
}
|
||
@if (method() === 'qr') {
|
||
@if (qrImage()) { <a [href]="externalUrl()!" target="_blank" rel="noopener"><img [src]="qrImage()!" alt="QR-код для входа" /></a> }
|
||
<button type="button" [disabled]="busy()" (click)="startQr()">{{ qrImage() ? 'Обновить QR' : 'Получить QR' }}</button>
|
||
}
|
||
@if (method() === 'yandex') { <button type="button" [disabled]="busy()" (click)="startYandex()">Войти через Яндекс</button> }
|
||
@if (busy()) { <p role="status">Ожидаем подтверждение…</p> }
|
||
@if (error()) { <p class="error" role="alert">{{ error()!.message }}</p> }
|
||
</section>
|
||
`, 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
|