feat: build admin user management UI

This commit is contained in:
Bastian Wagner
2026-08-01 10:01:25 +02:00
parent d0ec000bff
commit 3ae0fd2000
6 changed files with 1256 additions and 9 deletions

View File

@@ -0,0 +1,265 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, EventEmitter, Input, OnChanges, Output, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { filter, finalize, take } from 'rxjs';
import { AdminUsersApi } from '../../core/users/admin-users-api';
import {
AdminPlayerPage,
AdminPlayerSummary,
AdminUserDirectorySummary,
} from '../../models/user-directory.model';
import { ConfirmDialog } from '../../shared/confirm-dialog/confirm-dialog';
@Component({
selector: 'app-player-assignments',
imports: [
MatButtonModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
],
template: `
<section class="assignments" aria-label="Spielerzuordnungen verwalten">
<div class="assignments__heading">
<div>
<h3>Spielerzuordnungen</h3>
<p>Spieler teamübergreifend suchen und sicher verknüpfen.</p>
</div>
<button mat-button type="button" (click)="closed.emit()">Schließen</button>
</div>
<form class="assignments__search" (submit)="submitSearch(); $event.preventDefault()" role="search">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Spieler suchen</mat-label>
<mat-icon matPrefix>search</mat-icon>
<input matInput type="search" name="playerSearch" [value]="searchDraft" (input)="searchDraft = $any($event.target).value" />
</mat-form-field>
<button mat-stroked-button type="submit">Suchen</button>
</form>
@if (error()) {
<div class="assignments__error" role="alert">
<span>{{ error() }}</span>
<button mat-button type="button" (click)="loadPlayers()">Erneut versuchen</button>
</div>
}
@if (loading()) {
<div class="assignments__state"><mat-spinner diameter="30" /></div>
} @else if (!error() && players()?.data?.length === 0) {
<div class="assignments__state"><span>Keine Spieler gefunden.</span></div>
} @else if (players()) {
<div class="player-list">
@for (player of players()!.data; track player.id) {
<div class="player-row" [class.player-row--inactive]="!player.active">
<div class="player-row__copy">
<strong>{{ player.firstName }} {{ player.lastName }}</strong>
<span>{{ player.team.name }} · {{ player.active ? 'Aktiv' : 'Inaktiv' }}</span>
@if (player.currentUser) {
<span>Aktuell: {{ userName(player.currentUser) }}</span>
} @else {
<span>Nicht verknüpft</span>
}
</div>
@if (player.currentUser?.id === user.id) {
<button
mat-stroked-button
type="button"
[disabled]="pendingPlayerId() !== null"
(click)="confirmUnlink(player)"
>
{{ pendingPlayerId() === player.id ? 'Wird gelöst …' : 'Verknüpfung lösen' }}
</button>
} @else if (player.currentUser) {
<button
mat-stroked-button
type="button"
[disabled]="pendingPlayerId() !== null"
(click)="confirmReassign(player)"
>
{{ pendingPlayerId() === player.id ? 'Wird neu zugeordnet …' : 'Neu zuordnen' }}
</button>
} @else {
<button
mat-flat-button
type="button"
[disabled]="pendingPlayerId() !== null"
(click)="assign(player)"
>
{{ pendingPlayerId() === player.id ? 'Wird zugeordnet …' : 'Zuordnen' }}
</button>
}
</div>
}
</div>
<div class="assignments__paging" aria-label="Spielerseiten">
<button mat-button type="button" [disabled]="players()!.page <= 1 || loading()" (click)="previousPage()">
<mat-icon>chevron_left</mat-icon>Zurück
</button>
<span>Seite {{ players()!.page }}</span>
<button mat-button type="button" [disabled]="!players()!.hasNextPage || loading()" (click)="nextPage()">
Weiter<mat-icon>chevron_right</mat-icon>
</button>
</div>
}
</section>
`,
styles: `
.assignments { padding: 18px; border: 1px solid var(--mat-sys-outline-variant); border-radius: 18px; }
.assignments__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
h3, p { margin: 0; }
.assignments__heading p { margin-top: 4px; color: var(--mat-sys-on-surface-variant); }
.assignments__search { display: flex; align-items: center; gap: 10px; margin: 18px 0; }
.assignments__search mat-form-field { flex: 1; }
.player-list { display: grid; }
.player-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--mat-sys-outline-variant); }
.player-row--inactive { opacity: .68; }
.player-row__copy { display: grid; gap: 2px; min-width: 0; }
.player-row__copy span { color: var(--mat-sys-on-surface-variant); }
.assignments__state { min-height: 120px; display: grid; place-content: center; color: var(--mat-sys-on-surface-variant); }
.assignments__error { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; padding: 12px; border-radius: 12px; color: var(--mat-sys-on-error-container); background: var(--mat-sys-error-container); }
.assignments__paging { display: flex; align-items: center; justify-content: space-between; margin-top: 14px; }
@media (max-width: 600px) {
.assignments { padding: 16px; }
.assignments__heading, .assignments__search, .player-row { align-items: stretch; flex-direction: column; }
.player-row button { width: 100%; }
}
`,
})
export class PlayerAssignments implements OnChanges {
private readonly adminUsersApi = inject(AdminUsersApi);
private readonly dialog = inject(MatDialog);
@Input({ required: true }) user!: AdminUserDirectorySummary;
@Output() readonly directoryChanged = new EventEmitter<void>();
@Output() readonly closed = new EventEmitter<void>();
protected readonly players = signal<AdminPlayerPage | null>(null);
protected readonly loading = signal(false);
protected readonly error = signal<string | null>(null);
protected readonly pendingPlayerId = signal<number | null>(null);
protected searchDraft = '';
private search = '';
private page = 1;
private readonly limit = 20;
ngOnChanges(): void {
this.searchDraft = '';
this.search = '';
this.page = 1;
this.loadPlayers();
}
protected loadPlayers(): void {
this.loading.set(true);
this.error.set(null);
this.adminUsersApi
.loadPlayers({
assignment: 'all',
page: this.page,
limit: this.limit,
...(this.search ? { search: this.search } : {}),
})
.pipe(finalize(() => this.loading.set(false)))
.subscribe({
next: (players) => this.players.set(players),
error: (error: HttpErrorResponse) => this.error.set(this.errorMessage(error)),
});
}
protected submitSearch(): void {
this.search = this.searchDraft.trim();
this.page = 1;
this.loadPlayers();
}
protected previousPage(): void {
if (this.page <= 1 || this.loading()) return;
this.page -= 1;
this.loadPlayers();
}
protected nextPage(): void {
if (!this.players()?.hasNextPage || this.loading()) return;
this.page += 1;
this.loadPlayers();
}
protected assign(player: AdminPlayerSummary): void {
this.runAssignment(player);
}
protected confirmUnlink(player: AdminPlayerSummary): void {
this.confirm({
title: 'Verknüpfung lösen?',
message: `${player.firstName} ${player.lastName} wird von ${this.targetName()} getrennt.`,
confirmLabel: 'Verknüpfung lösen',
}).subscribe(() => this.runUnlink(player));
}
protected confirmReassign(player: AdminPlayerSummary): void {
this.confirm({
title: 'Spieler neu zuordnen?',
message: `${player.firstName} ${player.lastName} ist aktuell mit ${this.userName(player.currentUser!)} verknüpft und wird ${this.targetName()} zugeordnet.`,
confirmLabel: 'Neu zuordnen',
}).subscribe(() => this.runAssignment(player));
}
protected userName(user: { id: number; firstName: string | null; lastName: string | null }): string {
return [user.firstName, user.lastName].filter(Boolean).join(' ') || `Benutzer ${user.id}`;
}
private targetName(): string {
return this.userName(this.user);
}
private confirm(data: { title: string; message: string; confirmLabel: string }) {
return this.dialog
.open(ConfirmDialog, { data, restoreFocus: true })
.afterClosed()
.pipe(filter(Boolean), take(1));
}
private runAssignment(player: AdminPlayerSummary): void {
if (this.pendingPlayerId() !== null) return;
this.pendingPlayerId.set(player.id);
this.error.set(null);
this.adminUsersApi
.assignPlayer(this.user.id, player.id)
.pipe(finalize(() => this.pendingPlayerId.set(null)))
.subscribe({
next: () => this.refreshAfterMutation(),
error: (error: HttpErrorResponse) => this.error.set(this.errorMessage(error)),
});
}
private runUnlink(player: AdminPlayerSummary): void {
if (this.pendingPlayerId() !== null) return;
this.pendingPlayerId.set(player.id);
this.error.set(null);
this.adminUsersApi
.unlinkPlayer(this.user.id, player.id)
.pipe(finalize(() => this.pendingPlayerId.set(null)))
.subscribe({
next: () => this.refreshAfterMutation(),
error: (error: HttpErrorResponse) => this.error.set(this.errorMessage(error)),
});
}
private refreshAfterMutation(): void {
this.directoryChanged.emit();
this.loadPlayers();
}
private errorMessage(error: HttpErrorResponse): string {
const detail = typeof error.error?.message === 'string' ? error.error.message : '';
const fallback = error.status === 403 ? 'Keine Berechtigung.' : 'Zuordnung konnte nicht geändert werden.';
return detail ? `${fallback} ${detail}` : fallback;
}
}

