diff --git a/BACKEND_AUTH_TODO.ru.md b/BACKEND_AUTH_TODO.ru.md
new file mode 100644
index 0000000..78b09ac
--- /dev/null
+++ b/BACKEND_AUTH_TODO.ru.md
@@ -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.
\ No newline at end of file
diff --git a/README.md b/README.md
index 5c60866..43adee0 100644
--- a/README.md
+++ b/README.md
@@ -102,20 +102,33 @@ This will compile your project and store the build artifacts in the `dist/market
```typescript
export const environment = {
production: false,
- apiUrl: 'http://localhost:3000/api', // Local backend
- useMockData: true // Use mock data for development
+ apiUrl: '/api',
+ 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`)
```typescript
export const environment = {
production: true,
- apiUrl: 'https://api.dexarmarket.ru/api', // Production backend
- useMockData: false // Use real API
+ apiUrl: 'https://api.dexarmarket.ru:445',
+ 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
### Prerequisites
diff --git a/angular.json b/angular.json
index d0a211e..eb67f13 100644
--- a/angular.json
+++ b/angular.json
@@ -65,6 +65,9 @@
},
"serve": {
"builder": "@angular/build:dev-server",
+ "options": {
+ "proxyConfig": "proxy.conf.json"
+ },
"configurations": {
"production": {
"buildTarget": "market-backOffice:build:production"
diff --git a/package.json b/package.json
index b574200..243b350 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,7 @@
"version": "0.0.0",
"scripts": {
"ng": "ng",
- "start": "ng serve",
+ "start": "ng serve --proxy-config proxy.conf.json",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
diff --git a/proxy.conf.json b/proxy.conf.json
new file mode 100644
index 0000000..4e37ab8
--- /dev/null
+++ b/proxy.conf.json
@@ -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"
+ }
+}
\ No newline at end of file
diff --git a/src/app/app.config.ts b/src/app/app.config.ts
index 70bbb04..da8b262 100644
--- a/src/app/app.config.ts
+++ b/src/app/app.config.ts
@@ -1,15 +1,16 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
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 { authCredentialsInterceptor } from './interceptors/auth-credentials.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideAnimationsAsync(),
- provideHttpClient()
+ provideHttpClient(withInterceptors([authCredentialsInterceptor]))
]
};
diff --git a/src/app/app.scss b/src/app/app.scss
index e69de29..4e72f31 100644
--- a/src/app/app.scss
+++ b/src/app/app.scss
@@ -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);
+ }
+}
diff --git a/src/app/app.ts b/src/app/app.ts
index 89ef8cf..1dd4312 100644
--- a/src/app/app.ts
+++ b/src/app/app.ts
@@ -1,12 +1,51 @@
-import { Component, signal } from '@angular/core';
+import { Component, effect, inject, signal } from '@angular/core';
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({
selector: 'app-root',
- imports: [RouterOutlet],
- templateUrl: './app.html',
+ imports: [RouterOutlet, TelegramLoginComponent, TranslatePipe],
+ template: `
+ @if (auth.status() === 'unknown' || auth.status() === 'checking') {
+
+
+ {{ 'AUTH_CHECKING' | translate }}
+
+ } @else if (auth.isAuthenticated()) {
+
+ } @else {
+
+
+
{{ 'MARKETPLACE_BACKOFFICE' | translate }}
+
{{ 'AUTH_BACKOFFICE_REQUIRED' | translate }}
+
+
+
+ }
+
+
+ `,
styleUrl: './app.scss'
})
export class App {
+ protected readonly auth = inject(AuthService);
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();
+ }
}
diff --git a/src/app/components/telegram-login/telegram-login.component.html b/src/app/components/telegram-login/telegram-login.component.html
new file mode 100644
index 0000000..3b21c8f
--- /dev/null
+++ b/src/app/components/telegram-login/telegram-login.component.html
@@ -0,0 +1,80 @@
+@if (showDialog()) {
+
+
+
+
+
+
+
+
{{ 'AUTH_LOGIN_REQUIRED' | translate }}
+
{{ 'AUTH_LOGIN_DESCRIPTION' | translate }}
+
+
+
+
{{ 'AUTH_CHECKING' | translate }}
+
+
+
+
+
+
+
{{ 'AUTH_OR_SCAN_QR' | translate }}
+
+
+
+
+
![]()
+
+
+
+
+
{{ 'AUTH_QR_EXPIRED' | translate }}
+
+
+
+
+
{{ 'AUTH_QR_ERROR' | translate }}
+
+
+
+
{{ 'AUTH_LOGIN_NOTE' | translate }}
+
+
+
+
+}
\ No newline at end of file
diff --git a/src/app/components/telegram-login/telegram-login.component.scss b/src/app/components/telegram-login/telegram-login.component.scss
new file mode 100644
index 0000000..a742c21
--- /dev/null
+++ b/src/app/components/telegram-login/telegram-login.component.scss
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/src/app/components/telegram-login/telegram-login.component.ts b/src/app/components/telegram-login/telegram-login.component.ts
new file mode 100644
index 0000000..2130a64
--- /dev/null
+++ b/src/app/components/telegram-login/telegram-login.component.ts
@@ -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('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;
+
+ 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;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts
index 5c4e273..be84e0d 100644
--- a/src/app/i18n/translations.ts
+++ b/src/app/i18n/translations.ts
@@ -6,6 +6,15 @@ export const TRANSLATIONS: Record> = {
ACTIVE: 'Active',
INACTIVE: 'Inactive',
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 ---
CATEGORIES: 'Categories',
@@ -164,6 +173,15 @@ export const TRANSLATIONS: Record> = {
ACTIVE: 'Активен',
INACTIVE: 'Неактивен',
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 ---
CATEGORIES: 'Категории',
diff --git a/src/app/interceptors/auth-credentials.interceptor.ts b/src/app/interceptors/auth-credentials.interceptor.ts
new file mode 100644
index 0000000..ad6dc12
--- /dev/null
+++ b/src/app/interceptors/auth-credentials.interceptor.ts
@@ -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)['authApiUrl'] as string | undefined,
+ (environment as Record)['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('');
+}
\ No newline at end of file
diff --git a/src/app/models/auth.model.ts b/src/app/models/auth.model.ts
new file mode 100644
index 0000000..44821fc
--- /dev/null
+++ b/src/app/models/auth.model.ts
@@ -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';
\ No newline at end of file
diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts
new file mode 100644
index 0000000..db84ae3
--- /dev/null
+++ b/src/app/services/auth.service.ts
@@ -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)['authApiUrl'] as string || ''
+ );
+ private readonly userSessionApiBaseUrl = this.trimTrailingSlash(
+ (environment as Record)['userSessionApiUrl'] as string || environment.apiUrl || ''
+ );
+
+ private readonly sessionSignal = signal(null);
+ private readonly statusSignal = signal('unknown');
+ private readonly showLoginSignal = signal(false);
+ private sessionRefreshTimer?: ReturnType;
+
+ 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 {
+ return this.http
+ .get>(this.buildAuthUrl('/userauth/session'), {
+ withCredentials: true
+ })
+ .pipe(
+ map((response) => this.normalizeSession(response)),
+ catchError(() => this.checkStoredLegacySession())
+ );
+ }
+
+ createQrSession(): Observable {
+ return this.createUserauthQrSession().pipe(
+ catchError(() => this.createLegacyWebSession())
+ );
+ }
+
+ pollQrStatus(token: string): Observable {
+ if (token.startsWith(LEGACY_WEB_SESSION_TOKEN_PREFIX)) {
+ return this.pollLegacyWebSession(token.slice(LEGACY_WEB_SESSION_TOKEN_PREFIX.length));
+ }
+
+ return this.http
+ .get>(
+ 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 {
+ 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 {
+ return this.http
+ .post(
+ 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 {
+ const webSessionId = this.generateGuid();
+
+ return this.http
+ .post>(
+ 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 {
+ if (!sessionId) {
+ return of({ status: 'error' });
+ }
+
+ return this.http
+ .get>(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 {
+ 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 {
+ return this.http
+ .delete(this.buildAuthUrl(`/users/sessions/${encodeURIComponent(sessionId)}`), {
+ headers: { WebSessionID: sessionId },
+ withCredentials: true
+ })
+ .pipe(catchError(() => of(null)));
+ }
+
+ private syncSessionAfterLogin(sessionId?: string): Observable {
+ 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): 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 | 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 | 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, 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 | null {
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
+ ? value as Record
+ : 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)['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('')}`;
+ }
+}
diff --git a/src/app/services/index.ts b/src/app/services/index.ts
index cb50045..ae311be 100644
--- a/src/app/services/index.ts
+++ b/src/app/services/index.ts
@@ -1,4 +1,5 @@
export * from './api.service';
+export * from './auth.service';
export * from './validation.service';
export * from './toast.service';
export * from './language.service';
diff --git a/src/environments/environment.production.ts b/src/environments/environment.production.ts
index 133a159..546b825 100644
--- a/src/environments/environment.production.ts
+++ b/src/environments/environment.production.ts
@@ -1,6 +1,9 @@
export const environment = {
production: true,
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'
};
diff --git a/src/environments/environment.ts b/src/environments/environment.ts
index 132d62b..f848c45 100644
--- a/src/environments/environment.ts
+++ b/src/environments/environment.ts
@@ -1,6 +1,9 @@
export const environment = {
production: false,
- useMockData: true, // Set to false when backend is ready
+ useMockData: false,
apiUrl: '/api',
+ authApiUrl: '',
+ userSessionApiUrl: '',
+ telegramBot: 'myAMLKYCBOT',
marketplaceUrl: 'http://localhost:4200'
};