Merge branch 'feature/admin-user-management'
This commit is contained in:
@@ -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('/');
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
25
myteamwallet_frontend_modern/src/app/core/users/users-api.ts
Normal file
25
myteamwallet_frontend_modern/src/app/core/users/users-api.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -78,7 +78,6 @@ export class Register {
|
||||
password: value.password,
|
||||
firstName: value.firstName,
|
||||
lastName: value.lastName,
|
||||
linkPlayerId: invitation.playerId,
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
161
myteamwallet_frontend_modern/src/app/features/users/users.html
Normal file
161
myteamwallet_frontend_modern/src/app/features/users/users.html
Normal 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>
|
||||
198
myteamwallet_frontend_modern/src/app/features/users/users.scss
Normal file
198
myteamwallet_frontend_modern/src/app/features/users/users.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
254
myteamwallet_frontend_modern/src/app/features/users/users.ts
Normal file
254
myteamwallet_frontend_modern/src/app/features/users/users.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user