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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user