qr login with telegram

This commit is contained in:
sdarbinyan
2026-03-25 15:32:50 +04:00
parent ce301e9c70
commit db781fd871
10 changed files with 1234 additions and 25 deletions

View File

@@ -31,13 +31,41 @@
<div class="qr-section">
<p class="qr-hint">{{ 'auth.orScanQr' | translate }}</p>
<div class="qr-container">
<img [src]="'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + loginUrl()"
alt="QR Code"
width="180"
height="180"
loading="lazy" />
</div>
@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>{{ 'auth.qrExpired' | translate }}</span>
</div>
}
@case ('error') {
<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="lazy" />
</div>
}
}
</div>
<p class="login-note">{{ 'auth.loginNote' | translate }}</p>

View File

@@ -122,6 +122,42 @@ h2 {
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;
}
}
}
}

View File

@@ -1,6 +1,8 @@
import { Component, ChangeDetectionStrategy, inject, signal, OnInit, OnDestroy } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, OnDestroy } from '@angular/core';
import { AuthService } from '../../services/auth.service';
import { CartService } from '../../services/cart.service';
import { TranslatePipe } from '../../i18n/translate.pipe';
import { getDiscountedPrice } from '../../utils/item.utils';
@Component({
selector: 'app-telegram-login',
@@ -9,17 +11,28 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
styleUrls: ['./telegram-login.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TelegramLoginComponent implements OnInit, OnDestroy {
export class TelegramLoginComponent implements OnDestroy {
private authService = inject(AuthService);
private cartService = inject(CartService);
showDialog = this.authService.showLoginDialog;
status = this.authService.status;
loginUrl = signal('');
qrToken = signal('');
qrStatus = signal<'loading' | 'ready' | 'expired' | 'error'>('loading');
encodedQrUrl = computed(() => encodeURIComponent(this.loginUrl()));
private pollTimer?: ReturnType<typeof setInterval>;
ngOnInit(): void {
this.loginUrl.set(this.authService.getTelegramLoginUrl());
constructor() {
effect(() => {
if (this.showDialog()) {
this.initQrLogin();
} else {
this.stopPolling();
}
});
}
ngOnDestroy(): void {
@@ -31,32 +44,87 @@ export class TelegramLoginComponent implements OnInit, OnDestroy {
this.stopPolling();
}
/** Open Telegram login link and start polling for session */
openTelegramLogin(): void {
window.open(this.loginUrl(), '_blank');
this.startPolling();
if (!this.pollTimer) {
this.startPolling(this.qrToken());
}
}
/** Start polling the backend to detect when user completes Telegram auth */
private startPolling(): void {
refreshQr(): void {
this.stopPolling();
// Check every 3 seconds for up to 5 minutes
this.initQrLogin();
}
private initQrLogin(): void {
this.qrStatus.set('loading');
this.authService.createQrToken().subscribe({
next: (res) => {
this.loginUrl.set(res.url);
this.qrToken.set(res.token);
this.qrStatus.set('ready');
this.startPolling(res.token);
},
error: () => {
this.loginUrl.set(this.authService.getTelegramLoginUrl());
this.qrStatus.set('error');
}
});
}
private startPolling(token: string): void {
this.stopPolling();
if (!token) return;
let checks = 0;
this.pollTimer = setInterval(() => {
checks++;
if (checks > 100) { // 100 * 3s = 5 min
if (checks > 100) {
this.stopPolling();
this.qrStatus.set('expired');
return;
}
this.authService.checkSession();
// If authenticated, stop polling and close dialog
if (this.authService.isAuthenticated()) {
this.stopPolling();
this.authService.hideLogin();
}
this.authService.pollQrToken(token).subscribe({
next: (res) => {
switch (res.status) {
case 'confirmed':
this.stopPolling();
if (res.session) {
this.syncCartAndComplete(res.session.sessionId);
} else {
this.authService.onTelegramLoginComplete();
}
break;
case 'expired':
this.stopPolling();
this.qrStatus.set('expired');
break;
}
},
error: () => {
// Network error — keep polling
}
});
}, 3000);
}
private syncCartAndComplete(sessionId: string): void {
const cartItems = this.cartService.items().map(item => ({
itemID: item.itemID,
quantity: item.quantity,
colour: item.colour || '',
size: item.size || '',
price: item.discount > 0
? item.price * (1 - item.discount / 100)
: item.price,
}));
this.authService.syncCart(sessionId, cartItems).subscribe(() => {
this.authService.onTelegramLoginComplete();
});
}
private stopPolling(): void {
if (this.pollTimer) {
clearInterval(this.pollTimer);

View File

@@ -205,5 +205,6 @@ export const en: Translations = {
loginWithTelegram: 'Log in with Telegram',
orScanQr: 'Or scan the QR code',
loginNote: 'You will be redirected back after login',
qrExpired: 'QR code expired. Click to refresh',
},
};

View File

@@ -1,4 +1,4 @@
import { Translations } from './translations';
import { Translations } from './translations';
export const hy: Translations = {
header: {
@@ -205,5 +205,6 @@ export const hy: Translations = {
loginWithTelegram: 'Մուտք Telegram-ով',
orScanQr: 'Կամ սքանավորեք QR կոդը',
loginNote: 'Մուտքից հետո դուք կվերաուղղվեք',
qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար',
},
};

View File

@@ -205,5 +205,6 @@ export const ru: Translations = {
loginWithTelegram: 'Войти через Telegram',
orScanQr: 'Или отсканируйте QR-код',
loginNote: 'После входа вы будете перенаправлены обратно',
qrExpired: 'QR-код устарел. Нажмите, чтобы обновить',
},
};

View File

@@ -203,5 +203,6 @@ export interface Translations {
loginWithTelegram: string;
orScanQr: string;
loginNote: string;
qrExpired: string;
};
}

View File

@@ -17,4 +17,9 @@ export interface TelegramAuthData {
hash: string;
}
export interface QrPollResponse {
status: 'pending' | 'confirmed' | 'expired';
session?: AuthSession;
}
export type AuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';

View File

@@ -1,7 +1,7 @@
import { Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, catchError, map, tap } from 'rxjs';
import { AuthSession, AuthStatus } from '../models/auth.model';
import { AuthSession, AuthStatus, QrPollResponse } from '../models/auth.model';
import { environment } from '../../environments/environment';
@Injectable({
@@ -82,6 +82,34 @@ export class AuthService {
return this.getTelegramLoginUrl();
}
/** Create a one-time QR login token via backend */
createQrToken(): Observable<{ token: string; url: string }> {
return this.http.post<{ token: string; url: string }>(
`${this.apiUrl}/auth/qr/create`,
{},
{ withCredentials: true }
);
}
/** Poll the QR token status (pending → confirmed / expired) */
pollQrToken(token: string): Observable<QrPollResponse> {
return this.http.get<QrPollResponse>(
`${this.apiUrl}/auth/qr/poll`,
{
params: { token },
withCredentials: true,
}
);
}
/** Sync local cart to the backend session after login */
syncCart(sessionId: string, items: Array<{ itemID: number; quantity: number; colour?: string; size?: string; price?: number }>): Observable<unknown> {
if (!items.length) return of(null);
return this.http.post(`${this.apiUrl}/websession/${sessionId}`, items, {
withCredentials: true,
}).pipe(catchError(() => of(null)));
}
/** Show login dialog (called when user tries to pay without being logged in) */
requestLogin(): void {
this.showLoginSignal.set(true);