feat: First-Start Create Admin Modal 1.01
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { BootstrapService } from './core/services/bootstrap.service';
|
||||
import { AuthService } from './core/services/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -10,7 +11,9 @@ import { BootstrapService } from './core/services/bootstrap.service';
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
private bootstrap = inject(BootstrapService);
|
||||
private auth = inject(AuthService);
|
||||
async ngOnInit() {
|
||||
await this.bootstrap.load();
|
||||
await this.auth.restoreSession();
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,36 @@ import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { BootstrapService } from '../services/bootstrap.service';
|
||||
import { decideAdminGuard } from './admin.guard.decision';
|
||||
import { decideAdminGuard, AdminGuardDecision } from './admin.guard.decision';
|
||||
|
||||
export { decideAdminGuard } from './admin.guard.decision';
|
||||
export type { AdminGuardDecision } from './admin.guard.decision';
|
||||
|
||||
export interface AdminGuardDeps {
|
||||
auth: AuthService;
|
||||
bootstrap: BootstrapService;
|
||||
router: Router;
|
||||
}
|
||||
|
||||
export async function decideAdminAccessGuard(
|
||||
deps: AdminGuardDeps,
|
||||
): Promise<true | ReturnType<Router['createUrlTree']>> {
|
||||
await deps.bootstrap.ready();
|
||||
await deps.auth.waitUntilHydrated();
|
||||
|
||||
const decision: AdminGuardDecision = decideAdminGuard({
|
||||
initialized: deps.bootstrap.initialized(),
|
||||
isAuthenticated: deps.auth.isAuthenticated(),
|
||||
role: deps.auth.currentUser()?.role,
|
||||
});
|
||||
|
||||
if (decision.kind === 'allow') return true;
|
||||
return deps.router.createUrlTree([decision.path]);
|
||||
}
|
||||
|
||||
export const adminGuard: CanActivateFn = () => {
|
||||
const auth = inject(AuthService);
|
||||
const bootstrap = inject(BootstrapService);
|
||||
const router = inject(Router);
|
||||
|
||||
const decision = decideAdminGuard({
|
||||
initialized: bootstrap.initialized(),
|
||||
isAuthenticated: auth.isAuthenticated(),
|
||||
role: auth.currentUser()?.role,
|
||||
});
|
||||
|
||||
if (decision.kind === 'allow') return true;
|
||||
return router.createUrlTree([decision.path]);
|
||||
};
|
||||
return decideAdminAccessGuard({ auth, bootstrap, router });
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Router } from '@angular/router';
|
||||
|
||||
export interface AuthGuardInput {
|
||||
initialized: boolean;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
export function decideAuthRedirect(
|
||||
input: AuthGuardInput,
|
||||
router: Pick<Router, 'createUrlTree'>,
|
||||
): true | ReturnType<Router['createUrlTree']> {
|
||||
if (!input.initialized) {
|
||||
return router.createUrlTree(['/bootstrap']);
|
||||
}
|
||||
if (!input.isAuthenticated) {
|
||||
return router.createUrlTree(['/login']);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -2,17 +2,24 @@ import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { BootstrapService } from '../services/bootstrap.service';
|
||||
import { decideAuthRedirect } from './auth.guard.decision';
|
||||
|
||||
export const authGuard: CanActivateFn = () => {
|
||||
export { decideAuthRedirect } from './auth.guard.decision';
|
||||
export type { AuthGuardInput } from './auth.guard.decision';
|
||||
|
||||
export const authGuard: CanActivateFn = async () => {
|
||||
const auth = inject(AuthService);
|
||||
const bootstrap = inject(BootstrapService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (!bootstrap.initialized()) {
|
||||
return router.createUrlTree(['/bootstrap']);
|
||||
}
|
||||
if (!auth.isAuthenticated()) {
|
||||
return router.createUrlTree(['/login']);
|
||||
}
|
||||
return true;
|
||||
await bootstrap.ready();
|
||||
await auth.waitUntilHydrated();
|
||||
|
||||
return decideAuthRedirect(
|
||||
{
|
||||
initialized: bootstrap.initialized(),
|
||||
isAuthenticated: auth.isAuthenticated(),
|
||||
},
|
||||
router,
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Component,
|
||||
HostListener,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
@@ -32,6 +33,16 @@ export class SetupCreateAdminComponent {
|
||||
private readonly router = inject(Router);
|
||||
private readonly bootstrap = inject(BootstrapService);
|
||||
|
||||
private readonly alreadyInitialized = computed(() => this.bootstrap.initialized());
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.alreadyInitialized()) {
|
||||
void this.router.navigateByUrl(this.auth.isAuthenticated() ? '/' : '/login');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
readonly policyDescription = computed(() => this.bootstrap.passwordPolicy().description);
|
||||
|
||||
readonly form = this.fb.nonNullable.group(
|
||||
|
||||
Reference in New Issue
Block a user