AI Implementation feature(841): First-Start Create Admin Modal 1.00 (#3)

This commit was merged in pull request #3.
This commit is contained in:
2026-07-21 15:07:44 +00:00
parent 9f67ec8c50
commit 960516a7bc
18 changed files with 518 additions and 112 deletions
+10 -1
View File
@@ -1,5 +1,6 @@
import { Routes } from '@angular/router';
import { authGuard } from './core/guards/auth.guard';
import { adminGuard } from './core/guards/admin.guard';
export const APP_ROUTES: Routes = [
{
@@ -17,6 +18,14 @@ export const APP_ROUTES: Routes = [
path: '',
loadComponent: () => import('./features/home/home.component').then((m) => m.HomeComponent),
canActivate: [authGuard],
children: [
{
path: 'admin',
canActivate: [adminGuard],
loadComponent: () =>
import('./features/admin/admin-users.component').then((m) => m.AdminUsersComponent),
},
],
},
{ path: '**', redirectTo: '' },
];
];
@@ -0,0 +1,14 @@
export type AdminGuardDecision =
| { kind: 'allow' }
| { kind: 'redirect'; path: '/bootstrap' | '/login' | '/' };
export function decideAdminGuard(input: {
initialized: boolean;
isAuthenticated: boolean;
role: 'admin' | 'player' | undefined;
}): AdminGuardDecision {
if (!input.initialized) return { kind: 'redirect', path: '/bootstrap' };
if (!input.isAuthenticated) return { kind: 'redirect', path: '/login' };
if (input.role !== 'admin') return { kind: 'redirect', path: '/' };
return { kind: 'allow' };
}
@@ -0,0 +1,23 @@
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';
export { decideAdminGuard } from './admin.guard.decision';
export type { AdminGuardDecision } from './admin.guard.decision';
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]);
};
@@ -0,0 +1,20 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
export interface AdminUser {
id: string;
username: string;
role: 'admin' | 'player';
}
@Injectable({ providedIn: 'root' })
export class AdminService {
private readonly http = inject(HttpClient);
async listUsers(): Promise<AdminUser[]> {
return firstValueFrom(
this.http.get<AdminUser[]>('/api/v1/admin/users', { withCredentials: true }),
);
}
}
@@ -0,0 +1,48 @@
import {
ChangeDetectionStrategy,
Component,
OnInit,
inject,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { AdminService, AdminUser } from '../../core/services/admin.service';
@Component({
selector: 'app-admin-users',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
template: `
<section class="admin-area">
<h2>Admin area</h2>
<p data-testid="admin-loading" *ngIf="loading()">Loading users...</p>
<p data-testid="admin-error" *ngIf="error()" style="color:var(--color-danger)">{{ error() }}</p>
<ul data-testid="admin-user-list" *ngIf="!loading() && !error()">
<li *ngFor="let u of users()">
<b>{{ u.username }}</b> ({{ u.role }})
</li>
</ul>
</section>
`,
})
export class AdminUsersComponent implements OnInit {
private readonly admin = inject(AdminService);
readonly users = signal<AdminUser[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
async ngOnInit(): Promise<void> {
this.loading.set(true);
this.error.set(null);
try {
const list = await this.admin.listUsers();
this.users.set(list);
} catch (e: any) {
this.error.set(e?.error?.message ?? e?.message ?? 'Failed to load users');
} finally {
this.loading.set(false);
}
}
}
@@ -1,26 +1,47 @@
import { Component, inject } from '@angular/core';
import { Component, computed, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink, RouterOutlet } from '@angular/router';
import { BootstrapService } from '../../core/services/bootstrap.service';
import { AuthService } from '../../core/services/auth.service';
import { shouldShowAdminNav } from './home.shell';
export { shouldShowAdminNav } from './home.shell';
@Component({
selector: 'app-home',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, RouterLink, RouterOutlet],
template: `
<div class="container">
<h1>{{ bootstrap.payload()?.pageTitle ?? 'HIPCTF' }}</h1>
<div *ngIf="!auth.isAuthenticated()">
<p>{{ bootstrap.payload()?.welcomeMarkdown }}</p>
<a href="/login">Sign in</a>
</div>
<div *ngIf="auth.isAuthenticated()">
<p>Signed in as <b>{{ auth.currentUser()?.username }}</b> ({{ auth.currentUser()?.role }})</p>
</div>
<header class="shell-header">
<h1 class="shell-title">{{ bootstrap.payload()?.pageTitle ?? 'HIPCTF' }}</h1>
<div class="shell-user" *ngIf="auth.isAuthenticated()">
<span>Signed in as <b>{{ auth.currentUser()?.username }}</b> ({{ auth.currentUser()?.role }})</span>
</div>
</header>
<nav class="shell-nav" *ngIf="showAdminNav()">
<a routerLink="/admin" data-testid="nav-admin">Admin</a>
</nav>
<section class="shell-body">
<div *ngIf="!auth.isAuthenticated()">
<p>{{ bootstrap.payload()?.welcomeMarkdown }}</p>
<a href="/login">Sign in</a>
</div>
<router-outlet></router-outlet>
</section>
</div>
`,
})
export class HomeComponent {
bootstrap = inject(BootstrapService);
auth = inject(AuthService);
}
readonly showAdminNav = computed(() =>
shouldShowAdminNav({
isAuthenticated: this.auth.isAuthenticated(),
role: this.auth.currentUser()?.role,
}),
);
}
@@ -0,0 +1,6 @@
export function shouldShowAdminNav(input: {
isAuthenticated: boolean;
role: 'admin' | 'player' | undefined;
}): boolean {
return input.isAuthenticated && input.role === 'admin';
}
@@ -102,7 +102,7 @@ export class SetupCreateAdminComponent {
if (result.ok) {
this.auth.setSession(result.accessToken, result.user);
this.bootstrap.markInitialized();
await this.router.navigateByUrl('/challenges');
await this.router.navigateByUrl('/admin');
return;
}