fix(admin-auth): reuse exact same QR/session API and component for admin login
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:
sdarbinyan
2026-07-14 10:13:59 +04:00
parent 3877b70fdf
commit 6aec2ebcb2
17 changed files with 316 additions and 789 deletions

View File

@@ -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

View File

@@ -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" />
} }

View File

@@ -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'
}) })

View File

@@ -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 {

View File

@@ -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';
}
} }

View File

@@ -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>
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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',

View File

@@ -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: 'Ընտրյալներ',

View File

@@ -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: 'Избранное',

View File

@@ -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;

View File

@@ -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';

View File

@@ -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';
}
} }

View 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());
}
}

View File

@@ -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',

View File

@@ -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',