View File

@@ -0,0 +1,85 @@
import { Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { AdminUserDirectorySummary, AdminUserRoleId } from '../../models/user-directory.model';
export interface UserEditValue {
firstName: string;
lastName: string;
role: AdminUserRoleId;
}
@Component({
selector: 'app-user-edit',
imports: [MatButtonModule, MatFormFieldModule, MatInputModule, MatSelectModule],
template: `
<form class="user-edit" (submit)="submit(); $event.preventDefault()" aria-label="Benutzer bearbeiten">
<div class="user-edit__fields">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Vorname</mat-label>
<input matInput name="firstName" [value]="firstName" (input)="firstName = $any($event.target).value" required />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Nachname</mat-label>
<input matInput name="lastName" [value]="lastName" (input)="lastName = $any($event.target).value" required />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Globale Rolle</mat-label>
<select matNativeControl name="role" [value]="role" (change)="setRole($any($event.target).value)" [disabled]="self">
<option value="1">Administrator</option>
<option value="2">Benutzer</option>
</select>
</mat-form-field>
</div>
@if (self) {
<p class="user-edit__hint">Die eigene Administratorrolle kann hier nicht entzogen werden.</p>
}
<div class="user-edit__actions">
<button mat-button type="button" (click)="cancel.emit()" [disabled]="saving">Abbrechen</button>
<button mat-flat-button type="submit" [disabled]="saving || !firstName.trim() || !lastName.trim()">
{{ saving ? 'Wird gespeichert …' : 'Änderungen speichern' }}
</button>
</div>
</form>
`,
styles: `
.user-edit { padding: 18px; border-radius: 18px; background: var(--mat-sys-surface-container-low); }
.user-edit__fields { display: grid; grid-template-columns: 1fr 1fr 180px; gap: 12px; }
.user-edit__hint { margin: 10px 0 0; color: var(--mat-sys-on-surface-variant); }
.user-edit__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
@media (max-width: 700px) {
.user-edit__fields { grid-template-columns: 1fr; }
.user-edit__actions { align-items: stretch; flex-direction: column-reverse; }
}
`,
})
export class UserEdit implements OnChanges {
@Input({ required: true }) user!: AdminUserDirectorySummary;
@Input() self = false;
@Input() saving = false;
@Output() readonly saved = new EventEmitter<UserEditValue>();
@Output() readonly cancel = new EventEmitter<void>();
protected firstName = '';
protected lastName = '';
protected role: AdminUserRoleId = 2;
ngOnChanges(): void {
this.firstName = this.user.firstName ?? '';
this.lastName = this.user.lastName ?? '';
this.role = this.user.role?.id === 1 ? 1 : 2;
}
protected submit(): void {
const firstName = this.firstName.trim();
const lastName = this.lastName.trim();
if (!firstName || !lastName || this.saving) return;
this.saved.emit({ firstName, lastName, role: this.self ? 1 : this.role });
}
protected setRole(value: string): void {
this.role = value === '1' ? 1 : 2;
}
}

View File

@@ -0,0 +1,123 @@
<main class="users-page">
<a mat-button routerLink="/" class="back-link"><mat-icon>arrow_back</mat-icon>Zurück</a>
<header class="page-header">
<p class="eyebrow">Organisation</p>
<h1>Benutzer</h1>
<p>Konten und sichtbare Teamzuordnungen im Überblick.</p>
</header>
<form class="directory-search" (submit)="submitSearch(); $event.preventDefault()" role="search">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Benutzer suchen</mat-label>
<mat-icon matPrefix>search</mat-icon>
<input
matInput
type="search"
name="directorySearch"
[value]="searchDraft()"
(input)="searchDraft.set($any($event.target).value)"
/>
</mat-form-field>
<button mat-stroked-button type="submit" [disabled]="loading()">Suchen</button>
</form>
@if (mutationError()) {
<div class="message message--error" role="alert">{{ mutationError() }}</div>
}
@if (loading()) {
<div class="page-state" aria-live="polite">
<mat-spinner diameter="36" />
<span>Benutzer werden geladen …</span>
</div>
} @else if (loadError()) {
<div class="page-state page-state--error" role="alert">
<mat-icon>error_outline</mat-icon>
<strong>{{ loadError() }}</strong>
<button mat-stroked-button type="button" (click)="loadDirectory()">Erneut versuchen</button>
</div>
} @else if (directory()?.data?.length === 0) {
<div class="page-state">
<mat-icon>group_off</mat-icon>
<strong>Keine Benutzer gefunden</strong>
<span>Versuche einen anderen Suchbegriff.</span>
</div>
} @else if (directory()) {
<div class="directory" aria-live="polite">
@for (user of directory()!.data; track user.id) {
<article class="user-row" [attr.data-user-id]="user.id" [class.user-row--inactive]="user.status?.id === 2">
<div class="user-row__summary">
<div class="avatar" aria-hidden="true">{{ initials(user) }}</div>
<div class="user-row__identity">
<div class="user-row__name">
<h2>{{ fullName(user) }}</h2>
<span class="status" [class.status--inactive]="user.status?.id === 2">
{{ statusName(user.status?.id) }}
</span>
</div>
@if (adminDetails(user); as details) {
<span>{{ details.email ?? 'Keine E-Mail' }} · {{ roleName(details.role?.id) }}</span>
}
@if (user.assignments.length === 0) {
<span>Keine Spielerzuordnung sichtbar</span>
} @else {
<div class="assignment-summary">
@for (assignment of user.assignments; track assignment.id) {
<span>
{{ assignment.team.name }} · {{ assignment.firstName }} {{ assignment.lastName }} ·
{{ teamRoleName(assignment.teamRole?.name) }}
@if (!assignment.active) { · Inaktiv }
</span>
}
</div>
}
</div>
</div>
@if (adminDetails(user); as details) {
<div class="user-row__actions">
<button mat-button type="button" (click)="toggleEdit(user.id)">Bearbeiten</button>
<button mat-button type="button" (click)="toggleAssignments(user.id)">Zuordnungen verwalten</button>
<button
mat-stroked-button
type="button"
[disabled]="isSelf(user) || pendingUserId() !== null"
[attr.title]="isSelf(user) ? 'Das eigene Konto kann nicht deaktiviert werden.' : null"
(click)="changeStatus(details)"
>
{{ user.status?.id === 2 ? 'Aktivieren' : 'Deaktivieren' }}
</button>
</div>
@if (editingUserId() === user.id) {
<app-user-edit
[user]="details"
[self]="isSelf(user)"
[saving]="pendingUserId() === user.id"
(saved)="saveEdit(details, $event)"
(cancel)="editingUserId.set(null)"
/>
}
@if (assignmentUserId() === user.id) {
<app-player-assignments
[user]="details"
(directoryChanged)="assignmentsChanged()"
(closed)="assignmentUserId.set(null)"
/>
}
}
</article>
}
</div>
<nav class="pagination" aria-label="Benutzerseiten">
<button mat-button type="button" [disabled]="directory()!.page <= 1 || loading()" (click)="previousPage()">
<mat-icon>chevron_left</mat-icon>Zurück
</button>
<span>Seite {{ directory()!.page }} · {{ directory()!.total }} Benutzer</span>
<button mat-button type="button" [disabled]="!directory()!.hasNextPage || loading()" (click)="nextPage()">
Weiter<mat-icon>chevron_right</mat-icon>
</button>
</nav>
}
</main>

View File

@@ -0,0 +1,191 @@
:host {
display: block;
min-height: 100dvh;
background: var(--mat-sys-surface);
}
.users-page {
max-width: 960px;
margin: 0 auto;
padding: 24px 28px 40px;
}
.back-link {
margin-left: -12px;
}
.page-header {
margin: 20px 0 26px;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 8px;
font-size: clamp(2rem, 4vw, 3rem);
}
.page-header > p:last-child {
color: var(--mat-sys-on-surface-variant);
}
.eyebrow {
margin-bottom: 6px;
color: var(--mat-sys-primary);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.directory-search {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 22px;
}
.directory-search mat-form-field {
flex: 1;
}
.directory {
border-top: 1px solid var(--mat-sys-outline-variant);
}
.user-row {
display: grid;
gap: 16px;
padding: 20px 0;
border-bottom: 1px solid var(--mat-sys-outline-variant);
}
.user-row--inactive .avatar {
filter: grayscale(1);
opacity: 0.65;
}
.user-row__summary {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
gap: 14px;
}
.avatar {
width: 48px;
height: 48px;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
font-weight: 700;
}
.user-row__identity,
.assignment-summary {
display: grid;
gap: 4px;
min-width: 0;
}
.user-row__identity > span,
.assignment-summary span {
color: var(--mat-sys-on-surface-variant);
}
.user-row__name {
display: flex;
align-items: center;
gap: 10px;
}
.user-row__name h2 {
margin-bottom: 0;
font-size: 1.08rem;
}
.status {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 9px;
border-radius: 999px;
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
font-size: 0.75rem;
font-weight: 700;
}
.status--inactive {
background: var(--mat-sys-surface-container-high);
color: var(--mat-sys-on-surface-variant);
}
.user-row__actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 6px;
}
.page-state {
min-height: 240px;
display: grid;
place-content: center;
justify-items: center;
gap: 10px;
padding: 24px;
color: var(--mat-sys-on-surface-variant);
text-align: center;
}
.page-state--error {
color: var(--mat-sys-error);
}
.message {
margin-bottom: 16px;
padding: 12px 14px;
border-radius: 12px;
}
.message--error {
color: var(--mat-sys-on-error-container);
background: var(--mat-sys-error-container);
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 18px;
color: var(--mat-sys-on-surface-variant);
}
@media (max-width: 700px) {
.users-page {
padding: 20px 16px 32px;
}
.directory-search,
.user-row__actions {
align-items: stretch;
flex-direction: column;
}
.directory-search button,
.user-row__actions button {
width: 100%;
}
.pagination span {
font-size: 0.8rem;
text-align: center;
}
}

View File

@@ -0,0 +1,381 @@
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { provideRouter } from '@angular/router';
import { Subject } from 'rxjs';
import { environment } from '../../../environments/environment';
import { AuthStore } from '../../core/auth/auth-store';
import { AdminPlayerPage, AdminUserDirectorySummary, UserDirectoryPage } from '../../models/user-directory.model';
import { Users } from './users';
const api = `${environment.apiUrl}users/directory`;
const adminApi = `${environment.apiUrl}admin/users`;
const ada: AdminUserDirectorySummary = {
id: 7,
firstName: 'Ada',
lastName: 'Lovelace',
email: 'ada@example.test',
role: { id: 2, name: 'User' },
status: { id: 1, name: 'Active' },
assignments: [
{
id: 101,
firstName: 'Ada',
lastName: 'Lovelace',
active: true,
team: { id: 5, name: 'First Team', alias: 'first' },
teamRole: { id: 1, name: 'player' },
},
],
};
const admin: AdminUserDirectorySummary = {
id: 1,
firstName: 'Grace',
lastName: 'Admin',
email: 'grace@example.test',
role: { id: 1, name: 'Admin' },
status: { id: 1, name: 'Active' },
assignments: [],
};
function directoryPage(data = [ada], page = 1, total = data.length, hasNextPage = false): UserDirectoryPage {
return { data, page, limit: 20, total, hasNextPage };
}
function playersPage(overrides: Partial<AdminPlayerPage> = {}): AdminPlayerPage {
return {
data: [
{
id: 202,
firstName: 'Linus',
lastName: 'Player',
active: true,
team: { id: 6, name: 'Second Team', alias: 'second' },
currentUser: null,
},
],
page: 1,
limit: 20,
total: 1,
hasNextPage: false,
...overrides,
};
}
describe('Users directory', () => {
let fixture: ComponentFixture<Users>;
let http: HttpTestingController;
let isAdmin: ReturnType<typeof signal<boolean>>;
let currentUser: ReturnType<typeof signal<{ id: number; firstName: string; lastName: string; role: { id: number } }>>;
let closeDialog: Subject<boolean>;
let dialog: { open: ReturnType<typeof vi.fn> };
beforeEach(async () => {
isAdmin = signal(false);
currentUser = signal({ id: 99, firstName: 'Nora', lastName: 'Viewer', role: { id: 2 } });
closeDialog = new Subject<boolean>();
dialog = { open: vi.fn(() => ({ afterClosed: () => closeDialog.asObservable() })) };
await TestBed.configureTestingModule({
imports: [Users],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([]),
{ provide: AuthStore, useValue: { isGlobalAdmin: isAdmin, currentUser } },
{ provide: MatDialog, useValue: dialog },
],
}).compileComponents();
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
function create(): void {
fixture = TestBed.createComponent(Users);
fixture.detectChanges();
}
function text(): string {
return (fixture.nativeElement as HTMLElement).textContent ?? '';
}
function button(label: string): HTMLButtonElement {
const match = [...(fixture.nativeElement as HTMLElement).querySelectorAll('button')].find((element) =>
element.textContent?.includes(label),
);
if (!match) throw new Error(`Missing button: ${label}`);
return match as HTMLButtonElement;
}
function flushDirectory(page = directoryPage()): void {
http.expectOne(`${api}?page=1&limit=20`).flush(page);
fixture.detectChanges();
}
it('loads the first directory page, searches from page one, and pages forward', () => {
create();
flushDirectory(directoryPage([ada], 1, 12, true));
expect(text()).toContain('Ada Lovelace');
expect(text()).toContain('First Team');
expect(text()).toContain('Aktiv');
const search = (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>('input[type="search"]')!;
search.value = ' Linus ';
search.dispatchEvent(new Event('input'));
fixture.detectChanges();
search.closest('form')!.dispatchEvent(new Event('submit'));
fixture.detectChanges();
http.expectOne(`${api}?page=1&limit=20&search=Linus`).flush(directoryPage([], 1));
fixture.detectChanges();
search.value = '';
search.dispatchEvent(new Event('input'));
fixture.detectChanges();
search.closest('form')!.dispatchEvent(new Event('submit'));
fixture.detectChanges();
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([ada], 1, 12, true));
fixture.detectChanges();
button('Weiter').click();
fixture.detectChanges();
http.expectOne(`${api}?page=2&limit=20`).flush(directoryPage([admin], 2, 12, false));
});
it('redacts admin-only data and controls for a non-admin even if extra fields arrive', () => {
create();
flushDirectory(directoryPage([ada]));
expect(text()).toContain('Ada Lovelace');
expect(text()).not.toContain('ada@example.test');
expect(text()).not.toContain('Globale Rolle');
expect(text()).not.toContain('Bearbeiten');
expect(text()).not.toContain('Zuordnungen verwalten');
});
it('shows admin fields and actions while disabling self-demotion and self-deactivation', () => {
isAdmin.set(true);
currentUser.set({ id: 1, firstName: 'Grace', lastName: 'Admin', role: { id: 1 } });
create();
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([admin, ada]));
fixture.detectChanges();
expect(text()).toContain('ada@example.test');
expect(text()).toContain('Administrator');
expect(text()).toContain('Bearbeiten');
expect(text()).toContain('Zuordnungen verwalten');
const selfRow = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('[data-user-id="1"]')!;
const selfButtons = [...selfRow.querySelectorAll('button')];
expect(selfButtons.find((item) => item.textContent?.includes('Deaktivieren'))?.disabled).toBe(true);
selfButtons.find((item) => item.textContent?.includes('Bearbeiten'))?.click();
fixture.detectChanges();
expect(selfRow.querySelector<HTMLSelectElement>('select[name="role"]')?.disabled).toBe(true);
});
it('edits a profile and role pessimistically, then reloads the directory', () => {
isAdmin.set(true);
create();
flushDirectory();
button('Bearbeiten').click();
fixture.detectChanges();
const firstName = (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>('input[name="firstName"]')!;
const lastName = (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>('input[name="lastName"]')!;
const role = (fixture.nativeElement as HTMLElement).querySelector<HTMLSelectElement>('select[name="role"]')!;
firstName.value = 'Augusta';
firstName.dispatchEvent(new Event('input'));
lastName.value = 'King';
lastName.dispatchEvent(new Event('input'));
role.value = '1';
role.dispatchEvent(new Event('change'));
firstName.closest('form')!.dispatchEvent(new Event('submit'));
fixture.detectChanges();
const profile = http.expectOne(`${adminApi}/7/profile`);
expect(profile.request.body).toEqual({ firstName: 'Augusta', lastName: 'King' });
expect(text()).toContain('Wird gespeichert');
profile.flush(ada);
const roleRequest = http.expectOne(`${adminApi}/7/role`);
expect(roleRequest.request.body).toEqual({ role: 1 });
roleRequest.flush(ada);
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([{ ...ada, firstName: 'Augusta', lastName: 'King' }]));
fixture.detectChanges();
expect(text()).toContain('Augusta King');
});
it('reloads authoritative directory state when a role change fails after the profile was saved', () => {
isAdmin.set(true);
create();
flushDirectory();
button('Bearbeiten').click();
fixture.detectChanges();
const role = (fixture.nativeElement as HTMLElement).querySelector<HTMLSelectElement>('select[name="role"]')!;
role.value = '1';
role.dispatchEvent(new Event('change'));
role.closest('form')!.dispatchEvent(new Event('submit'));
http.expectOne(`${adminApi}/7/profile`).flush(ada);
http.expectOne(`${adminApi}/7/role`).flush(
{ message: 'At least one active admin must remain' },
{ status: 409, statusText: 'Conflict' },
);
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage());
fixture.detectChanges();
expect(text()).toContain('At least one active admin must remain');
});
it('confirms status changes before mutating and refreshes after success', () => {
isAdmin.set(true);
create();
flushDirectory();
button('Deaktivieren').click();
expect(dialog.open).toHaveBeenCalled();
expect(dialog.open.mock.calls[0][1].data.message).toContain('Ada Lovelace');
http.expectNone(`${adminApi}/7/status`);
closeDialog.next(true);
const request = http.expectOne(`${adminApi}/7/status`);
expect(request.request.body).toEqual({ status: 2 });
request.flush(ada);
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([{ ...ada, status: { id: 2, name: 'Inactive' } }]));
});
it('loads searchable player results and assigns an unlinked player before refreshing both lists', () => {
isAdmin.set(true);
create();
flushDirectory();
button('Zuordnungen verwalten').click();
fixture.detectChanges();
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage());
fixture.detectChanges();
expect(text()).toContain('Linus Player');
expect(text()).toContain('Second Team');
button('Zuordnen').click();
const assign = http.expectOne(`${adminApi}/7/players/202`);
expect(assign.request.method).toBe('PUT');
assign.flush(ada);
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage());
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage());
});
it('confirms unlinking and reassignment with the affected user names', () => {
isAdmin.set(true);
create();
flushDirectory();
button('Zuordnungen verwalten').click();
fixture.detectChanges();
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(
playersPage({
data: [
{
id: 101,
firstName: 'Ada',
lastName: 'Lovelace',
active: true,
team: { id: 5, name: 'First Team', alias: 'first' },
currentUser: { id: 7, firstName: 'Ada', lastName: 'Lovelace', status: { id: 1, name: 'Active' } },
},
{
id: 203,
firstName: 'Other',
lastName: 'Player',
active: true,
team: { id: 6, name: 'Second Team', alias: 'second' },
currentUser: { id: 8, firstName: 'Alan', lastName: 'Turing', status: { id: 1, name: 'Active' } },
},
],
total: 2,
}),
);
fixture.detectChanges();
button('Verknüpfung lösen').click();
expect(dialog.open.mock.calls[0][1].data.message).toContain('Ada Lovelace');
closeDialog.next(false);
http.expectNone(`${adminApi}/7/players/101`);
closeDialog = new Subject<boolean>();
dialog.open.mockReturnValue({ afterClosed: () => closeDialog.asObservable() });
button('Neu zuordnen').click();
const message = dialog.open.mock.calls[1][1].data.message;
expect(message).toContain('Alan Turing');
expect(message).toContain('Ada Lovelace');
closeDialog.next(true);
http.expectOne(`${adminApi}/7/players/203`).flush(ada);
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage());
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage());
});
it('keeps an unlink pending until success and then reloads directory and player results', () => {
isAdmin.set(true);
create();
flushDirectory();
button('Zuordnungen verwalten').click();
fixture.detectChanges();
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(
playersPage({
data: [
{
id: 101,
firstName: 'Ada',
lastName: 'Lovelace',
active: true,
team: { id: 5, name: 'First Team', alias: 'first' },
currentUser: { id: 7, firstName: 'Ada', lastName: 'Lovelace', status: { id: 1, name: 'Active' } },
},
],
}),
);
fixture.detectChanges();
button('Verknüpfung lösen').click();
http.expectNone(`${adminApi}/7/players/101`);
closeDialog.next(true);
fixture.detectChanges();
const unlink = http.expectOne(`${adminApi}/7/players/101`);
expect(unlink.request.method).toBe('DELETE');
expect(text()).toContain('Wird gelöst');
unlink.flush(ada);
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage());
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage());
});
it('renders loading, empty, general error, and retry states', () => {
create();
expect((fixture.nativeElement as HTMLElement).querySelector('[role="progressbar"]')).not.toBeNull();
http.expectOne(`${api}?page=1&limit=20`).flush('broken', { status: 500, statusText: 'Server Error' });
fixture.detectChanges();
expect(text()).toContain('Benutzer konnten nicht geladen werden');
button('Erneut versuchen').click();
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([]));
fixture.detectChanges();
expect(text()).toContain('Keine Benutzer gefunden');
});
it('surfaces directory and mutation authorization errors clearly', () => {
create();
http.expectOne(`${api}?page=1&limit=20`).flush({ message: 'Forbidden' }, { status: 403, statusText: 'Forbidden' });
fixture.detectChanges();
expect(text()).toContain('Keine Berechtigung');
isAdmin.set(true);
button('Erneut versuchen').click();
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage());
fixture.detectChanges();
button('Deaktivieren').click();
closeDialog.next(true);
http.expectOne(`${adminApi}/7/status`).flush(
{ message: 'At least one active admin must remain' },
{ status: 403, statusText: 'Forbidden' },
);
fixture.detectChanges();
expect(text()).toContain('Keine Berechtigung');
expect(text()).toContain('At least one active admin must remain');
});
});

