From 13b70dfbe8edbd7def83169089d535d28e55b2b7 Mon Sep 17 00:00:00 2001 From: Schalli Date: Mon, 21 Sep 2026 07:33:48 +0200 Subject: [PATCH] fix(web): SUPER_ADMIN-Zeile bietet einem ADMIN keine Aktionsknoepfe mehr an MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WINDOWS #36, Aufgabe 3/3: canManageRow spiegelt den Zielrollen-Riegel aus apps/api/src/user/user.controller.ts (update/remove, WINDOWS #29) rein ergonomisch — die Serverpruefung bleibt unveraendert und ist die einzige wirksame Grenze. Bearbeiten und Loeschen entfallen jetzt in der Zeile eines SUPER_ADMIN, wenn die angemeldete Person selbst keiner ist; Details bleibt in jeder Zeile. Die Sperre gegen Selbstloeschung bleibt unveraendert. Gesamtbestand apps/web: 66 Dateien / 459 Tests gruen, type-check Exit 0, lint 5/5 erfolgreich, apps/api unangetastet. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J --- .../web/src/app/(portal)/admin/users/page.tsx | 44 ++++++--- .../(portal)/admin/users/users-page.test.tsx | 99 ++++++++++++++++++- 2 files changed, 124 insertions(+), 19 deletions(-) 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(); + }); +});