created auth system
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
128
src/app/services/auth.service.ts
Normal file
128
src/app/services/auth.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
135
src/app/services/location.service.ts
Normal file
135
src/app/services/location.service.ts
Normal 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' },
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user