AI Implementation feature(901): Admin Area Challenges: List, Import/Export and Add/Edit Modal 1.01 (#42)
This commit was merged in pull request #42.
This commit is contained in:
@@ -29,6 +29,8 @@ import {
|
||||
defaultMinimumPoints,
|
||||
deriveMinimumPoints,
|
||||
emptyChallengeFormDefaults,
|
||||
firstErrorTab,
|
||||
partitionFilesBySize,
|
||||
prefillChallengeFormDefaults,
|
||||
toCreateBody,
|
||||
validateChallengeForm,
|
||||
@@ -50,7 +52,7 @@ interface ChallengeFormControls {
|
||||
decaySolves: FormControl<number>;
|
||||
flag: FormControl<string>;
|
||||
protocol: FormControl<'NC' | 'WEB'>;
|
||||
port: FormControl<number | null>;
|
||||
port: FormControl<number | string | null>;
|
||||
ipAddress: FormControl<string>;
|
||||
enabled: FormControl<boolean>;
|
||||
}
|
||||
@@ -66,7 +68,9 @@ export function buildChallengeFormGroup(fb: FormBuilder): FormGroup<ChallengeFor
|
||||
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 | null>(null),
|
||||
port: fb.nonNullable.control<number | string | null>(null, [
|
||||
Validators.pattern(/^\d+$/),
|
||||
]),
|
||||
ipAddress: fb.nonNullable.control(''),
|
||||
enabled: fb.nonNullable.control(true),
|
||||
});
|
||||
@@ -149,6 +153,9 @@ export function syncChallengeForm(
|
||||
.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()) {
|
||||
@@ -246,7 +253,7 @@ export function syncChallengeForm(
|
||||
|
||||
<div class="row">
|
||||
<label for="cf-port">Port</label>
|
||||
<input id="cf-port" type="number" min="1" max="65535" formControlName="port" data-testid="cf-port" />
|
||||
<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>
|
||||
|
||||
@@ -269,7 +276,17 @@ export function syncChallengeForm(
|
||||
</ul>
|
||||
<div class="row">
|
||||
<label for="cf-upload">Upload files (any file type)</label>
|
||||
<input id="cf-upload" type="file" multiple (change)="onFileChange($event)" data-testid="cf-upload" />
|
||||
<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>
|
||||
}
|
||||
@@ -320,6 +337,8 @@ export class ChallengeFormModalComponent {
|
||||
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;
|
||||
@@ -341,6 +360,8 @@ export class ChallengeFormModalComponent {
|
||||
this.pendingRemoval = [];
|
||||
this.fileUploadError.set(null);
|
||||
this.submitting.set(false);
|
||||
this.clientFieldErrors.set(null);
|
||||
this.dragOver.set(false);
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
{ allowSignalWrites: true },
|
||||
@@ -375,8 +396,6 @@ export class ChallengeFormModalComponent {
|
||||
.subscribe((p) => {
|
||||
if (p === 'WEB') {
|
||||
this.form.controls.port.setValue(null, { emitEvent: false });
|
||||
} else if (p === 'NC' && (this.form.controls.port.value === null || this.form.controls.port.value === undefined)) {
|
||||
this.form.controls.port.setValue(1337, { emitEvent: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -399,6 +418,8 @@ export class ChallengeFormModalComponent {
|
||||
}
|
||||
|
||||
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];
|
||||
@@ -415,6 +436,9 @@ export class ChallengeFormModalComponent {
|
||||
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`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -434,36 +458,49 @@ export class ChallengeFormModalComponent {
|
||||
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);
|
||||
for (const file of list) {
|
||||
if (file.size > this.globalFileLimit()) {
|
||||
this.fileUploadError.set(
|
||||
`File '${file.name}' exceeds the size limit of ${this.formatSize(this.globalFileLimit())}.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const placeholder: ChallengeStagedFileUi = {
|
||||
kind: 'staged',
|
||||
token: undefined,
|
||||
originalFilename: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
sizeBytes: file.size,
|
||||
uploading: true,
|
||||
};
|
||||
this.files.update((arr) => [...arr, placeholder]);
|
||||
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(file);
|
||||
const res = await this.admin.stageChallengeFile(a.file);
|
||||
this.files.update((arr) =>
|
||||
arr.map((entry) =>
|
||||
entry === placeholder
|
||||
entry === a.placeholder
|
||||
? { ...entry, token: res.token, uploading: false }
|
||||
: entry,
|
||||
),
|
||||
);
|
||||
} catch (e: any) {
|
||||
this.files.update((arr) => arr.filter((entry) => entry !== placeholder));
|
||||
this.files.update((arr) => arr.filter((entry) => entry !== a.placeholder));
|
||||
this.fileUploadError.set(
|
||||
`Failed to upload '${file.name}': ${e?.error?.message ?? e?.message ?? 'unknown error'}`,
|
||||
`Failed to upload '${a.file.name}': ${e?.error?.message ?? e?.message ?? 'unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -484,6 +521,11 @@ export class ChallengeFormModalComponent {
|
||||
this.fileUploadError.set('Wait for uploads to finish before saving.');
|
||||
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,
|
||||
@@ -494,15 +536,25 @@ export class ChallengeFormModalComponent {
|
||||
decaySolves: this.form.controls.decaySolves.value,
|
||||
flag: this.form.controls.flag.value,
|
||||
protocol: this.form.controls.protocol.value,
|
||||
port: this.form.controls.port.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.form.markAllAsTouched();
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user