207 lines
5.1 KiB
TypeScript
207 lines
5.1 KiB
TypeScript
import { Injectable, signal, computed, inject } from '@angular/core';
|
|
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
|
import { firstValueFrom } from 'rxjs';
|
|
import {
|
|
readStoredSession,
|
|
writeStoredSession,
|
|
clearStoredSession,
|
|
StoredSession,
|
|
} from './auth.session-storage';
|
|
|
|
export interface CurrentUser {
|
|
id: string;
|
|
username: string;
|
|
role: 'admin' | 'player';
|
|
}
|
|
|
|
interface RefreshResponse {
|
|
accessToken: string;
|
|
expiresIn: number;
|
|
user: CurrentUser;
|
|
}
|
|
|
|
export interface LoginSuccess {
|
|
ok: true;
|
|
accessToken: string;
|
|
expiresIn: number;
|
|
user: CurrentUser;
|
|
}
|
|
|
|
export interface LoginFailure {
|
|
ok: false;
|
|
status: number;
|
|
code: string;
|
|
message: string;
|
|
}
|
|
|
|
export type LoginResult = LoginSuccess | LoginFailure;
|
|
|
|
export interface RegisterSuccess {
|
|
ok: true;
|
|
accessToken: string;
|
|
expiresIn: number;
|
|
user: CurrentUser;
|
|
}
|
|
|
|
export interface RegisterFailure {
|
|
ok: false;
|
|
status: number;
|
|
code: string;
|
|
message: string;
|
|
}
|
|
|
|
export type RegisterResult = RegisterSuccess | RegisterFailure;
|
|
|
|
@Injectable({ providedIn: 'root' })
|
|
export class AuthService {
|
|
private http: HttpClient;
|
|
|
|
constructor(http?: HttpClient) {
|
|
this.http = http ?? inject(HttpClient);
|
|
}
|
|
|
|
private accessToken = signal<string | null>(null);
|
|
private user = signal<CurrentUser | null>(null);
|
|
readonly hydrated = signal<boolean>(false);
|
|
private hydrateWaiters: Array<() => void> = [];
|
|
|
|
readonly currentUser = computed(() => this.user());
|
|
readonly isAuthenticated = computed(() => !!this.user());
|
|
|
|
async ensureCsrf(): Promise<void> {
|
|
try {
|
|
await firstValueFrom(
|
|
this.http.get('/api/v1/auth/csrf', { withCredentials: true }),
|
|
);
|
|
} catch {
|
|
// middleware will still mint the cookie on the next response
|
|
}
|
|
}
|
|
|
|
async login(creds: { username: string; password: string }): Promise<LoginResult> {
|
|
await this.ensureCsrf();
|
|
try {
|
|
const res = await firstValueFrom(
|
|
this.http.post<{ accessToken: string; expiresIn: number; user: CurrentUser }>(
|
|
'/api/v1/auth/login',
|
|
creds,
|
|
{ withCredentials: true, observe: 'response' },
|
|
),
|
|
);
|
|
const success: LoginSuccess = {
|
|
ok: true,
|
|
accessToken: res.body!.accessToken,
|
|
expiresIn: res.body!.expiresIn,
|
|
user: res.body!.user,
|
|
};
|
|
return success;
|
|
} catch (e) {
|
|
return mapAuthError(e as HttpErrorResponse);
|
|
}
|
|
}
|
|
|
|
async register(creds: {
|
|
username: string;
|
|
password: string;
|
|
passwordConfirm: string;
|
|
}): Promise<RegisterResult> {
|
|
await this.ensureCsrf();
|
|
try {
|
|
const res = await firstValueFrom(
|
|
this.http.post<{ accessToken: string; expiresIn: number; user: CurrentUser }>(
|
|
'/api/v1/auth/register',
|
|
creds,
|
|
{ withCredentials: true, observe: 'response' },
|
|
),
|
|
);
|
|
const success: RegisterSuccess = {
|
|
ok: true,
|
|
accessToken: res.body!.accessToken,
|
|
expiresIn: res.body!.expiresIn,
|
|
user: res.body!.user,
|
|
};
|
|
return success;
|
|
} catch (e) {
|
|
return mapAuthError(e as HttpErrorResponse) as RegisterFailure;
|
|
}
|
|
}
|
|
|
|
setSession(token: string, user: CurrentUser): void {
|
|
this.accessToken.set(token);
|
|
this.user.set(user);
|
|
writeStoredSession(this.sessionStorage(), { token, user });
|
|
}
|
|
|
|
clear(): void {
|
|
this.accessToken.set(null);
|
|
this.user.set(null);
|
|
clearStoredSession(this.sessionStorage());
|
|
}
|
|
|
|
getAccessToken(): string | null {
|
|
return this.accessToken();
|
|
}
|
|
|
|
private sessionStorage(): Storage {
|
|
return typeof window !== 'undefined' ? window.sessionStorage : (undefined as unknown as Storage);
|
|
}
|
|
|
|
private restoreFromStorage(): void {
|
|
const stored = readStoredSession(this.sessionStorage());
|
|
if (stored) {
|
|
this.accessToken.set(stored.token);
|
|
this.user.set(stored.user as CurrentUser);
|
|
}
|
|
}
|
|
|
|
private markHydrated(): void {
|
|
if (this.hydrated()) return;
|
|
this.hydrated.set(true);
|
|
const waiters = this.hydrateWaiters;
|
|
this.hydrateWaiters = [];
|
|
for (const resolve of waiters) resolve();
|
|
}
|
|
|
|
waitUntilHydrated(): Promise<void> {
|
|
if (this.hydrated()) return Promise.resolve();
|
|
return new Promise<void>((resolve) => {
|
|
this.hydrateWaiters.push(resolve);
|
|
});
|
|
}
|
|
|
|
async restoreSession(): Promise<boolean> {
|
|
this.restoreFromStorage();
|
|
|
|
try {
|
|
const res = await firstValueFrom(
|
|
this.http.post<RefreshResponse>(
|
|
'/api/v1/auth/refresh',
|
|
{},
|
|
{ withCredentials: true },
|
|
),
|
|
);
|
|
if (res && res.accessToken && res.user) {
|
|
this.setSession(res.accessToken, res.user);
|
|
this.markHydrated();
|
|
return true;
|
|
}
|
|
this.clear();
|
|
this.markHydrated();
|
|
return false;
|
|
} catch {
|
|
this.clear();
|
|
this.markHydrated();
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
function mapAuthError(err: HttpErrorResponse): LoginFailure {
|
|
const body = err.error as { code?: string; message?: string } | null;
|
|
return {
|
|
ok: false,
|
|
status: err.status ?? 0,
|
|
code: body?.code ?? 'INTERNAL',
|
|
message: body?.message ?? err.message ?? 'Authentication failed',
|
|
};
|
|
} |