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

@@ -25,5 +25,5 @@
<div class="footer-placeholder" aria-hidden="true"></div>
}
<!-- <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 { 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'
})

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 { 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<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 engine!: QrLoginEngine<AuthSession>;
private readonly engine = new QrLoginEngine<AuthSession>(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<string>;
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 {
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 {

View File

@@ -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<AdminSession | null>(null);
private readonly api = inject(TelegramSessionApiService);
private readonly sessionSignal = signal<AuthSession | null>(null);
private readonly statusSignal = signal<AdminAuthStatus>('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<string, unknown>)['adminAuthApiUrl'] as string
?? `${environment.authApiUrl}/admin`;
private sessionCheckTimer?: ReturnType<typeof setTimeout>;
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<AdminSession | null> {
if (!webSessionID) {
return of(null);
}
return this.http.get<Record<string, unknown>>(
`${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<AuthSession | null> {
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<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 {
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<WebSessionStart> {
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<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 {
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<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',
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',

View File

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

View File

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

View File

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

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

View File

@@ -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<AuthSession | null>(null);
private statusSignal = signal<AuthStatus>('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<typeof setTimeout>;
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<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)),
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<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.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<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 {
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<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());
}
}