Add Telegram auth flow to backoffice

This commit is contained in:
sdarbinyan
2026-06-21 01:41:58 +04:00
parent 09e8465577
commit fb570a32f5
18 changed files with 1353 additions and 12 deletions

61
BACKEND_AUTH_TODO.ru.md Normal file
View File

@@ -0,0 +1,61 @@
# Что нужно сделать на backend для Telegram-логина
BackOffice уже готов к текущему backend: сначала пробует новый `/userauth/*` контракт из `marketplaces/docs/telegram-login-dialog.html`, а если backend отвечает `404`, использует текущий marketplace-flow через `/users/sessions`.
Чтобы все работало строго по новому единому контракту, нужно сделать следующее.
## Auth-service
Добавить или включить endpoints:
- `POST /userauth/qr/create` - создать короткоживущий QR/login token и вернуть ссылку Telegram.
- `GET /userauth/qr/poll?token=...` - вернуть статус `pending`, `confirmed` или `expired`.
- `GET /userauth/session` - вернуть текущую активную сессию пользователя или `401/404`, если сессии нет.
- `POST /userauth/logout` - завершить текущую сессию.
Минимальный ответ для подтвержденного login:
```json
{
"status": "confirmed",
"session": {
"sessionId": "uuid",
"telegramUserId": "123456",
"username": "user",
"displayName": "User Name",
"active": true,
"expiresAt": "2026-06-21T12:00:00Z"
}
}
```
## Main API
Поддержать активацию backend API-сессии:
- `POST /usersession/{sessionId}` - принять подтвержденный `sessionId` из auth-service и открыть доступ к marketplace/backoffice API.
## CORS и credentials
Разрешить реальные frontend origins для BackOffice и Marketplace:
- production domain BackOffice;
- `https://dexarmarket.ru`, если marketplace и backoffice используют общий auth-flow;
- dev через proxy уже настроен на frontend стороне.
Нужно разрешить:
- `Access-Control-Allow-Credentials: true`;
- методы `GET`, `POST`, `DELETE`, `OPTIONS`;
- headers `Content-Type`, `WebSessionID`;
- cookies с `SameSite=None; Secure; HttpOnly`, если auth-service использует cookie-сессию.
## Совместимость
Пока `/userauth/*` не развернуты, нельзя выключать текущие marketplace endpoints:
- `POST /users/sessions`;
- `GET /users/sessions/{sessionId}`;
- `DELETE /users/sessions/{sessionId}`.
BackOffice сейчас использует их как fallback, чтобы логин уже работал на текущем backend.

View File

@@ -102,20 +102,33 @@ This will compile your project and store the build artifacts in the `dist/market
```typescript ```typescript
export const environment = { export const environment = {
production: false, production: false,
apiUrl: 'http://localhost:3000/api', // Local backend apiUrl: '/api',
useMockData: true // Use mock data for development authApiUrl: '',
userSessionApiUrl: '',
telegramBot: 'myAMLKYCBOT',
useMockData: false
}; };
``` ```
Development runs through `proxy.conf.json`, which forwards `/api` and `/usersession` to `https://api.dexarmarket.ru:445` and `/userauth` plus `/users/sessions` to `https://users.vitanova.network:456`.
### Production (`src/environments/environment.production.ts`) ### Production (`src/environments/environment.production.ts`)
```typescript ```typescript
export const environment = { export const environment = {
production: true, production: true,
apiUrl: 'https://api.dexarmarket.ru/api', // Production backend apiUrl: 'https://api.dexarmarket.ru:445',
useMockData: false // Use real API authApiUrl: 'https://users.vitanova.network:456',
userSessionApiUrl: 'https://api.dexarmarket.ru:445',
telegramBot: 'myAMLKYCBOT',
useMockData: false
}; };
``` ```
Telegram login uses the shared userauth endpoints documented in `marketplaces/docs/telegram-login-dialog.html`:
`POST /userauth/qr/create`, `GET /userauth/qr/poll?token=...`, `GET /userauth/session`, and `POST /usersession/{sessionId}`.
If the deployed auth backend does not expose `/userauth/*` yet, the same dialog falls back to the current marketplace `/users/sessions` Telegram flow.
Short backend checklist in Russian: [BACKEND_AUTH_TODO.ru.md](BACKEND_AUTH_TODO.ru.md).
## Deployment ## Deployment
### Prerequisites ### Prerequisites

View File

