fix(web): Loeschweg der Benutzerverwaltung meldet abgewiesene Server-Antworten
WINDOWS #36, Aufgabe 1/3: handleDelete verschluckte einen 403 bisher komplett (nur res.ok geprueft, Fang-Zweig ohne Wirkung). readApiMessage liest jetzt gezielt das Feld message aus dem Antwortrumpf; der Loeschdialog zeigt den Servertext, eine uebersetzte Ersatzmeldung ohne verwertbaren Rumpf oder bei Verbindungsfehler — und bleibt in allen drei Faellen offen. Neue Texte unter admin.users.errors in de.json/en.json, Umlaut-Waechter gruen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
@@ -29,6 +29,26 @@ interface UserFormData {
|
|||||||
role: 'SUPER_ADMIN' | 'ADMIN' | 'USER';
|
role: 'SUPER_ADMIN' | 'ADMIN' | 'USER';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the backend's error message from a non-2xx JSON error body
|
||||||
|
* (Nest's default exception filter shape: `{ statusCode, message, error }`).
|
||||||
|
* Bewusst lokal kopiert statt aus `apps/web/src/lib/tender-radar-api.ts`
|
||||||
|
* importiert (jene Datei gehoert zum Modul Ausschreibungs-Radar) — nur das
|
||||||
|
* Feld `message` wird gelesen, nie der ganze Rumpf (T-A1D-01).
|
||||||
|
*/
|
||||||
|
async function readApiMessage(res: Response): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const body = (await res.json()) as { message?: unknown };
|
||||||
|
if (typeof body.message === 'string' && body.message) return body.message;
|
||||||
|
if (Array.isArray(body.message) && body.message.length) {
|
||||||
|
return body.message.join(', ');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* Rumpf war kein JSON — Ersatzmeldung greift beim Aufrufer */
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin users page -- User CRUD management (D-12).
|
* Admin users page -- User CRUD management (D-12).
|
||||||
* ADMIN and SUPER_ADMIN can access. ADMIN sees only own-tenant users.
|
* ADMIN and SUPER_ADMIN can access. ADMIN sees only own-tenant users.
|
||||||
@@ -44,6 +64,7 @@ export default function AdminUsersPage() {
|
|||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
const [detailsUser, setDetailsUser] = useState<User | null>(null);
|
const [detailsUser, setDetailsUser] = useState<User | null>(null);
|
||||||
const [formData, setFormData] = useState<UserFormData>({
|
const [formData, setFormData] = useState<UserFormData>({
|
||||||
username: '',
|
username: '',
|
||||||
@@ -142,6 +163,7 @@ export default function AdminUsersPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
|
setDeleteError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/users/${id}`, {
|
const res = await fetch(`${API_URL}/users/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -150,9 +172,14 @@ export default function AdminUsersPage() {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setDeleteConfirm(null);
|
setDeleteConfirm(null);
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
|
} else {
|
||||||
|
const detail = await readApiMessage(res);
|
||||||
|
setDeleteError(
|
||||||
|
detail ? t('errors.serverRejected', { detail }) : t('errors.generic'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// silently fail
|
setDeleteError(t('errors.network'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -263,7 +290,10 @@ export default function AdminUsersPage() {
|
|||||||
{tCommon('edit')}
|
{tCommon('edit')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteConfirm(user.id)}
|
onClick={() => {
|
||||||
|
setDeleteConfirm(user.id);
|
||||||
|
setDeleteError(null);
|
||||||
|
}}
|
||||||
disabled={user.id === currentUser?.id}
|
disabled={user.id === currentUser?.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"
|
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"
|
||||||
>
|
>
|
||||||
@@ -397,6 +427,14 @@ export default function AdminUsersPage() {
|
|||||||
<p className="text-sm text-foreground mb-4">
|
<p className="text-sm text-foreground mb-4">
|
||||||
{t('deleteConfirm')}
|
{t('deleteConfirm')}
|
||||||
</p>
|
</p>
|
||||||
|
{deleteError && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive mb-4"
|
||||||
|
>
|
||||||
|
{deleteError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteConfirm(null)}
|
onClick={() => setDeleteConfirm(null)}
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Namespace-aware next-intl mock — same convention as
|
||||||
|
// admin/groups/groups-page.test.tsx and admin/users/user-access-modal.test.tsx.
|
||||||
|
// {param} placeholders are substituted plainly.
|
||||||
|
const messages: Record<string, Record<string, unknown>> = {
|
||||||
|
'admin.users': {
|
||||||
|
title: 'Benutzerverwaltung',
|
||||||
|
create: 'Benutzer erstellen',
|
||||||
|
edit: 'Benutzer bearbeiten',
|
||||||
|
delete: 'Benutzer löschen',
|
||||||
|
deleteConfirm: 'Möchten Sie diesen Benutzer wirklich löschen?',
|
||||||
|
name: 'Name',
|
||||||
|
username: 'Benutzername',
|
||||||
|
email: 'E-Mail',
|
||||||
|
displayName: 'Anzeigename',
|
||||||
|
role: 'Rolle',
|
||||||
|
status: 'Status',
|
||||||
|
actions: 'Aktionen',
|
||||||
|
password: 'Passwort',
|
||||||
|
noUsers: 'Keine Benutzer gefunden',
|
||||||
|
grants: {
|
||||||
|
detailsButton: 'Details',
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
serverRejected: 'Der Server hat die Aktion abgelehnt: {detail}',
|
||||||
|
generic: 'Die Aktion konnte nicht durchgeführt werden. Bitte erneut versuchen.',
|
||||||
|
network: 'Der Server ist nicht erreichbar. Bitte erneut versuchen.',
|
||||||
|
loadFailed: 'Die Benutzerliste konnte nicht geladen werden. Bitte laden Sie die Seite neu.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
role: {
|
||||||
|
SUPER_ADMIN: 'Super-Admin',
|
||||||
|
ADMIN: 'Admin',
|
||||||
|
USER: 'Benutzer',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
loading: 'Laden...',
|
||||||
|
cancel: 'Abbrechen',
|
||||||
|
save: 'Speichern',
|
||||||
|
delete: 'Löschen',
|
||||||
|
edit: 'Bearbeiten',
|
||||||
|
accessDenied: 'Zugriff verweigert',
|
||||||
|
active: 'Aktiv',
|
||||||
|
inactive: 'Inaktiv',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolve(ns: string, key: string, params?: Record<string, unknown>): string {
|
||||||
|
const parts = key.split('.');
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let val: any = messages[ns] ?? {};
|
||||||
|
for (const part of parts) {
|
||||||
|
val = val?.[part];
|
||||||
|
}
|
||||||
|
if (typeof val !== 'string') return key;
|
||||||
|
if (params) {
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
val = val.replace(`{${k}}`, String(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) =>
|
||||||
|
resolve(ns, key, params),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockAuthStore = vi.fn();
|
||||||
|
vi.mock('@/lib/stores/auth-store', () => ({
|
||||||
|
useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// UserAccessModal is not exercised by these tests — stub it out so a click
|
||||||
|
// on "Details" doesn't need its own fetch fixtures.
|
||||||
|
vi.mock('./components/UserAccessModal', () => ({
|
||||||
|
UserAccessModal: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import AdminUsersPage from './page';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string | null;
|
||||||
|
displayName: string | null;
|
||||||
|
role: 'SUPER_ADMIN' | 'ADMIN' | 'USER';
|
||||||
|
isActive: boolean;
|
||||||
|
tenantId: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockUsers: User[] = [
|
||||||
|
{
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function stubAdmin(id = 'u1') {
|
||||||
|
mockAuthStore.mockImplementation(
|
||||||
|
(selector: (state: { user: { id: string; role: string; tenantId: string } }) => unknown) =>
|
||||||
|
selector({ user: { id, role: 'ADMIN', tenantId: 't1' } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AdminUsersPage — Loeschweg (WINDOWS #36, Aufgabe 1)', () => {
|
||||||
|
it('zeigt den Servertext im offenen Loeschdialog bei einer 403-Ablehnung', async () => {
|
||||||
|
stubAdmin();
|
||||||
|
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
|
||||||
|
if (typeof url === 'string' && url.endsWith('/users') && (!init || init.method === undefined)) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockUsers) });
|
||||||
|
}
|
||||||
|
if (typeof url === 'string' && init?.method === 'DELETE') {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({ statusCode: 403, message: 'Cannot delete a SUPER_ADMIN user' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
render(<AdminUsersPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('user.zwei')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteButtons = screen.getAllByText('Löschen');
|
||||||
|
await userEvent.click(deleteButtons[deleteButtons.length - 1]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Möchten Sie diesen Benutzer wirklich löschen?')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmButtons = screen.getAllByText('Löschen');
|
||||||
|
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText('Der Server hat die Aktion abgelehnt: Cannot delete a SUPER_ADMIN user'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
// Dialog bleibt offen.
|
||||||
|
expect(screen.getByText('Möchten Sie diesen Benutzer wirklich löschen?')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt die Ersatzmeldung, wenn die Antwort keinen verwertbaren Rumpf traegt', async () => {
|
||||||
|
stubAdmin();
|
||||||
|
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
|
||||||
|
if (typeof url === 'string' && url.endsWith('/users') && (!init || init.method === undefined)) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockUsers) });
|
||||||
|
}
|
||||||
|
if (typeof url === 'string' && init?.method === 'DELETE') {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: () => Promise.reject(new Error('not json')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
render(<AdminUsersPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('user.zwei')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteButtons = screen.getAllByText('Löschen');
|
||||||
|
await userEvent.click(deleteButtons[deleteButtons.length - 1]);
|
||||||
|
const confirmButtons = screen.getAllByText('Löschen');
|
||||||
|
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText('Die Aktion konnte nicht durchgeführt werden. Bitte erneut versuchen.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.queryByText(/Der Server hat die Aktion abgelehnt/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt die Netzmeldung, wenn die Verbindung beim Loeschen scheitert', async () => {
|
||||||
|
stubAdmin();
|
||||||
|
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
|
||||||
|
if (typeof url === 'string' && url.endsWith('/users') && (!init || init.method === undefined)) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockUsers) });
|
||||||
|
}
|
||||||
|
if (typeof url === 'string' && init?.method === 'DELETE') {
|
||||||
|
return Promise.reject(new Error('network down'));
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
render(<AdminUsersPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('user.zwei')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteButtons = screen.getAllByText('Löschen');
|
||||||
|
await userEvent.click(deleteButtons[deleteButtons.length - 1]);
|
||||||
|
const confirmButtons = screen.getAllByText('Löschen');
|
||||||
|
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText('Der Server ist nicht erreichbar. Bitte erneut versuchen.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schliesst den Dialog und laedt die Liste neu, wenn das Loeschen gelingt (Erfolgsfall unveraendert)', async () => {
|
||||||
|
stubAdmin();
|
||||||
|
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
|
||||||
|
if (typeof url === 'string' && url.endsWith('/users') && (!init || init.method === undefined)) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockUsers) });
|
||||||
|
}
|
||||||
|
if (typeof url === 'string' && init?.method === 'DELETE') {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
render(<AdminUsersPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('user.zwei')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteButtons = screen.getAllByText('Löschen');
|
||||||
|
await userEvent.click(deleteButtons[deleteButtons.length - 1]);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Möchten Sie diesen Benutzer wirklich löschen?')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
const confirmButtons = screen.getAllByText('Löschen');
|
||||||
|
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.queryByText('Möchten Sie diesen Benutzer wirklich löschen?'),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const usersCalls = fetchMock.mock.calls.filter(
|
||||||
|
(call) =>
|
||||||
|
typeof call[0] === 'string' &&
|
||||||
|
call[0].endsWith('/users') &&
|
||||||
|
(!call[1] || (call[1] as RequestInit).method === undefined),
|
||||||
|
);
|
||||||
|
// Erst-Laden + Neu-Laden nach erfolgreichem Loeschen.
|
||||||
|
expect(usersCalls.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -352,6 +352,12 @@
|
|||||||
"noActiveModules": "Für diesen Mandanten sind keine Module aktiviert.",
|
"noActiveModules": "Für diesen Mandanten sind keine Module aktiviert.",
|
||||||
"directCheckboxLabel": "{module} direkt für {user} {granted, select, true {freigeben} other {entziehen}}",
|
"directCheckboxLabel": "{module} direkt für {user} {granted, select, true {freigeben} other {entziehen}}",
|
||||||
"saveError": "Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen."
|
"saveError": "Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"serverRejected": "Der Server hat die Aktion abgelehnt: {detail}",
|
||||||
|
"generic": "Die Aktion konnte nicht durchgeführt werden. Bitte erneut versuchen.",
|
||||||
|
"network": "Der Server ist nicht erreichbar. Bitte erneut versuchen.",
|
||||||
|
"loadFailed": "Die Benutzerliste konnte nicht geladen werden. Bitte laden Sie die Seite neu."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tenants": {
|
"tenants": {
|
||||||
|
|||||||
@@ -352,6 +352,12 @@
|
|||||||
"noActiveModules": "No modules are activated for this tenant.",
|
"noActiveModules": "No modules are activated for this tenant.",
|
||||||
"directCheckboxLabel": "{granted, select, true {Grant} other {Revoke}} {module} directly for {user}",
|
"directCheckboxLabel": "{granted, select, true {Grant} other {Revoke}} {module} directly for {user}",
|
||||||
"saveError": "Could not save grant. Please try again."
|
"saveError": "Could not save grant. Please try again."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"serverRejected": "The server rejected the action: {detail}",
|
||||||
|
"generic": "The action could not be completed. Please try again.",
|
||||||
|
"network": "The server is not reachable. Please try again.",
|
||||||
|
"loadFailed": "The user list could not be loaded. Please reload the page."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tenants": {
|
"tenants": {
|
||||||
|
|||||||
Reference in New Issue
Block a user