AI Implementation feature(867): Admin Area Players Management (#56)

This commit was merged in pull request #56.
This commit is contained in:
2026-07-23 08:08:50 +00:00
parent 705530e71f
commit e4ccf0fe13
27 changed files with 2039 additions and 218 deletions
+5
View File
@@ -59,6 +59,11 @@ export const APP_ROUTES: Routes = [
loadComponent: () =>
import('./features/admin/challenges/challenges.component').then((m) => m.AdminChallengesComponent),
},
{
path: 'players',
loadComponent: () =>
import('./features/admin/admin-users.component').then((m) => m.AdminUsersComponent),
},
],
},
],
@@ -10,6 +10,7 @@ const FRIENDLY_MESSAGES: Record<string, string> = {
FORBIDDEN: 'You are not allowed to perform this action.',
NOT_FOUND: 'Not found.',
VALIDATION_FAILED: 'Invalid input.',
LAST_ADMIN: 'At least one admin must remain in the system. The action was rejected.',
};
function friendlyMessage(status: number, body: any): string {
@@ -48,5 +49,7 @@ function shouldSuppress(url: string | undefined, status: number): boolean {
if (status === 401 && /\/api\/v1\/auth\/(login|csrf|register)/.test(url)) return true;
// SSE / event-status snapshot errors are non-fatal (SSE will deliver the next frame).
if (status === 401 && url.endsWith('/api/v1/challenges/status')) return true;
// Admin Players modal owns its own inline error presentation.
if (/\/api\/v1\/admin\/players\/.+\/password/.test(url)) return true;
return false;
}
@@ -6,6 +6,19 @@ export interface AdminUser {
id: string;
username: string;
role: 'admin' | 'player';
status?: 'enabled' | 'disabled';
createdAt?: string;
}
export interface AdminResetPasswordBody {
newPassword: string;
confirmPassword: string;
}
export class AdminLastAdminError extends Error {
constructor() {
super('At least one admin must remain in the system.');
}
}
export interface GeneralSettings {
@@ -172,8 +185,57 @@ export class AdminService {
private readonly http = inject(HttpClient);
async listUsers(): Promise<AdminUser[]> {
return this.listPlayers();
}
async listPlayers(): Promise<AdminUser[]> {
const rows = await firstValueFrom(
this.http.get<AdminUser[]>('/api/v1/admin/players', { withCredentials: true }),
);
return rows.map((u) => ({
id: u.id,
username: u.username,
role: u.role,
status: u.status ?? 'enabled',
createdAt: u.createdAt ?? new Date().toISOString(),
}));
}
async updatePlayerRole(id: string, role: 'admin' | 'player'): Promise<AdminUser> {
return firstValueFrom(
this.http.get<AdminUser[]>('/api/v1/admin/users', { withCredentials: true }),
this.http.patch<AdminUser>(
`/api/v1/admin/players/${encodeURIComponent(id)}/role`,
{ role },
{ withCredentials: true },
),
);
}
async updatePlayerStatus(id: string, enabled: boolean): Promise<AdminUser> {
return firstValueFrom(
this.http.patch<AdminUser>(
`/api/v1/admin/players/${encodeURIComponent(id)}/status`,
{ enabled },
{ withCredentials: true },
),
);
}
async resetPlayerPassword(id: string, body: AdminResetPasswordBody): Promise<void> {
await firstValueFrom(
this.http.patch<void>(
`/api/v1/admin/players/${encodeURIComponent(id)}/password`,
body,
{ withCredentials: true },
),
);
}
async deletePlayer(id: string): Promise<void> {
await firstValueFrom(
this.http.delete<void>(`/api/v1/admin/players/${encodeURIComponent(id)}`, {
withCredentials: true,
}),
);
}
@@ -0,0 +1,62 @@
import { AdminUser } from '../../core/services/admin.service';
export function replacePlayer(rows: AdminUser[], updated: AdminUser): AdminUser[] {
return rows.map((r) => (r.id === updated.id ? { ...r, ...updated } : r));
}
export function removePlayer(rows: AdminUser[], id: string): AdminUser[] {
return rows.filter((r) => r.id !== id);
}
export function toggleRole(role: 'admin' | 'player'): 'admin' | 'player' {
return role === 'admin' ? 'player' : 'admin';
}
export function toggleEnabled(status: 'enabled' | 'disabled'): boolean {
return status !== 'enabled';
}
export interface ApiErrorBody {
status?: number;
code?: string;
message?: string;
details?: Array<{ path?: string; message?: string }> | unknown;
}
export function isLastAdminError(err: unknown): boolean {
if (!err || typeof err !== 'object') return false;
const e = err as any;
if (e.code === 'LAST_ADMIN') return true;
if (e.error?.code === 'LAST_ADMIN') return true;
if (e.response?.data?.code === 'LAST_ADMIN') return true;
return false;
}
export function lastAdminMessage(): string {
return 'At least one admin must remain in the system. The action was rejected.';
}
export function extractFieldErrors(err: unknown): Record<string, string> {
if (!err || typeof err !== 'object') return {};
const e = err as any;
const details = e.error?.details ?? e.details;
if (!Array.isArray(details)) return {};
const out: Record<string, string> = {};
for (const d of details) {
if (d && typeof d === 'object' && d.path && d.message) {
out[String(d.path)] = String(d.message);
}
}
return out;
}
export function adminPlayerApiErrorMessage(err: unknown, fallback: string): string {
if (!err || typeof err !== 'object') return fallback;
const e = err as any;
if (isLastAdminError(err)) return lastAdminMessage();
const code = e.error?.code ?? e.code;
const message = e.error?.message ?? e.message;
if (code === 'VALIDATION_FAILED') return message ?? 'Invalid input.';
if (code === 'NOT_FOUND') return 'User not found.';
return message ?? fallback;
}
@@ -12,7 +12,7 @@ interface AdminNavEntry {
const ENTRIES: AdminNavEntry[] = [
{ id: 'general', label: 'General', path: '/admin/general', enabled: true },
{ id: 'challenges', label: 'Challenges', path: '/admin/challenges', enabled: true },
{ id: 'players', label: 'Players', path: '/admin/players', enabled: false },
{ id: 'players', label: 'Players', path: '/admin/players', enabled: true },
{ id: 'blog', label: 'Blog', path: '/admin/blog', enabled: false },
{ id: 'system', label: 'System', path: '/admin/system', enabled: false },
];
@@ -7,42 +7,340 @@ import {
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { AdminService, AdminUser } from '../../core/services/admin.service';
import { NotificationService } from '../../core/services/notification.service';
import {
adminPlayerApiErrorMessage,
extractFieldErrors,
isLastAdminError,
lastAdminMessage,
removePlayer,
replacePlayer,
toggleEnabled,
toggleRole,
} from './admin-players.pure';
import { PlayerDeleteModalComponent } from './player-delete-modal.component';
import {
ChangePasswordModalComponent,
ChangePasswordSubmitPayload,
} from '../shell/change-password/change-password-modal.component';
@Component({
selector: 'app-admin-users',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
imports: [CommonModule, PlayerDeleteModalComponent, ChangePasswordModalComponent],
styles: [`
.players-page { display: flex; flex-direction: column; gap: 12px; }
.players-page h2 { margin: 0; }
.toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.toolbar input[type="search"] { flex: 1; min-width: 220px; padding: 6px 8px; }
.players-table { width: 100%; border-collapse: collapse; }
.players-table th, .players-table td { padding: 8px; border-bottom: 1px solid var(--color-secondary, #ccc); text-align: left; vertical-align: middle; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; line-height: 1.4; }
.badge-admin { background: #fef3c7; color: #92400e; }
.badge-player { background: #e0e7ff; color: #3730a3; }
.badge-enabled { background: #d1fae5; color: #065f46; }
.badge-disabled { background: #fee2e2; color: #991b1b; }
.toggle-cell { background: none; border: none; cursor: pointer; font: inherit; padding: 2px 0; }
.toggle-cell[disabled] { cursor: progress; opacity: 0.5; }
.row-actions { display: inline-flex; gap: 6px; }
.icon-button { background: none; border: 1px solid var(--color-secondary, #ccc); border-radius: 4px; padding: 4px 8px; cursor: pointer; }
.icon-button[disabled] { cursor: progress; opacity: 0.5; }
.empty { text-align: center; padding: 32px; color: var(--color-text-muted, #666); }
.status-banner { padding: 8px; border-radius: 4px; }
.status-banner.success { background: #ecfdf5; color: #065f46; }
.status-banner.error { background: #fee2e2; color: #991b1b; }
`],
template: `
<section class="admin-area">
<h2>Admin area</h2>
<p data-testid="admin-loading" *ngIf="loading()">Loading users...</p>
<p data-testid="admin-error" *ngIf="error()" style="color:var(--color-danger)">{{ error() }}</p>
<ul data-testid="admin-user-list" *ngIf="!loading() && !error()">
<li *ngFor="let u of users()">
<b>{{ u.username }}</b> ({{ u.role }})
</li>
</ul>
<section class="players-page" data-testid="admin-players">
<h2>Players</h2>
<div class="toolbar">
<input
type="search"
placeholder="Search players…"
[value]="searchInput()"
(input)="onSearchInput($event)"
data-testid="players-search"
aria-label="Search players"
/>
</div>
@if (statusMessage(); as msg) {
<p class="status-banner success" role="status" aria-live="polite" data-testid="players-status">{{ msg }}</p>
}
@if (errorMessage(); as msg) {
<p class="status-banner error" role="alert" aria-live="assertive" data-testid="players-error">{{ msg }}</p>
}
@if (loading()) {
<p data-testid="players-loading">Loading players…</p>
} @else if (filteredRows().length === 0) {
<p class="empty" data-testid="players-empty">No players match "{{ searchInput() }}"</p>
} @else {
<table class="players-table" data-testid="players-table">
<thead>
<tr>
<th scope="col">Player Name</th>
<th scope="col">Role</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows(); track row.id) {
<tr [attr.data-testid]="'player-row-' + row.id">
<td>{{ row.username }}</td>
<td>
<button
type="button"
class="toggle-cell"
(click)="onToggleRole(row)"
[disabled]="rowInFlight(row.id)"
[attr.data-testid]="'player-role-' + row.id"
[attr.aria-label]="'Toggle role for ' + row.username"
>
<span class="badge" [class.badge-admin]="row.role === 'admin'" [class.badge-player]="row.role === 'player'">
{{ row.role === 'admin' ? 'Admin' : 'Player' }}
</span>
</button>
</td>
<td>
<button
type="button"
class="toggle-cell"
(click)="onToggleStatus(row)"
[disabled]="rowInFlight(row.id)"
[attr.data-testid]="'player-status-' + row.id"
[attr.aria-label]="'Toggle status for ' + row.username"
>
<span class="badge" [class.badge-enabled]="(row.status ?? 'enabled') === 'enabled'" [class.badge-disabled]="row.status === 'disabled'">
{{ (row.status ?? 'enabled') === 'enabled' ? 'Enabled' : 'Disabled' }}
</span>
</button>
</td>
<td class="row-actions">
<button
type="button"
class="icon-button"
(click)="openResetPassword(row)"
[disabled]="rowInFlight(row.id)"
[attr.data-testid]="'player-edit-' + row.id"
aria-label="Edit password"
title="Edit password"
>&#9998;</button>
<button
type="button"
class="icon-button"
(click)="openDelete(row)"
[disabled]="rowInFlight(row.id)"
[attr.data-testid]="'player-delete-' + row.id"
aria-label="Delete user"
title="Delete user"
>&#128465;</button>
</td>
</tr>
}
</tbody>
</table>
}
<app-player-delete-modal
[open]="deleteOpen()"
[user]="deleteTarget()"
[errorMessage]="deleteError()"
[submitting]="deleting()"
(cancel)="closeDelete()"
(confirm)="onDeleteConfirm()"
/>
<app-change-password-modal
[open]="resetOpen()"
mode="admin-reset"
[errorMessage]="resetError()"
[fieldErrors]="resetFieldErrors()"
[submitting]="resetSubmitting()"
(cancel)="closeReset()"
(submit)="onResetSubmit($event)"
/>
</section>
`,
})
export class AdminUsersComponent implements OnInit {
private readonly admin = inject(AdminService);
private readonly notify = inject(NotificationService);
readonly users = signal<AdminUser[]>([]);
readonly rows = signal<AdminUser[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly searchInput = signal('');
readonly inflight = signal<Set<string>>(new Set());
readonly statusMessage = signal<string | null>(null);
readonly errorMessage = signal<string | null>(null);
readonly deleteOpen = signal(false);
readonly deleteTarget = signal<AdminUser | null>(null);
readonly deleteError = signal<string | null>(null);
readonly deleting = signal(false);
readonly resetOpen = signal(false);
readonly resetTarget = signal<AdminUser | null>(null);
readonly resetError = signal<string | null>(null);
readonly resetFieldErrors = signal<Record<string, string> | null>(null);
readonly resetSubmitting = signal(false);
readonly filteredRows = (): AdminUser[] => {
const term = this.searchInput().trim().toLowerCase();
const all = this.rows();
if (!term) return all;
return all.filter((u) => u.username.toLowerCase().includes(term));
};
async ngOnInit(): Promise<void> {
await this.load();
}
async load(): Promise<void> {
this.loading.set(true);
this.error.set(null);
this.errorMessage.set(null);
try {
const list = await this.admin.listUsers();
this.users.set(list);
} catch (e: any) {
this.error.set(e?.error?.message ?? e?.message ?? 'Failed to load users');
const list = await this.admin.listPlayers();
this.rows.set(list);
} catch (e) {
this.errorMessage.set(adminPlayerApiErrorMessage(e, 'Failed to load players'));
} finally {
this.loading.set(false);
}
}
onSearchInput(ev: Event): void {
this.searchInput.set((ev.target as HTMLInputElement).value);
}
rowInFlight(id: string): boolean {
return this.inflight().has(id);
}
private withInflight<T>(id: string, fn: () => Promise<T>): Promise<T> {
this.inflight.update((cur) => {
const next = new Set(cur);
next.add(id);
return next;
});
return fn().finally(() => {
this.inflight.update((cur) => {
const next = new Set(cur);
next.delete(id);
return next;
});
});
}
async onToggleRole(row: AdminUser): Promise<void> {
const nextRole = toggleRole(row.role);
try {
const updated = await this.withInflight(row.id, () => this.admin.updatePlayerRole(row.id, nextRole));
this.rows.set(replacePlayer(this.rows(), { ...row, ...updated }));
this.statusMessage.set(`Role updated to ${updated.role}.`);
} catch (e) {
if (isLastAdminError(e)) {
this.notify.error(lastAdminMessage());
} else {
this.notify.error(adminPlayerApiErrorMessage(e, 'Failed to update role'));
}
}
}
async onToggleStatus(row: AdminUser): Promise<void> {
const nextEnabled = toggleEnabled(row.status ?? 'enabled');
try {
const updated = await this.withInflight(row.id, () => this.admin.updatePlayerStatus(row.id, nextEnabled));
this.rows.set(replacePlayer(this.rows(), { ...row, ...updated }));
this.statusMessage.set(updated.status === 'enabled' ? 'User enabled.' : 'User disabled.');
} catch (e) {
this.notify.error(adminPlayerApiErrorMessage(e, 'Failed to update status'));
}
}
openDelete(row: AdminUser): void {
this.deleteTarget.set(row);
this.deleteError.set(null);
this.deleteOpen.set(true);
}
closeDelete(): void {
if (this.deleting()) return;
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.withInflight(target.id, () => this.admin.deletePlayer(target.id));
this.rows.set(removePlayer(this.rows(), target.id));
this.deleteOpen.set(false);
this.deleteTarget.set(null);
this.statusMessage.set(`User '${target.username}' deleted.`);
} catch (e) {
if (isLastAdminError(e)) {
this.deleteError.set(lastAdminMessage());
this.notify.error(lastAdminMessage());
} else {
this.deleteError.set(adminPlayerApiErrorMessage(e, 'Failed to delete user'));
this.notify.error(adminPlayerApiErrorMessage(e, 'Failed to delete user'));
}
} finally {
this.deleting.set(false);
}
}
openResetPassword(row: AdminUser): void {
this.resetTarget.set(row);
this.resetError.set(null);
this.resetFieldErrors.set(null);
this.resetOpen.set(true);
}
closeReset(): void {
if (this.resetSubmitting()) return;
this.resetOpen.set(false);
this.resetTarget.set(null);
this.resetError.set(null);
this.resetFieldErrors.set(null);
}
async onResetSubmit(payload: ChangePasswordSubmitPayload): Promise<void> {
const target = this.resetTarget();
if (!target) return;
this.resetSubmitting.set(true);
this.resetError.set(null);
this.resetFieldErrors.set(null);
try {
await this.withInflight(target.id, () =>
this.admin.resetPlayerPassword(target.id, {
newPassword: payload.newPassword,
confirmPassword: payload.confirmNewPassword,
}),
);
this.resetOpen.set(false);
this.resetTarget.set(null);
this.statusMessage.set(`Password reset for ${target.username}.`);
} catch (e) {
if (isLastAdminError(e)) {
this.resetError.set(lastAdminMessage());
} else {
const fieldErrors = extractFieldErrors(e);
if (Object.keys(fieldErrors).length > 0) {
this.resetFieldErrors.set(fieldErrors);
}
this.resetError.set(adminPlayerApiErrorMessage(e, 'Failed to reset password'));
}
} finally {
this.resetSubmitting.set(false);
}
}
}
@@ -0,0 +1,75 @@
import {
ChangeDetectionStrategy,
Component,
HostListener,
input,
output,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { AdminUser } from '../../core/services/admin.service';
@Component({
selector: 'app-player-delete-modal',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 70; }
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: 8px; width: min(420px, 90vw); }
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.error { color: var(--color-danger, #f00); 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; }
@keyframes spin { to { transform: rotate(360deg); } }
`],
template: `
@if (open()) {
<div class="modal-overlay" data-testid="player-delete-overlay" (click)="onCancel()">
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="pd-title"
data-testid="player-delete-modal"
(click)="$event.stopPropagation()"
>
<h3 id="pd-title">Delete user</h3>
<p>Delete this user?</p>
@if (errorMessage(); as msg) {
<p class="error" role="alert" data-testid="player-delete-error">{{ msg }}</p>
}
<div class="actions">
<button type="button" (click)="onCancel()" [disabled]="submitting()" data-testid="player-delete-cancel">Cancel</button>
<button type="button" (click)="onConfirm()" [disabled]="submitting()" data-testid="player-delete-ok">
<span *ngIf="submitting()" class="spinner" aria-hidden="true"></span>
Delete
</button>
</div>
</div>
</div>
}
`,
})
export class PlayerDeleteModalComponent {
readonly open = input(false);
readonly user = input<AdminUser | null>(null);
readonly errorMessage = input<string | null>(null);
readonly submitting = input(false);
readonly cancel = output<void>();
readonly confirm = output<void>();
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.open() && !this.submitting()) this.onCancel();
}
onCancel(): void {
if (this.submitting()) return;
this.cancel.emit();
}
onConfirm(): void {
if (this.submitting()) return;
this.confirm.emit();
}
}
@@ -6,7 +6,6 @@ import {
inject,
input,
output,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@@ -45,7 +44,7 @@ const DEFAULT_POLICY: PasswordPolicy = {
data-testid="change-password-modal"
(click)="$event.stopPropagation()"
>
<h2 id="cp-title">Change password</h2>
<h2 id="cp-title">Change Password</h2>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
@if (mode() === 'self') {
@@ -68,6 +67,9 @@ const DEFAULT_POLICY: PasswordPolicy = {
formControlName="newPassword"
data-testid="cp-new"
/>
@if (fieldErrorFor('newPassword'); as msg) {
<span class="error" data-testid="cp-new-error">{{ msg }}</span>
}
</label>
<label class="field">
@@ -78,6 +80,12 @@ const DEFAULT_POLICY: PasswordPolicy = {
formControlName="confirmNewPassword"
data-testid="cp-confirm"
/>
@if (fieldErrorFor('confirmPassword'); as msg) {
<span class="error" data-testid="cp-confirm-error">{{ msg }}</span>
}
@if (fieldErrorFor('confirmNewPassword'); as msg) {
<span class="error" data-testid="cp-confirm-error">{{ msg }}</span>
}
</label>
@if (errorMessage()) {
@@ -119,6 +127,7 @@ export class ChangePasswordModalComponent {
readonly policy = input<PasswordPolicy>(DEFAULT_POLICY);
readonly errorMessage = input<string | null>(null);
readonly submitting = input<boolean>(false);
readonly fieldErrors = input<Record<string, string> | null>(null);
readonly cancel = output<void>();
readonly submit = output<ChangePasswordSubmitPayload>();
@@ -138,12 +147,19 @@ export class ChangePasswordModalComponent {
return null;
});
fieldErrorFor(path: string): string | null {
const map = this.fieldErrors();
if (!map) return null;
return map[path] ?? null;
}
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.open()) this.onCancel();
if (this.open() && !this.submitting()) this.onCancel();
}
onCancel(): void {
if (this.submitting()) return;
this.form.reset({ oldPassword: '', newPassword: '', confirmNewPassword: '' });
this.cancel.emit();
}