Merge branch 'feature/admin-user-management'

This commit is contained in:
Bastian Wagner
2026-08-01 11:50:27 +02:00
57 changed files with 4897 additions and 260 deletions

View File

@@ -28,6 +28,17 @@ describe('app routing', () => {
expect(router.url).toBe('/auth/login');
});
it('redirects the protected users route to login when logged out', async () => {
await router.navigateByUrl('/users');
expect(router.url).toBe('/auth/login');
});
it('allows an authenticated user to open the users route', async () => {
authStore.setSession('token', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
await router.navigateByUrl('/users');
expect(router.url).toBe('/users');
});
it('redirects the root path to team-select when logged in', async () => {
authStore.setSession('token', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
await router.navigateByUrl('/');

View File

@@ -38,6 +38,11 @@ export const routes: Routes = [
canActivate: [authGuard],
loadComponent: () => import('./features/team-select/team-select').then((m) => m.TeamSelect),
},
{
path: 'users',
canActivate: [authGuard],
loadComponent: () => import('./features/users/users').then((m) => m.Users),
},
{
path: 't/:token/:playerId',
loadComponent: () => import('./features/public-team/public-player').then((m) => m.PublicPlayer),

View File

@@ -1,7 +1,7 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { AuthApi } from './auth-api';
import { AuthApi, RegistrationRequest } from './auth-api';
import { environment } from '../../../environments/environment';
import { User } from '../../models/user.model';
@@ -59,8 +59,8 @@ describe('AuthApi', () => {
request.flush(invitation);
});
it('registers and links an invited player', () => {
const registration = {
it('registers with only the supported account fields', () => {
const registrationWithLegacyField = {
email: 'alex@example.de',
password: 'secret1',
firstName: 'Alex',
@@ -68,11 +68,16 @@ describe('AuthApi', () => {
linkPlayerId: 7,
};
service.register(registration).subscribe();
service.register(registrationWithLegacyField as RegistrationRequest).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}auth/email/register`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual(registration);
expect(request.request.body).toEqual({
email: 'alex@example.de',
password: 'secret1',
firstName: 'Alex',
lastName: 'Muster',
});
request.flush(null);
});

View File

@@ -23,7 +23,6 @@ export interface RegistrationRequest {
password: string;
firstName: string;
lastName: string;
linkPlayerId: number;
}
export interface CreateInviteRequest extends InviteDetails {}
@@ -53,7 +52,13 @@ export class AuthApi {
}
register(request: RegistrationRequest): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/email/register`, request);
const { email, password, firstName, lastName } = request;
return this.http.post<void>(`${this.baseUrl}/email/register`, {
email,
password,
firstName,
lastName,
});
}
forgotPassword(email: string): Observable<void> {

View File

@@ -1,5 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { AuthStore } from './auth-store';
import { RoleId } from '../../models/role.model';
describe('AuthStore', () => {
beforeEach(() => {
@@ -76,4 +77,34 @@ describe('AuthStore', () => {
expect(localStorage.getItem('tw_token')).toBe('jwt-token');
expect(JSON.parse(localStorage.getItem('tw_user')!)).toEqual(updated);
});
it('derives the global admin presentation hint from the current user role', () => {
TestBed.configureTestingModule({});
const store = TestBed.inject(AuthStore);
store.setSession('jwt-token', {
id: 1,
email: 'admin@example.de',
firstName: 'Ada',
lastName: 'Admin',
role: { id: RoleId.Admin, name: 'admin' },
});
expect(store.currentGlobalRole()).toBe(RoleId.Admin);
expect(store.isGlobalAdmin()).toBe(true);
});
it('does not treat a signed-in standard user as a global admin', () => {
TestBed.configureTestingModule({});
const store = TestBed.inject(AuthStore);
store.setSession('jwt-token', {
id: 2,
email: 'user@example.de',
firstName: 'Ute',
lastName: 'User',
role: { id: RoleId.User, name: 'user' },
});
expect(store.currentGlobalRole()).toBe(RoleId.User);
expect(store.isGlobalAdmin()).toBe(false);
});
});

View File

@@ -1,4 +1,5 @@
import { Injectable, computed, signal } from '@angular/core';
import { RoleId } from '../../models/role.model';
import { User } from '../../models/user.model';
@Injectable({ providedIn: 'root' })
@@ -12,6 +13,11 @@ export class AuthStore {
readonly token = this.tokenSignal.asReadonly();
readonly currentUser = this.userSignal.asReadonly();
readonly isLoggedIn = computed(() => this.tokenSignal() !== null);
readonly currentGlobalRole = computed<RoleId | null>(() => {
const roleId = this.userSignal()?.role?.id;
return roleId === RoleId.Admin || roleId === RoleId.User ? roleId : null;
});
readonly isGlobalAdmin = computed(() => this.currentGlobalRole() === RoleId.Admin);
setSession(token: string, user: User): void {
localStorage.setItem(AuthStore.TOKEN_KEY, token);

View File

@@ -14,7 +14,7 @@ import { MatToolbarModule } from '@angular/material/toolbar';
import { AuthStore } from '../../auth/auth-store';
import { MyTeamsStore } from '../../team/my-teams-store';
import { TeamStore } from '../../team/team-store';
import { Team } from '../../../models/team.model';
import { UserTeamReference } from '../../../models/user-directory.model';
@Component({
selector: 'app-shell',
@@ -41,9 +41,9 @@ export class Shell {
protected readonly myTeams = computed(() => {
const seen = new Set<number>();
const teams: Team[] = [];
const teams: UserTeamReference[] = [];
for (const player of this.myTeamsStore.players()) {
if (player.team && !seen.has(player.team.id)) {
if (!seen.has(player.team.id)) {
seen.add(player.team.id);
teams.push(player.team);
}

View File

@@ -3,19 +3,16 @@ import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { MyTeamsStore } from './my-teams-store';
import { environment } from '../../../environments/environment';
import { Player } from '../../models/player.model';
describe('MyTeamsStore', () => {
let store: MyTeamsStore;
let httpMock: HttpTestingController;
const player: Player = {
const player = {
id: 1,
firstName: 'A',
lastName: 'B',
balance: 0,
active: true,
team: { id: 5, name: 'Team A', alias: 'team-a', balance: 0 },
team: { id: 5, name: 'Team A' },
};
beforeEach(() => {

View File

@@ -1,12 +1,12 @@
import { Injectable, inject, signal } from '@angular/core';
import { Player } from '../../models/player.model';
import { UserTeamMembership } from '../../models/user-directory.model';
import { TeamsApi } from './teams-api';
@Injectable({ providedIn: 'root' })
export class MyTeamsStore {
private readonly teamsApi = inject(TeamsApi);
private readonly playersSignal = signal<Player[]>([]);
private readonly playersSignal = signal<UserTeamMembership[]>([]);
private readonly loadingSignal = signal(false);
private readonly loadedForUserId = signal<number | null>(null);

View File

@@ -22,8 +22,8 @@ describe('TeamsApi', () => {
httpMock.verify();
});
it('fetches the players/teams belonging to a user', () => {
const players: Player[] = [{ id: 1, firstName: 'A', lastName: 'B', balance: 0, active: true }];
it('fetches the reduced bootstrap memberships belonging to a user', () => {
const players = [{ id: 1, firstName: 'A', lastName: 'B', team: { id: 5, name: 'Team A' } }];
service.loadMyTeams(42).subscribe((response) => {
expect(response).toEqual(players);

View File

@@ -2,9 +2,10 @@ import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { Player } from '../../models/player.model';
import { Team } from '../../models/team.model';
import { PlayerTransaction } from '../../models/transaction.model';
import { UserTeamMembership } from '../../models/user-directory.model';
import { Player } from '../../models/player.model';
export interface CreatePlayerRequest {
firstName: string;
@@ -16,8 +17,8 @@ export interface CreatePlayerRequest {
export class TeamsApi {
private readonly http = inject(HttpClient);
loadMyTeams(userId: number): Observable<Player[]> {
return this.http.get<Player[]>(`${environment.apiUrl}users/${userId}/teams`);
loadMyTeams(userId: number): Observable<UserTeamMembership[]> {
return this.http.get<UserTeamMembership[]>(`${environment.apiUrl}users/${userId}/teams`);
}
loadTeamOverview(teamId: number): Observable<Team> {

View File

@@ -0,0 +1,52 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import {
AdminPlayerFilters,
AdminPlayerPage,
AdminUserDirectorySummary,
AdminUserProfileRequest,
AdminUserRoleRequest,
AdminUserStatusRequest,
} from '../../models/user-directory.model';
@Injectable({ providedIn: 'root' })
export class AdminUsersApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}admin/users`;
updateProfile(userId: number, request: AdminUserProfileRequest): Observable<AdminUserDirectorySummary> {
return this.http.patch<AdminUserDirectorySummary>(`${this.baseUrl}/${userId}/profile`, request);
}
updateRole(userId: number, request: AdminUserRoleRequest): Observable<AdminUserDirectorySummary> {
return this.http.patch<AdminUserDirectorySummary>(`${this.baseUrl}/${userId}/role`, request);
}
updateStatus(userId: number, request: AdminUserStatusRequest): Observable<AdminUserDirectorySummary> {
return this.http.patch<AdminUserDirectorySummary>(`${this.baseUrl}/${userId}/status`, request);
}
loadPlayers(filters: AdminPlayerFilters = {}): Observable<AdminPlayerPage> {
return this.http.get<AdminPlayerPage>(`${this.baseUrl}/players`, { params: this.toParams(filters) });
}
assignPlayer(userId: number, playerId: number): Observable<AdminUserDirectorySummary> {
return this.http.put<AdminUserDirectorySummary>(`${this.baseUrl}/${userId}/players/${playerId}`, null);
}
unlinkPlayer(userId: number, playerId: number): Observable<AdminUserDirectorySummary> {
return this.http.delete<AdminUserDirectorySummary>(`${this.baseUrl}/${userId}/players/${playerId}`);
}
private toParams(filters: AdminPlayerFilters): HttpParams {
let params = new HttpParams();
if (filters.search !== undefined) params = params.set('search', filters.search);
if (filters.teamId !== undefined) params = params.set('teamId', filters.teamId);
if (filters.assignment !== undefined) params = params.set('assignment', filters.assignment);
if (filters.page !== undefined) params = params.set('page', filters.page);
if (filters.limit !== undefined) params = params.set('limit', filters.limit);
return params;
}
}

View File

@@ -0,0 +1,106 @@
import { provideHttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { environment } from '../../../environments/environment';
import { AdminUsersApi } from './admin-users-api';
import { AdminUserProfileRequest } from '../../models/user-directory.model';
import { UsersApi } from './users-api';
describe('UsersApi', () => {
let service: UsersApi;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
service = TestBed.inject(UsersApi);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('loads the safe user directory with supplied pagination and search filters', () => {
service.loadDirectory({ page: 2, limit: 10, search: 'Alex Muster' }).subscribe();
const request = httpMock.expectOne(
`${environment.apiUrl}users/directory?page=2&limit=10&search=Alex%20Muster`,
);
expect(request.request.method).toBe('GET');
request.flush({ data: [], page: 2, limit: 10, total: 0, hasNextPage: false });
});
});
describe('AdminUsersApi', () => {
let service: AdminUsersApi;
let httpMock: HttpTestingController;
it('accepts profile requests with omitted or string names only', () => {
const omitted: AdminUserProfileRequest = {};
const names: AdminUserProfileRequest = {
firstName: 'Ada',
lastName: 'Admin',
};
// @ts-expect-error Profile names cannot be explicitly cleared to null.
const nullName: AdminUserProfileRequest = { firstName: null };
expect(omitted).toEqual({});
expect(names).toEqual({ firstName: 'Ada', lastName: 'Admin' });
expect(nullName).toEqual({ firstName: null });
});
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
service = TestBed.inject(AdminUsersApi);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('uses the dedicated admin profile endpoint and payload', () => {
service.updateProfile(7, { firstName: 'Alex' }).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}admin/users/7/profile`);
expect(request.request.method).toBe('PATCH');
expect(request.request.body).toEqual({ firstName: 'Alex' });
request.flush({});
});
it('uses the dedicated admin role endpoint and payload', () => {
service.updateRole(7, { role: 1 }).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}admin/users/7/role`);
expect(request.request.method).toBe('PATCH');
expect(request.request.body).toEqual({ role: 1 });
request.flush({});
});
it('uses the dedicated admin status endpoint and payload', () => {
service.updateStatus(7, { status: 2 }).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}admin/users/7/status`);
expect(request.request.method).toBe('PATCH');
expect(request.request.body).toEqual({ status: 2 });
request.flush({});
});
it('loads players with the admin filters', () => {
service.loadPlayers({ search: 'Alex', teamId: 5, assignment: 'unassigned', page: 2, limit: 10 }).subscribe();
const request = httpMock.expectOne(
`${environment.apiUrl}admin/users/players?search=Alex&teamId=5&assignment=unassigned&page=2&limit=10`,
);
expect(request.request.method).toBe('GET');
request.flush({ data: [], page: 2, limit: 10, total: 0, hasNextPage: false });
});
it('assigns a player through the dedicated admin endpoint', () => {
service.assignPlayer(7, 9).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}admin/users/7/players/9`);
expect(request.request.method).toBe('PUT');
expect(request.request.body).toBeNull();
request.flush({});
});
it('unlinks a player through the dedicated admin endpoint', () => {
service.unlinkPlayer(7, 9).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}admin/users/7/players/9`);
expect(request.request.method).toBe('DELETE');
request.flush({});
});
});

View File

@@ -0,0 +1,25 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { UserDirectoryFilters, UserDirectoryPage } from '../../models/user-directory.model';
@Injectable({ providedIn: 'root' })
export class UsersApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}users`;
loadDirectory(filters: UserDirectoryFilters = {}): Observable<UserDirectoryPage> {
return this.http.get<UserDirectoryPage>(`${this.baseUrl}/directory`, {
params: this.toParams(filters),
});
}
private toParams(filters: UserDirectoryFilters): HttpParams {
let params = new HttpParams();
if (filters.page !== undefined) params = params.set('page', filters.page);
if (filters.limit !== undefined) params = params.set('limit', filters.limit);
if (filters.search !== undefined) params = params.set('search', filters.search);
return params;
}
}

View File

@@ -28,7 +28,7 @@ describe('Register', () => {
afterEach(() => httpMock.verify());
it('verifies the invitation and links its player during registration', () => {
it('verifies the invitation and registers without linking its player', () => {
const fixture = TestBed.createComponent(Register);
const navigateSpy = vi.spyOn(router, 'navigate');
fixture.detectChanges();
@@ -50,7 +50,12 @@ describe('Register', () => {
fixture.componentInstance['onSubmit']();
const request = httpMock.expectOne(`${environment.apiUrl}auth/email/register`);
expect(request.request.body.linkPlayerId).toBe(7);
expect(request.request.body).toEqual({
email: 'alex@example.de',
password: 'secret1',
firstName: 'Alex',
lastName: 'Muster',
});
request.flush(null);
expect(navigateSpy).toHaveBeenCalledWith(['/auth/login'], { replaceUrl: true });

View File

@@ -78,7 +78,6 @@ export class Register {
password: value.password,
firstName: value.firstName,
lastName: value.lastName,
linkPlayerId: invitation.playerId,
})
.subscribe({
next: () => {

View File

@@ -11,8 +11,8 @@
<h1>Team auswählen</h1>
<mat-nav-list>
@for (player of players(); track player.id) {
<a mat-list-item [routerLink]="['/team', player.team?.id, 'overview']">
<span matListItemTitle>{{ player.team?.name }}</span>
<a mat-list-item [routerLink]="['/team', player.team.id, 'overview']">
<span matListItemTitle>{{ player.team.name }}</span>
<span matListItemLine>{{ player.firstName }} {{ player.lastName }}</span>
</a>
}

View File

@@ -5,6 +5,13 @@
</header>
<section class="link-grid">
<a routerLink="/users"
><mat-card
><mat-icon>group</mat-icon>
<div><strong>Benutzer</strong><span>Benutzerverzeichnis öffnen</span></div>
<mat-icon>chevron_right</mat-icon></mat-card
></a
>
<a routerLink="penalties"
><mat-card
><mat-icon>gavel</mat-icon>

View File

@@ -31,6 +31,9 @@ describe('More', () => {
expect(fixture.nativeElement.textContent).toContain('Profil');
expect(fixture.nativeElement.textContent).toContain('Öffentliche Freigabe');
expect(fixture.nativeElement.textContent).toContain('Benutzer');
expect(fixture.nativeElement.querySelector('a[href="/users"]')).not.toBeNull();
fixture.componentInstance['logout']();
await fixture.whenStable();
expect(clearSession).toHaveBeenCalled();

View File

@@ -0,0 +1,325 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, DestroyRef, EventEmitter, Input, OnChanges, Output, SimpleChanges, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
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 { EMPTY, Subject, catchError, filter, finalize, switchMap, take, tap } 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"
[attr.aria-busy]="pendingPlayerId() !== null ? 'true' : null"
>
<div class="assignments__heading">
<div>
<h3>Spielerzuordnungen</h3>
<p>Spieler teamübergreifend suchen und sicher verknüpfen.</p>
</div>
<button mat-button type="button" [disabled]="pendingPlayerId() !== null" (click)="close()">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"
[disabled]="pendingPlayerId() !== null"
(input)="searchDraft = $any($event.target).value"
/>
</mat-form-field>
<button mat-stroked-button type="submit" [disabled]="pendingPlayerId() !== null">Suchen</button>
</form>
@if (loadError()) {
<div class="assignments__error" role="alert">
<span>{{ loadError() }}</span>
<button mat-button type="button" (click)="loadPlayers()">Erneut versuchen</button>
</div>
}
@if (mutationError()) {
<div class="assignments__error" role="alert">{{ mutationError() }}</div>
}
@if (loading()) {
<div class="assignments__state" aria-live="polite">
<mat-spinner diameter="30" />
<span>Spieler werden geladen …</span>
</div>
} @else if (!loadError() && 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>
<nav class="assignments__paging" aria-label="Spielerseiten">
<button mat-button type="button" [disabled]="players()!.page <= 1 || loading() || pendingPlayerId() !== null" (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() || pendingPlayerId() !== null" (click)="nextPage()">
Weiter<mat-icon>chevron_right</mat-icon>
</button>
</nav>
}
</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);
private readonly destroyRef = inject(DestroyRef);
private readonly playerRequests = new Subject<void>();
@Input({ required: true }) user!: AdminUserDirectorySummary;
@Output() readonly directoryChanged = new EventEmitter<void>();
@Output() readonly closed = new EventEmitter<void>();
@Output() readonly busyChange = new EventEmitter<boolean>();
protected readonly players = signal<AdminPlayerPage | null>(null);
protected readonly loading = signal(false);
protected readonly loadError = signal<string | null>(null);
protected readonly mutationError = signal<string | null>(null);
protected readonly pendingPlayerId = signal<number | null>(null);
protected searchDraft = '';
private search = '';
private page = 1;
private readonly limit = 20;
constructor() {
this.playerRequests
.pipe(
switchMap(() => {
this.loading.set(true);
this.loadError.set(null);
this.players.set(null);
return this.adminUsersApi
.loadPlayers({
assignment: 'all',
page: this.page,
limit: this.limit,
...(this.search ? { search: this.search } : {}),
})
.pipe(
tap((players) => this.players.set(players)),
catchError((error: HttpErrorResponse) => {
this.loadError.set(this.loadErrorMessage(error));
return EMPTY;
}),
finalize(() => this.loading.set(false)),
);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
}
ngOnChanges(changes: SimpleChanges): void {
const userChange = changes['user'];
if (!userChange) return;
const previous = userChange.previousValue as AdminUserDirectorySummary | undefined;
const current = userChange.currentValue as AdminUserDirectorySummary;
if (!userChange.firstChange && previous?.id === current.id) return;
this.searchDraft = '';
this.search = '';
this.page = 1;
this.loadPlayers();
}
protected loadPlayers(): void {
this.playerRequests.next();
}
protected submitSearch(): void {
if (this.pendingPlayerId() !== null) return;
const search = this.searchDraft.trim();
if (this.loading() && search === this.search && this.page === 1) return;
this.search = search;
this.page = 1;
this.loadPlayers();
}
protected previousPage(): void {
if (this.page <= 1 || this.loading() || this.pendingPlayerId() !== null) return;
this.page -= 1;
this.loadPlayers();
}
protected nextPage(): void {
if (!this.players()?.hasNextPage || this.loading() || this.pendingPlayerId() !== null) return;
this.page += 1;
this.loadPlayers();
}
protected assign(player: AdminPlayerSummary): void {
this.runAssignment(player);
}
protected close(): void {
if (this.pendingPlayerId() === null) this.closed.emit();
}
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), takeUntilDestroyed(this.destroyRef));
}
private runAssignment(player: AdminPlayerSummary): void {
if (this.pendingPlayerId() !== null) return;
this.setPending(player.id);
this.mutationError.set(null);
this.adminUsersApi
.assignPlayer(this.user.id, player.id)
.pipe(finalize(() => this.setPending(null)), takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => this.refreshAfterMutation(),
error: (error: HttpErrorResponse) => this.mutationError.set(this.mutationErrorMessage(error)),
});
}
private runUnlink(player: AdminPlayerSummary): void {
if (this.pendingPlayerId() !== null) return;
this.setPending(player.id);
this.mutationError.set(null);
this.adminUsersApi
.unlinkPlayer(this.user.id, player.id)
.pipe(finalize(() => this.setPending(null)), takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => this.refreshAfterMutation(),
error: (error: HttpErrorResponse) => this.mutationError.set(this.mutationErrorMessage(error)),
});
}
private refreshAfterMutation(): void {
this.directoryChanged.emit();
this.loadPlayers();
}
private setPending(playerId: number | null): void {
this.pendingPlayerId.set(playerId);
this.busyChange.emit(playerId !== null);
}
private loadErrorMessage(error: HttpErrorResponse): string {
const detail = typeof error.error?.message === 'string' ? error.error.message : '';
const fallback = error.status === 403 ? 'Keine Berechtigung.' : 'Spieler konnten nicht geladen werden.';
return detail ? `${fallback} ${detail}` : fallback;
}
private mutationErrorMessage(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,98 @@
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } 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(changes: SimpleChanges): void {
const userChange = changes['user'];
if (!userChange) return;
const previous = userChange.previousValue as AdminUserDirectorySummary | undefined;
const current = userChange.currentValue as AdminUserDirectorySummary;
if (
!userChange.firstChange &&
previous?.id === current.id &&
previous.firstName === current.firstName &&
previous.lastName === current.lastName &&
previous.role?.id === current.role?.id
) {
return;
}
this.firstName = current.firstName ?? '';
this.lastName = current.lastName ?? '';
this.role = current.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,161 @@
<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()"
[disabled]="assignmentBusyUserId() !== null"
(input)="searchDraft.set($any($event.target).value)"
/>
</mat-form-field>
<button mat-stroked-button type="submit" [disabled]="loading() || assignmentBusyUserId() !== null">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"
[attr.aria-busy]="pendingUserId() === user.id ? 'true' : null"
[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" [disabled]="assignmentBusyUserId() !== null" (click)="toggleEdit(user.id)">
Bearbeiten
</button>
<button
mat-button
type="button"
[disabled]="assignmentBusyUserId() !== null"
(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"
[attr.aria-describedby]="isSelf(user) ? 'self-status-note-' + user.id : null"
(click)="changeStatus(details)"
>
{{
pendingUserId() === user.id
? 'Status wird geändert …'
: user.status?.id === 2
? 'Aktivieren'
: 'Deaktivieren'
}}
</button>
</div>
@if (isSelf(user)) {
<p class="self-status-note" [id]="'self-status-note-' + user.id">
Das eigene Konto kann nicht deaktiviert werden.
</p>
}
@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)"
(busyChange)="assignmentBusyChanged(user.id, $event)"
/>
}
}
</article>
}
</div>
<nav class="pagination" aria-label="Benutzerseiten">
<button
mat-button
type="button"
[disabled]="directory()!.page <= 1 || loading() || assignmentBusyUserId() !== null"
(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() || assignmentBusyUserId() !== null"
(click)="nextPage()"
>
Weiter<mat-icon>chevron_right</mat-icon>
</button>
</nav>
}
</main>

View File

@@ -0,0 +1,198 @@
: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;
}
.self-status-note {
margin: -8px 0 0;
color: var(--mat-sys-on-surface-variant);
font-size: 0.8rem;
text-align: right;
}
.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,701 @@
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 updateUser: ReturnType<typeof vi.fn>;
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 } });
updateUser = vi.fn();
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, updateUser } },
{ provide: MatDialog, useValue: dialog },
],
}).compileComponents();
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify({ ignoreCancelled: true }));
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;
}
it('renders unknown role and status values explicitly', () => {
create();
flushDirectory();
expect(fixture.componentInstance['statusName']()).toBe('Unbekannt');
expect(fixture.componentInstance['statusName'](99)).toBe('Unbekannt');
expect(fixture.componentInstance['roleName']()).toBe('Unbekannt');
expect(fixture.componentInstance['roleName'](99)).toBe('Unbekannt');
});
it('persists returned names when an admin edits their own profile', () => {
isAdmin.set(true);
currentUser.set({ id: 7, firstName: 'Ada', lastName: 'Lovelace', role: { id: 1 } });
create();
flushDirectory(directoryPage([admin, ada]));
fixture.componentInstance['saveEdit'](ada, {
firstName: 'Augusta',
lastName: 'King',
role: 2,
});
http.expectOne(`${adminApi}/7/profile`).flush({
...ada,
firstName: 'Augusta',
lastName: 'King',
});
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage());
expect(updateUser).toHaveBeenCalledWith({
id: 7,
firstName: 'Augusta',
lastName: 'King',
role: { id: 1 },
});
});
function childButton(host: HTMLElement, label: string): HTMLButtonElement {
const match = [...host.querySelectorAll('button')].find((element) => element.textContent?.includes(label));
if (!match) throw new Error(`Missing child 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('cancels an older directory load when a newer search starts', () => {
create();
const older = http.expectOne(`${api}?page=1&limit=20`);
const search = (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>('input[type="search"]')!;
search.value = 'Grace';
search.dispatchEvent(new Event('input'));
search.closest('form')!.dispatchEvent(new Event('submit'));
const newer = http.expectOne(`${api}?page=1&limit=20&search=Grace`);
const olderWasCancelled = older.cancelled;
newer.flush(directoryPage([admin]));
if (!olderWasCancelled) older.flush(directoryPage([ada]));
fixture.detectChanges();
expect(olderWasCancelled).toBe(true);
expect(text()).toContain('Grace Admin');
expect(text()).not.toContain('Ada Lovelace');
});
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('keeps the editor draft while saving input changes and on an initial profile failure', () => {
isAdmin.set(true);
create();
flushDirectory();
button('Bearbeiten').click();
fixture.detectChanges();
const firstName = (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>('input[name="firstName"]')!;
firstName.value = 'Operator draft';
firstName.dispatchEvent(new Event('input'));
firstName.closest('form')!.dispatchEvent(new Event('submit'));
fixture.detectChanges();
expect(firstName.value).toBe('Operator draft');
http.expectOne(`${adminApi}/7/profile`).flush(
{ message: 'Profile rejected' },
{ status: 400, statusText: 'Bad Request' },
);
http.expectNone(`${api}?page=1&limit=20`);
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>('input[name="firstName"]')!.value).toBe(
'Operator draft',
);
expect(text()).toContain('Profile rejected');
});
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 });
fixture.detectChanges();
expect(text()).toContain('Status wird geändert');
expect((fixture.nativeElement as HTMLElement).querySelector('[data-user-id="7"]')?.getAttribute('aria-busy')).toBe('true');
request.flush(ada);
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([{ ...ada, status: { id: 2, name: 'Inactive' } }]));
});
it('searches players from page one after paging and assigns before refreshing both lists', () => {
isAdmin.set(true);
create();
flushDirectory();
button('Zuordnungen verwalten').click();
fixture.detectChanges();
const panel = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('app-player-assignments')!;
expect(panel.textContent).toContain('Spieler werden geladen');
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage({ hasNextPage: true, total: 21 }));
fixture.detectChanges();
expect(panel.querySelector('nav[aria-label="Spielerseiten"]')).not.toBeNull();
childButton(panel, 'Weiter').click();
http.expectOne(`${adminApi}/players?assignment=all&page=2&limit=20`).flush(playersPage({ page: 2 }));
fixture.detectChanges();
const playerSearch = panel.querySelector<HTMLInputElement>('input[type="search"]')!;
playerSearch.value = 'Linus';
playerSearch.dispatchEvent(new Event('input'));
playerSearch.closest('form')!.dispatchEvent(new Event('submit'));
http.expectOne(`${adminApi}/players?search=Linus&assignment=all&page=1&limit=20`).flush(playersPage());
fixture.detectChanges();
expect(text()).toContain('Linus Player');
expect(text()).toContain('Second Team');
childButton(panel, '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?search=Linus&assignment=all&page=1&limit=20`).flush(playersPage());
});
it('cancels an older player load when a newer player search starts', () => {
isAdmin.set(true);
create();
flushDirectory();
button('Zuordnungen verwalten').click();
fixture.detectChanges();
const older = http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`);
const panel = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('app-player-assignments')!;
const playerSearch = panel.querySelector<HTMLInputElement>('input[type="search"]')!;
playerSearch.value = 'New';
playerSearch.dispatchEvent(new Event('input'));
playerSearch.closest('form')!.dispatchEvent(new Event('submit'));
const newer = http.expectOne(`${adminApi}/players?search=New&assignment=all&page=1&limit=20`);
const olderWasCancelled = older.cancelled;
newer.flush(playersPage({ data: [{ ...playersPage().data[0], firstName: 'New' }] }));
if (!olderWasCancelled) older.flush(playersPage());
fixture.detectChanges();
expect(olderWasCancelled).toBe(true);
expect(panel.textContent).toContain('New Player');
expect(panel.textContent).not.toContain('Linus Player');
});
it('clears stale player rows on load errors and retries with load-specific copy', () => {
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();
const panel = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('app-player-assignments')!;
expect(panel.textContent).toContain('Linus Player');
const playerSearch = panel.querySelector<HTMLInputElement>('input[type="search"]')!;
playerSearch.value = 'broken';
playerSearch.dispatchEvent(new Event('input'));
playerSearch.closest('form')!.dispatchEvent(new Event('submit'));
http.expectOne(`${adminApi}/players?search=broken&assignment=all&page=1&limit=20`).flush(
{ message: 'Search unavailable' },
{ status: 500, statusText: 'Server Error' },
);
fixture.detectChanges();
expect(panel.textContent).not.toContain('Linus Player');
expect(panel.textContent).toContain('Spieler konnten nicht geladen werden');
expect(panel.textContent).toContain('Search unavailable');
childButton(panel, 'Erneut versuchen').click();
http.expectOne(`${adminApi}/players?search=broken&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');
const panel = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('app-player-assignments')!;
expect(panel.querySelector('section')?.getAttribute('aria-busy')).toBe('true');
expect(childButton(panel, 'Schließen').disabled).toBe(true);
expect(childButton(panel, 'Suchen').disabled).toBe(true);
const mainToggle = [...(fixture.nativeElement as HTMLElement).querySelectorAll<HTMLButtonElement>('[data-user-id="7"] > .user-row__actions button')].find(
(item) => item.textContent?.includes('Zuordnungen verwalten'),
)!;
expect(mainToggle.disabled).toBe(true);
childButton(panel, 'Schließen').click();
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('app-player-assignments')).not.toBeNull();
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('blocks directory search and paging while an assignment mutation is pending, then restores them', () => {
isAdmin.set(true);
create();
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([ada], 1, 60, true));
fixture.detectChanges();
button('Weiter').click();
http.expectOne(`${api}?page=2&limit=20`).flush(directoryPage([ada], 2, 60, true));
fixture.detectChanges();
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();
childButton(
(fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('app-player-assignments')!,
'Verknüpfung lösen',
).click();
closeDialog.next(true);
fixture.detectChanges();
const unlink = http.expectOne(`${adminApi}/7/players/101`);
const directoryForm = (fixture.nativeElement as HTMLElement).querySelector<HTMLFormElement>('.directory-search')!;
const directorySearch = directoryForm.querySelector<HTMLInputElement>('input[type="search"]')!;
const directorySearchButton = directoryForm.querySelector<HTMLButtonElement>('button[type="submit"]')!;
const paging = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('nav.pagination')!;
const [previous, next] = [...paging.querySelectorAll<HTMLButtonElement>('button')];
const controlsWereBlocked = directorySearch.disabled && directorySearchButton.disabled && previous.disabled && next.disabled;
directorySearch.value = 'Other';
directorySearch.dispatchEvent(new Event('input'));
directoryForm.dispatchEvent(new Event('submit'));
previous.click();
next.click();
fixture.detectChanges();
const unexpectedQueries = http.match((request) => request.url.startsWith(api));
const mutationStayedActive = !unlink.cancelled;
if (controlsWereBlocked && unexpectedQueries.length === 0 && mutationStayedActive) {
unlink.flush(ada);
http.expectOne(`${api}?page=2&limit=20`).flush(directoryPage([ada], 2, 60, true));
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage());
fixture.detectChanges();
expect(directorySearch.disabled).toBe(false);
expect(directorySearchButton.disabled).toBe(false);
expect(previous.disabled).toBe(false);
expect(next.disabled).toBe(false);
directoryForm.dispatchEvent(new Event('submit'));
http.expectOne(`${api}?page=1&limit=20&search=Other`).flush(directoryPage([]));
} else {
for (const query of unexpectedQueries) query.flush(directoryPage([ada], 1, 60, true));
fixture.detectChanges();
if (!unlink.cancelled) unlink.flush(ada);
for (const playerLoad of http.match((request) => request.url.startsWith(`${adminApi}/players`))) {
playerLoad.flush(playersPage());
}
}
expect(controlsWereBlocked).toBe(true);
expect(unexpectedQueries).toHaveLength(0);
expect(mutationStayedActive).toBe(true);
});
it('keeps directory controls blocked until a failed assignment mutation clears', () => {
isAdmin.set(true);
create();
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([ada], 1, 60, true));
fixture.detectChanges();
button('Weiter').click();
http.expectOne(`${api}?page=2&limit=20`).flush(directoryPage([ada], 2, 60, true));
fixture.detectChanges();
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();
const panel = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('app-player-assignments')!;
panel.querySelector<HTMLButtonElement>('.player-row button')!.click();
closeDialog.next(true);
fixture.detectChanges();
const unlink = http.expectOne(`${adminApi}/7/players/101`);
const directoryForm = (fixture.nativeElement as HTMLElement).querySelector<HTMLFormElement>('.directory-search')!;
const directorySearch = directoryForm.querySelector<HTMLInputElement>('input[type="search"]')!;
const directorySearchButton = directoryForm.querySelector<HTMLButtonElement>('button[type="submit"]')!;
const [previous, next] = [
...(fixture.nativeElement as HTMLElement).querySelectorAll<HTMLButtonElement>('nav.pagination button'),
];
expect(directorySearch.disabled).toBe(true);
expect(directorySearchButton.disabled).toBe(true);
expect(previous.disabled).toBe(true);
expect(next.disabled).toBe(true);
directorySearch.value = 'Other';
directorySearch.dispatchEvent(new Event('input'));
directoryForm.dispatchEvent(new Event('submit'));
previous.click();
next.click();
fixture.detectChanges();
expect(http.match((request) => request.url.startsWith(api))).toHaveLength(0);
expect(unlink.cancelled).toBe(false);
unlink.flush({ message: 'Assignment failed' }, { status: 500, statusText: 'Server Error' });
fixture.detectChanges();
expect(text()).toContain('Zuordnung konnte nicht geändert werden. Assignment failed');
expect(directorySearch.disabled).toBe(false);
expect(directorySearchButton.disabled).toBe(false);
expect(previous.disabled).toBe(false);
expect(next.disabled).toBe(false);
expect(http.match((request) => request.url.startsWith(api))).toHaveLength(0);
directoryForm.dispatchEvent(new Event('submit'));
http.expectOne(`${api}?page=1&limit=20&search=Other`).flush(directoryPage([]));
});
it('shows a persistent explanation for disabled 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]));
fixture.detectChanges();
const row = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('[data-user-id="1"]')!;
const statusButton = [...row.querySelectorAll('button')].find((item) => item.textContent?.includes('Deaktivieren'))!;
const descriptionId = statusButton.getAttribute('aria-describedby');
expect(descriptionId).toBeTruthy();
expect((fixture.nativeElement as HTMLElement).querySelector(`#${descriptionId}`)?.textContent).toContain(
'eigene Konto kann nicht deaktiviert werden',
);
});
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

@@ -0,0 +1,254 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, DestroyRef, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
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 { EMPTY, Subject, catchError, filter, finalize, of, startWith, switchMap, take, tap } 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({
selector: 'app-users',
imports: [
RouterLink,
MatButtonModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
PlayerAssignments,
UserEdit,
],
templateUrl: './users.html',
styleUrl: './users.scss',
})
export class Users {
private readonly usersApi = inject(UsersApi);
private readonly adminUsersApi = inject(AdminUsersApi);
private readonly authStore = inject(AuthStore);
private readonly dialog = inject(MatDialog);
private readonly destroyRef = inject(DestroyRef);
private readonly directoryRequests = new Subject<boolean>();
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 assignmentBusyUserId = signal<number | null>(null);
protected readonly pendingUserId = signal<number | null>(null);
constructor() {
this.directoryRequests
.pipe(
startWith(true),
switchMap((showLoading) => {
if (showLoading) this.loading.set(true);
this.loadError.set(null);
const search = this.search();
return this.usersApi
.loadDirectory({ page: this.page(), limit: this.limit, ...(search ? { search } : {}) })
.pipe(
tap((directory) => this.directory.set(directory)),
catchError((error: HttpErrorResponse) => {
this.directory.set(null);
this.loadError.set(this.errorMessage(error, 'Benutzer konnten nicht geladen werden.'));
return EMPTY;
}),
finalize(() => this.loading.set(false)),
);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
}
protected loadDirectory(showLoading = true): void {
this.directoryRequests.next(showLoading);
}
protected submitSearch(): void {
if (this.assignmentBusyUserId() !== null) return;
const search = this.searchDraft().trim();
if (this.loading() && search === this.search() && this.page() === 1) return;
this.search.set(search);
this.page.set(1);
this.loadDirectory();
}
protected previousPage(): void {
if (this.page() <= 1 || this.loading() || this.assignmentBusyUserId() !== null) return;
this.page.update((value) => value - 1);
this.loadDirectory();
}
protected nextPage(): void {
if (!this.directory()?.hasNextPage || this.loading() || this.assignmentBusyUserId() !== null) 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 {
if (statusId === 1) return 'Aktiv';
if (statusId === 2) return 'Inaktiv';
return 'Unbekannt';
}
protected roleName(roleId?: number): string {
if (roleId === 1) return 'Administrator';
if (roleId === 2) return 'Benutzer';
return 'Unbekannt';
}
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 {
if (this.assignmentBusyUserId() !== null) return;
this.assignmentUserId.set(null);
this.editingUserId.update((value) => (value === userId ? null : userId));
this.mutationError.set(null);
}
protected toggleAssignments(userId: number): void {
if (this.assignmentBusyUserId() !== null) return;
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;
let profileSaved = false;
profileRequest
.pipe(
tap((updatedUser) => {
profileSaved = true;
const currentUser = this.currentUser();
if (currentUser?.id === updatedUser.id) {
this.authStore.updateUser({
...currentUser,
firstName: updatedUser.firstName,
lastName: updatedUser.lastName,
});
}
}),
switchMap(() =>
roleId === value.role ? of(user) : this.adminUsersApi.updateRole(user.id, { role: value.role }),
),
finalize(() => this.pendingUserId.set(null)),
takeUntilDestroyed(this.destroyRef),
)
.subscribe({
next: () => {
this.editingUserId.set(null);
this.loadDirectory();
},
error: (error: HttpErrorResponse) => {
this.mutationError.set(this.errorMessage(error, 'Änderung fehlgeschlagen.'));
if (profileSaved) 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), takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.updateStatus(user.id, status));
}
protected isSelf(user: UserDirectoryRecord): boolean {
return user.id === this.currentUser()?.id;
}
protected assignmentsChanged(): void {
this.loadDirectory(false);
}
protected assignmentBusyChanged(userId: number, busy: boolean): void {
this.assignmentBusyUserId.set(busy ? userId : null);
}
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)), takeUntilDestroyed(this.destroyRef))
.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;
}
}

View File

@@ -0,0 +1,116 @@
export interface UserDirectoryReference {
id: number;
name?: string;
}
export interface UserDirectoryTeam {
id: number;
name: string;
alias: string;
}
export interface UserDirectoryAssignment {
id: number;
firstName: string;
lastName: string;
active: boolean;
team: UserDirectoryTeam;
teamRole: UserDirectoryReference | null;
}
export interface UserDirectorySummary {
id: number;
firstName: string | null;
lastName: string | null;
status: UserDirectoryReference | null;
assignments: UserDirectoryAssignment[];
}
export interface AdminUserDirectorySummary extends UserDirectorySummary {
email: string | null;
role: UserDirectoryReference | null;
}
export type UserDirectoryRecord = UserDirectorySummary | AdminUserDirectorySummary;
export interface UserDirectoryPage {
data: UserDirectoryRecord[];
page: number;
limit: number;
total: number;
hasNextPage: boolean;
}
export interface UserDirectoryFilters {
page?: number;
limit?: number;
search?: string;
}
export interface AdminUserProfileRequest {
firstName?: string;
lastName?: string;
}
export type AdminUserRoleId = 1 | 2;
export type AdminUserStatusId = 1 | 2;
export interface AdminUserRoleRequest {
role: AdminUserRoleId;
}
export interface AdminUserStatusRequest {
status: AdminUserStatusId;
}
export type AdminPlayerAssignmentFilter = 'all' | 'assigned' | 'unassigned';
export interface AdminPlayerFilters {
search?: string;
teamId?: number;
assignment?: AdminPlayerAssignmentFilter;
page?: number;
limit?: number;
}
export interface AdminPlayerTeam {
id: number;
name: string;
alias: string;
}
export interface AdminPlayerCurrentUser {
id: number;
firstName: string | null;
lastName: string | null;
status: UserDirectoryReference | null;
}
export interface AdminPlayerSummary {
id: number;
firstName: string;
lastName: string;
active: boolean;
team: AdminPlayerTeam;
currentUser: AdminPlayerCurrentUser | null;
}
export interface AdminPlayerPage {
data: AdminPlayerSummary[];
page: number;
limit: number;
total: number;
hasNextPage: boolean;
}
export interface UserTeamReference {
id: number;
name: string;
}
export interface UserTeamMembership {
id: number;
firstName: string;
lastName: string;
team: UserTeamReference;
}