AI Implementation feature(871): Admin Area System: Database Backup/Restore and Danger Zone (#61)

This commit was merged in pull request #61.
This commit is contained in:
2026-07-23 12:10:41 +00:00
parent 6bac67fad7
commit 470ddd30c3
42 changed files with 3996 additions and 55 deletions
+5
View File
@@ -69,6 +69,11 @@ export const APP_ROUTES: Routes = [
loadComponent: () =>
import('./features/admin/blog/blog.component').then((m) => m.AdminBlogComponent),
},
{
path: 'system',
loadComponent: () =>
import('./features/admin/system/system.component').then((m) => m.AdminSystemComponent),
},
],
},
],
@@ -240,6 +240,17 @@ export class AuthService {
this.publishInvalidation(message);
}
/**
* Forcibly invalidate the local session (and broadcast peer
* invalidation) — used after a destructive server-initiated action such
* as a successful restore that revokes access. Same as a normal logout
* from the client's perspective.
*/
forceServerInvalidation(): void {
this.suppressBroadcast = false;
this.clearAndBroadcast();
}
handleSseUnauthorized(): void {
this.clearAndBroadcast();
this.notifyPeerInvalidation('sse-unauthorized');
@@ -0,0 +1,14 @@
import { Injectable, signal } from '@angular/core';
export type SystemDataChangeKind = 'scores-reset' | 'challenges-wiped' | 'restore-completed';
@Injectable({ providedIn: 'root' })
export class SystemDataChangeService {
private readonly _events = signal<{ kind: SystemDataChangeKind; ts: number }[]>([]);
readonly events = this._events.asReadonly();
notify(kind: SystemDataChangeKind): void {
this._events.update((cur) => [...cur, { kind, ts: Date.now() }]);
}
}
@@ -14,7 +14,7 @@ const ENTRIES: AdminNavEntry[] = [
{ id: 'challenges', label: 'Challenges', path: '/admin/challenges', enabled: true },
{ id: 'players', label: 'Players', path: '/admin/players', enabled: true },
{ id: 'blog', label: 'Blog', path: '/admin/blog', enabled: true },
{ id: 'system', label: 'System', path: '/admin/system', enabled: false },
{ id: 'system', label: 'System', path: '/admin/system', enabled: true },
];
@Component({
@@ -0,0 +1,162 @@
import {
ChangeDetectionStrategy,
Component,
HostListener,
computed,
input,
output,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AdminSystemOperation } from './system.service';
import { destructiveCopy } from './system.pure';
@Component({
selector: 'app-system-confirm-modal',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, FormsModule],
styles: [`
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; z-index: 80; }
.modal { background: var(--color-surface, #fff); padding: 16px 20px; border-radius: 10px; width: min(520px, 92vw); max-height: 90vh; overflow: auto; box-shadow: 0 12px 40px rgba(0,0,0,0.2); }
.modal h3 { margin: 0 0 8px; color: var(--color-danger, #991b1b); }
.stage { padding: 8px 12px; border-radius: 6px; background: var(--color-warning-bg, #fef3c7); color: var(--color-warning-text, #92400e); margin: 12px 0; }
.row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
.row label { font-weight: 600; font-size: 14px; }
.row input { padding: 8px; border-radius: 6px; border: 1px solid var(--color-secondary, #ccc); }
.confirm-box { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.actions button.danger { background: var(--color-danger, #991b1b); color: #fff; border: none; border-radius: 6px; padding: 8px 14px; cursor: pointer; }
.actions button.cancel { background: transparent; border: 1px solid var(--color-secondary, #ccc); border-radius: 6px; padding: 8px 14px; cursor: pointer; }
.error { color: var(--color-danger, #b91c1c); margin-top: 8px; }
.progress { color: var(--color-warning, #92400e); margin-top: 8px; }
.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; margin-right: 6px; vertical-align: middle; }
@keyframes spin { to { transform: rotate(360deg); } }
.preserved { color: var(--color-success, #065f46); font-size: 13px; }
.detail { font-size: 14px; line-height: 1.45; }
`],
template: `
@if (open()) {
<div class="modal-overlay" data-testid="system-confirm-overlay" (click)="onCancel()">
<div
class="modal"
role="dialog"
aria-modal="true"
[attr.aria-labelledby]="'system-confirm-title'"
data-testid="system-confirm-modal"
(click)="$event.stopPropagation()"
>
<h3 id="system-confirm-title">{{ copy().title }}</h3>
<p class="detail" data-testid="system-confirm-detail">{{ copy().detail }}</p>
<p class="preserved" data-testid="system-confirm-preserved">{{ copy().preserved }}</p>
<div class="stage" data-testid="system-confirm-stage">{{ stageMessage() }}</div>
<div class="row">
<label for="system-confirm-password">Current administrator password</label>
<input
id="system-confirm-password"
type="password"
autocomplete="current-password"
[value]="password()"
(input)="onPasswordInput($event)"
[disabled]="submitting()"
data-testid="system-confirm-password"
/>
</div>
<div class="confirm-box">
<label for="system-confirm-phrase">Type <strong>{{ copy().confirmation }}</strong> to confirm</label>
<input
id="system-confirm-phrase"
type="text"
[value]="confirmation()"
(input)="onConfirmInput($event)"
[disabled]="submitting()"
data-testid="system-confirm-phrase"
/>
</div>
@if (errorMessage(); as msg) {
<p class="error" role="alert" data-testid="system-confirm-error">{{ msg }}</p>
}
@if (submitting()) {
<p class="progress" data-testid="system-confirm-progress">
<span class="spinner" aria-hidden="true"></span>Working…
</p>
}
<div class="actions">
<button
type="button"
class="cancel"
(click)="onCancel()"
[disabled]="submitting()"
data-testid="system-confirm-cancel"
>Cancel</button>
<button
type="button"
class="danger"
(click)="onConfirm()"
[disabled]="!canConfirm()"
data-testid="system-confirm-confirm"
>
<span *ngIf="submitting()" class="spinner" aria-hidden="true"></span>
{{ confirmLabel() }}
</button>
</div>
</div>
</div>
}
`,
})
export class SystemConfirmModalComponent {
readonly open = input(false);
readonly operation = input<AdminSystemOperation>('reset-scores');
readonly stageMessage = input<string>('Re-authenticate then confirm the action.');
readonly submitting = input(false);
readonly errorMessage = input<string | null>(null);
readonly cancel = output<void>();
readonly confirm = output<{ password: string; confirmation: string }>();
readonly password = signal('');
readonly confirmation = signal('');
readonly copy = computed(() => destructiveCopy(this.operation()));
readonly canConfirm = computed(() => {
if (this.submitting()) return false;
const phrase = this.copy().confirmation;
return this.password().length > 0 && this.confirmation().trim() === phrase;
});
readonly confirmLabel = computed(() => {
if (this.submitting()) return 'Working…';
return 'Confirm';
});
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.open() && !this.submitting()) this.onCancel();
}
onPasswordInput(ev: Event): void {
this.password.set((ev.target as HTMLInputElement).value);
}
onConfirmInput(ev: Event): void {
this.confirmation.set((ev.target as HTMLInputElement).value);
}
onCancel(): void {
if (this.submitting()) return;
this.cancel.emit();
}
onConfirm(): void {
if (!this.canConfirm()) return;
this.confirm.emit({ password: this.password(), confirmation: this.confirmation() });
}
}
@@ -0,0 +1,426 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
computed,
inject,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { NotificationService } from '../../../core/services/notification.service';
import { AuthService } from '../../../core/services/auth.service';
import { UserStore } from '../../../core/services/user.store';
import { SystemDataChangeService } from '../../../core/services/system-data-change.service';
import { AdminSystemComponent as _Alias } from './system.component';
import {
AdminSystemOperation,
AdminSystemRestoreSummary,
SystemAdminService,
} from './system.service';
import {
coerceSystemError,
destructiveCopy,
deriveBackupFilename,
friendlySystemErrorMessage,
pickRestoreFile,
} from './system.pure';
import { SystemConfirmModalComponent } from './system-confirm-modal.component';
// Preserve namespacing exports for tooling that scans barrel imports.
void _Alias;
interface RestoreState {
open: boolean;
phase: 'idle' | 'uploading' | 'validating' | 'staged' | 'committed' | 'failed';
staged: AdminSystemRestoreSummary | null;
errorMessage: string | null;
}
@Component({
selector: 'app-admin-system',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, SystemConfirmModalComponent],
styles: [`
:host { display: block; padding: 16px; }
.panels { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; }
@media (max-width: 900px) { .panels { grid-template-columns: 1fr; } }
.panel { background: var(--color-surface, #fff); padding: 16px; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
.panel h2 { margin: 0 0 6px; font-size: 18px; }
.panel .subtitle { color: var(--color-secondary, #6b7280); margin: 0 0 12px; font-size: 13px; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
button.primary { background: var(--color-primary, #2563eb); color: #fff; border: none; border-radius: 6px; padding: 8px 14px; cursor: pointer; }
button.danger { background: var(--color-danger, #991b1b); color: #fff; border: none; border-radius: 6px; padding: 8px 14px; cursor: pointer; }
button.cancel { background: transparent; border: 1px solid var(--color-secondary, #ccc); border-radius: 6px; padding: 8px 14px; cursor: pointer; }
button[disabled] { opacity: 0.5; cursor: not-allowed; }
.status { margin-top: 8px; font-size: 13px; }
.status.success { color: var(--color-success, #065f46); }
.status.error { color: var(--color-danger, #b91c1c); }
.status.progress { color: var(--color-warning, #92400e); }
.danger-zone { border: 1px dashed var(--color-danger, #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; margin-right: 6px; vertical-align: middle; }
@keyframes spin { to { transform: rotate(360deg); } }
.summary-grid { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; font-size: 13px; margin: 8px 0 0; }
`],
template: `
<div data-testid="admin-system-page">
<h1>System</h1>
<p class="subtitle">Two clearly separated panels: Database and Danger Zone.</p>
<div class="panels">
<section class="panel" data-testid="admin-system-database-panel">
<h2>Database</h2>
<p class="subtitle">Create or restore the full platform snapshot.</p>
<div class="actions">
<button
type="button"
class="primary"
(click)="onCreateBackup()"
[disabled]="anyInFlight()"
data-testid="admin-system-create-backup"
>
<span *ngIf="backupInFlight()" class="spinner" aria-hidden="true"></span>
Create backup
</button>
<button
type="button"
class="primary"
(click)="onPickRestore()"
[disabled]="anyInFlight()"
data-testid="admin-system-restore-backup"
>
Restore backup
</button>
</div>
@if (backupStatus(); as status) {
<p class="status success" role="status" data-testid="admin-system-backup-success">{{ status }}</p>
}
@if (backupError(); as msg) {
<p class="status error" role="alert" data-testid="admin-system-backup-error">{{ msg }}</p>
}
@if (restoreInFlight()) {
<p class="status progress" role="status" data-testid="admin-system-restore-progress">
<span class="spinner" aria-hidden="true"></span>{{ restoreProgressLabel() }}
</p>
}
@if (restoreSummary(); as summary) {
<div data-testid="admin-system-restore-summary">
<p class="status success">Backup validated and staged.</p>
<div class="summary-grid">
<span>Tables detected</span><strong>{{ summary.summary.tables.length }}</strong>
<span>Uploaded files</span><strong>{{ summary.summary.files }}</strong>
<span>Total bytes</span><strong>{{ summary.summary.totalBytes }}</strong>
<span>Stage expires</span><strong>{{ summary.expiresAt }}</strong>
</div>
<div class="actions" style="margin-top: 12px;">
<button
type="button"
class="danger"
(click)="onOpenConfirm('restore-backup')"
[disabled]="anyInFlight()"
data-testid="admin-system-confirm-restore"
>Continue to destructive restore…</button>
<button
type="button"
class="cancel"
(click)="discardRestore()"
[disabled]="anyInFlight()"
data-testid="admin-system-discard-restore"
>Discard staged backup</button>
</div>
</div>
}
@if (restoreError(); as msg) {
<p class="status error" role="alert" data-testid="admin-system-restore-error">{{ msg }}</p>
}
</section>
<section class="panel danger-zone" data-testid="admin-system-danger-panel">
<h2>Danger Zone</h2>
<p class="subtitle">Visually destructive actions. Re-authentication is required for every operation.</p>
<div class="actions">
<button
type="button"
class="danger"
(click)="onOpenConfirm('reset-scores')"
[disabled]="anyInFlight()"
data-testid="admin-system-reset-scores"
>
Reset all scores
</button>
<button
type="button"
class="danger"
(click)="onOpenConfirm('wipe-challenges')"
[disabled]="anyInFlight()"
data-testid="admin-system-wipe-challenges"
>
Wipe challenges
</button>
</div>
@if (dangerStatus(); as status) {
<p class="status success" role="status" data-testid="admin-system-danger-success">{{ status }}</p>
}
@if (dangerError(); as msg) {
<p class="status error" role="alert" data-testid="admin-system-danger-error">{{ msg }}</p>
}
</section>
</div>
<app-system-confirm-modal
[open]="confirmOpen()"
[operation]="confirmOperation()"
[stageMessage]="confirmStageMessage()"
[submitting]="confirmSubmitting()"
[errorMessage]="confirmError()"
(cancel)="closeConfirm()"
(confirm)="onConfirmAction($event)"
/>
</div>
`,
})
export class AdminSystemComponent {
private readonly system = inject(SystemAdminService);
private readonly auth = inject(AuthService);
private readonly userStore = inject(UserStore);
private readonly router = inject(Router);
private readonly notifications = inject(NotificationService);
private readonly destroyRef = inject(DestroyRef);
private readonly dataChanges = inject(SystemDataChangeService);
readonly backupInFlight = signal(false);
readonly backupStatus = signal<string | null>(null);
readonly backupError = signal<string | null>(null);
readonly restoreState = signal<RestoreState>({
open: false,
phase: 'idle',
staged: null,
errorMessage: null,
});
readonly restoreInFlight = computed(() => {
const phase = this.restoreState().phase;
return phase === 'uploading' || phase === 'validating' || phase === 'committed';
});
readonly restoreProgressLabel = computed(() => {
switch (this.restoreState().phase) {
case 'uploading':
return 'Reading selected backup…';
case 'validating':
return 'Validating backup server-side…';
case 'committed':
return 'Committing restore and finalizing…';
default:
return '';
}
});
readonly restoreSummary = computed(() => this.restoreState().staged);
readonly restoreError = computed(() => this.restoreState().errorMessage);
readonly dangerStatus = signal<string | null>(null);
readonly dangerError = signal<string | null>(null);
readonly confirmOpen = signal(false);
readonly confirmOperation = signal<AdminSystemOperation>('reset-scores');
readonly confirmSubmitting = signal(false);
readonly confirmError = signal<string | null>(null);
readonly anyInFlight = computed(
() =>
this.backupInFlight() ||
this.restoreInFlight() ||
this.confirmSubmitting() ||
!!this.confirmOpen(),
);
readonly confirmStageMessage = computed(() => {
switch (this.confirmOperation()) {
case 'restore-backup':
return 'You will be logged out after restore completes.';
case 'reset-scores':
return 'Solves and awards will be permanently removed.';
case 'wipe-challenges':
return 'Challenges, their files, and related solves/awards will be permanently removed.';
}
});
async onCreateBackup(): Promise<void> {
if (this.anyInFlight()) return;
this.backupStatus.set(null);
this.backupError.set(null);
this.backupInFlight.set(true);
try {
const blob = await this.system.downloadBackup();
const url = URL.createObjectURL(blob);
try {
const a = document.createElement('a');
a.href = url;
a.download = deriveBackupFilename();
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} finally {
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
this.backupStatus.set('Backup ready.');
} catch (e) {
const err = coerceSystemError(e, 'Failed to generate backup');
this.backupError.set(friendlySystemErrorMessage('restore-backup', err));
} finally {
this.backupInFlight.set(false);
}
}
async onPickRestore(): Promise<void> {
if (this.anyInFlight()) return;
this.restoreState.set({ open: true, phase: 'uploading', staged: null, errorMessage: null });
const file = await pickRestoreFile();
if (!file) {
this.restoreState.set({ open: false, phase: 'idle', staged: null, errorMessage: null });
return;
}
this.restoreState.update((s) => ({ ...s, phase: 'validating' }));
try {
const staged = await this.system.validateRestore({ text: file.text });
this.restoreState.set({ open: true, phase: 'staged', staged, errorMessage: null });
} catch (e) {
const err = coerceSystemError(e, 'Restore validation failed');
const state = { ...this.restoreState() };
state.errorMessage = friendlySystemErrorMessage('restore-backup', err);
state.phase = 'failed';
this.restoreState.set(state);
}
}
discardRestore(): void {
if (this.anyInFlight()) return;
this.restoreState.set({ open: false, phase: 'idle', staged: null, errorMessage: null });
}
onOpenConfirm(op: AdminSystemOperation): void {
if (this.anyInFlight()) return;
if (op === 'restore-backup') {
const staged = this.restoreState().staged;
if (!staged) {
this.restoreState.update((s) => ({ ...s, errorMessage: 'Stage a backup before continuing.' }));
return;
}
}
this.confirmError.set(null);
this.confirmOperation.set(op);
this.confirmOpen.set(true);
}
closeConfirm(): void {
if (this.confirmSubmitting()) return;
this.confirmOpen.set(false);
this.confirmError.set(null);
}
async onConfirmAction(payload: { password: string; confirmation: string }): Promise<void> {
if (this.confirmSubmitting()) return;
const operation = this.confirmOperation();
this.confirmSubmitting.set(true);
this.confirmError.set(null);
try {
const issued = await this.system.issueConfirmation({
operation,
password: payload.password,
...(operation === 'restore-backup' && this.restoreState().staged
? { stagingId: this.restoreState().staged!.stagingId }
: {}),
});
if (operation === 'restore-backup') {
this.confirmSubmitting.set(false);
this.confirmOpen.set(false);
await this.runRestoreCommit(issued.token);
} else if (operation === 'reset-scores') {
await this.runResetScores(issued.token);
} else if (operation === 'wipe-challenges') {
await this.runWipeChallenges(issued.token);
}
} catch (e) {
const err = coerceSystemError(e, friendlySystemFallback(operation));
this.confirmError.set(friendlySystemErrorMessage(operation, err));
} finally {
this.confirmSubmitting.set(false);
}
}
private async runRestoreCommit(token: string): Promise<void> {
const staged = this.restoreState().staged;
if (!staged) {
this.confirmError.set('No staged backup available.');
return;
}
this.restoreState.update((s) => ({ ...s, phase: 'committed' }));
try {
await this.system.commitRestore({
operation: 'restore-backup',
token,
stagingId: staged.stagingId,
});
this.restoreState.set({ open: false, phase: 'idle', staged: null, errorMessage: null });
this.confirmOpen.set(false);
this.dataChanges.notify('restore-completed');
this.notifications.info('Restore complete. Logging out…');
// Force client-side credential clear.
this.auth.forceServerInvalidation();
this.userStore.reset();
await this.router.navigateByUrl('/login');
} catch (e) {
const err = coerceSystemError(e, 'Restore failed.');
this.confirmError.set(friendlySystemErrorMessage('restore-backup', err));
this.restoreState.update((s) => ({ ...s, phase: 'failed', errorMessage: friendlySystemErrorMessage('restore-backup', err) }));
}
}
private async runResetScores(token: string): Promise<void> {
try {
const res = await this.system.resetScores({ operation: 'reset-scores', token });
this.dangerError.set(null);
this.dangerStatus.set(`${res.solvesRemoved} solve record(s) removed.`);
this.dataChanges.notify('scores-reset');
this.notifications.info(`Reset complete: ${res.solvesRemoved} solve(s) removed.`);
this.confirmOpen.set(false);
} catch (e) {
const err = coerceSystemError(e, 'Reset scores failed.');
this.confirmError.set(friendlySystemErrorMessage('reset-scores', err));
this.dangerError.set(friendlySystemErrorMessage('reset-scores', err));
}
}
private async runWipeChallenges(token: string): Promise<void> {
try {
const res = await this.system.wipeChallenges({ operation: 'wipe-challenges', token });
this.dangerError.set(null);
this.dangerStatus.set(
`${res.challengesRemoved} challenge(s), ${res.challengeFilesRemoved} file(s) wiped.`,
);
this.dataChanges.notify('challenges-wiped');
this.dataChanges.notify('scores-reset');
this.notifications.info(`Wipe complete: ${res.challengesRemoved} challenge(s) removed.`);
this.confirmOpen.set(false);
} catch (e) {
const err = coerceSystemError(e, 'Wipe challenges failed.');
this.confirmError.set(friendlySystemErrorMessage('wipe-challenges', err));
this.dangerError.set(friendlySystemErrorMessage('wipe-challenges', err));
}
}
}
function friendlySystemFallback(op: AdminSystemOperation): string {
switch (op) {
case 'restore-backup':
return 'Restore could not be confirmed.';
case 'reset-scores':
return 'Reset could not be confirmed.';
case 'wipe-challenges':
return 'Wipe could not be confirmed.';
}
}
@@ -0,0 +1,155 @@
import { AdminSystemOperation } from './system.service';
export interface SystemError {
status: number;
code: string;
message: string;
details?: unknown;
}
export function coerceSystemError(err: unknown, fallbackMessage: string): SystemError {
if (err && typeof err === 'object' && 'error' in (err as any)) {
const httpErr = err as { status?: number; error?: { code?: string; message?: string; details?: unknown } };
const body = httpErr.error;
return {
status: httpErr.status ?? 0,
code: body?.code ?? 'INTERNAL',
message: body?.message ?? fallbackMessage,
details: body?.details,
};
}
return {
status: 0,
code: 'INTERNAL',
message: (err as Error)?.message ?? fallbackMessage,
};
}
export function friendlySystemErrorMessage(op: AdminSystemOperation, err: SystemError): string {
const code = err.code;
switch (code) {
case 'SYSTEM_REAUTH_REQUIRED':
return 'Your session is no longer valid. Please log in again.';
case 'SYSTEM_INVALID_CREDENTIALS':
return 'The current password is incorrect.';
case 'SYSTEM_TOKEN_INVALID':
return 'Confirmation token is invalid. Re-authenticate and try again.';
case 'SYSTEM_TOKEN_EXPIRED':
return 'Confirmation token expired. Re-authenticate and try again.';
case 'SYSTEM_TOKEN_REUSED':
return 'Confirmation token was already used. Re-authenticate to get a fresh token.';
case 'SYSTEM_TOKEN_MISMATCH':
return 'Confirmation token does not match this operation.';
case 'SYSTEM_RESTORE_VALIDATION_FAILED':
return 'Backup validation failed. No data was changed.';
case 'SYSTEM_RESTORE_PAYLOAD_TOO_LARGE':
return 'The backup is larger than the configured upload limit.';
case 'SYSTEM_RESTORE_STAGE_EXPIRED':
return 'The backup staging window expired. Re-upload and validate again.';
case 'SYSTEM_RESTORE_ROLLED_BACK':
return 'Restore failed and was rolled back. Your data is unchanged.';
case 'SYSTEM_DANGER_ROLLED_BACK':
return 'Operation failed and was rolled back. Nothing was changed.';
case 'SYSTEM_BACKUP_FAILED':
case 'SYSTEM_FILE_READ_FAILED':
return 'Backup could not be generated. No data was changed.';
case 'SYSTEM_DANGER_FAILED':
return 'Operation could not complete. No data was changed.';
case 'SYSTEM_OPERATION_IN_PROGRESS':
return 'Another destructive operation is currently running.';
case 'CONFLICT':
return 'Server reported a conflict. Try again.';
case 'FORBIDDEN':
return 'You are not allowed to perform this action.';
case 'UNAUTHORIZED':
return 'Your session is no longer valid. Please log in again.';
case 'VALIDATION_FAILED':
return err.message && err.message.trim().length > 0 ? err.message : 'Validation failed.';
default:
return err.message && err.message.trim().length > 0 ? err.message : fallbackMessageForOperation(op);
}
}
function fallbackMessageForOperation(op: AdminSystemOperation): string {
switch (op) {
case 'restore-backup':
return 'Restore failed.';
case 'reset-scores':
return 'Reset scores failed.';
case 'wipe-challenges':
return 'Wipe challenges failed.';
}
}
export interface DestructiveCopy {
title: string;
confirmation: string;
detail: string;
preserved: string;
}
export function destructiveCopy(op: AdminSystemOperation): DestructiveCopy {
switch (op) {
case 'reset-scores':
return {
title: 'Reset all scores?',
confirmation: 'RESET SCORES',
detail:
'Every solve and award record will be permanently deleted. Every player will return to zero points and no solve history will remain.',
preserved:
'Users, sessions, roles, challenges, challenge files, categories and images, event settings, and blog posts are preserved.',
};
case 'wipe-challenges':
return {
title: 'Wipe all challenges?',
confirmation: 'WIPE CHALLENGES',
detail:
'Every challenge, its attached files, all of its solves, and all of its award records will be permanently removed. Every uploaded challenge file on disk will be deleted.',
preserved:
'Users, sessions, roles, categories and images, event settings, blog posts, and root-level uploads (such as the site logo) are preserved.',
};
case 'restore-backup':
return {
title: 'Restore database?',
confirmation: 'RESTORE BACKUP',
detail:
'The entire database and all uploaded files will be replaced with the contents of the selected backup. Current state will be overwritten.',
preserved:
'Nothing is preserved: every table, every challenge, every upload, and every refresh session is replaced.',
};
}
}
export interface PickRestoreFileResult {
text: string;
filename: string;
size: number;
}
/**
* Open a file picker restricted to JSON, read the file as text, and
* validate it parses. Pure DOM logic so it is testable in jsdom.
*/
export function pickRestoreFile(): Promise<PickRestoreFileResult | null> {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json,.json';
input.addEventListener('change', async () => {
const file = input.files?.[0];
if (!file) {
resolve(null);
return;
}
const text = await file.text();
resolve({ text, filename: file.name, size: file.size });
});
input.addEventListener('cancel', () => resolve(null));
input.click();
});
}
export function deriveBackupFilename(): string {
const date = new Date().toISOString().slice(0, 10);
return `hipctf-backup-${date}.json`;
}
@@ -0,0 +1,91 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
export type AdminSystemOperation = 'restore-backup' | 'reset-scores' | 'wipe-challenges';
export interface AdminSystemRestoreSummary {
stagingId: string;
expiresAt: string;
summary: {
tables: string[];
tableCounts: Record<string, number>;
files: number;
totalBytes: number;
};
}
@Injectable({ providedIn: 'root' })
export class SystemAdminService {
private readonly http = inject(HttpClient);
async downloadBackup(): Promise<Blob> {
return firstValueFrom(
this.http.get('/api/v1/admin/system/backup', {
withCredentials: true,
responseType: 'blob',
}),
);
}
async validateRestore(payload: { text: string } | { archive: unknown }): Promise<AdminSystemRestoreSummary> {
const body = 'text' in payload ? { text: payload.text } : { archive: payload.archive };
return firstValueFrom(
this.http.post<AdminSystemRestoreSummary>(
'/api/v1/admin/system/restore/validate',
body,
{ withCredentials: true },
),
);
}
async commitRestore(payload: { operation: 'restore-backup'; token: string; stagingId: string }): Promise<{ ok: true; operation: 'restore-backup' }> {
return firstValueFrom(
this.http.post<{ ok: true; operation: 'restore-backup' }>(
'/api/v1/admin/system/restore/commit',
payload,
{ withCredentials: true },
),
);
}
async issueConfirmation(payload: {
operation: AdminSystemOperation;
password: string;
stagingId?: string;
}): Promise<{ operation: AdminSystemOperation; token: string; expiresAt: string }> {
return firstValueFrom(
this.http.post<{ operation: AdminSystemOperation; token: string; expiresAt: string }>(
'/api/v1/admin/system/confirmations',
payload,
{ withCredentials: true },
),
);
}
async resetScores(payload: { operation: 'reset-scores'; token: string }): Promise<{ ok: true; solvesRemoved: number }> {
return firstValueFrom(
this.http.post<{ ok: true; solvesRemoved: number }>(
'/api/v1/admin/system/scores/reset',
payload,
{ withCredentials: true },
),
);
}
async wipeChallenges(payload: { operation: 'wipe-challenges'; token: string }): Promise<{
ok: true;
challengesRemoved: number;
challengeFilesRemoved: number;
solvesRemoved: number;
}> {
return firstValueFrom(
this.http.post<{
ok: true;
challengesRemoved: number;
challengeFilesRemoved: number;
solvesRemoved: number;
}>('/api/v1/admin/system/challenges/wipe', payload, { withCredentials: true }),
);
}
}
@@ -1,4 +1,4 @@
import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core';
import { DestroyRef, Injectable, computed, effect, inject, signal } from '@angular/core';
import { Observable } from 'rxjs';
import {
BoardCard,
@@ -17,11 +17,13 @@ import {
} from './challenges.pure';
import { ChallengesApiService } from './challenges.service';
import { EventSourceLike } from '../../core/services/event-status.pure';
import { SystemDataChangeService } from '../../core/services/system-data-change.service';
@Injectable({ providedIn: 'root' })
export class ChallengesStore {
private readonly api: ChallengesApiService;
private readonly destroyRef: DestroyRef;
private readonly dataChanges: SystemDataChangeService | null;
private readonly _board = signal<BoardResponse | null>(null);
private readonly _eventState = signal<EventStatePayloadLike['state']>('unconfigured');
@@ -35,10 +37,35 @@ export class ChallengesStore {
private readonly solveListeners = new Map<string, Set<(p: SolveEventLivePayload) => void>>();
constructor(api?: ChallengesApiService, destroyRef?: DestroyRef) {
constructor(api?: ChallengesApiService, destroyRef?: DestroyRef, dataChanges?: SystemDataChangeService) {
this.api = api ?? inject(ChallengesApiService);
this.destroyRef = destroyRef ?? inject(DestroyRef);
this.dataChanges = dataChanges ?? null;
this.destroyRef.onDestroy(() => this.stop());
this.installDataChangeListeners();
}
private installDataChangeListeners(): void {
if (!this.dataChanges) return;
const lastSeen = { ts: 0 };
effect(() => {
const evts = this.dataChanges!.events();
const latest = evts[evts.length - 1];
if (!latest || latest.ts === lastSeen.ts) return;
lastSeen.ts = latest.ts;
if (latest.kind === 'challenges-wiped' || latest.kind === 'restore-completed' || latest.kind === 'scores-reset') {
this.reset();
}
});
}
/**
* Exposed for callers that want to clear the cached board/selected detail
* after a destructive operation (e.g. challenge wipe). The signal
* subscription above re-evaluates when `events()` mutates.
*/
invalidateForSystemChange(_kind: 'scores-reset' | 'challenges-wiped' | 'restore-completed'): void {
this.reset();
}
readonly board = this._board.asReadonly();
@@ -1,4 +1,4 @@
import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core';
import { DestroyRef, Injectable, computed, effect, inject, signal } from '@angular/core';
import { ScoreboardApiService } from './scoreboard.service';
import {
EventLogRow,
@@ -15,11 +15,13 @@ import {
parseSolveEventIntoRanking,
} from './scoreboard.pure';
import { EventSourceLike } from '../../core/services/event-status.pure';
import { SystemDataChangeService } from '../../core/services/system-data-change.service';
@Injectable({ providedIn: 'root' })
export class ScoreboardStore {
private readonly api: ScoreboardApiService;
private readonly destroyRef: DestroyRef;
private readonly dataChanges: SystemDataChangeService | null;
private readonly _ranking = signal<RankingRow[] | null>(null);
private readonly _matrix = signal<MatrixView | null>(null);
@@ -35,10 +37,26 @@ export class ScoreboardStore {
private createSource: (() => EventSourceLike) | null = null;
private loadAllInFlight: Promise<void> | null = null;
constructor(api?: ScoreboardApiService, destroyRef?: DestroyRef) {
constructor(api?: ScoreboardApiService, destroyRef?: DestroyRef, dataChanges?: SystemDataChangeService) {
this.api = api ?? inject(ScoreboardApiService);
this.destroyRef = destroyRef ?? inject(DestroyRef);
this.dataChanges = dataChanges ?? null;
this.destroyRef.onDestroy(() => this.stop());
if (!this.dataChanges) return;
const lastSeen = { ts: 0 };
const dc = this.dataChanges;
effect(() => {
const evts = dc.events();
const latest = evts[evts.length - 1];
if (!latest || latest.ts === lastSeen.ts) return;
lastSeen.ts = latest.ts;
if (latest.kind === 'scores-reset' || latest.kind === 'restore-completed' || latest.kind === 'challenges-wiped') {
this._ranking.set(null);
this._matrix.set(null);
this._eventLog.set(null);
this._graph.set(null);
}
});
}
readonly ranking = this._ranking.asReadonly();