fix(admin-auth): reuse exact same QR/session API and component for admin login
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
- Removed invented adminAuthApiUrl endpoint and separate AdminLoginComponent.
Admin login now uses the exact same Telegram session backend
(TelegramSessionApiService, {authApiUrl}/users/sessions) and the exact
same TelegramLoginComponent (mode="customer" | "admin" input) as customer
login - only the storage (cookie/localStorage/signals) stays separate.
- Extracted the shared HTTP+normalization logic from AuthService into
TelegramSessionApiService so both AuthService and AdminAuthService call it
instead of duplicating request/parsing code.
- Documented the resulting backend gap in docs/Project-Editor.md: since the
session API has no concept of "admin", server-side role enforcement is
required when admin API calls are made - the frontend only decides where
to store the session, not whether the user is actually an admin.
This commit is contained in:
@@ -194,41 +194,69 @@ Static page HTML is edited via `MarketplaceHtmlEditorComponent`
|
|||||||
external dependency. It emits raw HTML on every change and never sanitizes —
|
external dependency. It emits raw HTML on every change and never sanitizes —
|
||||||
sanitization remains a storefront-render concern.
|
sanitization remains a storefront-render concern.
|
||||||
|
|
||||||
## QR Login Reuse (Sprint 18)
|
## QR Login Reuse (Sprint 18, corrected)
|
||||||
|
|
||||||
The Telegram QR-login flow (QR image, polling, expiry, "return from app"
|
There is exactly **one** Telegram QR/session backend
|
||||||
recovery via visibilitychange/focus/pageshow) was extracted from
|
(`{authApiUrl}/users/sessions`) and exactly **one** QR login component/UI.
|
||||||
`TelegramLoginComponent` into `shared/qr-login/qr-login.engine.ts`
|
Nothing about the QR flow is duplicated for admin:
|
||||||
(`QrLoginEngine<TSession>`) plus an adapter interface
|
|
||||||
(`shared/qr-login/qr-login.model.ts`, `QrLoginAdapter<TSession>`). The engine
|
|
||||||
is not a DI singleton - each login surface instantiates its own
|
|
||||||
`new QrLoginEngine(adapter)` and drives it from an `effect()` watching its own
|
|
||||||
dialog-visibility signal. `TelegramLoginComponent` was refactored onto this
|
|
||||||
engine with no behavior change. `AdminLoginComponent`
|
|
||||||
(`core/admin-auth/admin-login.component.ts`) reuses the same engine against a
|
|
||||||
separate adapter backed by `AdminAuthService`, so QR/polling/timeout logic is
|
|
||||||
not duplicated between customer and admin login.
|
|
||||||
|
|
||||||
## Admin Authentication (Sprint 18)
|
- `TelegramSessionApiService` (`services/telegram-session-api.service.ts`) is
|
||||||
|
the single place that calls `POST/GET/DELETE {authApiUrl}/users/sessions...`
|
||||||
|
and normalizes the response into `AuthSession`. It holds no state and
|
||||||
|
writes no cookies - it's a pure API wrapper.
|
||||||
|
- `QrLoginEngine<TSession>` (`shared/qr-login/qr-login.engine.ts`) is the
|
||||||
|
QR/polling/expiry/"return from Telegram app" state machine (extracted from
|
||||||
|
the original `TelegramLoginComponent`), driven by a small
|
||||||
|
`QrLoginAdapter<TSession>` (`shared/qr-login/qr-login.model.ts`).
|
||||||
|
- `TelegramLoginComponent` (`components/telegram-login/`) is **the same
|
||||||
|
component for both customer and admin login** - not two components. It
|
||||||
|
takes a `mode: 'customer' | 'admin'` input; `ngOnInit` picks
|
||||||
|
`AuthService` or `AdminAuthService` accordingly and builds the
|
||||||
|
`QrLoginAdapter` from whichever one, but the QR image, polling loop,
|
||||||
|
timeouts, and dialog markup are identical either way. Customer usage is
|
||||||
|
unchanged (`<app-telegram-login />` on the cart page, `mode` defaults to
|
||||||
|
`'customer'`); admin usage is `<app-telegram-login mode="admin" />`,
|
||||||
|
mounted once globally in `app.html`.
|
||||||
|
|
||||||
Admin authentication is completely separate from the customer/storefront
|
An earlier version of this sprint's work built a separate
|
||||||
session (`AuthService`), by design - one must never authenticate the other:
|
`AdminAuthService`/`AdminLoginComponent` pair that called its own
|
||||||
|
`adminAuthApiUrl` placeholder endpoint. That was wrong: there is no separate
|
||||||
|
admin backend, and inventing one client-side would have meant testing against
|
||||||
|
an endpoint that doesn't exist. It was replaced with the shared-API approach
|
||||||
|
described above.
|
||||||
|
|
||||||
|
## Admin Authentication (Sprint 18, corrected)
|
||||||
|
|
||||||
|
Only the **storage** is separate between customer and admin - the QR/session
|
||||||
|
API and UI component are shared (see above), by design, since one Telegram
|
||||||
|
QR/session backend serves both. What stays separate is everything needed so
|
||||||
|
that scanning the admin QR can never authenticate the customer session (or
|
||||||
|
vice versa):
|
||||||
|
|
||||||
| | Customer (`AuthService`) | Admin (`AdminAuthService`, `core/admin-auth/`) |
|
| | Customer (`AuthService`) | Admin (`AdminAuthService`, `core/admin-auth/`) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Cookie | `webSessionID` | `adminSessionID` (`SameSite=Strict`) |
|
| Cookie | `webSessionID` (`SameSite=Lax`) | `adminSessionID` (`SameSite=Strict`) |
|
||||||
| Anonymous/local id | `web_session_id` (localStorage, API attribution only) | `adminToken` / `adminRefreshToken` (localStorage, reserved for future JWT pair) |
|
| Token storage | `web_session_id` (localStorage, anonymous API attribution only, unrelated to auth) | `adminToken` / `adminRefreshToken` (localStorage, reserved for a future JWT pair - unused today) |
|
||||||
| Signals | `session`, `status`, `showLoginDialog` on `AuthService` | `session`, `status`, `showLoginDialog`, `role` on `AdminAuthService` |
|
| Signals | `session`, `status`, `showLoginDialog` on `AuthService` | `session`, `status`, `showLoginDialog` on `AdminAuthService` |
|
||||||
| Guard | none yet for customer routes | `adminAuthGuard` (`core/admin-auth/admin-auth.guard.ts`) |
|
| Guard | none yet for customer routes | `adminAuthGuard` (`core/admin-auth/admin-auth.guard.ts`) |
|
||||||
| Interceptor | `apiHeadersInterceptor` | `adminAuthHeadersInterceptor` (`core/admin-auth/admin-auth-headers.interceptor.ts`), self-guards on `/admin/` in the URL, sets `AdminWebSessionID` + `Authorization: Bearer <adminToken>` when present |
|
| Interceptor | `apiHeadersInterceptor` | `adminAuthHeadersInterceptor` (`core/admin-auth/admin-auth-headers.interceptor.ts`), self-guards on `/admin/` in the request URL, sets `AdminWebSessionID` + `Authorization: Bearer <adminToken>` when present |
|
||||||
| Login UI | `TelegramLoginComponent` (mounted per-page, e.g. cart) | `AdminLoginComponent` (mounted once, globally, in `app.html`) |
|
| Session/QR API | `TelegramSessionApiService` | same `TelegramSessionApiService` instance/endpoint |
|
||||||
|
| Login UI | `TelegramLoginComponent` (`mode="customer"`, default) | same `TelegramLoginComponent` (`mode="admin"`) |
|
||||||
|
|
||||||
**Backend gap:** `environment.adminAuthApiUrl` (`https://users.vitanova.network:456/admin`)
|
**Backend gap this creates, and why it matters:** because admin login goes
|
||||||
is a placeholder path under the existing auth host - there is no real admin
|
through the exact same Telegram session API as customer login, the backend
|
||||||
session/login backend yet. `AdminAuthService.createWebSession()` /
|
has **no concept of "this is an admin session"** at the point the QR is
|
||||||
`checkSessionOnce()` / `logout()` call `POST|GET|DELETE {adminAuthApiUrl}/sessions...`
|
scanned - it's just a regular Telegram user session, identical in shape to a
|
||||||
following the same shape as the customer session API; confirm/repoint this
|
customer's. The frontend only decides *where to store* the resulting session
|
||||||
once the backend ships dedicated admin endpoints.
|
id (admin cookie vs. customer cookie); it cannot and does not decide whether
|
||||||
|
that Telegram user is actually allowed to act as an admin. **Real admin
|
||||||
|
authorization must be enforced server-side**, at the point admin API calls
|
||||||
|
are made with the `AdminWebSessionID` header - the backend must check the
|
||||||
|
authenticated user against an admin/role list and reject non-admins, since
|
||||||
|
nothing on the frontend prevents any Telegram user from completing the QR
|
||||||
|
flow while `mode="admin"` is showing. This needs a backend decision (role
|
||||||
|
check keyed off the session id, or a dedicated admin-scoped token issuance)
|
||||||
|
before admin login can be considered secure, not just "separate storage."
|
||||||
|
|
||||||
### Login test mode
|
### Login test mode
|
||||||
|
|
||||||
@@ -237,10 +265,10 @@ once the backend ships dedicated admin endpoints.
|
|||||||
`AuthService.requestLogin()` / `AdminAuthService.requestLogin()` respectively,
|
`AuthService.requestLogin()` / `AdminAuthService.requestLogin()` respectively,
|
||||||
for manual testing. This only sets the same signal a normal "please log in"
|
for manual testing. This only sets the same signal a normal "please log in"
|
||||||
action would set - it does not bypass authentication or change any other
|
action would set - it does not bypass authentication or change any other
|
||||||
behavior, so it is safe in all environments. Note `TelegramLoginComponent` is
|
behavior, so it is safe in all environments. `TelegramLoginComponent` in
|
||||||
currently mounted only on the cart page, so `?login=true` only shows a dialog
|
customer mode is currently mounted only on the cart page, so `?login=true`
|
||||||
there; `AdminLoginComponent` is mounted globally so `?adminLogin=true` works
|
only shows a dialog there; the admin-mode instance is mounted globally so
|
||||||
from any route.
|
`?adminLogin=true` works from any route.
|
||||||
|
|
||||||
### Ed25519 prep
|
### Ed25519 prep
|
||||||
|
|
||||||
|
|||||||
@@ -25,5 +25,5 @@
|
|||||||
<div class="footer-placeholder" aria-hidden="true"></div>
|
<div class="footer-placeholder" aria-hidden="true"></div>
|
||||||
}
|
}
|
||||||
<!-- <app-telegram-login /> -->
|
<!-- <app-telegram-login /> -->
|
||||||
<app-admin-login />
|
<app-telegram-login mode="admin" />
|
||||||
}
|
}
|
||||||
@@ -15,13 +15,13 @@ import { PlatformRuntimeService } from './core/runtime/platform-runtime.service'
|
|||||||
import { UiRuntimeFacade } from './facades/runtime/ui-runtime.facade';
|
import { UiRuntimeFacade } from './facades/runtime/ui-runtime.facade';
|
||||||
import { ApiHealthService } from './services/api-health.service';
|
import { ApiHealthService } from './services/api-health.service';
|
||||||
import { FloatingNotificationsComponent } from './features/website/user-experience/components/floating-notifications/floating-notifications.component';
|
import { FloatingNotificationsComponent } from './features/website/user-experience/components/floating-notifications/floating-notifications.component';
|
||||||
import { AdminLoginComponent } from './core/admin-auth/admin-login.component';
|
|
||||||
import { AdminAuthService } from './core/admin-auth/admin-auth.service';
|
import { AdminAuthService } from './core/admin-auth/admin-auth.service';
|
||||||
import { AuthService } from './services/auth.service';
|
import { AuthService } from './services/auth.service';
|
||||||
|
import { TelegramLoginComponent } from './components/telegram-login/telegram-login.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TranslatePipe, FloatingNotificationsComponent, AdminLoginComponent],
|
imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TranslatePipe, FloatingNotificationsComponent, TelegramLoginComponent],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss'
|
styleUrl: './app.scss'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { Component, ChangeDetectionStrategy, inject, effect, OnDestroy } from '@angular/core';
|
import { Component, ChangeDetectionStrategy, Input, Injector, Signal, inject, effect, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { AuthService } from '../../services/auth.service';
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { AdminAuthService } from '../../core/admin-auth/admin-auth.service';
|
||||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||||
import { QrLoginEngine } from '../../shared/qr-login/qr-login.engine';
|
import { QrLoginEngine } from '../../shared/qr-login/qr-login.engine';
|
||||||
import { QrLoginAdapter } from '../../shared/qr-login/qr-login.model';
|
import { QrLoginAdapter, QrLoginStatus } from '../../shared/qr-login/qr-login.model';
|
||||||
import { AuthSession } from '../../models/auth.model';
|
import { AuthSession } from '../../models/auth.model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one QR-login dialog, reused as-is for both customer and admin login.
|
||||||
|
* `mode` only decides which session service/storage backs it (AuthService's
|
||||||
|
* customer session vs AdminAuthService's admin session) - the QR creation,
|
||||||
|
* polling, expiry, and "return from Telegram app" logic (QrLoginEngine) and
|
||||||
|
* the API call underneath it (TelegramSessionApiService) are identical for
|
||||||
|
* both, by design: there is one Telegram QR/session backend, not two.
|
||||||
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-telegram-login',
|
selector: 'app-telegram-login',
|
||||||
imports: [TranslatePipe],
|
imports: [TranslatePipe],
|
||||||
@@ -12,38 +21,60 @@ import { AuthSession } from '../../models/auth.model';
|
|||||||
styleUrls: ['./telegram-login.component.scss'],
|
styleUrls: ['./telegram-login.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class TelegramLoginComponent implements OnDestroy {
|
export class TelegramLoginComponent implements OnInit, OnDestroy {
|
||||||
private authService = inject(AuthService);
|
@Input() mode: 'customer' | 'admin' = 'customer';
|
||||||
|
|
||||||
showDialog = this.authService.showLoginDialog;
|
private readonly customerAuth = inject(AuthService);
|
||||||
status = this.authService.status;
|
private readonly adminAuth = inject(AdminAuthService);
|
||||||
|
private readonly injector = inject(Injector);
|
||||||
|
|
||||||
private readonly adapter: QrLoginAdapter<AuthSession> = {
|
private engine!: QrLoginEngine<AuthSession>;
|
||||||
createSession: () => this.authService.createWebSession(),
|
|
||||||
checkSessionOnce: webSessionID => this.authService.checkSessionOnce(webSessionID),
|
|
||||||
isSessionActive: session => !!session?.active,
|
|
||||||
getAppLoginUrl: webSessionID => this.authService.getTelegramAppLoginUrl(webSessionID),
|
|
||||||
onLoginComplete: () => this.authService.onTelegramLoginComplete(),
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly engine = new QrLoginEngine<AuthSession>(this.adapter);
|
showDialog = this.customerAuth.showLoginDialog;
|
||||||
readonly loginUrl = this.engine.loginUrl;
|
status = this.customerAuth.status;
|
||||||
readonly webSessionID = this.engine.webSessionID;
|
|
||||||
readonly qrStatus = this.engine.qrStatus;
|
|
||||||
readonly encodedQrUrl = this.engine.encodedQrUrl;
|
|
||||||
readonly awaitingTelegramReturn = this.engine.awaitingAppReturn;
|
|
||||||
|
|
||||||
constructor() {
|
loginUrl!: Signal<string>;
|
||||||
effect(() => this.engine.setActive(this.showDialog()));
|
webSessionID!: Signal<string>;
|
||||||
|
qrStatus!: Signal<QrLoginStatus>;
|
||||||
|
encodedQrUrl!: Signal<string>;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const service = this.mode === 'admin' ? this.adminAuth : this.customerAuth;
|
||||||
|
this.showDialog = service.showLoginDialog;
|
||||||
|
this.status = service.status;
|
||||||
|
|
||||||
|
const adapter: QrLoginAdapter<AuthSession> = this.mode === 'admin'
|
||||||
|
? {
|
||||||
|
createSession: () => this.adminAuth.createWebSession(),
|
||||||
|
checkSessionOnce: id => this.adminAuth.checkSessionOnce(id),
|
||||||
|
isSessionActive: session => !!session?.active,
|
||||||
|
getAppLoginUrl: id => this.adminAuth.getAdminAppLoginUrl(id),
|
||||||
|
onLoginComplete: () => this.adminAuth.onLoginComplete(),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
createSession: () => this.customerAuth.createWebSession(),
|
||||||
|
checkSessionOnce: id => this.customerAuth.checkSessionOnce(id),
|
||||||
|
isSessionActive: session => !!session?.active,
|
||||||
|
getAppLoginUrl: id => this.customerAuth.getTelegramAppLoginUrl(id),
|
||||||
|
onLoginComplete: () => this.customerAuth.onTelegramLoginComplete(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.engine = new QrLoginEngine<AuthSession>(adapter);
|
||||||
|
this.loginUrl = this.engine.loginUrl;
|
||||||
|
this.webSessionID = this.engine.webSessionID;
|
||||||
|
this.qrStatus = this.engine.qrStatus;
|
||||||
|
this.encodedQrUrl = this.engine.encodedQrUrl;
|
||||||
|
|
||||||
|
effect(() => this.engine.setActive(this.showDialog()), { injector: this.injector });
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.engine.destroy();
|
this.engine?.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
this.engine.setActive(false);
|
this.engine.setActive(false);
|
||||||
this.authService.hideLogin();
|
(this.mode === 'admin' ? this.adminAuth : this.customerAuth).hideLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
openTelegramLogin(): void {
|
openTelegramLogin(): void {
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
import { Injectable, signal, computed } from '@angular/core';
|
import { Injectable, signal, computed, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { Observable, tap } from 'rxjs';
|
||||||
import { Observable, of, catchError, map, tap } from 'rxjs';
|
import { AdminAuthStatus } from '../../models/admin-auth.model';
|
||||||
import { AdminAuthStatus, AdminSession, AdminWebSessionStart } from '../../models/admin-auth.model';
|
import { AuthSession, WebSessionStart } from '../../models/auth.model';
|
||||||
import { environment } from '../../../environments/environment';
|
import { TelegramSessionApiService } from '../../services/telegram-session-api.service';
|
||||||
import { generateGuid } from '../../shared/util/guid.util';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin session storage is completely separate from customer session storage
|
* Admin login uses the exact same Telegram QR/session API as the customer
|
||||||
* (AuthService uses cookie `webSessionID` + localStorage `web_session_id`).
|
* login (TelegramSessionApiService, `{authApiUrl}/users/sessions`) - there is
|
||||||
* Distinct cookie/localStorage names here are intentional: an admin login must
|
* no separate admin backend endpoint, and none should be invented client-side.
|
||||||
* never authenticate the customer session and vice versa.
|
* Only the *storage* is kept separate from AuthService, so an admin QR scan
|
||||||
|
* never authenticates the customer session or vice versa: distinct cookie
|
||||||
|
* name, distinct signals, distinct guard/interceptor.
|
||||||
|
*
|
||||||
|
* Backend gap this creates (see docs/Project-Editor.md): since the session
|
||||||
|
* API itself has no concept of "admin", the frontend cannot tell an admin
|
||||||
|
* Telegram session from a regular one. Actual admin authorization must be
|
||||||
|
* enforced server-side when admin API calls are made with the resulting
|
||||||
|
* session id - the frontend only decides where to *store* the result.
|
||||||
*/
|
*/
|
||||||
const ADMIN_SESSION_COOKIE = 'adminSessionID';
|
const ADMIN_SESSION_COOKIE = 'adminSessionID';
|
||||||
const ADMIN_TOKEN_STORAGE_KEY = 'adminToken';
|
const ADMIN_TOKEN_STORAGE_KEY = 'adminToken';
|
||||||
@@ -18,7 +25,9 @@ const ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60;
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AdminAuthService {
|
export class AdminAuthService {
|
||||||
private readonly sessionSignal = signal<AdminSession | null>(null);
|
private readonly api = inject(TelegramSessionApiService);
|
||||||
|
|
||||||
|
private readonly sessionSignal = signal<AuthSession | null>(null);
|
||||||
private readonly statusSignal = signal<AdminAuthStatus>('unknown');
|
private readonly statusSignal = signal<AdminAuthStatus>('unknown');
|
||||||
private readonly showLoginSignal = signal(false);
|
private readonly showLoginSignal = signal(false);
|
||||||
|
|
||||||
@@ -27,13 +36,10 @@ export class AdminAuthService {
|
|||||||
readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated');
|
readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated');
|
||||||
readonly showLoginDialog = this.showLoginSignal.asReadonly();
|
readonly showLoginDialog = this.showLoginSignal.asReadonly();
|
||||||
readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null);
|
readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null);
|
||||||
readonly role = computed(() => this.sessionSignal()?.role ?? null);
|
|
||||||
|
|
||||||
private readonly adminAuthApiUrl = (environment as Record<string, unknown>)['adminAuthApiUrl'] as string
|
|
||||||
?? `${environment.authApiUrl}/admin`;
|
|
||||||
private sessionCheckTimer?: ReturnType<typeof setTimeout>;
|
private sessionCheckTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
constructor(private readonly http: HttpClient) {
|
constructor() {
|
||||||
this.checkSession();
|
this.checkSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,52 +58,24 @@ export class AdminAuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check session without mutating internal state (used for polling). */
|
/** Check session without mutating internal state beyond activating on success (used for polling). */
|
||||||
checkSessionOnce(webSessionID = this.getStoredAdminSessionID()): Observable<AdminSession | null> {
|
checkSessionOnce(webSessionID = this.getStoredAdminSessionID()): Observable<AuthSession | null> {
|
||||||
if (!webSessionID) {
|
return this.api.checkSessionOnce(webSessionID).pipe(
|
||||||
return of(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.http.get<Record<string, unknown>>(
|
|
||||||
`${this.adminAuthApiUrl}/sessions/${encodeURIComponent(webSessionID)}`
|
|
||||||
).pipe(
|
|
||||||
map(response => this.normalizeSession(response, webSessionID)),
|
|
||||||
tap(session => {
|
tap(session => {
|
||||||
if (session?.active) {
|
if (session?.active) {
|
||||||
this.activateSession(session);
|
this.activateSession(session);
|
||||||
}
|
}
|
||||||
}),
|
|
||||||
catchError(() => of(null))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Create a backend admin web session, to be scanned/opened the same way customer QR login works. */
|
|
||||||
createWebSession(): Observable<AdminWebSessionStart> {
|
|
||||||
const webSessionID = generateGuid();
|
|
||||||
|
|
||||||
return this.http.post<Record<string, unknown>>(
|
|
||||||
`${this.adminAuthApiUrl}/sessions`,
|
|
||||||
{ webSessionID },
|
|
||||||
{ headers: { AdminWebSessionID: webSessionID } }
|
|
||||||
).pipe(
|
|
||||||
map(response => {
|
|
||||||
const responseWebSessionID = this.extractSessionId(response, webSessionID);
|
|
||||||
return {
|
|
||||||
webSessionID: responseWebSessionID,
|
|
||||||
url: this.getAdminLoginUrl(responseWebSessionID),
|
|
||||||
};
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getAdminLoginUrl(webSessionID: string): string {
|
/** Create a backend web session - identical call to the customer login (TelegramSessionApiService.createSession). */
|
||||||
const botUsername = this.getAdminBotUsername();
|
createWebSession(): Observable<WebSessionStart> {
|
||||||
return `https://t.me/${botUsername}?start=admin_${encodeURIComponent(webSessionID)}`;
|
return this.api.createSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
getAdminAppLoginUrl(webSessionID: string): string {
|
getAdminAppLoginUrl(webSessionID: string): string {
|
||||||
const botUsername = this.getAdminBotUsername();
|
return this.api.getBotAppLoginUrl(webSessionID);
|
||||||
return `tg://resolve?domain=${encodeURIComponent(botUsername)}&start=admin_${encodeURIComponent(webSessionID)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoginComplete(): void {
|
onLoginComplete(): void {
|
||||||
@@ -122,9 +100,7 @@ export class AdminAuthService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.http.delete(`${this.adminAuthApiUrl}/sessions/${encodeURIComponent(webSessionID)}`, {
|
this.api.logout(webSessionID).subscribe(() => this.clearAuthState('unauthenticated'));
|
||||||
headers: { AdminWebSessionID: webSessionID }
|
|
||||||
}).pipe(catchError(() => of(null))).subscribe(() => this.clearAuthState('unauthenticated'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** JWT pair storage, reserved for once the backend issues admin access/refresh tokens. Unused until then. */
|
/** JWT pair storage, reserved for once the backend issues admin access/refresh tokens. Unused until then. */
|
||||||
@@ -148,7 +124,7 @@ export class AdminAuthService {
|
|||||||
localStorage.removeItem(ADMIN_REFRESH_STORAGE_KEY);
|
localStorage.removeItem(ADMIN_REFRESH_STORAGE_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
private activateSession(session: AdminSession): void {
|
private activateSession(session: AuthSession): void {
|
||||||
this.sessionSignal.set(session);
|
this.sessionSignal.set(session);
|
||||||
this.statusSignal.set('authenticated');
|
this.statusSignal.set('authenticated');
|
||||||
this.setStoredAdminSessionID(session.sessionId);
|
this.setStoredAdminSessionID(session.sessionId);
|
||||||
@@ -181,73 +157,6 @@ export class AdminAuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeSession(response: Record<string, unknown> | null, fallbackSessionId: string): AdminSession | null {
|
|
||||||
if (!response) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = this.readFirst(response, ['status', 'Status', 'active', 'Active', 'authenticated', 'Authenticated']);
|
|
||||||
const active = this.isActiveStatus(status);
|
|
||||||
const sessionId = this.extractSessionId(response, fallbackSessionId);
|
|
||||||
const username = this.readString(this.readFirst(response, ['username', 'Username']));
|
|
||||||
const displayName = this.readString(this.readFirst(response, ['displayName', 'DisplayName', 'name', 'Name'])) ?? username ?? 'Admin';
|
|
||||||
const role = this.readString(this.readFirst(response, ['role', 'Role']));
|
|
||||||
const adminId = this.readNumber(this.readFirst(response, ['adminId', 'AdminId', 'userId', 'UserId', 'id', 'ID']));
|
|
||||||
const expiresAt = this.readString(this.readFirst(response, ['expiresAt', 'ExpiresAt', 'expires', 'Expires']))
|
|
||||||
?? new Date(Date.now() + ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS * 1000).toISOString();
|
|
||||||
|
|
||||||
return { sessionId, adminId, username, displayName, role, active, expires: expiresAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractSessionId(response: Record<string, unknown> | null, fallbackSessionId: string): string {
|
|
||||||
if (!response) {
|
|
||||||
return fallbackSessionId;
|
|
||||||
}
|
|
||||||
return this.readString(this.readFirst(response, [
|
|
||||||
'webSessionID', 'WebSessionID', 'sessionID', 'SessionID', 'sessionId', 'id', 'ID'
|
|
||||||
])) ?? fallbackSessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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 getStoredAdminSessionID(): string | null {
|
private getStoredAdminSessionID(): string | null {
|
||||||
if (typeof document === 'undefined') {
|
if (typeof document === 'undefined') {
|
||||||
return null;
|
return null;
|
||||||
@@ -277,10 +186,4 @@ export class AdminAuthService {
|
|||||||
}
|
}
|
||||||
document.cookie = `${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; SameSite=Strict`;
|
document.cookie = `${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; SameSite=Strict`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getAdminBotUsername(): string {
|
|
||||||
return (environment as Record<string, unknown>)['adminTelegramBot'] as string
|
|
||||||
?? (environment as Record<string, unknown>)['telegramBot'] as string
|
|
||||||
?? 'DexarSupport_bot';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
@if (showDialog()) {
|
|
||||||
<div class="login-overlay" (click)="close()">
|
|
||||||
<div class="login-dialog" (click)="$event.stopPropagation()">
|
|
||||||
<button class="close-btn" (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"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="login-icon">
|
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
||||||
<path d="M12 2 3 6v6c0 5 4 8.7 9 10 5-1.3 9-5 9-10V6l-9-4z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>{{ 'adminAuth.loginRequired' | translate }}</h2>
|
|
||||||
<p class="login-desc">{{ 'adminAuth.loginDescription' | translate }}</p>
|
|
||||||
|
|
||||||
@if (status() === 'checking') {
|
|
||||||
<div class="login-status checking">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
<span>{{ 'adminAuth.checking' | translate }}</span>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<button class="telegram-btn" (click)="openAppLogin()">
|
|
||||||
{{ 'adminAuth.loginWithApp' | translate }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="qr-section">
|
|
||||||
<p class="qr-hint">{{ 'adminAuth.orScanQr' | translate }}</p>
|
|
||||||
|
|
||||||
@switch (qrStatus()) {
|
|
||||||
@case ('loading') {
|
|
||||||
<div class="qr-container qr-loading">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@case ('ready') {
|
|
||||||
<div class="qr-container">
|
|
||||||
<img [src]="'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + encodedQrUrl()"
|
|
||||||
alt="QR Code"
|
|
||||||
width="180"
|
|
||||||
height="180"
|
|
||||||
loading="eager" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@case ('expired') {
|
|
||||||
<div class="qr-container qr-expired" (click)="refreshQr()">
|
|
||||||
<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 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"/>
|
|
||||||
</svg>
|
|
||||||
<span>{{ 'adminAuth.qrExpired' | translate }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@case ('error') {
|
|
||||||
<div class="qr-container qr-error" (click)="refreshQr()">
|
|
||||||
<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 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"/>
|
|
||||||
</svg>
|
|
||||||
<span>{{ 'adminAuth.qrError' | translate }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="login-note">{{ 'adminAuth.loginNote' | translate }}</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
.login-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 10000;
|
|
||||||
animation: fadeIn 0.2s ease;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-dialog {
|
|
||||||
position: relative;
|
|
||||||
background: var(--bg-card, #fff);
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 32px 28px;
|
|
||||||
max-width: 400px;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
|
||||||
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, #f0f0f0);
|
|
||||||
color: var(--text-secondary, #666);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #e0e0e0;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-icon {
|
|
||||||
margin: 0 auto 16px;
|
|
||||||
width: 72px;
|
|
||||||
height: 72px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--accent-light, rgba(73, 118, 113, 0.1));
|
|
||||||
color: var(--accent-color, #497671);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary, #1a1a1a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-desc {
|
|
||||||
margin: 0 0 24px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-secondary, #666);
|
|
||||||
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: #2AABEE;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #229ED9;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(42, 171, 238, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tg-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bot-link {
|
|
||||||
display: block;
|
|
||||||
margin-top: 10px;
|
|
||||||
color: var(--accent-color, #497671);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.35;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-section {
|
|
||||||
margin-top: 20px;
|
|
||||||
|
|
||||||
.qr-hint {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary, #999);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-container {
|
|
||||||
display: inline-flex;
|
|
||||||
padding: 12px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid #e8e8e8;
|
|
||||||
|
|
||||||
img {
|
|
||||||
display: block;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.qr-loading {
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 204px;
|
|
||||||
height: 204px;
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: 3px solid #e0e0e0;
|
|
||||||
border-top-color: var(--accent-color, #497671);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.qr-expired {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: 204px;
|
|
||||||
height: 204px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-secondary, #999);
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--accent-color, #497671);
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.qr-error {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: 204px;
|
|
||||||
height: 204px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-secondary, #999);
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--accent-color, #497671);
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-note {
|
|
||||||
margin: 16px 0 0;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary, #999);
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-status {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 16px;
|
|
||||||
color: var(--text-secondary, #666);
|
|
||||||
font-size: 14px;
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border: 2px solid #e0e0e0;
|
|
||||||
border-top-color: var(--accent-color, #497671);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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-section .qr-container img {
|
|
||||||
width: 140px;
|
|
||||||
height: 140px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { Component, ChangeDetectionStrategy, inject, effect, OnDestroy } from '@angular/core';
|
|
||||||
import { AdminAuthService } from './admin-auth.service';
|
|
||||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
|
||||||
import { QrLoginEngine } from '../../shared/qr-login/qr-login.engine';
|
|
||||||
import { QrLoginAdapter } from '../../shared/qr-login/qr-login.model';
|
|
||||||
import { AdminSession } from '../../models/admin-auth.model';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-admin-login',
|
|
||||||
standalone: true,
|
|
||||||
imports: [TranslatePipe],
|
|
||||||
templateUrl: './admin-login.component.html',
|
|
||||||
styleUrls: ['./admin-login.component.scss'],
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
|
||||||
})
|
|
||||||
export class AdminLoginComponent implements OnDestroy {
|
|
||||||
private readonly adminAuth = inject(AdminAuthService);
|
|
||||||
|
|
||||||
readonly showDialog = this.adminAuth.showLoginDialog;
|
|
||||||
readonly status = this.adminAuth.status;
|
|
||||||
|
|
||||||
private readonly adapter: QrLoginAdapter<AdminSession> = {
|
|
||||||
createSession: () => this.adminAuth.createWebSession(),
|
|
||||||
checkSessionOnce: webSessionID => this.adminAuth.checkSessionOnce(webSessionID),
|
|
||||||
isSessionActive: session => !!session?.active,
|
|
||||||
getAppLoginUrl: webSessionID => this.adminAuth.getAdminAppLoginUrl(webSessionID),
|
|
||||||
onLoginComplete: () => this.adminAuth.onLoginComplete(),
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly engine = new QrLoginEngine<AdminSession>(this.adapter);
|
|
||||||
readonly qrStatus = this.engine.qrStatus;
|
|
||||||
readonly encodedQrUrl = this.engine.encodedQrUrl;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
effect(() => this.engine.setActive(this.showDialog()));
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.engine.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
close(): void {
|
|
||||||
this.engine.setActive(false);
|
|
||||||
this.adminAuth.hideLogin();
|
|
||||||
}
|
|
||||||
|
|
||||||
openAppLogin(): void {
|
|
||||||
this.engine.openAppLogin();
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshQr(): void {
|
|
||||||
this.engine.refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -562,16 +562,6 @@ export const en: Translations = {
|
|||||||
qrExpired: 'QR code expired. Click to refresh',
|
qrExpired: 'QR code expired. Click to refresh',
|
||||||
qrError: 'Could not create login session. Click to retry',
|
qrError: 'Could not create login session. Click to retry',
|
||||||
},
|
},
|
||||||
adminAuth: {
|
|
||||||
loginRequired: 'Admin login required',
|
|
||||||
loginDescription: 'Log in with your admin account to continue. This is a separate session from the storefront login.',
|
|
||||||
checking: 'Checking...',
|
|
||||||
loginWithApp: 'Log in with app',
|
|
||||||
orScanQr: 'Or scan the QR code',
|
|
||||||
loginNote: 'You will be redirected back after login',
|
|
||||||
qrExpired: 'QR code expired. Click to refresh',
|
|
||||||
qrError: 'Could not create login session. Click to retry',
|
|
||||||
},
|
|
||||||
ux: {
|
ux: {
|
||||||
items: 'items',
|
items: 'items',
|
||||||
wishlistTitle: 'Wishlist',
|
wishlistTitle: 'Wishlist',
|
||||||
|
|||||||
@@ -562,16 +562,6 @@ export const hy: Translations = {
|
|||||||
qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար',
|
qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար',
|
||||||
qrError: 'Չհաջողվեց ստեղծել մուտքի սեսիա։ Սեղմեք՝ կրկնելու համար',
|
qrError: 'Չհաջողվեց ստեղծել մուտքի սեսիա։ Սեղմեք՝ կրկնելու համար',
|
||||||
},
|
},
|
||||||
adminAuth: {
|
|
||||||
loginRequired: 'Անհրաժեշտ է ադմինի մուտք',
|
|
||||||
loginDescription: 'Մուտք գործեք ադմինի հաշվով։ Սա առանձին սեսիա է՝ խանութի մուտքից անկախ։',
|
|
||||||
checking: 'Ստուգում...',
|
|
||||||
loginWithApp: 'Մուտք հավելվածով',
|
|
||||||
orScanQr: 'Կամ սքանավորեք QR կոդը',
|
|
||||||
loginNote: 'Մուտքից հետո դուք կվերաուղղվեք',
|
|
||||||
qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար',
|
|
||||||
qrError: 'Չհաջողվեց ստեղծել մուտքի սեսիա։ Սեղմեք՝ կրկնելու համար',
|
|
||||||
},
|
|
||||||
ux: {
|
ux: {
|
||||||
items: 'ապրանք',
|
items: 'ապրանք',
|
||||||
wishlistTitle: 'Ընտրյալներ',
|
wishlistTitle: 'Ընտրյալներ',
|
||||||
|
|||||||
@@ -562,16 +562,6 @@ export const ru: Translations = {
|
|||||||
qrExpired: 'QR-код устарел. Нажмите, чтобы обновить',
|
qrExpired: 'QR-код устарел. Нажмите, чтобы обновить',
|
||||||
qrError: 'Не удалось создать сессию входа. Нажмите, чтобы повторить',
|
qrError: 'Не удалось создать сессию входа. Нажмите, чтобы повторить',
|
||||||
},
|
},
|
||||||
adminAuth: {
|
|
||||||
loginRequired: 'Требуется вход администратора',
|
|
||||||
loginDescription: 'Войдите под учётной записью администратора. Это отдельная сессия от входа покупателя.',
|
|
||||||
checking: 'Проверка...',
|
|
||||||
loginWithApp: 'Войти через приложение',
|
|
||||||
orScanQr: 'Или отсканируйте QR-код',
|
|
||||||
loginNote: 'После входа вы будете перенаправлены обратно',
|
|
||||||
qrExpired: 'QR-код устарел. Нажмите, чтобы обновить',
|
|
||||||
qrError: 'Не удалось создать сессию входа. Нажмите, чтобы повторить',
|
|
||||||
},
|
|
||||||
ux: {
|
ux: {
|
||||||
items: 'товаров',
|
items: 'товаров',
|
||||||
wishlistTitle: 'Избранное',
|
wishlistTitle: 'Избранное',
|
||||||
|
|||||||
@@ -560,16 +560,6 @@ export interface Translations {
|
|||||||
qrExpired: string;
|
qrExpired: string;
|
||||||
qrError: string;
|
qrError: string;
|
||||||
};
|
};
|
||||||
adminAuth: {
|
|
||||||
loginRequired: string;
|
|
||||||
loginDescription: string;
|
|
||||||
checking: string;
|
|
||||||
loginWithApp: string;
|
|
||||||
orScanQr: string;
|
|
||||||
loginNote: string;
|
|
||||||
qrExpired: string;
|
|
||||||
qrError: string;
|
|
||||||
};
|
|
||||||
ux: {
|
ux: {
|
||||||
items: string;
|
items: string;
|
||||||
wishlistTitle: string;
|
wishlistTitle: string;
|
||||||
|
|||||||
@@ -1,16 +1 @@
|
|||||||
export interface AdminSession {
|
|
||||||
sessionId: string;
|
|
||||||
adminId: number | null;
|
|
||||||
username: string | null;
|
|
||||||
displayName: string;
|
|
||||||
role: string | null;
|
|
||||||
active: boolean;
|
|
||||||
expires: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminWebSessionStart {
|
|
||||||
webSessionID: string;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminAuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';
|
export type AdminAuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { Injectable, signal, computed } from '@angular/core';
|
import { Injectable, signal, computed, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { Observable, tap } from 'rxjs';
|
||||||
import { Observable, of, catchError, map, tap } from 'rxjs';
|
|
||||||
import { AuthSession, AuthStatus, WebSessionStart } from '../models/auth.model';
|
import { AuthSession, AuthStatus, WebSessionStart } from '../models/auth.model';
|
||||||
import { environment } from '../../environments/environment';
|
import { TelegramSessionApiService } from './telegram-session-api.service';
|
||||||
import { generateGuid } from '../shared/util/guid.util';
|
|
||||||
|
|
||||||
const WEB_SESSION_COOKIE = 'webSessionID';
|
const WEB_SESSION_COOKIE = 'webSessionID';
|
||||||
const WEB_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60;
|
const WEB_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60;
|
||||||
@@ -12,6 +10,8 @@ const WEB_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60;
|
|||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
|
private readonly api = inject(TelegramSessionApiService);
|
||||||
|
|
||||||
private sessionSignal = signal<AuthSession | null>(null);
|
private sessionSignal = signal<AuthSession | null>(null);
|
||||||
private statusSignal = signal<AuthStatus>('unknown');
|
private statusSignal = signal<AuthStatus>('unknown');
|
||||||
private showLoginSignal = signal(false);
|
private showLoginSignal = signal(false);
|
||||||
@@ -27,10 +27,9 @@ export class AuthService {
|
|||||||
/** Display name of authenticated user */
|
/** Display name of authenticated user */
|
||||||
readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null);
|
readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null);
|
||||||
|
|
||||||
private readonly authApiUrl = environment.authApiUrl;
|
|
||||||
private sessionCheckTimer?: ReturnType<typeof setTimeout>;
|
private sessionCheckTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
constructor(private http: HttpClient) {
|
constructor() {
|
||||||
// On init, check existing session via cookie
|
// On init, check existing session via cookie
|
||||||
this.checkSession();
|
this.checkSession();
|
||||||
}
|
}
|
||||||
@@ -53,22 +52,14 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check session without updating internal state (for polling) */
|
/** Check session without updating internal state beyond activating on success (used for polling). */
|
||||||
checkSessionOnce(webSessionID = this.getStoredWebSessionID()): Observable<AuthSession | null> {
|
checkSessionOnce(webSessionID = this.getStoredWebSessionID()): Observable<AuthSession | null> {
|
||||||
if (!webSessionID) {
|
return this.api.checkSessionOnce(webSessionID).pipe(
|
||||||
return of(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.http.get<Record<string, unknown>>(
|
|
||||||
`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`
|
|
||||||
).pipe(
|
|
||||||
map(response => this.normalizeWebSession(response, webSessionID)),
|
|
||||||
tap(session => {
|
tap(session => {
|
||||||
if (session?.active) {
|
if (session?.active) {
|
||||||
this.activateSession(session);
|
this.activateSession(session);
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
catchError(() => of(null))
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,39 +75,18 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Generate the Telegram login URL for bot-based auth */
|
/** Generate the Telegram login URL for bot-based auth */
|
||||||
getTelegramLoginUrl(webSessionID = generateGuid()): string {
|
getTelegramLoginUrl(webSessionID: string): string {
|
||||||
const botUsername = this.getTelegramBotUsername();
|
return this.api.getBotLoginUrl(webSessionID);
|
||||||
return `https://t.me/${botUsername}?start=${encodeURIComponent(webSessionID)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generate a Telegram app deep link for mobile login without opening a browser tab. */
|
/** Generate a Telegram app deep link for mobile login without opening a browser tab. */
|
||||||
getTelegramAppLoginUrl(webSessionID: string): string {
|
getTelegramAppLoginUrl(webSessionID: string): string {
|
||||||
const botUsername = this.getTelegramBotUsername();
|
return this.api.getBotAppLoginUrl(webSessionID);
|
||||||
return `tg://resolve?domain=${encodeURIComponent(botUsername)}&start=${encodeURIComponent(webSessionID)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get QR code data URL for Telegram login */
|
|
||||||
getTelegramQrUrl(): string {
|
|
||||||
return this.getTelegramLoginUrl();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a backend web session and return the Telegram start link for it. */
|
/** Create a backend web session and return the Telegram start link for it. */
|
||||||
createWebSession(): Observable<WebSessionStart> {
|
createWebSession(): Observable<WebSessionStart> {
|
||||||
const webSessionID = generateGuid();
|
return this.api.createSession();
|
||||||
|
|
||||||
return this.http.post<Record<string, unknown>>(
|
|
||||||
`${this.authApiUrl}/users/sessions`,
|
|
||||||
{ webSessionID },
|
|
||||||
{ headers: { WebSessionID: webSessionID } }
|
|
||||||
).pipe(
|
|
||||||
map(response => {
|
|
||||||
const responseWebSessionID = this.extractSessionId(response, webSessionID);
|
|
||||||
return {
|
|
||||||
webSessionID: responseWebSessionID,
|
|
||||||
url: this.getTelegramLoginUrl(responseWebSessionID),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Show login dialog (called when user tries to pay without being logged in) */
|
/** Show login dialog (called when user tries to pay without being logged in) */
|
||||||
@@ -138,11 +108,7 @@ export class AuthService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.http.delete(`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`, {
|
this.api.logout(webSessionID).subscribe(() => {
|
||||||
headers: { WebSessionID: webSessionID }
|
|
||||||
}).pipe(
|
|
||||||
catchError(() => of(null))
|
|
||||||
).subscribe(() => {
|
|
||||||
this.clearAuthState('unauthenticated');
|
this.clearAuthState('unauthenticated');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -184,120 +150,6 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeWebSession(response: Record<string, unknown> | null, fallbackSessionId: string): AuthSession | null {
|
|
||||||
if (!response) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = this.asRecord(this.readFirst(response, ['user', 'User', 'telegramUser', 'TelegramUser'])) ?? response;
|
|
||||||
const status = this.readFirst(response, [
|
|
||||||
'status',
|
|
||||||
'Status',
|
|
||||||
'active',
|
|
||||||
'Active',
|
|
||||||
'loggedIn',
|
|
||||||
'LoggedIn',
|
|
||||||
'isLoggedIn',
|
|
||||||
'IsLoggedIn',
|
|
||||||
'authenticated',
|
|
||||||
'Authenticated'
|
|
||||||
]);
|
|
||||||
const active = this.isActiveStatus(status);
|
|
||||||
const sessionId = this.extractSessionId(response, fallbackSessionId);
|
|
||||||
const username = this.readString(this.readFirst(user, ['username', 'Username']))
|
|
||||||
?? this.readString(this.readFirst(response, ['username', 'Username']));
|
|
||||||
const firstName = this.readString(this.readFirst(user, ['firstName', 'first_name', 'FirstName', 'First_name']));
|
|
||||||
const lastName = this.readString(this.readFirst(user, ['lastName', 'last_name', 'LastName', 'Last_name']));
|
|
||||||
const fullName = [firstName, lastName].filter(Boolean).join(' ');
|
|
||||||
const explicitDisplayName = this.readString(this.readFirst(response, ['displayName', 'DisplayName', 'name', 'Name']))
|
|
||||||
?? this.readString(this.readFirst(user, ['displayName', 'DisplayName', 'name', 'Name']))
|
|
||||||
const displayName = explicitDisplayName ?? username ?? (fullName || 'Telegram User');
|
|
||||||
const telegramUserId = this.readNumber(this.readFirst(user, ['userId','telegramUserId', 'telegramUserID', 'TelegramUserID', 'id', 'ID']))
|
|
||||||
?? this.readNumber(this.readFirst(response, ['userId', 'telegramUserId', 'telegramUserID', 'TelegramUserID', 'userID', 'UserID', 'UserId']))
|
|
||||||
?? null;
|
|
||||||
const expiresAt = this.readString(this.readFirst(response, ['expiresAt', 'ExpiresAt', 'expires', 'Expires']))
|
|
||||||
?? new Date(Date.now() + WEB_SESSION_COOKIE_MAX_AGE_SECONDS * 1000).toISOString();
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId,
|
|
||||||
userId: telegramUserId,
|
|
||||||
username,
|
|
||||||
displayName,
|
|
||||||
active,
|
|
||||||
expires: expiresAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractSessionId(response: Record<string, unknown> | null, fallbackSessionId: string): string {
|
|
||||||
if (!response) {
|
|
||||||
return fallbackSessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.readString(this.readFirst(response, [
|
|
||||||
'webSessionID',
|
|
||||||
'WebSessionID',
|
|
||||||
'webSessionId',
|
|
||||||
'sessionID',
|
|
||||||
'SessionID',
|
|
||||||
'sessionId',
|
|
||||||
'id',
|
|
||||||
'ID'
|
|
||||||
])) ?? fallbackSessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 getStoredWebSessionID(): string | null {
|
private getStoredWebSessionID(): string | null {
|
||||||
if (typeof document === 'undefined') {
|
if (typeof document === 'undefined') {
|
||||||
return null;
|
return null;
|
||||||
@@ -334,8 +186,4 @@ export class AuthService {
|
|||||||
|
|
||||||
document.cookie = `${WEB_SESSION_COOKIE}=; Max-Age=0; Path=/; SameSite=Lax`;
|
document.cookie = `${WEB_SESSION_COOKIE}=; Max-Age=0; Path=/; SameSite=Lax`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getTelegramBotUsername(): string {
|
|
||||||
return (environment as Record<string, unknown>)['telegramBot'] as string || 'DexarSupport_bot';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
156
src/app/services/telegram-session-api.service.ts
Normal file
156
src/app/services/telegram-session-api.service.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable, of, catchError, map } from 'rxjs';
|
||||||
|
import { AuthSession, WebSessionStart } from '../models/auth.model';
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
import { generateGuid } from '../shared/util/guid.util';
|
||||||
|
|
||||||
|
const SESSION_MAX_AGE_SECONDS = 60 * 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one Telegram QR/session API (`{authApiUrl}/users/sessions`). Customer
|
||||||
|
* login (AuthService) and admin login (AdminAuthService) both call this same
|
||||||
|
* service against this same endpoint - there is no separate admin backend.
|
||||||
|
* This class only does the HTTP call + response normalization; it holds no
|
||||||
|
* session state and writes no cookies, so each caller manages its own
|
||||||
|
* storage/signals independently on top of it.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class TelegramSessionApiService {
|
||||||
|
private readonly authApiUrl = environment.authApiUrl;
|
||||||
|
|
||||||
|
constructor(private readonly http: HttpClient) {}
|
||||||
|
|
||||||
|
createSession(): Observable<WebSessionStart> {
|
||||||
|
const webSessionID = generateGuid();
|
||||||
|
|
||||||
|
return this.http.post<Record<string, unknown>>(
|
||||||
|
`${this.authApiUrl}/users/sessions`,
|
||||||
|
{ webSessionID },
|
||||||
|
{ headers: { WebSessionID: webSessionID } }
|
||||||
|
).pipe(
|
||||||
|
map(response => {
|
||||||
|
const responseWebSessionID = this.extractSessionId(response, webSessionID);
|
||||||
|
return {
|
||||||
|
webSessionID: responseWebSessionID,
|
||||||
|
url: this.getBotLoginUrl(responseWebSessionID),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkSessionOnce(webSessionID: string | null): Observable<AuthSession | null> {
|
||||||
|
if (!webSessionID) {
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.get<Record<string, unknown>>(
|
||||||
|
`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`
|
||||||
|
).pipe(
|
||||||
|
map(response => this.normalizeWebSession(response, webSessionID)),
|
||||||
|
catchError(() => of(null))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logout(webSessionID: string): Observable<unknown> {
|
||||||
|
return this.http.delete(`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`, {
|
||||||
|
headers: { WebSessionID: webSessionID }
|
||||||
|
}).pipe(catchError(() => of(null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
getBotLoginUrl(webSessionID: string): string {
|
||||||
|
return `https://t.me/${this.getBotUsername()}?start=${encodeURIComponent(webSessionID)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBotAppLoginUrl(webSessionID: string): string {
|
||||||
|
return `tg://resolve?domain=${encodeURIComponent(this.getBotUsername())}&start=${encodeURIComponent(webSessionID)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBotUsername(): string {
|
||||||
|
return (environment as Record<string, unknown>)['telegramBot'] as string || 'DexarSupport_bot';
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeWebSession(response: Record<string, unknown> | null, fallbackSessionId: string): AuthSession | null {
|
||||||
|
if (!response) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = this.asRecord(this.readFirst(response, ['user', 'User', 'telegramUser', 'TelegramUser'])) ?? response;
|
||||||
|
const status = this.readFirst(response, [
|
||||||
|
'status', 'Status', 'active', 'Active', 'loggedIn', 'LoggedIn',
|
||||||
|
'isLoggedIn', 'IsLoggedIn', 'authenticated', 'Authenticated'
|
||||||
|
]);
|
||||||
|
const active = this.isActiveStatus(status);
|
||||||
|
const sessionId = this.extractSessionId(response, fallbackSessionId);
|
||||||
|
const username = this.readString(this.readFirst(user, ['username', 'Username']))
|
||||||
|
?? this.readString(this.readFirst(response, ['username', 'Username']));
|
||||||
|
const firstName = this.readString(this.readFirst(user, ['firstName', 'first_name', 'FirstName', 'First_name']));
|
||||||
|
const lastName = this.readString(this.readFirst(user, ['lastName', 'last_name', 'LastName', 'Last_name']));
|
||||||
|
const fullName = [firstName, lastName].filter(Boolean).join(' ');
|
||||||
|
const explicitDisplayName = this.readString(this.readFirst(response, ['displayName', 'DisplayName', 'name', 'Name']))
|
||||||
|
?? this.readString(this.readFirst(user, ['displayName', 'DisplayName', 'name', 'Name']));
|
||||||
|
const displayName = explicitDisplayName ?? username ?? (fullName || 'Telegram User');
|
||||||
|
const telegramUserId = this.readNumber(this.readFirst(user, ['userId', 'telegramUserId', 'telegramUserID', 'TelegramUserID', 'id', 'ID']))
|
||||||
|
?? this.readNumber(this.readFirst(response, ['userId', 'telegramUserId', 'telegramUserID', 'TelegramUserID', 'userID', 'UserID', 'UserId']))
|
||||||
|
?? null;
|
||||||
|
const expiresAt = this.readString(this.readFirst(response, ['expiresAt', 'ExpiresAt', 'expires', 'Expires']))
|
||||||
|
?? new Date(Date.now() + SESSION_MAX_AGE_SECONDS * 1000).toISOString();
|
||||||
|
|
||||||
|
return { sessionId, userId: telegramUserId, username, displayName, active, expires: expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractSessionId(response: Record<string, unknown> | null, fallbackSessionId: string): string {
|
||||||
|
if (!response) {
|
||||||
|
return fallbackSessionId;
|
||||||
|
}
|
||||||
|
return this.readString(this.readFirst(response, [
|
||||||
|
'webSessionID', 'WebSessionID', 'webSessionId', 'sessionID', 'SessionID', 'sessionId', 'id', 'ID'
|
||||||
|
])) ?? fallbackSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,6 @@ export const environment = {
|
|||||||
theme: 'dexar',
|
theme: 'dexar',
|
||||||
apiUrl: 'https://api.dexarmarket.ru:445',
|
apiUrl: 'https://api.dexarmarket.ru:445',
|
||||||
authApiUrl: 'https://users.vitanova.network:456',
|
authApiUrl: 'https://users.vitanova.network:456',
|
||||||
// Placeholder until backend delivers dedicated admin auth endpoints - keep path-scoped under authApiUrl so it's easy to repoint.
|
|
||||||
adminAuthApiUrl: 'https://users.vitanova.network:456/admin',
|
|
||||||
qrApiUrl: 'https://qr.vitanova.network/api',
|
qrApiUrl: 'https://qr.vitanova.network/api',
|
||||||
logo: '/icons/icon-192x192.png',
|
logo: '/icons/icon-192x192.png',
|
||||||
contactEmail: 'info@dexarmarket.ru',
|
contactEmail: 'info@dexarmarket.ru',
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ export const environment = {
|
|||||||
theme: 'dexar',
|
theme: 'dexar',
|
||||||
apiUrl: '/api',
|
apiUrl: '/api',
|
||||||
authApiUrl: 'https://users.vitanova.network:456',
|
authApiUrl: 'https://users.vitanova.network:456',
|
||||||
// Placeholder until backend delivers dedicated admin auth endpoints - keep path-scoped under authApiUrl so it's easy to repoint.
|
|
||||||
adminAuthApiUrl: 'https://users.vitanova.network:456/admin',
|
|
||||||
qrApiUrl: 'https://qr.vitanova.network/api',
|
qrApiUrl: 'https://qr.vitanova.network/api',
|
||||||
logo: '/icons/icon-192x192.png',
|
logo: '/icons/icon-192x192.png',
|
||||||
contactEmail: 'info@dexarmarket.ru',
|
contactEmail: 'info@dexarmarket.ru',
|
||||||
|
|||||||
Reference in New Issue
Block a user