From c1fca4c5a2659a07704228cde393c916d3a4c2e9 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 20 Jul 2026 19:11:58 +0200 Subject: [PATCH] fix(custom-widgets): keep URL errors inline --- .../custom-widgets/troubleshooting.mdx | 2 + .../_custom-widget-sources-editor.tsx | 212 +++++++++--------- .../src/core/custom-jsx-schema.ts | 1 + .../src/core/definition-security.ts | 2 +- .../custom-widgets/src/core/request-schema.ts | 30 ++- .../custom-widgets/src/test/schema.spec.ts | 9 + packages/translation/src/lang/en.json | 6 + 7 files changed, 151 insertions(+), 111 deletions(-) diff --git a/apps/docs/docs/management/custom-widgets/troubleshooting.mdx b/apps/docs/docs/management/custom-widgets/troubleshooting.mdx index 800c00131..c7fe1efdd 100644 --- a/apps/docs/docs/management/custom-widgets/troubleshooting.mdx +++ b/apps/docs/docs/management/custom-widgets/troubleshooting.mdx @@ -12,6 +12,8 @@ The Custom Widgets management page isolates definitions that no longer match the Use the first schema/JSX diagnostic and its path. Ensure source/request IDs are unique, every request references a source, parameters are declared, defaults match the options schema, and network components reference the correct request kind. +Incomplete API source and icon URLs are normal form validation errors. The workbench keeps the current draft mounted and highlights the invalid field while you type; malformed URLs must never replace the form with the error boundary. + ## Request fails Check the source network scope, base URL, credentials, request method/path/body, runtime parameters, and board permission. For “invalid placeholder” or an unresolved parameter on a load query, first verify that every declared parameter has an explicit `optionsBinding` option reference or literal. Matching parameter and option names are not a binding. The preview journal shows redacted method, request ID, path template, status, and duration without credentials. diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-sources-editor.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-sources-editor.tsx index d6096c56a..cd9a8a9a4 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-sources-editor.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-sources-editor.tsx @@ -4,7 +4,8 @@ import { Accordion, Button, Fieldset, Group, PasswordInput, Select, Stack, Text, import { IconKey, IconPlus, IconTrash } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; -import type { CustomWidgetSource } from "@homarr/custom-widgets/core"; +import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core"; +import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core"; import { showSuccessNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; @@ -18,6 +19,13 @@ const secretFields: Record = { + invalid: "baseUrlError.invalid", + protocol: "baseUrlError.protocol", + credentials: "baseUrlError.credentials", + queryOrFragment: "baseUrlError.queryOrFragment", +}; + export function CustomWidgetSourcesEditor({ form, definitionId, @@ -122,107 +130,111 @@ export function CustomWidgetSourcesEditor({ return ( - {sources.map((source, index) => ( -
- - + {sources.map((source, index) => { + const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl); + return ( +
+ + + update(index, { id: event.currentTarget.value })} + /> + update(index, { name: event.currentTarget.value })} + /> + update(index, { id: event.currentTarget.value })} + label={t("baseUrl")} + type="url" + value={source.baseUrl} + error={baseUrlIssue ? t(sourceUrlErrorKeys[baseUrlIssue]) : undefined} + onChange={(event) => update(index, { baseUrl: event.currentTarget.value })} /> - update(index, { name: event.currentTarget.value })} - /> - - update(index, { baseUrl: event.currentTarget.value })} - /> - - setAuth(index, value ?? "none")} - allowDeselect={false} - /> - - {source.auth.type === "apiKeyHeader" && ( - - update(index, { auth: { type: "apiKeyHeader", headerName: event.currentTarget.value } }) - } - /> - )} - {source.auth.type === "apiKeyQuery" && ( - - update(index, { auth: { type: "apiKeyQuery", parameterName: event.currentTarget.value } }) - } - /> - )} - {(secretFields[source.auth.type] ?? []).map((field) => { - const secret = form.values.secrets.find( - (entry) => entry.sourceId === source.id && entry.kind === field.kind, - ); - const Input = field.kind === "username" ? TextInput : PasswordInput; - return ( - - } - onChange={(event) => setSecret(source.id, field.kind, event.currentTarget.value)} - /> - {definitionId && secret?.hasValue && ( - - )} - - ); - })} - {index > 0 && ( - - )} - -
- ))} + + setAuth(index, value ?? "none")} + allowDeselect={false} + /> + + {source.auth.type === "apiKeyHeader" && ( + + update(index, { auth: { type: "apiKeyHeader", headerName: event.currentTarget.value } }) + } + /> + )} + {source.auth.type === "apiKeyQuery" && ( + + update(index, { auth: { type: "apiKeyQuery", parameterName: event.currentTarget.value } }) + } + /> + )} + {(secretFields[source.auth.type] ?? []).map((field) => { + const secret = form.values.secrets.find( + (entry) => entry.sourceId === source.id && entry.kind === field.kind, + ); + const Input = field.kind === "username" ? TextInput : PasswordInput; + return ( + + } + onChange={(event) => setSecret(source.id, field.kind, event.currentTarget.value)} + /> + {definitionId && secret?.hasValue && ( + + )} + + ); + })} + {index > 0 && ( + + )} +
+
+ ); + })} {t("advanced")} diff --git a/packages/custom-widgets/src/core/custom-jsx-schema.ts b/packages/custom-widgets/src/core/custom-jsx-schema.ts index 74331647d..d3149cd60 100644 --- a/packages/custom-widgets/src/core/custom-jsx-schema.ts +++ b/packages/custom-widgets/src/core/custom-jsx-schema.ts @@ -61,6 +61,7 @@ export const customWidgetDefinitionSchema = z .string() .url() .refine((value) => { + if (!URL.canParse(value)) return false; const url = new URL(value); return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password; }, "Widget icons must use HTTP or HTTPS without embedded credentials") diff --git a/packages/custom-widgets/src/core/definition-security.ts b/packages/custom-widgets/src/core/definition-security.ts index 6205c5b7a..799756b35 100644 --- a/packages/custom-widgets/src/core/definition-security.ts +++ b/packages/custom-widgets/src/core/definition-security.ts @@ -44,7 +44,7 @@ export function validateCredentialFreeExport(definition: SecurityDefinition, ctx if (credentialTextPattern.test(definition.template)) { ctx.addIssue({ code: "custom", path: ["template"], message: "Templates cannot contain literal credentials" }); } - if (definition.iconUrl) { + if (definition.iconUrl && URL.canParse(definition.iconUrl)) { const icon = new URL(definition.iconUrl); if ([...icon.searchParams].some(([key, value]) => isCredentialKeyName(key) && value.trim().length > 0)) { ctx.addIssue({ code: "custom", path: ["iconUrl"], message: "Widget icon URLs cannot contain credentials" }); diff --git a/packages/custom-widgets/src/core/request-schema.ts b/packages/custom-widgets/src/core/request-schema.ts index 6f776ccd8..a0f4dc678 100644 --- a/packages/custom-widgets/src/core/request-schema.ts +++ b/packages/custom-widgets/src/core/request-schema.ts @@ -120,6 +120,24 @@ const authSchema = z.discriminatedUnion("type", [ }), ]); +export type CustomWidgetSourceUrlIssue = "invalid" | "protocol" | "credentials" | "queryOrFragment"; + +export function getCustomWidgetSourceUrlIssue(value: string): CustomWidgetSourceUrlIssue | null { + if (!URL.canParse(value)) return "invalid"; + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return "protocol"; + if (url.username || url.password) return "credentials"; + if (url.search || url.hash) return "queryOrFragment"; + return null; +} + +const sourceUrlIssueMessages: Record = { + invalid: "Must be a valid URL", + protocol: "API sources must use HTTP or HTTPS", + credentials: "Credentials must not be embedded in an API source URL", + queryOrFragment: "API source URLs cannot contain a query string or fragment", +}; + export const customWidgetSourceSchema = z.object({ id: customWidgetIdentifierSchema, name: z.string().trim().min(1).max(128), @@ -128,16 +146,8 @@ export const customWidgetSourceSchema = z.object({ .url() .max(2048) .superRefine((value, ctx) => { - const url = new URL(value); - if (url.protocol !== "http:" && url.protocol !== "https:") { - ctx.addIssue({ code: "custom", message: "API sources must use HTTP or HTTPS" }); - } - if (url.username || url.password) { - ctx.addIssue({ code: "custom", message: "Credentials must not be embedded in an API source URL" }); - } - if (url.search || url.hash) { - ctx.addIssue({ code: "custom", message: "API source URLs cannot contain a query string or fragment" }); - } + const issue = getCustomWidgetSourceUrlIssue(value); + if (issue && issue !== "invalid") ctx.addIssue({ code: "custom", message: sourceUrlIssueMessages[issue] }); }), networkScope: z.enum(customJsxNetworkScopes), auth: authSchema, diff --git a/packages/custom-widgets/src/test/schema.spec.ts b/packages/custom-widgets/src/test/schema.spec.ts index d0fa0cf7d..49f6696f2 100644 --- a/packages/custom-widgets/src/test/schema.spec.ts +++ b/packages/custom-widgets/src/test/schema.spec.ts @@ -16,6 +16,15 @@ describe("Custom JSX v2 validation", () => { expect(result.success, result.error?.issues.map((issue) => issue.message).join("; ")).toBe(true); }); + test.each(["q", "http://["])("reports an incomplete URL inline without throwing: %s", (value) => { + const source = { ...CUSTOM_WIDGET_STARTER.sources[0], baseUrl: value }; + + expect(() => customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, sources: [source] })).not.toThrow(); + expect(customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, sources: [source] }).success).toBe(false); + expect(() => customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, iconUrl: value })).not.toThrow(); + expect(customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, iconUrl: value }).success).toBe(false); + }); + test("keeps the bundled seed set stable, disabled-ready, and credential-free", () => { expect(BUNDLED_CUSTOM_WIDGETS.map(({ id }) => id)).toEqual([ "seed-dog-facts", diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 8c613f218..1ea4533a1 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -872,6 +872,12 @@ "id": "Source ID", "name": "Name", "baseUrl": "Base URL", + "baseUrlError": { + "invalid": "Enter a complete URL.", + "protocol": "Use an HTTP or HTTPS URL.", + "credentials": "Remove the username and password from this URL. Configure credentials below instead.", + "queryOrFragment": "Remove the query string or fragment from this base URL. Add query values to a named request." + }, "networkScope": "Network access", "authentication": "Authentication", "headerName": "Header name",