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()) { +
+ +
+} \ 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' };