diff --git a/myteamwallet_frontend_modern/src/app/features/users/player-assignments.ts b/myteamwallet_frontend_modern/src/app/features/users/player-assignments.ts new file mode 100644 index 0000000..697eaa7 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/features/users/player-assignments.ts @@ -0,0 +1,265 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { Component, EventEmitter, Input, OnChanges, Output, inject, signal } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { filter, finalize, take } from 'rxjs'; +import { AdminUsersApi } from '../../core/users/admin-users-api'; +import { + AdminPlayerPage, + AdminPlayerSummary, + AdminUserDirectorySummary, +} from '../../models/user-directory.model'; +import { ConfirmDialog } from '../../shared/confirm-dialog/confirm-dialog'; + +@Component({ + selector: 'app-player-assignments', + imports: [ + MatButtonModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatProgressSpinnerModule, + ], + template: ` +
+
+
+

Spielerzuordnungen

+

Spieler teamübergreifend suchen und sicher verknüpfen.

+
+ +
+ + + + @if (error()) { + + } + + @if (loading()) { +
+ } @else if (!error() && players()?.data?.length === 0) { +
Keine Spieler gefunden.
+ } @else if (players()) { +
+ @for (player of players()!.data; track player.id) { +
+
+ {{ player.firstName }} {{ player.lastName }} + {{ player.team.name }} · {{ player.active ? 'Aktiv' : 'Inaktiv' }} + @if (player.currentUser) { + Aktuell: {{ userName(player.currentUser) }} + } @else { + Nicht verknüpft + } +
+ @if (player.currentUser?.id === user.id) { + + } @else if (player.currentUser) { + + } @else { + + } +
+ } +
+
+ + Seite {{ players()!.page }} + +
+ } +
+ `, + styles: ` + .assignments { padding: 18px; border: 1px solid var(--mat-sys-outline-variant); border-radius: 18px; } + .assignments__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } + h3, p { margin: 0; } + .assignments__heading p { margin-top: 4px; color: var(--mat-sys-on-surface-variant); } + .assignments__search { display: flex; align-items: center; gap: 10px; margin: 18px 0; } + .assignments__search mat-form-field { flex: 1; } + .player-list { display: grid; } + .player-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--mat-sys-outline-variant); } + .player-row--inactive { opacity: .68; } + .player-row__copy { display: grid; gap: 2px; min-width: 0; } + .player-row__copy span { color: var(--mat-sys-on-surface-variant); } + .assignments__state { min-height: 120px; display: grid; place-content: center; color: var(--mat-sys-on-surface-variant); } + .assignments__error { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; padding: 12px; border-radius: 12px; color: var(--mat-sys-on-error-container); background: var(--mat-sys-error-container); } + .assignments__paging { display: flex; align-items: center; justify-content: space-between; margin-top: 14px; } + @media (max-width: 600px) { + .assignments { padding: 16px; } + .assignments__heading, .assignments__search, .player-row { align-items: stretch; flex-direction: column; } + .player-row button { width: 100%; } + } + `, +}) +export class PlayerAssignments implements OnChanges { + private readonly adminUsersApi = inject(AdminUsersApi); + private readonly dialog = inject(MatDialog); + + @Input({ required: true }) user!: AdminUserDirectorySummary; + @Output() readonly directoryChanged = new EventEmitter(); + @Output() readonly closed = new EventEmitter(); + + protected readonly players = signal(null); + protected readonly loading = signal(false); + protected readonly error = signal(null); + protected readonly pendingPlayerId = signal(null); + protected searchDraft = ''; + private search = ''; + private page = 1; + private readonly limit = 20; + + ngOnChanges(): void { + this.searchDraft = ''; + this.search = ''; + this.page = 1; + this.loadPlayers(); + } + + protected loadPlayers(): void { + this.loading.set(true); + this.error.set(null); + this.adminUsersApi + .loadPlayers({ + assignment: 'all', + page: this.page, + limit: this.limit, + ...(this.search ? { search: this.search } : {}), + }) + .pipe(finalize(() => this.loading.set(false))) + .subscribe({ + next: (players) => this.players.set(players), + error: (error: HttpErrorResponse) => this.error.set(this.errorMessage(error)), + }); + } + + protected submitSearch(): void { + this.search = this.searchDraft.trim(); + this.page = 1; + this.loadPlayers(); + } + + protected previousPage(): void { + if (this.page <= 1 || this.loading()) return; + this.page -= 1; + this.loadPlayers(); + } + + protected nextPage(): void { + if (!this.players()?.hasNextPage || this.loading()) return; + this.page += 1; + this.loadPlayers(); + } + + protected assign(player: AdminPlayerSummary): void { + this.runAssignment(player); + } + + protected confirmUnlink(player: AdminPlayerSummary): void { + this.confirm({ + title: 'Verknüpfung lösen?', + message: `${player.firstName} ${player.lastName} wird von ${this.targetName()} getrennt.`, + confirmLabel: 'Verknüpfung lösen', + }).subscribe(() => this.runUnlink(player)); + } + + protected confirmReassign(player: AdminPlayerSummary): void { + this.confirm({ + title: 'Spieler neu zuordnen?', + message: `${player.firstName} ${player.lastName} ist aktuell mit ${this.userName(player.currentUser!)} verknüpft und wird ${this.targetName()} zugeordnet.`, + confirmLabel: 'Neu zuordnen', + }).subscribe(() => this.runAssignment(player)); + } + + protected userName(user: { id: number; firstName: string | null; lastName: string | null }): string { + return [user.firstName, user.lastName].filter(Boolean).join(' ') || `Benutzer ${user.id}`; + } + + private targetName(): string { + return this.userName(this.user); + } + + private confirm(data: { title: string; message: string; confirmLabel: string }) { + return this.dialog + .open(ConfirmDialog, { data, restoreFocus: true }) + .afterClosed() + .pipe(filter(Boolean), take(1)); + } + + private runAssignment(player: AdminPlayerSummary): void { + if (this.pendingPlayerId() !== null) return; + this.pendingPlayerId.set(player.id); + this.error.set(null); + this.adminUsersApi + .assignPlayer(this.user.id, player.id) + .pipe(finalize(() => this.pendingPlayerId.set(null))) + .subscribe({ + next: () => this.refreshAfterMutation(), + error: (error: HttpErrorResponse) => this.error.set(this.errorMessage(error)), + }); + } + + private runUnlink(player: AdminPlayerSummary): void { + if (this.pendingPlayerId() !== null) return; + this.pendingPlayerId.set(player.id); + this.error.set(null); + this.adminUsersApi + .unlinkPlayer(this.user.id, player.id) + .pipe(finalize(() => this.pendingPlayerId.set(null))) + .subscribe({ + next: () => this.refreshAfterMutation(), + error: (error: HttpErrorResponse) => this.error.set(this.errorMessage(error)), + }); + } + + private refreshAfterMutation(): void { + this.directoryChanged.emit(); + this.loadPlayers(); + } + + private errorMessage(error: HttpErrorResponse): string { + const detail = typeof error.error?.message === 'string' ? error.error.message : ''; + const fallback = error.status === 403 ? 'Keine Berechtigung.' : 'Zuordnung konnte nicht geändert werden.'; + return detail ? `${fallback} ${detail}` : fallback; + } +} diff --git a/myteamwallet_frontend_modern/src/app/features/users/user-edit.ts b/myteamwallet_frontend_modern/src/app/features/users/user-edit.ts new file mode 100644 index 0000000..e572592 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/features/users/user-edit.ts @@ -0,0 +1,85 @@ +import { Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { AdminUserDirectorySummary, AdminUserRoleId } from '../../models/user-directory.model'; + +export interface UserEditValue { + firstName: string; + lastName: string; + role: AdminUserRoleId; +} + +@Component({ + selector: 'app-user-edit', + imports: [MatButtonModule, MatFormFieldModule, MatInputModule, MatSelectModule], + template: ` +
+
+ + Vorname + + + + Nachname + + + + Globale Rolle + + +
+ @if (self) { +

Die eigene Administratorrolle kann hier nicht entzogen werden.

+ } +
+ + +
+
+ `, + 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(); + @Output() readonly cancel = new EventEmitter(); + + protected firstName = ''; + protected lastName = ''; + protected role: AdminUserRoleId = 2; + + ngOnChanges(): void { + this.firstName = this.user.firstName ?? ''; + this.lastName = this.user.lastName ?? ''; + this.role = this.user.role?.id === 1 ? 1 : 2; + } + + protected submit(): void { + const firstName = this.firstName.trim(); + const lastName = this.lastName.trim(); + if (!firstName || !lastName || this.saving) return; + this.saved.emit({ firstName, lastName, role: this.self ? 1 : this.role }); + } + + protected setRole(value: string): void { + this.role = value === '1' ? 1 : 2; + } +} diff --git a/myteamwallet_frontend_modern/src/app/features/users/users.html b/myteamwallet_frontend_modern/src/app/features/users/users.html new file mode 100644 index 0000000..7d46401 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/features/users/users.html @@ -0,0 +1,123 @@ +
+ arrow_backZurück + + + + + @if (mutationError()) { + + } + + @if (loading()) { +
+ + Benutzer werden geladen … +
+ } @else if (loadError()) { + + } @else if (directory()?.data?.length === 0) { +
+ group_off + Keine Benutzer gefunden + Versuche einen anderen Suchbegriff. +
+ } @else if (directory()) { +
+ @for (user of directory()!.data; track user.id) { +
+
+ +
+
+

{{ fullName(user) }}

+ + {{ statusName(user.status?.id) }} + +
+ @if (adminDetails(user); as details) { + {{ details.email ?? 'Keine E-Mail' }} · {{ roleName(details.role?.id) }} + } + @if (user.assignments.length === 0) { + Keine Spielerzuordnung sichtbar + } @else { +
+ @for (assignment of user.assignments; track assignment.id) { + + {{ assignment.team.name }} · {{ assignment.firstName }} {{ assignment.lastName }} · + {{ teamRoleName(assignment.teamRole?.name) }} + @if (!assignment.active) { · Inaktiv } + + } +
+ } +
+
+ + @if (adminDetails(user); as details) { +
+ + + +
+ + @if (editingUserId() === user.id) { + + } + @if (assignmentUserId() === user.id) { + + } + } +
+ } +
+ + + } +
diff --git a/myteamwallet_frontend_modern/src/app/features/users/users.scss b/myteamwallet_frontend_modern/src/app/features/users/users.scss new file mode 100644 index 0000000..730f9ce --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/features/users/users.scss @@ -0,0 +1,191 @@ +:host { + display: block; + min-height: 100dvh; + background: var(--mat-sys-surface); +} + +.users-page { + max-width: 960px; + margin: 0 auto; + padding: 24px 28px 40px; +} + +.back-link { + margin-left: -12px; +} + +.page-header { + margin: 20px 0 26px; +} + +h1, +h2, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 8px; + font-size: clamp(2rem, 4vw, 3rem); +} + +.page-header > p:last-child { + color: var(--mat-sys-on-surface-variant); +} + +.eyebrow { + margin-bottom: 6px; + color: var(--mat-sys-primary); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.directory-search { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 22px; +} + +.directory-search mat-form-field { + flex: 1; +} + +.directory { + border-top: 1px solid var(--mat-sys-outline-variant); +} + +.user-row { + display: grid; + gap: 16px; + padding: 20px 0; + border-bottom: 1px solid var(--mat-sys-outline-variant); +} + +.user-row--inactive .avatar { + filter: grayscale(1); + opacity: 0.65; +} + +.user-row__summary { + display: grid; + grid-template-columns: 48px minmax(0, 1fr); + gap: 14px; +} + +.avatar { + width: 48px; + height: 48px; + display: grid; + place-items: center; + border-radius: 50%; + background: var(--mat-sys-primary-container); + color: var(--mat-sys-on-primary-container); + font-weight: 700; +} + +.user-row__identity, +.assignment-summary { + display: grid; + gap: 4px; + min-width: 0; +} + +.user-row__identity > span, +.assignment-summary span { + color: var(--mat-sys-on-surface-variant); +} + +.user-row__name { + display: flex; + align-items: center; + gap: 10px; +} + +.user-row__name h2 { + margin-bottom: 0; + font-size: 1.08rem; +} + +.status { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 9px; + border-radius: 999px; + background: var(--mat-sys-primary-container); + color: var(--mat-sys-on-primary-container); + font-size: 0.75rem; + font-weight: 700; +} + +.status--inactive { + background: var(--mat-sys-surface-container-high); + color: var(--mat-sys-on-surface-variant); +} + +.user-row__actions { + display: flex; + justify-content: flex-end; + flex-wrap: wrap; + gap: 6px; +} + +.page-state { + min-height: 240px; + display: grid; + place-content: center; + justify-items: center; + gap: 10px; + padding: 24px; + color: var(--mat-sys-on-surface-variant); + text-align: center; +} + +.page-state--error { + color: var(--mat-sys-error); +} + +.message { + margin-bottom: 16px; + padding: 12px 14px; + border-radius: 12px; +} + +.message--error { + color: var(--mat-sys-on-error-container); + background: var(--mat-sys-error-container); +} + +.pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 18px; + color: var(--mat-sys-on-surface-variant); +} + +@media (max-width: 700px) { + .users-page { + padding: 20px 16px 32px; + } + + .directory-search, + .user-row__actions { + align-items: stretch; + flex-direction: column; + } + + .directory-search button, + .user-row__actions button { + width: 100%; + } + + .pagination span { + font-size: 0.8rem; + text-align: center; + } +} diff --git a/myteamwallet_frontend_modern/src/app/features/users/users.spec.ts b/myteamwallet_frontend_modern/src/app/features/users/users.spec.ts new file mode 100644 index 0000000..c65211a --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/features/users/users.spec.ts @@ -0,0 +1,381 @@ +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { provideRouter } from '@angular/router'; +import { Subject } from 'rxjs'; +import { environment } from '../../../environments/environment'; +import { AuthStore } from '../../core/auth/auth-store'; +import { AdminPlayerPage, AdminUserDirectorySummary, UserDirectoryPage } from '../../models/user-directory.model'; +import { Users } from './users'; + +const api = `${environment.apiUrl}users/directory`; +const adminApi = `${environment.apiUrl}admin/users`; + +const ada: AdminUserDirectorySummary = { + id: 7, + firstName: 'Ada', + lastName: 'Lovelace', + email: 'ada@example.test', + role: { id: 2, name: 'User' }, + status: { id: 1, name: 'Active' }, + assignments: [ + { + id: 101, + firstName: 'Ada', + lastName: 'Lovelace', + active: true, + team: { id: 5, name: 'First Team', alias: 'first' }, + teamRole: { id: 1, name: 'player' }, + }, + ], +}; + +const admin: AdminUserDirectorySummary = { + id: 1, + firstName: 'Grace', + lastName: 'Admin', + email: 'grace@example.test', + role: { id: 1, name: 'Admin' }, + status: { id: 1, name: 'Active' }, + assignments: [], +}; + +function directoryPage(data = [ada], page = 1, total = data.length, hasNextPage = false): UserDirectoryPage { + return { data, page, limit: 20, total, hasNextPage }; +} + +function playersPage(overrides: Partial = {}): AdminPlayerPage { + 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; + let http: HttpTestingController; + let isAdmin: ReturnType>; + let currentUser: ReturnType>; + let closeDialog: Subject; + let dialog: { open: ReturnType }; + + beforeEach(async () => { + isAdmin = signal(false); + currentUser = signal({ id: 99, firstName: 'Nora', lastName: 'Viewer', role: { id: 2 } }); + closeDialog = new Subject(); + dialog = { open: vi.fn(() => ({ afterClosed: () => closeDialog.asObservable() })) }; + + await TestBed.configureTestingModule({ + imports: [Users], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + provideRouter([]), + { provide: AuthStore, useValue: { isGlobalAdmin: isAdmin, currentUser } }, + { provide: MatDialog, useValue: dialog }, + ], + }).compileComponents(); + + http = TestBed.inject(HttpTestingController); + }); + + afterEach(() => http.verify()); + + function create(): void { + fixture = TestBed.createComponent(Users); + fixture.detectChanges(); + } + + function text(): string { + return (fixture.nativeElement as HTMLElement).textContent ?? ''; + } + + function button(label: string): HTMLButtonElement { + const match = [...(fixture.nativeElement as HTMLElement).querySelectorAll('button')].find((element) => + element.textContent?.includes(label), + ); + if (!match) throw new Error(`Missing button: ${label}`); + return match as HTMLButtonElement; + } + + function flushDirectory(page = directoryPage()): void { + http.expectOne(`${api}?page=1&limit=20`).flush(page); + fixture.detectChanges(); + } + + it('loads the first directory page, searches from page one, and pages forward', () => { + create(); + flushDirectory(directoryPage([ada], 1, 12, true)); + + expect(text()).toContain('Ada Lovelace'); + expect(text()).toContain('First Team'); + expect(text()).toContain('Aktiv'); + + const search = (fixture.nativeElement as HTMLElement).querySelector('input[type="search"]')!; + search.value = ' Linus '; + search.dispatchEvent(new Event('input')); + fixture.detectChanges(); + search.closest('form')!.dispatchEvent(new Event('submit')); + fixture.detectChanges(); + http.expectOne(`${api}?page=1&limit=20&search=Linus`).flush(directoryPage([], 1)); + fixture.detectChanges(); + + search.value = ''; + search.dispatchEvent(new Event('input')); + fixture.detectChanges(); + search.closest('form')!.dispatchEvent(new Event('submit')); + fixture.detectChanges(); + http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([ada], 1, 12, true)); + fixture.detectChanges(); + button('Weiter').click(); + fixture.detectChanges(); + http.expectOne(`${api}?page=2&limit=20`).flush(directoryPage([admin], 2, 12, false)); + }); + + it('redacts admin-only data and controls for a non-admin even if extra fields arrive', () => { + create(); + flushDirectory(directoryPage([ada])); + + expect(text()).toContain('Ada Lovelace'); + expect(text()).not.toContain('ada@example.test'); + expect(text()).not.toContain('Globale Rolle'); + expect(text()).not.toContain('Bearbeiten'); + expect(text()).not.toContain('Zuordnungen verwalten'); + }); + + it('shows admin fields and actions while disabling self-demotion and self-deactivation', () => { + isAdmin.set(true); + currentUser.set({ id: 1, firstName: 'Grace', lastName: 'Admin', role: { id: 1 } }); + create(); + http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([admin, ada])); + fixture.detectChanges(); + + expect(text()).toContain('ada@example.test'); + expect(text()).toContain('Administrator'); + expect(text()).toContain('Bearbeiten'); + expect(text()).toContain('Zuordnungen verwalten'); + const selfRow = (fixture.nativeElement as HTMLElement).querySelector('[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('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('input[name="firstName"]')!; + const lastName = (fixture.nativeElement as HTMLElement).querySelector('input[name="lastName"]')!; + const role = (fixture.nativeElement as HTMLElement).querySelector('select[name="role"]')!; + firstName.value = 'Augusta'; + firstName.dispatchEvent(new Event('input')); + lastName.value = 'King'; + lastName.dispatchEvent(new Event('input')); + role.value = '1'; + role.dispatchEvent(new Event('change')); + firstName.closest('form')!.dispatchEvent(new Event('submit')); + fixture.detectChanges(); + + const profile = http.expectOne(`${adminApi}/7/profile`); + expect(profile.request.body).toEqual({ firstName: 'Augusta', lastName: 'King' }); + expect(text()).toContain('Wird gespeichert'); + profile.flush(ada); + const roleRequest = http.expectOne(`${adminApi}/7/role`); + expect(roleRequest.request.body).toEqual({ role: 1 }); + roleRequest.flush(ada); + http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([{ ...ada, firstName: 'Augusta', lastName: 'King' }])); + fixture.detectChanges(); + expect(text()).toContain('Augusta King'); + }); + + it('reloads authoritative directory state when a role change fails after the profile was saved', () => { + isAdmin.set(true); + create(); + flushDirectory(); + button('Bearbeiten').click(); + fixture.detectChanges(); + const role = (fixture.nativeElement as HTMLElement).querySelector('select[name="role"]')!; + role.value = '1'; + role.dispatchEvent(new Event('change')); + role.closest('form')!.dispatchEvent(new Event('submit')); + + http.expectOne(`${adminApi}/7/profile`).flush(ada); + http.expectOne(`${adminApi}/7/role`).flush( + { message: 'At least one active admin must remain' }, + { status: 409, statusText: 'Conflict' }, + ); + http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage()); + fixture.detectChanges(); + expect(text()).toContain('At least one active admin must remain'); + }); + + it('confirms status changes before mutating and refreshes after success', () => { + isAdmin.set(true); + create(); + flushDirectory(); + button('Deaktivieren').click(); + + expect(dialog.open).toHaveBeenCalled(); + expect(dialog.open.mock.calls[0][1].data.message).toContain('Ada Lovelace'); + http.expectNone(`${adminApi}/7/status`); + closeDialog.next(true); + const request = http.expectOne(`${adminApi}/7/status`); + expect(request.request.body).toEqual({ status: 2 }); + request.flush(ada); + http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([{ ...ada, status: { id: 2, name: 'Inactive' } }])); + }); + + it('loads searchable player results and assigns an unlinked player before refreshing both lists', () => { + isAdmin.set(true); + create(); + flushDirectory(); + button('Zuordnungen verwalten').click(); + fixture.detectChanges(); + http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage()); + fixture.detectChanges(); + expect(text()).toContain('Linus Player'); + expect(text()).toContain('Second Team'); + + button('Zuordnen').click(); + const assign = http.expectOne(`${adminApi}/7/players/202`); + expect(assign.request.method).toBe('PUT'); + assign.flush(ada); + http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage()); + http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage()); + }); + + it('confirms unlinking and reassignment with the affected user names', () => { + isAdmin.set(true); + create(); + flushDirectory(); + button('Zuordnungen verwalten').click(); + fixture.detectChanges(); + http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush( + playersPage({ + data: [ + { + id: 101, + firstName: 'Ada', + lastName: 'Lovelace', + active: true, + team: { id: 5, name: 'First Team', alias: 'first' }, + currentUser: { id: 7, firstName: 'Ada', lastName: 'Lovelace', status: { id: 1, name: 'Active' } }, + }, + { + id: 203, + firstName: 'Other', + lastName: 'Player', + active: true, + team: { id: 6, name: 'Second Team', alias: 'second' }, + currentUser: { id: 8, firstName: 'Alan', lastName: 'Turing', status: { id: 1, name: 'Active' } }, + }, + ], + total: 2, + }), + ); + fixture.detectChanges(); + + button('Verknüpfung lösen').click(); + expect(dialog.open.mock.calls[0][1].data.message).toContain('Ada Lovelace'); + closeDialog.next(false); + http.expectNone(`${adminApi}/7/players/101`); + + closeDialog = new Subject(); + dialog.open.mockReturnValue({ afterClosed: () => closeDialog.asObservable() }); + button('Neu zuordnen').click(); + const message = dialog.open.mock.calls[1][1].data.message; + expect(message).toContain('Alan Turing'); + expect(message).toContain('Ada Lovelace'); + closeDialog.next(true); + http.expectOne(`${adminApi}/7/players/203`).flush(ada); + http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage()); + http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage()); + }); + + it('keeps an unlink pending until success and then reloads directory and player results', () => { + isAdmin.set(true); + create(); + flushDirectory(); + button('Zuordnungen verwalten').click(); + fixture.detectChanges(); + http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush( + playersPage({ + data: [ + { + id: 101, + firstName: 'Ada', + lastName: 'Lovelace', + active: true, + team: { id: 5, name: 'First Team', alias: 'first' }, + currentUser: { id: 7, firstName: 'Ada', lastName: 'Lovelace', status: { id: 1, name: 'Active' } }, + }, + ], + }), + ); + fixture.detectChanges(); + + button('Verknüpfung lösen').click(); + http.expectNone(`${adminApi}/7/players/101`); + closeDialog.next(true); + fixture.detectChanges(); + const unlink = http.expectOne(`${adminApi}/7/players/101`); + expect(unlink.request.method).toBe('DELETE'); + expect(text()).toContain('Wird gelöst'); + unlink.flush(ada); + http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage()); + http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage()); + }); + + it('renders loading, empty, general error, and retry states', () => { + create(); + expect((fixture.nativeElement as HTMLElement).querySelector('[role="progressbar"]')).not.toBeNull(); + http.expectOne(`${api}?page=1&limit=20`).flush('broken', { status: 500, statusText: 'Server Error' }); + fixture.detectChanges(); + expect(text()).toContain('Benutzer konnten nicht geladen werden'); + button('Erneut versuchen').click(); + http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([])); + fixture.detectChanges(); + expect(text()).toContain('Keine Benutzer gefunden'); + }); + + it('surfaces directory and mutation authorization errors clearly', () => { + create(); + http.expectOne(`${api}?page=1&limit=20`).flush({ message: 'Forbidden' }, { status: 403, statusText: 'Forbidden' }); + fixture.detectChanges(); + expect(text()).toContain('Keine Berechtigung'); + + isAdmin.set(true); + button('Erneut versuchen').click(); + http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage()); + fixture.detectChanges(); + button('Deaktivieren').click(); + closeDialog.next(true); + http.expectOne(`${adminApi}/7/status`).flush( + { message: 'At least one active admin must remain' }, + { status: 403, statusText: 'Forbidden' }, + ); + fixture.detectChanges(); + expect(text()).toContain('Keine Berechtigung'); + expect(text()).toContain('At least one active admin must remain'); + }); +}); diff --git a/myteamwallet_frontend_modern/src/app/features/users/users.ts b/myteamwallet_frontend_modern/src/app/features/users/users.ts index 1d81d4d..bc9f6e5 100644 --- a/myteamwallet_frontend_modern/src/app/features/users/users.ts +++ b/myteamwallet_frontend_modern/src/app/features/users/users.ts @@ -1,13 +1,215 @@ -import { Component } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { Component, inject, signal } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { filter, finalize, of, switchMap, take } from 'rxjs'; +import { AuthStore } from '../../core/auth/auth-store'; +import { AdminUsersApi } from '../../core/users/admin-users-api'; +import { UsersApi } from '../../core/users/users-api'; +import { + AdminUserDirectorySummary, + AdminUserStatusId, + UserDirectoryPage, + UserDirectoryRecord, +} from '../../models/user-directory.model'; +import { ConfirmDialog } from '../../shared/confirm-dialog/confirm-dialog'; +import { PlayerAssignments } from './player-assignments'; +import { UserEdit, UserEditValue } from './user-edit'; @Component({ selector: 'app-users', - template: ` -
-

Organisation

-

Benutzer

-

Benutzerverzeichnis wird vorbereitet.

-
- `, + imports: [ + RouterLink, + MatButtonModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatProgressSpinnerModule, + PlayerAssignments, + UserEdit, + ], + templateUrl: './users.html', + styleUrl: './users.scss', }) -export class Users {} +export class Users { + private readonly usersApi = inject(UsersApi); + private readonly adminUsersApi = inject(AdminUsersApi); + private readonly authStore = inject(AuthStore); + private readonly dialog = inject(MatDialog); + + protected readonly isAdmin = this.authStore.isGlobalAdmin; + protected readonly currentUser = this.authStore.currentUser; + protected readonly directory = signal(null); + protected readonly loading = signal(true); + protected readonly loadError = signal(null); + protected readonly mutationError = signal(null); + protected readonly searchDraft = signal(''); + protected readonly search = signal(''); + protected readonly page = signal(1); + protected readonly limit = 20; + protected readonly editingUserId = signal(null); + protected readonly assignmentUserId = signal(null); + protected readonly pendingUserId = signal(null); + + constructor() { + this.loadDirectory(); + } + + protected loadDirectory(): void { + this.loading.set(true); + this.loadError.set(null); + const search = this.search(); + this.usersApi + .loadDirectory({ page: this.page(), limit: this.limit, ...(search ? { search } : {}) }) + .pipe(finalize(() => this.loading.set(false))) + .subscribe({ + next: (directory) => this.directory.set(directory), + error: (error: HttpErrorResponse) => { + this.directory.set(null); + this.loadError.set(this.errorMessage(error, 'Benutzer konnten nicht geladen werden.')); + }, + }); + } + + protected submitSearch(): void { + this.search.set(this.searchDraft().trim()); + this.page.set(1); + this.loadDirectory(); + } + + protected previousPage(): void { + if (this.page() <= 1 || this.loading()) return; + this.page.update((value) => value - 1); + this.loadDirectory(); + } + + protected nextPage(): void { + if (!this.directory()?.hasNextPage || this.loading()) return; + this.page.update((value) => value + 1); + this.loadDirectory(); + } + + protected adminDetails(user: UserDirectoryRecord): AdminUserDirectorySummary | null { + if (!this.isAdmin()) return null; + return 'email' in user && 'role' in user ? user : null; + } + + protected fullName(user: UserDirectoryRecord): string { + return [user.firstName, user.lastName].filter(Boolean).join(' ') || `Benutzer ${user.id}`; + } + + protected initials(user: UserDirectoryRecord): string { + const value = `${user.firstName?.charAt(0) ?? ''}${user.lastName?.charAt(0) ?? ''}`.trim(); + return value || '?'; + } + + protected statusName(statusId?: number): string { + return statusId === 2 ? 'Inaktiv' : 'Aktiv'; + } + + protected roleName(roleId?: number): string { + return roleId === 1 ? 'Administrator' : 'Benutzer'; + } + + protected teamRoleName(name?: string): string { + return ( + { + player: 'Spieler', + scnd_treasurer: '2. Kassenwart', + captain: 'Kapitän', + treasurer: 'Kassenwart', + coach: 'Trainer', + }[name ?? ''] ?? 'Spieler' + ); + } + + protected toggleEdit(userId: number): void { + this.assignmentUserId.set(null); + this.editingUserId.update((value) => (value === userId ? null : userId)); + this.mutationError.set(null); + } + + protected toggleAssignments(userId: number): void { + this.editingUserId.set(null); + this.assignmentUserId.update((value) => (value === userId ? null : userId)); + this.mutationError.set(null); + } + + protected saveEdit(user: AdminUserDirectorySummary, value: UserEditValue): void { + if (this.pendingUserId() !== null) return; + this.pendingUserId.set(user.id); + this.mutationError.set(null); + const profileRequest = this.adminUsersApi.updateProfile(user.id, { + firstName: value.firstName, + lastName: value.lastName, + }); + const roleId = user.role?.id; + profileRequest + .pipe( + switchMap(() => + roleId === value.role ? of(user) : this.adminUsersApi.updateRole(user.id, { role: value.role }), + ), + finalize(() => this.pendingUserId.set(null)), + ) + .subscribe({ + next: () => { + this.editingUserId.set(null); + this.loadDirectory(); + }, + error: (error: HttpErrorResponse) => { + this.mutationError.set(this.errorMessage(error, 'Änderung fehlgeschlagen.')); + this.loadDirectory(); + }, + }); + } + + protected changeStatus(user: AdminUserDirectorySummary): void { + if (this.isSelf(user) || this.pendingUserId() !== null) return; + const isActive = user.status?.id !== 2; + const status: AdminUserStatusId = isActive ? 2 : 1; + this.dialog + .open(ConfirmDialog, { + data: { + title: isActive ? 'Benutzer deaktivieren?' : 'Benutzer aktivieren?', + message: `${this.fullName(user)} wird ${isActive ? 'deaktiviert' : 'aktiviert'}.`, + confirmLabel: isActive ? 'Deaktivieren' : 'Aktivieren', + }, + restoreFocus: true, + }) + .afterClosed() + .pipe(filter(Boolean), take(1)) + .subscribe(() => this.updateStatus(user.id, status)); + } + + protected isSelf(user: UserDirectoryRecord): boolean { + return user.id === this.currentUser()?.id; + } + + protected assignmentsChanged(): void { + this.loadDirectory(); + } + + private updateStatus(userId: number, status: AdminUserStatusId): void { + this.pendingUserId.set(userId); + this.mutationError.set(null); + this.adminUsersApi + .updateStatus(userId, { status }) + .pipe(finalize(() => this.pendingUserId.set(null))) + .subscribe({ + next: () => this.loadDirectory(), + error: (error: HttpErrorResponse) => + this.mutationError.set(this.errorMessage(error, 'Status konnte nicht geändert werden.')), + }); + } + + private errorMessage(error: HttpErrorResponse, fallback: string): string { + const detail = typeof error.error?.message === 'string' ? error.error.message : ''; + if (error.status === 403) return `Keine Berechtigung. ${detail}`.trim(); + return detail ? `${fallback} ${detail}` : fallback; + } +}