feat(frontend): add tab navigation and all-users table to AdminUserApprovalComponent

- Add activeTab signal ('pending' | 'all') with tab navigation UI
- Add allUsers signal with loadAllUsers() using generated AdminIAMService.getAllUsers()
- Add getStatusLabel() helper for German status labels (Ausstehend/Aktiv/Freigeschaltet/Inaktiv/Abgelehnt)
- Add getStatusBadgeClass() for color-coded status badges (yellow/green/red)
- Fix duplicate 'Status' column header bug in pending users table
- All-users tab shows Name, Email, Status badge, Rolle columns (no action buttons)
- Add 22 passing tests covering signals, helpers, tab switching, approve/reject, and template
This commit is contained in:
Bernhard Müller 2026-07-23 14:32:02 +02:00
parent cab55c4abb
commit 2bf1d49af1
3 changed files with 439 additions and 68 deletions

View File

@ -4,6 +4,36 @@
<p class="text-gray-600 mt-2">Prüfung der ausstehenden Anmeldungen und EDA-Netz-Checks.</p>
</div>
<!-- Tab Navigation -->
<div class="border-b border-gray-200 mb-6">
<nav class="flex gap-8" aria-label="Tabs">
<button
role="tab"
[attr.aria-selected]="activeTab() === 'pending'"
[class]="
activeTab() === 'pending'
? 'border-b-2 border-indigo-500 text-indigo-600 py-3 px-1 text-sm font-medium'
: 'border-b-2 border-transparent text-gray-500 hover:text-gray-700 py-3 px-1 text-sm font-medium'
"
(click)="switchTab('pending')"
>
Ausstehende Anträge
</button>
<button
role="tab"
[attr.aria-selected]="activeTab() === 'all'"
[class]="
activeTab() === 'all'
? 'border-b-2 border-indigo-500 text-indigo-600 py-3 px-1 text-sm font-medium'
: 'border-b-2 border-transparent text-gray-500 hover:text-gray-700 py-3 px-1 text-sm font-medium'
"
(click)="switchTab('all')"
>
Alle Benutzer
</button>
</nav>
</div>
@if (errorMessage()) {
<div class="bg-red-50 border-l-4 border-red-500 text-red-700 p-4 mb-6" role="alert">
{{ errorMessage() }}
@ -12,60 +42,126 @@
@if (isLoading()) {
<div class="flex items-center justify-center py-12">
<div class="text-gray-500 text-lg">Lade ausstehende Anträge...</div>
<div class="text-gray-500 text-lg">Lade Daten...</div>
</div>
}
@if (!isLoading() && pendingUsers().length === 0) {
<div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<p class="text-gray-600 text-lg">Aktuell gibt es keine neuen User im Status PENDING.</p>
</div>
}
@if (!isLoading() && pendingUsers().length > 0) {
<div class="bg-white shadow-sm border border-gray-200 rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-gray-50 border-b border-gray-200">
<th class="p-4 text-sm font-semibold text-gray-700 uppercase tracking-wider">Benutzer</th>
<th class="p-4 text-sm font-semibold text-gray-700 uppercase tracking-wider">Status</th>
<th class="p-4 text-sm font-semibold text-gray-700 uppercase tracking-wider">Status</th>
<th class="p-4 text-sm font-semibold text-gray-700 uppercase tracking-wider text-right">Aktionen</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@for (user of pendingUsers(); track user.id) {
<tr class="hover:bg-gray-50 transition-colors">
<td class="p-4">
<div class="font-medium text-gray-900">{{ user.firstName }} {{ user.lastName }}</div>
<div class="text-sm text-gray-500">{{ user.email }}</div>
</td>
<td class="p-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
{{ user.status }}
</span>
</td>
<td class="p-4 flex justify-end gap-3">
<button (click)="approveUser(user.id)"
class="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition">
Freigeben
</button>
<button (click)="rejectUser(user)"
class="inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition">
Ablehnen
</button>
</td>
</tr>
}
</tbody>
</table>
@if (!isLoading() && activeTab() === 'pending') {
@if (pendingUsers().length === 0) {
<div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<p class="text-gray-600 text-lg">Aktuell gibt es keine neuen User im Status PENDING.</p>
</div>
</div>
} @else {
<div class="bg-white shadow-sm border border-gray-200 rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-gray-50 border-b border-gray-200">
<th class="p-4 text-sm font-semibold text-gray-700 uppercase tracking-wider">
Benutzer
</th>
<th class="p-4 text-sm font-semibold text-gray-700 uppercase tracking-wider">
Status
</th>
<th
class="p-4 text-sm font-semibold text-gray-700 uppercase tracking-wider text-right"
>
Aktionen
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@for (user of pendingUsers(); track user.id) {
<tr class="hover:bg-gray-50 transition-colors">
<td class="p-4">
<div class="font-medium text-gray-900">
{{ user.firstName }} {{ user.lastName }}
</div>
<div class="text-sm text-gray-500">{{ user.email }}</div>
</td>
<td class="p-4">
<span
[class]="
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ' +
getStatusBadgeClass(user.status || 'PENDING')
"
>
{{ getStatusLabel(user.status || 'PENDING') }}
</span>
</td>
<td class="p-4 flex justify-end gap-3">
<button
(click)="approveUser(user.id)"
class="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition"
>
Freigeben
</button>
<button
(click)="rejectUser(user)"
class="inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition"
>
Ablehnen
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
@if (!isLoading() && activeTab() === 'all') {
@if (allUsers().length === 0) {
<div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<p class="text-gray-600 text-lg">Keine Benutzer vorhanden.</p>
</div>
} @else {
<div class="bg-white shadow-sm border border-gray-200 rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-gray-50 border-b border-gray-200">
<th class="p-4 text-sm font-semibold text-gray-700 uppercase tracking-wider">
Benutzer
</th>
<th class="p-4 text-sm font-semibold text-gray-700 uppercase tracking-wider">
Status
</th>
<th class="p-4 text-sm font-semibold text-gray-700 uppercase tracking-wider">
Rolle
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@for (user of allUsers(); track user.id) {
<tr class="hover:bg-gray-50 transition-colors">
<td class="p-4">
<div class="font-medium text-gray-900">
{{ user.firstName }} {{ user.lastName }}
</div>
<div class="text-sm text-gray-500">{{ user.email }}</div>
</td>
<td class="p-4">
<span
[class]="
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ' +
getStatusBadgeClass(user.status || 'UNKNOWN')
"
>
{{ getStatusLabel(user.status || 'UNKNOWN') }}
</span>
</td>
<td class="p-4 text-sm text-gray-700">
{{ user.participantType === 'COMPANY' ? 'Unternehmen' : 'Privatperson' }}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
</div>

View File

@ -1,22 +1,230 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { vi } from 'vitest';
import { AdminUserApprovalComponent } from './admin-user-approval';
import { UserProfileResponse } from '../../api';
describe('AdminUserApproval', () => {
describe('AdminUserApprovalComponent', () => {
let component: AdminUserApprovalComponent;
let fixture: ComponentFixture<AdminUserApprovalComponent>;
let httpMock: HttpTestingController;
const mockPendingUser: UserProfileResponse = {
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
email: 'max@test.at',
status: 'PENDING',
participantType: UserProfileResponse.ParticipantTypeEnum.Private,
};
const mockApprovedUser: UserProfileResponse = {
id: '2',
firstName: 'Anna',
lastName: 'Schmidt',
email: 'anna@test.at',
status: 'APPROVED',
participantType: UserProfileResponse.ParticipantTypeEnum.Company,
};
const mockRejectedUser: UserProfileResponse = {
id: '3',
firstName: 'Tom',
lastName: 'Berger',
email: 'tom@test.at',
status: 'REJECTED',
participantType: UserProfileResponse.ParticipantTypeEnum.Private,
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AdminUserApprovalComponent],
providers: [provideHttpClient(), provideHttpClientTesting()],
}).compileComponents();
fixture = TestBed.createComponent(AdminUserApprovalComponent);
component = fixture.componentInstance;
await fixture.whenStable();
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('should create', () => {
fixture.detectChanges();
const req = httpMock.expectOne((r) => r.url.includes('/admin/users/pending'));
req.flush([]);
expect(component).toBeTruthy();
});
it('should default activeTab to pending', () => {
fixture.detectChanges();
const req = httpMock.expectOne((r) => r.url.includes('/admin/users/pending'));
req.flush([]);
expect(component.activeTab()).toBe('pending');
});
it('should initialize allUsers as empty array', () => {
fixture.detectChanges();
const req = httpMock.expectOne((r) => r.url.includes('/admin/users/pending'));
req.flush([]);
expect(component.allUsers()).toEqual([]);
});
it('should load pending users on init', () => {
fixture.detectChanges();
const req = httpMock.expectOne((r) => r.url.includes('/admin/users/pending'));
expect(req.request.method).toBe('GET');
req.flush([mockPendingUser]);
expect(component.pendingUsers().length).toBe(1);
expect(component.pendingUsers()[0].firstName).toBe('Max');
});
it('should load all users when switching to all tab', () => {
fixture.detectChanges();
const pendingReq = httpMock.expectOne((r) => r.url.includes('/admin/users/pending'));
pendingReq.flush([]);
component.switchTab('all');
const allReq = httpMock.expectOne(
(r) => r.url.includes('/admin/users') && !r.url.includes('/pending'),
);
expect(allReq.request.method).toBe('GET');
allReq.flush([mockPendingUser, mockApprovedUser, mockRejectedUser]);
expect(component.allUsers().length).toBe(3);
});
it('should switch activeTab signal', () => {
fixture.detectChanges();
const req = httpMock.expectOne((r) => r.url.includes('/admin/users/pending'));
req.flush([]);
component.switchTab('all');
const allReq = httpMock.expectOne(
(r) => r.url.includes('/admin/users') && !r.url.includes('/pending'),
);
allReq.flush([]);
expect(component.activeTab()).toBe('all');
component.switchTab('pending');
expect(component.activeTab()).toBe('pending');
});
describe('getStatusLabel', () => {
it('should return "Ausstehend" for PENDING', () => {
expect(component.getStatusLabel('PENDING')).toBe('Ausstehend');
});
it('should return "Aktiv" for ACTIVE', () => {
expect(component.getStatusLabel('ACTIVE')).toBe('Aktiv');
});
it('should return "Freigeschaltet" for APPROVED', () => {
expect(component.getStatusLabel('APPROVED')).toBe('Freigeschaltet');
});
it('should return "Inaktiv" for INACTIVE', () => {
expect(component.getStatusLabel('INACTIVE')).toBe('Inaktiv');
});
it('should return "Abgelehnt" for REJECTED', () => {
expect(component.getStatusLabel('REJECTED')).toBe('Abgelehnt');
});
it('should return the original status for unknown values', () => {
expect(component.getStatusLabel('UNKNOWN')).toBe('UNKNOWN');
});
});
describe('getStatusBadgeClass', () => {
it('should return yellow badge for PENDING', () => {
expect(component.getStatusBadgeClass('PENDING')).toContain('yellow');
});
it('should return green badge for APPROVED', () => {
expect(component.getStatusBadgeClass('APPROVED')).toContain('green');
});
it('should return green badge for ACTIVE', () => {
expect(component.getStatusBadgeClass('ACTIVE')).toContain('green');
});
it('should return red badge for REJECTED', () => {
expect(component.getStatusBadgeClass('REJECTED')).toContain('red');
});
it('should return red badge for INACTIVE', () => {
expect(component.getStatusBadgeClass('INACTIVE')).toContain('red');
});
});
it('should approve user and remove from pending list', () => {
fixture.detectChanges();
const pendingReq = httpMock.expectOne((r) => r.url.includes('/admin/users/pending'));
pendingReq.flush([mockPendingUser]);
vi.spyOn(window, 'confirm').mockReturnValue(true);
component.approveUser(mockPendingUser.id);
const approveReq = httpMock.expectOne((r) => r.url.includes('/approve'));
approveReq.flush({});
expect(component.pendingUsers().length).toBe(0);
});
it('should reject user and remove from pending list', () => {
fixture.detectChanges();
const pendingReq = httpMock.expectOne((r) => r.url.includes('/admin/users/pending'));
pendingReq.flush([mockPendingUser]);
vi.spyOn(window, 'confirm').mockReturnValue(true);
component.rejectUser(mockPendingUser);
const rejectReq = httpMock.expectOne((r) => r.url.includes('/reject'));
rejectReq.flush({});
expect(component.pendingUsers().length).toBe(0);
});
describe('template', () => {
it('should render tab navigation', () => {
fixture.detectChanges();
const req = httpMock.expectOne((r) => r.url.includes('/admin/users/pending'));
req.flush([]);
fixture.detectChanges();
const tabs = fixture.nativeElement.querySelectorAll('[role="tab"]');
expect(tabs.length).toBe(2);
expect(tabs[0].textContent).toContain('Ausstehende Anträge');
expect(tabs[1].textContent).toContain('Alle Benutzer');
});
it('should show pending tab as active by default', () => {
fixture.detectChanges();
const req = httpMock.expectOne((r) => r.url.includes('/admin/users/pending'));
req.flush([]);
fixture.detectChanges();
const activeTab = fixture.nativeElement.querySelector('[role="tab"][aria-selected="true"]');
expect(activeTab?.textContent).toContain('Ausstehende Anträge');
});
it('should show only one Status column header (no duplicate)', () => {
fixture.detectChanges();
const req = httpMock.expectOne((r) => r.url.includes('/admin/users/pending'));
req.flush([mockPendingUser]);
fixture.detectChanges();
const allHeaders = fixture.nativeElement.querySelectorAll('th');
let statusCount = 0;
for (let i = 0; i < allHeaders.length; i++) {
if (allHeaders[i].textContent?.trim() === 'Status') {
statusCount++;
}
}
expect(statusCount).toBe(1);
});
});
});

View File

@ -1,18 +1,19 @@
import {Component, DestroyRef, inject, OnInit, signal} from '@angular/core';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {AdminIAMService, UserProfileResponse} from '../../api';
import {ToastService} from '../../services/toast';
import { Component, DestroyRef, inject, OnInit, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AdminIAMService, UserProfileResponse } from '../../api';
import { ToastService } from '../../services/toast';
@Component({
selector: 'app-admin-user-approval',
standalone: true,
imports: [],
templateUrl: './admin-user-approval.html',
styleUrls: ['./admin-user-approval.scss']
styleUrls: ['./admin-user-approval.scss'],
})
export class AdminUserApprovalComponent implements OnInit {
// Signals für reaktives State-Management (perfekt für den Zoneless-Modus)
public readonly activeTab = signal<'pending' | 'all'>('pending');
public readonly pendingUsers = signal<UserProfileResponse[]>([]);
public readonly allUsers = signal<UserProfileResponse[]>([]);
public readonly isLoading = signal<boolean>(true);
public readonly errorMessage = signal<string>('');
@ -24,11 +25,53 @@ export class AdminUserApprovalComponent implements OnInit {
this.loadPendingUsers();
}
public switchTab(tab: 'pending' | 'all'): void {
this.activeTab.set(tab);
if (tab === 'all' && this.allUsers().length === 0) {
this.loadAllUsers();
}
}
public getStatusLabel(status: string): string {
switch (status) {
case 'PENDING':
return 'Ausstehend';
case 'ACTIVE':
return 'Aktiv';
case 'APPROVED':
return 'Freigeschaltet';
case 'INACTIVE':
return 'Inaktiv';
case 'REJECTED':
return 'Abgelehnt';
default:
return status;
}
}
public getStatusBadgeClass(status: string): string {
switch (status) {
case 'PENDING':
return 'bg-yellow-100 text-yellow-800';
case 'ACTIVE':
return 'bg-green-100 text-green-800';
case 'APPROVED':
return 'bg-green-100 text-green-800';
case 'REJECTED':
return 'bg-red-100 text-red-800';
case 'INACTIVE':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
}
private loadPendingUsers(): void {
this.isLoading.set(true);
this.errorMessage.set('');
this.adminService.getPendingUsers()
this.adminService
.getPendingUsers()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (users: UserProfileResponse[]) => {
@ -38,7 +81,26 @@ export class AdminUserApprovalComponent implements OnInit {
error: () => {
this.errorMessage.set('Fehler beim Laden der Daten aus dem EDA-Netz-Check.');
this.isLoading.set(false);
}
},
});
}
private loadAllUsers(): void {
this.isLoading.set(true);
this.errorMessage.set('');
this.adminService
.getAllUsers()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (users: UserProfileResponse[]) => {
this.allUsers.set(users);
this.isLoading.set(false);
},
error: () => {
this.errorMessage.set('Fehler beim Laden aller Benutzer.');
this.isLoading.set(false);
},
});
}
@ -46,29 +108,34 @@ export class AdminUserApprovalComponent implements OnInit {
if (!userId) return;
this.errorMessage.set('');
this.adminService.approveUser(userId)
this.adminService
.approveUser(userId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.pendingUsers.update(users => users.filter(user => user.id !== userId));
this.pendingUsers.update((users) => users.filter((user) => user.id !== userId));
},
error: () => {
this.errorMessage.set('Der User konnte nicht freigegeben werden.');
}
},
});
}
public rejectUser(user: UserProfileResponse): void {
if (!user.id) return;
if (confirm(`Möchtest du die Registrierung von ${user.firstName} ${user.lastName} wirklich ablehnen?`)) {
if (
confirm(
`Möchtest du die Registrierung von ${user.firstName} ${user.lastName} wirklich ablehnen?`,
)
) {
this.adminService.rejectUser(user.id).subscribe({
next: () => {
this.pendingUsers.update(users => users.filter(u => u.id !== user.id));
this.pendingUsers.update((users) => users.filter((u) => u.id !== user.id));
},
error: (err) => {
this.toastService.error(err.error?.message || 'Der User konnte nicht abgelehnt werden.');
}
},
});
}
}