feat: modernize dashboard widgets
This commit is contained in:
1 parent
1a5e083fd0
commit
6f2cd7fc2b
270 files changed
+10189
-3544
No files matched your search
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getAllowedPermissions, getSandboxFlags, isSupportedProtocol } from "./component";
|
||||
|
||||
const permissions = {
|
||||
allowFullScreen: true,
|
||||
allowPayment: false,
|
||||
allowAutoPlay: true,
|
||||
allowMicrophone: false,
|
||||
allowCamera: false,
|
||||
allowGeolocation: false,
|
||||
allowModals: true,
|
||||
};
|
||||
|
||||
describe("iframe policy", () => {
|
||||
it("keeps sandbox-only modal permission out of Permissions Policy", () => {
|
||||
expect(getAllowedPermissions(permissions)).toBe("fullscreen *; autoplay *");
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Stack, Text, Title } from "@mantine/core";
|
||||
import { Anchor, Box, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconBrowserOff, IconProtocol } from "@tabler/icons-react";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
@@ -9,7 +9,7 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
export default function IFrameWidget({ options, isEditMode }: WidgetComponentProps<"iframe">) {
|
||||
export default function IFrameWidget({ options, isEditMode, displayMode }: WidgetComponentProps<"iframe">) {
|
||||
const t = useI18n();
|
||||
const { embedUrl, allowScrolling, ...permissions } = options;
|
||||
const allowedPermissions = getAllowedPermissions(permissions);
|
||||
@@ -21,25 +21,37 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro
|
||||
}
|
||||
|
||||
return (
|
||||
<Box h="100%" w="100%">
|
||||
<iframe
|
||||
style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined}
|
||||
className={classes.iframe}
|
||||
src={embedUrl}
|
||||
title="widget iframe"
|
||||
allow={allowedPermissions}
|
||||
scrolling={allowScrolling ? "yes" : "no"}
|
||||
sandbox={sandboxFlags.join(" ")}
|
||||
>
|
||||
<Text>{t("widget.iframe.error.noBrowerSupport")}</Text>
|
||||
</iframe>
|
||||
</Box>
|
||||
<Stack h="100%" w="100%" gap={0}>
|
||||
{displayMode === "advanced" && (
|
||||
<Group key="advanced-header" px="xs" py={4} wrap="nowrap" justify="space-between">
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{getFrameTitle(embedUrl)}
|
||||
</Text>
|
||||
<Anchor href={embedUrl} target="_blank" rel="noreferrer" size="xs">
|
||||
{embedUrl}
|
||||
</Anchor>
|
||||
</Group>
|
||||
)}
|
||||
<Box key="frame" style={{ flex: 1, minHeight: 0 }}>
|
||||
<iframe
|
||||
style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined}
|
||||
className={classes.iframe}
|
||||
src={embedUrl}
|
||||
title={getFrameTitle(embedUrl)}
|
||||
allow={allowedPermissions}
|
||||
scrolling={allowScrolling ? "yes" : "no"}
|
||||
sandbox={sandboxFlags.join(" ")}
|
||||
>
|
||||
<Text>{t("widget.iframe.error.noBrowerSupport")}</Text>
|
||||
</iframe>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const supportedProtocols = ["http", "https"];
|
||||
|
||||
const isSupportedProtocol = (url: string) => {
|
||||
export const isSupportedProtocol = (url: string) => {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
return supportedProtocols.map((protocol) => `${protocol}:`).includes(`${parsedUrl.protocol}`);
|
||||
@@ -74,19 +86,22 @@ const UnsupportedProtocol = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const getAllowedPermissions = (
|
||||
export const getAllowedPermissions = (
|
||||
permissions: Omit<WidgetComponentProps<"iframe">["options"], "embedUrl" | "allowScrolling">,
|
||||
) => {
|
||||
return (
|
||||
objectEntries(permissions)
|
||||
.filter(([_key, value]) => value)
|
||||
// * means it applies to all origins
|
||||
.map(([key]) => `${permissionMapping[key]} *`)
|
||||
// * means it applies to all origins. Sandbox-only flags such as
|
||||
// allow-modals intentionally have no Permissions Policy mapping.
|
||||
.flatMap(([key, value]) => {
|
||||
const permission = permissionMapping[key];
|
||||
return value && permission ? [`${permission} *`] : [];
|
||||
})
|
||||
.join("; ")
|
||||
);
|
||||
};
|
||||
|
||||
const getSandboxFlags = (
|
||||
export const getSandboxFlags = (
|
||||
permissions: Omit<WidgetComponentProps<"iframe">["options"], "embedUrl" | "allowScrolling">,
|
||||
) => {
|
||||
const baseSandbox = [
|
||||
@@ -112,12 +127,21 @@ const getSandboxFlags = (
|
||||
return baseSandbox;
|
||||
};
|
||||
|
||||
const permissionMapping = {
|
||||
const permissionMapping: Partial<
|
||||
Record<keyof Omit<WidgetComponentProps<"iframe">["options"], "embedUrl" | "allowScrolling">, string>
|
||||
> = {
|
||||
allowAutoPlay: "autoplay",
|
||||
allowCamera: "camera",
|
||||
allowFullScreen: "fullscreen",
|
||||
allowGeolocation: "geolocation",
|
||||
allowMicrophone: "microphone",
|
||||
allowPayment: "payment",
|
||||
allowModals: "allow-modals",
|
||||
} satisfies Record<keyof Omit<WidgetComponentProps<"iframe">["options"], "embedUrl" | "allowScrolling">, string>;
|
||||
};
|
||||
|
||||
const getFrameTitle = (url: string) => {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return "iframe";
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user