AI Implementation feature(821): First-Start Create Admin Modal (#2)
This commit was merged in pull request #2.
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user