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

@@ -30,6 +30,8 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
};
const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuProps) => {
const { data: session } = useSession();
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const refResetErrorBoundaryOnNextRender = useRef(false);
const tItem = useScopedI18n("item");
const t = useI18n();
@@ -135,9 +137,11 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
>
{tItem("action.moveResize")}
</Menu.Item>
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
{tItem("action.duplicate")}
</Menu.Item>
{canDuplicate && (
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
{tItem("action.duplicate")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
@@ -3,6 +3,7 @@ import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text,
import { IconApi } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { createId, objectEntries } from "@homarr/common";
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
@@ -18,12 +19,17 @@ import { WorkshopInstallButton } from "~/components/workshop/workshop-install-bu
import { useItemActions } from "./item-actions";
export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, innerProps }) => {
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const [search, setSearch] = useState("");
const t = useI18n();
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
const { data: integrationData } = clientApi.integration.all.useQuery();
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery({ boardId: innerProps.boardId });
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery(
{ boardId: innerProps.boardId },
{ enabled: isAdmin },
);
const settings = useSettings();
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
@@ -160,61 +166,65 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne
/>
))}
<Divider
label={t("customWidget.page.list.title")}
labelPosition="center"
my="sm"
style={{ gridColumn: "1 / -1" }}
/>
{filteredCustomWidgets.map((def) => (
<Card
key={def.id}
h={selectGridCardHeight}
withBorder
pos="relative"
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
{def.iconUrl ? (
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
) : (
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
)}
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{def.name}
</Text>
</Group>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{def.description ?? ""}
</Text>
</Stack>
<Box
pos="absolute"
bottom={0}
left={0}
right={0}
p="xs"
style={{
opacity: "var(--_hover-opacity)",
transition: "opacity 150ms ease",
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
<Button onClick={() => handleAddCustomWidget(def)} variant="light" size="xs" fullWidth>
{t("item.create.addToBoard")}
</Button>
{isAdmin && (
<>
<Divider
label={t("customWidget.page.list.title")}
labelPosition="center"
my="sm"
style={{ gridColumn: "1 / -1" }}
/>
{filteredCustomWidgets.map((def) => (
<Card
key={def.id}
h={selectGridCardHeight}
withBorder
pos="relative"
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
{def.iconUrl ? (
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
) : (
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
)}
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{def.name}
</Text>
</Group>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{def.description ?? ""}
</Text>
</Stack>
<Box
pos="absolute"
bottom={0}
left={0}
right={0}
p="xs"
style={{
opacity: "var(--_hover-opacity)",
transition: "opacity 150ms ease",
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
<Button onClick={() => handleAddCustomWidget(def)} variant="light" size="xs" fullWidth>
{t("item.create.addToBoard")}
</Button>
</Box>
</Card>
))}
<Box style={{ gridColumn: "1 / -1" }}>
<WorkshopInstallButton fullWidth>{t("workshop.installDialog")}</WorkshopInstallButton>
</Box>
</Card>
))}
</>
)}
<Box style={{ gridColumn: "1 / -1" }}>
<WorkshopInstallButton fullWidth>{t("workshop.installDialog")}</WorkshopInstallButton>
</Box>
{filteredItems.length === 0 && filteredCustomWidgets.length === 0 && (
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
@@ -49,6 +49,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const t = useI18n();
const settings = useSettings();
const isRightClickEnabled = settings.enableRightClickOnWidgets;
const canConfigureWidget = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const { openModal } = useModalAction(WidgetEditModal);
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
const { openConfirmModal } = useConfirmModal();
@@ -76,9 +77,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
type OptionDef = { type: string; skipContextMenu?: boolean };
const toggleOptions = useMemo(() => {
if (!canConfigureWidget) return [];
const rawOptions = currentDefinition.createOptions(settings) as unknown as Record<string, OptionDef>;
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
}, [currentDefinition, settings]);
}, [canConfigureWidget, currentDefinition, settings]);
const widgetContextActions = useMemo(() => {
const def = currentDefinition as Record<string, unknown>;
@@ -227,13 +229,15 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
>
{tItem("action.moveResize")}
</Menu.Item>
<Menu.Item
closeMenuOnClick
leftSection={<IconCopy size={16} />}
onClick={() => duplicateItem({ itemId: item.id })}
>
{tItem("action.duplicate")}
</Menu.Item>
{canConfigureWidget && (
<Menu.Item
closeMenuOnClick
leftSection={<IconCopy size={16} />}
onClick={() => duplicateItem({ itemId: item.id })}
>
{tItem("action.duplicate")}
</Menu.Item>
)}
</>
)}
@@ -28,6 +28,7 @@ import {
IconBuildingStore,
IconDownload,
IconFlag,
IconRefresh,
IconSearch,
IconThumbDown,
IconThumbUp,
@@ -217,7 +218,20 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
<Skeleton h={280} />
</SimpleGrid>
) : list.isError ? (
<Alert color="red">{list.error instanceof Error ? list.error.message : t("unavailable")}</Alert>
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("unavailableTitle")}>
<Stack gap="sm" align="flex-start">
<Text size="sm">{t("unavailable")}</Text>
<Button
size="xs"
variant="light"
leftSection={<IconRefresh size={14} />}
loading={list.isFetching}
onClick={() => void list.refetch()}
>
{t("retry")}
</Button>
</Stack>
</Alert>
) : list.data?.items.length ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
{list.data.items.map((item) => (