feat(packages): add standalone auth and payment
This commit is contained in:
@@ -1,7 +1,39 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
import { EnvironmentProviders, InjectionToken, makeEnvironmentProviders } from '@angular/core';
|
||||
|
||||
export interface MarketplacesAuthConfig {
|
||||
/** Central auth service URL. It is not the tenant API URL. */
|
||||
apiUrl: string;
|
||||
/** Override only for SSR/custom-domain integrations. Browser default is location.hostname. */
|
||||
marketplaceDomain?: string | (() => string);
|
||||
telegramBotUsername?: string;
|
||||
credentialsPath?: string;
|
||||
yandexStartPath?: string;
|
||||
yandexSessionPath?: string;
|
||||
pollIntervalMs?: number;
|
||||
}
|
||||
|
||||
/** Base URL for the auth backend, e.g. `https://api.example.com`. Provide from the consuming app's environment config. */
|
||||
export const AUTH_API_URL = new InjectionToken<string>('@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. */
|
||||
export const TELEGRAM_BOT_USERNAME = new InjectionToken<string>('@marketplaces/auth TELEGRAM_BOT_USERNAME');
|
||||
|
||||
export const MARKETPLACES_AUTH_CONFIG = new InjectionToken<MarketplacesAuthConfig>('@marketplaces/auth config');
|
||||
|
||||
export function provideMarketplacesAuth(config: MarketplacesAuthConfig): EnvironmentProviders {
|
||||
const normalized: MarketplacesAuthConfig = {
|
||||
...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 }]
|
||||
: []),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
// - 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.
|
||||
|
||||
export { AUTH_API_URL, TELEGRAM_BOT_USERNAME } from './config';
|
||||
export { AUTH_API_URL, TELEGRAM_BOT_USERNAME, MARKETPLACES_AUTH_CONFIG, provideMarketplacesAuth } from './config';
|
||||
export type { MarketplacesAuthConfig } from './config';
|
||||
export { MARKETPLACE_DOMAIN_HEADER, AuthMarketplaceContext, normalizeMarketplaceDomain } from './marketplace-context';
|
||||
export { MarketplacesAuthComponent } from './ui/auth.component';
|
||||
export { MARKETPLACES_AUTH_GATEWAY, HttpMarketplacesAuthGateway } from './ui/auth.gateway';
|
||||
export type { MarketplacesAuthGateway } from './ui/auth.gateway';
|
||||
export type { AuthMode, AuthMethod, CredentialLogin, AuthResult, ExternalAuthStart, AuthFailure } from './ui/auth.models';
|
||||
|
||||
// Telegram module
|
||||
export { AuthSession, WebSessionStart, AuthStatus, AdminAuthStatus } from './telegram/models/session.model';
|
||||
|
||||
28
packages/auth/src/marketplace-context.ts
Normal file
28
packages/auth/src/marketplace-context.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { HttpHeaders } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { MARKETPLACES_AUTH_CONFIG } from './config';
|
||||
|
||||
export const MARKETPLACE_DOMAIN_HEADER = 'X-Marketplace-Domain';
|
||||
|
||||
export function normalizeMarketplaceDomain(domain: string): string {
|
||||
return domain.trim().toLowerCase().replace(/\.$/, '');
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthMarketplaceContext {
|
||||
private readonly config = inject(MARKETPLACES_AUTH_CONFIG);
|
||||
|
||||
domain(): string {
|
||||
const configured = this.config.marketplaceDomain;
|
||||
const domain = typeof configured === 'function'
|
||||
? configured()
|
||||
: configured ?? (typeof location === 'undefined' ? '' : location.hostname);
|
||||
return normalizeMarketplaceDomain(domain);
|
||||
}
|
||||
|
||||
headers(extra?: Record<string, string>): HttpHeaders {
|
||||
const domain = this.domain();
|
||||
if (!domain) throw new Error('Marketplace domain cannot be resolved');
|
||||
return new HttpHeaders({ [MARKETPLACE_DOMAIN_HEADER]: domain, ...extra });
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,12 @@ export class AdminAuthService {
|
||||
this.api.logout(webSessionID).subscribe(() => this.clearAuthState('unauthenticated'));
|
||||
}
|
||||
|
||||
/** Accept a session/tokens returned by credentials or an external provider. */
|
||||
acceptSession(session: AuthSession, token?: string, refreshToken?: string): void {
|
||||
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(): string | null {
|
||||
return typeof localStorage === 'undefined' ? null : localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY);
|
||||
|
||||
@@ -114,6 +114,11 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Accept a session returned by credentials or an external provider. */
|
||||
acceptSession(session: AuthSession): void {
|
||||
this.activateSession(session);
|
||||
}
|
||||
|
||||
private activateSession(session: AuthSession): void {
|
||||
this.sessionSignal.set(session);
|
||||
this.statusSignal.set('authenticated');
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Observable, of, catchError, map } from 'rxjs';
|
||||
import { AuthSession, WebSessionStart } from './models/session.model';
|
||||
import { AUTH_API_URL, TELEGRAM_BOT_USERNAME } from '../config';
|
||||
import { generateGuid } from '../util/guid.util';
|
||||
import { AuthMarketplaceContext } from '../marketplace-context';
|
||||
|
||||
const SESSION_MAX_AGE_SECONDS = 60 * 60;
|
||||
const DEFAULT_TELEGRAM_BOT_USERNAME = 'DexarSupport_bot';
|
||||
@@ -21,6 +22,7 @@ export class TelegramSessionApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly authApiUrl = inject(AUTH_API_URL);
|
||||
private readonly telegramBotUsername = inject(TELEGRAM_BOT_USERNAME, { optional: true });
|
||||
private readonly marketplaceContext = inject(AuthMarketplaceContext);
|
||||
|
||||
createSession(): Observable<WebSessionStart> {
|
||||
const webSessionID = generateGuid();
|
||||
@@ -28,7 +30,7 @@ export class TelegramSessionApiService {
|
||||
return this.http.post<Record<string, unknown>>(
|
||||
`${this.authApiUrl}/users/sessions`,
|
||||
{ webSessionID },
|
||||
{ headers: { WebSessionID: webSessionID } }
|
||||
{ headers: this.marketplaceContext.headers({ WebSessionID: webSessionID }) }
|
||||
).pipe(
|
||||
map(response => {
|
||||
const responseWebSessionID = this.extractSessionId(response, webSessionID);
|
||||
@@ -46,7 +48,8 @@ export class TelegramSessionApiService {
|
||||
}
|
||||
|
||||
return this.http.get<Record<string, unknown>>(
|
||||
`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`
|
||||
`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`,
|
||||
{ headers: this.marketplaceContext.headers() }
|
||||
).pipe(
|
||||
map(response => this.normalizeWebSession(response, webSessionID)),
|
||||
catchError(() => of(null))
|
||||
@@ -55,7 +58,7 @@ export class TelegramSessionApiService {
|
||||
|
||||
logout(webSessionID: string): Observable<unknown> {
|
||||
return this.http.delete(`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`, {
|
||||
headers: { WebSessionID: webSessionID }
|
||||
headers: this.marketplaceContext.headers({ WebSessionID: webSessionID })
|
||||
}).pipe(catchError(() => of(null)));
|
||||
}
|
||||
|
||||
|
||||
124
packages/auth/src/ui/auth.component.ts
Normal file
124
packages/auth/src/ui/auth.component.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Component, DestroyRef, booleanAttribute, inject, input, output, signal } from '@angular/core';
|
||||
import { FormField, form, required } from '@angular/forms/signals';
|
||||
import * as QRCode from 'qrcode';
|
||||
import { Subscription, switchMap, timer } from 'rxjs';
|
||||
import { MARKETPLACES_AUTH_CONFIG } from '../config';
|
||||
import { MARKETPLACES_AUTH_GATEWAY } from './auth.gateway';
|
||||
import { AuthFailure, AuthMethod, AuthMode, AuthResult } from './auth.models';
|
||||
|
||||
@Component({
|
||||
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}
|
||||
`],
|
||||
})
|
||||
export class MarketplacesAuthComponent {
|
||||
readonly qr = input(false, { transform: booleanAttribute });
|
||||
readonly credentials = input(false, { transform: booleanAttribute });
|
||||
readonly yandex = input(false, { transform: booleanAttribute });
|
||||
readonly mode = input<AuthMode>('customer');
|
||||
readonly title = input('Вход');
|
||||
readonly authenticated = output<AuthResult>();
|
||||
readonly authError = output<AuthFailure>();
|
||||
readonly cancelled = output<void>();
|
||||
readonly method = signal<AuthMethod | null>(null);
|
||||
readonly busy = signal(false);
|
||||
readonly error = signal<AuthFailure | null>(null);
|
||||
readonly qrImage = signal<string | null>(null);
|
||||
readonly externalUrl = signal<string | null>(null);
|
||||
private readonly credentialsModel = signal({ login: '', password: '' });
|
||||
readonly credentialsForm = form(this.credentialsModel, path => {
|
||||
required(path.login, { message: 'Введите логин' });
|
||||
required(path.password, { message: 'Введите пароль' });
|
||||
});
|
||||
private readonly gateway = inject(MARKETPLACES_AUTH_GATEWAY);
|
||||
private readonly config = inject(MARKETPLACES_AUTH_CONFIG);
|
||||
private poll?: Subscription;
|
||||
|
||||
constructor() {
|
||||
inject(DestroyRef).onDestroy(() => this.poll?.unsubscribe());
|
||||
queueMicrotask(() => this.select(this.qr() ? 'qr' : this.credentials() ? 'credentials' : this.yandex() ? 'yandex' : null));
|
||||
}
|
||||
select(method: AuthMethod | null): void { this.poll?.unsubscribe(); this.busy.set(false); this.error.set(null); this.method.set(method); }
|
||||
startQr(): void {
|
||||
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): void {
|
||||
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(): void {
|
||||
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),
|
||||
});
|
||||
}
|
||||
private pollForQr(attemptId: string): void {
|
||||
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) });
|
||||
}
|
||||
private async prepareQr(url: string, attemptId: string): Promise<void> {
|
||||
this.externalUrl.set(url);
|
||||
this.qrImage.set(await QRCode.toDataURL(url, { width: 320, margin: 1 }));
|
||||
this.pollForQr(attemptId);
|
||||
}
|
||||
private pollForYandex(attemptId: string): void {
|
||||
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) });
|
||||
}
|
||||
private begin(): void { this.poll?.unsubscribe(); this.error.set(null); this.busy.set(true); }
|
||||
private finish(result: AuthResult): void { this.poll?.unsubscribe(); this.busy.set(false); this.authenticated.emit(result); }
|
||||
private fail(method: AuthMethod, cause: unknown): void {
|
||||
this.poll?.unsubscribe(); this.busy.set(false);
|
||||
const failure: AuthFailure = this.isFailure(cause) ? cause : { method, code: 'backend', message: 'Не удалось выполнить вход', cause };
|
||||
this.error.set(failure); this.authError.emit(failure);
|
||||
}
|
||||
private isFailure(value: unknown): value is AuthFailure { return !!value && typeof value === 'object' && 'code' in value && 'message' in value; }
|
||||
}
|
||||
79
packages/auth/src/ui/auth.gateway.ts
Normal file
79
packages/auth/src/ui/auth.gateway.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { Injectable, InjectionToken, inject } from '@angular/core';
|
||||
import { Observable, catchError, map, of, throwError } from 'rxjs';
|
||||
import { MARKETPLACES_AUTH_CONFIG } from '../config';
|
||||
import { AuthMarketplaceContext } from '../marketplace-context';
|
||||
import { AdminAuthService } from '../telegram/admin-auth.service';
|
||||
import { AuthService } from '../telegram/auth.service';
|
||||
import { AuthSession, WebSessionStart } from '../telegram/models/session.model';
|
||||
import { AuthFailure, AuthMode, AuthResult, CredentialLogin, ExternalAuthStart } from './auth.models';
|
||||
|
||||
export interface MarketplacesAuthGateway {
|
||||
startQr(mode: AuthMode): Observable<WebSessionStart>;
|
||||
checkQr(mode: AuthMode, attemptId: string): Observable<AuthSession | null>;
|
||||
loginWithCredentials(mode: AuthMode, credentials: CredentialLogin): Observable<AuthResult>;
|
||||
startYandex(mode: AuthMode, returnUrl: string): Observable<ExternalAuthStart>;
|
||||
checkYandex(mode: AuthMode, attemptId: string): Observable<AuthResult | null>;
|
||||
}
|
||||
|
||||
export const MARKETPLACES_AUTH_GATEWAY = new InjectionToken<MarketplacesAuthGateway>(
|
||||
'@marketplaces/auth gateway',
|
||||
{ providedIn: 'root', factory: () => inject(HttpMarketplacesAuthGateway) }
|
||||
);
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class HttpMarketplacesAuthGateway implements MarketplacesAuthGateway {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly config = inject(MARKETPLACES_AUTH_CONFIG);
|
||||
private readonly context = inject(AuthMarketplaceContext);
|
||||
private readonly customerAuth = inject(AuthService);
|
||||
private readonly adminAuth = inject(AdminAuthService);
|
||||
|
||||
startQr(mode: AuthMode): Observable<WebSessionStart> {
|
||||
return mode === 'admin' ? this.adminAuth.createWebSession() : this.customerAuth.createWebSession();
|
||||
}
|
||||
checkQr(mode: AuthMode, attemptId: string): Observable<AuthSession | null> {
|
||||
return mode === 'admin' ? this.adminAuth.checkSessionOnce(attemptId) : this.customerAuth.checkSessionOnce(attemptId);
|
||||
}
|
||||
loginWithCredentials(mode: AuthMode, credentials: CredentialLogin): Observable<AuthResult> {
|
||||
return this.http.post<AuthResult>(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: AuthMode, returnUrl: string): Observable<ExternalAuthStart> {
|
||||
return this.http.post<ExternalAuthStart>(this.url(this.config.yandexStartPath), {
|
||||
provider: 'yandex', mode, returnUrl,
|
||||
}, { headers: this.context.headers() }).pipe(
|
||||
catchError(error => throwError(() => this.failure('yandex', error)))
|
||||
);
|
||||
}
|
||||
checkYandex(mode: AuthMode, attemptId: string): Observable<AuthResult | null> {
|
||||
return this.http.get<AuthResult | null>(
|
||||
`${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: HttpErrorResponse) => error.status === 404 || error.status === 202
|
||||
? of(null)
|
||||
: throwError(() => this.failure('yandex', error)))
|
||||
);
|
||||
}
|
||||
private accept(mode: AuthMode, result: AuthResult): AuthResult {
|
||||
if (mode === 'admin') this.adminAuth.acceptSession(result.session, result.accessToken, result.refreshToken);
|
||||
else this.customerAuth.acceptSession(result.session);
|
||||
return result;
|
||||
}
|
||||
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;
|
||||
return {
|
||||
method,
|
||||
code: response?.status === 401 ? 'invalid_credentials' : 'backend',
|
||||
message: response?.error?.message || response?.message || 'Authentication failed',
|
||||
cause,
|
||||
};
|
||||
}
|
||||
}
|
||||
19
packages/auth/src/ui/auth.models.ts
Normal file
19
packages/auth/src/ui/auth.models.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { AuthSession } from '../telegram/models/session.model';
|
||||
|
||||
export type AuthMode = 'customer' | 'admin';
|
||||
export type AuthMethod = 'qr' | 'credentials' | 'yandex';
|
||||
export interface CredentialLogin { login: string; password: string; }
|
||||
export interface AuthResult {
|
||||
method: AuthMethod;
|
||||
mode: AuthMode;
|
||||
session: AuthSession;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
}
|
||||
export interface ExternalAuthStart { attemptId: string; authorizationUrl: string; }
|
||||
export interface AuthFailure {
|
||||
method: AuthMethod;
|
||||
code: 'configuration' | 'invalid_credentials' | 'backend' | 'popup_blocked' | 'expired';
|
||||
message: string;
|
||||
cause?: unknown;
|
||||
}
|
||||
Reference in New Issue
Block a user