fix(custom-widgets): keep URL errors inline
This commit is contained in:
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.
|
||||
|
||||
+112
-100
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user