@@ -65,6 +65,9 @@
}, },
"serve": { "serve": {
"builder": "@angular/build:dev-server", "builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
"configurations": { "configurations": {
"production": { "production": {
"buildTarget": "market-backOffice:build:production" "buildTarget": "market-backOffice:build:production"

View File

@@ -3,7 +3,7 @@
"version": "0.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve --proxy-config proxy.conf.json",
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test" "test": "ng test"

35
proxy.conf.json Normal file
View File

@@ -0,0 +1,35 @@
{
"/api": {
"target": "https://api.dexarmarket.ru:445",
"secure": false,
"changeOrigin": true,
"pathRewrite": {
"^/api": ""
},
"logLevel": "debug"
},
"/userauth": {
"target": "https://users.vitanova.network:456",
"secure": false,
"changeOrigin": true,
"headers": {
"Origin": "https://users.vitanova.network:456"
},
"logLevel": "debug"
},
"/users/sessions": {
"target": "https://users.vitanova.network:456",
"secure": false,
"changeOrigin": true,
"headers": {
"Origin": "https://users.vitanova.network:456"
},
"logLevel": "debug"
},
"/usersession": {
"target": "https://api.dexarmarket.ru:445",
"secure": false,
"changeOrigin": true,
"logLevel": "debug"
}
}

View File

@@ -1,15 +1,16 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { authCredentialsInterceptor } from './interceptors/auth-credentials.interceptor';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideRouter(routes), provideRouter(routes),
provideAnimationsAsync(), provideAnimationsAsync(),
provideHttpClient() provideHttpClient(withInterceptors([authCredentialsInterceptor]))
] ]
}; };

View File

@@ -0,0 +1,69 @@
.auth-shell {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 32px 20px;
background: linear-gradient(135deg, #f4f7fb 0%, #e8eef4 100%);
color: #1a1a1a;
text-align: center;
}
.auth-shell__content {
width: min(100%, 420px);
padding: 32px 28px;
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: 20px;
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 18px 50px rgba(38, 52, 73, 0.12);
backdrop-filter: blur(14px);
}
.auth-shell h1 {
margin: 0 0 10px;
font-size: 26px;
line-height: 1.15;
}
.auth-shell p {
margin: 0;
color: #666;
font-size: 14px;
line-height: 1.5;
}
.auth-shell__button {
width: 100%;
margin-top: 22px;
padding: 14px 24px;
border: none;
border-radius: 12px;
background: #2aabee;
color: #fff;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.auth-shell__button:hover {
background: #229ed9;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(42, 171, 238, 0.3);
}
.auth-shell__spinner {
width: 32px;
height: 32px;
margin: 0 auto 14px;
border: 3px solid #d9e1e8;
border-top-color: #497671;
border-radius: 50%;
animation: authSpin 0.8s linear infinite;
}
@keyframes authSpin {
to {
transform: rotate(360deg);
}
}

View File

@@ -1,12 +1,51 @@
import { Component, signal } from '@angular/core'; import { Component, effect, inject, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { TelegramLoginComponent } from './components/telegram-login/telegram-login.component';
import { AuthService } from './services/auth.service';
import { TranslatePipe } from './pipes/translate.pipe';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet], imports: [RouterOutlet, TelegramLoginComponent, TranslatePipe],
templateUrl: './app.html', template: `
@if (auth.status() === 'unknown' || auth.status() === 'checking') {
<section class="auth-shell" aria-live="polite">
<div class="auth-shell__spinner"></div>
<p>{{ 'AUTH_CHECKING' | translate }}</p>
</section>
} @else if (auth.isAuthenticated()) {
<router-outlet></router-outlet>
} @else {
<section class="auth-shell">
<div class="auth-shell__content">
<h1>{{ 'MARKETPLACE_BACKOFFICE' | translate }}</h1>
<p>{{ 'AUTH_BACKOFFICE_REQUIRED' | translate }}</p>
<button class="auth-shell__button" type="button" (click)="openLogin()">
{{ 'AUTH_LOGIN_WITH_TELEGRAM' | translate }}
</button>
</div>
</section>
}
<app-telegram-login />
`,
styleUrl: './app.scss' styleUrl: './app.scss'
}) })
export class App { export class App {
protected readonly auth = inject(AuthService);
protected readonly title = signal('market-backOffice'); protected readonly title = signal('market-backOffice');
constructor() {
effect(() => {
const status = this.auth.status();
if (status === 'unauthenticated' || status === 'expired') {
this.auth.requestLogin();
}
});
}
protected openLogin(): void {
this.auth.requestLogin();
}
} }

View File

