fix(web): SUPER_ADMIN-Zeile bietet einem ADMIN keine Aktionsknoepfe mehr an
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
@@ -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) => {
|
const roleBadgeClass = (role: string) => {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case 'SUPER_ADMIN':
|
case 'SUPER_ADMIN':
|
||||||
@@ -305,22 +313,26 @@ export default function AdminUsersPage() {
|
|||||||
>
|
>
|
||||||
{t('grants.detailsButton')}
|
{t('grants.detailsButton')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
{canManageRow(user) && (
|
||||||
onClick={() => openEdit(user)}
|
<button
|
||||||
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
|
onClick={() => openEdit(user)}
|
||||||
>
|
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
|
||||||
{tCommon('edit')}
|
>
|
||||||
</button>
|
{tCommon('edit')}
|
||||||
<button
|
</button>
|
||||||
onClick={() => {
|
)}
|
||||||
setDeleteConfirm(user.id);
|
{canManageRow(user) && (
|
||||||
setDeleteError(null);
|
<button
|
||||||
}}
|
onClick={() => {
|
||||||
disabled={user.id === currentUser?.id}
|
setDeleteConfirm(user.id);
|
||||||
className="rounded px-2 py-1 text-xs text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none"
|
setDeleteError(null);
|
||||||
>
|
}}
|
||||||
{tCommon('delete')}
|
disabled={user.id === currentUser?.id}
|
||||||
</button>
|
className="rounded px-2 py-1 text-xs text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
{tCommon('delete')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -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 userEvent from '@testing-library/user-event';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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(
|
mockAuthStore.mockImplementation(
|
||||||
(selector: (state: { user: { id: string; role: string; tenantId: string } }) => unknown) =>
|
(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(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@@ -471,3 +504,63 @@ describe('AdminUsersPage — Formularweg und Listenladen (WINDOWS #36, Aufgabe 2
|
|||||||
).not.toBeInTheDocument();
|
).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(<AdminUsersPage />);
|
||||||
|
|
||||||
|
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(<AdminUsersPage />);
|
||||||
|
|
||||||
|
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(<AdminUsersPage />);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user