Wrong password blanks the login screen: the structured error envelope is rendered as a React child
Describe the bug
Entering a wrong password on the login screen blanks the app. React throws while rendering the error message, the <form> is torn down, and nothing is left on the page — no error text, no way back except a reload.
The two ends of the message do not agree on its shape:
- Server. The auth routes hand every failure to
next(error)(server/modules/auth/auth.routes.ts), and the global error middleware serialises anAppErroras a structured envelope (server/index.ts:254):{"success":false,"error":{"code":"AUTH_INVALID_CREDENTIALS","message":"Invalid username or password"}} - Client.
resolveApiErrorMessage(src/modules/auth/context/AuthContext.tsx:78) returnspayload.error ?? payload.message ?? fallback. Its return type saysstring, butApiErrorPayloaddeclareserror?: stringandparseJsonSafelyonly casts the parsed body — nothing checks it at runtime, so the object is returned.
That object travels through setError / { success: false, error } into LoginForm.tsx:53 and reaches AuthErrorAlert.tsx as <p>{errorMessage}</p>. errorMessage || does not help — an object is truthy. There is no error boundary above <ProtectedRoute> in src/App.tsx, so the throw unmounts the tree.
Every business failure of the auth service takes this path — invalid credentials, missing fields, password too short, username conflict, and any unexpected 500. The errors raised by auth.middleware.ts (missing/expired/invalid token) still use { error: 'string' } and render correctly, which is probably why this has gone unnoticed: only some auth errors blank the screen.
To Reproduce
- Run the OSS server with auth enabled and a registered user.
- Open the app logged out, so the login form shows.
- Enter the right username and a wrong password, and submit.
- The form disappears and the page is blank.
Expected behavior
The login form stays on screen and shows Invalid username or password.
Error message
Production build:
Minified React error #31; ... args[]=object with keys {code, message}Development build:
Objects are not valid as a React child (found: object with keys {code, message}).
If you meant to render a collection of children, use an array instead.Measured in the same run, on a clean instance (v1.37.x, main): form count before submit 1, after submit 0, and the body text is empty. The server answered 401 with the structured envelope quoted above.
Desktop
- OS: Linux (Debian 13)
- Browser: Chromium 140 (headless), production build served by the app's own server
- Version:
main(verified withgit show origin/main:...— the code in question is unchanged there)
Additional context
Possible fix, at the boundary that already knows about both shapes:
type ApiErrorPayload = {
error?: string | { code?: string; message?: string; details?: unknown };
message?: string;
};
function resolveApiErrorMessage(payload: ApiErrorPayload | null, fallback: string): string {
const candidate = typeof payload?.error === 'object' && payload.error !== null
? payload.error.message
: payload?.error;
if (typeof candidate === 'string' && candidate.trim()) return candidate;
if (typeof payload?.message === 'string' && payload.message.trim()) return payload.message;
return fallback;
}SetupForm reaches the same helper through register, so it is covered by the same change. Elsewhere the client already unwraps this envelope correctly — src/modules/git-panel/hooks/useWorktreesController.ts:39 reads payload.error?.message — and readApiJson in src/shared/api.ts handles both shapes; auth predates it.
Two things that would make this class of bug harmless rather than fatal, if you want them:
- have
AuthErrorAlertacceptunknownand render only strings, so no caller can blank the login screen; - put an error boundary above
<ProtectedRoute>insrc/App.tsx.
Related: #1109 reports the same envelope crashing the render in the plugin-install flow, which suggests the contract, not this one call site, is what is off.
I'm happy to send a PR for the resolveApiErrorMessage fix if that's useful.
Source: siteboon/claudecodeui