@@ -0,0 +1,80 @@
@if (showDialog()) {
<div class="login-overlay" (click)="close()">
<div class="login-dialog" role="dialog" aria-modal="true" aria-label="Telegram login dialog" (click)="$event.stopPropagation()">
<button class="close-btn" type="button" aria-label="Close dialog" (click)="close()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"></path>
</svg>
</button>
<div class="dialog-content" [attr.data-state]="state()">
<div class="login-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>
</svg>
</div>
<h2>{{ 'AUTH_LOGIN_REQUIRED' | translate }}</h2>
<p class="login-desc">{{ 'AUTH_LOGIN_DESCRIPTION' | translate }}</p>
<div class="login-status checking">
<div class="spinner"></div>
<span>{{ 'AUTH_CHECKING' | translate }}</span>
</div>
<div class="action-block">
<button class="telegram-btn" type="button" (click)="openTelegram()">
<svg class="tg-icon" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"></path>
</svg>
{{ 'AUTH_LOGIN_WITH_TELEGRAM' | translate }}
</button>
<div class="qr-section">
<p class="qr-hint">{{ 'AUTH_OR_SCAN_QR' | translate }}</p>
<div class="qr-container qr-loading">
<div class="spinner"></div>
</div>
<div class="qr-container qr-ready">
<img [src]="qrImageUrl()" [alt]="qrAlt()" width="180" height="180" loading="eager" />
</div>
<div
class="qr-container qr-expired"
role="button"
tabindex="0"
aria-label="Refresh QR code"
(click)="refreshQr()"
(keydown)="refreshQrByKeyboard($event)"
>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 4v6h6M23 20v-6h-6"></path>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"></path>
</svg>
<span>{{ 'AUTH_QR_EXPIRED' | translate }}</span>
</div>
<div
class="qr-container qr-error"
role="button"
tabindex="0"
aria-label="Retry QR login"
(click)="refreshQr()"
(keydown)="refreshQrByKeyboard($event)"
>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 4v6h6M23 20v-6h-6"></path>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"></path>
</svg>
<span>{{ 'AUTH_QR_ERROR' | translate }}</span>
</div>
</div>
<p class="login-note">{{ 'AUTH_LOGIN_NOTE' | translate }}</p>
</div>
</div>
</div>
</div>
}

View File

