qr login with telegram
This commit is contained in:
1040
docs/QR_LOGIN_IMPLEMENTATION_RU.md
Normal file
1040
docs/QR_LOGIN_IMPLEMENTATION_RU.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -31,13 +31,41 @@
|
|||||||
|
|
||||||
<div class="qr-section">
|
<div class="qr-section">
|
||||||
<p class="qr-hint">{{ 'auth.orScanQr' | translate }}</p>
|
<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()"
|
@switch (qrStatus()) {
|
||||||
alt="QR Code"
|
@case ('loading') {
|
||||||
width="180"
|
<div class="qr-container qr-loading">
|
||||||
height="180"
|
<div class="spinner"></div>
|
||||||
loading="lazy" />
|
</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>
|
</div>
|
||||||
|
|
||||||
<p class="login-note">{{ 'auth.loginNote' | translate }}</p>
|
<p class="login-note">{{ 'auth.loginNote' | translate }}</p>
|
||||||
|
|||||||
@@ -122,6 +122,42 @@ h2 {
|
|||||||
display: block;
|
display: block;
|
||||||
border-radius: 4px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { AuthService } from '../../services/auth.service';
|
||||||
|
import { CartService } from '../../services/cart.service';
|
||||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||||
|
import { getDiscountedPrice } from '../../utils/item.utils';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-telegram-login',
|
selector: 'app-telegram-login',
|
||||||
@@ -9,17 +11,28 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
|
|||||||
styleUrls: ['./telegram-login.component.scss'],
|
styleUrls: ['./telegram-login.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class TelegramLoginComponent implements OnInit, OnDestroy {
|
export class TelegramLoginComponent implements OnDestroy {
|
||||||
private authService = inject(AuthService);
|
private authService = inject(AuthService);
|
||||||
|
private cartService = inject(CartService);
|
||||||
|
|
||||||
showDialog = this.authService.showLoginDialog;
|
showDialog = this.authService.showLoginDialog;
|
||||||
status = this.authService.status;
|
status = this.authService.status;
|
||||||
|
|
||||||
loginUrl = signal('');
|
loginUrl = signal('');
|
||||||
|
qrToken = signal('');
|
||||||
|
qrStatus = signal<'loading' | 'ready' | 'expired' | 'error'>('loading');
|
||||||
|
encodedQrUrl = computed(() => encodeURIComponent(this.loginUrl()));
|
||||||
|
|
||||||
private pollTimer?: ReturnType<typeof setInterval>;
|
private pollTimer?: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
ngOnInit(): void {
|
constructor() {
|
||||||
this.loginUrl.set(this.authService.getTelegramLoginUrl());
|
effect(() => {
|
||||||
|
if (this.showDialog()) {
|
||||||
|
this.initQrLogin();
|
||||||
|
} else {
|
||||||
|
this.stopPolling();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
@@ -31,32 +44,87 @@ export class TelegramLoginComponent implements OnInit, OnDestroy {
|
|||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open Telegram login link and start polling for session */
|
|
||||||
openTelegramLogin(): void {
|
openTelegramLogin(): void {
|
||||||
window.open(this.loginUrl(), '_blank');
|
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 */
|
refreshQr(): void {
|
||||||
private startPolling(): void {
|
|
||||||
this.stopPolling();
|
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;
|
let checks = 0;
|
||||||
this.pollTimer = setInterval(() => {
|
this.pollTimer = setInterval(() => {
|
||||||
checks++;
|
checks++;
|
||||||
if (checks > 100) { // 100 * 3s = 5 min
|
if (checks > 100) {
|
||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
|
this.qrStatus.set('expired');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.authService.checkSession();
|
|
||||||
// If authenticated, stop polling and close dialog
|
this.authService.pollQrToken(token).subscribe({
|
||||||
if (this.authService.isAuthenticated()) {
|
next: (res) => {
|
||||||
this.stopPolling();
|
switch (res.status) {
|
||||||
this.authService.hideLogin();
|
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);
|
}, 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 {
|
private stopPolling(): void {
|
||||||
if (this.pollTimer) {
|
if (this.pollTimer) {
|
||||||
clearInterval(this.pollTimer);
|
clearInterval(this.pollTimer);
|
||||||
|
|||||||
@@ -205,5 +205,6 @@ export const en: Translations = {
|
|||||||
loginWithTelegram: 'Log in with Telegram',
|
loginWithTelegram: 'Log in with Telegram',
|
||||||
orScanQr: 'Or scan the QR code',
|
orScanQr: 'Or scan the QR code',
|
||||||
loginNote: 'You will be redirected back after login',
|
loginNote: 'You will be redirected back after login',
|
||||||
|
qrExpired: 'QR code expired. Click to refresh',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Translations } from './translations';
|
import { Translations } from './translations';
|
||||||
|
|
||||||
export const hy: Translations = {
|
export const hy: Translations = {
|
||||||
header: {
|
header: {
|
||||||
@@ -205,5 +205,6 @@ export const hy: Translations = {
|
|||||||
loginWithTelegram: 'Մուտք Telegram-ով',
|
loginWithTelegram: 'Մուտք Telegram-ով',
|
||||||
orScanQr: 'Կամ սքանավորեք QR կոդը',
|
orScanQr: 'Կամ սքանավորեք QR կոդը',
|
||||||
loginNote: 'Մուտքից հետո դուք կվերաուղղվեք',
|
loginNote: 'Մուտքից հետո դուք կվերաուղղվեք',
|
||||||
|
qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -205,5 +205,6 @@ export const ru: Translations = {
|
|||||||
loginWithTelegram: 'Войти через Telegram',
|
loginWithTelegram: 'Войти через Telegram',
|
||||||
orScanQr: 'Или отсканируйте QR-код',
|
orScanQr: 'Или отсканируйте QR-код',
|
||||||
loginNote: 'После входа вы будете перенаправлены обратно',
|
loginNote: 'После входа вы будете перенаправлены обратно',
|
||||||
|
qrExpired: 'QR-код устарел. Нажмите, чтобы обновить',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -203,5 +203,6 @@ export interface Translations {
|
|||||||
loginWithTelegram: string;
|
loginWithTelegram: string;
|
||||||
orScanQr: string;
|
orScanQr: string;
|
||||||
loginNote: string;
|
loginNote: string;
|
||||||
|
qrExpired: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,9 @@ export interface TelegramAuthData {
|
|||||||
hash: string;
|
hash: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QrPollResponse {
|
||||||
|
status: 'pending' | 'confirmed' | 'expired';
|
||||||
|
session?: AuthSession;
|
||||||
|
}
|
||||||
|
|
||||||
export type AuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';
|
export type AuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, signal, computed } from '@angular/core';
|
import { Injectable, signal, computed } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable, of, catchError, map, tap } from 'rxjs';
|
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';
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
@@ -82,6 +82,34 @@ export class AuthService {
|
|||||||
return this.getTelegramLoginUrl();
|
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) */
|
/** Show login dialog (called when user tries to pay without being logged in) */
|
||||||
requestLogin(): void {
|
requestLogin(): void {
|
||||||
this.showLoginSignal.set(true);
|
this.showLoginSignal.set(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user