Files
HIPCTF2/frontend/src/app/features/admin/challenges/challenges.component.ts
T

505 lines
17 KiB
TypeScript

import {
ChangeDetectionStrategy,
Component,
DestroyRef,
OnDestroy,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs';
import {
AdminService,
AdminCategory,
AdminChallengeListItem,
CreateChallengeBody,
ImportPreviewResponse,
ImportResultResponse,
UpdateChallengeBody,
} from '../../../core/services/admin.service';
import {
buildExportFilename,
categoryIconSrc,
deriveEmptyState,
difficultyClass,
importErrorMessage,
summarizeImportPreview,
summarizeImportResult,
} from './challenges.pure';
import { ChallengeFormModalComponent } from './challenge-form-modal.component';
import { ChallengeDeleteModalComponent } from './challenge-delete-modal.component';
import { ChallengeImportModalComponent } from './challenge-import-modal.component';
@Component({
selector: 'app-admin-challenges',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
ChallengeFormModalComponent,
ChallengeDeleteModalComponent,
ChallengeImportModalComponent,
],
styles: [`
.toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
.toolbar input[type="search"] { flex: 1; min-width: 220px; }
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { padding: 8px; border-bottom: 1px solid var(--color-secondary, #ccc); text-align: left; }
.icon-cell img { width: 32px; height: 32px; object-fit: cover; border-radius: 4px; background: #eee; }
.icon-cell .fallback { display: inline-block; min-width: 32px; text-align: center; font-weight: bold; }
.badge { padding: 2px 6px; border-radius: 4px; font-size: 12px; }
.difficulty-LOW { background: #d1fae5; color: #065f46; }
.difficulty-MEDIUM { background: #fef3c7; color: #92400e; }
.difficulty-HIGH { background: #fee2e2; color: #991b1b; }
.empty { text-align: center; padding: 32px; }
.row-actions button { margin-left: 4px; }
.status-banner { padding: 8px; margin: 8px 0; border-radius: 4px; }
.status-banner.success { background: #ecfdf5; color: #065f46; }
.status-banner.error { background: #fee2e2; color: #991b1b; }
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
button[disabled] { opacity: 0.5; cursor: not-allowed; }
`],
template: `
<section data-testid="admin-challenges">
<div class="toolbar">
<input
type="search"
placeholder="Search challenges…"
[value]="searchInput()"
(input)="onSearchInput($event)"
data-testid="challenges-search"
aria-label="Search challenges"
/>
<button type="button" (click)="openCreate()" [disabled]="actionInFlight()" data-testid="challenges-add">
Add challenge
</button>
<button type="button" (click)="openImport()" [disabled]="actionInFlight()" data-testid="challenges-import">
Import challenges
</button>
<button type="button" (click)="onExport()" [disabled]="actionInFlight()" data-testid="challenges-export">
<span *ngIf="exporting()" class="spinner" aria-hidden="true"></span>
Export challenges
</button>
</div>
@if (statusMessage(); as msg) {
<p class="status-banner success" role="status" aria-live="polite" data-testid="challenges-status">{{ msg }}</p>
}
@if (errorMessage(); as msg) {
<p class="status-banner error" role="alert" aria-live="assertive" data-testid="challenges-error">{{ msg }}</p>
}
@if (loading()) {
<p data-testid="challenges-loading">Loading challenges…</p>
} @else if (emptyState()) {
@if (emptyState(); as state) {
@if (state.kind === 'empty-list') {
<div class="empty" data-testid="challenges-empty">
<p>No challenges yet</p>
<button type="button" (click)="openCreate()" [disabled]="actionInFlight()" data-testid="challenges-empty-add">
Add challenge
</button>
</div>
} @else {
<div class="empty" data-testid="challenges-empty-search">
<p>No challenges match "{{ state.search }}"</p>
</div>
}
}
} @else {
<table class="table" data-testid="challenges-table">
<thead>
<tr>
<th scope="col">Category</th>
<th scope="col">
<button type="button" (click)="toggleSort()" data-testid="challenges-sort-name">
Name {{ sortDirection() === 'asc' ? '↑' : '↓' }}
</button>
</th>
<th scope="col">Difficulty</th>
<th scope="col">Protocol</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
@for (row of rows(); track row.id) {
<tr [attr.data-testid]="'challenge-row-' + row.id">
<td class="icon-cell">
@if (row.categoryIconPath) {
<img [src]="iconSrc(row)" [alt]="row.categoryAbbreviation" (error)="onIconError($event)" />
} @else {
<span class="fallback">{{ row.categoryAbbreviation }}</span>
}
</td>
<td>{{ row.name }}</td>
<td><span [class]="difficultyClass(row.difficulty)">{{ row.difficulty }}</span></td>
<td>{{ row.protocol }}</td>
<td class="row-actions">
<button type="button" (click)="openEdit(row)" aria-label="Edit" data-testid="challenges-row-edit">✎</button>
<button type="button" (click)="openDelete(row)" aria-label="Delete" data-testid="challenges-row-delete">🗑</button>
</td>
</tr>
}
</tbody>
</table>
}
<app-challenge-form-modal
[open]="formOpen()"
[mode]="formMode()"
[detail]="formDetail()"
[categories]="categories()"
[globalFileLimit]="globalFileLimit()"
[errorMessage]="formError()"
[fieldErrors]="fieldErrors()"
[saving]="saving()"
(cancel)="closeForm()"
(submit)="onFormSubmit($event)"
/>
<app-challenge-delete-modal
[open]="deleteOpen()"
[challenge]="deleteTarget()"
[errorMessage]="deleteError()"
[submitting]="deleting()"
(cancel)="closeDelete()"
(confirm)="onDeleteConfirm()"
/>
<app-challenge-import-modal
[open]="importOpen()"
[document]="importDoc()"
[preview]="importPreview()"
[submitting]="importing()"
[errorMessage]="importError()"
[result]="importResult()"
(cancel)="closeImport()"
(confirm)="onImportConfirm()"
/>
</section>
`,
})
export class AdminChallengesComponent implements OnInit, OnDestroy {
private readonly admin = inject(AdminService);
private readonly destroyRef = inject(DestroyRef);
private readonly searchSubject = new Subject<string>();
private readonly objectUrls = new Set<string>();
private requestSeq = 0;
readonly categories = signal<AdminCategory[]>([]);
readonly rows = signal<AdminChallengeListItem[]>([]);
readonly loading = signal(true);
readonly searchInput = signal('');
readonly activeSearch = signal('');
readonly sortDirection = signal<'asc' | 'desc'>('asc');
readonly formOpen = signal(false);
readonly formMode = signal<'create' | 'edit'>('create');
readonly formDetail = signal<any>(null);
readonly formError = signal<string | null>(null);
readonly fieldErrors = signal<Record<string, string> | null>(null);
readonly saving = signal(false);
readonly deleteOpen = signal(false);
readonly deleteTarget = signal<AdminChallengeListItem | null>(null);
readonly deleteError = signal<string | null>(null);
readonly deleting = signal(false);
readonly importOpen = signal(false);
readonly importDoc = signal<any>(null);
readonly importPreview = signal<ImportPreviewResponse | null>(null);
readonly importResult = signal<ImportResultResponse | null>(null);
readonly importError = signal<string | null>(null);
readonly importing = signal(false);
readonly statusMessage = signal<string | null>(null);
readonly errorMessage = signal<string | null>(null);
readonly exporting = signal(false);
readonly globalFileLimit = signal<number>(100 * 1024 * 1024);
readonly emptyState = computed(() =>
deriveEmptyState(this.rows(), this.activeSearch(), this.loading()),
);
readonly actionInFlight = computed(
() => this.saving() || this.deleting() || this.exporting() || this.importing(),
);
ngOnInit(): void {
this.searchSubject
.pipe(debounceTime(250), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef))
.subscribe((value) => {
this.activeSearch.set(value);
void this.load();
});
void this.loadCategories();
void this.load();
}
ngOnDestroy(): void {
for (const url of this.objectUrls) {
try {
URL.revokeObjectURL(url);
} catch {
// ignore
}
}
}
async loadCategories(): Promise<void> {
try {
const cats = await this.admin.listCategories();
this.categories.set(cats);
} catch {
// non-fatal; form will surface category load failure
}
}
async load(): Promise<void> {
const seq = ++this.requestSeq;
this.loading.set(true);
this.errorMessage.set(null);
try {
const q = this.activeSearch();
const list = await this.admin.listChallenges({ q: q.length > 0 ? q : undefined });
if (seq !== this.requestSeq) return;
const sorted = this.applySort(list);
this.rows.set(sorted);
} catch (e: any) {
if (seq !== this.requestSeq) return;
this.errorMessage.set(e?.error?.message ?? e?.message ?? 'Failed to load challenges');
} finally {
if (seq === this.requestSeq) this.loading.set(false);
}
}
applySort(list: AdminChallengeListItem[]): AdminChallengeListItem[] {
const sorted = [...list].sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
return this.sortDirection() === 'desc' ? sorted.reverse() : sorted;
}
toggleSort(): void {
this.sortDirection.set(this.sortDirection() === 'asc' ? 'desc' : 'asc');
this.rows.set(this.applySort(this.rows()));
}
onSearchInput(ev: Event): void {
const value = (ev.target as HTMLInputElement).value;
this.searchInput.set(value);
this.searchSubject.next(value);
}
difficultyClass(d: 'LOW' | 'MEDIUM' | 'HIGH'): string {
return difficultyClass(d);
}
iconSrc(row: AdminChallengeListItem): string {
return categoryIconSrc(row.categoryIconPath, row.categoryAbbreviation);
}
onIconError(ev: Event): void {
const img = ev.target as HTMLImageElement;
img.style.display = 'none';
}
openCreate(): void {
this.formMode.set('create');
this.formDetail.set(null);
this.formError.set(null);
this.fieldErrors.set(null);
this.formOpen.set(true);
}
async openEdit(row: AdminChallengeListItem): Promise<void> {
this.formMode.set('edit');
this.formError.set(null);
this.fieldErrors.set(null);
this.formOpen.set(true);
this.formDetail.set(null);
try {
const detail = await this.admin.getChallengeDetail(row.id);
this.formDetail.set(detail);
} catch (e: any) {
this.formOpen.set(false);
this.errorMessage.set(e?.error?.message ?? e?.message ?? 'Failed to load challenge');
}
}
closeForm(): void {
this.formOpen.set(false);
this.formDetail.set(null);
this.formError.set(null);
this.fieldErrors.set(null);
}
async onFormSubmit(payload: { body: CreateChallengeBody | UpdateChallengeBody }): Promise<void> {
this.saving.set(true);
this.formError.set(null);
this.fieldErrors.set(null);
const body = payload.body;
try {
if (this.formMode() === 'create') {
await this.admin.createChallenge(body as CreateChallengeBody);
} else {
const detail = this.formDetail();
if (!detail) throw new Error('Missing challenge detail');
await this.admin.updateChallenge(detail.id, body as UpdateChallengeBody);
}
this.closeForm();
await this.load();
this.statusMessage.set(this.formMode() === 'create' ? 'Challenge created.' : 'Challenge updated.');
} catch (e: any) {
const code = e?.error?.code ?? e?.code;
const message = e?.error?.message ?? e?.message ?? 'Failed to save challenge';
this.formError.set(this.mapFormErrorMessage(code, message));
const details = e?.error?.details;
if (Array.isArray(details)) {
const map: Record<string, string> = {};
for (const d of details) {
if (d && typeof d === 'object' && d.path && d.message) {
map[String(d.path)] = String(d.message);
}
}
this.fieldErrors.set(Object.keys(map).length > 0 ? map : null);
}
} finally {
this.saving.set(false);
}
}
mapFormErrorMessage(code: string | undefined, fallback: string): string {
if (code === 'CHALLENGE_NAME_TAKEN') return 'A challenge with this name already exists.';
if (code === 'CHALLENGE_INVALID_CATEGORY') return 'Selected category does not exist.';
return fallback;
}
openDelete(row: AdminChallengeListItem): void {
this.deleteTarget.set(row);
this.deleteError.set(null);
this.deleteOpen.set(true);
}
closeDelete(): void {
this.deleteOpen.set(false);
this.deleteTarget.set(null);
this.deleteError.set(null);
}
async onDeleteConfirm(): Promise<void> {
const target = this.deleteTarget();
if (!target) return;
this.deleting.set(true);
this.deleteError.set(null);
try {
await this.admin.deleteChallenge(target.id);
this.closeDelete();
await this.load();
this.statusMessage.set(`Challenge '${target.name}' deleted.`);
} catch (e: any) {
this.deleteError.set(e?.error?.message ?? e?.message ?? 'Failed to delete challenge');
} finally {
this.deleting.set(false);
}
}
openImport(): void {
this.importDoc.set(null);
this.importPreview.set(null);
this.importResult.set(null);
this.importError.set(null);
this.importOpen.set(true);
void this.pickImportFile();
}
async pickImportFile(): Promise<void> {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json,.json';
input.addEventListener('change', async () => {
const file = input.files?.[0];
if (!file) {
this.closeImport();
return;
}
const text = await file.text();
let parsed: any;
try {
parsed = JSON.parse(text);
} catch {
this.importError.set('Selected file is not valid JSON.');
return;
}
try {
const preview = await this.admin.previewChallengeImport(parsed);
this.importDoc.set(parsed);
this.importPreview.set(preview);
} catch (e: any) {
this.importError.set(e?.error?.message ?? e?.message ?? 'Failed to validate import');
}
});
input.click();
}
closeImport(): void {
this.importOpen.set(false);
this.importDoc.set(null);
this.importPreview.set(null);
this.importResult.set(null);
this.importError.set(null);
}
async onImportConfirm(): Promise<void> {
const doc = this.importDoc();
if (!doc) return;
this.importing.set(true);
this.importError.set(null);
try {
const result = await this.admin.confirmChallengeImport(doc);
this.statusMessage.set(summarizeImportResult(result));
await this.load();
this.closeImport();
} catch (e: any) {
this.importError.set(importErrorMessage(e, 'Failed to import challenges'));
} finally {
this.importing.set(false);
}
}
async onExport(): Promise<void> {
this.exporting.set(true);
this.errorMessage.set(null);
try {
const blob = await this.admin.exportChallenges();
const url = URL.createObjectURL(blob);
this.objectUrls.add(url);
const a = document.createElement('a');
a.href = url;
a.download = buildExportFilename();
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => {
try {
URL.revokeObjectURL(url);
this.objectUrls.delete(url);
} catch {
// ignore
}
}, 60_000);
this.statusMessage.set('Export ready.');
} catch (e: any) {
this.errorMessage.set(e?.error?.message ?? e?.message ?? 'Failed to export challenges');
} finally {
this.exporting.set(false);
}
}
summarizePreview(p: ImportPreviewResponse | null): string {
return p ? summarizeImportPreview(p) : '';
}
}