feat: build admin user management UI
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
123
myteamwallet_frontend_modern/src/app/features/users/users.html
Normal file
123
myteamwallet_frontend_modern/src/app/features/users/users.html
Normal 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>
|
||||||
191
myteamwallet_frontend_modern/src/app/features/users/users.scss
Normal file
191
myteamwallet_frontend_modern/src/app/features/users/users.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user