fix(web): Formularweg und Listenladen der Benutzerverwaltung melden Fehler sichtbar

WINDOWS #36, Aufgabe 2/3: handleSubmit und fetchUsers verschluckten
abgewiesene Antworten und Verbindungsfehler ebenso wie der Loeschweg aus
Aufgabe 1. formError zeigt jetzt den Servertext oder eine Ersatzmeldung
im offenen Formulardialog; loadError verhindert die irrefuehrende
Meldung "Keine Benutzer gefunden", wenn das Laden selbst gescheitert
ist. Beide Zustaende werden beim Oeffnen eines neuen Dialogs
zurueckgesetzt, damit eine alte Meldung nicht in den naechsten Aufruf
hinueberwandert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
2026-09-21 07:31:47 +02:00
parent 38d2586466
commit 51bff7564f
2 changed files with 220 additions and 4 deletions
+34 -4
View File
@@ -65,6 +65,8 @@ export default function AdminUsersPage() {
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 [deleteError, setDeleteError] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [loadError, setLoadError] = 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: '',
@@ -79,19 +81,22 @@ export default function AdminUsersPage() {
currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN'; currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
const fetchUsers = useCallback(async () => { const fetchUsers = useCallback(async () => {
setLoadError(null);
try { try {
const res = await fetch(`${API_URL}/users`, { const res = await fetch(`${API_URL}/users`, {
credentials: 'include', credentials: 'include',
}); });
if (res.ok) { if (res.ok) {
setUsers(await res.json()); setUsers(await res.json());
} else {
setLoadError(t('errors.loadFailed'));
} }
} catch { } catch {
// silently fail setLoadError(t('errors.loadFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [t]);
useEffect(() => { useEffect(() => {
if (hasAccess) { if (hasAccess) {
@@ -110,6 +115,7 @@ export default function AdminUsersPage() {
displayName: '', displayName: '',
role: 'USER', role: 'USER',
}); });
setFormError(null);
setShowForm(true); setShowForm(true);
}; };
@@ -122,11 +128,13 @@ export default function AdminUsersPage() {
displayName: user.displayName ?? '', displayName: user.displayName ?? '',
role: user.role, role: user.role,
}); });
setFormError(null);
setShowForm(true); setShowForm(true);
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setFormError(null);
const url = editingUser const url = editingUser
? `${API_URL}/users/${editingUser.id}` ? `${API_URL}/users/${editingUser.id}`
@@ -156,9 +164,14 @@ export default function AdminUsersPage() {
if (res.ok) { if (res.ok) {
setShowForm(false); setShowForm(false);
fetchUsers(); fetchUsers();
} else {
const detail = await readApiMessage(res);
setFormError(
detail ? t('errors.serverRejected', { detail }) : t('errors.generic'),
);
} }
} catch { } catch {
// silently fail setFormError(t('errors.network'));
} }
}; };
@@ -215,10 +228,19 @@ export default function AdminUsersPage() {
</button> </button>
</div> </div>
{loadError && (
<div
role="alert"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{loadError}
</div>
)}
{/* Users table */} {/* Users table */}
{loading ? ( {loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p> <p className="text-muted-foreground">{tCommon('loading')}</p>
) : users.length === 0 ? ( ) : loadError ? null : users.length === 0 ? (
<p className="text-muted-foreground">{t('noUsers')}</p> <p className="text-muted-foreground">{t('noUsers')}</p>
) : ( ) : (
<div className="overflow-x-auto rounded-md border border-border"> <div className="overflow-x-auto rounded-md border border-border">
@@ -400,6 +422,14 @@ export default function AdminUsersPage() {
)} )}
</select> </select>
</div> </div>
{formError && (
<div
role="alert"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{formError}
</div>
)}
<div className="flex justify-end gap-3 pt-2"> <div className="flex justify-end gap-3 pt-2">
<button <button
type="button" type="button"
@@ -285,3 +285,189 @@ describe('AdminUsersPage — Loeschweg (WINDOWS #36, Aufgabe 1)', () => {
expect(usersCalls.length).toBeGreaterThanOrEqual(2); expect(usersCalls.length).toBeGreaterThanOrEqual(2);
}); });
}); });
describe('AdminUsersPage — Formularweg und Listenladen (WINDOWS #36, Aufgabe 2)', () => {
it('zeigt den Servertext im offenen Formular, wenn das Speichern abgewiesen wird', 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 === 'PATCH') {
return Promise.resolve({
ok: false,
status: 403,
json: () => Promise.resolve({ message: 'Cannot modify 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 editButtons = screen.getAllByText('Bearbeiten');
await userEvent.click(editButtons[0]);
const usernameInput = await screen.findByDisplayValue('admin.eins');
const saveButton = screen.getByText('Speichern');
await userEvent.click(saveButton);
await waitFor(() => {
expect(
screen.getByText('Der Server hat die Aktion abgelehnt: Cannot modify a SUPER_ADMIN user'),
).toBeInTheDocument();
});
// Formular bleibt offen.
expect(usernameInput).toBeInTheDocument();
});
it('verbindet Pruefmeldungen aus einem Feld mit Komma im Rahmensatz', 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 === 'PATCH') {
return Promise.resolve({
ok: false,
status: 400,
json: () =>
Promise.resolve({ message: ['username must be longer', 'email must be an email'] }),
});
}
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
});
vi.stubGlobal('fetch', fetchMock);
render(<AdminUsersPage />);
await waitFor(() => {
expect(screen.getByText('user.zwei')).toBeInTheDocument();
});
const editButtons = screen.getAllByText('Bearbeiten');
await userEvent.click(editButtons[0]);
await screen.findByDisplayValue('admin.eins');
await userEvent.click(screen.getByText('Speichern'));
await waitFor(() => {
expect(
screen.getByText(
'Der Server hat die Aktion abgelehnt: username must be longer, email must be an email',
),
).toBeInTheDocument();
});
});
it('zeigt die Netzmeldung im offenen Formular, wenn die Verbindung beim Speichern 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 === 'PATCH') {
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 editButtons = screen.getAllByText('Bearbeiten');
await userEvent.click(editButtons[0]);
const usernameInput = await screen.findByDisplayValue('admin.eins');
await userEvent.click(screen.getByText('Speichern'));
await waitFor(() => {
expect(
screen.getByText('Der Server ist nicht erreichbar. Bitte erneut versuchen.'),
).toBeInTheDocument();
});
expect(usernameInput).toBeInTheDocument();
});
it('meldet ein gescheitertes Laden im Listenkopf statt faelschlich "Keine Benutzer gefunden" zu zeigen', 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: 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('Die Benutzerliste konnte nicht geladen werden. Bitte laden Sie die Seite neu.'),
).toBeInTheDocument();
});
expect(screen.queryByText('Keine Benutzer gefunden')).not.toBeInTheDocument();
});
it('laesst eine Meldung nicht das Schliessen und erneute Oeffnen des Formulars ueberdauern', 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 === 'PATCH') {
return Promise.resolve({
ok: false,
status: 403,
json: () => Promise.resolve({ message: 'Cannot modify 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 editButtons = screen.getAllByText('Bearbeiten');
await userEvent.click(editButtons[0]);
await screen.findByDisplayValue('admin.eins');
await userEvent.click(screen.getByText('Speichern'));
await waitFor(() => {
expect(
screen.getByText('Der Server hat die Aktion abgelehnt: Cannot modify a SUPER_ADMIN user'),
).toBeInTheDocument();
});
await userEvent.click(screen.getByText('Abbrechen'));
await waitFor(() => {
expect(screen.queryByDisplayValue('admin.eins')).not.toBeInTheDocument();
});
const editButtonsAgain = screen.getAllByText('Bearbeiten');
await userEvent.click(editButtonsAgain[0]);
await screen.findByDisplayValue('admin.eins');
expect(
screen.queryByText('Der Server hat die Aktion abgelehnt: Cannot modify a SUPER_ADMIN user'),
).not.toBeInTheDocument();
});
});