Repository navigation
Description
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
- Sign in as an admin and open
/admin/branding/login-page. - Change the instance name, then in "Login Image" type
www.example.org/logo.pnginto "Image URL". "Uploaded image" can stay selected. - 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
joshunrau commentedon Oct 26, 2026
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.useUpdateSetupStateMutationalready takesthrowOnError(apps/web/src/hooks/useUpdateSetupStateMutation.ts:19, defaulttrueat:26), andapps/web/src/routes/_app/admin/settings.tsx:65passesfalse. Pass it atapps/web/src/components/LoginPageEditor/hooks.ts:100too. 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. NoisErrorUI is needed. The JSDoc atuseUpdateSetupStateMutation.ts:14-18limits 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
URL_PATTERN.URL_PATTERN(LoginPageEditor/constants.ts:67) is the resource-link rule, and it disagrees with$BrandingConfig.shape.customLogoUrlin both directions. The schema acceptshttps://intranet/logo.png,http://localhost:8080/logo.png,HTTPS://example.org/a.pngandhttps:/example.org/logo.png, andURL_PATTERNrejects all four. (zod's v4url()swaps innew URL(value).hrefbefore the regex runs.)URL_PATTERNacceptshttps://example.org:99999/logo.png, and the schema rejects it. Validate the exact valuehooks.ts:360sends.form.showLogo &&toisCustomSizeInvalid. That is safe there because an unparseable dimension is sent asnull. The URL is sent as typed whatevershowLogoandlogoSourceare set to. Nulling it while the section is hidden does not help either.updateStatereplaces 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 theform.showLogo &&block (LoginPageEditor/sections/SectionCards.tsx:105), so Save is never disabled without a visible reason.isLogoUrlInvalidfrom the hook asisCustomSizeInvalidis returned (hooks.ts:405). Do not re-test it in the card the waySectionCards.tsx:380does for links. The link message (SectionCards.tsx:423-427) asks for a domain, so this field needs its ownt({ en, es, fr }).MAX_RESOURCE_LINKSfrompackages/schemas/src/setup/setup.ts, the same wayMAX_ASSIGNMENT_DURATION_DAYSis exported. Use it atsetup.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.useUpdateSetupStateMutationalways passesthrowOnErrortouseMutation, so the rejection already throws in this harness whatever its client defaults are. Wrap the hook in an error boundary asuseUpdateSetupStateMutation.test.ts:19-43does. Assert that the boundary never catches and thatblockerOptions().shouldBlockFn()is stilltrue.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_LINKSlinks pass and one more fails.testing/src/specs/admin-branding.spec.ts: the issue's test, plus one that usespage.routeto answer only the PATCH to**/v1/setupwith a 400 (aserror-page.spec.ts:13does). It asserts that the typed instance name is still there and thaterror-pageis not shown. Adddata-testids for the Image URL input and its message (testing/AGENTS.md:22).getByLabel('Image URL')also matches thelogoSource-urlradio (SectionCards.tsx:184-187).Landing
Land after #1825. Its ec28908 edits
isCustomSizeInvalidand adds Login Image locators to the page model. Its e5ff0ca putsadmin-branding.spec.tsin serial mode and addssaveAndConfirm. #1716 edits the samehooks.ts,SectionCards.tsxand spec, so take the two together, one commit each. #1687 is the samethrowOnErrorfailure on a query.Triage note written with Claude Code. Code references point at
main@d86740e.