Files
boilerplate/apps/frontend/src/app/features/admin/admin-role-detail.page.ts
Bastian Wagner c03b2e17f5 bump
2026-07-16 15:23:53 +02:00

275 lines
7.6 KiB
TypeScript

import { Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import {
ApiClientService,
type ApiErrorBody,
type Permission,
type RoleDto,
} from '@boilerplate/api-client';
import { adminPermissionGroups } from './admin-permissions';
@Component({
standalone: true,
imports: [FormsModule, RouterLink],
template: `
<a routerLink="/admin/roles">Zurueck zur Rollenliste</a>
@if (error(); as currentError) {
<section class="notice error">
<strong>{{ messageFor(currentError) }}</strong>
@if (currentError.requestId) {
<small>Request-ID: {{ currentError.requestId }}</small>
}
</section>
}
@if (!loading()) {
<form class="editor" (ngSubmit)="save()">
<section>
<h2>{{ isNew() ? 'Rolle anlegen' : 'Rolle bearbeiten' }}</h2>
@if (role()?.system) {
<span class="system">Systemrolle</span>
}
<label>
Name
<input
name="name"
[(ngModel)]="name"
[readonly]="role()?.system"
required
maxlength="80"
/>
</label>
<label>
Beschreibung
<textarea name="description" [(ngModel)]="description" maxlength="255"></textarea>
</label>
</section>
<section>
<h3>Permissions</h3>
<div class="groups">
@for (group of groups; track group.title) {
<fieldset>
<legend>{{ group.title }}</legend>
@for (permission of group.permissions; track permission.id) {
<label>
<input
type="checkbox"
[checked]="selectedPermissions().has(permission.id)"
[disabled]="role()?.name === 'admin'"
(change)="toggle(permission.id)"
/>
<span>
<code>{{ permission.id }}</code>
{{ permission.label }}
</span>
</label>
}
</fieldset>
}
</div>
</section>
<div class="actions">
<button type="submit" [disabled]="!name.trim()">Speichern</button>
@if (role(); as currentRole) {
@if (!currentRole.system) {
<button type="button" class="danger" (click)="delete(currentRole)">Loeschen</button>
}
}
</div>
</form>
} @else {
<section class="notice">Rolle wird geladen.</section>
}
`,
styles: [
`
a {
display: inline-flex;
margin-bottom: 16px;
color: var(--color-primary-hover);
}
.notice,
.editor {
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);
margin-bottom: 16px;
}
.editor,
section,
.groups,
fieldset,
.actions {
display: grid;
gap: 14px;
}
h2,
h3 {
margin: 0;
}
label {
display: grid;
gap: 6px;
}
input,
textarea,
button {
min-height: 44px;
}
input,
textarea {
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 8px 10px;
}
textarea {
min-height: 96px;
resize: vertical;
}
button {
border: 0;
border-radius: 6px;
background: var(--color-primary);
color: var(--color-surface);
padding: 0 14px;
}
button.danger {
background: var(--color-danger);
}
button:disabled {
opacity: 0.55;
}
fieldset {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 12px;
}
fieldset label {
grid-template-columns: 24px 1fr;
align-items: start;
}
code {
display: block;
margin-bottom: 2px;
}
.system {
width: fit-content;
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 4px 8px;
color: var(--color-text-secondary);
}
@media (min-width: 900px) {
.groups {
grid-template-columns: 1fr 1fr;
}
.actions {
grid-template-columns: auto auto;
justify-content: start;
}
}
`,
],
})
export class AdminRoleDetailPageComponent {
private readonly api = inject(ApiClientService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
readonly role = signal<RoleDto | null>(null);
readonly loading = signal(false);
readonly error = signal<ApiErrorBody | null>(null);
readonly selectedPermissions = signal(new Set<Permission>());
readonly groups = adminPermissionGroups;
readonly isNew = computed(() => this.route.snapshot.paramMap.get('id') === 'new');
name = '';
description = '';
constructor() {
if (!this.isNew()) this.load();
}
load(): void {
const id = this.route.snapshot.paramMap.get('id');
if (!id) return;
this.loading.set(true);
this.error.set(null);
this.api.adminRole(id).subscribe({
next: (role) => {
this.role.set(role);
this.name = role.name;
this.description = role.description;
this.selectedPermissions.set(new Set(role.permissions.map((permission) => permission.id)));
this.loading.set(false);
},
error: (error: { error?: ApiErrorBody }) => {
this.error.set(error.error ?? this.genericError());
this.loading.set(false);
},
});
}
toggle(permission: Permission): void {
if (this.role()?.name === 'admin') return;
const next = new Set(this.selectedPermissions());
if (next.has(permission)) next.delete(permission);
else next.add(permission);
this.selectedPermissions.set(next);
}
save(): void {
const body = {
name: this.name,
description: this.description,
permissions: Array.from(this.selectedPermissions()),
};
const request = this.isNew()
? this.api.createAdminRole(body)
: this.api.updateAdminRole(this.role()?.id ?? '', body);
request.subscribe({
next: (role) => {
void this.router.navigate(['/admin/roles', role.id]);
},
error: (error: { error?: ApiErrorBody }) =>
this.error.set(error.error ?? this.genericError()),
});
}
delete(role: RoleDto): void {
if (!confirm('Rolle wirklich loeschen?')) return;
this.api.deleteAdminRole(role.id).subscribe({
next: () => {
void this.router.navigate(['/admin/roles']);
},
error: (error: { error?: ApiErrorBody }) =>
this.error.set(error.error ?? this.genericError()),
});
}
messageFor(error: ApiErrorBody): string {
if (error.code === 'ROLE_STILL_ASSIGNED') return 'Diese Rolle ist noch Benutzern zugewiesen.';
if (error.code === 'SYSTEM_ROLE_PROTECTED') return 'Diese Systemrolle ist geschuetzt.';
if (error.code === 'LAST_ACTIVE_ADMIN_REQUIRED') {
return 'Mindestens ein aktiver Administrator muss erhalten bleiben.';
}
return error.message;
}
private genericError(): ApiErrorBody {
return {
status: 0,
code: 'UNKNOWN',
message: 'Die Rolle konnte nicht verarbeitet werden.',
requestId: '',
};
}
}