Wrong password blanks the login screen: the structured error envelope is rendered as a React child

Author: jjscarafiaCreated Sep 9, 2026Updated Sep 9, 2026

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 an AppError as a structured envelope (server/index.ts:254):
    json
    {"success":false,"error":{"code":"AUTH_INVALID_CREDENTIALS","message":"Invalid username or password"}}
  • Client. resolveApiErrorMessage (src/modules/auth/context/AuthContext.tsx:78) returns payload.error ?? payload.message ?? fallback. Its return type says string, but ApiErrorPayload declares error?: string and parseJsonSafely only 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

  1. Run the OSS server with auth enabled and a registered user.
  2. Open the app logged out, so the login form shows.
  3. Enter the right username and a wrong password, and submit.
  4. 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 with git show origin/main:... — the code in question is unchanged there)

Additional context

Possible fix, at the boundary that already knows about both shapes:

typescript
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 AuthErrorAlert accept unknown and render only strings, so no caller can blank the login screen;
  • put an error boundary above <ProtectedRoute> in src/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.