fix(custom-widgets): address v2 merge review (#6446)

This commit is contained in:
Thomas authored and GitHub committed 2026-07-23 01:55:15 +02:00
1 parent 6aceb2fd1f
commit df967751f6
123 files changed
+8632 -1477

No files matched your search

@@ -1,18 +1,23 @@
"use client";
import { Alert, Anchor, Text } from "@mantine/core";
import { IconFlask } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets";
export function CustomWidgetBetaBanner() {
const t = useScopedI18n("customWidget.betaBanner");
return (
<Alert variant="light" color="yellow" title="Custom Widgets are in Beta" icon={<IconFlask />}>
<Alert variant="light" color="yellow" title={t("title")} icon={<IconFlask />}>
<Text size="sm">
This feature is experimental and will probably change a lot in the future. If you encounter bugs, wish it did
more, or find something confusing, then please complain to us as much as possible! Only feedback posted on the{" "}
{t("description")}{" "}
<Anchor href={FEEDBACK_URL} target="_blank" fw={600}>
official discussion thread
{t("discussionLink")}
</Anchor>{" "}
will be taken into account. Happy Hacking!
{t("closing")}
</Text>
</Alert>
);
@@ -1,23 +1,26 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconBuildingStore, IconCopy, IconDots, IconDownload, IconTrash, IconUpload } from "@tabler/icons-react";
import {
IconBuildingStore,
IconCopy,
IconDots,
IconDownload,
IconSparkles,
IconTrash,
IconUpload,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import {
formatCustomWidgetImportIssues,
looksLikeCustomWidgetClipboard,
parseCustomWidgetClipboardDetailed,
} from "@homarr/custom-widgets/core";
import { formatCustomWidgetImportIssues, parseCustomWidgetClipboardDetailed } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { WorkshopPublishModal } from "~/components/workshop/workshop-publish-modal";
@@ -28,15 +31,59 @@ interface WidgetRef {
name: string;
enabled: boolean;
valid: boolean;
migrationRequired: boolean;
}
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
const t = useScopedI18n("customWidget");
const { openConfirmModal } = useConfirmModal();
const deleteMutation = clientApi.customWidget.delete.useMutation();
const deleteLegacyMutation = clientApi.customWidget.deleteLegacy.useMutation();
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
const utils = clientApi.useUtils();
const [publishOpened, publishControls] = useDisclosure(false);
const [migrationOpened, migrationControls] = useDisclosure(false);
const migrationFileInputRef = useRef<HTMLInputElement>(null);
const [migratedWidget, setMigratedWidget] = useState<HomarrCustomWidgetV2 | null>(null);
const copyMigrationPrompt = async () => {
try {
const result = await utils.customWidget.legacyMigrationPrompt.fetch({ id: widget.id });
await navigator.clipboard.writeText(result.prompt);
showSuccessNotification({
title: t("action.copyMigrationPrompt"),
message: t("notification.migrationPromptCopied"),
});
} catch (error) {
showErrorNotification({
title: t("action.copyMigrationPrompt"),
message: error instanceof Error ? error.message : t("notification.migrationPromptCopyError"),
});
}
};
const handleMigrationFile = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.addEventListener("load", (loadEvent) => {
const result = parseCustomWidgetClipboardDetailed(loadEvent.target?.result as string);
if (!result.success) {
showErrorNotification({
title: t("action.migrate"),
message: formatCustomWidgetImportIssues(result.issues),
});
return;
}
setMigratedWidget(result.widget);
migrationControls.open();
});
reader.addEventListener("error", () => {
showErrorNotification({ title: t("action.migrate"), message: t("notification.migrationError") });
});
reader.readAsText(file);
event.target.value = "";
};
const handleExport = async () => {
try {
@@ -77,23 +124,26 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
title: t("action.delete"),
children: t("action.deleteConfirm", { name: widget.name }),
onConfirm: () => {
deleteMutation.mutate(
{ id: widget.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("action.delete"),
message: t("notification.deleted", { name: widget.name }),
});
void utils.customWidget.list.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
},
const callbacks = {
onSuccess: () => {
showSuccessNotification({
title: t("action.delete"),
message: t("notification.deleted", { name: widget.name }),
});
void utils.customWidget.list.invalidate();
void utils.customWidget.available.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
);
onError: () => {
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
},
};
if (widget.migrationRequired) {
deleteLegacyMutation.mutate({ id: widget.id }, callbacks);
return;
}
deleteMutation.mutate({ id: widget.id }, callbacks);
},
});
};
@@ -125,104 +175,52 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
<Menu.Divider />
</>
)}
{widget.migrationRequired && (
<>
<Menu.Item onClick={() => void copyMigrationPrompt()} leftSection={<IconSparkles {...iconProps} />}>
{t("action.copyMigrationPrompt")}
</Menu.Item>
<Menu.Item
onClick={() => migrationFileInputRef.current?.click()}
leftSection={<IconUpload {...iconProps} />}
>
{t("action.importMigration")}
</Menu.Item>
<Menu.Divider />
</>
)}
<Menu.Item
color="red"
leftSection={<IconTrash {...iconProps} />}
onClick={handleDelete}
disabled={deleteMutation.isPending}
disabled={deleteMutation.isPending || deleteLegacyMutation.isPending}
>
{t("action.delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
{widget.valid && <WorkshopPublishModal opened={publishOpened} onClose={publishControls.close} widget={widget} />}
</>
);
};
export const ImportCustomWidgetButton = () => {
const t = useScopedI18n("customWidget");
const fileInputRef = useRef<HTMLInputElement>(null);
const [pendingImport, setPendingImport] = useState<HomarrCustomWidgetV2 | null>(null);
const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false);
const queueImport = useCallback(
(value: HomarrCustomWidgetV2) => {
setPendingImport(value);
openReview();
},
[openReview],
);
useEffect(() => {
const handlePaste = (event: ClipboardEvent) => {
if (event.target instanceof Element && event.target.matches("input, textarea, [contenteditable='true']")) return;
const text = event.clipboardData?.getData("text/plain") ?? "";
if (!looksLikeCustomWidgetClipboard(text)) return;
event.preventDefault();
const result = parseCustomWidgetClipboardDetailed(text);
if (!result.success) {
showErrorNotification({
title: t("action.import"),
message: formatCustomWidgetImportIssues(result.issues),
});
return;
}
queueImport(result.widget);
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [queueImport, t]);
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.addEventListener("load", (e) => {
try {
const result = parseCustomWidgetClipboardDetailed(e.target?.result as string);
if (!result.success) throw new Error(formatCustomWidgetImportIssues(result.issues));
queueImport(result.widget);
} catch (error) {
showErrorNotification({
title: t("action.import"),
message: error instanceof Error ? error.message : t("notification.importError"),
});
}
});
reader.addEventListener("error", () => {
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
});
reader.readAsText(file);
event.target.value = "";
};
return (
<>
<MobileAffixButton
variant="default"
leftSection={<IconUpload size={16} />}
onClick={() => fileInputRef.current?.click()}
title={t("action.pasteImportHint")}
>
{t("action.import")}
</MobileAffixButton>
<input
ref={fileInputRef}
type="file"
accept=".json,.md,text/markdown,application/json"
hidden
onChange={handleImport}
aria-label={t("importReview.fileLabel")}
/>
<CustomWidgetImportDialog
opened={reviewOpened}
widget={pendingImport}
onClose={() => {
closeReview();
setPendingImport(null);
}}
/>
{widget.migrationRequired && (
<>
<input
ref={migrationFileInputRef}
type="file"
accept=".json,.md,text/markdown,application/json"
hidden
onChange={handleMigrationFile}
aria-label={t("action.importMigration")}
/>
<CustomWidgetImportDialog
opened={migrationOpened}
widget={migratedWidget}
legacyId={widget.id}
onClose={() => {
migrationControls.close();
setMigratedWidget(null);
}}
/>
</>
)}
</>
);
};
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import {
filterSecretsForSourceAuthentication,
getChangedSecrets,
getCustomWidgetPreviewOptionIssues,
loadPreviewQueries,
@@ -62,4 +63,18 @@ describe("Custom Widget workbench preview options", () => {
}),
).toEqual([{ sourceId: "other", kind: "password", value: "replacement" }]);
});
it("drops credentials that do not apply after an authentication transition", () => {
expect(
filterSecretsForSourceAuthentication(
[
{ sourceId: "default", kind: "username", value: "admin", hasValue: true },
{ sourceId: "default", kind: "password", value: "secret", hasValue: true },
{ sourceId: "secondary", kind: "apiKey", value: "unchanged", hasValue: true },
],
"default",
"bearer",
),
).toEqual([{ sourceId: "secondary", kind: "apiKey", value: "unchanged", hasValue: true }]);
});
});
@@ -2,6 +2,7 @@ import { fetchApi } from "@homarr/api/client";
import {
customWidgetDefinitionSchema,
getCustomWidgetDefaultOptions,
getCustomWidgetRequiredSecretKinds,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetSource, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
@@ -33,6 +34,15 @@ export function getChangedSecrets(values: CustomWidgetFormValues) {
}));
}
export function filterSecretsForSourceAuthentication(
secrets: CustomWidgetFormValues["secrets"],
sourceId: string,
authType: Parameters<typeof getCustomWidgetRequiredSecretKinds>[0],
) {
const requiredKinds = new Set<string>(getCustomWidgetRequiredSecretKinds(authType));
return secrets.filter((secret) => secret.sourceId !== sourceId || requiredKinds.has(secret.kind));
}
export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrCustomWidgetV2) {
form.setValues({
...form.values,
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Accordion, Box, Button, Paper, SegmentedControl, Stack, TextInput, Textarea } from "@mantine/core";
import {
IconAlertCircle,
@@ -39,6 +39,7 @@ import { createCustomWidgetRenameHandlers } from "./_custom-widget-rename-handle
import { CustomWidgetSaveIssuesAlert } from "./_custom-widget-save-issues-alert";
import { CustomWidgetSourcesEditor } from "./_custom-widget-sources-editor";
import { useCustomWidgetFormActions } from "./_use-custom-widget-form-actions";
import { useUnsavedChangesGuard } from "./_use-unsaved-changes-guard";
import classes from "./_custom-widget-form.module.css";
interface CustomWidgetFormProps {
@@ -71,8 +72,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
const initialDefinition = buildDefinition(formInitialValues);
return initialDefinition.success ? getDefinitionDefaults(initialDefinition.data) : {};
});
const dirtyRef = useRef(false);
dirtyRef.current = form.isDirty();
useUnsavedChangesGuard(form.isDirty());
const candidate = useMemo(() => buildDefinition(form.values), [form.values]);
const parsedOptions = useMemo(
@@ -117,13 +117,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
optionsSnapshot,
setOptionsSnapshot,
});
useEffect(() => {
const beforeUnload = (event: BeforeUnloadEvent) => {
if (dirtyRef.current) event.preventDefault();
};
window.addEventListener("beforeunload", beforeUnload);
return () => window.removeEventListener("beforeunload", beforeUnload);
}, []);
useEffect(() => {
if (parsedOptions.success) setOptionsSnapshot(getCustomWidgetDefaultOptions(parsedOptions.data));
}, [parsedOptions]);
@@ -0,0 +1,28 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const readSource = (path: string) => readFileSync(`${process.cwd()}/${path}`, "utf8");
describe("legacy custom widget migration UI", () => {
it("keeps legacy definitions visible and exposes the redacted LLM workflow", () => {
const list = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx");
const actions = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx");
expect(list).toContain("widget.migrationRequired");
expect(list).toContain('t("page.list.migrationInstructions")');
expect(actions).toContain("legacyMigrationPrompt.fetch");
expect(actions).toContain("navigator.clipboard.writeText(result.prompt)");
expect(actions).toContain("parseCustomWidgetClipboardDetailed");
expect(actions).toContain("legacyId={widget.id}");
});
it("replaces the legacy record through migration and refreshes every consumer", () => {
const dialog = readSource("apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx");
expect(dialog).toContain("customWidget.migrateLegacy.useMutation");
expect(dialog).toContain("migrateMutation.mutate({ id: legacyId, widget: configuredWidget, secrets })");
expect(dialog).toContain("utils.customWidget.list.invalidate()");
expect(dialog).toContain("utils.customWidget.available.invalidate()");
expect(dialog).toContain("utils.widget.customApi.getData.invalidate()");
});
});
@@ -1,7 +1,7 @@
"use client";
import { ActionIcon, Avatar, Badge, Card, Group, Stack, Switch, Text, Tooltip } from "@mantine/core";
import { IconAlertTriangle, IconApi, IconPencil } from "@tabler/icons-react";
import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -70,7 +70,13 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
<Card
padding="sm"
withBorder={!widget.valid}
bd={!widget.valid ? "1px solid var(--mantine-color-red-6)" : undefined}
bd={
widget.migrationRequired
? "1px solid var(--mantine-color-yellow-6)"
: !widget.valid
? "1px solid var(--mantine-color-red-6)"
: undefined
}
style={{
opacity: widget.valid && !widget.enabled ? 0.55 : 1,
transition: "opacity 150ms ease",
@@ -89,18 +95,34 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
<Text size="sm" fw={600} lineClamp={1} style={{ minWidth: 0 }}>
{widget.name}
</Text>
{!widget.valid && (
{widget.migrationRequired ? (
<Text size="xs" c="yellow.8">
{t("page.list.migrationDescription")}
</Text>
) : !widget.valid ? (
<Text size="xs" c="red">
{t("page.list.invalidDefinitionDescription")}
</Text>
)}
) : null}
{widget.sources[0] && (
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1} style={{ wordBreak: "break-all", minWidth: 0 }}>
{widget.sources.map((source) => source.origin).join(" · ")}
</Text>
)}
</Stack>
{widget.valid ? (
{widget.migrationRequired ? (
<Tooltip label={t("page.list.migrationInstructions")} multiline maw={420}>
<Badge
color="yellow"
size="sm"
variant="light"
leftSection={<IconSparkles size={12} />}
style={{ flexShrink: 0 }}
>
{t("page.list.migrationRequired")}
</Badge>
</Tooltip>
) : widget.valid ? (
<Badge color="pink" size="sm" variant="light" style={{ flexShrink: 0 }}>
JSX · {widget.requestCount}
</Badge>
@@ -166,7 +188,13 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
</>
)}
<CustomWidgetRowActions
widget={{ id: widget.id, name: widget.name, enabled: widget.enabled, valid: widget.valid }}
widget={{
id: widget.id,
name: widget.name,
enabled: widget.enabled,
valid: widget.valid,
migrationRequired: widget.migrationRequired,
}}
/>
</Group>
</Group>
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { getDependentRequestIds, removeDependentRequests } from "./_custom-widget-source-dependencies";
const requests = JSON.stringify({
status: { source: "default", path: "/status" },
restart: { source: "secondary", path: "/restart", kind: "action" },
logs: { source: "secondary", path: "/logs" },
});
describe("custom widget source dependencies", () => {
it("lists every request that would be removed with a source", () => {
expect(getDependentRequestIds(requests, "secondary")).toEqual(["restart", "logs"]);
});
it("removes only requests that use the selected source", () => {
expect(removeDependentRequests(requests, "secondary")).toEqual({
status: { source: "default", path: "/status" },
});
});
});
@@ -0,0 +1,27 @@
export function getDependentRequestIds(requestsJson: string, sourceId: string) {
const requests = parseRequestRecord(requestsJson);
return Object.entries(requests).flatMap(([requestId, request]) =>
isRecord(request) && request.source === sourceId ? [requestId] : [],
);
}
export function removeDependentRequests(requestsJson: string, sourceId: string) {
return Object.fromEntries(
Object.entries(parseRequestRecord(requestsJson)).filter(
([, request]) => !isRecord(request) || request.source !== sourceId,
),
);
}
function parseRequestRecord(requestsJson: string): Record<string, unknown> {
try {
const requests = JSON.parse(requestsJson) as unknown;
return isRecord(requests) ? requests : {};
} catch {
return {};
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,155 @@
"use client";
import { Button, Fieldset, Group, PasswordInput, Select, Stack, TextInput } from "@mantine/core";
import { IconKey, IconTrash } from "@tabler/icons-react";
import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import { useScopedI18n } from "@homarr/translation/client";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
type SecretKind = "apiKey" | "username" | "password";
const secretFields: Record<string, Array<{ kind: SecretKind }>> = {
bearer: [{ kind: "apiKey" }],
basic: [{ kind: "username" }, { kind: "password" }],
apiKeyHeader: [{ kind: "apiKey" }],
apiKeyQuery: [{ kind: "apiKey" }],
};
const sourceUrlErrorKeys: Record<CustomWidgetSourceUrlIssue, `baseUrlError.${CustomWidgetSourceUrlIssue}`> = {
invalid: "baseUrlError.invalid",
protocol: "baseUrlError.protocol",
credentials: "baseUrlError.credentials",
queryOrFragment: "baseUrlError.queryOrFragment",
};
interface CustomWidgetSourceFieldProps {
source: CustomWidgetSource & { id: string };
index: number;
form: CustomWidgetWorkbenchForm;
definitionId?: string;
clearSecretPending: boolean;
onUpdate(index: number, changes: Partial<CustomWidgetSource> & { id?: string }): void;
onSetAuthentication(index: number, type: string): void;
onSetSecret(sourceId: string, kind: string, value: string): void;
onClearSecret(sourceId: string, kind: SecretKind): Promise<void>;
onRemove(index: number): void;
}
export function CustomWidgetSourceField({
source,
index,
form,
definitionId,
clearSecretPending,
onUpdate,
onSetAuthentication,
onSetSecret,
onClearSecret,
onRemove,
}: CustomWidgetSourceFieldProps) {
const t = useScopedI18n("customWidget.workbench.sources");
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
return (
<Fieldset legend={index === 0 ? t("primary") : source.name}>
<Stack gap="sm">
<Group grow align="start">
<CustomWidgetIdentifierInput
label={t("id")}
value={source.id}
disabled={source.id === "default" || Boolean(definitionId)}
error={form.errors.sources}
onCommit={(value) => onUpdate(index, { id: value })}
/>
<TextInput
label={t("name")}
value={source.name}
onChange={(event) => onUpdate(index, { name: event.currentTarget.value })}
/>
</Group>
<TextInput
label={t("baseUrl")}
type="url"
value={source.baseUrl}
error={baseUrlIssue ? t(sourceUrlErrorKeys[baseUrlIssue]) : undefined}
onChange={(event) => onUpdate(index, { baseUrl: event.currentTarget.value })}
/>
<Group grow align="start">
<Select
label={t("networkScope")}
data={["public", "private", "loopback"]}
value={source.networkScope}
onChange={(value) =>
value && onUpdate(index, { networkScope: value as CustomWidgetSource["networkScope"] })
}
allowDeselect={false}
/>
<Select
label={t("authentication")}
data={["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"]}
value={authType}
onChange={(value) => onSetAuthentication(index, value ?? "none")}
allowDeselect={false}
/>
</Group>
{typeof source.auth === "object" && source.auth.type === "apiKeyHeader" && (
<TextInput
label={t("headerName")}
value={source.auth.name}
onChange={(event) => onUpdate(index, { auth: { type: "apiKeyHeader", name: event.currentTarget.value } })}
/>
)}
{typeof source.auth === "object" && source.auth.type === "apiKeyQuery" && (
<TextInput
label={t("queryParameter")}
value={source.auth.name}
onChange={(event) => onUpdate(index, { auth: { type: "apiKeyQuery", name: event.currentTarget.value } })}
/>
)}
{(secretFields[authType] ?? []).map(({ kind }) => {
const secret = form.values.secrets.find((entry) => entry.sourceId === source.id && entry.kind === kind);
const Input = kind === "username" ? TextInput : PasswordInput;
return (
<Group key={kind} align="end" wrap="nowrap">
<Input
style={{ flex: 1 }}
label={t(`secret.${kind}`)}
value={secret?.value ?? ""}
placeholder={secret?.hasValue ? t("configured") : undefined}
leftSection={<IconKey size={15} />}
onChange={(event) => onSetSecret(source.id, kind, event.currentTarget.value)}
/>
{definitionId && secret?.hasValue && (
<Button
type="button"
color="red"
variant="subtle"
loading={clearSecretPending}
onClick={() => void onClearSecret(source.id, kind)}
>
{t("clear")}
</Button>
)}
</Group>
);
})}
{index > 0 && (
<Button
type="button"
color="red"
variant="subtle"
leftSection={<IconTrash size={16} />}
onClick={() => onRemove(index)}
>
{t("remove")}
</Button>
)}
</Stack>
</Fieldset>
);
}
@@ -1,31 +1,18 @@
"use client";
import { Accordion, Button, Fieldset, Group, PasswordInput, Select, Stack, Text, TextInput } from "@mantine/core";
import { IconKey, IconPlus, IconTrash } from "@tabler/icons-react";
import { Accordion, Button, List, Stack, Text } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals";
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
import { filterSecretsForSourceAuthentication, isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
const secretFields: Record<string, Array<{ kind: "apiKey" | "username" | "password" }>> = {
bearer: [{ kind: "apiKey" }],
basic: [{ kind: "username" }, { kind: "password" }],
apiKeyHeader: [{ kind: "apiKey" }],
apiKeyQuery: [{ kind: "apiKey" }],
};
const sourceUrlErrorKeys: Record<CustomWidgetSourceUrlIssue, `baseUrlError.${CustomWidgetSourceUrlIssue}`> = {
invalid: "baseUrlError.invalid",
protocol: "baseUrlError.protocol",
credentials: "baseUrlError.credentials",
queryOrFragment: "baseUrlError.queryOrFragment",
};
import { getDependentRequestIds, removeDependentRequests } from "./_custom-widget-source-dependencies";
import { CustomWidgetSourceField } from "./_custom-widget-source-field";
export function CustomWidgetSourcesEditor({
form,
@@ -35,6 +22,8 @@ export function CustomWidgetSourcesEditor({
definitionId?: string;
}) {
const t = useScopedI18n("customWidget.workbench.sources");
const w = useScopedI18n("customWidget.workbench");
const { openConfirmModal } = useConfirmModal();
const utils = clientApi.useUtils();
const clearSecretMutation = clientApi.customWidget.secretClear.useMutation();
const sources = parseSources(form.values.sources);
@@ -83,6 +72,14 @@ export function CustomWidgetSourcesEditor({
const auth =
type === "apiKeyHeader" ? { type, name: "X-API-Key" } : type === "apiKeyQuery" ? { type, name: "api_key" } : type;
update(index, { auth: auth as CustomWidgetSource["auth"] });
form.setFieldValue(
"secrets",
filterSecretsForSourceAuthentication(
form.values.secrets,
sources[index]?.id ?? "",
type as Parameters<typeof filterSecretsForSourceAuthentication>[2],
),
);
};
const setSecret = (sourceId: string, kind: string, value: string) => {
const current = form.values.secrets.filter((secret) => !(secret.sourceId === sourceId && secret.kind === kind));
@@ -111,7 +108,7 @@ export function CustomWidgetSourcesEditor({
),
);
};
const removeSource = (index: number) => {
const removeSourceAndDependents = (index: number) => {
const removedId = sources[index]?.id;
form.setFieldValue(
"sources",
@@ -122,25 +119,38 @@ export function CustomWidgetSourcesEditor({
),
);
if (!removedId) return;
form.setFieldValue(
"requests",
JSON.stringify(
Object.fromEntries(
Object.entries(
isRecord(parseJson(form.values.requests))
? (parseJson(form.values.requests) as Record<string, unknown>)
: {},
).filter(([, request]) => !isRecord(request) || request.source !== removedId),
),
null,
2,
),
);
form.setFieldValue("requests", JSON.stringify(removeDependentRequests(form.values.requests, removedId), null, 2));
form.setFieldValue(
"secrets",
form.values.secrets.filter((secret) => secret.sourceId !== removedId),
);
};
const removeSource = (index: number) => {
const removedId = sources[index]?.id;
if (!removedId) return;
const dependentRequestIds = getDependentRequestIds(form.values.requests, removedId);
if (dependentRequestIds.length === 0) {
removeSourceAndDependents(index);
return;
}
openConfirmModal({
title: t("remove"),
children: (
<Stack gap="xs">
<Text size="sm" fw={500}>
{w("requests.title")} ({dependentRequestIds.length})
</Text>
<List size="sm">
{dependentRequestIds.map((requestId) => (
<List.Item key={requestId}>{requestId}</List.Item>
))}
</List>
</Stack>
),
confirmProps: { children: t("remove") },
onConfirm: () => removeSourceAndDependents(index),
});
};
const clearSecret = async (sourceId: string, kind: "apiKey" | "username" | "password") => {
if (!definitionId) return;
await clearSecretMutation.mutateAsync({ definitionId, sourceId, kind });
@@ -153,109 +163,20 @@ export function CustomWidgetSourcesEditor({
return (
<Stack gap="sm">
{sources.map((source, index) => {
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
return (
<Fieldset key={index} legend={index === 0 ? t("primary") : source.name}>
<Stack gap="sm">
<Group grow align="start">
<CustomWidgetIdentifierInput
label={t("id")}
value={source.id}
disabled={source.id === "default" || Boolean(definitionId)}
error={form.errors.sources}
onCommit={(value) => update(index, { id: 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}
error={baseUrlIssue ? t(sourceUrlErrorKeys[baseUrlIssue]) : undefined}
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={typeof source.auth === "string" ? source.auth : source.auth.type}
onChange={(value) => setAuth(index, value ?? "none")}
allowDeselect={false}
/>
</Group>
{typeof source.auth === "object" && source.auth.type === "apiKeyHeader" && (
<TextInput
label={t("headerName")}
value={source.auth.name}
onChange={(event) =>
update(index, { auth: { type: "apiKeyHeader", name: event.currentTarget.value } })
}
/>
)}
{typeof source.auth === "object" && source.auth.type === "apiKeyQuery" && (
<TextInput
label={t("queryParameter")}
value={source.auth.name}
onChange={(event) =>
update(index, { auth: { type: "apiKeyQuery", name: event.currentTarget.value } })
}
/>
)}
{(secretFields[typeof source.auth === "string" ? source.auth : 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>
<CustomWidgetSourceField
key={source.id}
source={source}
index={index}
form={form}
definitionId={definitionId}
clearSecretPending={clearSecretMutation.isPending}
onUpdate={update}
onSetAuthentication={setAuth}
onSetSecret={setSecret}
onClearSecret={clearSecret}
onRemove={removeSource}
/>
);
})}
<Accordion variant="contained">
@@ -0,0 +1,104 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useDisclosure } from "@mantine/hooks";
import { IconUpload } from "@tabler/icons-react";
import {
formatCustomWidgetImportIssues,
looksLikeCustomWidgetClipboard,
parseCustomWidgetClipboardDetailed,
} from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
export const ImportCustomWidgetButton = () => {
const t = useScopedI18n("customWidget");
const fileInputRef = useRef<HTMLInputElement>(null);
const [pendingImport, setPendingImport] = useState<HomarrCustomWidgetV2 | null>(null);
const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false);
const queueImport = useCallback(
(value: HomarrCustomWidgetV2) => {
setPendingImport(value);
openReview();
},
[openReview],
);
useEffect(() => {
const handlePaste = (event: ClipboardEvent) => {
if (event.target instanceof Element && event.target.matches("input, textarea, [contenteditable='true']")) return;
const text = event.clipboardData?.getData("text/plain") ?? "";
if (!looksLikeCustomWidgetClipboard(text)) return;
event.preventDefault();
const result = parseCustomWidgetClipboardDetailed(text);
if (!result.success) {
showErrorNotification({
title: t("action.import"),
message: formatCustomWidgetImportIssues(result.issues),
});
return;
}
queueImport(result.widget);
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [queueImport, t]);
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.addEventListener("load", (loadEvent) => {
try {
const result = parseCustomWidgetClipboardDetailed(loadEvent.target?.result as string);
if (!result.success) throw new Error(formatCustomWidgetImportIssues(result.issues));
queueImport(result.widget);
} catch (error) {
showErrorNotification({
title: t("action.import"),
message: error instanceof Error ? error.message : t("notification.importError"),
});
}
});
reader.addEventListener("error", () => {
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
});
reader.readAsText(file);
event.target.value = "";
};
return (
<>
<MobileAffixButton
variant="default"
leftSection={<IconUpload size={16} />}
onClick={() => fileInputRef.current?.click()}
title={t("action.pasteImportHint")}
>
{t("action.import")}
</MobileAffixButton>
<input
ref={fileInputRef}
type="file"
accept=".json,.md,text/markdown,application/json"
hidden
onChange={handleImport}
aria-label={t("importReview.fileLabel")}
/>
<CustomWidgetImportDialog
opened={reviewOpened}
widget={pendingImport}
onClose={() => {
closeReview();
setPendingImport(null);
}}
/>
</>
);
};
@@ -0,0 +1,42 @@
interface UnsavedChangesGuardOptions {
isDirty(): boolean;
confirmNavigation(href: string): void;
}
export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: UnsavedChangesGuardOptions) {
const handleClick = (event: MouseEvent) => {
if (!isDirty() || event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const eventTarget = event.target instanceof Element ? event.target : null;
const anchor = eventTarget?.closest<HTMLAnchorElement>("a[href]");
if (!anchor || anchor.target === "_blank" || anchor.hasAttribute("download")) return;
const destination = new URL(anchor.href, window.location.href);
const current = new URL(window.location.href);
if (destination.origin !== current.origin) return;
if (
destination.pathname === current.pathname &&
destination.search === current.search &&
destination.hash !== current.hash
) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
confirmNavigation(`${destination.pathname}${destination.search}${destination.hash}`);
};
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!isDirty()) return;
event.preventDefault();
event.returnValue = true;
};
document.addEventListener("click", handleClick, true);
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
document.removeEventListener("click", handleClick, true);
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}
@@ -0,0 +1,51 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { registerUnsavedChangesGuard } from "./_unsaved-changes-guard";
const cleanups: Array<() => void> = [];
afterEach(() => {
cleanups.splice(0).forEach((cleanup) => cleanup());
document.body.replaceChildren();
window.history.replaceState(null, "", "/en/manage/custom-widgets/edit/widget-1");
});
describe("custom widget unsaved changes guard", () => {
it("blocks client-side link navigation and requests confirmation for a dirty form", () => {
window.history.replaceState(null, "", "/en/manage/custom-widgets/edit/widget-1");
const confirmNavigation = vi.fn();
cleanups.push(registerUnsavedChangesGuard({ isDirty: () => true, confirmNavigation }));
const link = document.createElement("a");
link.href = "/en/manage/custom-widgets";
link.textContent = "Custom widgets";
document.body.append(link);
const event = new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 });
link.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(confirmNavigation).toHaveBeenCalledWith("/en/manage/custom-widgets");
});
it("allows section links and navigation after the form is clean", () => {
window.history.replaceState(null, "", "/en/manage/custom-widgets/edit/widget-1");
let dirty = true;
const confirmNavigation = vi.fn();
cleanups.push(registerUnsavedChangesGuard({ isDirty: () => dirty, confirmNavigation }));
const sectionLink = document.createElement("a");
sectionLink.href = "#sources";
document.body.append(sectionLink);
expect(sectionLink.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }))).toBe(
true,
);
dirty = false;
const listLink = document.createElement("a");
listLink.href = "/en/manage/custom-widgets";
document.body.append(listLink);
expect(listLink.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }))).toBe(true);
expect(confirmNavigation).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,34 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { registerUnsavedChangesGuard } from "./_unsaved-changes-guard";
export function useUnsavedChangesGuard(isDirty: boolean) {
const t = useI18n();
const router = useRouter();
const { openConfirmModal } = useConfirmModal();
const dirtyRef = useRef(isDirty);
const confirmNavigationRef = useRef<(href: string) => void>(() => undefined);
dirtyRef.current = isDirty;
confirmNavigationRef.current = (href) => {
openConfirmModal({
title: t("board.action.edit.confirmLeave.title"),
children: t("board.action.edit.confirmLeave.message"),
confirmProps: { children: t("common.action.discard") },
onConfirm: () => router.push(href),
});
};
useEffect(
() =>
registerUnsavedChangesGuard({
isDirty: () => dirtyRef.current,
confirmNavigation: (href) => confirmNavigationRef.current(href),
}),
[],
);
}
@@ -11,7 +11,7 @@ import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { CustomWidgetBetaBanner } from "./_beta-banner";
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
import { ImportCustomWidgetButton } from "./_custom-widget-actions";
import { ImportCustomWidgetButton } from "./_import-custom-widget-button";
import { CustomWidgetList } from "./_custom-widget-list";
export default async function CustomWidgetsPage() {
@@ -16,6 +16,8 @@ import { customWidgetSourceSchema, hasSameCustomWidgetSourceAuthentication } fro
import { and, db, eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import { readConfigurationRequestBody } from "../body";
interface RouteContext {
params: Promise<{ token: string }>;
}
@@ -44,15 +46,18 @@ export async function POST(request: NextRequest, context: RouteContext) {
if (!pending || pending.status !== "pending") {
return NextResponse.json({ error: "This credential request is invalid, completed, or expired." }, { status: 404 });
}
const body = (await request.json().catch(() => null)) as {
baseUrl?: unknown;
networkScope?: unknown;
secrets?: Record<string, unknown>;
} | null;
const parsedBody = await readConfigurationRequestBody(request);
if (parsedBody.status === "too-large") {
return NextResponse.json({ error: "The configuration payload is too large." }, { status: 413 });
}
if (parsedBody.status !== "ok") {
return NextResponse.json({ error: "Enter a valid source configuration." }, { status: 400 });
}
const body = parsedBody.data;
const sourceResult = customWidgetSourceSchema.safeParse({
...pending.source,
baseUrl: body?.baseUrl,
networkScope: body?.networkScope,
baseUrl: body.baseUrl,
networkScope: body.networkScope,
});
if (!sourceResult.success) {
return NextResponse.json(
@@ -61,7 +66,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
);
}
const secrets = pending.kinds.flatMap((kind) => {
const value = body?.secrets?.[kind];
const value = body.secrets[kind];
return typeof value === "string" && value.length > 0 && value.length <= 8192
? [{ sourceId: pending.sourceId, kind, value }]
: [];
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { MAX_CONFIGURATION_REQUEST_BODY_BYTES, readConfigurationRequestBody } from "./body";
describe("custom widget configuration request body", () => {
it("rejects a streaming payload once it exceeds the byte budget", async () => {
const request = new Request("http://localhost/configure", {
method: "POST",
body: JSON.stringify({
baseUrl: "https://example.com",
networkScope: "public",
secrets: { apiKey: "x".repeat(MAX_CONFIGURATION_REQUEST_BODY_BYTES) },
}),
});
await expect(readConfigurationRequestBody(request)).resolves.toEqual({ status: "too-large" });
});
it("strictly parses a bounded configuration object", async () => {
const body = { baseUrl: "https://example.com", networkScope: "public", secrets: { apiKey: "secret" } };
await expect(
readConfigurationRequestBody(
new Request("http://localhost/configure", { method: "POST", body: JSON.stringify(body) }),
),
).resolves.toEqual({ status: "ok", data: body });
await expect(
readConfigurationRequestBody(
new Request("http://localhost/configure", {
method: "POST",
body: JSON.stringify({ ...body, unexpected: true }),
}),
),
).resolves.toEqual({ status: "invalid" });
});
});
@@ -0,0 +1,52 @@
import { z } from "zod/v4";
export const MAX_CONFIGURATION_REQUEST_BODY_BYTES = 24 * 1024;
const configurationRequestBodySchema = z.strictObject({
baseUrl: z.string(),
networkScope: z.enum(["public", "private", "loopback"]),
secrets: z.record(z.string(), z.string()),
});
export async function readConfigurationRequestBody(
request: Request,
): Promise<
{ status: "ok"; data: z.infer<typeof configurationRequestBodySchema> } | { status: "invalid" | "too-large" }
> {
const contentLength = request.headers.get("content-length");
if (contentLength && Number(contentLength) > MAX_CONFIGURATION_REQUEST_BODY_BYTES) {
return { status: "too-large" };
}
if (!request.body) return { status: "invalid" };
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let byteLength = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
byteLength += value.byteLength;
if (byteLength > MAX_CONFIGURATION_REQUEST_BODY_BYTES) {
await reader.cancel();
return { status: "too-large" };
}
chunks.push(value);
}
} catch {
return { status: "invalid" };
}
const bytes = new Uint8Array(byteLength);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
try {
const candidate = JSON.parse(new TextDecoder().decode(bytes)) as unknown;
const result = configurationRequestBodySchema.safeParse(candidate);
return result.success ? { status: "ok", data: result.data } : { status: "invalid" };
} catch {
return { status: "invalid" };
}
}
@@ -0,0 +1,196 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
authenticate: vi.fn(),
buildPrompt: vi.fn((request?: string, documentationUrl?: string) =>
[`Request: ${request ?? ""}`, `Documentation: ${documentationUrl ?? ""}`].join("\n"),
),
getComponent: vi.fn((name: string) => (name === "Text Input" ? { name, safety: "allowed" } : null)),
}));
vi.mock("next/server", () => ({ userAgent: () => ({ ua: "MCP route test" }) }));
vi.mock("@homarr/api/mcp", () => ({
createTRPCContext: vi.fn(() => ({})),
mcpRouter: { createCaller: vi.fn(() => ({})) },
}));
vi.mock("@homarr/auth/api-key", () => ({
API_KEY_HEADER_NAME: "ApiKey",
getSessionFromApiKeyAsync: mocks.authenticate,
}));
vi.mock("@homarr/common", () => ({ extractBaseUrlFromHeaders: () => "http://localhost" }));
vi.mock("@homarr/common/server", () => ({ ipAddressFromHeaders: () => "127.0.0.1" }));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
}));
vi.mock("@homarr/custom-widgets/authoring-prompt", () => ({ buildCustomWidgetMcpPrompt: mocks.buildPrompt }));
vi.mock("@homarr/custom-widgets/authoring-resources", () => ({
getCustomWidgetComponent: mocks.getComponent,
getCustomWidgetComponentCatalog: () => ({ components: [{ name: "Text Input" }] }),
getCustomWidgetSkillContent: () => "# Custom Widget Skill",
}));
vi.mock("@homarr/custom-widgets/core", () => ({
customJsxExamples: [{ id: "status-card", title: "Status card", template: "<Text>Status</Text>" }],
getCustomWidgetJsonSchema: () => ({ type: "object", title: "Custom Widget" }),
}));
vi.mock("@homarr/db", () => ({ db: {} }));
vi.mock("~/versions/package-reader", () => ({ getPackageVersion: () => "test-version" }));
vi.mock("../_extract-tools", () => ({ extractMcpTools: () => [] }));
import { POST } from "./route";
interface JsonRpcResponse {
jsonrpc: "2.0";
id: number | null;
result?: Record<string, unknown>;
error?: { code: number; message: string };
}
beforeEach(() => {
mocks.authenticate.mockResolvedValue({ user: { id: "user-1" } });
mocks.buildPrompt.mockClear();
mocks.getComponent.mockClear();
});
async function callMcp(method: string, params: Record<string, unknown> = {}, id = 1) {
const request = new Request("http://localhost/api/mcp/mcp", {
method: "POST",
headers: {
Accept: "application/json, text/event-stream",
ApiKey: "key.secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
});
const response = await POST(request as never);
const text = await response.text();
const data = response.headers.get("content-type")?.includes("text/event-stream")
? text
.split("\n")
.find((line) => line.startsWith("data: "))
?.slice("data: ".length)
: text;
if (!data) throw new Error(`MCP response did not contain JSON-RPC data: ${text}`);
return { response, body: JSON.parse(data) as JsonRpcResponse };
}
describe("authenticated MCP prompt protocol", () => {
test("initializes and advertises prompt and resource capabilities", async () => {
const { response, body } = await callMcp("initialize", {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "route-test", version: "1.0.0" },
});
expect(response.status).toBe(200);
expect(body.error).toBeUndefined();
expect(body.result).toMatchObject({
protocolVersion: expect.any(String),
capabilities: { prompts: {}, resources: {} },
serverInfo: { name: "homarr", version: "test-version" },
});
expect(mocks.authenticate).toHaveBeenCalledWith({}, "key.secret", "127.0.0.1", "MCP route test");
});
test("lists and interpolates the custom-widget authoring prompt", async () => {
const listed = await callMcp("prompts/list");
expect(listed.body.result).toEqual({
prompts: [
{
name: "homarr-custom-widget-author",
description: "Author and iterate on one Homarr Custom JSX v2 widget.",
arguments: [
{ name: "request", description: "The widget the user wants.", required: false },
{ name: "documentationUrl", description: "External API documentation URL.", required: false },
],
},
],
});
const rendered = await callMcp("prompts/get", {
name: "homarr-custom-widget-author",
arguments: { request: "Build a status card", documentationUrl: "https://docs.example.test/api" },
});
expect(rendered.body.result).toMatchObject({
description: "Current Homarr Custom Widget authoring workflow.",
messages: [
{
role: "user",
content: {
type: "text",
text: "Request: Build a status card\nDocumentation: https://docs.example.test/api",
},
},
],
});
expect(mocks.buildPrompt).toHaveBeenCalledWith("Build a status card", "https://docs.example.test/api");
});
});
describe("authenticated MCP resource protocol", () => {
test("lists concrete resources and URI templates with reviewed MIME types", async () => {
const listed = await callMcp("resources/list");
expect(listed.body.result).toEqual({
resources: [
{ uri: "homarr://custom-widgets/schema", name: "Custom Widget schema", mimeType: "application/schema+json" },
{ uri: "homarr://custom-widgets/components", name: "Custom Widget components", mimeType: "application/json" },
{ uri: "homarr://custom-widgets/skill", name: "Custom Widget skill", mimeType: "text/markdown" },
{
uri: "homarr://custom-widgets/examples/status-card",
name: "Custom Widget example: Status card",
mimeType: "application/json",
},
],
});
const templates = await callMcp("resources/templates/list");
expect(templates.body.result).toEqual({
resourceTemplates: [
expect.objectContaining({
uriTemplate: "homarr://custom-widgets/components/{name}",
mimeType: "application/json",
}),
expect.objectContaining({
uriTemplate: "homarr://custom-widgets/examples/{name}",
mimeType: "application/json",
}),
],
});
});
test.each([
["homarr://custom-widgets/schema", "application/schema+json", { type: "object", title: "Custom Widget" }],
["homarr://custom-widgets/components", "application/json", { components: [{ name: "Text Input" }] }],
["homarr://custom-widgets/skill", "text/markdown", "# Custom Widget Skill"],
[
"homarr://custom-widgets/examples/status-card",
"application/json",
{ id: "status-card", title: "Status card", template: "<Text>Status</Text>" },
],
["homarr://custom-widgets/components/Text%20Input", "application/json", { name: "Text Input", safety: "allowed" }],
])("dispatches %s with its MIME type", async (uri, mimeType, expected) => {
const { body } = await callMcp("resources/read", { uri });
const contents = body.result?.contents as Array<{ uri: string; mimeType: string; text: string }> | undefined;
const content = contents?.[0];
expect(content).toMatchObject({ uri, mimeType });
expect(mimeType === "text/markdown" ? content?.text : JSON.parse(content?.text ?? "null")).toEqual(expected);
});
});
describe("MCP protocol errors", () => {
test.each([
["prompts/get", { name: "unknown" }],
["resources/read", { uri: "homarr://custom-widgets/unknown" }],
])("returns JSON-RPC errors for unknown %s targets", async (method, params) => {
const { body } = await callMcp(method, params);
expect(body.result).toBeUndefined();
expect(body.error).toEqual(expect.objectContaining({ code: expect.any(Number), message: expect.any(String) }));
});
test("returns a protocol error for a malformed resource request", async () => {
const { body } = await callMcp("resources/read", {});
expect(body.result).toBeUndefined();
expect(body.error).toEqual(expect.objectContaining({ code: -32603, message: expect.any(String) }));
});
});
@@ -249,8 +249,10 @@ const mcpHandler = createMcpHandler(
const { uri } = request.params;
let text: string;
let mimeType = "application/json";
if (uri === "homarr://custom-widgets/schema") text = JSON.stringify(getCustomWidgetJsonSchema());
else if (uri === "homarr://custom-widgets/components") text = JSON.stringify(getCustomWidgetComponentCatalog());
if (uri === "homarr://custom-widgets/schema") {
text = JSON.stringify(getCustomWidgetJsonSchema());
mimeType = "application/schema+json";
} else if (uri === "homarr://custom-widgets/components") text = JSON.stringify(getCustomWidgetComponentCatalog());
else if (uri === "homarr://custom-widgets/skill") {
text = getCustomWidgetSkillContent();
mimeType = "text/markdown";
@@ -24,6 +24,7 @@ import { useScopedI18n } from "@homarr/translation/client";
interface CustomWidgetImportDialogProps {
opened: boolean;
widget: HomarrCustomWidgetV2 | null;
legacyId?: string;
stackId?: string;
zIndex?: number;
onClose(): void;
@@ -33,6 +34,7 @@ interface CustomWidgetImportDialogProps {
export function CustomWidgetImportDialog({
opened,
widget,
legacyId,
stackId,
zIndex,
onClose,
@@ -59,19 +61,27 @@ export function CustomWidgetImportDialog({
};
}, [values, widget]);
const review = useMemo(() => getImportReview(configuredWidget), [configuredWidget]);
const mutation = clientApi.customWidget.import.useMutation({
onSuccess: (result) => {
showSuccessNotification({ title: t("action.import"), message: t("notification.imported") });
void utils.customWidget.list.invalidate();
void utils.customWidget.available.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets").then(() => router.refresh());
onImported?.(result);
onClose();
},
onError: (error) => {
showErrorNotification({ title: t("action.import"), message: error.message || t("notification.importError") });
},
});
const onSuccess = (result: { id: string }) => {
showSuccessNotification({
title: legacyId ? t("action.migrate") : t("action.import"),
message: legacyId ? t("notification.migrated") : t("notification.imported"),
});
void utils.customWidget.list.invalidate();
void utils.customWidget.available.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets").then(() => router.refresh());
onImported?.(result);
onClose();
};
const onError = (error: { message?: string }) => {
showErrorNotification({
title: legacyId ? t("action.migrate") : t("action.import"),
message: error.message || (legacyId ? t("notification.migrationError") : t("notification.importError")),
});
};
const importMutation = clientApi.customWidget.import.useMutation({ onSuccess, onError });
const migrateMutation = clientApi.customWidget.migrateLegacy.useMutation({ onSuccess, onError });
const pending = importMutation.isPending || migrateMutation.isPending;
useEffect(() => {
if (opened) setValues(createCustomWidgetSourceSetupValues(setups));
@@ -84,7 +94,8 @@ export function CustomWidgetImportDialog({
secret?.trim() ? [{ sourceId, kind: kind as CustomWidgetSecretKind, value: secret }] : [],
),
);
mutation.mutate({ widget: configuredWidget, secrets });
if (legacyId) migrateMutation.mutate({ id: legacyId, widget: configuredWidget, secrets });
else importMutation.mutate({ widget: configuredWidget, secrets });
};
return (
@@ -93,13 +104,13 @@ export function CustomWidgetImportDialog({
stackId={stackId}
zIndex={zIndex}
review={review}
pending={mutation.isPending}
pending={pending}
confirmDisabled={!isCustomWidgetSourceSetupReady(setups, values)}
onClose={onClose}
onConfirm={importWidget}
messages={{
title: t("importReview.title"),
description: t("importReview.description"),
title: legacyId ? t("importReview.migrationTitle") : t("importReview.title"),
description: legacyId ? t("importReview.migrationDescription") : t("importReview.description"),
name: t("importReview.name"),
origin: t("importReview.origin"),
authentication: t("importReview.authentication"),
@@ -109,7 +120,7 @@ export function CustomWidgetImportDialog({
actionWarningTitle: t("importReview.actionWarning.title"),
actionWarningDescription: t("importReview.actionWarning.description"),
cancel: t("importReview.cancel"),
confirm: t("importReview.confirm"),
confirm: legacyId ? t("importReview.confirmMigration") : t("importReview.confirm"),
permission: (permission) => t(`preview.request.permission.${permission}` as never),
}}
>
@@ -0,0 +1,41 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
getPrivateWorkshopSourceNames,
serializeWorkshopDefinition,
workshopDefinitionChanged,
} from "./workshop-publish-definition";
const definition = {
name: "Status",
sources: {
public: { name: "Public API", networkScope: "public" },
private: { name: "Home server", networkScope: "private" },
local: { networkScope: "loopback" },
},
};
describe("Workshop publish definition inspection", () => {
it("reports every source URL that requires review", () => {
expect(getPrivateWorkshopSourceNames(definition)).toEqual(["Home server", "local"]);
});
it("detects an export that changed after inspection", () => {
expect(workshopDefinitionChanged(definition, definition)).toBe(false);
expect(workshopDefinitionChanged(definition, { ...definition, name: "Updated" })).toBe(true);
});
it("submits the same refreshed export that passed the change check", () => {
const source = readFileSync(
`${process.cwd()}/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx`,
"utf8",
);
expect(source).toContain("customWidget.export.useQuery");
expect(source).toContain("await definition.refetch()");
expect(source).toContain("workshopDefinitionChanged(inspectedDefinition, refreshed.data)");
expect(source).toContain("content: serializeWorkshopDefinition(refreshed.data)");
expect(source).toMatch(/disabled=\{[\s\S]*!definition\.data[\s\S]*definition\.isError/u);
expect(serializeWorkshopDefinition(definition)).toBe(JSON.stringify(definition));
});
});
@@ -0,0 +1,17 @@
interface PublishableWorkshopDefinition {
sources: Record<string, { name?: string; networkScope: string }>;
}
export const serializeWorkshopDefinition = (definition: unknown): string => {
const serialized = JSON.stringify(definition);
if (serialized === undefined) throw new Error("Workshop definition is not serializable");
return serialized;
};
export const workshopDefinitionChanged = (previous: unknown, current: unknown) =>
serializeWorkshopDefinition(previous) !== serializeWorkshopDefinition(current);
export const getPrivateWorkshopSourceNames = (definition: PublishableWorkshopDefinition | undefined) =>
Object.entries(definition?.sources ?? {}).flatMap(([sourceId, source]) =>
source.networkScope === "public" ? [] : [source.name ?? sourceId],
);
@@ -10,6 +10,11 @@ import type { WorkshopUser } from "@homarr/workshop/schema";
import { useScopedI18n } from "@homarr/translation/client";
import { createWorkshopClient } from "./workshop-client";
import {
getPrivateWorkshopSourceNames,
serializeWorkshopDefinition,
workshopDefinitionChanged,
} from "./workshop-publish-definition";
export function WorkshopPublishModal({
opened,
@@ -22,7 +27,6 @@ export function WorkshopPublishModal({
}) {
const t = useScopedI18n("workshop");
const client = useMemo(createWorkshopClient, []);
const utils = clientApi.useUtils();
const [user, setUser] = useState<WorkshopUser | null>(null);
const [title, setTitle] = useState(widget.name);
const [description, setDescription] = useState("");
@@ -31,10 +35,9 @@ export function WorkshopPublishModal({
const [error, setError] = useState<string | null>(null);
const [published, setPublished] = useState(false);
const [sourceUrlsReviewed, setSourceUrlsReviewed] = useState(false);
const definition = clientApi.customWidget.get.useQuery({ id: widget.id }, { enabled: opened });
const privateSources = Object.entries(definition.data?.sources ?? {}).filter(
([, source]) => source.networkScope !== "public",
);
const definition = clientApi.customWidget.export.useQuery({ id: widget.id }, { enabled: opened });
const privateSourceNames = getPrivateWorkshopSourceNames(definition.data);
const definitionFingerprint = definition.data ? serializeWorkshopDefinition(definition.data) : null;
useEffect(() => {
const unsubscribe = client.subscribeToAuth(setUser);
@@ -50,13 +53,27 @@ export function WorkshopPublishModal({
setPublished(false);
setSourceUrlsReviewed(false);
}, [opened, widget.name]);
useEffect(() => setSourceUrlsReviewed(false), [definitionFingerprint]);
const publish = async () => {
setError(null);
try {
const definition = await utils.customWidget.export.fetch({ id: widget.id });
if (!definition.data) throw new Error(t("publish.error"));
const inspectedDefinition = definition.data;
const refreshed = await definition.refetch();
if (!refreshed.data) throw new Error(t("publish.error"));
if (workshopDefinitionChanged(inspectedDefinition, refreshed.data)) {
setSourceUrlsReviewed(false);
setError(t("publish.error"));
return;
}
await createSubmission.mutateAsync({
input: { type: "customWidget", title, description, content: JSON.stringify(definition) },
input: {
type: "customWidget",
title,
description,
content: serializeWorkshopDefinition(refreshed.data),
},
screenshots,
});
setPublished(true);
@@ -113,10 +130,10 @@ export function WorkshopPublishModal({
value={screenshots}
onChange={setScreenshots}
/>
{privateSources.length > 0 && (
{privateSourceNames.length > 0 && (
<Alert color="yellow">
{t("publish.privateSourceWarning", {
sources: privateSources.map(([sourceId, source]) => source.name ?? sourceId).join(", "),
sources: privateSourceNames.join(", "),
})}
<Checkbox
mt="sm"
@@ -126,10 +143,17 @@ export function WorkshopPublishModal({
/>
</Alert>
)}
{definition.isError && <Alert color="red">{t("publish.error")}</Alert>}
{error && <Alert color="red">{error}</Alert>}
<Button
loading={createSubmission.isPending}
disabled={!user || title.trim().length < 3 || (privateSources.length > 0 && !sourceUrlsReviewed)}
loading={createSubmission.isPending || definition.isFetching}
disabled={
!user ||
!definition.data ||
definition.isError ||
title.trim().length < 3 ||
(privateSourceNames.length > 0 && !sourceUrlsReviewed)
}
onClick={() => void publish()}
>
{t("publish.action")}