fix: harden v2 custom widgets and Workshop (#6480)

This commit is contained in:
Thomas authored and GitHub committed 2026-07-31 13:28:53 +02:00
1 parent e8a2c87c2d
commit 557ebe0a2f
138 files changed
+5151 -1976

No files matched your search

@@ -10,6 +10,7 @@ import { WidgetCustomWidgetSelectInput } from "./widget-custom-widget-select-inp
const mocks = vi.hoisted(() => ({
available: [] as Array<Record<string, unknown>>,
permissions: ["admin"] as string[],
setFieldValue: vi.fn(),
}));
@@ -62,6 +63,10 @@ vi.mock("@homarr/api/client", () => ({
},
}));
vi.mock("@homarr/auth/client", () => ({
useSession: () => ({ data: { user: { permissions: mocks.permissions } } }),
}));
vi.mock("@homarr/boards/context", () => ({
useOptionalBoard: () => ({ id: "board-1" }),
}));
@@ -70,8 +75,9 @@ vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string) =>
({
migrationRequired: "Migration required",
migrationDescription: "Copy its redacted prompt to an LLM and import the returned v2 JSON.",
migrationDescription: "Copy its redacted prompt to an LLM, then paste the single JSON response into Homarr.",
manageMigration: "Open Manage custom widgets",
contactAdmin: "Contact an administrator.",
})[key] ?? key,
}));
@@ -90,6 +96,7 @@ let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
mocks.available = [];
mocks.permissions = ["admin"];
mocks.setFieldValue.mockReset();
host = document.createElement("div");
document.body.append(host);
@@ -137,4 +144,26 @@ describe("custom widget picker migration status", () => {
expect(host.textContent).toContain("Copy its redacted prompt to an LLM");
expect(host.querySelector('a[href="/manage/custom-widgets"]')?.textContent).toBe("Open Manage custom widgets");
});
it("tells non-admins to contact an administrator without linking management", async () => {
mocks.permissions = ["board-modify-all"];
mocks.available = [
{
id: "legacy-widget",
name: "Legacy status",
description: "Old API widget",
iconUrl: null,
sources: [],
requestCapabilities: [],
defaultOptions: {},
updatedAt: new Date(0),
migrationRequired: true,
},
];
await renderInput();
expect(host.textContent).toContain("Contact an administrator.");
expect(host.querySelector('a[href="/manage/custom-widgets"]')).toBeNull();
});
});
@@ -17,6 +17,7 @@ import {
import { IconAlertTriangle, IconApi } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useOptionalBoard } from "@homarr/boards/context";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
@@ -32,6 +33,8 @@ export const WidgetCustomWidgetSelectInput = ({
}: CommonWidgetInputProps<"customWidgetSelect">) => {
const t = useWidgetInputTranslation(kind, property);
const labels = useScopedI18n("widget.customApi.picker");
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const form = useFormContext();
const board = useOptionalBoard();
const currentValue = form.values.options[property] as string;
@@ -153,9 +156,15 @@ export const WidgetCustomWidgetSelectInput = ({
title={labels("migrationRequired")}
>
<Text size="xs">{labels("migrationDescription")}</Text>
<Anchor component={Link} href="/manage/custom-widgets" size="xs" fw={600}>
{labels("manageMigration")}
</Anchor>
{isAdmin ? (
<Anchor component={Link} href="/manage/custom-widgets" size="xs" fw={600}>
{labels("manageMigration")}
</Anchor>
) : (
<Text size="xs" fw={600}>
{labels("contactAdmin")}
</Text>
)}
</Alert>
)}
</Stack>
+13 -2
View File
@@ -5,6 +5,7 @@ import { Button, Center, Loader, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
@@ -21,6 +22,8 @@ export default function CustomApiWidget({
}: WidgetComponentProps<"customApi">) {
const t = useScopedI18n("widget.customApi");
const tCustomJsx = useScopedI18n("widget.customApi.customJsx");
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const definitionId = options.definitionId;
const safeInterval = Number.isFinite(options.refreshInterval) ? options.refreshInterval : 30;
const intervalMs = Math.max(1_000, safeInterval * 1_000);
@@ -64,8 +67,9 @@ export default function CustomApiWidget({
}
danger={!isUnavailable && !migrationRequired}
removeLabel={isUnavailable ? t("removeFromBoard") : undefined}
actionLabel={migrationRequired ? t("manageMigration") : undefined}
actionHref={migrationRequired ? "/manage/custom-widgets" : undefined}
actionLabel={migrationRequired && isAdmin ? t("manageMigration") : undefined}
actionHref={migrationRequired && isAdmin ? "/manage/custom-widgets" : undefined}
supplementaryMessage={migrationRequired && !isAdmin ? t("contactAdmin") : undefined}
onRemove={removeItem}
/>
);
@@ -90,6 +94,7 @@ function Unavailable({
removeLabel,
actionLabel,
actionHref,
supplementaryMessage,
onRemove,
}: {
message: string;
@@ -97,6 +102,7 @@ function Unavailable({
removeLabel?: string;
actionLabel?: string;
actionHref?: string;
supplementaryMessage?: string;
onRemove?: () => void;
}) {
return (
@@ -106,6 +112,11 @@ function Unavailable({
<Text c="dimmed" size="sm" ta="center">
{message}
</Text>
{supplementaryMessage && (
<Text size="sm" fw={600} ta="center">
{supplementaryMessage}
</Text>
)}
{actionLabel && actionHref && (
<Button component={Link} href={actionHref} size="compact-sm" color="yellow" variant="light">
{actionLabel}
@@ -92,6 +92,7 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
const { openModal } = useModalAction(WidgetAdvancedOptionsModal);
const canModifyApps = session?.user.permissions.includes("app-modify-all") ?? false;
const canConfigureWidget = innerProps.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const appId = innerProps.appId;
const showAppTab = innerProps.kind === "app" && canModifyApps && Boolean(appId);
@@ -120,7 +121,7 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
const widgetFormContent = (
<Stack>
{innerProps.integrationSupport && (
{canConfigureWidget && innerProps.integrationSupport && (
<WidgetIntegrationSelect
label={t("item.edit.field.integrations.label")}
data={innerProps.integrationData}
@@ -131,32 +132,33 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
{...form.getInputProps("integrationIds")}
/>
)}
{Object.entries(options).map(([key, value]) => {
const Input = getInputForType(value.type);
{canConfigureWidget &&
Object.entries(options).map(([key, value]) => {
const Input = getInputForType(value.type);
if (
!Input ||
value.shouldHide?.(
form.values.options as never,
innerProps.integrationData
.filter(({ id }) => form.values.integrationIds.includes(id))
.map(({ kind }) => kind),
)
) {
return null;
}
if (
!Input ||
value.shouldHide?.(
form.values.options as never,
innerProps.integrationData
.filter(({ id }) => form.values.integrationIds.includes(id))
.map(({ kind }) => kind),
)
) {
return null;
}
return (
<Input
key={key}
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
/>
);
})}
return (
<Input
key={key}
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
/>
);
})}
{showAppTab ? (
<Button
variant="subtle"