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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user