Compare commits
3 Commits
5c6cb051ac
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9038a1f782 | ||
|
|
fb570a32f5 | ||
|
|
09e8465577 |
61
BACKEND_AUTH_TODO.ru.md
Normal file
61
BACKEND_AUTH_TODO.ru.md
Normal 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.
|
||||||
21
README.md
21
README.md
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
35
proxy.conf.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]))
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
275
src/app/components/telegram-login/telegram-login.component.scss
Normal file
275
src/app/components/telegram-login/telegram-login.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
157
src/app/components/telegram-login/telegram-login.component.ts
Normal file
157
src/app/components/telegram-login/telegram-login.component.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
@@ -103,6 +112,9 @@ export const TRANSLATIONS: Record<string, Record<string, string>> = {
|
|||||||
NO_MORE_ITEMS: 'No more items to load',
|
NO_MORE_ITEMS: 'No more items to load',
|
||||||
SHOW: 'Show',
|
SHOW: 'Show',
|
||||||
HIDE: 'Hide',
|
HIDE: 'Hide',
|
||||||
|
SHOW_ALL: 'Show All',
|
||||||
|
HIDE_ALL: 'Hide All',
|
||||||
|
UPDATING_VISIBILITY: 'Updating visibility...',
|
||||||
|
|
||||||
// --- Translations tab ---
|
// --- Translations tab ---
|
||||||
TRANSLATIONS_HINT: 'Fill in translations for marketplace localization.',
|
TRANSLATIONS_HINT: 'Fill in translations for marketplace localization.',
|
||||||
@@ -155,7 +167,10 @@ export const TRANSLATIONS: Record<string, Record<string, string>> = {
|
|||||||
FAILED_DELETE_SUBCATEGORY: 'Failed to delete subcategory',
|
FAILED_DELETE_SUBCATEGORY: 'Failed to delete subcategory',
|
||||||
FAILED_DELETE_ITEM: 'Failed to delete item',
|
FAILED_DELETE_ITEM: 'Failed to delete item',
|
||||||
FAILED_UPDATE_ITEMS: 'Failed to update items',
|
FAILED_UPDATE_ITEMS: 'Failed to update items',
|
||||||
|
FAILED_UPDATE_VISIBILITY: 'Failed to update visibility',
|
||||||
FAILED_UPLOAD_IMAGE: 'Failed to upload image',
|
FAILED_UPLOAD_IMAGE: 'Failed to upload image',
|
||||||
|
ALL_CONTENT_VISIBLE: 'All categories, subcategories, and items are now visible',
|
||||||
|
ALL_CONTENT_HIDDEN: 'All categories, subcategories, and items are now hidden',
|
||||||
},
|
},
|
||||||
|
|
||||||
ru: {
|
ru: {
|
||||||
@@ -164,6 +179,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: 'Категории',
|
||||||
@@ -261,6 +285,9 @@ export const TRANSLATIONS: Record<string, Record<string, string>> = {
|
|||||||
NO_MORE_ITEMS: 'Все товары загружены',
|
NO_MORE_ITEMS: 'Все товары загружены',
|
||||||
SHOW: 'Показать',
|
SHOW: 'Показать',
|
||||||
HIDE: 'Скрыть',
|
HIDE: 'Скрыть',
|
||||||
|
SHOW_ALL: 'Показать все',
|
||||||
|
HIDE_ALL: 'Скрыть все',
|
||||||
|
UPDATING_VISIBILITY: 'Обновляем видимость...',
|
||||||
|
|
||||||
// --- Translations tab ---
|
// --- Translations tab ---
|
||||||
TRANSLATIONS_HINT: 'Переводы для локализации маркетплейса.',
|
TRANSLATIONS_HINT: 'Переводы для локализации маркетплейса.',
|
||||||
@@ -313,6 +340,9 @@ export const TRANSLATIONS: Record<string, Record<string, string>> = {
|
|||||||
FAILED_DELETE_SUBCATEGORY: 'Не удалось удалить подкатегорию',
|
FAILED_DELETE_SUBCATEGORY: 'Не удалось удалить подкатегорию',
|
||||||
FAILED_DELETE_ITEM: 'Не удалось удалить товар',
|
FAILED_DELETE_ITEM: 'Не удалось удалить товар',
|
||||||
FAILED_UPDATE_ITEMS: 'Не удалось обновить товары',
|
FAILED_UPDATE_ITEMS: 'Не удалось обновить товары',
|
||||||
|
FAILED_UPDATE_VISIBILITY: 'Не удалось обновить видимость',
|
||||||
FAILED_UPLOAD_IMAGE: 'Не удалось загрузить изображение',
|
FAILED_UPLOAD_IMAGE: 'Не удалось загрузить изображение',
|
||||||
|
ALL_CONTENT_VISIBLE: 'Все категории, подкатегории и товары теперь видимы',
|
||||||
|
ALL_CONTENT_HIDDEN: 'Все категории, подкатегории и товары теперь скрыты',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
70
src/app/interceptors/auth-credentials.interceptor.ts
Normal file
70
src/app/interceptors/auth-credentials.interceptor.ts
Normal 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('');
|
||||||
|
}
|
||||||
21
src/app/models/auth.model.ts
Normal file
21
src/app/models/auth.model.ts
Normal 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';
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { ItemName } from './item.model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-language translation content for a category or subcategory.
|
* Per-language translation content for a category or subcategory.
|
||||||
* Stored under `translations['ru']`, `translations['en']`, etc.
|
* Stored under `translations['ru']`, `translations['en']`, etc.
|
||||||
@@ -16,6 +18,15 @@ export interface Category {
|
|||||||
subcategories?: Subcategory[];
|
subcategories?: Subcategory[];
|
||||||
/** Optional translations keyed by language code: { ru: { name: '...' } } */
|
/** Optional translations keyed by language code: { ru: { name: '...' } } */
|
||||||
translations?: { [lang: string]: CategoryTranslation };
|
translations?: { [lang: string]: CategoryTranslation };
|
||||||
|
|
||||||
|
// Fields from Go backend struct
|
||||||
|
categoryID?: number;
|
||||||
|
parentID?: number;
|
||||||
|
icon?: string;
|
||||||
|
wideBanner?: string;
|
||||||
|
itemCount?: number;
|
||||||
|
categoriesCount?: number;
|
||||||
|
names?: ItemName[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Subcategory {
|
export interface Subcategory {
|
||||||
|
|||||||
@@ -11,12 +11,15 @@ export interface ItemTranslation {
|
|||||||
export interface ItemName {
|
export interface ItemName {
|
||||||
language: string;
|
language: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
/** Backend typo — some responses use 'valuue' instead of 'value' */
|
||||||
|
valuue?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Localized description entry */
|
/** Localized description entry */
|
||||||
export interface ItemDescription {
|
export interface ItemDescription {
|
||||||
language: string;
|
language: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
valuue?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Key-value attribute pair */
|
/** Key-value attribute pair */
|
||||||
@@ -25,6 +28,39 @@ export interface ItemAttribute {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Item variant detail (price, size, colour per variant) */
|
||||||
|
export interface ItemDetail {
|
||||||
|
color?: string;
|
||||||
|
colour?: string;
|
||||||
|
size?: string;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
remaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Photo entry with type (photo or video) */
|
||||||
|
export interface Photo {
|
||||||
|
type?: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Question on an item */
|
||||||
|
export interface Question {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
like?: number;
|
||||||
|
dislike?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Review / callback on an item */
|
||||||
|
export interface Review {
|
||||||
|
rating?: number;
|
||||||
|
content?: string;
|
||||||
|
userID?: string;
|
||||||
|
answer?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Item {
|
export interface Item {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -48,6 +84,18 @@ export interface Item {
|
|||||||
comments?: Comment[];
|
comments?: Comment[];
|
||||||
/** Optional translations keyed by language code: { ru: { name: '...', simpleDescription: '...', description: [...] } } */
|
/** Optional translations keyed by language code: { ru: { name: '...', simpleDescription: '...', description: [...] } } */
|
||||||
translations?: { [lang: string]: ItemTranslation };
|
translations?: { [lang: string]: ItemTranslation };
|
||||||
|
|
||||||
|
// Fields from Go backend struct
|
||||||
|
itemID?: number;
|
||||||
|
categoryID?: number;
|
||||||
|
rating?: number;
|
||||||
|
visits?: number;
|
||||||
|
itemDetails?: ItemDetail[];
|
||||||
|
photos?: Photo[];
|
||||||
|
questions?: Question[];
|
||||||
|
callbacks?: Review[];
|
||||||
|
partnerID?: string;
|
||||||
|
remaining?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ItemDescriptionField {
|
export interface ItemDescriptionField {
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export class ItemEditorComponent implements OnInit {
|
|||||||
ruSimpleDesc = '';
|
ruSimpleDesc = '';
|
||||||
ruDescFields: ItemDescriptionField[] = [];
|
ruDescFields: ItemDescriptionField[] = [];
|
||||||
|
|
||||||
currencies = ['USD', 'EUR', 'RUB', 'GBP', 'UAH'];
|
currencies = ['USD', 'EUR', 'RUB', 'GBP', 'UAH', 'AMD'];
|
||||||
|
|
||||||
predefinedBadges: { label: string; value: string; color: string }[] = [
|
predefinedBadges: { label: string; value: string; color: string }[] = [
|
||||||
{ label: 'New', value: 'new', color: '#009688' },
|
{ label: 'New', value: 'new', color: '#009688' },
|
||||||
|
|||||||
@@ -14,12 +14,40 @@
|
|||||||
<mat-sidenav-container class="sidenav-container">
|
<mat-sidenav-container class="sidenav-container">
|
||||||
<mat-sidenav mode="side" opened class="categories-sidebar">
|
<mat-sidenav mode="side" opened class="categories-sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
|
<div class="sidebar-title-row">
|
||||||
<h2>{{ 'CATEGORIES' | translate }}</h2>
|
<h2>{{ 'CATEGORIES' | translate }}</h2>
|
||||||
<button mat-mini-fab color="primary" (click)="addCategory()" [matTooltip]="'ADD_CATEGORY' | translate">
|
<button mat-mini-fab color="primary" (click)="addCategory()" [matTooltip]="'ADD_CATEGORY' | translate">
|
||||||
<mat-icon>add</mat-icon>
|
<mat-icon>add</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-bulk-row">
|
||||||
|
<div class="bulk-visibility-actions">
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
color="primary"
|
||||||
|
(click)="setAllVisibility(true)"
|
||||||
|
[disabled]="loading() || bulkVisibilityUpdating() || !categories().length">
|
||||||
|
<mat-icon>visibility</mat-icon>
|
||||||
|
{{ 'SHOW_ALL' | translate }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
color="primary"
|
||||||
|
(click)="setAllVisibility(false)"
|
||||||
|
[disabled]="loading() || bulkVisibilityUpdating() || !categories().length">
|
||||||
|
<mat-icon>visibility_off</mat-icon>
|
||||||
|
{{ 'HIDE_ALL' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (bulkVisibilityUpdating()) {
|
||||||
|
<span class="bulk-status">{{ 'UPDATING_VISIBILITY' | translate }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<app-loading-skeleton type="tree"></app-loading-skeleton>
|
<app-loading-skeleton type="tree"></app-loading-skeleton>
|
||||||
} @else {
|
} @else {
|
||||||
|
|||||||
@@ -46,15 +46,44 @@
|
|||||||
|
|
||||||
.categories-sidebar {
|
.categories-sidebar {
|
||||||
width: 420px;
|
width: 420px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
border-right: 1px solid #e0e0e0;
|
border-right: 1px solid #e0e0e0;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
|
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-bottom: 1px solid #e0e0e0;
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
|
||||||
|
.sidebar-title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-bulk-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-visibility-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
button {
|
||||||
|
flex: 1 1 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-status {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #5f6368;
|
||||||
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -73,7 +102,8 @@
|
|||||||
|
|
||||||
.tree-container {
|
.tree-container {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
height: calc(100% - 65px);
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export class ProjectViewComponent implements OnInit {
|
|||||||
project = signal<Project | null>(null);
|
project = signal<Project | null>(null);
|
||||||
categories = signal<Category[]>([]);
|
categories = signal<Category[]>([]);
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
|
bulkVisibilityUpdating = signal(false);
|
||||||
treeData = signal<CategoryNode[]>([]);
|
treeData = signal<CategoryNode[]>([]);
|
||||||
selectedNodeId = signal<string | null>(null);
|
selectedNodeId = signal<string | null>(null);
|
||||||
|
|
||||||
@@ -201,6 +202,26 @@ export class ProjectViewComponent implements OnInit {
|
|||||||
this.router.navigate(['/']);
|
this.router.navigate(['/']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAllVisibility(visible: boolean) {
|
||||||
|
if (this.bulkVisibilityUpdating() || !this.categories().length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bulkVisibilityUpdating.set(true);
|
||||||
|
this.apiService.setProjectVisibility(this.categories(), visible).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.loadCategories();
|
||||||
|
this.bulkVisibilityUpdating.set(false);
|
||||||
|
this.toast.success(this.lang.t(visible ? 'ALL_CONTENT_VISIBLE' : 'ALL_CONTENT_HIDDEN'));
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Failed to update project visibility', err);
|
||||||
|
this.bulkVisibilityUpdating.set(false);
|
||||||
|
this.toast.error(err.message || this.lang.t('FAILED_UPDATE_VISIBILITY'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
addCategory() {
|
addCategory() {
|
||||||
const dialogRef = this.dialog.open(CreateDialogComponent, {
|
const dialogRef = this.dialog.open(CreateDialogComponent, {
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
import { Injectable, inject, signal } from '@angular/core';
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { Observable, Subject, throwError } from 'rxjs';
|
import { EMPTY, Observable, Subject, from, of, throwError } from 'rxjs';
|
||||||
import { debounceTime, retry, catchError, map, groupBy, mergeMap } from 'rxjs/operators';
|
import {
|
||||||
|
catchError,
|
||||||
|
concatMap,
|
||||||
|
debounceTime,
|
||||||
|
expand,
|
||||||
|
groupBy,
|
||||||
|
map,
|
||||||
|
mergeMap,
|
||||||
|
reduce,
|
||||||
|
retry,
|
||||||
|
toArray,
|
||||||
|
} from 'rxjs/operators';
|
||||||
import { Project, Category, Subcategory, Item, ItemsListResponse } from '../models';
|
import { Project, Category, Subcategory, Item, ItemsListResponse } from '../models';
|
||||||
|
import { ItemName } from '../models/item.model';
|
||||||
import { MockDataService } from './mock-data.service';
|
import { MockDataService } from './mock-data.service';
|
||||||
import { ToastService } from './toast.service';
|
import { ToastService } from './toast.service';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
@@ -34,6 +46,108 @@ export class ApiService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Normalizers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Get text from an ItemName entry, handling the backend 'valuue' typo */
|
||||||
|
private nameValue(entry: ItemName): string {
|
||||||
|
return entry.value || entry.valuue || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize an item from the backend — extracts itemDetails, names, photos */
|
||||||
|
private normalizeItem(raw: any): Item {
|
||||||
|
const item: Item = { ...raw };
|
||||||
|
|
||||||
|
// Extract price/currency/remaining from itemDetails[0] if present
|
||||||
|
const details = raw.itemDetails || raw.itemdetails;
|
||||||
|
if (details && Array.isArray(details) && details.length > 0) {
|
||||||
|
const d = details[0];
|
||||||
|
item.itemDetails = details;
|
||||||
|
if (item.price == null || item.price === 0) item.price = d.price ?? 0;
|
||||||
|
if (!item.currency) item.currency = d.currency || 'RUB';
|
||||||
|
if (!item.colour) item.colour = d.colour || d.color || '';
|
||||||
|
if (!item.size) item.size = d.size || '';
|
||||||
|
if (item.quantity == null && d.remaining != null) item.quantity = d.remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build translations from names[]/descriptions[] if translations map is empty
|
||||||
|
if (raw.names && Array.isArray(raw.names)) {
|
||||||
|
item.translations = item.translations || {};
|
||||||
|
for (const n of raw.names) {
|
||||||
|
const lang = n.language?.toLowerCase();
|
||||||
|
const val = this.nameValue(n);
|
||||||
|
if (lang && val) {
|
||||||
|
item.translations[lang] = item.translations[lang] || {};
|
||||||
|
item.translations[lang].name = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (raw.descriptions && Array.isArray(raw.descriptions)) {
|
||||||
|
item.translations = item.translations || {};
|
||||||
|
for (const d of raw.descriptions) {
|
||||||
|
const lang = d.language?.toLowerCase();
|
||||||
|
const val = d.value || d.valuue || '';
|
||||||
|
if (lang && val) {
|
||||||
|
item.translations[lang] = item.translations[lang] || {};
|
||||||
|
item.translations[lang].simpleDescription = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build imgs[] from photos[] if imgs is empty
|
||||||
|
if ((!item.imgs || item.imgs.length === 0) && raw.photos && Array.isArray(raw.photos)) {
|
||||||
|
item.imgs = raw.photos.map((p: any) => p.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map callbacks → comments if comments is missing
|
||||||
|
if ((!item.comments || item.comments.length === 0) && raw.callbacks && Array.isArray(raw.callbacks)) {
|
||||||
|
item.comments = raw.callbacks.map((c: any) => ({
|
||||||
|
id: c.userID || '',
|
||||||
|
text: c.content || '',
|
||||||
|
author: c.userID || '',
|
||||||
|
stars: c.rating,
|
||||||
|
createdAt: c.timestamp,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults
|
||||||
|
item.name = item.name || '';
|
||||||
|
item.price = item.price ?? 0;
|
||||||
|
item.discount = item.discount ?? 0;
|
||||||
|
item.quantity = item.quantity ?? 0;
|
||||||
|
item.currency = item.currency || 'RUB';
|
||||||
|
item.imgs = item.imgs || [];
|
||||||
|
item.tags = item.tags || [];
|
||||||
|
item.description = item.description || [];
|
||||||
|
item.simpleDescription = item.simpleDescription || '';
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize a category — merges names[] into translations, maps Go struct fields */
|
||||||
|
private normalizeCategory(raw: any): Category {
|
||||||
|
const cat: Category = { ...raw };
|
||||||
|
|
||||||
|
if (raw.names && Array.isArray(raw.names)) {
|
||||||
|
cat.translations = cat.translations || {};
|
||||||
|
for (const n of raw.names) {
|
||||||
|
const lang = n.language?.toLowerCase();
|
||||||
|
const val = this.nameValue(n);
|
||||||
|
if (lang && val) {
|
||||||
|
cat.translations[lang] = cat.translations[lang] || {};
|
||||||
|
cat.translations[lang].name = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map Go struct fields
|
||||||
|
if (raw.icon && !cat.img) cat.img = raw.icon;
|
||||||
|
if (raw.wideicon) cat.wideBanner = raw.wideicon;
|
||||||
|
if (raw.ItemsCount != null) cat.itemCount = raw.ItemsCount;
|
||||||
|
if (raw.CategoriesCount != null) cat.categoriesCount = raw.CategoriesCount;
|
||||||
|
|
||||||
|
return cat;
|
||||||
|
}
|
||||||
|
|
||||||
// Projects
|
// Projects
|
||||||
getProjects(): Observable<Project[]> {
|
getProjects(): Observable<Project[]> {
|
||||||
if (environment.useMockData) return this.mockService.getProjects();
|
if (environment.useMockData) return this.mockService.getProjects();
|
||||||
@@ -46,16 +160,18 @@ export class ApiService {
|
|||||||
// Categories
|
// Categories
|
||||||
getCategories(projectId: string): Observable<Category[]> {
|
getCategories(projectId: string): Observable<Category[]> {
|
||||||
if (environment.useMockData) return this.mockService.getCategories(projectId);
|
if (environment.useMockData) return this.mockService.getCategories(projectId);
|
||||||
return this.http.get<Category[]>(`${this.API_BASE}/projects/${projectId}/categories`).pipe(
|
return this.http.get<any[]>(`${this.API_BASE}/projects/${projectId}/categories`).pipe(
|
||||||
retry(2),
|
retry(2),
|
||||||
|
map(cats => cats.map(c => this.normalizeCategory(c))),
|
||||||
catchError(this.handleError)
|
catchError(this.handleError)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getCategory(categoryId: string): Observable<Category> {
|
getCategory(categoryId: string): Observable<Category> {
|
||||||
if (environment.useMockData) return this.mockService.getCategory(categoryId);
|
if (environment.useMockData) return this.mockService.getCategory(categoryId);
|
||||||
return this.http.get<Category>(`${this.API_BASE}/categories/${categoryId}`).pipe(
|
return this.http.get<any>(`${this.API_BASE}/categories/${categoryId}`).pipe(
|
||||||
retry(2),
|
retry(2),
|
||||||
|
map(c => this.normalizeCategory(c)),
|
||||||
catchError(this.handleError)
|
catchError(this.handleError)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -148,16 +264,21 @@ export class ApiService {
|
|||||||
params = params.set('tags', filters.tags.join(','));
|
params = params.set('tags', filters.tags.join(','));
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.http.get<ItemsListResponse>(`${this.API_BASE}/subcategories/${subcategoryId}/items`, { params }).pipe(
|
return this.http.get<any>(`${this.API_BASE}/subcategories/${subcategoryId}/items`, { params }).pipe(
|
||||||
retry(2),
|
retry(2),
|
||||||
|
map(resp => ({
|
||||||
|
...resp,
|
||||||
|
items: (resp.items || []).map((i: any) => this.normalizeItem(i))
|
||||||
|
})),
|
||||||
catchError(this.handleError)
|
catchError(this.handleError)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getItem(itemId: string): Observable<Item> {
|
getItem(itemId: string): Observable<Item> {
|
||||||
if (environment.useMockData) return this.mockService.getItem(itemId);
|
if (environment.useMockData) return this.mockService.getItem(itemId);
|
||||||
return this.http.get<Item>(`${this.API_BASE}/items/${itemId}`).pipe(
|
return this.http.get<any>(`${this.API_BASE}/items/${itemId}`).pipe(
|
||||||
retry(2),
|
retry(2),
|
||||||
|
map(raw => this.normalizeItem(raw)),
|
||||||
catchError(this.handleError)
|
catchError(this.handleError)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -192,6 +313,25 @@ export class ApiService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setProjectVisibility(categories: Category[], visible: boolean): Observable<void> {
|
||||||
|
const { categoryIds, subcategoryIds } = this.collectVisibilityTargets(categories);
|
||||||
|
const requests = [
|
||||||
|
...categoryIds.map(id => () => this.updateCategory(id, { visible })),
|
||||||
|
...subcategoryIds.map(id => () => this.updateSubcategory(id, { visible })),
|
||||||
|
...subcategoryIds.map(id => () => this.updateSubcategoryItemsVisibility(id, visible)),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!requests.length) {
|
||||||
|
return of(void 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return from(requests).pipe(
|
||||||
|
concatMap(request => request()),
|
||||||
|
toArray(),
|
||||||
|
map(() => void 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Image upload
|
// Image upload
|
||||||
uploadImage(file: File): Observable<{ url: string }> {
|
uploadImage(file: File): Observable<{ url: string }> {
|
||||||
if (environment.useMockData) return this.mockService.uploadImage(file);
|
if (environment.useMockData) return this.mockService.uploadImage(file);
|
||||||
@@ -239,6 +379,47 @@ export class ApiService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private updateSubcategoryItemsVisibility(subcategoryId: string, visible: boolean): Observable<void> {
|
||||||
|
return this.getAllSubcategoryItemIds(subcategoryId).pipe(
|
||||||
|
concatMap(itemIds => itemIds.length ? this.bulkUpdateItems(itemIds, { visible }) : of(void 0))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAllSubcategoryItemIds(subcategoryId: string): Observable<string[]> {
|
||||||
|
const pageSize = 100;
|
||||||
|
|
||||||
|
return this.getItems(subcategoryId, 1, pageSize).pipe(
|
||||||
|
expand(response =>
|
||||||
|
response.hasMore ? this.getItems(subcategoryId, response.page + 1, pageSize) : EMPTY
|
||||||
|
),
|
||||||
|
reduce((itemIds, response) => {
|
||||||
|
itemIds.push(...response.items.map(item => item.id));
|
||||||
|
return itemIds;
|
||||||
|
}, [] as string[])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectVisibilityTargets(categories: Category[]) {
|
||||||
|
const categoryIds = categories.map(category => category.id);
|
||||||
|
const subcategoryIds: string[] = [];
|
||||||
|
|
||||||
|
const visitSubcategories = (subcategories: Subcategory[]) => {
|
||||||
|
for (const subcategory of subcategories) {
|
||||||
|
subcategoryIds.push(subcategory.id);
|
||||||
|
|
||||||
|
if (subcategory.subcategories?.length) {
|
||||||
|
visitSubcategories(subcategory.subcategories);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const category of categories) {
|
||||||
|
visitSubcategories(category.subcategories || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { categoryIds, subcategoryIds };
|
||||||
|
}
|
||||||
|
|
||||||
private handleError = (error: any): Observable<never> => {
|
private handleError = (error: any): Observable<never> => {
|
||||||
let errorMessage = 'An unexpected error occurred';
|
let errorMessage = 'An unexpected error occurred';
|
||||||
|
|
||||||
|
|||||||
492
src/app/services/auth.service.ts
Normal file
492
src/app/services/auth.service.ts
Normal 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('')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export class ValidationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
validateCurrency(value: string): string | null {
|
validateCurrency(value: string): string | null {
|
||||||
const validCurrencies = ['USD', 'EUR', 'RUB', 'GBP', 'UAH'];
|
const validCurrencies = ['USD', 'EUR', 'RUB', 'GBP', 'UAH', 'AMD'];
|
||||||
if (!validCurrencies.includes(value)) {
|
if (!validCurrencies.includes(value)) {
|
||||||
return `Currency must be one of: ${validCurrencies.join(', ')}`;
|
return `Currency must be one of: ${validCurrencies.join(', ')}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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'
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user