fix(custom-widgets): finish v2 hardening

This commit is contained in:
Thomas Camlong committed 2026-07-20 23:39:57 +02:00
1 parent 7c66c912e7
commit 1c02678a7e
97 files changed
+2833 -12393

No files matched your search

@@ -1,8 +1,6 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, useMemo, useState } from "react";
import { json } from "@codemirror/lang-json";
import { useEffect, useId, useMemo, useState } from "react";
import {
Accordion,
Alert,
@@ -17,23 +15,20 @@ import {
Text,
Textarea,
TextInput,
useComputedColorScheme,
} from "@mantine/core";
import { DateInput, TimeInput } from "@mantine/dates";
import { IconAlertTriangle, IconBraces } from "@tabler/icons-react";
import { IconAlertTriangle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useOptionalBoard } from "@homarr/boards/context";
import { resolveCustomWidgetOptionsBinding, validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
import { IconPicker } from "@homarr/forms-collection";
import { useScopedI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useFormContext } from "./form";
const CodeMirror = dynamic(() => import("@uiw/react-codemirror").then((module) => module.default), { ssr: false });
const jsonExtensions = [json()];
type Schema = Record<string, unknown>;
export const WidgetCustomWidgetConfigurationInput = ({
@@ -111,7 +106,6 @@ export const WidgetCustomWidgetConfigurationInput = ({
</Accordion.Item>
</Accordion>
)}
<AdvancedJson value={configuration} onChange={(value) => form.setFieldValue(`options.${property}`, value)} />
</Stack>
);
};
@@ -193,119 +187,158 @@ function OptionField({
/>
);
}
if (control === "switch") return <Switch label={label} description={description} required={required} {...input} />;
if (control === "number")
return <NumberInput label={label} description={description} required={required} {...input} />;
if (control === "duration")
return <NumberInput label={label} description={description} required={required} suffix=" s" min={0} {...input} />;
if (control === "slider") {
return (
<Stack gap={4}>
<Text size="sm" fw={500}>
{label}
{required ? " *" : ""}
</Text>
{description && (
<Text size="xs" c="dimmed">
{description}
</Text>
)}
<Slider
min={typeof schema.minimum === "number" ? schema.minimum : 0}
max={typeof schema.maximum === "number" ? schema.maximum : 100}
step={typeof schema.multipleOf === "number" ? schema.multipleOf : 1}
{...input}
/>
</Stack>
);
}
if (control === "select") {
const numeric = schema.type === "number" || schema.type === "integer";
return (
<Select
label={label}
description={description}
placeholder={placeholder}
data={choices}
searchable
required={required}
value={input.value === undefined || input.value === null ? null : String(input.value)}
onBlur={input.onBlur}
onChange={(value) => form.setFieldValue(path, numeric && value !== null ? Number(value) : value)}
/>
);
}
if (control === "multi-select") {
const itemSchema = isRecord(schema.items) ? schema.items : {};
const numeric = itemSchema.type === "number" || itemSchema.type === "integer";
return (
<MultiSelect
label={label}
description={description}
placeholder={placeholder}
data={choices}
searchable
required={required}
value={Array.isArray(input.value) ? input.value.map(String) : []}
onBlur={input.onBlur}
onChange={(value) => form.setFieldValue(path, numeric ? value.map(Number) : value)}
/>
);
}
if (control === "date")
return (
<DateInput label={label} description={description} required={required} valueFormat="YYYY-MM-DD" {...input} />
);
if (control === "time") return <TimeInput label={label} description={description} required={required} {...input} />;
if (control === "color") return <ColorInput label={label} description={description} required={required} {...input} />;
if (control === "icon") {
return (
<Stack gap={4}>
<IconPicker label={label} withAsterisk={required} {...input} />
{description && (
<Text size="xs" c="dimmed">
{description}
</Text>
)}
</Stack>
);
}
if (control === "timeZone") {
return (
<Select
label={label}
description={description}
data={Intl.supportedValuesOf("timeZone")}
searchable
required={required}
{...input}
/>
);
}
if (control === "textarea")
return (
<Textarea
label={label}
description={description}
placeholder={placeholder}
required={required}
autosize
minRows={3}
{...input}
/>
);
return (
<TextInput
<StaticOptionControl
control={control}
schema={schema}
path={path}
label={label}
description={description}
placeholder={placeholder}
required={required}
type={control === "url" ? "url" : "text"}
{...input}
choices={choices}
/>
);
}
function StaticOptionControl({
control,
schema,
path,
label,
description,
placeholder,
required,
choices,
}: {
control: string;
schema: Schema;
path: string;
label: string;
description?: string;
placeholder?: string;
required: boolean;
choices: Array<{ value: string; label: string }>;
}) {
const form = useFormContext();
const input = form.getInputProps(path, { type: control === "switch" ? "checkbox" : "input" });
switch (control) {
case "switch":
return <Switch label={label} description={description} required={required} {...input} />;
case "number":
return <NumberInput label={label} description={description} required={required} {...input} />;
case "duration":
return <NumberInput label={label} description={description} required={required} suffix=" s" min={0} {...input} />;
case "slider":
return (
<Stack gap={4}>
<Text size="sm" fw={500}>
{label}
{required ? " *" : ""}
</Text>
{description && (
<Text size="xs" c="dimmed">
{description}
</Text>
)}
<Slider
min={typeof schema.minimum === "number" ? schema.minimum : 0}
max={typeof schema.maximum === "number" ? schema.maximum : 100}
step={typeof schema.multipleOf === "number" ? schema.multipleOf : 1}
{...input}
/>
</Stack>
);
case "select": {
const numeric = schema.type === "number" || schema.type === "integer";
return (
<Select
label={label}
description={description}
placeholder={placeholder}
data={choices}
searchable
required={required}
value={input.value === undefined || input.value === null ? null : String(input.value)}
onBlur={input.onBlur}
onChange={(value) => form.setFieldValue(path, numeric && value !== null ? Number(value) : value)}
/>
);
}
case "multi-select": {
const itemSchema = isRecord(schema.items) ? schema.items : {};
const numeric = itemSchema.type === "number" || itemSchema.type === "integer";
return (
<MultiSelect
label={label}
description={description}
placeholder={placeholder}
data={choices}
searchable
required={required}
value={Array.isArray(input.value) ? input.value.map(String) : []}
onBlur={input.onBlur}
onChange={(value) => form.setFieldValue(path, numeric ? value.map(Number) : value)}
/>
);
}
case "date":
return (
<DateInput label={label} description={description} required={required} valueFormat="YYYY-MM-DD" {...input} />
);
case "time":
return <TimeInput label={label} description={description} required={required} {...input} />;
case "color":
return <ColorInput label={label} description={description} required={required} {...input} />;
case "icon":
return (
<Stack gap={4}>
<IconPicker label={label} withAsterisk={required} {...input} />
{description && (
<Text size="xs" c="dimmed">
{description}
</Text>
)}
</Stack>
);
case "timeZone":
return (
<Select
label={label}
description={description}
data={Intl.supportedValuesOf("timeZone")}
searchable
required={required}
{...input}
/>
);
case "textarea":
return (
<Textarea
label={label}
description={description}
placeholder={placeholder}
required={required}
autosize
minRows={3}
{...input}
/>
);
default:
return (
<TextInput
label={label}
description={description}
placeholder={placeholder}
required={required}
type={control === "url" ? "url" : "text"}
{...input}
/>
);
}
}
function DynamicOptionsSelect({
label,
description,
@@ -391,48 +424,6 @@ function DynamicOptionsSelect({
);
}
function AdvancedJson({
value,
onChange,
}: {
value: Record<string, unknown>;
onChange(value: Record<string, unknown>): void;
}) {
const labels = useScopedI18n("widget.customApi.configuration");
const [draft, setDraft] = useState(() => JSON.stringify(value, null, 2));
const [error, setError] = useState<string | null>(null);
useEffect(() => setDraft(JSON.stringify(value, null, 2)), [value]);
return (
<Accordion variant="contained">
<Accordion.Item value="json">
<Accordion.Control icon={<IconBraces size={16} />}>{labels("advancedJson")}</Accordion.Control>
<Accordion.Panel>
<ConfigurationJsonEditor
value={draft}
height="220px"
onChange={(next) => {
setDraft(next);
try {
const parsed = JSON.parse(next) as unknown;
if (!isRecord(parsed)) throw new Error(labels("jsonObjectRequired"));
setError(null);
onChange(parsed);
} catch (cause) {
setError(cause instanceof Error ? cause.message : labels("invalidJson"));
}
}}
/>
{error && (
<Text c="red" size="xs" mt="xs">
{error}
</Text>
)}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}
function JsonValueInput({
label,
description,
@@ -451,51 +442,84 @@ function JsonValueInput({
const [error, setError] = useState<string | null>(null);
useEffect(() => setDraft(JSON.stringify(value ?? null, null, 2)), [value]);
return (
<Stack gap={4}>
<Text size="sm" fw={500}>
{label}
{required ? " *" : ""}
</Text>
{description && (
<Text size="xs" c="dimmed">
{description}
</Text>
)}
<ConfigurationJsonEditor
value={draft}
height="160px"
onChange={(next) => {
setDraft(next);
try {
const parsed = JSON.parse(next) as unknown;
setError(null);
onChange(parsed);
} catch (cause) {
setError(cause instanceof Error ? cause.message : labels("invalidJson"));
}
}}
/>
{error && (
<Text c="red" size="xs">
{error}
</Text>
)}
</Stack>
<ConfigurationJsonEditor
label={label}
description={description}
required={required}
error={error}
value={draft}
height="160px"
onChange={(next) => {
setDraft(next);
try {
const parsed = JSON.parse(next) as unknown;
setError(null);
onChange(parsed);
} catch (cause) {
setError(cause instanceof Error ? cause.message : labels("invalidJson"));
}
}}
/>
);
}
function ConfigurationJsonEditor({
label,
description,
required,
error,
value,
height,
onChange,
}: {
label: string;
description?: string;
required?: boolean;
error?: string | null;
value: string;
height: string;
onChange(value: string): void;
}) {
const colorScheme = useComputedColorScheme("light");
const t = useScopedI18n("customWidget.editor");
const id = useId();
return (
<CodeMirror value={value} extensions={jsonExtensions} height={height} theme={colorScheme} onChange={onChange} />
<CustomWidgetCodeEditor
id={id}
label={label}
description={description}
required={required}
error={error}
value={value}
language="json"
height={height}
onChange={onChange}
messages={{
languageJsx: t("language.jsx"),
languageJson: t("language.json"),
undo: t("action.undo"),
redo: t("action.redo"),
components: t("action.components"),
componentSearch: t("componentReference.search"),
componentEmpty: t("componentReference.empty"),
componentCount: (count) => t("componentReference.count", { count }),
insertStarter: t("action.insertStarter"),
format: t("action.format"),
copy: t("action.copy"),
copied: t("action.copied"),
schema: t("action.schema"),
schemaTab: t("reference.schema"),
minimalTab: t("reference.minimal"),
fullTab: t("reference.full"),
errors: (count) => t("status.errors", { count }),
warnings: (count) => t("status.warnings", { count }),
ready: t("status.ready"),
position: (cursor) => t("status.position", cursor),
characters: (count, limit) =>
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
diagnosticsTitle: t("diagnostics.title"),
diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
}}
/>
);
}