This commit is contained in:
Bastian Wagner
2026-07-20 17:01:16 +02:00
parent 2f84a109e8
commit aa7758c9dd
38 changed files with 1129 additions and 134 deletions

View File

@@ -3,6 +3,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { ApiClientService } from '@boilerplate/api-client';
import { AdminRoleDetailPageComponent } from './admin-role-detail.page';
import { adminPermissionGroups } from './admin-permissions';
import { of } from 'rxjs';
describe('AdminRoleDetailPageComponent', () => {
it('groups permissions for the role editor', async () => {
@@ -31,4 +32,41 @@ describe('AdminRoleDetailPageComponent', () => {
expect(element.textContent).toContain('Benutzer aktivieren');
expect(element.textContent).toContain('notifications.manage');
});
it('shows the environment-managed OIDC mapping for the system admin role', async () => {
await TestBed.configureTestingModule({
imports: [AdminRoleDetailPageComponent],
providers: [
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: { get: () => 'admin-role' } } },
},
{ provide: Router, useValue: { navigate: vi.fn() } },
{
provide: ApiClientService,
useValue: {
adminRole: () =>
of({
id: 'admin-role',
name: 'admin',
description: 'Administrator',
system: true,
protected: true,
roleKey: 'ADMIN',
oidcManaged: true,
oidcRoleName: 'hauspilot-admin',
permissions: [],
}),
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(AdminRoleDetailPageComponent);
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(host.textContent).toContain('Automatische OIDC-Verwaltung');
expect(host.textContent).toContain('hauspilot-admin');
expect(host.textContent).toContain('OIDC_ADMIN_ROLE');
});
});

View File

@@ -31,6 +31,19 @@ import { adminPermissionGroups } from './admin-permissions';
@if (role()?.system) {
<span class="system">Systemrolle</span>
}
@if (role()?.roleKey === 'ADMIN'; as isAdminRole) {
<aside class="oidc-notice">
<strong>Automatische OIDC-Verwaltung</strong>
@if (role()?.oidcManaged) {
<span
>OIDC-Rolle: <code>{{ role()?.oidcRoleName }}</code></span
>
<small>Quelle: Umgebungsvariable OIDC_ADMIN_ROLE</small>
} @else {
<span>Die automatische Administrator-Synchronisierung ist nicht konfiguriert.</span>
}
</aside>
}
<label>
Name
<input
@@ -168,6 +181,14 @@ import { adminPermissionGroups } from './admin-permissions';
padding: 4px 8px;
color: var(--color-text-secondary);
}
.oidc-notice {
display: grid;
gap: 6px;
padding: 12px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-background);
}
@media (min-width: 900px) {
.groups {
grid-template-columns: 1fr 1fr;

View File

@@ -0,0 +1,50 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { ApiClientService, type AdminUserDetailDto, type RoleDto } from '@boilerplate/api-client';
import { of } from 'rxjs';
import { AdminUserDetailPageComponent } from './admin-user-detail.page';
describe('AdminUserDetailPageComponent', () => {
it('shows OIDC role provenance and does not offer local removal without a manual source', async () => {
const adminRole = {
id: 'admin-role',
name: 'admin',
description: 'Administrator',
system: true,
protected: true,
roleKey: 'ADMIN',
oidcManaged: true,
oidcRoleName: 'hauspilot-admin',
permissions: [],
} satisfies RoleDto;
const user = {
id: 'user-1',
name: 'Bastian',
email: 'admin@example.test',
active: true,
roles: [{ id: adminRole.id, name: 'admin', system: true, sources: ['OIDC'] }],
lastLoginAt: '2026-07-20',
createdAt: '2026-07-20',
activeSessionCount: 0,
effectivePermissions: [],
sessions: [],
} satisfies AdminUserDetailDto;
await TestBed.configureTestingModule({
imports: [AdminUserDetailPageComponent],
providers: [
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => user.id } } } },
{
provide: ApiClientService,
useValue: { adminRoles: () => of([adminRole]), adminUser: () => of(user) },
},
],
}).compileComponents();
const fixture = TestBed.createComponent(AdminUserDetailPageComponent);
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(host.textContent).toContain('Herkunft: OIDC');
expect(host.textContent).toContain('Entfernen Sie die externe Rolle im Identity Provider');
expect(host.textContent).not.toContain('Manuelle Zuweisung entfernen');
});
});

View File

@@ -50,11 +50,20 @@ import {
<h3>Rollen</h3>
<div class="chips">
@for (role of currentUser.roles; track role.id) {
<span>
{{ role.name }}
<button type="button" (click)="removeRole(currentUser.id, role.id, role.name)">
Entfernen
</button>
<span class="role-chip">
<span>
<strong>{{ role.name }}</strong>
<small>Herkunft: {{ roleSourceLabel(role.sources) }}</small>
</span>
@if (role.sources.includes('MANUAL')) {
<button type="button" (click)="removeRole(currentUser.id, role.id, role.name)">
Manuelle Zuweisung entfernen
</button>
} @else if (role.sources.includes('OIDC')) {
<small class="managed-hint">
Automatisch verwaltet. Entfernen Sie die externe Rolle im Identity Provider.
</small>
}
</span>
}
</div>
@@ -203,6 +212,13 @@ import {
border-radius: 999px;
padding: 4px 6px 4px 10px;
}
.role-chip > span {
display: grid;
}
.managed-hint {
max-width: 20rem;
color: var(--color-text-muted);
}
.chips button {
min-height: 34px;
background: var(--color-danger);
@@ -323,8 +339,15 @@ export class AdminUserDetailPageComponent {
}
availableRoles(user: AdminUserDetailDto): RoleDto[] {
const assigned = new Set(user.roles.map((role) => role.id));
return this.roles().filter((role) => !assigned.has(role.id));
const manuallyAssigned = new Set(
user.roles.filter((role) => role.sources.includes('MANUAL')).map((role) => role.id),
);
return this.roles().filter((role) => !manuallyAssigned.has(role.id));
}
roleSourceLabel(sources: AdminUserDetailDto['roles'][number]['sources']): string {
const labels = { MANUAL: 'Manuell', OIDC: 'OIDC', SYSTEM: 'System' } as const;
return sources.map((source) => labels[source]).join(' + ');
}
messageFor(error: ApiErrorBody): string {