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

@@ -95,7 +95,7 @@ describe("CopyAiPromptButton", () => {
});
});
test("copies the complete offline bundle and reports its exact character count", async () => {
test("copies the complete embedded skill and reports its exact character count", async () => {
await act(async () => {
root.render(
<MantineProvider>
@@ -120,10 +120,26 @@ describe("CopyAiPromptButton", () => {
const prompt = writeText.mock.calls[0]?.[0] as string;
expect(prompt.startsWith("Please create this Homarr Custom JSX v2 widget:\n\nCreate a Pokédex")).toBe(true);
expect(prompt).toContain("# Homarr Custom Widget offline authoring bundle");
expect(prompt).toContain("--- BEGIN SKILL FILE: SKILL.md ---");
expect(prompt).toContain("--- BEGIN SKILL FILE: references/widget-schema.json ---");
expect(prompt).toContain("--- BEGIN CANONICAL COMPONENT CATALOG ---");
expect(prompt).toContain("--- BEGIN CANONICAL RUNTIME EXAMPLES ---");
expect(prompt.length).toBeGreaterThan(500_000);
expect(prompt.length).toBeLessThanOrEqual(1_700_000);
expect(prompt.endsWith(`${CUSTOM_WIDGET_OFFLINE_BUNDLE_SENTINEL}\nCharacters: ${prompt.length}`)).toBe(true);
expect(showSuccessNotification).toHaveBeenCalledWith({
title: "action.copyAiPrompt",
message: `notification.aiPromptCopiedWithCount:${prompt.length}`,
});
});
test("places the AI authoring card before General information", async () => {
const source = await readFile(
resolve(process.cwd(), "apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx"),
"utf8",
);
expect(source.indexOf("<CustomWidgetAiCard")).toBeLessThan(source.indexOf('<EditorSection id="general"'));
});
});
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { getCustomWidgetPreviewOptionIssues, loadPreviewQueries } from "./_custom-widget-form-utils";
import { getChangedSecrets, getCustomWidgetPreviewOptionIssues, loadPreviewQueries } from "./_custom-widget-form-utils";
const previewQuery = vi.hoisted(() => vi.fn());
@@ -64,4 +64,23 @@ describe("Custom Widget workbench preview options", () => {
{ path: "configuration.environmentId", message: "Expected number" },
]);
});
it("normalizes only changed secrets for save and preview", () => {
expect(
getChangedSecrets({
name: "Widget",
description: "",
iconUrl: "",
sources: "[]",
requests: "[]",
optionsSchema: "{}",
defaultOptions: "{}",
template: "<Text />",
secrets: [
{ sourceId: "default", kind: "apiKey", value: "", hasValue: true },
{ sourceId: "other", kind: "password", value: "replacement" },
],
}),
).toEqual([{ sourceId: "other", kind: "password", value: "replacement" }]);
});
});
@@ -24,6 +24,16 @@ export function buildDefinition(values: CustomWidgetFormValues) {
});
}
export function getChangedSecrets(values: CustomWidgetFormValues) {
return values.secrets
.filter((secret) => secret.value.trim())
.map(({ sourceId, kind, value }) => ({
sourceId,
kind: kind as "apiKey" | "username" | "password",
value,
}));
}
export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrCustomWidgetV2) {
form.setValues({
...form.values,
@@ -165,6 +165,14 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
</nav>
<div className={classes.workbench} data-mobile-pane={mobilePane}>
<Stack gap="lg" className={classes.configuration}>
<CustomWidgetAiCard
candidate={candidate.success ? candidate.data : null}
request={request}
onRequestChange={setRequest}
documentationUrl={documentationUrl}
onDocumentationUrlChange={setDocumentationUrl}
onPaste={() => void pasteAiResponse()}
/>
<EditorSection id="general" title={w("generalInformation")} icon={IconSettings}>
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
<Textarea label={t("field.description")} autosize minRows={2} {...form.getInputProps("description")} />
@@ -224,14 +232,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
maxLength={50_000}
/>
</EditorSection>
<CustomWidgetAiCard
candidate={candidate.success ? candidate.data : null}
request={request}
onRequestChange={setRequest}
documentationUrl={documentationUrl}
onDocumentationUrlChange={setDocumentationUrl}
onPaste={() => void pasteAiResponse()}
/>
<Paper p="md" className={classes.mobileSaveBar} shadow="sm">
<SaveActions
dirty={form.isDirty()}
@@ -18,6 +18,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import {
applyDefinition,
buildDefinition,
getChangedSecrets,
getCustomWidgetPreviewOptionIssues,
isRecord,
loadPreviewQueries,
@@ -63,13 +64,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
);
return;
}
const changedSecrets = values.secrets
.filter((secret) => secret.value.trim())
.map(({ sourceId, kind, value }) => ({
sourceId,
kind: kind as "apiKey" | "username" | "password",
value,
}));
const changedSecrets = getChangedSecrets(values);
try {
if (input.mode === "create") {
const result = await createMutation.mutateAsync({ ...definition.data, secrets: changedSecrets });
@@ -135,13 +130,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
definition: input.candidate.data,
definitionId: input.definitionId,
options: input.optionsSnapshot,
secrets: input.form.values.secrets
.filter((secret) => secret.value.trim())
.map(({ sourceId, kind, value }) => ({
sourceId,
kind: kind as "apiKey" | "username" | "password",
value,
})),
secrets: getChangedSecrets(input.form.values),
});
const snapshot = await loadPreviewQueries(input.candidate.data, created.previewSession.id, input.optionsSnapshot);
const failed = Object.values(snapshot.status).filter((status) => isRecord(status) && status.ok === false).length;
@@ -13,5 +13,6 @@ export function GET() {
"homarr://custom-widgets/components",
"homarr://custom-widgets/skill",
],
httpResources: ["/api/custom-widgets/schema", "/api/custom-widgets/components", "/api/custom-widgets/skill"],
});
}
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
export const dynamic = "force-static";
export function GET() {
return NextResponse.json(getCustomWidgetJsonSchema());
}
@@ -22,7 +22,7 @@ import { buildCustomWidgetMcpPrompt } from "@homarr/custom-widgets/authoring-pro
import {
getCustomWidgetComponent,
getCustomWidgetComponentCatalog,
getCustomWidgetSkill,
getCustomWidgetOfflineBundle,
} from "@homarr/custom-widgets/authoring-resources";
import { customJsxExamples, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
import { db } from "@homarr/db";
@@ -252,8 +252,7 @@ const mcpHandler = createMcpHandler(
if (uri === "homarr://custom-widgets/schema") text = JSON.stringify(getCustomWidgetJsonSchema());
else if (uri === "homarr://custom-widgets/components") text = JSON.stringify(getCustomWidgetComponentCatalog());
else if (uri === "homarr://custom-widgets/skill") {
const skill = getCustomWidgetSkill();
text = `${skill.skillMd}\n\nInstall from skills.sh: ${skill.skillsShUrl}\nSource: ${skill.sourceUrl}\nCommand: ${skill.installCommand}\n`;
text = getCustomWidgetOfflineBundle();
mimeType = "text/markdown";
} else if (uri.startsWith("homarr://custom-widgets/components/")) {
const name = decodeURIComponent(uri.slice("homarr://custom-widgets/components/".length));
@@ -10,6 +10,7 @@ import {
Code,
Group,
Image,
Loader,
Modal,
Pagination,
Select,
@@ -33,6 +34,8 @@ import {
} from "@tabler/icons-react";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import type { WorkshopReport, WorkshopSubmissionDetail, WorkshopUser } from "@homarr/workshop";
import { validateWorkshopWidget, workshopExportFilename } from "@homarr/workshop";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -49,7 +52,7 @@ function downloadWorkshopSubmission(submission: WorkshopSubmissionDetail) {
URL.revokeObjectURL(url);
}
export function WorkshopBrowser({ onInstall }: { onInstall?(submission: WorkshopSubmissionDetail): Promise<void> }) {
export function WorkshopBrowser({ onInstall }: { onInstall?(widget: HomarrCustomWidgetV2): Promise<void> }) {
const t = useScopedI18n("workshop");
const client = useMemo(createWorkshopClient, []);
const queryClient = useQueryClient();
@@ -85,6 +88,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
() => (detail.data ? validateWorkshopWidget(detail.data.content) : null),
[detail.data],
);
const detailCompatible = detailValidation?.success === true && detail.data?.widgetSchema === CUSTOM_WIDGET_SCHEMA;
const signIn = () => {
setLoginPending(true);
setLoginError(null);
@@ -99,7 +103,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
.finally(() => setLoginPending(false));
};
const install = useMutation({
mutationFn: async (submission: WorkshopSubmissionDetail) => onInstall?.(submission),
mutationFn: async (widget: HomarrCustomWidgetV2) => onInstall?.(widget),
onSuccess: () => setSelectedId(null),
});
const vote = useMutation({
@@ -167,6 +171,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
<TextInput
label={t("search")}
leftSection={<IconSearch size={16} />}
rightSection={list.isFetching ? <Loader size={16} /> : undefined}
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
@@ -194,7 +199,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
<Skeleton h={280} />
</SimpleGrid>
) : list.isError ? (
<Alert color="red">{t("unavailable")}</Alert>
<Alert color="red">{list.error instanceof Error ? list.error.message : t("unavailable")}</Alert>
) : list.data?.items.length ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
{list.data.items.map((item) => (
@@ -223,6 +228,9 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
<IconThumbUp size={13} /> {item.upvotes} · <IconThumbDown size={13} /> {item.downvotes}
</Text>
</Group>
<Badge color={item.widgetSchema === CUSTOM_WIDGET_SCHEMA ? "green" : "red"} variant="light">
{item.widgetSchema}
</Badge>
<Text fw={700} lineClamp={1}>
{item.title}
</Text>
@@ -254,7 +262,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
{detail.isPending ? (
<Skeleton h={400} />
) : detail.isError || !detail.data ? (
<Alert color="red">{t("loadError")}</Alert>
<Alert color="red">{detail.error instanceof Error ? detail.error.message : t("loadError")}</Alert>
) : (
<Stack>
<Text>{detail.data.description}</Text>
@@ -273,12 +281,17 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
))}
</SimpleGrid>
)}
<CapabilitySummary content={detail.data.content} />
{detailValidation?.success && <CapabilitySummary widget={detailValidation.data} />}
{detailValidation && !detailValidation.success && (
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
{t("installErrorDescription")} {detailValidation.error}
</Alert>
)}
{detailValidation?.success && !detailCompatible && (
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
{t("installErrorDescription")}
</Alert>
)}
<Code block mah={360} style={{ overflow: "auto", whiteSpace: "pre" }}>
{detail.data.content}
</Code>
@@ -294,7 +307,8 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
<Button
variant="subtle"
leftSection={<IconThumbUp size={16} />}
disabled={!user}
loading={vote.isPending && vote.variables?.value === 1}
disabled={!user || vote.isPending}
onClick={() => vote.mutate({ submission: detail.data.id, value: 1 })}
>
{detail.data.upvotes}
@@ -302,7 +316,8 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
<Button
variant="subtle"
leftSection={<IconThumbDown size={16} />}
disabled={!user}
loading={vote.isPending && vote.variables?.value === -1}
disabled={!user || vote.isPending}
onClick={() => vote.mutate({ submission: detail.data.id, value: -1 })}
>
{detail.data.downvotes}
@@ -320,8 +335,8 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
{onInstall && (
<Button
loading={install.isPending}
disabled={!detailValidation?.success}
onClick={() => install.mutate(detail.data)}
disabled={!detailCompatible}
onClick={() => detailValidation?.success && install.mutate(detailValidation.data)}
>
{t("install")}
</Button>
@@ -332,7 +347,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
{t("signInHint")}
</Text>
)}
{vote.error && <Alert color="red">{t("voteError")}</Alert>}
{vote.error && <Alert color="red">{vote.error.message || t("voteError")}</Alert>}
{install.error && <Alert color="red">{install.error.message}</Alert>}
</Stack>
)}
@@ -367,10 +382,8 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
);
}
function CapabilitySummary({ content }: { content: string }) {
function CapabilitySummary({ widget }: { widget: HomarrCustomWidgetV2 }) {
const t = useScopedI18n("workshop");
const parsed = validateWorkshopWidget(content);
if (!parsed.success) return null;
return (
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<Card withBorder p="sm">
@@ -378,7 +391,7 @@ function CapabilitySummary({ content }: { content: string }) {
{t("sources")}
</Text>
<Stack gap={6}>
{parsed.data.sources.map((source) => (
{widget.sources.map((source) => (
<Box key={source.id}>
<Text size="sm" fw={500}>
{source.name}
@@ -402,13 +415,13 @@ function CapabilitySummary({ content }: { content: string }) {
<Text fw={600} size="sm" mb="xs">
{t("requests")}
</Text>
{parsed.data.requests.length === 0 ? (
{widget.requests.length === 0 ? (
<Text size="xs" c="dimmed">
{t("noRequests")}
</Text>
) : (
<Stack gap={6}>
{parsed.data.requests.map((request) => (
{widget.requests.map((request) => (
<Group key={request.id} justify="space-between" wrap="nowrap">
<Text size="xs" ff="monospace" truncate>
{request.id}
@@ -1,10 +1,13 @@
import { WorkshopClient, WORKSHOP_API_URL } from "@homarr/workshop";
export function createWorkshopClient() {
const runtimeUrl =
typeof document === "undefined"
? undefined
: document.querySelector<HTMLMetaElement>('meta[name="homarr-workshop-api-url"]')?.content;
let browserClient: WorkshopClient | undefined;
return new WorkshopClient(runtimeUrl || WORKSHOP_API_URL);
export function createWorkshopClient() {
if (typeof document === "undefined") return new WorkshopClient(WORKSHOP_API_URL);
if (browserClient) return browserClient;
const runtimeUrl = document.querySelector<HTMLMetaElement>('meta[name="homarr-workshop-api-url"]')?.content;
browserClient = new WorkshopClient(runtimeUrl || WORKSHOP_API_URL);
return browserClient;
}
@@ -5,9 +5,7 @@ import { Button, Modal } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconBuildingStore } from "@tabler/icons-react";
import { customWidgetImportSchema } 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";
@@ -25,23 +23,9 @@ export function WorkshopInstallButton() {
</Button>
<Modal opened={opened} onClose={controls.close} title={t("installDialog")} size="90%">
<WorkshopBrowser
onInstall={async (submission) => {
try {
const parsed = customWidgetImportSchema.safeParse(JSON.parse(submission.content) as unknown);
if (!parsed.success) throw new Error(t("installErrorDescription"));
setPendingWidget(parsed.data);
reviewControls.open();
} catch (error) {
showErrorNotification({
title: t("installError"),
message:
error instanceof SyntaxError
? t("installErrorDescription")
: error instanceof Error
? error.message
: t("installErrorDescription"),
});
}
onInstall={async (widget) => {
setPendingWidget(widget);
reviewControls.open();
}}
/>
</Modal>
@@ -4,38 +4,19 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import { useDisclosure } from "@mantine/hooks";
import { customWidgetImportSchema } 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 type { WorkshopSubmissionDetail } from "@homarr/workshop";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { WorkshopBrowser } from "./workshop-browser";
export function WorkshopInstaller() {
const t = useScopedI18n("workshop");
const router = useRouter();
const [pendingWidget, setPendingWidget] = useState<HomarrCustomWidgetV2 | null>(null);
const [reviewOpened, reviewControls] = useDisclosure(false);
const install = async (submission: WorkshopSubmissionDetail) => {
try {
const parsed = customWidgetImportSchema.safeParse(JSON.parse(submission.content) as unknown);
if (!parsed.success) throw new Error(t("installErrorDescription"));
setPendingWidget(parsed.data);
reviewControls.open();
} catch (error) {
showErrorNotification({
title: t("installError"),
message:
error instanceof SyntaxError
? t("installErrorDescription")
: error instanceof Error
? error.message
: t("installErrorDescription"),
});
}
const install = async (widget: HomarrCustomWidgetV2) => {
setPendingWidget(widget);
reviewControls.open();
};
return (