Skip to content

Saving the login page with an invalid image URL replaces the editor with the error page and loses every edit #1671

Open

Description

@joshunrau

The login page editor (/admin/branding/login-page) does not check the "Image URL" field before saving, but the API does, and a rejection destroys the editor. useBrandingForm validates colours, custom logo dimensions and resource-link URLs, and each failure disables Save. customLogoUrl has no check at all and is sent as form.customLogoUrl.trim() || null, even when "Uploaded image" is the selected source or the Login Image section is hidden. $BrandingConfig.customLogoUrl only accepts z.string().url() values that start with http(s)://, so a value like www.example.org/logo.png gets a 400 from PATCH /v1/setup. useUpdateSetupStateMutation keeps the default throwOnError: true, so that 400 reaches the router error boundary. A 400 is not a transient error, so AppErrorComponent renders the terminal ErrorPage in place of the editor. Every unsaved change is lost: text, colours, section order and any uploaded image.

Where

apps/web/src/components/LoginPageEditor/hooks.ts:270-276: the URL is not part of the Save guard:

const isSubmitDisabled =
  isCustomColorInvalid ||
  isRightPanelCustomColorInvalid ||
  isPanelTextColorInvalid ||
  isCustomSizeInvalid ||
  hasInvalidResourceLinks ||
  updateSetupStateMutation.isPending;

hooks.ts:360: it is sent as typed:

customLogoUrl: form.customLogoUrl.trim() || null,

packages/schemas/src/setup/setup.ts:126: the server-side rule it fails:

customLogoUrl: z.string().url().max(2000).regex(HTTP_URL_SCHEME_PATTERN, 'Must be an http(s) URL').nullish(),

Reproduce

  1. Sign in as an admin and open /admin/branding/login-page.
  2. Change the instance name, then in "Login Image" type www.example.org/logo.png into "Image URL". "Uploaded image" can stay selected.
  3. Click Save.

Actual: Save is enabled, the request fails with 400, and the page is replaced by the application error page. Every edit made in the editor is lost.
Expected: the Image URL field shows an inline error like the resource-link URLs do, and Save stays disabled until the URL is valid or empty.

Tests

apps/web/src/components/LoginPageEditor/__tests__/hooks.test.tsx: it('should disable saving while the image URL is not an http(s) URL, so the API never rejects the save and wipes the editor').

testing/src/specs/admin-branding.spec.ts: test('should refuse to save a login image URL without a scheme and keep the editor open').

Suggested fix

Add an isLogoUrlInvalid flag that is true when the trimmed URL is non-empty and fails the same rule the schema applies. The simplest option is to reuse $BrandingConfig.shape.customLogoUrl.safeParse so the two cannot drift. Include the flag in isSubmitDisabled and the handleSubmit guard, and render an inline message under the Image URL input in SectionCards.tsx. The client also lets an admin add more than the 50 resource links the schema allows (.max(50)). That ends the same way and can be guarded in the same change.

