fix: address release v2 review findings (#6708)

This commit is contained in:
Thomas authored and GitHub committed 2026-08-27 13:59:42 +02:00
1 parent a002b91add
commit 7da2e10064
39 files changed
+500 -769

No files matched your search

@@ -12,6 +12,11 @@ describe("getUsableWidgetQueryData", () => {
expect(getUsableWidgetQueryData({ data, error: new Error("offline") })).toBe(data);
});
test("throws access-denied refetches instead of rendering stale cached data", () => {
const error = { data: { code: "FORBIDDEN" } };
expect(() => getUsableWidgetQueryData({ data: [{ id: "revoked" }], error })).toThrow(error);
});
test("throws a terminal failure when no usable data exists", () => {
const error = new Error("offline");
expect(() => getUsableWidgetQueryData({ data: undefined, error })).toThrow(error);
@@ -1,3 +1,5 @@
import { isTrpcForbiddenError } from "@homarr/api/query-cache";
interface WidgetQueryState {
data: unknown;
error: unknown;
@@ -9,6 +11,7 @@ interface PendingWidgetQueryState extends WidgetQueryState {
/** Throws terminal query failures while preserving successful empty values and stale cached data. */
export const getUsableWidgetQueryData = <TQuery extends WidgetQueryState>(query: TQuery): TQuery["data"] => {
if (isTrpcForbiddenError(query.error)) throw query.error;
if (query.error && query.data === undefined) throw query.error;
return query.data;
};
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { getAllowedPermissions, getFrameTitle, getSandboxFlags, isSupportedProtocol } from "./component";
import { getAllowedPermissions, getFrameTitle, getSandboxFlags } from "./component";
const permissions = {
allowFullScreen: true,
@@ -18,12 +18,6 @@ describe("iframe policy", () => {
expect(getSandboxFlags(permissions)).toContain("allow-modals");
});
it("accepts only HTTP protocols", () => {
expect(isSupportedProtocol("https://example.com")).toBe(true);
expect(isSupportedProtocol("javascript:alert(1)")).toBe(false);
expect(isSupportedProtocol("not a url")).toBe(false);
});
it("creates a display title without credentials, paths, or query parameters", () => {
expect(getFrameTitle("https://user:secret@example.com/private?token=sensitive")).toBe("example.com");
});
+3 -29
View File
@@ -1,26 +1,21 @@
"use client";
import { Box, Stack, Text, Title } from "@mantine/core";
import { IconBrowserOff, IconProtocol } from "@tabler/icons-react";
import { IconBrowserOff } from "@tabler/icons-react";
import { objectEntries } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { getSafeApplicationUrl } from "../common/application-url";
import classes from "./component.module.css";
export default function IFrameWidget({ options, isEditMode }: WidgetComponentProps<"iframe">) {
const t = useI18n("widget.iframe");
const { embedUrl, allowScrolling, ...permissions } = options;
const safeEmbedUrl = getSafeApplicationUrl(embedUrl);
const allowedPermissions = getAllowedPermissions(permissions);
const sandboxFlags = getSandboxFlags(permissions);
if (embedUrl.trim() === "") return <NoUrl />;
if (!safeEmbedUrl) {
return <UnsupportedProtocol />;
}
return (
<Stack h="100%" w="100%" gap={0}>
@@ -29,8 +24,8 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro
loading="lazy"
style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined}
className={classes.iframe}
src={safeEmbedUrl}
title={getFrameTitle(safeEmbedUrl)}
src={embedUrl}
title={getFrameTitle(embedUrl)}
allow={allowedPermissions}
scrolling={allowScrolling ? "yes" : "no"}
sandbox={sandboxFlags.join(" ")}
@@ -42,12 +37,6 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro
);
}
const supportedProtocols = ["http", "https"];
export const isSupportedProtocol = (url: string) => {
return getSafeApplicationUrl(url) !== undefined;
};
const NoUrl = () => {
const t = useI18n("widget.iframe");
@@ -59,21 +48,6 @@ const NoUrl = () => {
);
};
const UnsupportedProtocol = () => {
const t = useI18n("widget.iframe");
return (
<Stack align="center" justify="center" h="100%">
<IconProtocol />
<Title order={4} ta="center">
{t("error.unsupportedProtocol", {
supportedProtocols: supportedProtocols.map((protocol) => protocol).join(", "),
})}
</Title>
</Stack>
);
};
export const getAllowedPermissions = (
permissions: Omit<WidgetComponentProps<"iframe">["options"], "embedUrl" | "allowScrolling">,
) => {
+11 -12
View File
@@ -1,7 +1,6 @@
import { IconBrowser, IconExternalLink } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { getSafeApplicationUrl } from "../common/application-url";
import { optionsBuilder } from "../options";
const createOptions = () =>
@@ -23,17 +22,17 @@ export const { definition, componentLoader } = createWidgetDefinition("iframe",
supportsAdvancedFocus: false,
icon: IconBrowser,
contextActions: ({ options }) => {
const embedUrl = getSafeApplicationUrl(options.embedUrl);
return embedUrl
? [
{
key: "open-iframe",
label: "widget.common.openInNewTab.label",
icon: IconExternalLink,
onClick: () => window.open(embedUrl, "_blank", "noopener,noreferrer"),
},
]
: [];
const embedUrl = options.embedUrl;
if (typeof embedUrl !== "string" || embedUrl.trim() === "") return [];
return [
{
key: "open-iframe",
label: "widget.common.openInNewTab.label",
icon: IconExternalLink,
onClick: () => window.open(embedUrl, "_blank", "noopener,noreferrer"),
},
];
},
createOptions,
}).withDynamicImport(() => import("./component"));
@@ -68,10 +68,14 @@ export interface WidgetEditModalSize {
height: number;
}
export interface WidgetPreviewDimensions extends WidgetEditModalSize {
scale?: number;
}
interface WidgetEditPreviewResizeOptions {
initialSize: WidgetEditModalSize;
maximumSize: WidgetEditModalSize;
getDimensions?: (size: WidgetEditModalSize) => { width: number; height: number; scale?: number };
getDimensions?: (size: WidgetEditModalSize) => WidgetPreviewDimensions;
}
export interface EmbeddedIntegrationEditFormHandle {
@@ -99,7 +103,7 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
onIntegrationSaved?: () => void;
onOpenNewIntegration?: (onCreated?: (id: string) => void) => void;
previewComponent?: ComponentType<WidgetComponentProps<TSort>>;
previewDimensions?: { width: number; height: number; scale?: number };
previewDimensions?: WidgetPreviewDimensions;
previewResize?: WidgetEditPreviewResizeOptions;
previewWrapper?: ComponentType<PropsWithChildren>;
}
@@ -111,7 +115,7 @@ interface WidgetEditPreviewProps {
state: WidgetEditModalState;
itemId?: string;
boardId?: string;
dimensions?: { width: number; height: number; scale?: number };
dimensions?: WidgetPreviewDimensions;
integrationData: IntegrationSelectOption[];
onChangeOptions: (newOptions: Record<string, unknown>) => void;
resize?: {
@@ -179,7 +183,9 @@ const WidgetEditPreview = ({
},
}));
let componentItemId = itemId;
if (kind === "assistant") componentItemId = `widget-preview-${generatedPreviewId}`;
if (kind === "assistant" || kind === "timer") {
componentItemId = `widget-preview-${generatedPreviewId}`;
}
const isPendingCustomWidget = kind === "customApi" && !itemId;
const previewOpacity = (board?.opacity ?? 100) / 100;
const handleResizeValue = (dimension: keyof WidgetEditModalSize, value: string | number) => {