View File

@@ -1,13 +1,215 @@
import { Component } from '@angular/core'; import { HttpErrorResponse } from '@angular/common/http';
import { Component, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { filter, finalize, of, switchMap, take } from 'rxjs';
import { AuthStore } from '../../core/auth/auth-store';
import { AdminUsersApi } from '../../core/users/admin-users-api';
import { UsersApi } from '../../core/users/users-api';
import {
AdminUserDirectorySummary,
AdminUserStatusId,
UserDirectoryPage,
UserDirectoryRecord,
} from '../../models/user-directory.model';
import { ConfirmDialog } from '../../shared/confirm-dialog/confirm-dialog';
import { PlayerAssignments } from './player-assignments';
import { UserEdit, UserEditValue } from './user-edit';
@Component({ @Component({
selector: 'app-users', selector: 'app-users',
template: ` imports: [
<header> RouterLink,
<p class="eyebrow">Organisation</p> MatButtonModule,
<h1>Benutzer</h1> MatFormFieldModule,
<p>Benutzerverzeichnis wird vorbereitet.</p> MatIconModule,
</header> MatInputModule,
`, MatProgressSpinnerModule,
PlayerAssignments,
UserEdit,
],
templateUrl: './users.html',
styleUrl: './users.scss',
}) })
export class Users {} export class Users {
private readonly usersApi = inject(UsersApi);
private readonly adminUsersApi = inject(AdminUsersApi);
private readonly authStore = inject(AuthStore);
private readonly dialog = inject(MatDialog);
protected readonly isAdmin = this.authStore.isGlobalAdmin;
protected readonly currentUser = this.authStore.currentUser;
protected readonly directory = signal<UserDirectoryPage | null>(null);
protected readonly loading = signal(true);
protected readonly loadError = signal<string | null>(null);
protected readonly mutationError = signal<string | null>(null);
protected readonly searchDraft = signal('');
protected readonly search = signal('');
protected readonly page = signal(1);
protected readonly limit = 20;
protected readonly editingUserId = signal<number | null>(null);
protected readonly assignmentUserId = signal<number | null>(null);
protected readonly pendingUserId = signal<number | null>(null);
constructor() {
this.loadDirectory();
}
protected loadDirectory(): void {
this.loading.set(true);
this.loadError.set(null);
const search = this.search();
this.usersApi
.loadDirectory({ page: this.page(), limit: this.limit, ...(search ? { search } : {}) })
.pipe(finalize(() => this.loading.set(false)))
.subscribe({
next: (directory) => this.directory.set(directory),
error: (error: HttpErrorResponse) => {
this.directory.set(null);
this.loadError.set(this.errorMessage(error, 'Benutzer konnten nicht geladen werden.'));
},
});
}
protected submitSearch(): void {
this.search.set(this.searchDraft().trim());
this.page.set(1);
this.loadDirectory();
}
protected previousPage(): void {
if (this.page() <= 1 || this.loading()) return;
this.page.update((value) => value - 1);
this.loadDirectory();
}
protected nextPage(): void {
if (!this.directory()?.hasNextPage || this.loading()) return;
this.page.update((value) => value + 1);
this.loadDirectory();
}
protected adminDetails(user: UserDirectoryRecord): AdminUserDirectorySummary | null {
if (!this.isAdmin()) return null;
return 'email' in user && 'role' in user ? user : null;
}
protected fullName(user: UserDirectoryRecord): string {
return [user.firstName, user.lastName].filter(Boolean).join(' ') || `Benutzer ${user.id}`;
}
protected initials(user: UserDirectoryRecord): string {
const value = `${user.firstName?.charAt(0) ?? ''}${user.lastName?.charAt(0) ?? ''}`.trim();
return value || '?';
}
protected statusName(statusId?: number): string {
return statusId === 2 ? 'Inaktiv' : 'Aktiv';
}
protected roleName(roleId?: number): string {
return roleId === 1 ? 'Administrator' : 'Benutzer';
}
protected teamRoleName(name?: string): string {
return (
{
player: 'Spieler',
scnd_treasurer: '2. Kassenwart',
captain: 'Kapitän',
treasurer: 'Kassenwart',
coach: 'Trainer',
}[name ?? ''] ?? 'Spieler'
);
}
protected toggleEdit(userId: number): void {
this.assignmentUserId.set(null);
this.editingUserId.update((value) => (value === userId ? null : userId));
this.mutationError.set(null);
}
protected toggleAssignments(userId: number): void {
this.editingUserId.set(null);
this.assignmentUserId.update((value) => (value === userId ? null : userId));
this.mutationError.set(null);
}
protected saveEdit(user: AdminUserDirectorySummary, value: UserEditValue): void {
if (this.pendingUserId() !== null) return;
this.pendingUserId.set(user.id);
this.mutationError.set(null);
const profileRequest = this.adminUsersApi.updateProfile(user.id, {
firstName: value.firstName,
lastName: value.lastName,
});
const roleId = user.role?.id;
profileRequest
.pipe(
switchMap(() =>
roleId === value.role ? of(user) : this.adminUsersApi.updateRole(user.id, { role: value.role }),
),
finalize(() => this.pendingUserId.set(null)),
)
.subscribe({
next: () => {
this.editingUserId.set(null);
this.loadDirectory();
},
error: (error: HttpErrorResponse) => {
this.mutationError.set(this.errorMessage(error, 'Änderung fehlgeschlagen.'));
this.loadDirectory();
},
});
}
protected changeStatus(user: AdminUserDirectorySummary): void {
if (this.isSelf(user) || this.pendingUserId() !== null) return;
const isActive = user.status?.id !== 2;
const status: AdminUserStatusId = isActive ? 2 : 1;
this.dialog
.open(ConfirmDialog, {
data: {
title: isActive ? 'Benutzer deaktivieren?' : 'Benutzer aktivieren?',
message: `${this.fullName(user)} wird ${isActive ? 'deaktiviert' : 'aktiviert'}.`,
confirmLabel: isActive ? 'Deaktivieren' : 'Aktivieren',
},
restoreFocus: true,
})
.afterClosed()
.pipe(filter(Boolean), take(1))
.subscribe(() => this.updateStatus(user.id, status));
}
protected isSelf(user: UserDirectoryRecord): boolean {
return user.id === this.currentUser()?.id;
}
protected assignmentsChanged(): void {
this.loadDirectory();
}
private updateStatus(userId: number, status: AdminUserStatusId): void {
this.pendingUserId.set(userId);
this.mutationError.set(null);
this.adminUsersApi
.updateStatus(userId, { status })
.pipe(finalize(() => this.pendingUserId.set(null)))
.subscribe({
next: () => this.loadDirectory(),
error: (error: HttpErrorResponse) =>
this.mutationError.set(this.errorMessage(error, 'Status konnte nicht geändert werden.')),
});
}
private errorMessage(error: HttpErrorResponse, fallback: string): string {
const detail = typeof error.error?.message === 'string' ? error.error.message : '';
if (error.status === 403) return `Keine Berechtigung. ${detail}`.trim();
return detail ? `${fallback} ${detail}` : fallback;
}
}