AI Implementation feature(867): Admin Area Players Management (#56)
This commit was merged in pull request #56.
This commit is contained in:
@@ -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"
|
||||
>✎</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"
|
||||
>🗑</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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user