AI Implementation feature(842): First-Start Create Admin Modal 1.01 (#4)

This commit was merged in pull request #4.
This commit is contained in:
2026-07-21 15:32:51 +00:00
parent 960516a7bc
commit 6feaf08bdb
17 changed files with 834 additions and 217 deletions
+78 -1
View File
@@ -1,4 +1,12 @@
import { Injectable, signal, computed } from '@angular/core';
import { Injectable, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import {
readStoredSession,
writeStoredSession,
clearStoredSession,
StoredSession,
} from './auth.session-storage';
export interface CurrentUser {
id: string;
@@ -6,10 +14,24 @@ export interface CurrentUser {
role: 'admin' | 'player';
}
interface RefreshResponse {
accessToken: string;
expiresIn: number;
user: CurrentUser;
}
@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());
@@ -17,14 +39,69 @@ export class AuthService {
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;
}
}
}
@@ -0,0 +1,43 @@
export interface StoredSession {
token: string;
user: { id: string; username: string; role: 'admin' | 'player' };
}
export interface RefreshResponse {
accessToken: string;
expiresIn: number;
user: { id: string; username: string; role: 'admin' | 'player' };
}
export interface SessionStorageLike {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
export const STORAGE_KEY = 'hipctf.auth.v1';
export function readStoredSession(storage: SessionStorageLike): StoredSession | null {
const raw = storage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as StoredSession;
if (parsed && typeof parsed.token === 'string' && parsed.user) {
return parsed;
}
return null;
} catch {
return null;
}
}
export function writeStoredSession(
storage: SessionStorageLike,
session: StoredSession,
): void {
storage.setItem(STORAGE_KEY, JSON.stringify(session));
}
export function clearStoredSession(storage: SessionStorageLike): void {
storage.removeItem(STORAGE_KEY);
}
@@ -31,7 +31,16 @@ export class BootstrapService {
() => this.payload()?.passwordPolicy ?? DEFAULT_POLICY,
);
private loadPromise: Promise<BootstrapPayload | null> | null = null;
async load(): Promise<BootstrapPayload | null> {
if (!this.loadPromise) {
this.loadPromise = this._load();
}
return this.loadPromise;
}
private async _load(): Promise<BootstrapPayload | null> {
try {
const res = await fetch('/api/v1/bootstrap', { credentials: 'include' });
const data = (await res.json()) as BootstrapPayload;
@@ -45,6 +54,13 @@ export class BootstrapService {
}
}
ready(): Promise<BootstrapPayload | null> {
if (!this.loadPromise) {
this.loadPromise = this._load();
}
return this.loadPromise;
}
markInitialized(): void {
this.initialized.set(true);
}