created auth system

This commit is contained in:
sdarbinyan
2026-02-28 17:18:24 +04:00
parent 86d11364f0
commit 6689acbe57
28 changed files with 1254 additions and 674 deletions

View File

@@ -1,15 +1,17 @@
import { Injectable } from '@angular/core';
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Category, Item } from '../models';
import { environment } from '../../environments/environment';
import { LocationService } from './location.service';
@Injectable({
providedIn: 'root'
})
export class ApiService {
private readonly baseUrl = environment.apiUrl;
private locationService = inject(LocationService);
constructor(private http: HttpClient) {}
@@ -27,32 +29,42 @@ export class ApiService {
return items.map(item => this.normalizeItem(item));
}
/** Append region query param if a region is selected */
private withRegion(params: HttpParams = new HttpParams()): HttpParams {
const regionId = this.locationService.regionId();
return regionId ? params.set('region', regionId) : params;
}
ping(): Observable<{ message: string }> {
return this.http.get<{ message: string }>(`${this.baseUrl}/ping`);
}
getCategories(): Observable<Category[]> {
return this.http.get<Category[]>(`${this.baseUrl}/category`);
return this.http.get<Category[]>(`${this.baseUrl}/category`, { params: this.withRegion() });
}
getCategoryItems(categoryID: number, count: number = 50, skip: number = 0): Observable<Item[]> {
const params = new HttpParams()
.set('count', count.toString())
.set('skip', skip.toString());
const params = this.withRegion(
new HttpParams()
.set('count', count.toString())
.set('skip', skip.toString())
);
return this.http.get<Item[]>(`${this.baseUrl}/category/${categoryID}`, { params })
.pipe(map(items => this.normalizeItems(items)));
}
getItem(itemID: number): Observable<Item> {
return this.http.get<Item>(`${this.baseUrl}/item/${itemID}`)
return this.http.get<Item>(`${this.baseUrl}/item/${itemID}`, { params: this.withRegion() })
.pipe(map(item => this.normalizeItem(item)));
}
searchItems(search: string, count: number = 50, skip: number = 0): Observable<{ items: Item[], total: number }> {
const params = new HttpParams()
.set('search', search)
.set('count', count.toString())
.set('skip', skip.toString());
const params = this.withRegion(
new HttpParams()
.set('search', search)
.set('count', count.toString())
.set('skip', skip.toString())
);
return this.http.get<{ items: Item[], total: number, count: number, skip: number }>(`${this.baseUrl}/searchitems`, { params })
.pipe(
map(response => ({
@@ -157,7 +169,7 @@ export class ApiService {
}
getRandomItems(count: number = 5, categoryID?: number): Observable<Item[]> {
let params = new HttpParams().set('count', count.toString());
let params = this.withRegion(new HttpParams().set('count', count.toString()));
if (categoryID) {
params = params.set('category', categoryID.toString());
}

View File

@@ -0,0 +1,128 @@
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 { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root'
})
export class AuthService {
private sessionSignal = signal<AuthSession | null>(null);
private statusSignal = signal<AuthStatus>('unknown');
private showLoginSignal = signal(false);
/** Current auth session */
readonly session = this.sessionSignal.asReadonly();
/** Current auth status */
readonly status = this.statusSignal.asReadonly();
/** Whether user is fully authenticated */
readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated');
/** Whether to show login dialog */
readonly showLoginDialog = this.showLoginSignal.asReadonly();
/** Display name of authenticated user */
readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null);
private readonly apiUrl = environment.apiUrl;
private sessionCheckTimer?: ReturnType<typeof setInterval>;
constructor(private http: HttpClient) {
// On init, check existing session via cookie
this.checkSession();
}
/**
* Check current session status with backend.
* The backend reads the session cookie and returns the session info.
*/
checkSession(): void {
this.statusSignal.set('checking');
this.http.get<AuthSession>(`${this.apiUrl}/auth/session`, {
withCredentials: true
}).pipe(
catchError(() => {
this.statusSignal.set('unauthenticated');
this.sessionSignal.set(null);
return of(null);
})
).subscribe(session => {
if (session && session.active) {
this.sessionSignal.set(session);
this.statusSignal.set('authenticated');
this.scheduleSessionRefresh(session.expiresAt);
} else if (session && !session.active) {
this.sessionSignal.set(null);
this.statusSignal.set('expired');
} else {
this.statusSignal.set('unauthenticated');
}
});
}
/**
* Called after user completes Telegram login.
* The callback URL from Telegram will hit our backend which sets the cookie.
* Then we re-check the session.
*/
onTelegramLoginComplete(): void {
this.checkSession();
this.hideLogin();
}
/** Generate the Telegram login URL for bot-based auth */
getTelegramLoginUrl(): string {
const botUsername = (environment as Record<string, unknown>)['telegramBot'] as string || 'dexarmarket_bot';
const callbackUrl = encodeURIComponent(`${this.apiUrl}/auth/telegram/callback`);
return `https://t.me/${botUsername}?start=auth_${callbackUrl}`;
}
/** Get QR code data URL for Telegram login */
getTelegramQrUrl(): string {
return this.getTelegramLoginUrl();
}
/** Show login dialog (called when user tries to pay without being logged in) */
requestLogin(): void {
this.showLoginSignal.set(true);
}
/** Hide login dialog */
hideLogin(): void {
this.showLoginSignal.set(false);
}
/** Logout — clears session on backend and locally */
logout(): void {
this.http.post(`${this.apiUrl}/auth/logout`, {}, {
withCredentials: true
}).pipe(
catchError(() => of(null))
).subscribe(() => {
this.sessionSignal.set(null);
this.statusSignal.set('unauthenticated');
this.clearSessionRefresh();
});
}
/** Schedule a session re-check before it expires */
private scheduleSessionRefresh(expiresAt: string): void {
this.clearSessionRefresh();
const expiresMs = new Date(expiresAt).getTime();
const nowMs = Date.now();
// Re-check 60 seconds before expiry, minimum 30s from now
const refreshIn = Math.max(expiresMs - nowMs - 60_000, 30_000);
this.sessionCheckTimer = setTimeout(() => {
this.checkSession();
}, refreshIn);
}
private clearSessionRefresh(): void {
if (this.sessionCheckTimer) {
clearTimeout(this.sessionCheckTimer);
this.sessionCheckTimer = undefined;
}
}
}

View File

@@ -3,3 +3,5 @@ export * from './cart.service';
export * from './telegram.service';
export * from './language.service';
export * from './seo.service';
export * from './location.service';
export * from './auth.service';

View File

@@ -0,0 +1,135 @@
import { Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Region, GeoIpResponse } from '../models/location.model';
import { environment } from '../../environments/environment';
const STORAGE_KEY = 'selected_region';
@Injectable({
providedIn: 'root'
})
export class LocationService {
private regionSignal = signal<Region | null>(null);
private regionsSignal = signal<Region[]>([]);
private loadingSignal = signal(false);
private detectedSignal = signal(false);
/** Current selected region (null = global / all regions) */
readonly region = this.regionSignal.asReadonly();
/** All available regions */
readonly regions = this.regionsSignal.asReadonly();
/** Whether geo-detection is in progress */
readonly detecting = this.loadingSignal.asReadonly();
/** Whether region was auto-detected */
readonly autoDetected = this.detectedSignal.asReadonly();
/** Computed region id for API calls — empty string means global */
readonly regionId = computed(() => this.regionSignal()?.id ?? '');
private readonly apiUrl = environment.apiUrl;
constructor(private http: HttpClient) {
this.loadRegions();
this.restoreFromStorage();
}
/** Fetch available regions from backend */
loadRegions(): void {
this.http.get<Region[]>(`${this.apiUrl}/regions`).subscribe({
next: (regions) => {
this.regionsSignal.set(regions);
// If we have a stored region, validate it still exists
const stored = this.regionSignal();
if (stored && !regions.find(r => r.id === stored.id)) {
this.clearRegion();
}
},
error: () => {
// Fallback: hardcoded popular regions
this.regionsSignal.set(this.getFallbackRegions());
}
});
}
/** Set region by user choice */
setRegion(region: Region): void {
this.regionSignal.set(region);
localStorage.setItem(STORAGE_KEY, JSON.stringify(region));
}
/** Clear region (go global) */
clearRegion(): void {
this.regionSignal.set(null);
localStorage.removeItem(STORAGE_KEY);
}
/** Auto-detect user location via IP geolocation */
detectLocation(): void {
if (this.detectedSignal()) return; // already tried
this.loadingSignal.set(true);
// Using free ip-api.com — no key required, 45 req/min
this.http.get<GeoIpResponse>('http://ip-api.com/json/?fields=city,country,countryCode,region,timezone,lat,lon')
.subscribe({
next: (geo) => {
this.detectedSignal.set(true);
this.loadingSignal.set(false);
// Only auto-set if user hasn't manually chosen a region
if (!this.regionSignal()) {
const matchedRegion = this.findRegionByGeo(geo);
if (matchedRegion) {
this.setRegion(matchedRegion);
}
}
},
error: () => {
this.detectedSignal.set(true);
this.loadingSignal.set(false);
}
});
}
/** Try to match detected geo data to an available region */
private findRegionByGeo(geo: GeoIpResponse): Region | null {
const regions = this.regionsSignal();
if (!regions.length) return null;
// Exact city match
const cityMatch = regions.find(r =>
r.city.toLowerCase() === geo.city?.toLowerCase()
);
if (cityMatch) return cityMatch;
// Country match — pick the first region for that country
const countryMatch = regions.find(r =>
r.countryCode.toLowerCase() === geo.countryCode?.toLowerCase()
);
return countryMatch || null;
}
/** Restore previously selected region from storage */
private restoreFromStorage(): void {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const region: Region = JSON.parse(stored);
this.regionSignal.set(region);
}
} catch {
localStorage.removeItem(STORAGE_KEY);
}
}
/** Fallback regions if backend /regions endpoint is unavailable */
private getFallbackRegions(): Region[] {
return [
{ id: 'moscow', city: 'Москва', country: 'Россия', countryCode: 'RU', timezone: 'Europe/Moscow' },
{ id: 'spb', city: 'Санкт-Петербург', country: 'Россия', countryCode: 'RU', timezone: 'Europe/Moscow' },
{ id: 'yerevan', city: 'Ереван', country: 'Армения', countryCode: 'AM', timezone: 'Asia/Yerevan' },
{ id: 'minsk', city: 'Минск', country: 'Беларусь', countryCode: 'BY', timezone: 'Europe/Minsk' },
{ id: 'almaty', city: 'Алматы', country: 'Казахстан', countryCode: 'KZ', timezone: 'Asia/Almaty' },
{ id: 'tbilisi', city: 'Тбилиси', country: 'Грузия', countryCode: 'GE', timezone: 'Asia/Tbilisi' },
];
}
}