From 484c7473fbdc242b4d8a632af935154d9df6d06d Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 1 Aug 2026 09:17:57 +0200 Subject: [PATCH] feat: add admin user frontend contract --- .../src/app/app.routes.spec.ts | 11 ++ .../src/app/app.routes.ts | 5 + .../src/app/core/auth/auth-api.ts | 1 - .../src/app/core/auth/auth-store.spec.ts | 31 +++++ .../src/app/core/auth/auth-store.ts | 6 + .../src/app/core/layout/shell/shell.ts | 6 +- .../src/app/core/team/my-teams-store.spec.ts | 7 +- .../src/app/core/team/my-teams-store.ts | 4 +- .../src/app/core/team/teams-api.spec.ts | 4 +- .../src/app/core/team/teams-api.ts | 7 +- .../src/app/core/users/admin-users-api.ts | 52 ++++++++ .../src/app/core/users/users-api.spec.ts | 90 ++++++++++++++ .../src/app/core/users/users-api.ts | 25 ++++ .../features/auth/register/register.spec.ts | 9 +- .../app/features/auth/register/register.ts | 1 - .../app/features/team-select/team-select.html | 4 +- .../src/app/features/team/more/more.html | 7 ++ .../src/app/features/team/more/more.spec.ts | 3 + .../src/app/features/users/users.ts | 13 ++ .../src/app/models/user-directory.model.ts | 113 ++++++++++++++++++ 20 files changed, 378 insertions(+), 21 deletions(-) create mode 100644 myteamwallet_frontend_modern/src/app/core/users/admin-users-api.ts create mode 100644 myteamwallet_frontend_modern/src/app/core/users/users-api.spec.ts create mode 100644 myteamwallet_frontend_modern/src/app/core/users/users-api.ts create mode 100644 myteamwallet_frontend_modern/src/app/features/users/users.ts create mode 100644 myteamwallet_frontend_modern/src/app/models/user-directory.model.ts diff --git a/myteamwallet_frontend_modern/src/app/app.routes.spec.ts b/myteamwallet_frontend_modern/src/app/app.routes.spec.ts index a5ef8a2..99753e4 100644 --- a/myteamwallet_frontend_modern/src/app/app.routes.spec.ts +++ b/myteamwallet_frontend_modern/src/app/app.routes.spec.ts @@ -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('/'); diff --git a/myteamwallet_frontend_modern/src/app/app.routes.ts b/myteamwallet_frontend_modern/src/app/app.routes.ts index 4e611ad..55eaf23 100644 --- a/myteamwallet_frontend_modern/src/app/app.routes.ts +++ b/myteamwallet_frontend_modern/src/app/app.routes.ts @@ -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), diff --git a/myteamwallet_frontend_modern/src/app/core/auth/auth-api.ts b/myteamwallet_frontend_modern/src/app/core/auth/auth-api.ts index e6fdd94..eea30bc 100644 --- a/myteamwallet_frontend_modern/src/app/core/auth/auth-api.ts +++ b/myteamwallet_frontend_modern/src/app/core/auth/auth-api.ts @@ -23,7 +23,6 @@ export interface RegistrationRequest { password: string; firstName: string; lastName: string; - linkPlayerId: number; } export interface CreateInviteRequest extends InviteDetails {} diff --git a/myteamwallet_frontend_modern/src/app/core/auth/auth-store.spec.ts b/myteamwallet_frontend_modern/src/app/core/auth/auth-store.spec.ts index ab0fea8..f3459c7 100644 --- a/myteamwallet_frontend_modern/src/app/core/auth/auth-store.spec.ts +++ b/myteamwallet_frontend_modern/src/app/core/auth/auth-store.spec.ts @@ -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); + }); }); diff --git a/myteamwallet_frontend_modern/src/app/core/auth/auth-store.ts b/myteamwallet_frontend_modern/src/app/core/auth/auth-store.ts index a26e141..686195c 100644 --- a/myteamwallet_frontend_modern/src/app/core/auth/auth-store.ts +++ b/myteamwallet_frontend_modern/src/app/core/auth/auth-store.ts @@ -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(() => { + 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); diff --git a/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.ts b/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.ts index af5e8e1..ea0a1f7 100644 --- a/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.ts +++ b/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.ts @@ -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(); - 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); } diff --git a/myteamwallet_frontend_modern/src/app/core/team/my-teams-store.spec.ts b/myteamwallet_frontend_modern/src/app/core/team/my-teams-store.spec.ts index a29c183..92b1468 100644 --- a/myteamwallet_frontend_modern/src/app/core/team/my-teams-store.spec.ts +++ b/myteamwallet_frontend_modern/src/app/core/team/my-teams-store.spec.ts @@ -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(() => { diff --git a/myteamwallet_frontend_modern/src/app/core/team/my-teams-store.ts b/myteamwallet_frontend_modern/src/app/core/team/my-teams-store.ts index 085aa5b..3102aa1 100644 --- a/myteamwallet_frontend_modern/src/app/core/team/my-teams-store.ts +++ b/myteamwallet_frontend_modern/src/app/core/team/my-teams-store.ts @@ -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([]); + private readonly playersSignal = signal([]); private readonly loadingSignal = signal(false); private readonly loadedForUserId = signal(null); diff --git a/myteamwallet_frontend_modern/src/app/core/team/teams-api.spec.ts b/myteamwallet_frontend_modern/src/app/core/team/teams-api.spec.ts index 0894aba..de482ce 100644 --- a/myteamwallet_frontend_modern/src/app/core/team/teams-api.spec.ts +++ b/myteamwallet_frontend_modern/src/app/core/team/teams-api.spec.ts @@ -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); diff --git a/myteamwallet_frontend_modern/src/app/core/team/teams-api.ts b/myteamwallet_frontend_modern/src/app/core/team/teams-api.ts index 75facbd..b76ba49 100644 --- a/myteamwallet_frontend_modern/src/app/core/team/teams-api.ts +++ b/myteamwallet_frontend_modern/src/app/core/team/teams-api.ts @@ -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 { - return this.http.get(`${environment.apiUrl}users/${userId}/teams`); + loadMyTeams(userId: number): Observable { + return this.http.get(`${environment.apiUrl}users/${userId}/teams`); } loadTeamOverview(teamId: number): Observable { diff --git a/myteamwallet_frontend_modern/src/app/core/users/admin-users-api.ts b/myteamwallet_frontend_modern/src/app/core/users/admin-users-api.ts new file mode 100644 index 0000000..6b29630 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/users/admin-users-api.ts @@ -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 { + return this.http.patch(`${this.baseUrl}/${userId}/profile`, request); + } + + updateRole(userId: number, request: AdminUserRoleRequest): Observable { + return this.http.patch(`${this.baseUrl}/${userId}/role`, request); + } + + updateStatus(userId: number, request: AdminUserStatusRequest): Observable { + return this.http.patch(`${this.baseUrl}/${userId}/status`, request); + } + + loadPlayers(filters: AdminPlayerFilters = {}): Observable { + return this.http.get(`${this.baseUrl}/players`, { params: this.toParams(filters) }); + } + + assignPlayer(userId: number, playerId: number): Observable { + return this.http.put(`${this.baseUrl}/${userId}/players/${playerId}`, null); + } + + unlinkPlayer(userId: number, playerId: number): Observable { + return this.http.delete(`${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; + } +} diff --git a/myteamwallet_frontend_modern/src/app/core/users/users-api.spec.ts b/myteamwallet_frontend_modern/src/app/core/users/users-api.spec.ts new file mode 100644 index 0000000..e838ae5 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/users/users-api.spec.ts @@ -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({}); + }); +}); diff --git a/myteamwallet_frontend_modern/src/app/core/users/users-api.ts b/myteamwallet_frontend_modern/src/app/core/users/users-api.ts new file mode 100644 index 0000000..3fac777 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/users/users-api.ts @@ -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 { + return this.http.get(`${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; + } +} diff --git a/myteamwallet_frontend_modern/src/app/features/auth/register/register.spec.ts b/myteamwallet_frontend_modern/src/app/features/auth/register/register.spec.ts index 1ecd967..520af52 100644 --- a/myteamwallet_frontend_modern/src/app/features/auth/register/register.spec.ts +++ b/myteamwallet_frontend_modern/src/app/features/auth/register/register.spec.ts @@ -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 }); diff --git a/myteamwallet_frontend_modern/src/app/features/auth/register/register.ts b/myteamwallet_frontend_modern/src/app/features/auth/register/register.ts index 2383f11..9a0c1be 100644 --- a/myteamwallet_frontend_modern/src/app/features/auth/register/register.ts +++ b/myteamwallet_frontend_modern/src/app/features/auth/register/register.ts @@ -78,7 +78,6 @@ export class Register { password: value.password, firstName: value.firstName, lastName: value.lastName, - linkPlayerId: invitation.playerId, }) .subscribe({ next: () => { diff --git a/myteamwallet_frontend_modern/src/app/features/team-select/team-select.html b/myteamwallet_frontend_modern/src/app/features/team-select/team-select.html index fa7d4a5..dd5dea9 100644 --- a/myteamwallet_frontend_modern/src/app/features/team-select/team-select.html +++ b/myteamwallet_frontend_modern/src/app/features/team-select/team-select.html @@ -11,8 +11,8 @@

Team auswählen

@for (player of players(); track player.id) { - - {{ player.team?.name }} + + {{ player.team.name }} {{ player.firstName }} {{ player.lastName }} } diff --git a/myteamwallet_frontend_modern/src/app/features/team/more/more.html b/myteamwallet_frontend_modern/src/app/features/team/more/more.html index 25512c3..5ae851c 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/more/more.html +++ b/myteamwallet_frontend_modern/src/app/features/team/more/more.html @@ -5,6 +5,13 @@