feat: First-Start Create Admin Modal

This commit is contained in:
OpenVelo Agent
2026-07-21 14:45:53 +00:00
parent 81fd3d8a29
commit 8642669952
22 changed files with 1009 additions and 130 deletions
+4 -1
View File
@@ -4,7 +4,10 @@ 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),
loadComponent: () =>
import('./features/setup/setup-create-admin.component').then(
(m) => m.SetupCreateAdminComponent,
),
},
{
path: 'login',
@@ -1,4 +1,10 @@
import { Injectable, signal } from '@angular/core';
import { Injectable, signal, computed } from '@angular/core';
export interface PasswordPolicyInfo {
minLength: number;
requireMixed: boolean;
description: string;
}
export interface BootstrapPayload {
initialized: boolean;
@@ -8,12 +14,22 @@ export interface BootstrapPayload {
theme: any;
defaultChallengeIp: string;
registrationsEnabled: boolean;
passwordPolicy: PasswordPolicyInfo;
}
const DEFAULT_POLICY: PasswordPolicyInfo = {
minLength: 12,
requireMixed: true,
description: 'At least 12 characters with upper, lower, digit and symbol',
};
@Injectable({ providedIn: 'root' })
export class BootstrapService {
readonly payload = signal<BootstrapPayload | null>(null);
readonly initialized = signal<boolean>(false);
readonly passwordPolicy = computed<PasswordPolicyInfo>(
() => this.payload()?.passwordPolicy ?? DEFAULT_POLICY,
);
async load(): Promise<BootstrapPayload | null> {
try {
@@ -29,6 +45,10 @@ export class BootstrapService {
}
}
markInitialized(): void {
this.initialized.set(true);
}
private applyTheme(theme: any): void {
if (!theme?.tokens) return;
const root = document.documentElement.style;
@@ -0,0 +1,135 @@
:host {
display: contents;
}
.overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-family, system-ui, sans-serif);
}
.modal {
width: min(440px, calc(100vw - 32px));
background: var(--color-surface, #1b1b1f);
color: var(--color-text, #f5f5f7);
border-radius: var(--radius-md, 12px);
padding: 28px 28px 24px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.modal h1 {
margin: 0 0 4px;
font-size: 18px;
font-weight: 600;
}
.subtitle {
margin: 0 0 20px;
font-size: 13px;
color: var(--color-text, #f5f5f7);
opacity: 0.7;
}
form {
display: flex;
flex-direction: column;
gap: 14px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
}
.field > span {
font-weight: 500;
}
.field input {
font: inherit;
padding: 10px 12px;
border-radius: var(--radius-sm, 6px);
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: inherit;
outline: none;
}
.field input:focus {
border-color: var(--color-primary, #4f8cff);
}
.hint {
font-size: 11px;
opacity: 0.65;
}
.field-error {
color: var(--color-danger, #ff6b6b);
font-size: 12px;
}
.server-error {
display: flex;
align-items: center;
gap: 12px;
background: rgba(255, 107, 107, 0.12);
border: 1px solid rgba(255, 107, 107, 0.35);
border-radius: var(--radius-sm, 6px);
padding: 8px 12px;
font-size: 13px;
color: var(--color-danger, #ff6b6b);
}
.server-error .link {
background: transparent;
border: 0;
color: inherit;
text-decoration: underline;
cursor: pointer;
font: inherit;
}
button.primary {
margin-top: 6px;
padding: 11px 14px;
font: inherit;
font-weight: 600;
border-radius: var(--radius-sm, 6px);
border: 0;
background: var(--color-primary, #4f8cff);
color: #fff;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
button.primary[disabled] {
cursor: not-allowed;
opacity: 0.65;
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,69 @@
<div class="overlay" (click)="swallowBackdrop($event)">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="setup-title" (click)="$event.stopPropagation()">
<h1 id="setup-title">Welcome to HIPCTF — Create the first administrator</h1>
<p class="subtitle">This instance has not been initialized yet.</p>
<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
<label class="field">
<span>Username</span>
<input
type="text"
autocomplete="username"
formControlName="username"
[attr.aria-invalid]="!!usernameErrors()"
data-testid="setup-username"
/>
@if (usernameErrors()) {
<small class="field-error" data-testid="setup-username-error">{{ usernameErrors() }}</small>
}
</label>
<label class="field">
<span>Password</span>
<input
type="password"
autocomplete="new-password"
formControlName="password"
data-testid="setup-password"
/>
<small class="hint">{{ policyDescription() }}</small>
</label>
<label class="field">
<span>Confirm Password</span>
<input
type="password"
autocomplete="new-password"
formControlName="passwordConfirm"
data-testid="setup-password-confirm"
/>
@if (confirmErrors()) {
<small class="field-error" data-testid="setup-confirm-error">{{ confirmErrors() }}</small>
}
</label>
@if (serverError()) {
<div class="server-error" role="alert" data-testid="setup-server-error">
<span>{{ serverError() }}</span>
@if (canRetry()) {
<button type="button" class="link" (click)="submit()" data-testid="setup-retry">Retry</button>
}
</div>
}
<button
type="submit"
class="primary"
[disabled]="submitting() || form.invalid"
data-testid="setup-submit"
>
@if (submitting()) {
<span class="spinner" aria-hidden="true"></span>
<span>Creating...</span>
} @else {
<span>Create admin</span>
}
</button>
</form>
</div>
</div>
@@ -0,0 +1,119 @@
import {
ChangeDetectionStrategy,
Component,
HostListener,
computed,
inject,
signal,
} from '@angular/core';
import {
FormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from '../../core/services/auth.service';
import { BootstrapService } from '../../core/services/bootstrap.service';
import { SetupCreateAdminService, CreateAdminFailure } from './setup-create-admin.service';
import { passwordMatchValidator } from './setup-create-admin.validators';
@Component({
selector: 'app-setup-create-admin',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule],
templateUrl: './setup-create-admin.component.html',
styleUrls: ['./setup-create-admin.component.css'],
})
export class SetupCreateAdminComponent {
private readonly fb = inject(FormBuilder);
private readonly api = inject(SetupCreateAdminService);
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
private readonly bootstrap = inject(BootstrapService);
readonly policyDescription = computed(() => this.bootstrap.passwordPolicy().description);
readonly form = this.fb.nonNullable.group(
{
username: this.fb.nonNullable.control('', [
Validators.required,
Validators.minLength(3),
Validators.maxLength(32),
Validators.pattern(/^[a-zA-Z0-9_.-]+$/),
]),
password: this.fb.nonNullable.control('', [Validators.required]),
passwordConfirm: this.fb.nonNullable.control('', [Validators.required]),
},
{ validators: passwordMatchValidator },
);
readonly submitting = signal(false);
readonly serverError = signal<string | null>(null);
readonly serverErrorCode = signal<string | null>(null);
readonly canRetry = computed(
() => !this.submitting() && this.serverError() !== null && this.serverErrorCode() !== 'USERNAME_TAKEN',
);
readonly usernameErrors = computed(() => {
const c = this.form.controls.username;
if (!c.touched || c.valid) return null;
if (c.errors?.['required']) return 'Username is required';
if (c.errors?.['minlength']) return 'Username must be at least 3 characters';
if (c.errors?.['maxlength']) return 'Username must be at most 32 characters';
if (c.errors?.['pattern']) return 'Username may only contain letters, digits, dot, dash and underscore';
return 'Invalid username';
});
readonly confirmErrors = computed(() => {
const c = this.form.controls.passwordConfirm;
const groupMismatch = this.form.errors?.['passwordMismatch'];
if ((!c.touched || c.valid) && !groupMismatch) return null;
if (c.errors?.['required']) return 'Please confirm your password';
if (groupMismatch) return 'Passwords do not match';
return 'Invalid value';
});
@HostListener('document:keydown.escape', ['$event'])
swallowEscape(event: KeyboardEvent): void {
event.preventDefault();
event.stopPropagation();
}
swallowBackdrop(_event: MouseEvent): void {
// intentionally no-op: modal cannot be dismissed by clicking the backdrop
}
async submit(): Promise<void> {
if (this.submitting()) return;
this.form.markAllAsTouched();
if (this.form.invalid) return;
this.submitting.set(true);
this.serverError.set(null);
this.serverErrorCode.set(null);
const { username, password, passwordConfirm } = this.form.getRawValue();
const result = await this.api.create({ username, password, passwordConfirm });
this.submitting.set(false);
if (result.ok) {
this.auth.setSession(result.accessToken, result.user);
this.bootstrap.markInitialized();
await this.router.navigateByUrl('/challenges');
return;
}
const failure = result as CreateAdminFailure;
if (failure.code === 'USERNAME_TAKEN') {
this.serverError.set('Username already exists');
this.serverErrorCode.set('USERNAME_TAKEN');
return;
}
this.serverError.set('Setup failed, please retry');
this.serverErrorCode.set(failure.code);
}
}
@@ -0,0 +1,66 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
export interface CreateAdminRequest {
username: string;
password: string;
passwordConfirm: string;
}
export interface CreateAdminSuccess {
ok: true;
accessToken: string;
expiresIn: number;
user: { id: string; username: string; role: 'admin' | 'player' };
}
export interface CreateAdminFailure {
ok: false;
status: number;
code: string;
message: string;
}
export type CreateAdminResult = CreateAdminSuccess | CreateAdminFailure;
@Injectable({ providedIn: 'root' })
export class SetupCreateAdminService {
private readonly http = inject(HttpClient);
async ensureCsrf(): Promise<void> {
try {
await fetch('/api/v1/auth/csrf', { credentials: 'include' });
} catch {
// ignore: csrf middleware will mint a cookie if missing
}
}
async create(body: CreateAdminRequest): Promise<CreateAdminResult> {
await this.ensureCsrf();
try {
const res = await firstValueFrom(
this.http.post<{
accessToken: string;
expiresIn: number;
user: { id: string; username: string; role: 'admin' | 'player' };
}>('/api/v1/setup/create-admin', body, { withCredentials: true, observe: 'response' }),
);
return {
ok: true,
accessToken: res.body!.accessToken,
expiresIn: res.body!.expiresIn,
user: res.body!.user,
};
} catch (e) {
return mapError(e as HttpErrorResponse);
}
}
}
function mapError(err: HttpErrorResponse): CreateAdminFailure {
const body = err.error as { code?: string; message?: string } | null;
const code = body?.code ?? 'INTERNAL';
const message = body?.message ?? err.message ?? 'Setup failed';
return { ok: false, status: err.status ?? 0, code, message };
}
@@ -0,0 +1,20 @@
export interface PasswordMatchGroupLike {
get(name: 'password' | 'passwordConfirm'): { value: string | null | undefined } | null;
}
export interface PasswordMatchResult {
passwordMismatch?: boolean;
}
/**
* Group-level validator for the "Confirm Password" field.
* Returns `{ passwordMismatch: true }` when the two values differ,
* or `null` when they match (or when either is empty, so that
* `required`/`minlength` validators can take over).
*/
export function passwordMatchValidator(group: PasswordMatchGroupLike): PasswordMatchResult | null {
const password = group.get('password')?.value;
const confirm = group.get('passwordConfirm')?.value;
if (!password || !confirm) return null;
return password === confirm ? null : { passwordMismatch: true };
}