142 lines
4.3 KiB
TypeScript
142 lines
4.3 KiB
TypeScript
import { HttpClient, HttpParams } from '@angular/common/http';
|
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
|
import { Observable, finalize, shareReplay, tap, throwError } from 'rxjs';
|
|
import {
|
|
AuthTokenResponse,
|
|
LoginRequest,
|
|
PublicUser,
|
|
PublicUserSearchResult,
|
|
RegisterRequest,
|
|
RegisterResponse,
|
|
TaskDigestPreference,
|
|
} from './auth.models';
|
|
|
|
const ACCESS_TOKEN_KEY = 'listify.accessToken';
|
|
const REFRESH_TOKEN_KEY = 'listify.refreshToken';
|
|
const USER_KEY = 'listify.user';
|
|
|
|
@Injectable({ providedIn: 'root' })
|
|
export class AuthService {
|
|
private readonly http = inject(HttpClient);
|
|
private readonly apiUrl = '/api/auth';
|
|
private readonly userSignal = signal<PublicUser | null>(this.readStoredUser());
|
|
private refreshRequest$: Observable<AuthTokenResponse> | null = null;
|
|
|
|
readonly user = this.userSignal.asReadonly();
|
|
readonly isAuthenticated = computed(() => Boolean(this.userSignal()));
|
|
|
|
login(credentials: LoginRequest): Observable<AuthTokenResponse> {
|
|
return this.http
|
|
.post<AuthTokenResponse>(`${this.apiUrl}/login`, credentials)
|
|
.pipe(tap((response) => this.storeSession(response)));
|
|
}
|
|
|
|
startSsoLogin(): void {
|
|
if (typeof window !== 'undefined') {
|
|
window.location.href = `${this.apiUrl}/sso/login`;
|
|
}
|
|
}
|
|
|
|
completeSsoLogin(response: AuthTokenResponse): void {
|
|
this.storeSession(response);
|
|
}
|
|
|
|
exchangeSsoCode(code: string, state: string): Observable<AuthTokenResponse> {
|
|
return this.http
|
|
.post<AuthTokenResponse>(`${this.apiUrl}/sso/exchange`, { code, state })
|
|
.pipe(tap((response) => this.storeSession(response)));
|
|
}
|
|
|
|
register(data: RegisterRequest): Observable<RegisterResponse> {
|
|
return this.http.post<RegisterResponse>(`${this.apiUrl}/register`, data);
|
|
}
|
|
|
|
loadCurrentUser(): Observable<PublicUser> {
|
|
return this.http.get<PublicUser>(`${this.apiUrl}/me`).pipe(tap((user) => this.storeUser(user)));
|
|
}
|
|
|
|
searchUsers(query: string): Observable<PublicUserSearchResult[]> {
|
|
const params = new HttpParams().set('q', query);
|
|
return this.http.get<PublicUserSearchResult[]>(`${this.apiUrl}/users/search`, {
|
|
params,
|
|
});
|
|
}
|
|
|
|
updateOnboardingCompleted(completed: boolean): Observable<PublicUser> {
|
|
return this.http
|
|
.patch<PublicUser>(`${this.apiUrl}/me/onboarding`, { completed })
|
|
.pipe(tap((user) => this.storeUser(user)));
|
|
}
|
|
|
|
updateTaskDigestPreference(preference: TaskDigestPreference): Observable<PublicUser> {
|
|
return this.http
|
|
.patch<PublicUser>(`${this.apiUrl}/me/task-digest`, { preference })
|
|
.pipe(tap((user) => this.storeUser(user)));
|
|
}
|
|
|
|
accessToken(): string | null {
|
|
return this.storage?.getItem(ACCESS_TOKEN_KEY) ?? null;
|
|
}
|
|
|
|
refreshToken(): string | null {
|
|
return this.storage?.getItem(REFRESH_TOKEN_KEY) ?? null;
|
|
}
|
|
|
|
refreshSession(): Observable<AuthTokenResponse> {
|
|
const refreshToken = this.refreshToken();
|
|
|
|
if (!refreshToken) {
|
|
return throwError(() => new Error('Refresh token is missing.'));
|
|
}
|
|
|
|
this.refreshRequest$ ??= this.http
|
|
.post<AuthTokenResponse>(`${this.apiUrl}/refresh`, { refreshToken })
|
|
.pipe(
|
|
tap((response) => this.storeSession(response)),
|
|
finalize(() => {
|
|
this.refreshRequest$ = null;
|
|
}),
|
|
shareReplay({ bufferSize: 1, refCount: true }),
|
|
);
|
|
|
|
return this.refreshRequest$;
|
|
}
|
|
|
|
logout(): void {
|
|
this.storage?.removeItem(ACCESS_TOKEN_KEY);
|
|
this.storage?.removeItem(REFRESH_TOKEN_KEY);
|
|
this.storage?.removeItem(USER_KEY);
|
|
this.userSignal.set(null);
|
|
}
|
|
|
|
private storeSession(response: AuthTokenResponse): void {
|
|
this.storage?.setItem(ACCESS_TOKEN_KEY, response.accessToken);
|
|
this.storage?.setItem(REFRESH_TOKEN_KEY, response.refreshToken);
|
|
this.storeUser(response.user);
|
|
}
|
|
|
|
private storeUser(user: PublicUser): void {
|
|
this.storage?.setItem(USER_KEY, JSON.stringify(user));
|
|
this.userSignal.set(user);
|
|
}
|
|
|
|
private readStoredUser(): PublicUser | null {
|
|
const rawUser = this.storage?.getItem(USER_KEY);
|
|
|
|
if (!rawUser) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(rawUser) as PublicUser;
|
|
} catch {
|
|
this.storage?.removeItem(USER_KEY);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private get storage(): Storage | null {
|
|
return typeof window === 'undefined' ? null : window.localStorage;
|
|
}
|
|
}
|