This commit is contained in:
Bastian Wagner
2026-07-16 15:23:53 +02:00
parent 543e8273a7
commit c03b2e17f5
114 changed files with 7631 additions and 506 deletions

View File

@@ -0,0 +1,270 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import {
ApiClientService,
type AdminUserListItemDto,
type ApiErrorBody,
type RoleDto,
} from '@boilerplate/api-client';
type ActiveFilter = 'all' | 'active' | 'inactive';
type UserSort = 'name' | 'email' | 'lastLoginAt' | 'createdAt';
@Component({
standalone: true,
imports: [FormsModule, RouterLink],
template: `
<form class="toolbar" (ngSubmit)="load()">
<label>
Suche
<input name="search" [(ngModel)]="search" placeholder="Name oder E-Mail" />
</label>
<label>
Status
<select name="active" [(ngModel)]="active">
<option value="all">Alle</option>
<option value="active">Aktiv</option>
<option value="inactive">Deaktiviert</option>
</select>
</label>
<label>
Rolle
<select name="roleId" [(ngModel)]="roleId">
<option value="">Alle Rollen</option>
@for (role of roles(); track role.id) {
<option [value]="role.id">{{ role.name }}</option>
}
</select>
</label>
<label>
Sortierung
<select name="sort" [(ngModel)]="sort">
<option value="name">Name</option>
<option value="email">E-Mail</option>
<option value="lastLoginAt">Letzter Login</option>
<option value="createdAt">Erstellt</option>
</select>
</label>
<button type="submit">Suchen</button>
</form>
@if (error(); as currentError) {
<section class="notice error">
<strong>{{ currentError.message }}</strong>
@if (currentError.requestId) {
<small>Request-ID: {{ currentError.requestId }}</small>
}
</section>
}
@if (loading()) {
<section class="notice">Benutzer werden geladen.</section>
} @else if (users().length === 0) {
<section class="notice">Keine Benutzer gefunden.</section>
} @else {
<section class="list">
@for (user of users(); track user.id) {
<article [class.inactive]="!user.active">
<div class="summary">
<strong>{{ user.name }}</strong>
<span>{{ user.email || 'Keine E-Mail' }}</span>
<span>{{ user.active ? 'Aktiv' : 'Deaktiviert' }}</span>
</div>
<div class="chips" aria-label="Rollen">
@for (role of user.roles; track role.id) {
<span>{{ role.name }}</span>
}
</div>
<dl>
<div>
<dt>Sessions</dt>
<dd>{{ user.activeSessionCount }}</dd>
</div>
<div>
<dt>Letzter Login</dt>
<dd>{{ user.lastLoginAt || 'nie' }}</dd>
</div>
</dl>
<a class="button" [routerLink]="['/admin/users', user.id]">Details</a>
</article>
}
</section>
<nav class="pagination" aria-label="Seitennavigation">
<button type="button" [disabled]="page <= 1" (click)="previous()">Zurueck</button>
<span>Seite {{ page }} von {{ totalPages() }}</span>
<button type="button" [disabled]="page >= totalPages()" (click)="next()">Weiter</button>
</nav>
}
`,
styles: [
`
.toolbar {
display: grid;
gap: 12px;
margin-bottom: 16px;
}
label,
.summary,
dl {
display: grid;
gap: 6px;
}
input,
select,
button,
.button {
min-height: 44px;
}
input,
select {
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0 10px;
}
button,
.button {
border: 0;
border-radius: 6px;
background: var(--color-primary);
color: var(--color-surface);
padding: 0 14px;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
.notice,
article {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 16px;
}
.notice.error {
border-color: var(--color-danger);
color: var(--color-danger);
}
.list {
display: grid;
gap: 12px;
}
article {
display: grid;
gap: 12px;
}
article.inactive {
border-left: 4px solid var(--color-border-strong);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chips span {
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 4px 8px;
background: var(--color-background);
}
dl {
margin: 0;
}
dt {
color: var(--color-text-muted);
font-size: 0.88rem;
}
dd {
margin: 0;
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 16px;
}
@media (min-width: 900px) {
.toolbar {
grid-template-columns: 2fr 1fr 1fr 1fr auto;
align-items: end;
}
article {
grid-template-columns: 1.4fr 1fr 1.2fr auto;
align-items: center;
}
}
`,
],
})
export class AdminUsersPageComponent {
private readonly api = inject(ApiClientService);
readonly users = signal<AdminUserListItemDto[]>([]);
readonly roles = signal<RoleDto[]>([]);
readonly loading = signal(false);
readonly error = signal<ApiErrorBody | null>(null);
search = '';
active: ActiveFilter = 'all';
roleId = '';
sort: UserSort = 'name';
page = 1;
pageSize = 25;
total = 0;
constructor() {
this.api.adminRoles().subscribe((roles) => this.roles.set(roles));
this.load();
}
load(): void {
this.loading.set(true);
this.error.set(null);
this.api
.adminUsers({
search: this.search,
active: this.active,
roleId: this.roleId,
sort: this.sort,
page: this.page,
pageSize: this.pageSize,
})
.subscribe({
next: (page) => {
this.users.set(page.items);
this.total = page.total;
this.page = page.page;
this.pageSize = page.pageSize;
this.loading.set(false);
},
error: (error: { error?: ApiErrorBody }) => {
this.error.set(error.error ?? this.genericError());
this.loading.set(false);
},
});
}
previous(): void {
this.page -= 1;
this.load();
}
next(): void {
this.page += 1;
this.load();
}
totalPages(): number {
return Math.max(1, Math.ceil(this.total / this.pageSize));
}
private genericError(): ApiErrorBody {
return {
status: 0,
code: 'UNKNOWN',
message: 'Benutzer konnten nicht geladen werden.',
requestId: '',
};
}
}