feat: add admin user frontend contract

This commit is contained in:
Bastian Wagner
2026-08-01 09:17:57 +02:00
parent 78761b570b
commit 484c7473fb
20 changed files with 378 additions and 21 deletions

View File

@@ -28,6 +28,17 @@ describe('app routing', () => {
expect(router.url).toBe('/auth/login'); 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 () => { 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' }); authStore.setSession('token', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
await router.navigateByUrl('/'); await router.navigateByUrl('/');

View File

@@ -38,6 +38,11 @@ export const routes: Routes = [
canActivate: [authGuard], canActivate: [authGuard],
loadComponent: () => import('./features/team-select/team-select').then((m) => m.TeamSelect), 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', path: 't/:token/:playerId',
loadComponent: () => import('./features/public-team/public-player').then((m) => m.PublicPlayer), loadComponent: () => import('./features/public-team/public-player').then((m) => m.PublicPlayer),

View File

@@ -23,7 +23,6 @@ export interface RegistrationRequest {
password: string; password: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
linkPlayerId: number;
} }
export interface CreateInviteRequest extends InviteDetails {} export interface CreateInviteRequest extends InviteDetails {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,90 @@
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 { 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;
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', lastName: null }).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}admin/users/7/profile`);
expect(request.request.method).toBe('PATCH');
expect(request.request.body).toEqual({ firstName: 'Alex', lastName: null });
request.flush({});
});
it('uses the dedicated admin role endpoint and payload', () => {
service.updateRole(7, { role: 1 }).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}admin/users/7/role`);
expect(request.request.method).toBe('PATCH');
expect(request.request.body).toEqual({ role: 1 });
request.flush({});
});
it('uses the dedicated admin status endpoint and payload', () => {
service.updateStatus(7, { status: 2 }).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}admin/users/7/status`);
expect(request.request.method).toBe('PATCH');
expect(request.request.body).toEqual({ status: 2 });
request.flush({});
});
it('loads players with the admin filters', () => {
service.loadPlayers({ search: 'Alex', teamId: 5, assignment: 'unassigned', page: 2, limit: 10 }).subscribe();
const request = httpMock.expectOne(
`${environment.apiUrl}admin/users/players?search=Alex&teamId=5&assignment=unassigned&page=2&limit=10`,
);
expect(request.request.method).toBe('GET');
request.flush({ data: [], page: 2, limit: 10, total: 0, hasNextPage: false });
});
it('assigns a player through the dedicated admin endpoint', () => {
service.assignPlayer(7, 9).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}admin/users/7/players/9`);
expect(request.request.method).toBe('PUT');
expect(request.request.body).toBeNull();
request.flush({});
});
it('unlinks a player through the dedicated admin endpoint', () => {
service.unlinkPlayer(7, 9).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}admin/users/7/players/9`);
expect(request.request.method).toBe('DELETE');
request.flush({});
});
});

View File

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

View File

@@ -28,7 +28,7 @@ describe('Register', () => {
afterEach(() => httpMock.verify()); 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 fixture = TestBed.createComponent(Register);
const navigateSpy = vi.spyOn(router, 'navigate'); const navigateSpy = vi.spyOn(router, 'navigate');
fixture.detectChanges(); fixture.detectChanges();
@@ -50,7 +50,12 @@ describe('Register', () => {
fixture.componentInstance['onSubmit'](); fixture.componentInstance['onSubmit']();
const request = httpMock.expectOne(`${environment.apiUrl}auth/email/register`); 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); request.flush(null);
expect(navigateSpy).toHaveBeenCalledWith(['/auth/login'], { replaceUrl: true }); expect(navigateSpy).toHaveBeenCalledWith(['/auth/login'], { replaceUrl: true });

View File

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

View File

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

View File

@@ -5,6 +5,13 @@
</header> </header>
<section class="link-grid"> <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" <a routerLink="penalties"
><mat-card ><mat-card
><mat-icon>gavel</mat-icon> ><mat-icon>gavel</mat-icon>

View File

@@ -31,6 +31,9 @@ describe('More', () => {
expect(fixture.nativeElement.textContent).toContain('Profil'); expect(fixture.nativeElement.textContent).toContain('Profil');
expect(fixture.nativeElement.textContent).toContain('Öffentliche Freigabe'); 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'](); fixture.componentInstance['logout']();
await fixture.whenStable(); await fixture.whenStable();
expect(clearSession).toHaveBeenCalled(); expect(clearSession).toHaveBeenCalled();

View File

@@ -0,0 +1,13 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-users',
template: `
<header>
<p class="eyebrow">Organisation</p>
<h1>Benutzer</h1>
<p>Benutzerverzeichnis wird vorbereitet.</p>
</header>
`,
})
export class Users {}

View File

@@ -0,0 +1,113 @@
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 | null;
lastName?: string | null;
}
export interface AdminUserRoleRequest {
role: number;
}
export interface AdminUserStatusRequest {
status: number;
}
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;
}