Files
HIPCTF2/frontend/src/app/features/admin/challenges/challenge-form-modal.component.ts
T

629 lines
26 KiB
TypeScript

import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
HostListener,
effect,
inject,
input,
output,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { AbstractControl, FormBuilder, FormControl, FormGroup, ReactiveFormsModule, ValidationErrors, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
AdminCategory,
AdminChallengeDetail,
AdminChallengeFile,
AdminService,
} from '../../../core/services/admin.service';
import { MarkdownService } from '../../../core/services/markdown.service';
import {
ChallengeFormDefaults,
ChallengeFormErrors,
ChallengeFormFileItem,
ChallengeStagedFileUi,
CATEGORY_ID_PATTERN,
buildInitialFiles,
defaultMinimumPoints,
deriveMinimumPoints,
emptyChallengeFormDefaults,
firstErrorTab,
partitionFilesBySize,
prefillChallengeFormDefaults,
toCreateBody,
validateChallengeForm,
} from './challenge-form.pure';
export type ChallengeFormMode = 'create' | 'edit';
export interface ChallengeFormSubmitPayload {
body: ReturnType<typeof toCreateBody>;
}
interface ChallengeFormControls {
name: FormControl<string>;
descriptionMd: FormControl<string>;
categoryId: FormControl<string>;
difficulty: FormControl<'LOW' | 'MEDIUM' | 'HIGH'>;
initialPoints: FormControl<number>;
minimumPoints: FormControl<number>;
decaySolves: FormControl<number>;
flag: FormControl<string>;
protocol: FormControl<'NC' | 'WEB'>;
port: FormControl<number | string | null>;
ipAddress: FormControl<string>;
enabled: FormControl<boolean>;
}
export function buildChallengeFormGroup(fb: FormBuilder): FormGroup<ChallengeFormControls> {
return fb.nonNullable.group({
name: fb.nonNullable.control('', [Validators.required, Validators.maxLength(128)]),
descriptionMd: fb.nonNullable.control('', [Validators.required]),
categoryId: fb.nonNullable.control('', [Validators.required, categoryIdUuidValidator]),
difficulty: fb.nonNullable.control<'LOW' | 'MEDIUM' | 'HIGH'>('MEDIUM'),
initialPoints: fb.nonNullable.control(100, [Validators.required, Validators.min(1), Validators.max(100000)]),
minimumPoints: fb.nonNullable.control(50, [Validators.required, Validators.min(1)]),
decaySolves: fb.nonNullable.control(10, [Validators.required, Validators.min(1), Validators.max(10000)]),
flag: fb.nonNullable.control('', [Validators.required]),
protocol: fb.nonNullable.control<'NC' | 'WEB'>('WEB'),
port: fb.nonNullable.control<number | string | null>(null, [
Validators.pattern(/^\d+$/),
]),
ipAddress: fb.nonNullable.control(''),
enabled: fb.nonNullable.control(true),
});
}
export function syncChallengeForm(
form: FormGroup<ChallengeFormControls>,
mode: ChallengeFormMode,
detail: AdminChallengeDetail | null,
): ChallengeFormDefaults {
if (mode === 'edit' && detail) {
const defaults = prefillChallengeFormDefaults(detail);
form.patchValue(
{
name: defaults.name,
descriptionMd: defaults.descriptionMd,
categoryId: defaults.categoryId,
difficulty: defaults.difficulty,
initialPoints: defaults.initialPoints,
minimumPoints: defaults.minimumPoints,
decaySolves: defaults.decaySolves,
flag: defaults.flag,
protocol: defaults.protocol,
port: defaults.port,
ipAddress: defaults.ipAddress,
enabled: defaults.enabled,
},
{ emitEvent: false },
);
for (const name of Object.keys(form.controls) as (keyof ChallengeFormControls)[]) {
form.controls[name].markAsPristine();
form.controls[name].markAsUntouched();
}
return defaults;
}
const empty = emptyChallengeFormDefaults();
form.reset({
name: empty.name,
descriptionMd: empty.descriptionMd,
categoryId: empty.categoryId,
difficulty: empty.difficulty,
initialPoints: empty.initialPoints,
minimumPoints: empty.minimumPoints,
decaySolves: empty.decaySolves,
flag: empty.flag,
protocol: empty.protocol,
port: empty.port,
ipAddress: empty.ipAddress,
enabled: empty.enabled,
});
for (const name of Object.keys(form.controls) as (keyof ChallengeFormControls)[]) {
form.controls[name].markAsPristine();
form.controls[name].markAsUntouched();
}
return empty;
}
@Component({
selector: 'app-challenge-form-modal',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, ReactiveFormsModule],
styles: [`
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 60; padding: 16px; }
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: 8px; width: min(720px, 100%); max-height: 90vh; overflow: auto; }
.tabs { display: flex; gap: 4px; margin-bottom: 8px; }
.tabs button { padding: 6px 12px; border-radius: 4px; border: 1px solid var(--color-secondary, #ccc); background: transparent; cursor: pointer; }
.tabs button.active { background: var(--color-primary, #3b82f6); color: #fff; border-color: var(--color-primary, #3b82f6); }
.row { display: flex; flex-direction: column; gap: 4px; margin: 8px 0; }
.row label { font-weight: 600; font-size: 13px; }
.row .error { color: var(--color-danger, #f00); font-size: 12px; }
.markdown-preview { border: 1px solid var(--color-secondary, #ccc); padding: 8px; border-radius: 4px; min-height: 60px; }
.field-row { display: flex; gap: 8px; }
.field-row > * { flex: 1; }
.file-list { list-style: none; padding: 0; margin: 0; }
.file-list li { display: flex; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px solid var(--color-secondary, #ccc); }
.file-list li .remove { margin-left: auto; }
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.flag-input { display: flex; gap: 4px; }
.flag-input input { flex: 1; }
.preview-toggle { font-size: 12px; }
.upload-error { color: var(--color-danger, #f00); font-size: 12px; }
.drop-zone { border: 2px dashed var(--color-secondary, #ccc); border-radius: 6px; padding: 12px; text-align: center; transition: background 0.15s, border-color 0.15s; }
.drop-zone.dragover { background: rgba(59,130,246,0.08); border-color: var(--color-primary, #3b82f6); }
.drop-zone__hint { margin: 0 0 8px; font-size: 12px; color: var(--color-secondary, #666); }
`],
template: `
@if (open()) {
<div class="modal-overlay" role="presentation" (click)="onCancel()" data-testid="challenge-form-backdrop">
<form class="modal" role="dialog" aria-modal="true" [attr.aria-label]="mode() === 'create' ? 'Add challenge' : 'Edit challenge'" (click)="$event.stopPropagation()" (submit)="$event.preventDefault()" data-testid="challenge-form-modal">
<h2>{{ mode() === 'create' ? 'Add challenge' : 'Edit challenge' }}</h2>
<div class="tabs" role="tablist">
<button type="button" role="tab" [class.active]="tab() === 'general'" [attr.aria-selected]="tab() === 'general'" (click)="setTab('general')" data-testid="cf-tab-general">General</button>
<button type="button" role="tab" [class.active]="tab() === 'connection'" [attr.aria-selected]="tab() === 'connection'" (click)="setTab('connection')" data-testid="cf-tab-connection">Connection</button>
<button type="button" role="tab" [class.active]="tab() === 'files'" [attr.aria-selected]="tab() === 'files'" (click)="setTab('files')" data-testid="cf-tab-files">Files</button>
</div>
<div [formGroup]="form">
@if (tab() === 'general') {
<div class="row">
<label for="cf-name">Name</label>
<input id="cf-name" type="text" formControlName="name" maxlength="128" data-testid="cf-name" />
@if (showError('name'); as msg) { <span class="error" data-testid="cf-error-name">{{ msg }}</span> }
</div>
<div class="row">
<label for="cf-desc">Description (Markdown)</label>
<textarea id="cf-desc" rows="4" formControlName="descriptionMd" data-testid="cf-desc"></textarea>
<button type="button" class="preview-toggle" (click)="togglePreview()" data-testid="cf-desc-preview-toggle">
{{ showPreview() ? 'Hide preview' : 'Preview' }}
</button>
@if (showPreview()) {
<div class="markdown-preview" data-testid="cf-desc-preview" [innerHTML]="previewHtml()"></div>
}
@if (showError('descriptionMd'); as msg) { <span class="error" data-testid="cf-error-description">{{ msg }}</span> }
</div>
<div class="row">
<label for="cf-category">Category</label>
<select id="cf-category" formControlName="categoryId" data-testid="cf-category">
<option value="">Select a category…</option>
@for (cat of categories(); track cat.id) {
<option [value]="cat.id">{{ cat.abbreviation }} — {{ cat.name }}</option>
}
</select>
@if (showError('categoryId'); as msg) { <span class="error" data-testid="cf-error-category">{{ msg }}</span> }
</div>
<div class="row">
<label>Difficulty</label>
<div role="radiogroup" aria-label="Difficulty">
@for (d of difficulties; track d) {
<label>
<input type="radio" [value]="d" formControlName="difficulty" [attr.data-testid]="'cf-difficulty-' + d" /> {{ d }}
</label>
}
</div>
</div>
<div class="field-row">
<div class="row">
<label for="cf-initial">Initial points</label>
<input id="cf-initial" type="number" min="1" max="100000" formControlName="initialPoints" data-testid="cf-initial" />
@if (showError('initialPoints'); as msg) { <span class="error" data-testid="cf-error-initial">{{ msg }}</span> }
</div>
<div class="row">
<label for="cf-minimum">Minimum points</label>
<input id="cf-minimum" type="number" min="1" max="100000" formControlName="minimumPoints" data-testid="cf-minimum" />
@if (showError('minimumPoints'); as msg) { <span class="error" data-testid="cf-error-minimum">{{ msg }}</span> }
</div>
<div class="row">
<label for="cf-decay">Decay solves</label>
<input id="cf-decay" type="number" min="1" max="10000" formControlName="decaySolves" data-testid="cf-decay" />
@if (showError('decaySolves'); as msg) { <span class="error" data-testid="cf-error-decay">{{ msg }}</span> }
</div>
</div>
<div class="row">
<label for="cf-flag">Secret flag</label>
<div class="flag-input">
<input id="cf-flag" [type]="flagVisible() ? 'text' : 'password'" formControlName="flag" data-testid="cf-flag" />
<button type="button" (click)="toggleFlag()" [attr.aria-label]="flagVisible() ? 'Hide flag' : 'Reveal flag'" data-testid="cf-flag-toggle">{{ flagVisible() ? '🙈' : '👁' }}</button>
</div>
@if (showError('flag'); as msg) { <span class="error" data-testid="cf-error-flag">{{ msg }}</span> }
</div>
}
@if (tab() === 'connection') {
<div class="row">
<label>Protocol</label>
<div role="radiogroup" aria-label="Protocol">
@for (p of protocols; track p) {
<label>
<input type="radio" [value]="p" formControlName="protocol" [attr.data-testid]="'cf-protocol-' + p" /> {{ p }}
</label>
}
</div>
</div>
<div class="row">
<label for="cf-port">Port</label>
<input id="cf-port" type="text" inputmode="numeric" formControlName="port" data-testid="cf-port" />
@if (showError('port'); as msg) { <span class="error" data-testid="cf-error-port">{{ msg }}</span> }
</div>
<div class="row">
<label for="cf-ip">IP address (optional; falls back to Default Challenge IP)</label>
<input id="cf-ip" type="text" formControlName="ipAddress" data-testid="cf-ip" />
@if (showError('ipAddress'); as msg) { <span class="error" data-testid="cf-error-ip">{{ msg }}</span> }
</div>
}
@if (tab() === 'files') {
<ul class="file-list" data-testid="cf-files">
@for (file of files(); track $index) {
<li>
<span>{{ file.originalFilename }}</span>
<small>{{ file.mimeType }} · {{ formatSize(file.sizeBytes) }}</small>
<button type="button" class="remove" (click)="removeFile(file)" [attr.aria-label]="'Remove ' + file.originalFilename" data-testid="cf-file-remove">🗑</button>
</li>
}
</ul>
<div class="row">
<label for="cf-upload">Upload files (any file type)</label>
<div
class="drop-zone"
[class.dragover]="dragOver()"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
data-testid="cf-drop-zone"
>
<p class="drop-zone__hint">Drop files here or use the picker below</p>
<input id="cf-upload" type="file" multiple (change)="onFileChange($event)" data-testid="cf-upload" />
</div>
@if (fileUploadError(); as err) {
<span class="upload-error" data-testid="cf-upload-error">{{ err }}</span>
}
<small>Limit: {{ formatSize(globalFileLimit()) }} per file</small>
</div>
}
@if (errorMessage(); as msg) {
<p class="error" data-testid="cf-error" role="alert" style="margin-top: 8px;">{{ msg }}</p>
}
</div>
<div class="actions">
<button type="button" (click)="onCancel()" [disabled]="submitting()" data-testid="cf-cancel">Cancel</button>
<button type="button" (click)="onOk()" [disabled]="submitting()" data-testid="cf-ok">
{{ submitting() ? 'Saving…' : (mode() === 'create' ? 'Create' : 'Save') }}
</button>
</div>
</form>
</div>
}
`,
})
export class ChallengeFormModalComponent {
private readonly fb = inject(FormBuilder);
private readonly destroyRef = inject(DestroyRef);
private readonly cdr = inject(ChangeDetectorRef);
private readonly markdown = inject(MarkdownService);
private readonly admin = inject(AdminService);
readonly open = input(false);
readonly mode = input<ChallengeFormMode>('create');
readonly detail = input<AdminChallengeDetail | null>(null);
readonly categories = input<AdminCategory[]>([]);
readonly globalFileLimit = input<number>(100 * 1024 * 1024);
readonly errorMessage = input<string | null>(null);
readonly fieldErrors = input<ChallengeFormErrors | Record<string, string> | null>(null);
readonly saving = input(false);
readonly cancel = output<void>();
readonly submit = output<ChallengeFormSubmitPayload>();
readonly form: FormGroup<ChallengeFormControls> = buildChallengeFormGroup(this.fb);
readonly tab = signal<'general' | 'connection' | 'files'>('general');
readonly flagVisible = signal(false);
readonly showPreview = signal(false);
readonly previewHtml = signal('');
readonly files = signal<ChallengeStagedFileUi[]>([]);
readonly fileUploadError = signal<string | null>(null);
readonly submitting = signal(false);
readonly clientFieldErrors = signal<ChallengeFormErrors | null>(null);
readonly dragOver = signal(false);
readonly difficulties = ['LOW', 'MEDIUM', 'HIGH'] as const;
readonly protocols = ['NC', 'WEB'] as const;
private minimumTouched = false;
private previousInitial = 100;
private pendingRemoval: string[] = [];
constructor() {
effect(
() => {
const result = syncChallengeForm(this.form, this.mode(), this.detail());
this.minimumTouched = false;
this.previousInitial = result.initialPoints;
this.flagVisible.set(false);
this.showPreview.set(false);
this.previewHtml.set(this.markdown.render(result.descriptionMd));
this.files.set(buildInitialFiles(this.detail()));
this.pendingRemoval = [];
this.fileUploadError.set(null);
this.submitting.set(false);
this.clientFieldErrors.set(null);
this.dragOver.set(false);
this.cdr.markForCheck();
},
{ allowSignalWrites: true },
);
this.form.controls.descriptionMd.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((v) => {
this.previewHtml.set(this.markdown.render(v));
});
this.form.controls.initialPoints.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((value) => {
const next = Number(value);
if (Number.isFinite(next)) {
const currentMin = this.form.controls.minimumPoints.value;
const newMin = deriveMinimumPoints(next, currentMin, this.previousInitial, this.minimumTouched);
this.form.controls.minimumPoints.setValue(newMin, { emitEvent: false });
this.previousInitial = next;
}
});
this.form.controls.minimumPoints.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.minimumTouched = true;
});
this.form.controls.protocol.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((p) => {
if (p === 'WEB') {
this.form.controls.port.setValue(null, { emitEvent: false });
}
});
}
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.open()) this.onCancel();
}
setTab(tab: 'general' | 'connection' | 'files'): void {
this.tab.set(tab);
}
toggleFlag(): void {
this.flagVisible.set(!this.flagVisible());
}
togglePreview(): void {
this.showPreview.set(!this.showPreview());
}
showError(field: keyof ChallengeFormErrors): string | null {
const fromClient = (this.clientFieldErrors() ?? {})[field as string];
if (fromClient) return fromClient;
const fromServer = (this.fieldErrors() ?? {})[field as string];
if (fromServer) return fromServer;
const ctrl = this.form.controls[field as keyof ChallengeFormControls];
if (!ctrl || (!ctrl.touched && !ctrl.dirty)) return null;
if (ctrl.errors?.['required']) {
return `${humanLabel(field)} is required`;
}
if (ctrl.errors?.['maxlength']) {
return `${humanLabel(field)} is too long`;
}
if (ctrl.errors?.['min']) {
return `${humanLabel(field)} must be at least ${ctrl.errors['min'].min}`;
}
if (ctrl.errors?.['max']) {
return `${humanLabel(field)} must be at most ${ctrl.errors['max'].max}`;
}
if (ctrl.errors?.['pattern']) {
return `${humanLabel(field)} must be a whole number`;
}
if (ctrl.errors?.['uuid']) {
return `${humanLabel(field)} is required`;
}
return null;
}
onCancel(): void {
// discard any pending staged tokens
for (const f of this.files()) {
if (f.kind === 'staged' && f.token) {
void this.admin.discardChallengeFile(f.token).catch(() => undefined);
}
}
this.files.set([]);
this.pendingRemoval = [];
this.cancel.emit();
}
async onFileChange(ev: Event): Promise<void> {
const input = ev.target as HTMLInputElement;
const list = input.files ? Array.from(input.files) : [];
input.value = '';
await this.stageFiles(list);
}
onDragOver(ev: DragEvent): void {
ev.preventDefault();
if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'copy';
this.dragOver.set(true);
}
onDragLeave(ev: DragEvent): void {
ev.preventDefault();
this.dragOver.set(false);
}
async onDrop(ev: DragEvent): Promise<void> {
ev.preventDefault();
this.dragOver.set(false);
const list = ev.dataTransfer?.files ? Array.from(ev.dataTransfer.files) : [];
if (list.length === 0) return;
await this.stageFiles(list);
}
private async stageFiles(list: File[]): Promise<void> {
this.fileUploadError.set(null);
const { accepted, rejected } = partitionFilesBySize(list, this.globalFileLimit());
for (const r of rejected) {
this.fileUploadError.set(r.reason);
}
for (const a of accepted) {
this.files.update((arr) => [...arr, a.placeholder]);
try {
const res = await this.admin.stageChallengeFile(a.file);
this.files.update((arr) =>
arr.map((entry) =>
entry === a.placeholder
? { ...entry, token: res.token, uploading: false }
: entry,
),
);
} catch (e: any) {
this.files.update((arr) => arr.filter((entry) => entry !== a.placeholder));
this.fileUploadError.set(
`Failed to upload '${a.file.name}': ${e?.error?.message ?? e?.message ?? 'unknown error'}`,
);
}
}
}
removeFile(file: ChallengeStagedFileUi): void {
if (file.kind === 'staged' && file.token) {
void this.admin.discardChallengeFile(file.token).catch(() => undefined);
this.files.update((arr) => arr.filter((f) => f !== file));
} else if (file.kind === 'existing' && file.id) {
this.pendingRemoval.push(file.id);
this.files.update((arr) => arr.filter((f) => f !== file));
}
}
onOk(): void {
if (this.files().some((f) => f.uploading)) {
this.fileUploadError.set('Wait for uploads to finish before saving.');
return;
}
const categoryRaw = this.form.controls.categoryId.value;
if (!categoryRaw || !CATEGORY_ID_PATTERN.test(categoryRaw)) {
this.clientFieldErrors.set({ categoryId: 'Category is required' });
this.form.controls.categoryId.markAsTouched();
this.form.controls.categoryId.markAsDirty();
this.form.controls.categoryId.setErrors({ ...(this.form.controls.categoryId.errors ?? {}), uuid: true });
this.tab.set('general');
return;
}
const portRaw = this.form.controls.port.value;
const portAsNumber =
portRaw === null || portRaw === undefined || portRaw === ''
? null
: Number(portRaw);
const values: ChallengeFormDefaults = {
name: this.form.controls.name.value,
descriptionMd: this.form.controls.descriptionMd.value,
categoryId: this.form.controls.categoryId.value,
difficulty: this.form.controls.difficulty.value,
initialPoints: this.form.controls.initialPoints.value,
minimumPoints: this.form.controls.minimumPoints.value,
decaySolves: this.form.controls.decaySolves.value,
flag: this.form.controls.flag.value,
protocol: this.form.controls.protocol.value,
port: Number.isFinite(portAsNumber) ? portAsNumber : null,
ipAddress: this.form.controls.ipAddress.value,
enabled: this.form.controls.enabled.value,
};
const clientErrors = validateChallengeForm(values);
if (Object.keys(clientErrors).length > 0) {
this.clientFieldErrors.set(clientErrors);
for (const f of Object.keys(clientErrors) as (keyof ChallengeFormControls)[]) {
const ctrl = this.form.controls[f];
if (ctrl) {
ctrl.markAsTouched();
ctrl.markAsDirty();
}
}
const tab = firstErrorTab(clientErrors);
if (tab) this.tab.set(tab);
return;
}
this.clientFieldErrors.set(null);
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.form.markAllAsTouched();
const filePayload: ChallengeFormFileItem[] = this.files().map((f) => ({
id: f.kind === 'existing' ? f.id : undefined,
stagedToken: f.kind === 'staged' ? f.token : undefined,
originalFilename: f.originalFilename,
mimeType: f.mimeType,
sizeBytes: f.sizeBytes,
}));
for (const id of this.pendingRemoval) {
filePayload.push({
id,
originalFilename: '',
mimeType: '',
sizeBytes: 0,
remove: true,
});
}
const body = toCreateBody(values, filePayload);
this.submit.emit({ body });
}
formatSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
}
function humanLabel(field: keyof ChallengeFormErrors): string {
switch (field) {
case 'name': return 'Name';
case 'descriptionMd': return 'Description';
case 'categoryId': return 'Category';
case 'initialPoints': return 'Initial points';
case 'minimumPoints': return 'Minimum points';
case 'decaySolves': return 'Decay solves';
case 'flag': return 'Flag';
case 'port': return 'Port';
case 'ipAddress': return 'IP address';
case 'protocol': return 'Protocol';
default: return 'Field';
}
}
function categoryIdUuidValidator(control: AbstractControl): ValidationErrors | null {
const raw = control.value;
if (raw === null || raw === undefined || raw === '') {
return null;
}
return CATEGORY_ID_PATTERN.test(String(raw)) ? null : { uuid: true };
}
// Re-export so tests can find default helpers.
export { defaultMinimumPoints };