AI Implementation feature(820): Project Scaffold and Platform Foundations (#1)
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { BootstrapService } from './core/services/bootstrap.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet],
|
||||
template: `<router-outlet></router-outlet>`,
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
private bootstrap = inject(BootstrapService);
|
||||
async ngOnInit() {
|
||||
await this.bootstrap.load();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { authGuard } from './core/guards/auth.guard';
|
||||
|
||||
export const APP_ROUTES: Routes = [
|
||||
{
|
||||
path: 'bootstrap',
|
||||
loadComponent: () => import('./features/bootstrap/create-admin.component').then((m) => m.CreateAdminComponent),
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () => import('./features/auth/login.component').then((m) => m.LoginComponent),
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () => import('./features/home/home.component').then((m) => m.HomeComponent),
|
||||
canActivate: [authGuard],
|
||||
},
|
||||
{ path: '**', redirectTo: '' },
|
||||
];
|
||||
@@ -0,0 +1,18 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { BootstrapService } from '../services/bootstrap.service';
|
||||
|
||||
export const authGuard: CanActivateFn = () => {
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const auth = inject(AuthService);
|
||||
const token = auth.getAccessToken();
|
||||
if (token) {
|
||||
req = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
|
||||
}
|
||||
return next(req);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
|
||||
function readCookie(name: string): string | null {
|
||||
const header = document.cookie;
|
||||
if (!header) return null;
|
||||
for (const part of header.split(';')) {
|
||||
const [k, ...v] = part.trim().split('=');
|
||||
if (k === name) return v.join('=');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
|
||||
export const csrfInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
if (UNSAFE.has(req.method)) {
|
||||
const token = readCookie('csrf');
|
||||
if (token) {
|
||||
req = req.clone({ setHeaders: { 'X-CSRF-Token': token } });
|
||||
}
|
||||
}
|
||||
return next(req);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Injectable, signal, computed } from '@angular/core';
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string;
|
||||
username: string;
|
||||
role: 'admin' | 'player';
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private accessToken = signal<string | null>(null);
|
||||
private user = signal<CurrentUser | null>(null);
|
||||
|
||||
readonly currentUser = computed(() => this.user());
|
||||
readonly isAuthenticated = computed(() => !!this.user());
|
||||
|
||||
setSession(token: string, user: CurrentUser): void {
|
||||
this.accessToken.set(token);
|
||||
this.user.set(user);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.accessToken.set(null);
|
||||
this.user.set(null);
|
||||
}
|
||||
|
||||
getAccessToken(): string | null {
|
||||
return this.accessToken();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export interface BootstrapPayload {
|
||||
initialized: boolean;
|
||||
pageTitle: string;
|
||||
logo: string;
|
||||
welcomeMarkdown: string;
|
||||
theme: any;
|
||||
defaultChallengeIp: string;
|
||||
registrationsEnabled: boolean;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BootstrapService {
|
||||
readonly payload = signal<BootstrapPayload | null>(null);
|
||||
readonly initialized = signal<boolean>(false);
|
||||
|
||||
async load(): Promise<BootstrapPayload | null> {
|
||||
try {
|
||||
const res = await fetch('/api/v1/bootstrap', { credentials: 'include' });
|
||||
const data = (await res.json()) as BootstrapPayload;
|
||||
this.payload.set(data);
|
||||
this.initialized.set(data.initialized);
|
||||
this.applyTheme(data.theme);
|
||||
return data;
|
||||
} catch (e) {
|
||||
console.error('Bootstrap load failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private applyTheme(theme: any): void {
|
||||
if (!theme?.tokens) return;
|
||||
const root = document.documentElement.style;
|
||||
const t = theme.tokens;
|
||||
root.setProperty('--color-primary', t.primary);
|
||||
root.setProperty('--color-secondary', t.secondary);
|
||||
root.setProperty('--color-accent', t.accent);
|
||||
root.setProperty('--color-surface', t.surface);
|
||||
root.setProperty('--color-text', t.text);
|
||||
root.setProperty('--color-success', t.success);
|
||||
root.setProperty('--color-warning', t.warning);
|
||||
root.setProperty('--color-danger', t.danger);
|
||||
root.setProperty('--font-family', t.fontFamily);
|
||||
root.setProperty('--radius-sm', t.radii.sm);
|
||||
root.setProperty('--radius-md', t.radii.md);
|
||||
root.setProperty('--radius-lg', t.radii.lg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [FormsModule],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h1>Sign in</h1>
|
||||
<label>Username<input [(ngModel)]="username" name="u" /></label>
|
||||
<label>Password<input [(ngModel)]="password" name="p" type="password" /></label>
|
||||
<p style="color:var(--color-danger)">{{ error }}</p>
|
||||
<button (click)="submit()" [disabled]="loading">{{ loading ? 'Signing in...' : 'Sign in' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LoginComponent {
|
||||
private http = inject(HttpClient);
|
||||
private auth = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
|
||||
username = '';
|
||||
password = '';
|
||||
loading = false;
|
||||
error = '';
|
||||
|
||||
async submit(): Promise<void> {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
try {
|
||||
await fetch('/api/v1/auth/csrf', { credentials: 'include' });
|
||||
const res: any = await this.http
|
||||
.post('/api/v1/auth/login', { username: this.username, password: this.password }, { withCredentials: true })
|
||||
.toPromise();
|
||||
this.auth.setSession(res.accessToken, res.user);
|
||||
await this.router.navigateByUrl('/');
|
||||
} catch (e: any) {
|
||||
this.error = e?.error?.message ?? 'Failed';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
|
||||
@Component({
|
||||
selector: 'app-create-admin',
|
||||
standalone: true,
|
||||
imports: [FormsModule],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h1>Create the first admin</h1>
|
||||
<p>This HIPCTF instance has not been initialized.</p>
|
||||
<label>Username<input [(ngModel)]="username" name="username" /></label>
|
||||
<label>Password<input [(ngModel)]="password" name="password" type="password" /></label>
|
||||
<p style="color:var(--color-danger)">{{ error }}</p>
|
||||
<button (click)="submit()" [disabled]="loading">{{ loading ? 'Creating...' : 'Create admin' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class CreateAdminComponent {
|
||||
private http = inject(HttpClient);
|
||||
private auth = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
|
||||
username = '';
|
||||
password = '';
|
||||
loading = false;
|
||||
error = '';
|
||||
|
||||
async submit(): Promise<void> {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
try {
|
||||
await fetch('/api/v1/auth/csrf', { credentials: 'include' });
|
||||
const res: any = await this.http
|
||||
.post('/api/v1/auth/register-first-admin', { username: this.username, password: this.password }, { withCredentials: true })
|
||||
.toPromise();
|
||||
this.auth.setSession(res.accessToken, res.user);
|
||||
await this.router.navigateByUrl('/');
|
||||
} catch (e: any) {
|
||||
this.error = e?.error?.message ?? 'Failed';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { BootstrapService } from '../../core/services/bootstrap.service';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
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>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class HomeComponent {
|
||||
bootstrap = inject(BootstrapService);
|
||||
auth = inject(AuthService);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>HIPCTF</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<base href="/" />
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'zone.js';
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { provideHttpClient, withInterceptors, HttpInterceptorFn } from '@angular/common/http';
|
||||
import { provideRouter, withComponentInputBinding } from '@angular/router';
|
||||
import { AppComponent } from './app/app.component';
|
||||
import { APP_ROUTES } from './app/app.routes';
|
||||
import { authInterceptor } from './app/core/interceptors/auth.interceptor';
|
||||
import { csrfInterceptor } from './app/core/interceptors/csrf.interceptor';
|
||||
|
||||
bootstrapApplication(AppComponent, {
|
||||
providers: [
|
||||
provideHttpClient(withInterceptors([csrfInterceptor, authInterceptor])),
|
||||
provideRouter(APP_ROUTES, withComponentInputBinding()),
|
||||
],
|
||||
}).catch((err) => console.error(err));
|
||||
@@ -0,0 +1,22 @@
|
||||
:root {
|
||||
--color-primary: #3b82f6;
|
||||
--color-secondary: #64748b;
|
||||
--color-accent: #f59e0b;
|
||||
--color-surface: #ffffff;
|
||||
--color-text: #0f172a;
|
||||
--color-success: #10b981;
|
||||
--color-warning: #f59e0b;
|
||||
--color-danger: #ef4444;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 16px;
|
||||
--font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; height: 100%; font-family: var(--font-family); background: var(--color-surface); color: var(--color-text); }
|
||||
button { font: inherit; padding: 8px 16px; border: 0; border-radius: var(--radius-md); background: var(--color-primary); color: #fff; cursor: pointer; }
|
||||
button[disabled] { opacity: .5; cursor: not-allowed; }
|
||||
input { font: inherit; padding: 8px 12px; border: 1px solid var(--color-secondary); border-radius: var(--radius-sm); width: 100%; }
|
||||
.container { max-width: 960px; margin: 0 auto; padding: 24px; }
|
||||
.card { padding: 24px; border-radius: var(--radius-lg); box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
||||
Reference in New Issue
Block a user