diff --git a/apps/web/src/app/(portal)/admin/users/page.tsx b/apps/web/src/app/(portal)/admin/users/page.tsx
index 5a24f08..f3440f0 100644
--- a/apps/web/src/app/(portal)/admin/users/page.tsx
+++ b/apps/web/src/app/(portal)/admin/users/page.tsx
@@ -204,6 +204,14 @@ export default function AdminUsersPage() {
);
}
+ // Spiegelt den Zielrollen-Riegel aus `apps/api/src/user/user.controller.ts`
+ // (update/remove, WINDOWS #29): eine Zeile ist gesperrt, wenn ihre Person
+ // SUPER_ADMIN ist und die angemeldete Person es nicht ist. Rein
+ // ergonomisch — die Serverpruefung bleibt die einzige wirksame Grenze
+ // (D-04, T-A1D-02).
+ const canManageRow = (user: User) =>
+ !(user.role === 'SUPER_ADMIN' && currentUser?.role !== 'SUPER_ADMIN');
+
const roleBadgeClass = (role: string) => {
switch (role) {
case 'SUPER_ADMIN':
@@ -305,22 +313,26 @@ export default function AdminUsersPage() {
>
{t('grants.detailsButton')}
-
-
+ {canManageRow(user) && (
+
+ )}
+ {canManageRow(user) && (
+
+ )}
diff --git a/apps/web/src/app/(portal)/admin/users/users-page.test.tsx b/apps/web/src/app/(portal)/admin/users/users-page.test.tsx
index 2e314ce..1ad2961 100644
--- a/apps/web/src/app/(portal)/admin/users/users-page.test.tsx
+++ b/apps/web/src/app/(portal)/admin/users/users-page.test.tsx
@@ -1,4 +1,4 @@
-import { cleanup, render, screen, waitFor } from '@testing-library/react';
+import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -118,13 +118,46 @@ const mockUsers: User[] = [
},
];
-function stubAdmin(id = 'u1') {
+function stubAdmin(id = 'u1', role = 'ADMIN') {
mockAuthStore.mockImplementation(
(selector: (state: { user: { id: string; role: string; tenantId: string } }) => unknown) =>
- selector({ user: { id, role: 'ADMIN', tenantId: 't1' } }),
+ selector({ user: { id, role, tenantId: 't1' } }),
);
}
+const mockUsersWithSuperAdmin: User[] = [
+ {
+ id: 'u0',
+ username: 'super.null',
+ email: 'super.null@ctl.de',
+ displayName: 'Super Null',
+ role: 'SUPER_ADMIN',
+ isActive: true,
+ tenantId: 't1',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ },
+ {
+ id: 'u1',
+ username: 'admin.eins',
+ email: 'admin.eins@ctl.de',
+ displayName: 'Admin Eins',
+ role: 'ADMIN',
+ isActive: true,
+ tenantId: 't1',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ },
+ {
+ id: 'u2',
+ username: 'user.zwei',
+ email: 'user.zwei@ctl.de',
+ displayName: 'User Zwei',
+ role: 'USER',
+ isActive: true,
+ tenantId: 't1',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ },
+];
+
afterEach(() => {
cleanup();
vi.restoreAllMocks();
@@ -471,3 +504,63 @@ describe('AdminUsersPage — Formularweg und Listenladen (WINDOWS #36, Aufgabe 2
).not.toBeInTheDocument();
});
});
+
+describe('AdminUsersPage — Aktionsknoepfe der SUPER_ADMIN-Zeile (WINDOWS #36, Aufgabe 3)', () => {
+ function stubList() {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockUsersWithSuperAdmin) })),
+ );
+ }
+
+ it('bietet einem ADMIN in der SUPER_ADMIN-Zeile weder Bearbeiten noch Loeschen an, in einer normalen Zeile beide', async () => {
+ stubAdmin('u1', 'ADMIN');
+ stubList();
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('super.null')).toBeInTheDocument();
+ });
+
+ const superAdminRow = screen.getByText('super.null').closest('tr') as HTMLElement;
+ expect(within(superAdminRow).getByText('Details')).toBeInTheDocument();
+ expect(within(superAdminRow).queryByText('Bearbeiten')).not.toBeInTheDocument();
+ expect(within(superAdminRow).queryByText('Löschen')).not.toBeInTheDocument();
+
+ const normalRow = screen.getByText('user.zwei').closest('tr') as HTMLElement;
+ expect(within(normalRow).getByText('Bearbeiten')).toBeInTheDocument();
+ expect(within(normalRow).getByText('Löschen')).toBeInTheDocument();
+ });
+
+ it('bietet einem SUPER_ADMIN in der SUPER_ADMIN-Zeile beide Aktionsknoepfe an', async () => {
+ stubAdmin('u1', 'SUPER_ADMIN');
+ stubList();
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('super.null')).toBeInTheDocument();
+ });
+
+ const superAdminRow = screen.getByText('super.null').closest('tr') as HTMLElement;
+ expect(within(superAdminRow).getByText('Bearbeiten')).toBeInTheDocument();
+ expect(within(superAdminRow).getByText('Löschen')).toBeInTheDocument();
+ });
+
+ it('haelt die bestehende Sperre gegen Selbstloeschung unveraendert (ADMIN u1)', async () => {
+ stubAdmin('u1', 'ADMIN');
+ stubList();
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('admin.eins')).toBeInTheDocument();
+ });
+
+ const ownRow = screen.getByText('admin.eins').closest('tr') as HTMLElement;
+ const ownDeleteButton = within(ownRow).getByText('Löschen');
+ expect(ownDeleteButton).toBeInTheDocument();
+ expect(ownDeleteButton).toBeDisabled();
+ });
+});