feat(auth): add AdminAuthService.loginWithCredentials + rate_limited AuthFailure code

Convenience wrapper around MarketplacesAuthGateway.loginWithCredentials('admin', ...)
that activates the returned session in one call, for apps building a custom admin
login screen. AuthFailure gains a rate_limited code (HTTP 429, parsed Retry-After)
and an optional status field so 401/403/429 surface distinctly without new plumbing.

No credentials, no client-side comparison, no superadmin concept anywhere in this
package - same code path as any other admin credential login. Inert without a
backend implementing POST {credentialsPath} { login, password, mode: 'admin' }.

Additive, backward-compatible. QR/Yandex flows unmodified.
This commit is contained in:
2026-08-23 21:33:52 +04:00
parent f6a58a9a3e
commit 3bc2a42488
7 changed files with 197 additions and 4 deletions

View File

@@ -1,7 +1,9 @@
import { Injectable, signal, computed, inject, isDevMode } from '@angular/core';
import { Injectable, Injector, signal, computed, inject, isDevMode } from '@angular/core';
import { Observable, tap } from 'rxjs';
import { AdminAuthStatus, AuthSession, WebSessionStart } from './models/session.model';
import { TelegramSessionApiService } from './telegram-session-api.service';
import type { AuthResult, CredentialLogin } from '../ui/auth.models';
import { MARKETPLACES_AUTH_GATEWAY } from '../ui/auth.gateway';
/**
* Admin login uses the exact same Telegram QR/session API as the customer
@@ -25,6 +27,11 @@ const ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60;
@Injectable({ providedIn: 'root' })
export class AdminAuthService {
private readonly api = inject(TelegramSessionApiService);
// Resolved lazily (not injected eagerly) because MARKETPLACES_AUTH_GATEWAY's
// default factory constructs HttpMarketplacesAuthGateway, which itself
// injects AdminAuthService - an eager inject() here would be a circular
// dependency. By call time both singletons already exist.
private readonly injector = inject(Injector);
private readonly sessionSignal = signal<AuthSession | null>(null);
private readonly statusSignal = signal<AdminAuthStatus>('unknown');
@@ -130,6 +137,21 @@ export class AdminAuthService {
if (token && refreshToken) this.setAdminTokens(token, refreshToken);
}
/**
* Log an admin in with a login/password pair and activate the resulting
* session in one call. The package has no notion of who this account is -
* it carries {login, password} to the backend exactly like any other admin
* credential login and accepts whatever session comes back. A consuming
* app can build a fully custom login screen around this single call.
*/
loginWithCredentials(credentials: CredentialLogin): Observable<AuthResult> {
// Gateway.loginWithCredentials already calls acceptSession() on success
// for mode 'admin' (see HttpMarketplacesAuthGateway.accept()) - this
// wrapper only exists so a consuming app doesn't need to wire the
// gateway token itself.
return this.injector.get(MARKETPLACES_AUTH_GATEWAY).loginWithCredentials('admin', credentials);
}
/** JWT pair storage, reserved for once the backend issues admin access/refresh tokens. Unused until then. */
getAdminToken(): string | null {
return typeof localStorage === 'undefined' ? null : localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY);

View File

@@ -69,11 +69,21 @@ export class HttpMarketplacesAuthGateway implements MarketplacesAuthGateway {
private url(path = ''): string { return `${this.config.apiUrl}${path.startsWith('/') ? path : `/${path}`}`; }
private failure(method: 'credentials' | 'yandex', cause: unknown): AuthFailure {
const response = cause instanceof HttpErrorResponse ? cause : null;
const status = response?.status;
const code: AuthFailure['code'] =
status === 401 ? 'invalid_credentials' : status === 429 ? 'rate_limited' : 'backend';
return {
method,
code: response?.status === 401 ? 'invalid_credentials' : 'backend',
code,
message: response?.error?.message || response?.message || 'Authentication failed',
status,
retryAfterSeconds: code === 'rate_limited' ? this.parseRetryAfter(response) : undefined,
cause,
};
}
private parseRetryAfter(response: HttpErrorResponse | null): number | undefined {
const header = response?.headers?.get('Retry-After');
const seconds = header ? Number(header) : NaN;
return Number.isFinite(seconds) ? seconds : undefined;
}
}

View File

@@ -13,7 +13,11 @@ export interface AuthResult {
export interface ExternalAuthStart { attemptId: string; authorizationUrl: string; }
export interface AuthFailure {
method: AuthMethod;
code: 'configuration' | 'invalid_credentials' | 'backend' | 'popup_blocked' | 'expired';
code: 'configuration' | 'invalid_credentials' | 'rate_limited' | 'backend' | 'popup_blocked' | 'expired';
message: string;
/** HTTP status of the failed request, when the failure came from an HTTP response. */
status?: number;
/** Parsed `Retry-After` header (seconds), present when code is 'rate_limited'. */
retryAfterSeconds?: number;
cause?: unknown;
}