@@ -0,0 +1,275 @@
:host {
--bg-card: #ffffff;
--bg-hover: #f0f0f0;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--accent-color: #497671;
--accent-light: rgba(73, 118, 113, 0.1);
--telegram: #2aabee;
--telegram-hover: #229ed9;
--border: #e8e8e8;
--shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
}
.login-overlay {
position: fixed;
inset: 0;
z-index: 10000;
border-radius: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease;
padding: 16px;
}
.login-dialog {
position: relative;
background: var(--bg-card);
border-radius: 20px;
padding: 32px 28px;
max-width: 400px;
width: 100%;
text-align: center;
box-shadow: var(--shadow);
animation: scaleIn 0.25s ease;
}
.close-btn {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(--bg-hover);
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.close-btn:hover {
background: #e0e0e0;
color: #333;
}
.login-icon {
margin: 0 auto 16px;
width: 72px;
height: 72px;
border-radius: 50%;
background: var(--accent-light);
color: var(--accent-color);
display: flex;
align-items: center;
justify-content: center;
}
h2 {
margin: 0 0 8px;
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
.login-desc {
margin: 0 0 24px;
font-size: 14px;
color: var(--text-secondary);
line-height: 1.5;
}
.telegram-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 14px 24px;
border: none;
border-radius: 12px;
background: var(--telegram);
color: #fff;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.telegram-btn:hover {
background: var(--telegram-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(42, 171, 238, 0.3);
}
.telegram-btn:active {
transform: translateY(0);
}
.tg-icon {
flex-shrink: 0;
}
.qr-section {
margin-top: 20px;
}
.qr-hint {
margin: 0 0 12px;
font-size: 13px;
color: #999;
}
.qr-container {
display: inline-flex;
padding: 12px;
background: #fff;
border-radius: 12px;
border: 1px solid var(--border);
}
.qr-container img {
display: block;
border-radius: 4px;
}
.qr-loading,
.qr-expired,
.qr-error {
align-items: center;
justify-content: center;
width: 204px;
height: 204px;
}
.qr-loading .spinner,
.login-status .spinner {
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.qr-loading .spinner {
width: 32px;
height: 32px;
border: 3px solid #e0e0e0;
border-top-color: var(--accent-color);
}
.qr-expired,
.qr-error {
flex-direction: column;
gap: 8px;
cursor: pointer;
color: #999;
transition: color 0.2s ease;
}
.qr-expired:hover,
.qr-error:hover {
color: var(--accent-color);
}
.qr-expired span,
.qr-error span {
font-size: 13px;
}
.login-note {
margin: 16px 0 0;
font-size: 12px;
color: #999;
line-height: 1.4;
}
.login-status {
display: none;
align-items: center;
justify-content: center;
gap: 10px;
padding: 16px;
color: var(--text-secondary);
font-size: 14px;
}
.login-status .spinner {
width: 20px;
height: 20px;
border: 2px solid #e0e0e0;
border-top-color: var(--accent-color);
}
.dialog-content[data-state='checking'] .login-status {
display: flex;
}
.dialog-content[data-state='checking'] .action-block,
.dialog-content[data-state='checking'] .qr-section,
.dialog-content[data-state='checking'] .login-note {
display: none;
}
.dialog-content[data-state='ready'] .qr-loading,
.dialog-content[data-state='ready'] .qr-expired,
.dialog-content[data-state='ready'] .qr-error,
.dialog-content[data-state='loading'] .qr-ready,
.dialog-content[data-state='loading'] .qr-expired,
.dialog-content[data-state='loading'] .qr-error,
.dialog-content[data-state='expired'] .qr-loading,
.dialog-content[data-state='expired'] .qr-ready,
.dialog-content[data-state='expired'] .qr-error,
.dialog-content[data-state='error'] .qr-loading,
.dialog-content[data-state='error'] .qr-ready,
.dialog-content[data-state='error'] .qr-expired {
display: none;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 480px) {
.login-dialog {
padding: 24px 20px;
border-radius: 16px;
}
.qr-container img {
width: 140px;
height: 140px;
}
.qr-loading,
.qr-expired,
.qr-error {
width: 164px;
height: 164px;
}
}

View File

@@ -0,0 +1,157 @@
import { ChangeDetectionStrategy, Component, OnDestroy, computed, effect, inject, signal } from '@angular/core';
import { AuthService } from '../../services/auth.service';
import { TranslatePipe } from '../../pipes/translate.pipe';
type DialogState = 'ready' | 'loading' | 'checking' | 'expired' | 'error';
@Component({
selector: 'app-telegram-login',
standalone: true,
imports: [TranslatePipe],
templateUrl: './telegram-login.component.html',
styleUrl: './telegram-login.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TelegramLoginComponent implements OnDestroy {
private readonly authService = inject(AuthService);
protected readonly showDialog = this.authService.showLoginDialog;
protected readonly state = signal<DialogState>('loading');
protected readonly telegramDeepLink = signal('');
protected readonly qrImageUrl = signal('');
protected readonly qrAlt = computed(() => this.qrImageUrl() ? 'QR Code' : '');
private qrToken = '';
private pollCount = 0;
private pollTimer?: ReturnType<typeof setInterval>;
constructor() {
effect(() => {
if (this.showDialog()) {
this.startLoginFlow();
return;
}
this.stopPolling();
});
}
ngOnDestroy(): void {
this.stopPolling();
}
protected close(): void {
this.authService.hideLogin();
this.stopPolling();
}
protected openTelegram(): void {
const deepLink = this.telegramDeepLink();
if (!deepLink) {
return;
}
window.open(deepLink, '_blank', 'noopener,noreferrer');
}
protected refreshQr(): void {
this.startLoginFlow();
}
protected refreshQrByKeyboard(event: KeyboardEvent): void {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.refreshQr();
}
}
private startLoginFlow(): void {
this.stopPolling();
this.state.set('loading');
this.telegramDeepLink.set('');
this.qrImageUrl.set('');
this.qrToken = '';
this.pollCount = 0;
this.authService.createQrSession().subscribe({
next: (response) => {
this.qrToken = response.token;
this.telegramDeepLink.set(response.url);
this.qrImageUrl.set(
response.qrUrl
?? `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(response.url)}`
);
this.state.set('ready');
this.startPolling();
},
error: () => {
this.checkExistingSessionFallback();
}
});
}
private startPolling(): void {
this.stopPolling();
this.pollTimer = setInterval(() => {
this.pollCount += 1;
if (this.pollCount >= 100) {
this.state.set('expired');
this.stopPolling();
return;
}
this.pollQrStatus();
}, 3000);
}
private pollQrStatus(): void {
if (!this.qrToken) {
return;
}
this.authService.pollQrStatus(this.qrToken).subscribe((result) => {
if (result.status === 'pending') {
return;
}
if (result.status === 'expired') {
this.state.set('expired');
this.stopPolling();
return;
}
if (result.status === 'confirmed') {
this.state.set('checking');
this.stopPolling();
this.authService.completeLogin(result.session).subscribe();
return;
}
this.state.set('error');
this.stopPolling();
});
}
private checkExistingSessionFallback(): void {
this.state.set('checking');
this.stopPolling();
this.authService.checkSessionOnce().subscribe((session) => {
if (session?.active) {
this.authService.completeLogin(session).subscribe();
return;
}
this.state.set('error');
});
}
private stopPolling(): void {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = undefined;
}
}
}

View File

@@ -6,6 +6,15 @@ export const TRANSLATIONS: Record<string, Record<string, string>> = {
ACTIVE: 'Active', ACTIVE: 'Active',
INACTIVE: 'Inactive', INACTIVE: 'Inactive',
PROJECTS: 'Projects', PROJECTS: 'Projects',
AUTH_BACKOFFICE_REQUIRED: 'Telegram login is required to use the back office.',
AUTH_LOGIN_REQUIRED: 'Login required',
AUTH_LOGIN_DESCRIPTION: 'Please log in via Telegram to proceed with your order.',
AUTH_CHECKING: 'Checking...',
AUTH_LOGIN_WITH_TELEGRAM: 'Log in with Telegram',
AUTH_OR_SCAN_QR: 'Or scan the QR code',
AUTH_QR_EXPIRED: 'QR code expired. Click to refresh',
AUTH_QR_ERROR: 'QR login failed. Click to retry',
AUTH_LOGIN_NOTE: 'You will be redirected back after login.',
// --- Navigation / Project view --- // --- Navigation / Project view ---
CATEGORIES: 'Categories', CATEGORIES: 'Categories',
@@ -164,6 +173,15 @@ export const TRANSLATIONS: Record<string, Record<string, string>> = {
ACTIVE: 'Активен', ACTIVE: 'Активен',
INACTIVE: 'Неактивен', INACTIVE: 'Неактивен',
PROJECTS: 'Проекты', PROJECTS: 'Проекты',
AUTH_BACKOFFICE_REQUIRED: 'Для работы с бэкофисом нужен вход через Telegram.',
AUTH_LOGIN_REQUIRED: 'Требуется вход',
AUTH_LOGIN_DESCRIPTION: 'Войдите через Telegram, чтобы продолжить.',
AUTH_CHECKING: 'Проверяем...',
AUTH_LOGIN_WITH_TELEGRAM: 'Войти через Telegram',
AUTH_OR_SCAN_QR: 'Или отсканируйте QR-код',
AUTH_QR_EXPIRED: 'QR-код истек. Нажмите, чтобы обновить',
AUTH_QR_ERROR: 'Не удалось создать QR. Нажмите, чтобы повторить',
AUTH_LOGIN_NOTE: 'После входа вы вернетесь обратно.',
// --- Navigation / Project view --- // --- Navigation / Project view ---
CATEGORIES: 'Категории', CATEGORIES: 'Категории',

View File

@@ -0,0 +1,70 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { environment } from '../../environments/environment';
const AUTHENTICATED_SESSION_STORAGE_KEY = 'userauth_session_id';
const ANONYMOUS_SESSION_STORAGE_KEY = 'web_session_id';
export const authCredentialsInterceptor: HttpInterceptorFn = (request, next) => {
if (!shouldUseCredentials(request.url)) {
return next(request);
}
const webSessionId = getStoredAuthenticatedSessionId() ?? getAnonymousSessionId();
const headers = request.headers.has('WebSessionID')
? request.headers
: request.headers.set('WebSessionID', webSessionId);
return next(request.clone({ headers, withCredentials: true }));
};
function shouldUseCredentials(url: string): boolean {
if (url.startsWith('/api') || url.startsWith('/userauth') || url.startsWith('/usersession') || url.startsWith('/users/sessions')) {
return true;
}
const credentialBases = [
environment.apiUrl,
(environment as Record<string, unknown>)['authApiUrl'] as string | undefined,
(environment as Record<string, unknown>)['userSessionApiUrl'] as string | undefined
].filter((baseUrl): baseUrl is string => Boolean(baseUrl));
return credentialBases.some((baseUrl) => url.startsWith(baseUrl));
}
function getStoredAuthenticatedSessionId(): string | null {
if (typeof localStorage === 'undefined') {
return null;
}
const sessionId = localStorage.getItem(AUTHENTICATED_SESSION_STORAGE_KEY);
return sessionId?.trim() || null;
}
function getAnonymousSessionId(): string {
if (typeof localStorage === 'undefined') {
return generateSessionId();
}
let sessionId = localStorage.getItem(ANONYMOUS_SESSION_STORAGE_KEY);
if (!sessionId || sessionId.length !== 32) {
sessionId = generateSessionId();
localStorage.setItem(ANONYMOUS_SESSION_STORAGE_KEY, sessionId);
}
return sessionId;
}
function generateSessionId(): string {
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);
}
}
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
}

View File

@@ -0,0 +1,21 @@
export interface AuthSession {
sessionId: string;
telegramUserId: number | null;
username: string | null;
displayName: string;
active: boolean;
expiresAt: string;
}
export interface QrLoginSession {
token: string;
url: string;
qrUrl?: string;
}
export interface QrPollResult {
status: 'pending' | 'confirmed' | 'expired' | 'error';
session?: AuthSession | null;
}
export type AuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';

View File

@@ -0,0 +1,492 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';
import { environment } from '../../environments/environment';
import { AuthSession, AuthStatus, QrLoginSession, QrPollResult } from '../models/auth.model';
const SESSION_REFRESH_FALLBACK_MS = 60 * 60 * 1000;
const AUTHENTICATED_SESSION_STORAGE_KEY = 'userauth_session_id';
const LEGACY_WEB_SESSION_TOKEN_PREFIX = 'websession:';
interface QrCreateResponse {
token?: string;
url?: string;
deeplink?: string;
deepLink?: string;
telegramUrl?: string;
qrUrl?: string;
}
@Injectable({
providedIn: 'root'
})
export class AuthService {
private readonly http = inject(HttpClient);
private readonly authApiBaseUrl = this.trimTrailingSlash(
(environment as Record<string, unknown>)['authApiUrl'] as string || ''
);
private readonly userSessionApiBaseUrl = this.trimTrailingSlash(
(environment as Record<string, unknown>)['userSessionApiUrl'] as string || environment.apiUrl || ''
);
private readonly sessionSignal = signal<AuthSession | null>(null);
private readonly statusSignal = signal<AuthStatus>('unknown');
private readonly showLoginSignal = signal(false);
private sessionRefreshTimer?: ReturnType<typeof setTimeout>;
readonly session = this.sessionSignal.asReadonly();
readonly status = this.statusSignal.asReadonly();
readonly showLoginDialog = this.showLoginSignal.asReadonly();
readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated');
readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null);
constructor() {
this.checkSession();
}
checkSession(): void {
this.statusSignal.set('checking');
this.checkSessionOnce().subscribe((session) => {
if (session?.active) {
this.activateSession(session);
return;
}
this.clearAuthState('unauthenticated');
});
}
checkSessionOnce(): Observable<AuthSession | null> {
return this.http
.get<Record<string, unknown>>(this.buildAuthUrl('/userauth/session'), {
withCredentials: true
})
.pipe(
map((response) => this.normalizeSession(response)),
catchError(() => this.checkStoredLegacySession())
);
}
createQrSession(): Observable<QrLoginSession> {
return this.createUserauthQrSession().pipe(
catchError(() => this.createLegacyWebSession())
);
}
pollQrStatus(token: string): Observable<QrPollResult> {
if (token.startsWith(LEGACY_WEB_SESSION_TOKEN_PREFIX)) {
return this.pollLegacyWebSession(token.slice(LEGACY_WEB_SESSION_TOKEN_PREFIX.length));
}
return this.http
.get<Record<string, unknown>>(
this.buildAuthUrl(`/userauth/qr/poll?token=${encodeURIComponent(token)}`),
{ withCredentials: true }
)
.pipe(
map((response) => this.normalizeQrPoll(response)),
catchError(() => of({ status: 'error' as const }))
);
}
completeLogin(session?: AuthSession | null): Observable<void> {
const activeSession = session?.active ? session : null;
if (activeSession) {
this.activateSession(activeSession);
}
return this.syncSessionAfterLogin(activeSession?.sessionId).pipe(
tap(() => {
if (activeSession) {
this.hideLogin();
return;
}
this.checkSession();
}),
map(() => void 0)
);
}
requestLogin(): void {
this.showLoginSignal.set(true);
}
hideLogin(): void {
this.showLoginSignal.set(false);
}
logout(): void {
const legacySessionId = this.readStoredAuthenticatedSessionId();
this.http
.post(this.buildAuthUrl('/userauth/logout'), {}, { withCredentials: true })
.pipe(catchError(() => of(null)))
.subscribe(() => {
if (legacySessionId) {
this.deleteLegacyWebSession(legacySessionId).subscribe();
}
this.clearAuthState('unauthenticated');
this.requestLogin();
});
}
private createUserauthQrSession(): Observable<QrLoginSession> {
return this.http
.post<QrCreateResponse>(
this.buildAuthUrl('/userauth/qr/create'),
{},
{ withCredentials: true }
)
.pipe(
map((response) => {
const token = response?.token?.trim() ?? '';
const url = response?.url ?? response?.deeplink ?? response?.deepLink ?? response?.telegramUrl ?? '';
if (!token || !url) {
throw new Error('Invalid QR create response');
}
return {
token,
url,
qrUrl: response.qrUrl
};
})
);
}
private createLegacyWebSession(): Observable<QrLoginSession> {
const webSessionId = this.generateGuid();
return this.http
.post<Record<string, unknown>>(
this.buildAuthUrl('/users/sessions'),
{ webSessionID: webSessionId },
{
headers: { WebSessionID: webSessionId },
withCredentials: true
}
)
.pipe(
map((response) => {
const sessionId = this.extractSessionId(response, webSessionId);
return {
token: `${LEGACY_WEB_SESSION_TOKEN_PREFIX}${sessionId}`,
url: this.getTelegramLoginUrl(sessionId)
};
})
);
}
private pollLegacyWebSession(sessionId: string): Observable<QrPollResult> {
if (!sessionId) {
return of({ status: 'error' });
}
return this.http
.get<Record<string, unknown>>(this.buildAuthUrl(`/users/sessions/${encodeURIComponent(sessionId)}`), {
headers: { WebSessionID: sessionId },
withCredentials: true
})
.pipe(
map((response) => {
const session = this.normalizeSession(response, sessionId);
return session?.active
? { status: 'confirmed' as const, session }
: { status: 'pending' as const };
}),
catchError(() => of({ status: 'error' as const }))
);
}
private checkStoredLegacySession(): Observable<AuthSession | null> {
const storedSessionId = this.readStoredAuthenticatedSessionId();
if (!storedSessionId) {
return of(null);
}
return this.pollLegacyWebSession(storedSessionId).pipe(
map((result) => result.status === 'confirmed' ? result.session ?? null : null)
);
}
private deleteLegacyWebSession(sessionId: string): Observable<unknown> {
return this.http
.delete(this.buildAuthUrl(`/users/sessions/${encodeURIComponent(sessionId)}`), {
headers: { WebSessionID: sessionId },
withCredentials: true
})
.pipe(catchError(() => of(null)));
}
private syncSessionAfterLogin(sessionId?: string): Observable<void> {
if (!sessionId) {
return of(void 0);
}
return this.http
.post(this.buildUserSessionUrl(`/usersession/${encodeURIComponent(sessionId)}`), {}, {
withCredentials: true
})
.pipe(
catchError(() => of(null)),
map(() => void 0)
);
}
private activateSession(session: AuthSession): void {
this.sessionSignal.set(session);
this.statusSignal.set('authenticated');
this.storeAuthenticatedSessionId(session.sessionId);
this.hideLogin();
this.scheduleSessionRefresh(session.expiresAt);
}
private clearAuthState(status: AuthStatus): void {
this.sessionSignal.set(null);
this.statusSignal.set(status);
this.clearStoredAuthenticatedSessionId();
this.clearSessionRefresh();
}
private storeAuthenticatedSessionId(sessionId: string): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(AUTHENTICATED_SESSION_STORAGE_KEY, sessionId);
}
private clearStoredAuthenticatedSessionId(): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.removeItem(AUTHENTICATED_SESSION_STORAGE_KEY);
}
private readStoredAuthenticatedSessionId(): string | null {
if (typeof localStorage === 'undefined') {
return null;
}
return localStorage.getItem(AUTHENTICATED_SESSION_STORAGE_KEY);
}
private scheduleSessionRefresh(expiresAt: string): void {
this.clearSessionRefresh();
const expiresAtMs = new Date(expiresAt).getTime();
const refreshInMs = Number.isFinite(expiresAtMs)
? Math.max(expiresAtMs - Date.now() - 60_000, 30_000)
: SESSION_REFRESH_FALLBACK_MS;
this.sessionRefreshTimer = setTimeout(() => {
this.checkSession();
}, refreshInMs);
}
private clearSessionRefresh(): void {
if (this.sessionRefreshTimer) {
clearTimeout(this.sessionRefreshTimer);
this.sessionRefreshTimer = undefined;
}
}
private normalizeQrPoll(response: Record<string, unknown>): QrPollResult {
const status = this.readString(this.readFirst(response, ['status', 'Status'])) ?? 'pending';
if (status === 'confirmed') {
const rawSession = this.asRecord(this.readFirst(response, ['session', 'Session'])) ?? response;
return {
status: 'confirmed',
session: this.normalizeSession(rawSession)
};
}
if (status === 'expired') {
return { status: 'expired' };
}
return { status: 'pending' };
}
private normalizeSession(response: Record<string, unknown> | null, fallbackSessionId?: string): AuthSession | null {
if (!response) {
return null;
}
const sessionSource = this.asRecord(this.readFirst(response, ['session', 'Session'])) ?? response;
const user = this.asRecord(this.readFirst(sessionSource, ['user', 'User', 'telegramUser', 'TelegramUser'])) ?? sessionSource;
const sessionId = this.extractSessionId(sessionSource, fallbackSessionId);
if (!sessionId) {
return null;
}
const status = this.readFirst(sessionSource, [
'active',
'Active',
'authenticated',
'Authenticated',
'status',
'Status',
'loggedIn',
'LoggedIn'
]);
const active = status === undefined ? true : this.isActiveStatus(status);
const username = this.readString(this.readFirst(user, ['username', 'Username']))
?? this.readString(this.readFirst(sessionSource, ['username', 'Username']));
const firstName = this.readString(this.readFirst(user, ['firstName', 'first_name', 'FirstName']));
const lastName = this.readString(this.readFirst(user, ['lastName', 'last_name', 'LastName']));
const fullName = [firstName, lastName].filter(Boolean).join(' ');
const displayName = this.readString(this.readFirst(sessionSource, ['displayName', 'DisplayName', 'name', 'Name']))
?? this.readString(this.readFirst(user, ['displayName', 'DisplayName', 'name', 'Name']))
?? username
?? fullName
?? 'Telegram User';
const telegramUserId = this.readNumber(this.readFirst(sessionSource, [
'telegramUserId',
'telegramUserID',
'TelegramUserID',
'userId',
'userID',
'UserID'
])) ?? this.readNumber(this.readFirst(user, ['telegramUserId', 'telegramUserID', 'id', 'ID']));
const expiresAt = this.readString(this.readFirst(sessionSource, ['expiresAt', 'ExpiresAt', 'expires', 'Expires']))
?? new Date(Date.now() + SESSION_REFRESH_FALLBACK_MS).toISOString();
return {
sessionId,
telegramUserId,
username,
displayName,
active,
expiresAt
};
}
private extractSessionId(response: Record<string, unknown> | null, fallbackSessionId?: string): string {
if (!response) {
return fallbackSessionId ?? '';
}
return this.readString(this.readFirst(response, [
'sessionId',
'SessionId',
'sessionID',
'SessionID',
'webSessionID',
'WebSessionID',
'webSessionId',
'userSessionId',
'userSessionID',
'id',
'ID'
])) ?? fallbackSessionId ?? '';
}
private buildAuthUrl(path: string): string {
return `${this.authApiBaseUrl}${this.withLeadingSlash(path)}`;
}
private buildUserSessionUrl(path: string): string {
return `${this.userSessionApiBaseUrl}${this.withLeadingSlash(path)}`;
}
private withLeadingSlash(path: string): string {
return path.startsWith('/') ? path : `/${path}`;
}
private trimTrailingSlash(value: string): string {
return value.endsWith('/') ? value.slice(0, -1) : value;
}
private readFirst(source: Record<string, unknown>, keys: string[]): unknown {
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
return source[key];
}
}
return undefined;
}
private readString(value: unknown): string | null {
if (typeof value === 'string' && value.trim()) {
return value;
}
if (typeof value === 'number' || typeof value === 'bigint') {
return value.toString();
}
return null;
}
private readNumber(value: unknown): number | null {
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;
}
private asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
private isActiveStatus(status: unknown): boolean {
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());
}
private getTelegramLoginUrl(sessionId: string): string {
const botUsername = (environment as Record<string, unknown>)['telegramBot'] as string || 'DexarSupport_bot';
return `https://t.me/${botUsername}?start=${encodeURIComponent(sessionId)}`;
}
private generateGuid(): string {
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('')}`;
}
}

View File

@@ -1,4 +1,5 @@
export * from './api.service'; export * from './api.service';
export * from './auth.service';
export * from './validation.service'; export * from './validation.service';
export * from './toast.service'; export * from './toast.service';
export * from './language.service'; export * from './language.service';

View File

@@ -1,6 +1,9 @@
export const environment = { export const environment = {
production: true, production: true,
useMockData: false, useMockData: false,
apiUrl: '/api', apiUrl: 'https://api.dexarmarket.ru:445',
authApiUrl: 'https://users.vitanova.network:456',
userSessionApiUrl: 'https://api.dexarmarket.ru:445',
telegramBot: 'myAMLKYCBOT',
marketplaceUrl: 'https://dexarmarket.ru' marketplaceUrl: 'https://dexarmarket.ru'
}; };

View File

@@ -1,6 +1,9 @@
export const environment = { export const environment = {
production: false, production: false,
useMockData: true, // Set to false when backend is ready useMockData: false,
apiUrl: '/api', apiUrl: '/api',
authApiUrl: '',
userSessionApiUrl: '',
telegramBot: 'myAMLKYCBOT',
marketplaceUrl: 'http://localhost:4200' marketplaceUrl: 'http://localhost:4200'
}; };