Activity

  1. added
    BugType: existing behavior is wrong
    Difficulty: LowIsolated change, about 2 hours or less
    Area: Webapps/web (clinician-facing React SPA)
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). Every line the issue cites is unchanged.

    Also keep the editor mounted when a save fails

    Validation removes this 400, but any other failed save still unmounts the editor. That covers a 5xx, and also a dropped connection or the 10 s timeout (apps/web/src/services/axios.ts:109). Neither is retried for a PATCH (axios.ts:57-64), so they land on the "Connection Problem" screen and lose the same edits. useUpdateSetupStateMutation already takes throwOnError (apps/web/src/hooks/useUpdateSetupStateMutation.ts:19, default true at :26), and apps/web/src/routes/_app/admin/settings.tsx:65 passes false. Pass it at apps/web/src/components/LoginPageEditor/hooks.ts:100 too. The interceptor's error toast still fires (axios.ts:181-205). onSuccess (hooks.ts:391-393) does not run, so the form stays dirty and the blocker keeps guarding it. No isError UI is needed. The JSDoc at useUpdateSetupStateMutation.ts:14-18 limits the opt-out to autosaves, so widen it. #1731 left this hook to this issue, and b281d60 in #1825 applies the same opt-out to five other hooks. Land it as its own commit.

    Gotchas in the validation

    • Use the schema, not URL_PATTERN. URL_PATTERN (LoginPageEditor/constants.ts:67) is the resource-link rule, and it disagrees with $BrandingConfig.shape.customLogoUrl in both directions. The schema accepts https://intranet/logo.png, http://localhost:8080/logo.png, HTTPS://example.org/a.png and https:/example.org/logo.png, and URL_PATTERN rejects all four. (zod's v4 url() swaps in new URL(value).href before the regex runs.) URL_PATTERN accepts https://example.org:99999/logo.png, and the schema rejects it. Validate the exact value hooks.ts:360 sends.
    • Do not copy Hiding the login image with an incomplete custom size disables Save with no visible reason #1676's gate. ec28908 in fix: resolve 26 low-difficulty issues, one commit each #1825 adds form.showLogo && to isCustomSizeInvalid. That is safe there because an unparseable dimension is sent as null. The URL is sent as typed whatever showLogo and logoSource are set to. Nulling it while the section is hidden does not help either. updateState replaces the whole branding document (apps/api/src/setup/setup.service.ts:121-125), so the saved URL would be erased. Keep the check ungated. While the section is hidden, render the message outside the form.showLogo && block (LoginPageEditor/sections/SectionCards.tsx:105), so Save is never disabled without a visible reason.
    • Return isLogoUrlInvalid from the hook as isCustomSizeInvalid is returned (hooks.ts:405). Do not re-test it in the card the way SectionCards.tsx:380 does for links. The link message (SectionCards.tsx:423-427) asks for a domain, so this field needs its own t({ en, es, fr }).
    • 50-link cap: export a MAX_RESOURCE_LINKS from packages/schemas/src/setup/setup.ts, the same way MAX_ASSIGNMENT_DURATION_DAYS is exported. Use it at setup.ts:158, and disable "Add link" (SectionCards.tsx:433) at that count.

    Tests

    • hooks.test.tsx: besides the issue's test, cover the URL with "Uploaded image" selected, with Login Image hidden, and on a rejected PATCH. useUpdateSetupStateMutation always passes throwOnError to useMutation, so the rejection already throws in this harness whatever its client defaults are. Wrap the hook in an error boundary as useUpdateSetupStateMutation.test.ts:19-43 does. Assert that the boundary never catches and that blockerOptions().shouldBlockFn() is still true.
    • SectionCards.test.tsx: the message under the input and in the hidden card, and "Add link" disabled at the cap.
    • packages/schemas/src/setup/__tests__/setup.test.ts: MAX_RESOURCE_LINKS links pass and one more fails.
    • testing/src/specs/admin-branding.spec.ts: the issue's test, plus one that uses page.route to answer only the PATCH to **/v1/setup with a 400 (as error-page.spec.ts:13 does). It asserts that the typed instance name is still there and that error-page is not shown. Add data-testids for the Image URL input and its message (testing/AGENTS.md:22). getByLabel('Image URL') also matches the logoSource-url radio (SectionCards.tsx:184-187).

    Landing

    Land after #1825. Its ec28908 edits isCustomSizeInvalid and adds Login Image locators to the page model. Its e5ff0ca puts admin-branding.spec.ts in serial mode and adds saveAndConfirm. #1716 edits the same hooks.ts, SectionCards.tsx and spec, so take the two together, one commit each. #1687 is the same throwOnError failure on a query.

    Triage note written with Claude Code. Code references point at main @ d86740e.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: Webapps/web (clinician-facing React SPA)BugType: existing behavior is wrongDifficulty: LowIsolated change, about 2 hours or lessPriority: MediumReal value; schedule normally

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions