diff --git a/docs/Project-Editor.md b/docs/Project-Editor.md index 824f85e..d9c9ab9 100644 --- a/docs/Project-Editor.md +++ b/docs/Project-Editor.md @@ -194,41 +194,69 @@ Static page HTML is edited via `MarketplaceHtmlEditorComponent` external dependency. It emits raw HTML on every change and never sanitizes — 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" -recovery via visibilitychange/focus/pageshow) was extracted from -`TelegramLoginComponent` into `shared/qr-login/qr-login.engine.ts` -(`QrLoginEngine`) plus an adapter interface -(`shared/qr-login/qr-login.model.ts`, `QrLoginAdapter`). 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. +There is exactly **one** Telegram QR/session backend +(`{authApiUrl}/users/sessions`) and exactly **one** QR login component/UI. +Nothing about the QR flow is duplicated for admin: -## 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` (`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` (`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 (`` on the cart page, `mode` defaults to + `'customer'`); admin usage is ``, + mounted once globally in `app.html`. -Admin authentication is completely separate from the customer/storefront -session (`AuthService`), by design - one must never authenticate the other: +An earlier version of this sprint's work built a separate +`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/`) | |---|---|---| -| Cookie | `webSessionID` | `adminSessionID` (`SameSite=Strict`) | -| Anonymous/local id | `web_session_id` (localStorage, API attribution only) | `adminToken` / `adminRefreshToken` (localStorage, reserved for future JWT pair) | -| Signals | `session`, `status`, `showLoginDialog` on `AuthService` | `session`, `status`, `showLoginDialog`, `role` on `AdminAuthService` | +| Cookie | `webSessionID` (`SameSite=Lax`) | `adminSessionID` (`SameSite=Strict`) | +| 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` on `AdminAuthService` | | 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 ` when present | -| Login UI | `TelegramLoginComponent` (mounted per-page, e.g. cart) | `AdminLoginComponent` (mounted once, globally, in `app.html`) | +| Interceptor | `apiHeadersInterceptor` | `adminAuthHeadersInterceptor` (`core/admin-auth/admin-auth-headers.interceptor.ts`), self-guards on `/admin/` in the request URL, sets `AdminWebSessionID` + `Authorization: Bearer ` when present | +| 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`) -is a placeholder path under the existing auth host - there is no real admin -session/login backend yet. `AdminAuthService.createWebSession()` / -`checkSessionOnce()` / `logout()` call `POST|GET|DELETE {adminAuthApiUrl}/sessions...` -following the same shape as the customer session API; confirm/repoint this -once the backend ships dedicated admin endpoints. +**Backend gap this creates, and why it matters:** because admin login goes +through the exact same Telegram session API as customer login, the backend +has **no concept of "this is an admin session"** at the point the QR is +scanned - it's just a regular Telegram user session, identical in shape to a +customer's. The frontend only decides *where to store* the resulting session +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 @@ -237,10 +265,10 @@ once the backend ships dedicated admin endpoints. `AuthService.requestLogin()` / `AdminAuthService.requestLogin()` respectively, 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 -behavior, so it is safe in all environments. Note `TelegramLoginComponent` is -currently mounted only on the cart page, so `?login=true` only shows a dialog -there; `AdminLoginComponent` is mounted globally so `?adminLogin=true` works -from any route. +behavior, so it is safe in all environments. `TelegramLoginComponent` in +customer mode is currently mounted only on the cart page, so `?login=true` +only shows a dialog there; the admin-mode instance is mounted globally so +`?adminLogin=true` works from any route. ### Ed25519 prep diff --git a/src/app/app.html b/src/app/app.html index 91031eb..981a6e2 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -25,5 +25,5 @@ } - + } \ No newline at end of file diff --git a/src/app/app.ts b/src/app/app.ts index 79b8845..19032ee 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -15,13 +15,13 @@ import { PlatformRuntimeService } from './core/runtime/platform-runtime.service' import { UiRuntimeFacade } from './facades/runtime/ui-runtime.facade'; import { ApiHealthService } from './services/api-health.service'; 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 { AuthService } from './services/auth.service'; +import { TelegramLoginComponent } from './components/telegram-login/telegram-login.component'; @Component({ selector: 'app-root', - imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TranslatePipe, FloatingNotificationsComponent, AdminLoginComponent], + imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TranslatePipe, FloatingNotificationsComponent, TelegramLoginComponent], templateUrl: './app.html', styleUrl: './app.scss' }) diff --git a/src/app/components/telegram-login/telegram-login.component.ts b/src/app/components/telegram-login/telegram-login.component.ts index c4528f0..8d9ac51 100644 --- a/src/app/components/telegram-login/telegram-login.component.ts +++ b/src/app/components/telegram-login/telegram-login.component.ts @@ -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 { AdminAuthService } from '../../core/admin-auth/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 { QrLoginAdapter, QrLoginStatus } from '../../shared/qr-login/qr-login.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({ selector: 'app-telegram-login', imports: [TranslatePipe], @@ -12,38 +21,60 @@ import { AuthSession } from '../../models/auth.model'; styleUrls: ['./telegram-login.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class TelegramLoginComponent implements OnDestroy { - private authService = inject(AuthService); +export class TelegramLoginComponent implements OnInit, OnDestroy { + @Input() mode: 'customer' | 'admin' = 'customer'; - showDialog = this.authService.showLoginDialog; - status = this.authService.status; + private readonly customerAuth = inject(AuthService); + private readonly adminAuth = inject(AdminAuthService); + private readonly injector = inject(Injector); - private readonly adapter: QrLoginAdapter = { - 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 engine!: QrLoginEngine; - private readonly engine = new QrLoginEngine(this.adapter); - readonly loginUrl = this.engine.loginUrl; - readonly webSessionID = this.engine.webSessionID; - readonly qrStatus = this.engine.qrStatus; - readonly encodedQrUrl = this.engine.encodedQrUrl; - readonly awaitingTelegramReturn = this.engine.awaitingAppReturn; + showDialog = this.customerAuth.showLoginDialog; + status = this.customerAuth.status; - constructor() { - effect(() => this.engine.setActive(this.showDialog())); + loginUrl!: Signal; + webSessionID!: Signal; + qrStatus!: Signal; + encodedQrUrl!: Signal; + + ngOnInit(): void { + const service = this.mode === 'admin' ? this.adminAuth : this.customerAuth; + this.showDialog = service.showLoginDialog; + this.status = service.status; + + const adapter: QrLoginAdapter = 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(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 { - this.engine.destroy(); + this.engine?.destroy(); } close(): void { this.engine.setActive(false); - this.authService.hideLogin(); + (this.mode === 'admin' ? this.adminAuth : this.customerAuth).hideLogin(); } openTelegramLogin(): void { diff --git a/src/app/core/admin-auth/admin-auth.service.ts b/src/app/core/admin-auth/admin-auth.service.ts index e6a9207..170c837 100644 --- a/src/app/core/admin-auth/admin-auth.service.ts +++ b/src/app/core/admin-auth/admin-auth.service.ts @@ -1,15 +1,22 @@ -import { Injectable, signal, computed } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { Observable, of, catchError, map, tap } from 'rxjs'; -import { AdminAuthStatus, AdminSession, AdminWebSessionStart } from '../../models/admin-auth.model'; -import { environment } from '../../../environments/environment'; -import { generateGuid } from '../../shared/util/guid.util'; +import { Injectable, signal, computed, inject } from '@angular/core'; +import { Observable, tap } from 'rxjs'; +import { AdminAuthStatus } from '../../models/admin-auth.model'; +import { AuthSession, WebSessionStart } from '../../models/auth.model'; +import { TelegramSessionApiService } from '../../services/telegram-session-api.service'; /** - * Admin session storage is completely separate from customer session storage - * (AuthService uses cookie `webSessionID` + localStorage `web_session_id`). - * Distinct cookie/localStorage names here are intentional: an admin login must - * never authenticate the customer session and vice versa. + * Admin login uses the exact same Telegram QR/session API as the customer + * login (TelegramSessionApiService, `{authApiUrl}/users/sessions`) - there is + * no separate admin backend endpoint, and none should be invented client-side. + * 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_TOKEN_STORAGE_KEY = 'adminToken'; @@ -18,7 +25,9 @@ const ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60; @Injectable({ providedIn: 'root' }) export class AdminAuthService { - private readonly sessionSignal = signal(null); + private readonly api = inject(TelegramSessionApiService); + + private readonly sessionSignal = signal(null); private readonly statusSignal = signal('unknown'); private readonly showLoginSignal = signal(false); @@ -27,13 +36,10 @@ export class AdminAuthService { readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated'); readonly showLoginDialog = this.showLoginSignal.asReadonly(); readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null); - readonly role = computed(() => this.sessionSignal()?.role ?? null); - private readonly adminAuthApiUrl = (environment as Record)['adminAuthApiUrl'] as string - ?? `${environment.authApiUrl}/admin`; private sessionCheckTimer?: ReturnType; - constructor(private readonly http: HttpClient) { + constructor() { this.checkSession(); } @@ -52,52 +58,24 @@ export class AdminAuthService { }); } - /** Check session without mutating internal state (used for polling). */ - checkSessionOnce(webSessionID = this.getStoredAdminSessionID()): Observable { - if (!webSessionID) { - return of(null); - } - - return this.http.get>( - `${this.adminAuthApiUrl}/sessions/${encodeURIComponent(webSessionID)}` - ).pipe( - map(response => this.normalizeSession(response, webSessionID)), + /** Check session without mutating internal state beyond activating on success (used for polling). */ + checkSessionOnce(webSessionID = this.getStoredAdminSessionID()): Observable { + return this.api.checkSessionOnce(webSessionID).pipe( tap(session => { if (session?.active) { 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 { - const webSessionID = generateGuid(); - - return this.http.post>( - `${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 { - const botUsername = this.getAdminBotUsername(); - return `https://t.me/${botUsername}?start=admin_${encodeURIComponent(webSessionID)}`; + /** Create a backend web session - identical call to the customer login (TelegramSessionApiService.createSession). */ + createWebSession(): Observable { + return this.api.createSession(); } getAdminAppLoginUrl(webSessionID: string): string { - const botUsername = this.getAdminBotUsername(); - return `tg://resolve?domain=${encodeURIComponent(botUsername)}&start=admin_${encodeURIComponent(webSessionID)}`; + return this.api.getBotAppLoginUrl(webSessionID); } onLoginComplete(): void { @@ -122,9 +100,7 @@ export class AdminAuthService { return; } - this.http.delete(`${this.adminAuthApiUrl}/sessions/${encodeURIComponent(webSessionID)}`, { - headers: { AdminWebSessionID: webSessionID } - }).pipe(catchError(() => of(null))).subscribe(() => this.clearAuthState('unauthenticated')); + this.api.logout(webSessionID).subscribe(() => this.clearAuthState('unauthenticated')); } /** 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); } - private activateSession(session: AdminSession): void { + private activateSession(session: AuthSession): void { this.sessionSignal.set(session); this.statusSignal.set('authenticated'); this.setStoredAdminSessionID(session.sessionId); @@ -181,73 +157,6 @@ export class AdminAuthService { } } - private normalizeSession(response: Record | 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 | 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, 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 { if (typeof document === 'undefined') { return null; @@ -277,10 +186,4 @@ export class AdminAuthService { } document.cookie = `${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; SameSite=Strict`; } - - private getAdminBotUsername(): string { - return (environment as Record)['adminTelegramBot'] as string - ?? (environment as Record)['telegramBot'] as string - ?? 'DexarSupport_bot'; - } } diff --git a/src/app/core/admin-auth/admin-login.component.html b/src/app/core/admin-auth/admin-login.component.html deleted file mode 100644 index 2650ed8..0000000 --- a/src/app/core/admin-auth/admin-login.component.html +++ /dev/null @@ -1,72 +0,0 @@ -@if (showDialog()) { - -} diff --git a/src/app/core/admin-auth/admin-login.component.scss b/src/app/core/admin-auth/admin-login.component.scss deleted file mode 100644 index a1302ad..0000000 --- a/src/app/core/admin-auth/admin-login.component.scss +++ /dev/null @@ -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; - } -} diff --git a/src/app/core/admin-auth/admin-login.component.ts b/src/app/core/admin-auth/admin-login.component.ts deleted file mode 100644 index f49518a..0000000 --- a/src/app/core/admin-auth/admin-login.component.ts +++ /dev/null @@ -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 = { - 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(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(); - } -} diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index 32d14bb..c53b52b 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -562,16 +562,6 @@ export const en: Translations = { qrExpired: 'QR code expired. Click to refresh', 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: { items: 'items', wishlistTitle: 'Wishlist', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index cf362b3..0747ff4 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -562,16 +562,6 @@ export const hy: Translations = { qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար', qrError: 'Չհաջողվեց ստեղծել մուտքի սեսիա։ Սեղմեք՝ կրկնելու համար', }, - adminAuth: { - loginRequired: 'Անհրաժեշտ է ադմինի մուտք', - loginDescription: 'Մուտք գործեք ադմինի հաշվով։ Սա առանձին սեսիա է՝ խանութի մուտքից անկախ։', - checking: 'Ստուգում...', - loginWithApp: 'Մուտք հավելվածով', - orScanQr: 'Կամ սքանավորեք QR կոդը', - loginNote: 'Մուտքից հետո դուք կվերաուղղվեք', - qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար', - qrError: 'Չհաջողվեց ստեղծել մուտքի սեսիա։ Սեղմեք՝ կրկնելու համար', - }, ux: { items: 'ապրանք', wishlistTitle: 'Ընտրյալներ', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 1d063ef..32db53c 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -562,16 +562,6 @@ export const ru: Translations = { qrExpired: 'QR-код устарел. Нажмите, чтобы обновить', qrError: 'Не удалось создать сессию входа. Нажмите, чтобы повторить', }, - adminAuth: { - loginRequired: 'Требуется вход администратора', - loginDescription: 'Войдите под учётной записью администратора. Это отдельная сессия от входа покупателя.', - checking: 'Проверка...', - loginWithApp: 'Войти через приложение', - orScanQr: 'Или отсканируйте QR-код', - loginNote: 'После входа вы будете перенаправлены обратно', - qrExpired: 'QR-код устарел. Нажмите, чтобы обновить', - qrError: 'Не удалось создать сессию входа. Нажмите, чтобы повторить', - }, ux: { items: 'товаров', wishlistTitle: 'Избранное', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index e577428..89d1a0d 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -560,16 +560,6 @@ export interface Translations { qrExpired: string; qrError: string; }; - adminAuth: { - loginRequired: string; - loginDescription: string; - checking: string; - loginWithApp: string; - orScanQr: string; - loginNote: string; - qrExpired: string; - qrError: string; - }; ux: { items: string; wishlistTitle: string; diff --git a/src/app/models/admin-auth.model.ts b/src/app/models/admin-auth.model.ts index bc18439..af192d9 100644 --- a/src/app/models/admin-auth.model.ts +++ b/src/app/models/admin-auth.model.ts @@ -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'; diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts index 56df48f..c5b321f 100644 --- a/src/app/services/auth.service.ts +++ b/src/app/services/auth.service.ts @@ -1,9 +1,7 @@ -import { Injectable, signal, computed } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { Observable, of, catchError, map, tap } from 'rxjs'; +import { Injectable, signal, computed, inject } from '@angular/core'; +import { Observable, tap } from 'rxjs'; import { AuthSession, AuthStatus, WebSessionStart } from '../models/auth.model'; -import { environment } from '../../environments/environment'; -import { generateGuid } from '../shared/util/guid.util'; +import { TelegramSessionApiService } from './telegram-session-api.service'; const WEB_SESSION_COOKIE = 'webSessionID'; const WEB_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60; @@ -12,6 +10,8 @@ const WEB_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60; providedIn: 'root' }) export class AuthService { + private readonly api = inject(TelegramSessionApiService); + private sessionSignal = signal(null); private statusSignal = signal('unknown'); private showLoginSignal = signal(false); @@ -27,10 +27,9 @@ export class AuthService { /** Display name of authenticated user */ readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null); - private readonly authApiUrl = environment.authApiUrl; private sessionCheckTimer?: ReturnType; - constructor(private http: HttpClient) { + constructor() { // On init, check existing session via cookie 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 { - if (!webSessionID) { - return of(null); - } - - return this.http.get>( - `${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}` - ).pipe( - map(response => this.normalizeWebSession(response, webSessionID)), + return this.api.checkSessionOnce(webSessionID).pipe( tap(session => { if (session?.active) { this.activateSession(session); } - }), - catchError(() => of(null)) + }) ); } @@ -84,39 +75,18 @@ export class AuthService { } /** Generate the Telegram login URL for bot-based auth */ - getTelegramLoginUrl(webSessionID = generateGuid()): string { - const botUsername = this.getTelegramBotUsername(); - return `https://t.me/${botUsername}?start=${encodeURIComponent(webSessionID)}`; + getTelegramLoginUrl(webSessionID: string): string { + return this.api.getBotLoginUrl(webSessionID); } /** Generate a Telegram app deep link for mobile login without opening a browser tab. */ getTelegramAppLoginUrl(webSessionID: string): string { - const botUsername = this.getTelegramBotUsername(); - return `tg://resolve?domain=${encodeURIComponent(botUsername)}&start=${encodeURIComponent(webSessionID)}`; - } - - /** Get QR code data URL for Telegram login */ - getTelegramQrUrl(): string { - return this.getTelegramLoginUrl(); + return this.api.getBotAppLoginUrl(webSessionID); } /** Create a backend web session and return the Telegram start link for it. */ createWebSession(): Observable { - const webSessionID = generateGuid(); - - return this.http.post>( - `${this.authApiUrl}/users/sessions`, - { webSessionID }, - { headers: { WebSessionID: webSessionID } } - ).pipe( - map(response => { - const responseWebSessionID = this.extractSessionId(response, webSessionID); - return { - webSessionID: responseWebSessionID, - url: this.getTelegramLoginUrl(responseWebSessionID), - }; - }) - ); + return this.api.createSession(); } /** Show login dialog (called when user tries to pay without being logged in) */ @@ -138,11 +108,7 @@ export class AuthService { return; } - this.http.delete(`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`, { - headers: { WebSessionID: webSessionID } - }).pipe( - catchError(() => of(null)) - ).subscribe(() => { + this.api.logout(webSessionID).subscribe(() => { this.clearAuthState('unauthenticated'); }); } @@ -184,120 +150,6 @@ export class AuthService { } } - private normalizeWebSession(response: Record | 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 | 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, keys: string[]): unknown { - for (const key of keys) { - if (Object.prototype.hasOwnProperty.call(source, key)) { - return source[key]; - } - } - - return undefined; - } - - private readString(value: unknown): string | null { - if (typeof value === 'string' && value.trim()) { - return value; - } - - if (typeof value === 'number' || typeof value === 'bigint') { - return value.toString(); - } - - return null; - } - - private readNumber(value: unknown): number | null { - if (typeof value === 'number' && Number.isFinite(value)) { - return value; - } - - if (typeof value === 'string') { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : null; - } - - return null; - } - - private asRecord(value: unknown): Record | null { - return value !== null && typeof value === 'object' && !Array.isArray(value) - ? value as Record - : null; - } - - private isActiveStatus(status: unknown): boolean { - if (status === true || status === 1) { - return true; - } - - if (typeof status !== 'string') { - return false; - } - - return ['true', '1', 'active', 'authenticated', 'confirmed', 'success', 'logged_in'].includes(status.toLowerCase()); - } - private getStoredWebSessionID(): string | null { if (typeof document === 'undefined') { return null; @@ -334,8 +186,4 @@ export class AuthService { document.cookie = `${WEB_SESSION_COOKIE}=; Max-Age=0; Path=/; SameSite=Lax`; } - - private getTelegramBotUsername(): string { - return (environment as Record)['telegramBot'] as string || 'DexarSupport_bot'; - } } diff --git a/src/app/services/telegram-session-api.service.ts b/src/app/services/telegram-session-api.service.ts new file mode 100644 index 0000000..33ac152 --- /dev/null +++ b/src/app/services/telegram-session-api.service.ts @@ -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 { + const webSessionID = generateGuid(); + + return this.http.post>( + `${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 { + if (!webSessionID) { + return of(null); + } + + return this.http.get>( + `${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}` + ).pipe( + map(response => this.normalizeWebSession(response, webSessionID)), + catchError(() => of(null)) + ); + } + + logout(webSessionID: string): Observable { + 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)['telegramBot'] as string || 'DexarSupport_bot'; + } + + private normalizeWebSession(response: Record | 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 | 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, keys: string[]): unknown { + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(source, key)) { + return source[key]; + } + } + return undefined; + } + + private readString(value: unknown): string | null { + if (typeof value === 'string' && value.trim()) { + return value; + } + if (typeof value === 'number' || typeof value === 'bigint') { + return value.toString(); + } + return null; + } + + private readNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; + } + + private asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; + } + + private isActiveStatus(status: unknown): boolean { + if (status === true || status === 1) { + return true; + } + if (typeof status !== 'string') { + return false; + } + return ['true', '1', 'active', 'authenticated', 'confirmed', 'success', 'logged_in'].includes(status.toLowerCase()); + } +} diff --git a/src/environments/environment.production.ts b/src/environments/environment.production.ts index ff6c902..37c62b0 100644 --- a/src/environments/environment.production.ts +++ b/src/environments/environment.production.ts @@ -15,8 +15,6 @@ export const environment = { theme: 'dexar', apiUrl: 'https://api.dexarmarket.ru:445', 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', logo: '/icons/icon-192x192.png', contactEmail: 'info@dexarmarket.ru', diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 4024fd3..72c0611 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -16,8 +16,6 @@ export const environment = { theme: 'dexar', apiUrl: '/api', 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', logo: '/icons/icon-192x192.png', contactEmail: 'info@dexarmarket.ru',