fix(custom-widgets): keep URL errors inline

This commit is contained in:
Thomas Camlong committed 2026-07-20 19:11:58 +02:00
1 parent dc63dc750f
commit c1fca4c5a2
7 files changed
+151 -111

No files matched your search

@@ -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.
@@ -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<string, Array<{ kind: "apiKey" | "username" | "passwo
apiKeyQuery: [{ kind: "apiKey" }],
};
const sourceUrlErrorKeys: Record<CustomWidgetSourceUrlIssue, `baseUrlError.${CustomWidgetSourceUrlIssue}`> = {
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 (
<Stack gap="sm">
{sources.map((source, index) => (
<Fieldset key={source.id} legend={index === 0 ? t("primary") : source.name}>
<Stack gap="sm">
<Group grow align="start">
{sources.map((source, index) => {
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
return (
<Fieldset key={source.id} legend={index === 0 ? t("primary") : source.name}>
<Stack gap="sm">
<Group grow align="start">
<TextInput
label={t("id")}
value={source.id}
disabled={Boolean(definitionId)}
onChange={(event) => update(index, { id: event.currentTarget.value })}
/>
<TextInput
label={t("name")}
value={source.name}
onChange={(event) => update(index, { name: event.currentTarget.value })}
/>
</Group>
<TextInput
label={t("id")}
value={source.id}
disabled={Boolean(definitionId)}
onChange={(event) => 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 })}
/>
<TextInput
label={t("name")}
value={source.name}
onChange={(event) => update(index, { name: event.currentTarget.value })}
/>
</Group>
<TextInput
label={t("baseUrl")}
type="url"
value={source.baseUrl}
onChange={(event) => update(index, { baseUrl: event.currentTarget.value })}
/>
<Group grow align="start">
<Select
label={t("networkScope")}
data={["public", "private", "loopback"]}
value={source.networkScope}
onChange={(value) =>
value && update(index, { networkScope: value as CustomWidgetSource["networkScope"] })
}
allowDeselect={false}
/>
<Select
label={t("authentication")}
data={["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"]}
value={source.auth.type}
onChange={(value) => setAuth(index, value ?? "none")}
allowDeselect={false}
/>
</Group>
{source.auth.type === "apiKeyHeader" && (
<TextInput
label={t("headerName")}
value={source.auth.headerName}
onChange={(event) =>
update(index, { auth: { type: "apiKeyHeader", headerName: event.currentTarget.value } })
}
/>
)}
{source.auth.type === "apiKeyQuery" && (
<TextInput
label={t("queryParameter")}
value={source.auth.parameterName}
onChange={(event) =>
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 (
<Group key={field.kind} align="end" wrap="nowrap">
<Input
style={{ flex: 1 }}
label={t(`secret.${field.kind}`)}
value={secret?.value ?? ""}
placeholder={secret?.hasValue ? t("configured") : undefined}
leftSection={<IconKey size={15} />}
onChange={(event) => setSecret(source.id, field.kind, event.currentTarget.value)}
/>
{definitionId && secret?.hasValue && (
<Button
type="button"
color="red"
variant="subtle"
loading={clearSecretMutation.isPending}
onClick={() => void clearSecret(source.id, field.kind)}
>
{t("clear")}
</Button>
)}
</Group>
);
})}
{index > 0 && (
<Button
type="button"
color="red"
variant="subtle"
leftSection={<IconTrash size={16} />}
onClick={() => removeSource(index)}
>
{t("remove")}
</Button>
)}
</Stack>
</Fieldset>
))}
<Group grow align="start">
<Select
label={t("networkScope")}
data={["public", "private", "loopback"]}
value={source.networkScope}
onChange={(value) =>
value && update(index, { networkScope: value as CustomWidgetSource["networkScope"] })
}
allowDeselect={false}
/>
<Select
label={t("authentication")}
data={["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"]}
value={source.auth.type}
onChange={(value) => setAuth(index, value ?? "none")}
allowDeselect={false}
/>
</Group>
{source.auth.type === "apiKeyHeader" && (
<TextInput
label={t("headerName")}
value={source.auth.headerName}
onChange={(event) =>
update(index, { auth: { type: "apiKeyHeader", headerName: event.currentTarget.value } })
}
/>
)}
{source.auth.type === "apiKeyQuery" && (
<TextInput
label={t("queryParameter")}
value={source.auth.parameterName}
onChange={(event) =>
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 (
<Group key={field.kind} align="end" wrap="nowrap">
<Input
style={{ flex: 1 }}
label={t(`secret.${field.kind}`)}
value={secret?.value ?? ""}
placeholder={secret?.hasValue ? t("configured") : undefined}
leftSection={<IconKey size={15} />}
onChange={(event) => setSecret(source.id, field.kind, event.currentTarget.value)}
/>
{definitionId && secret?.hasValue && (
<Button
type="button"
color="red"
variant="subtle"
loading={clearSecretMutation.isPending}
onClick={() => void clearSecret(source.id, field.kind)}
>
{t("clear")}
</Button>
)}
</Group>
);
})}
{index > 0 && (
<Button
type="button"
color="red"
variant="subtle"
leftSection={<IconTrash size={16} />}
onClick={() => removeSource(index)}
>
{t("remove")}
</Button>
)}
</Stack>
</Fieldset>
);
})}
<Accordion variant="contained">
<Accordion.Item value="advanced">
<Accordion.Control>{t("advanced")}</Accordion.Control>
@@ -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")
@@ -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" });
@@ -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<CustomWidgetSourceUrlIssue, string> = {
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,
@@ -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",
+6
View File
@@ -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",