From 836a4cd46b87c2253c52d386c1e3fd23d0afc29e Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 3 Aug 2026 17:18:22 +0200 Subject: [PATCH] feat: modernize widget experience --- .../widgets/beszel-system-stats/index.mdx | 2 +- .../docs/widgets/media-request-list/index.mdx | 2 +- apps/docs/docs/widgets/notebook/index.mdx | 2 +- apps/docs/docs/widgets/patchmon/index.mdx | 2 +- apps/docs/docs/widgets/releases/index.mdx | 2 +- apps/docs/docs/widgets/system-disks/index.mdx | 2 +- .../src/components/widgets/interactions.tsx | 71 ++-- .../custom-widgets/_custom-widget-form.tsx | 1 - .../advanced-focus/advanced-focus.module.css | 40 +- .../board/advanced-focus/context.tsx | 44 +- .../board/advanced-focus/escape.spec.ts | 20 +- .../components/board/advanced-focus/escape.ts | 12 +- .../advanced-focus/manual-surface.spec.ts | 107 +++++ .../board/advanced-focus/manual-surface.tsx | 101 +++++ .../components/board/advanced-focus/wheel.ts | 5 +- .../src/components/board/items/item-menu.tsx | 1 - e2e/board-advanced-interactions.spec.ts | 170 +++++--- e2e/shared/login.ts | 51 +++ .../custom-widget/custom-widget-access.ts | 45 +++ packages/api/src/router/location.ts | 7 +- .../api/src/router/test/custom-widget.spec.ts | 376 ++++++++++++++++++ packages/api/src/router/test/location.spec.ts | 36 ++ .../partial-integration-failures.spec.ts | 108 ++++- .../__tests__/custom-api-security.spec.ts | 2 + packages/api/src/router/widgets/beszel.ts | 25 +- packages/api/src/router/widgets/calendar.ts | 4 +- .../src/router/widgets/custom-api-security.ts | 46 +++ packages/api/src/router/widgets/firewall.ts | 4 +- .../api/src/router/widgets/indexer-manager.ts | 4 +- .../api/src/router/widgets/media-organizer.ts | 4 +- .../api/src/router/widgets/media-requests.ts | 6 +- .../api/src/router/widgets/media-server.ts | 4 +- .../api/src/router/widgets/notifications.ts | 4 +- packages/api/src/router/widgets/releases.ts | 24 +- packages/api/src/router/widgets/vpn.ts | 4 +- packages/api/src/settle-integrations.ts | 4 + .../api/src/test/settle-integrations.spec.ts | 13 +- packages/common/src/url.ts | 35 ++ .../actions/items/children-action-item.tsx | 13 +- .../actions/items/group-action-item.tsx | 13 +- .../src/_inputs/widget-location-input.tsx | 10 +- ...widget-multiReleasesRepositories-input.tsx | 12 +- .../src/advanced-focus-support.spec.ts | 42 ++ .../widgets/src/anchor-note/component.tsx | 5 +- packages/widgets/src/app/component.tsx | 174 ++++---- .../widgets/src/app/ping/ping-indicator.tsx | 4 +- packages/widgets/src/bazarr/component.tsx | 27 +- packages/widgets/src/bazarr/index.ts | 1 + packages/widgets/src/bazarr/layout.spec.ts | 4 +- .../widgets/src/beszel-alerts/component.tsx | 7 +- .../src/beszel-system-grid/component.tsx | 23 +- .../src/beszel-system-stats/component.tsx | 79 ++-- .../widgets/src/beszel-system-stats/index.ts | 1 + packages/widgets/src/beszel/_shared/chart.tsx | 57 +-- .../widgets/src/beszel/_shared/stats-view.tsx | 47 ++- packages/widgets/src/bookmarks/component.tsx | 283 ++++++------- packages/widgets/src/bookmarks/layout.spec.ts | 2 +- .../src/calendar/calendar-event-list.tsx | 72 ++-- .../src/common/application-url.spec.ts | 30 ++ .../widgets/src/common/application-url.ts | 1 + packages/widgets/src/common/locale.spec.ts | 35 ++ packages/widgets/src/common/locale.ts | 61 +++ .../src/common/query-state-indicator.spec.ts | 56 +++ .../src/common/query-state-indicator.tsx | 38 ++ .../widgets/src/common/query-state.spec.ts | 39 ++ packages/widgets/src/common/query-state.ts | 22 + packages/widgets/src/coolify/component.tsx | 34 +- packages/widgets/src/coolify/index.ts | 1 + packages/widgets/src/coolify/resource-row.tsx | 16 +- .../widgets/src/coolify/servers-section.tsx | 8 +- .../src/coolify/single-instance-layout.tsx | 72 ++-- .../src/custom-api/custom-jsx-display.spec.ts | 1 - .../src/custom-api/custom-jsx-display.tsx | 1 - packages/widgets/src/custom-api/index.ts | 21 +- packages/widgets/src/definition.spec.ts | 53 +-- .../src/dns-hole/controls/component.tsx | 18 +- .../widgets/src/dns-hole/controls/index.ts | 1 + .../src/dns-hole/summary/component.tsx | 52 ++- .../widgets/src/dns-hole/summary/index.ts | 1 + packages/widgets/src/errors/component.spec.ts | 61 +++ packages/widgets/src/errors/component.tsx | 11 +- packages/widgets/src/firewall/component.tsx | 46 ++- .../src/firewall/failure-state.spec.ts | 29 +- .../cluster/cluster-health.tsx | 31 +- .../cluster/resource-table.tsx | 24 +- .../src/health-monitoring/component.tsx | 3 +- .../src/health-monitoring/system-health.tsx | 7 +- packages/widgets/src/iframe/component.tsx | 31 +- packages/widgets/src/iframe/index.ts | 46 ++- .../src/immich/album-carousel/component.tsx | 57 ++- .../src/immich/album-carousel/index.ts | 109 +++-- .../widgets/src/immich/albums-query.spec.ts | 63 ++- .../immich/server-stats/component.module.css | 5 - .../src/immich/server-stats/component.tsx | 102 +++-- packages/widgets/src/index.tsx | 4 + .../src/indexer-manager/component.module.css | 13 + .../widgets/src/indexer-manager/component.tsx | 123 +++--- packages/widgets/src/indexer-manager/index.ts | 25 +- .../widgets/src/media-releases/component.tsx | 49 ++- .../src/media-requests/list/component.tsx | 61 +-- .../widgets/src/media-requests/list/index.ts | 39 +- .../src/media-requests/stats/component.tsx | 109 +++-- .../widgets/src/media-requests/stats/index.ts | 9 +- .../src/media-transcoding/component.tsx | 107 +++-- .../src/media-transcoding/layout.spec.ts | 19 +- .../src/minecraft/server-status/component.tsx | 29 +- .../src/minecraft/server-status/index.ts | 1 + .../network-status/component.tsx | 24 +- .../network-status/layout.spec.ts | 49 +++ .../network-status/layout.ts | 42 ++ .../network-controller/summary/component.tsx | 90 ++++- .../src/network-controller/summary/index.ts | 1 + packages/widgets/src/notebook/index.ts | 1 + packages/widgets/src/notebook/notebook.tsx | 26 +- .../widgets/src/notifications/component.tsx | 134 ++++--- .../widgets/src/paperless-ngx/component.tsx | 18 +- packages/widgets/src/paperless-ngx/index.ts | 1 + packages/widgets/src/patchmon/component.tsx | 34 +- packages/widgets/src/patchmon/index.ts | 1 + packages/widgets/src/releases/component.tsx | 30 +- packages/widgets/src/releases/index.ts | 1 + packages/widgets/src/rssFeed/component.tsx | 128 +++--- .../widgets/src/rssFeed/feed-display.spec.ts | 20 +- packages/widgets/src/rssFeed/index.ts | 1 + packages/widgets/src/runtime-hooks.ts | 40 ++ .../execute-automation/component.tsx | 19 +- .../src/speedtest-tracker/component.tsx | 4 +- packages/widgets/src/stocks/component.spec.ts | 10 +- packages/widgets/src/stocks/component.tsx | 21 +- packages/widgets/src/stocks/index.ts | 1 + .../widgets/src/system-disks/component.tsx | 24 +- packages/widgets/src/system-disks/index.ts | 1 + packages/widgets/src/timetable/component.tsx | 14 +- packages/widgets/src/tracearr/component.tsx | 4 +- packages/widgets/src/traefik/component.tsx | 3 +- packages/widgets/src/umami/umami-content.tsx | 53 ++- .../src/umami/umami-events-content.tsx | 17 +- packages/widgets/src/umami/umami-top-list.tsx | 23 +- packages/widgets/src/ups/component.tsx | 3 +- .../widgets/src/uptime-kuma/component.tsx | 3 +- packages/widgets/src/video/component.tsx | 29 +- packages/widgets/src/video/index.ts | 1 + packages/widgets/src/vpn/component.tsx | 62 ++- packages/widgets/src/vpn/index.ts | 1 + .../widgets/src/weather/component.spec.ts | 4 +- packages/widgets/src/weather/component.tsx | 90 +++-- packages/widgets/src/weather/icon.tsx | 26 +- packages/widgets/src/widget-runtime.spec.ts | 88 ++++ 148 files changed, 3598 insertions(+), 1584 deletions(-) create mode 100644 apps/nextjs/src/components/board/advanced-focus/manual-surface.spec.ts create mode 100644 apps/nextjs/src/components/board/advanced-focus/manual-surface.tsx create mode 100644 e2e/shared/login.ts create mode 100644 packages/api/src/router/custom-widget/custom-widget-access.ts create mode 100644 packages/api/src/router/test/custom-widget.spec.ts create mode 100644 packages/api/src/router/test/location.spec.ts create mode 100644 packages/widgets/src/advanced-focus-support.spec.ts create mode 100644 packages/widgets/src/common/application-url.spec.ts create mode 100644 packages/widgets/src/common/application-url.ts create mode 100644 packages/widgets/src/common/locale.spec.ts create mode 100644 packages/widgets/src/common/locale.ts create mode 100644 packages/widgets/src/common/query-state-indicator.spec.ts create mode 100644 packages/widgets/src/common/query-state-indicator.tsx create mode 100644 packages/widgets/src/common/query-state.spec.ts create mode 100644 packages/widgets/src/common/query-state.ts create mode 100644 packages/widgets/src/errors/component.spec.ts create mode 100644 packages/widgets/src/network-controller/network-status/layout.spec.ts create mode 100644 packages/widgets/src/network-controller/network-status/layout.ts create mode 100644 packages/widgets/src/runtime-hooks.ts create mode 100644 packages/widgets/src/widget-runtime.spec.ts diff --git a/apps/docs/docs/widgets/beszel-system-stats/index.mdx b/apps/docs/docs/widgets/beszel-system-stats/index.mdx index 4bc00990e..6ac158aac 100644 --- a/apps/docs/docs/widgets/beszel-system-stats/index.mdx +++ b/apps/docs/docs/widgets/beszel-system-stats/index.mdx @@ -34,7 +34,7 @@ This widget displays time-series charts for a selected Beszel system, including ]} /> - + ### Adding the widget diff --git a/apps/docs/docs/widgets/media-request-list/index.mdx b/apps/docs/docs/widgets/media-request-list/index.mdx index 4bd3aa6d1..840315831 100644 --- a/apps/docs/docs/widgets/media-request-list/index.mdx +++ b/apps/docs/docs/widgets/media-request-list/index.mdx @@ -48,7 +48,7 @@ import mediaRequestList from "./img/list.png"; - Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations. - A warning indicator identifies unavailable integrations without hiding requests returned by healthy integrations. - + ### Adding the widget diff --git a/apps/docs/docs/widgets/notebook/index.mdx b/apps/docs/docs/widgets/notebook/index.mdx index df4d14ec2..887908372 100644 --- a/apps/docs/docs/widgets/notebook/index.mdx +++ b/apps/docs/docs/widgets/notebook/index.mdx @@ -26,7 +26,7 @@ import editOn from "./img/edit.png"; notebook edit-on - + ### Adding the widget diff --git a/apps/docs/docs/widgets/patchmon/index.mdx b/apps/docs/docs/widgets/patchmon/index.mdx index 2175a3945..c9d419b50 100644 --- a/apps/docs/docs/widgets/patchmon/index.mdx +++ b/apps/docs/docs/widgets/patchmon/index.mdx @@ -87,7 +87,7 @@ Advanced view is a complete PatchMon posture view. It always shows the complianc ]} /> - + ### Adding the widget diff --git a/apps/docs/docs/widgets/releases/index.mdx b/apps/docs/docs/widgets/releases/index.mdx index 017ea97ca..38134344a 100644 --- a/apps/docs/docs/widgets/releases/index.mdx +++ b/apps/docs/docs/widgets/releases/index.mdx @@ -35,7 +35,7 @@ GitHub, Docker Hub, GitLab, NPM, Codeberg, GitHub Container Registry, Quay, and For GitHub Container Registry, use `owner/package` identifiers such as `homarr-labs/homarr`. Full image references such as `ghcr.io/homarr-labs/homarr:latest` are also accepted. - + ### Adding the widget diff --git a/apps/docs/docs/widgets/system-disks/index.mdx b/apps/docs/docs/widgets/system-disks/index.mdx index 5015ac725..d533fa025 100644 --- a/apps/docs/docs/widgets/system-disks/index.mdx +++ b/apps/docs/docs/widgets/system-disks/index.mdx @@ -44,7 +44,7 @@ This widget displays filesystem and SMART disk usage for supported server and NA ]} /> - + ### Adding the widget diff --git a/apps/docs/src/components/widgets/interactions.tsx b/apps/docs/src/components/widgets/interactions.tsx index 1328aa548..b0c08e51c 100644 --- a/apps/docs/src/components/widgets/interactions.tsx +++ b/apps/docs/src/components/widgets/interactions.tsx @@ -1,12 +1,13 @@ import { IconArrowsMaximize, IconLayoutGrid, IconMouse2, IconPlus } from "@tabler/icons-react"; -const interactions = [ - { - icon: IconArrowsMaximize, - title: "Advanced view", - description: - "Hold Shift while hovering for an in-place preview, press Shift + Enter, or choose Open advanced view from the widget menu.", - }, +const advancedInteraction = { + icon: IconArrowsMaximize, + title: "Advanced view", + description: + "Hold Shift while hovering for an in-place preview, press Shift + Enter, or choose Open advanced view from the widget menu.", +} as const; + +const standardInteractions = [ { icon: IconMouse2, title: "Widget actions", @@ -27,27 +28,35 @@ const interactions = [ }, ] as const; -export const WidgetInteractionGuide = () => ( -
- - Power-user controls - Show shortcuts - - Hide shortcuts - - -
- {interactions.map(({ icon: Icon, title, description }) => ( -
- -
{title}
-

{description}

-
- ))} -
-

- Advanced view reuses the widget's live data and cache. The rest of the board stays visible but dimmed. Touch - devices expose a maximize control; app tiles stay compact. -

-
-); +export const WidgetInteractionGuide = ({ advancedView = true }: { advancedView?: boolean }) => { + const interactions = advancedView ? [advancedInteraction, ...standardInteractions] : standardInteractions; + + return ( +
+ + Power-user controls + Show shortcuts + + Hide shortcuts + + +
+ {interactions.map(({ icon: Icon, title, description }) => ( +
+ +
{title}
+

{description}

+
+ ))} +
+ {advancedView && ( +

+ Advanced view reuses the widget's live data and cache. The rest of the board stays visible but dimmed. + Touch devices expose a maximize control; compact-only widgets adapt in place instead. +

+ )} +
+ ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx index 0e724a8df..c1cb2b876 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx @@ -47,7 +47,6 @@ interface CustomWidgetFormProps { initialValues?: Partial; definitionId?: string; } - const sectionLinks = [ ["general", "section.general", IconSettings], ["sources", "section.sources", IconApi], diff --git a/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css index 60cc31954..a16e2ba12 100644 --- a/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css +++ b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css @@ -3,6 +3,10 @@ animation: focus-backdrop-in 180ms cubic-bezier(0.16, 1, 0.3, 1); } +.manualBackdrop { + pointer-events: auto; +} + .surface { position: fixed; z-index: 1; @@ -15,6 +19,25 @@ transform-origin: top left; } +.manualSurface { + position: fixed; + max-width: none; + max-height: none; + overflow: visible; + background: transparent; + box-shadow: none; + overscroll-behavior: contain; + animation: focus-surface-in 180ms cubic-bezier(0.16, 1, 0.3, 1); + transform-origin: top left; +} + +.manualSurface > div { + width: 100%; + height: 100%; + max-height: none !important; + overflow: visible; +} + .backdropClosing { animation: focus-backdrop-out 180ms ease-in forwards; } @@ -32,6 +55,18 @@ height: 100%; } +.surfacePortalTarget, +.manualSurfaceMount { + width: 100%; + height: 100%; + min-height: 0; +} + +.manualSurfaceMount { + overflow: hidden; + border-radius: inherit; +} + .surfaceHostAdvanced { position: fixed; inset: 0; @@ -49,7 +84,7 @@ } .closeButton { - position: fixed; + position: absolute; z-index: 2; pointer-events: auto; } @@ -99,7 +134,8 @@ @media (prefers-reduced-motion: reduce) { .backdrop, - .surface { + .surface, + .manualSurface { animation: none; } } diff --git a/apps/nextjs/src/components/board/advanced-focus/context.tsx b/apps/nextjs/src/components/board/advanced-focus/context.tsx index 3f38bdc44..39143a2a8 100644 --- a/apps/nextjs/src/components/board/advanced-focus/context.tsx +++ b/apps/nextjs/src/components/board/advanced-focus/context.tsx @@ -16,6 +16,7 @@ interface ActiveFocus { sourceRect: FocusRect; activation: "preview" | "manual"; autofocusClose: boolean; + restorePreviewFocus: boolean; phase: "visible" | "closing"; } @@ -79,6 +80,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { sourceRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height }, activation: options.activation ?? "manual", autofocusClose: options.autofocusClose ?? false, + restorePreviewFocus: options.activation === "preview" && document.activeElement === source, phase: "visible", }); }, @@ -118,12 +120,46 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { if (current?.activation === "preview" && current.itemId !== itemId) { cancelCloseTimer(); updateActive(null); + if (current.restorePreviewFocus && current.source.isConnected) current.source.focus({ preventScroll: true }); } startHoverTimer(); }, [cancelCloseTimer, startHoverTimer, updateActive], ); + useEffect(() => { + const handlePointerMove = (event: PointerEvent) => { + const current = activeRef.current; + if (!shiftHeldRef.current || current?.activation !== "preview") return; + + const underlyingSource = document + .elementsFromPoint(event.clientX, event.clientY) + .map((element) => + element instanceof HTMLElement + ? element.matches(".grid-stack-item-content") + ? element + : element.closest(".grid-stack-item-content") + : null, + ) + .find((source) => source !== null && source !== current.source); + const itemId = underlyingSource?.closest(".grid-stack-item[data-id]")?.dataset.id; + if (!underlyingSource || !itemId) return; + + if (underlyingSource.hasAttribute("aria-keyshortcuts")) { + hover(itemId, underlyingSource); + return; + } + + hoveredRef.current = null; + cancelHoverTimer(); + cancelCloseTimer(); + updateActive(null); + }; + + window.addEventListener("pointermove", handlePointerMove, { capture: true }); + return () => window.removeEventListener("pointermove", handlePointerMove, { capture: true }); + }, [cancelCloseTimer, cancelHoverTimer, hover, updateActive]); + const leave = useCallback( (itemId: string) => { if (hoveredRef.current?.itemId === itemId) hoveredRef.current = null; @@ -145,7 +181,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape" && activeRef.current) { - const advancedSurface = document.querySelector(`.${classes.surface}`); + const advancedSurface = document.querySelector("[data-advanced-focus-surface]"); if (event.defaultPrevented || event.isComposing || isEscapeOwnedByNestedOverlay(event.target, advancedSurface)) return; event.preventDefault(); @@ -170,11 +206,11 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { if (activeRef.current?.activation === "preview") close(false); }; - window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keydown", handleKeyDown, { capture: true }); window.addEventListener("keyup", handleKeyUp); window.addEventListener("blur", handleBlur); return () => { - window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keydown", handleKeyDown, { capture: true }); window.removeEventListener("keyup", handleKeyUp); window.removeEventListener("blur", handleBlur); cancelHoverTimer(); @@ -194,7 +230,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { return ( {children} - {active && ( + {active?.activation === "preview" && ( { test("allows Escape from the advanced surface itself", () => { const surface = document.createElement("section"); - surface.setAttribute("role", "region"); + surface.setAttribute("role", "dialog"); const button = document.createElement("button"); surface.append(button); @@ -44,6 +44,24 @@ describe("isEscapeOwnedByNestedOverlay", () => { expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true); }); + test("leaves Escape to a visible portalled menu when focus stays in the advanced surface", () => { + const surface = document.createElement("section"); + surface.setAttribute("role", "dialog"); + const target = document.createElement("button"); + surface.append(target); + document.body.append(surface); + + const menu = document.createElement("div"); + menu.setAttribute("role", "menu"); + menu.getClientRects = () => [{ width: 100, height: 100 }] as unknown as DOMRectList; + document.body.append(menu); + + expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true); + + menu.remove(); + surface.remove(); + }); + test("does not mistake the compact widget source for a nested overlay", () => { const surface = document.createElement("section"); surface.setAttribute("role", "region"); diff --git a/apps/nextjs/src/components/board/advanced-focus/escape.ts b/apps/nextjs/src/components/board/advanced-focus/escape.ts index 47bd86dd1..8e58947a2 100644 --- a/apps/nextjs/src/components/board/advanced-focus/escape.ts +++ b/apps/nextjs/src/components/board/advanced-focus/escape.ts @@ -6,8 +6,14 @@ const escapeOwnerSelector = [ ].join(", "); export const isEscapeOwnedByNestedOverlay = (target: EventTarget | null, advancedSurface: Element | null) => { - if (!(target instanceof Element)) return false; + const escapeOwner = target instanceof Element ? target.closest(escapeOwnerSelector) : null; + if (escapeOwner !== null && escapeOwner !== advancedSurface) return true; - const escapeOwner = target.closest(escapeOwnerSelector); - return escapeOwner !== null && escapeOwner !== advancedSurface; + return Array.from(document.querySelectorAll(escapeOwnerSelector)).some( + (owner) => + owner !== advancedSurface && + owner.getAttribute("aria-hidden") !== "true" && + owner.closest('[aria-hidden="true"]') === null && + owner.getClientRects().length > 0, + ); }; diff --git a/apps/nextjs/src/components/board/advanced-focus/manual-surface.spec.ts b/apps/nextjs/src/components/board/advanced-focus/manual-surface.spec.ts new file mode 100644 index 000000000..3d5827aef --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/manual-surface.spec.ts @@ -0,0 +1,107 @@ +// @vitest-environment jsdom + +import { act, createElement, Fragment, useRef, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { MantineProvider } from "@mantine/core"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { AdvancedFocusManualSurface } from "./manual-surface"; + +const sourceRect = { left: 100, top: 120, width: 240, height: 160 }; +const rect = { left: 40, top: 80, width: 640, height: 480 }; +const closePosition = { left: 688, top: 80 }; + +describe("AdvancedFocusManualSurface", () => { + let host: HTMLDivElement | undefined; + let root: ReturnType | undefined; + + afterEach(async () => { + if (root) await act(async () => root?.unmount()); + host?.remove(); + document.querySelectorAll("[data-portal]").forEach((portal) => portal.remove()); + vi.unstubAllGlobals(); + }); + + test("portals dialog semantics, traps focus, closes from the backdrop, and restores focus", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "matchMedia", + vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + + const Harness = () => { + const [opened, setOpened] = useState(false); + const contentRef = useRef(null); + + return createElement( + Fragment, + null, + createElement("button", { type: "button", onClick: () => setOpened(true) }, "Open advanced view"), + createElement( + AdvancedFocusManualSurface, + { + opened, + phase: "visible", + id: "advanced-focus-test", + label: "Test widget advanced view", + closeLabel: "Close advanced view", + rect, + closePosition, + sourceRect, + radius: "md", + contentRef, + onClose: () => setOpened(false), + }, + createElement("button", { type: "button" }, "First widget action"), + createElement("button", { type: "button" }, "Last widget action"), + ), + ); + }; + + await act(async () => root?.render(createElement(MantineProvider, null, createElement(Harness)))); + const trigger = document.querySelector("button"); + expect(trigger).not.toBeNull(); + trigger?.focus(); + await act(async () => trigger?.click()); + await act(async () => await new Promise((resolve) => setTimeout(resolve, 20))); + + const dialog = document.querySelector("[role='dialog']"); + const closeButton = dialog?.querySelector("[aria-label='Close advanced view']"); + const actions = dialog?.querySelectorAll("button"); + expect(dialog?.id).toBe("advanced-focus-test"); + expect(dialog?.getAttribute("aria-modal")).toBe("true"); + expect(dialog?.closest("[data-portal]")).not.toBeNull(); + expect(dialog?.closest(".grid-stack-item")).toBeNull(); + expect(document.getElementById(dialog?.getAttribute("aria-labelledby") ?? "")?.textContent).toBe( + "Test widget advanced view", + ); + expect(document.activeElement).toBe(closeButton); + + const lastAction = actions?.item((actions?.length ?? 1) - 1); + lastAction?.focus(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true })); + expect(document.activeElement).toBe(closeButton); + closeButton?.focus(); + document.dispatchEvent( + new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true }), + ); + expect(document.activeElement).toBe(lastAction); + + const backdrop = document.querySelector("[data-advanced-focus-overlay]"); + await act(async () => backdrop?.click()); + await act(async () => await new Promise((resolve) => setTimeout(resolve, 20))); + expect(document.activeElement).toBe(trigger); + }); +}); diff --git a/apps/nextjs/src/components/board/advanced-focus/manual-surface.tsx b/apps/nextjs/src/components/board/advanced-focus/manual-surface.tsx new file mode 100644 index 000000000..7772f0d6f --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/manual-surface.tsx @@ -0,0 +1,101 @@ +"use client"; + +import type { CSSProperties, PropsWithChildren, Ref } from "react"; +import { Modal, VisuallyHidden } from "@mantine/core"; +import { useReducedMotion } from "@mantine/hooks"; + +import classes from "./advanced-focus.module.css"; +import type { FocusClosePosition, FocusRect } from "./geometry"; + +interface AdvancedFocusManualSurfaceProps extends PropsWithChildren { + opened: boolean; + phase: "visible" | "closing"; + id: string; + label: string; + closeLabel: string; + rect: FocusRect; + closePosition: FocusClosePosition; + sourceRect: FocusRect; + radius: string | number; + contentRef: Ref; + onClose: () => void; +} + +export const AdvancedFocusManualSurface = ({ + opened, + phase, + id, + label, + closeLabel, + rect, + closePosition, + sourceRect, + radius, + contentRef, + onClose, + children, +}: AdvancedFocusManualSurfaceProps) => { + const reduceMotion = useReducedMotion(); + const transitionDuration = reduceMotion ? 0 : 180; + const geometryStyle = { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + "--focus-translate-x": `${sourceRect.left - rect.left}px`, + "--focus-translate-y": `${sourceRect.top - rect.top}px`, + "--focus-scale-x": rect.width > 0 ? sourceRect.width / rect.width : 1, + "--focus-scale-y": rect.height > 0 ? sourceRect.height / rect.height : 1, + } as CSSProperties; + const relativeClosePosition = { + left: closePosition.left - rect.left, + top: closePosition.top - rect.top, + }; + + return ( + + + + + {label} + + + {children} + + + ); +}; diff --git a/apps/nextjs/src/components/board/advanced-focus/wheel.ts b/apps/nextjs/src/components/board/advanced-focus/wheel.ts index 193fa5dde..e6cb1946e 100644 --- a/apps/nextjs/src/components/board/advanced-focus/wheel.ts +++ b/apps/nextjs/src/components/board/advanced-focus/wheel.ts @@ -13,10 +13,8 @@ export const redirectShiftWheel = (root: HTMLElement, target: EventTarget | null if (delta === 0) return false; let current = target instanceof Element ? target : null; - let startedInScrollContainer = false; while (current && root.contains(current)) { const isCurrentScrollContainer = isVerticalScrollContainer(current); - startedInScrollContainer ||= isCurrentScrollContainer; if (isCurrentScrollContainer && canScrollVertically(current, delta)) { current.scrollTop += delta; return true; @@ -24,8 +22,9 @@ export const redirectShiftWheel = (root: HTMLElement, target: EventTarget | null if (current === root) break; current = current.parentElement; } - if (startedInScrollContainer) return false; + // A nested control can reach its edge before the widget's main Mantine viewport. + // Hand the gesture to that viewport instead of making Shift+wheel feel stuck. const viewport = [...root.querySelectorAll("[data-scrollbars='y'], [data-scrollbars='xy']")].find( (element) => isVerticalScrollContainer(element) && canScrollVertically(element, delta), ); diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index 64537d151..c373e3c66 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -28,7 +28,6 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => { return ; }; - const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => { const { data: session } = useSession(); const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false); diff --git a/e2e/board-advanced-interactions.spec.ts b/e2e/board-advanced-interactions.spec.ts index c37e34e2b..e94f6cfb6 100644 --- a/e2e/board-advanced-interactions.spec.ts +++ b/e2e/board-advanced-interactions.spec.ts @@ -19,6 +19,7 @@ import { import type { SqliteDatabase } from "./shared/e2e-db"; import { createSqliteDbFileAsync } from "./shared/e2e-db"; import { createHomarrContainer } from "./shared/create-homarr-container"; +import { loginAsync } from "./shared/login"; import { seedAdminUserAsync } from "./shared/seed-admin-user"; const ownerCredentials = { username: "owner", password: "Comp(exP4sswOrd" }; @@ -68,11 +69,12 @@ describe("Board advanced interactions", () => { try { const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first(); - const compactSurface = widget.locator(".clock-wrapper"); - const advancedSurface = page.getByRole("region", { name: "Date and time advanced view" }); - const iframeAdvancedSurface = page.getByRole("region", { name: "iFrame advanced view" }); + const compactSurface = page.locator(".clock-wrapper").first(); + const previewSurface = page.getByRole("region", { name: "Date and time advanced view" }); + const manualSurface = page.getByRole("dialog", { name: "Date and time advanced view" }); + const bookmarksPreviewSurface = page.getByRole("region", { name: "Bookmarks advanced view" }); const dimmingOverlay = page.locator("[data-advanced-focus-overlay]"); - const otherWidget = page.locator(".grid-stack-item[data-kind='iframe'] > .grid-stack-item-content").first(); + const otherWidget = page.locator(".grid-stack-item[data-kind='bookmarks'] > .grid-stack-item-content").first(); await compactSurface.evaluate((element) => { element.setAttribute("data-lifecycle-probe", "same-instance"); @@ -80,42 +82,54 @@ describe("Board advanced interactions", () => { await widget.focus(); await page.keyboard.down("Shift"); await widget.hover(); - await expect(advancedSurface).toBeVisible({ timeout: 2_000 }); - await expect(advancedSurface).toHaveCSS("animation-name", "none"); + await expect(previewSurface).toBeVisible({ timeout: 2_000 }); + await expect(previewSurface).toHaveCSS("animation-name", "none"); const compactBounds = await widget.boundingBox(); - const advancedBounds = await advancedSurface.boundingBox(); + const advancedBounds = await previewSurface.boundingBox(); expect(advancedBounds?.width).toBeGreaterThan(compactBounds?.width ?? 0); - await expect(advancedSurface).toHaveAttribute("data-lifecycle-probe", "same-instance"); + await expect(previewSurface).toHaveAttribute("data-lifecycle-probe", "same-instance"); await expect(dimmingOverlay).toBeVisible(); await expect(dimmingOverlay).toHaveCSS("pointer-events", "none"); await expect(otherWidget).toBeVisible(); await expect(widget).toBeFocused(); - await advancedSurface.click({ button: "right", position: { x: 80, y: 80 } }); + await previewSurface.click({ button: "right", position: { x: 80, y: 80 } }); const portalledMenuItem = page.getByRole("menuitem", { name: "Open advanced view" }); await expect(portalledMenuItem).toBeVisible(); await portalledMenuItem.hover(); - await expect(advancedSurface).toBeVisible(); + await expect(previewSurface).toBeVisible(); await page.keyboard.press("Escape"); await expect(portalledMenuItem).toBeHidden(); - await expect(advancedSurface).toBeVisible(); + await expect(previewSurface).toBeVisible(); - await otherWidget.hover(); - await expect(advancedSurface).toBeHidden(); - await expect(iframeAdvancedSurface).toBeVisible({ timeout: 2_000 }); + const otherBounds = await otherWidget.boundingBox(); + expect(otherBounds).not.toBeNull(); + if (!otherBounds) throw new Error("Bookmarks widget has no bounds"); + await page.mouse.move(otherBounds.x + otherBounds.width / 2, otherBounds.y + otherBounds.height / 2); + await expect(previewSurface).toBeHidden(); + await expect(bookmarksPreviewSurface).toBeVisible({ timeout: 2_000 }); await page.keyboard.up("Shift"); - await expect(iframeAdvancedSurface).toBeHidden(); + await expect(bookmarksPreviewSurface).toBeHidden(); await expect(dimmingOverlay).toBeHidden(); await expect(compactSurface).toHaveAttribute("data-lifecycle-probe", "same-instance"); await expect(widget).toBeFocused(); await page.keyboard.press("Shift+Enter"); - await expect(advancedSurface).toBeVisible(); + await expect(manualSurface).toBeVisible(); + await expect(manualSurface).toHaveAttribute("aria-modal", "true"); + expect(await manualSurface.evaluate((element) => element.closest(".grid-stack-item"))).toBeNull(); + await expect(manualSurface.locator(".clock-wrapper")).toHaveAttribute("data-lifecycle-probe", "same-instance"); + await expect(dimmingOverlay).toHaveCSS("pointer-events", "auto"); + expect(await page.evaluate(() => getComputedStyle(document.body).overflow)).toBe("hidden"); + await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused(); + await page.keyboard.press("Shift+Tab"); await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused(); await expect(page.getByRole("button", { name: /advanced view is pinned|keep advanced view open/i })).toHaveCount( 0, ); - await advancedSurface.evaluate((element) => { + await manualSurface.evaluate((element) => { const probe = document.createElement("div"); probe.dataset.shiftScrollProbe = "true"; probe.style.cssText = "height:80px;overflow-y:auto"; @@ -123,11 +137,19 @@ describe("Board advanced interactions", () => { probe.addEventListener("wheel", (event) => event.stopPropagation()); element.append(probe); - const unrelated = probe.cloneNode(true) as HTMLDivElement; + const mainViewport = document.createElement("div"); + mainViewport.dataset.scrollbars = "y"; + mainViewport.style.cssText = "height:80px;overflow-y:auto"; + mainViewport.innerHTML = '
'; + element.append(mainViewport); + + const unrelated = document.createElement("div"); unrelated.dataset.shiftScrollProbe = "unrelated"; + unrelated.style.cssText = "height:80px;overflow-y:hidden"; + unrelated.innerHTML = '
'; element.append(unrelated); }); - const scrollProbe = advancedSurface.locator("[data-shift-scroll-probe='true']"); + const scrollProbe = manualSurface.locator("[data-shift-scroll-probe='true']"); await page.keyboard.down("Shift"); const pixelEventCancelled = await scrollProbe.evaluate( (element) => @@ -156,56 +178,78 @@ describe("Board advanced interactions", () => { new WheelEvent("wheel", { bubbles: true, cancelable: true, shiftKey: true, deltaX: 120 }), ); }); + expect(await manualSurface.locator("[data-scrollbars='y']").evaluate((element) => element.scrollTop)).toBe(120); expect( - await advancedSurface.locator("[data-shift-scroll-probe='unrelated']").evaluate((element) => element.scrollTop), + await manualSurface.locator("[data-shift-scroll-probe='unrelated']").evaluate((element) => element.scrollTop), ).toBe(0); + + await manualSurface.click({ button: "right", position: { x: 80, y: 80 } }); + await expect(portalledMenuItem).toBeVisible(); await page.keyboard.press("Escape"); - await expect(advancedSurface).toBeHidden(); + await expect(portalledMenuItem).toBeHidden(); + await expect(manualSurface).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(manualSurface).toBeHidden(); await expect(widget).toBeFocused(); + expect(await page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe("hidden"); await widget.click({ button: "right" }); await page.getByRole("menuitem", { name: "Open advanced view" }).click(); - await expect(advancedSurface).toBeVisible(); - await page.keyboard.press("Escape"); - await expect(advancedSurface).toBeHidden(); + await expect(manualSurface).toBeVisible(); + const viewport = page.viewportSize(); + if (!viewport) throw new Error("Board page has no viewport"); + await page.mouse.click(4, viewport.height - 4); + await expect(manualSurface).toBeHidden(); + await expect(widget).toBeFocused(); - const appWidget = page.locator(".grid-stack-item[data-kind='app'] > .grid-stack-item-content").first(); - await expect(appWidget).not.toHaveAttribute("aria-keyshortcuts", "Shift+Enter"); - await appWidget.click({ button: "right" }); - await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0); - await page.keyboard.press("Escape"); - await page.keyboard.down("Shift"); - await appWidget.hover(); - await expect(advancedSurface).toBeHidden(); - await page.keyboard.up("Shift"); + for (const compactOnlyKind of ["app", "iframe"]) { + const compactOnlyWidget = page + .locator(`.grid-stack-item[data-kind='${compactOnlyKind}'] > .grid-stack-item-content`) + .first(); + await expect(compactOnlyWidget).not.toHaveAttribute("aria-keyshortcuts", "Shift+Enter"); + await compactOnlyWidget.dispatchEvent("contextmenu", { button: 2 }); + await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0); + await page.keyboard.press("Escape"); + await page.keyboard.down("Shift"); + await compactOnlyWidget.hover({ position: { x: 4, y: 4 } }); + await expect(previewSurface).toBeHidden(); + await expect(dimmingOverlay).toBeHidden(); + await page.keyboard.up("Shift"); + } } finally { await context.close(); } }, 60_000); - test("preserves iframe browsing state and dismisses focus when a responsive layout remounts", async () => { + test("keeps compact-only iframe state and dismisses retained focus when a responsive layout remounts", async () => { const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials); try { const iframeSlot = page.locator(".grid-stack-item[data-kind='iframe'] > .grid-stack-item-content").first(); const iframe = iframeSlot.locator("iframe"); await iframe.evaluate((element) => { - element.srcdoc = ''; + (element as HTMLIFrameElement).srcdoc = ''; + (element as HTMLIFrameElement).dataset.advancedFocusProbe = "true"; }); - const frame = page.frameLocator(".grid-stack-item[data-kind='iframe'] iframe"); + const frame = page.frameLocator("iframe[data-advanced-focus-probe='true']"); await frame.locator("#state").fill("changed"); - await iframeSlot.focus(); - await page.keyboard.press("Shift+Enter"); - await expect(page.getByRole("region", { name: "iFrame advanced view" })).toBeVisible(); - await expect(frame.locator("#state")).toHaveValue("changed"); + await expect(iframeSlot).not.toHaveAttribute("aria-keyshortcuts", "Shift+Enter"); + await iframeSlot.dispatchEvent("contextmenu", { button: 2 }); + await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0); await page.keyboard.press("Escape"); + await page.keyboard.down("Shift"); + await iframeSlot.hover({ position: { x: 4, y: 4 } }); + await expect(page.getByRole("region", { name: "iFrame advanced view" })).toHaveCount(0); + await page.keyboard.up("Shift"); await expect(frame.locator("#state")).toHaveValue("changed"); - await iframeSlot.focus(); + const bookmarksSlot = page.locator(".grid-stack-item[data-kind='bookmarks'] > .grid-stack-item-content").first(); + await bookmarksSlot.focus(); await page.keyboard.press("Shift+Enter"); + await expect(page.getByRole("dialog", { name: "Bookmarks advanced view" })).toBeVisible(); await page.setViewportSize({ width: 800, height: 900 }); - await expect(page.getByRole("region", { name: "iFrame advanced view" })).toBeHidden(); + await expect(page.getByRole("dialog", { name: "Bookmarks advanced view" })).toBeHidden(); await expect(page.locator(".grid-stack-item[data-kind='iframe'] iframe")).toBeVisible(); } finally { await context.close(); @@ -229,7 +273,7 @@ describe("Board advanced interactions", () => { expect(bounds.height).toBeGreaterThanOrEqual(44); expect(bounds.x + bounds.width).toBeLessThanOrEqual(1366); expect(bounds.y + bounds.height).toBeLessThanOrEqual(768); - const surfaceBounds = await page.getByRole("region", { name: "Date and time advanced view" }).boundingBox(); + const surfaceBounds = await page.getByRole("dialog", { name: "Date and time advanced view" }).boundingBox(); const contentBounds = await page.locator(".clock-widget-container").boundingBox(); expect(surfaceBounds).not.toBeNull(); expect(contentBounds).not.toBeNull(); @@ -240,7 +284,7 @@ describe("Board advanced interactions", () => { ); } await closeButton.click(); - await expect(page.getByRole("region", { name: "Date and time advanced view" })).toBeHidden(); + await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeHidden(); } finally { await context.close(); } @@ -304,7 +348,7 @@ describe("Board advanced interactions", () => { await widget.click({ button: "right" }); await page.getByRole("menuitem", { name: "Open advanced view" }).click(); - await expect(page.getByRole("region", { name: "Date and time advanced view" })).toBeVisible(); + await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeVisible(); await page.keyboard.press("Escape"); await expect(grid).not.toHaveAttribute("aria-label", "Add item here"); @@ -325,14 +369,8 @@ const openBoardAsync = async ( const context = await browser.newContext({ reducedMotion: "reduce", viewport }); const page = await context.newPage(); - await page.goto(`${baseUrl}/auth/login`); - await page.getByLabel("Username").fill(credentials.username); - await page.locator("#password").fill(credentials.password); - const loginRedirect = page.waitForURL(baseUrl, { timeout: 15_000, waitUntil: "commit" }); - await page.locator("css=button[type='submit']").click(); - await loginRedirect; - await page.goto(`${baseUrl}/boards/${boardName}`); - await expect(page.locator(".grid-stack-item[data-kind='clock'] .clock-wrapper").first()).toBeVisible({ + await loginAsync({ page, baseUrl, credentials, destination: `/boards/${boardName}` }); + await expect(page.locator(".clock-wrapper").first()).toBeVisible({ timeout: 15_000, }); @@ -368,6 +406,7 @@ const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) => const desktopLayoutId = createId(); const sectionId = createId(); const clockItemId = createId(); + const bookmarksItemId = createId(); const iframeItemId = createId(); const appItemId = createId(); const appId = createId(); @@ -392,6 +431,22 @@ const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) => options: stringify({ is24HourFormat: true, showSeconds: false, showDate: true }), advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }), }, + { + id: bookmarksItemId, + kind: "bookmarks", + boardId, + options: stringify({ + title: "", + layout: "column", + hideTitle: false, + hideIcon: false, + hideHostname: false, + openNewTab: true, + withBorder: false, + items: [appId], + }), + advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }), + }, { id: iframeItemId, kind: "iframe", @@ -418,6 +473,15 @@ const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) => width: 4, height: 3, }, + { + itemId: bookmarksItemId, + sectionId, + layoutId, + xOffset: 0, + yOffset: 3, + width: 4, + height: 3, + }, { itemId: iframeItemId, sectionId, diff --git a/e2e/shared/login.ts b/e2e/shared/login.ts new file mode 100644 index 000000000..37684299b --- /dev/null +++ b/e2e/shared/login.ts @@ -0,0 +1,51 @@ +import type { Page } from "@playwright/test"; + +interface LoginInput { + page: Page; + baseUrl: string; + credentials: { + username: string; + password: string; + }; + destination?: string; +} + +export const loginAsync = async ({ page, baseUrl, credentials, destination = "/" }: LoginInput) => { + const destinationUrl = new URL(destination, baseUrl); + const loginUrl = new URL("/auth/login", baseUrl); + loginUrl.searchParams.set("callbackUrl", destinationUrl.href); + + await page.goto(loginUrl.href, { waitUntil: "load" }); + await page.waitForFunction(() => { + const form = document.querySelector("form"); + return form !== null && Object.keys(form).some((key) => key.startsWith("__reactProps$")); + }); + await page.getByLabel("Username").fill(credentials.username); + await page.locator("#password").fill(credentials.password); + + await page.locator("button[type='submit']").click(); + await waitForAuthenticatedSessionAsync(page, baseUrl); + + await page.goto(destinationUrl.href, { waitUntil: "domcontentloaded" }); + const currentUrl = new URL(page.url()); + if (currentUrl.origin !== destinationUrl.origin || currentUrl.pathname !== destinationUrl.pathname) { + throw new Error("Authenticated destination was not reached"); + } +}; + +const waitForAuthenticatedSessionAsync = async (page: Page, baseUrl: string) => { + const sessionUrl = new URL("/api/auth/session", baseUrl).href; + const deadline = Date.now() + 30_000; + + while (Date.now() < deadline) { + const response = await page.request.get(sessionUrl); + if (response.ok()) { + const session = (await response.json()) as { user?: unknown } | null; + if (session?.user !== undefined) return; + } + + await page.waitForTimeout(100); + } + + throw new Error("Authentication session was not established"); +}; diff --git a/packages/api/src/router/custom-widget/custom-widget-access.ts b/packages/api/src/router/custom-widget/custom-widget-access.ts new file mode 100644 index 000000000..90abeebc3 --- /dev/null +++ b/packages/api/src/router/custom-widget/custom-widget-access.ts @@ -0,0 +1,45 @@ +import { TRPCError } from "@trpc/server"; +import { parse } from "superjson"; +import { z } from "zod/v4"; + +import type { Session } from "@homarr/auth"; +import type { Database } from "@homarr/db"; +import { and, eq } from "@homarr/db"; +import { boards, items } from "@homarr/db/schema"; +import type { BoardPermission } from "@homarr/definitions"; + +import { throwIfActionForbiddenAsync } from "../board/board-access"; + +export const customApiItemInputSchema = z.object({ + boardId: z.string().min(1).max(100), + itemId: z.string().min(1).max(100), + definitionId: z.string().min(1).max(100), +}); + +const customApiOptionsSchema = z.object({ definitionId: z.string() }).passthrough(); + +export async function assertCustomApiItemBindingAsync( + ctx: { db: Database; session: Session | null }, + input: z.infer, + permission?: BoardPermission, +) { + const item = await ctx.db.query.items.findFirst({ + columns: { options: true }, + where: and(eq(items.id, input.itemId), eq(items.boardId, input.boardId), eq(items.kind, "customApi")), + }); + + if (!item) throwCustomApiWidgetNotFound(); + if (permission) await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), permission); + + try { + const options = customApiOptionsSchema.parse(parse(item.options)); + if (options.definitionId !== input.definitionId) throwCustomApiWidgetNotFound(); + } catch (error) { + if (error instanceof TRPCError) throw error; + throwCustomApiWidgetNotFound(); + } +} + +function throwCustomApiWidgetNotFound(): never { + throw new TRPCError({ code: "NOT_FOUND", message: "Custom API widget not found" }); +} diff --git a/packages/api/src/router/location.ts b/packages/api/src/router/location.ts index 99bb9c3af..050d66cb4 100644 --- a/packages/api/src/router/location.ts +++ b/packages/api/src/router/location.ts @@ -37,11 +37,10 @@ export const locationRouter = createTRPCRouter({ .input(locationSearchCityInput) .output(locationSearchCityOutput) .query(async ({ input }) => { + const url = new URL("https://geocoding-api.open-meteo.com/v1/search"); + url.searchParams.set("name", input.query); const res = await withTimeoutAsync(async (signal) => { - return await fetchWithTrustedCertificatesAsync( - `https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`, - { signal }, - ); + return await fetchWithTrustedCertificatesAsync(url.toString(), { signal }); }); return (await res.json()) as z.infer; }), diff --git a/packages/api/src/router/test/custom-widget.spec.ts b/packages/api/src/router/test/custom-widget.spec.ts new file mode 100644 index 000000000..b026b2f68 --- /dev/null +++ b/packages/api/src/router/test/custom-widget.spec.ts @@ -0,0 +1,376 @@ +// @vitest-environment node + +import { stringify } from "superjson"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Session } from "@homarr/auth"; +import { createId } from "@homarr/common"; +import type { Database } from "@homarr/db"; +import { eq } from "@homarr/db"; +import { boards, boardUserPermissions, customWidgetDefinitions, items, users } from "@homarr/db/schema"; +import { createDb } from "@homarr/db/test"; +import type { GroupPermissionKey } from "@homarr/definitions"; + +import { customWidgetRouter } from "../custom-widget/custom-widget-router"; +import { customApiRouter } from "../widgets/custom-api"; + +const createSession = (userId: string, permissions: GroupPermissionKey[] = []): Session => ({ + user: { id: userId, permissions, colorScheme: "light" }, + expires: new Date().toISOString(), +}); + +const createCustomWidgetCaller = (db: Database, session: Session | null) => + customWidgetRouter.createCaller({ db, deviceType: undefined, session }); + +const createCustomApiCaller = (db: Database, session: Session | null) => + customApiRouter.createCaller({ db, deviceType: undefined, session }); + +async function createFixtureAsync(db: Database) { + const ownerId = createId(); + const viewerId = createId(); + const modifierId = createId(); + const unrelatedId = createId(); + const adminId = createId(); + const boardId = createId(); + const itemId = createId(); + const definitionId = createId(); + + await db.insert(users).values( + [ownerId, viewerId, modifierId, unrelatedId, adminId].map((id) => ({ + id, + name: id, + })), + ); + await db.insert(boards).values({ id: boardId, name: `board-${boardId}`, creatorId: ownerId, isPublic: false }); + await db.insert(boardUserPermissions).values([ + { boardId, userId: viewerId, permission: "view" }, + { boardId, userId: modifierId, permission: "modify" }, + ]); + await db.insert(customWidgetDefinitions).values({ + id: definitionId, + name: "Restart service", + description: "Restarts a service", + url: "https://api.example.com/restart", + method: "POST", + displayType: "actionButton", + displayConfig: stringify({ + type: "actionButton", + buttonLabel: "Restart", + confirmText: "Continue?", + }), + creatorId: adminId, + }); + await db.insert(items).values({ + id: itemId, + boardId, + kind: "customApi", + options: stringify({ definitionId, refreshInterval: 30 }), + }); + + return { ownerId, viewerId, modifierId, unrelatedId, adminId, boardId, itemId, definitionId }; +} + +beforeEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe("custom widget definition authorization", () => { + it("restricts the full definition list to administrators", async () => { + const db = createDb(); + const fixture = await createFixtureAsync(db); + + await expect(createCustomWidgetCaller(db, null).all()).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + for (const userId of [fixture.ownerId, fixture.viewerId, fixture.modifierId, fixture.unrelatedId]) { + await expect(createCustomWidgetCaller(db, createSession(userId)).all()).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + } + + await expect(createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).all()).resolves.toEqual([ + expect.objectContaining({ id: fixture.definitionId, url: "https://api.example.com/restart" }), + ]); + }); + + it("returns only safe definition summaries to board modifiers", async () => { + const db = createDb(); + const fixture = await createFixtureAsync(db); + + const result = await createCustomWidgetCaller(db, createSession(fixture.modifierId)).available({ + boardId: fixture.boardId, + }); + expect(result).toEqual([ + { + id: fixture.definitionId, + name: "Restart service", + description: "Restarts a service", + iconUrl: null, + displayType: "actionButton", + enabled: true, + }, + ]); + expect(JSON.stringify(result)).not.toContain("api.example.com"); + + for (const userId of [fixture.viewerId, fixture.unrelatedId]) { + await expect( + createCustomWidgetCaller(db, createSession(userId)).available({ boardId: fixture.boardId }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + } + }); +}); + +describe("custom widget action authorization", () => { + it("requires an administrator even for users who can view or modify the board", async () => { + const db = createDb(); + const fixture = await createFixtureAsync(db); + const input = { + boardId: fixture.boardId, + itemId: fixture.itemId, + definitionId: fixture.definitionId, + }; + + await expect(createCustomWidgetCaller(db, null).execute(input)).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + for (const userId of [fixture.ownerId, fixture.viewerId, fixture.modifierId, fixture.unrelatedId]) { + await expect(createCustomWidgetCaller(db, createSession(userId)).execute(input)).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + } + }); + + it("executes for an administrator only when board, item, and definition are bound", async () => { + const db = createDb(); + const fixture = await createFixtureAsync(db); + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + vi.stubGlobal("fetch", fetchMock); + const caller = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])); + + await expect( + caller.execute({ + boardId: fixture.boardId, + itemId: fixture.itemId, + definitionId: fixture.definitionId, + }), + ).resolves.toEqual({ success: true, responseInfo: { status: 204 } }); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/restart", + expect.objectContaining({ method: "POST", redirect: "error", signal: expect.any(AbortSignal) }), + ); + }); + + it.each(["board", "item", "definition"] as const)("rejects a mismatched %s binding", async (mismatch) => { + const db = createDb(); + const fixture = await createFixtureAsync(db); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const input = { + boardId: mismatch === "board" ? createId() : fixture.boardId, + itemId: mismatch === "item" ? createId() : fixture.itemId, + definitionId: mismatch === "definition" ? createId() : fixture.definitionId, + }; + + await expect( + createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute(input), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects disabled definitions before sending a request", async () => { + const db = createDb(); + const fixture = await createFixtureAsync(db); + await db + .update(customWidgetDefinitions) + .set({ enabled: false }) + .where(eq(customWidgetDefinitions.id, fixture.definitionId)); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect( + createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({ + boardId: fixture.boardId, + itemId: fixture.itemId, + definitionId: fixture.definitionId, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("custom API outbound request hardening", () => { + it("rejects credential-bearing action URLs without exposing the credential", async () => { + const db = createDb(); + const fixture = await createFixtureAsync(db); + await db + .update(customWidgetDefinitions) + .set({ url: "https://operator:super-secret@private.example.com/restart" }) + .where(eq(customWidgetDefinitions.id, fixture.definitionId)); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const result = await createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({ + boardId: fixture.boardId, + itemId: fixture.itemId, + definitionId: fixture.definitionId, + }); + expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null }); + expect(JSON.stringify(result)).not.toMatch(/super-secret|private\.example\.com/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects redirects and redacts fetch errors", async () => { + const db = createDb(); + const fixture = await createFixtureAsync(db); + const fetchMock = vi + .fn() + .mockRejectedValue( + new TypeError("Redirect to http://token:secret@internal.example.local/admin?apiKey=top-secret was rejected"), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({ + boardId: fixture.boardId, + itemId: fixture.itemId, + definitionId: fixture.definitionId, + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/restart", + expect.objectContaining({ redirect: "error" }), + ); + expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null }); + expect(JSON.stringify(result)).not.toMatch(/top-secret|internal\.example\.local|apiKey/); + }); + + it("aborts timed-out action requests and returns a stable failure", async () => { + vi.useFakeTimers(); + const db = createDb(); + const fixture = await createFixtureAsync(db); + const started = Promise.withResolvers(); + const fetchMock = vi.fn((_url: string, init: RequestInit) => { + started.resolve(); + return new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => { + reject(new Error("Timed out calling http://internal.example.local?secret=timeout-secret")); + }); + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const request = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({ + boardId: fixture.boardId, + itemId: fixture.itemId, + definitionId: fixture.definitionId, + }); + await started.promise; + await vi.advanceTimersByTimeAsync(10_000); + + const result = await request; + expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null }); + expect(JSON.stringify(result)).not.toMatch(/timeout-secret|internal\.example\.local/); + }); + + it("bounds action responses before reporting success", async () => { + const db = createDb(); + const fixture = await createFixtureAsync(db); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("x".repeat(2 * 1024 * 1024 + 1)))); + + await expect( + createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({ + boardId: fixture.boardId, + itemId: fixture.itemId, + definitionId: fixture.definitionId, + }), + ).resolves.toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null }); + }); + + it("binds data requests to a viewable item and never exposes backend error text", async () => { + const db = createDb(); + const fixture = await createFixtureAsync(db); + await db + .update(customWidgetDefinitions) + .set({ + displayType: "singleValue", + method: "GET", + displayConfig: stringify({ type: "singleValue", jsonPath: "$.value" }), + }) + .where(eq(customWidgetDefinitions.id, fixture.definitionId)); + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new Error("Backend http://private.example.local/path?token=super-secret\nstack trace")), + ); + + const input = { + boardId: fixture.boardId, + itemId: fixture.itemId, + definitionId: fixture.definitionId, + }; + const error = await createCustomApiCaller(db, createSession(fixture.viewerId)) + .getData(input) + .catch((caught: unknown) => caught); + expect(error).toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Custom API request failed" }); + expect(JSON.stringify(error)).not.toMatch(/super-secret|private\.example\.local|stack trace/); + + await expect(createCustomApiCaller(db, createSession(fixture.unrelatedId)).getData(input)).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + await expect( + createCustomApiCaller(db, createSession(fixture.viewerId)).getData({ ...input, definitionId: createId() }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("bounds and disposes non-success data responses", async () => { + const db = createDb(); + const fixture = await createFixtureAsync(db); + await db + .update(customWidgetDefinitions) + .set({ + displayType: "singleValue", + method: "GET", + displayConfig: stringify({ type: "singleValue", jsonPath: "$.value" }), + }) + .where(eq(customWidgetDefinitions.id, fixture.definitionId)); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("x".repeat(2 * 1024 * 1024 + 1), { status: 502 }))); + + const error = await createCustomApiCaller(db, createSession(fixture.viewerId)) + .getData({ + boardId: fixture.boardId, + itemId: fixture.itemId, + definitionId: fixture.definitionId, + }) + .catch((caught: unknown) => caught); + + expect(error).toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Custom API request failed" }); + expect(JSON.stringify(error)).not.toContain("x".repeat(100)); + }); + + it("redacts unsafe preview failures and rejects redirects", async () => { + const db = createDb(); + const fixture = await createFixtureAsync(db); + const fetchMock = vi.fn().mockRejectedValue(new Error("redirected to http://private.local?secret=value")); + vi.stubGlobal("fetch", fetchMock); + const caller = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])); + + const result = await caller.preview({ + url: "https://api.example.com/data", + method: "GET", + authType: "none", + displayType: "singleValue", + displayConfig: { type: "singleValue", jsonPath: "$.value", label: "Value", unit: "" }, + secrets: [], + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/data", + expect.objectContaining({ redirect: "error" }), + ); + expect(result).toEqual({ + success: false, + error: "Custom API preview failed", + responseInfo: null, + rawResponse: null, + }); + expect(JSON.stringify(result)).not.toMatch(/private\.local|secret=value/); + }); +}); diff --git a/packages/api/src/router/test/location.spec.ts b/packages/api/src/router/test/location.spec.ts new file mode 100644 index 000000000..64c6cdfb4 --- /dev/null +++ b/packages/api/src/router/test/location.spec.ts @@ -0,0 +1,36 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { createDb } from "@homarr/db/test"; + +import { locationRouter } from "../location"; + +const mocks = vi.hoisted(() => ({ fetch: vi.fn() })); + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: mocks.fetch, +})); + +vi.mock("@homarr/core/infrastructure/http/timeout", () => ({ + withTimeoutAsync: async (callback: (signal: AbortSignal) => Promise) => + await callback(new AbortController().signal), +})); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.fetch.mockResolvedValue(new Response(JSON.stringify({ results: [] }))); +}); + +describe("locationRouter", () => { + test("encodes the complete search text as one name parameter", async () => { + const caller = locationRouter.createCaller({ db: createDb(), deviceType: undefined, session: null }); + + await caller.searchCity({ query: "Paris&language=xx&token=secret" }); + + const requestedUrl = new URL(mocks.fetch.mock.calls[0]?.[0] as string); + expect(requestedUrl.searchParams.get("name")).toBe("Paris&language=xx&token=secret"); + expect(requestedUrl.searchParams.has("language")).toBe(false); + expect(requestedUrl.searchParams.has("token")).toBe(false); + }); +}); diff --git a/packages/api/src/router/test/widgets/partial-integration-failures.spec.ts b/packages/api/src/router/test/widgets/partial-integration-failures.spec.ts index 4e3db53fc..d72d125d2 100644 --- a/packages/api/src/router/test/widgets/partial-integration-failures.spec.ts +++ b/packages/api/src/router/test/widgets/partial-integration-failures.spec.ts @@ -12,8 +12,14 @@ import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types import type * as MediaRequestListHandlerModule from "@homarr/request-handler/media-request-list"; import { calendarRouter } from "../../widgets/calendar"; +import { beszelRouter } from "../../widgets/beszel"; +import { firewallRouter } from "../../widgets/firewall"; +import { indexerManagerRouter } from "../../widgets/indexer-manager"; +import { mediaOrganizerRouter } from "../../widgets/media-organizer"; import { mediaRequestsRouter } from "../../widgets/media-requests"; import { mediaServerRouter } from "../../widgets/media-server"; +import { notificationsRouter } from "../../widgets/notifications"; +import { vpnRouter } from "../../widgets/vpn"; vi.hoisted(() => { process.env.SKIP_ENV_VALIDATION = "true"; @@ -21,7 +27,11 @@ vi.hoisted(() => { const createHandler = (integration: { name: string }, data: T) => ({ getDataAsync: async () => { - if (integration.name === "Offline") throw new Error("offline https://internal.example?token=secret"); + if (integration.name === "Offline") { + throw new Error( + "GET http://admin:password@internal.example/private/path?token=secret returned\nprivate response body", + ); + } return { data, timestamp: new Date() }; }, }); @@ -34,6 +44,40 @@ vi.mock("@homarr/request-handler/media-server", () => ({ mediaServerRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) }, })); +vi.mock("@homarr/request-handler/media-organizer", () => ({ + mediaOrganizerRequestHandler: { + handler: (integration: { name: string }) => + createHandler(integration, { missing: [], missingCount: 0, queued: [], queuedCount: 0 }), + }, +})); + +vi.mock("@homarr/request-handler/beszel", () => ({ + beszelSystemsRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) }, +})); + +vi.mock("@homarr/request-handler/firewall", () => ({ + firewallCpuRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, { total: 1 }) }, + firewallInterfacesRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) }, + firewallMemoryRequestHandler: { + handler: (integration: { name: string }) => createHandler(integration, { used: 1, total: 2, percent: 50 }), + }, + firewallVersionRequestHandler: { + handler: (integration: { name: string }) => createHandler(integration, { version: "1.0" }), + }, +})); + +vi.mock("@homarr/request-handler/indexer-manager", () => ({ + indexerManagerRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) }, +})); + +vi.mock("@homarr/request-handler/notifications", () => ({ + notificationsRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) }, +})); + +vi.mock("@homarr/request-handler/vpn", () => ({ + vpnSummaryHandler: { handler: (integration: { name: string }) => createHandler(integration, { connected: true }) }, +})); + vi.mock("@homarr/request-handler/media-request-list", async (importOriginal) => { const actual = await importOriginal(); return { @@ -121,7 +165,7 @@ describe("partial integration failures", () => { expect.arrayContaining([ expect.objectContaining({ integration: expect.objectContaining({ id: calendar.offlineId }), - error: "Integration request failed", + error: "INTEGRATION_REQUEST_FAILED", }), ]), ); @@ -136,7 +180,7 @@ describe("partial integration failures", () => { expect.objectContaining({ integrationId: mediaServer.offlineId, sessions: [], - error: "Integration request failed", + error: "INTEGRATION_REQUEST_FAILED", }), ]), ); @@ -165,13 +209,13 @@ describe("partial integration failures", () => { }), ]); expect(latest.failedIntegrations).toEqual([ - expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "Integration request failed" }), + expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "INTEGRATION_REQUEST_FAILED" }), ]); expect(JSON.stringify(latest)).not.toContain("secret"); const stats = await caller.getStats({ integrationIds: mediaRequests.integrationIds }); expect(stats.failedIntegrations).toEqual([ - expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "Integration request failed" }), + expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "INTEGRATION_REQUEST_FAILED" }), ]); expect(JSON.stringify(stats)).not.toContain("secret"); expect(stats.stats).toHaveLength(1); @@ -183,4 +227,58 @@ describe("partial integration failures", () => { }), ]); }); + + test("aggregate widget payloads never expose sensitive partial-failure details", async () => { + const cases = [ + { + kind: "sonarr" as const, + run: async (setup: Awaited>) => + await mediaOrganizerRouter + .createCaller({ db: setup.db, deviceType: undefined, session: setup.session }) + .getData({ integrationIds: setup.integrationIds, pageSize: 10 }), + }, + { + kind: "beszel" as const, + run: async (setup: Awaited>) => + await beszelRouter + .createCaller({ db: setup.db, deviceType: undefined, session: setup.session }) + .getSystems({ integrationIds: setup.integrationIds }), + }, + { + kind: "opnsense" as const, + run: async (setup: Awaited>) => + await firewallRouter + .createCaller({ db: setup.db, deviceType: undefined, session: setup.session }) + .getFirewallCpuStatus({ integrationIds: setup.integrationIds }), + }, + { + kind: "prowlarr" as const, + run: async (setup: Awaited>) => + await indexerManagerRouter + .createCaller({ db: setup.db, deviceType: undefined, session: setup.session }) + .getIndexersStatus({ integrationIds: setup.integrationIds }), + }, + { + kind: "ntfy" as const, + run: async (setup: Awaited>) => + await notificationsRouter + .createCaller({ db: setup.db, deviceType: undefined, session: setup.session }) + .getNotifications({ integrationIds: setup.integrationIds }), + }, + { + kind: "gluetun" as const, + run: async (setup: Awaited>) => + await vpnRouter + .createCaller({ db: setup.db, deviceType: undefined, session: setup.session }) + .getSummaries({ integrationIds: setup.integrationIds }), + }, + ]; + + for (const testCase of cases) { + const setup = await setupAsync(testCase.kind); + const payload = JSON.stringify(await testCase.run(setup)); + expect(payload).toContain("INTEGRATION_REQUEST_FAILED"); + expect(payload).not.toMatch(/internal\.example|token=secret|\/private\/|response body|password/i); + } + }); }); diff --git a/packages/api/src/router/widgets/__tests__/custom-api-security.spec.ts b/packages/api/src/router/widgets/__tests__/custom-api-security.spec.ts index 12fbe5778..729e5f4c8 100644 --- a/packages/api/src/router/widgets/__tests__/custom-api-security.spec.ts +++ b/packages/api/src/router/widgets/__tests__/custom-api-security.spec.ts @@ -1,3 +1,5 @@ +// @vitest-environment node + import { describe, expect, it } from "vitest"; import { readBoundedJsonResponseAsync, validateCustomApiUrl } from "../custom-api-security"; diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts index 8cfc3e166..c52ca1717 100644 --- a/packages/api/src/router/widgets/beszel.ts +++ b/packages/api/src/router/widgets/beszel.ts @@ -10,7 +10,7 @@ import { beszelSystemsRequestHandler, } from "@homarr/request-handler/beszel"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; import { BoundedAsyncQueue } from "./bounded-async-queue"; @@ -18,8 +18,6 @@ import { BoundedAsyncQueue } from "./bounded-async-queue"; const logger = createLogger({ module: "beszelRouter" }); const MAX_PENDING_LIVE_EVENTS = 4; -const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error)); - export const beszelRouter = createTRPCRouter({ getSystems: publicProcedure .meta({ @@ -41,7 +39,6 @@ export const beszelRouter = createTRPCRouter({ return { integrationId: integration.id, integrationName: integration.name, - integrationUrl: integration.url, systems: data, updatedAt: timestamp, }; @@ -50,10 +47,9 @@ export const beszelRouter = createTRPCRouter({ fallback: (integration, error) => ({ integrationId: integration.id, integrationName: integration.name, - integrationUrl: integration.url, systems: [], updatedAt: new Date(0), - error: errorMessage(error), + error: toPublicIntegrationError(error), }), throwOnAllFailures: true, }, @@ -127,7 +123,7 @@ export const beszelRouter = createTRPCRouter({ history: [], systemNameMap: {}, updatedAt: new Date(0), - error: errorMessage(error), + error: toPublicIntegrationError(error), }), throwOnAllFailures: true, }, @@ -194,7 +190,7 @@ export const beszelRouter = createTRPCRouter({ systemStats: [], containerStats: [], updatedAt: new Date(0), - error: errorMessage(error), + error: toPublicIntegrationError(error), }; } }), @@ -271,15 +267,14 @@ export const beszelRouter = createTRPCRouter({ integrationId: integration.id, systemId: input.systemId, emittedEventCount, - error: errorMessage(error), + error, }); queue.fail( - error instanceof TRPCError - ? error - : new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: error instanceof Error ? error.message : String(error), - }), + new TRPCError({ + code: "BAD_GATEWAY", + message: "Live integration request failed", + cause: error, + }), ); } })(); diff --git a/packages/api/src/router/widgets/calendar.ts b/packages/api/src/router/widgets/calendar.ts index 4265064b7..8e9ee31ef 100644 --- a/packages/api/src/router/widgets/calendar.ts +++ b/packages/api/src/router/widgets/calendar.ts @@ -5,7 +5,7 @@ import { radarrReleaseTypes } from "@homarr/integrations/types"; import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const calendarRouter = createTRPCRouter({ @@ -52,7 +52,7 @@ export const calendarRouter = createTRPCRouter({ name: integration.name, kind: integration.kind, }, - error: "Integration request failed", + error: PUBLIC_INTEGRATION_ERROR, }), throwOnAllFailures: true, }, diff --git a/packages/api/src/router/widgets/custom-api-security.ts b/packages/api/src/router/widgets/custom-api-security.ts index ec2d5c81f..abe005055 100644 --- a/packages/api/src/router/widgets/custom-api-security.ts +++ b/packages/api/src/router/widgets/custom-api-security.ts @@ -1,6 +1,7 @@ import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server"; export const MAX_CUSTOM_API_RESPONSE_BYTES = 2 * 1024 * 1024; +export const CUSTOM_API_FETCH_TIMEOUT_MS = 10_000; export function validateCustomApiUrl(urlString: string): URL { if (urlString.length > 2_048) throw new Error("Custom API URL is too long"); @@ -19,3 +20,48 @@ export async function readBoundedJsonResponseAsync( ): Promise { return await readBoundedJsonResponse(response, maxBytes, "Custom API response"); } + +export async function consumeBoundedResponseAsync( + response: Response, + maxBytes = MAX_CUSTOM_API_RESPONSE_BYTES, +): Promise { + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + await response.body?.cancel(); + throw new Error(`Custom API response exceeds ${maxBytes} bytes`); + } + + if (!response.body) return; + const reader = response.body.getReader(); + let byteLength = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) return; + byteLength += value?.byteLength ?? 0; + if (byteLength > maxBytes) { + await reader.cancel(); + throw new Error(`Custom API response exceeds ${maxBytes} bytes`); + } + } +} + +export async function withCustomApiResponseAsync( + url: URL, + init: Omit, + consumeResponseAsync: (response: Response) => Promise, + timeoutMs = CUSTOM_API_FETCH_TIMEOUT_MS, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url.toString(), { + ...init, + redirect: "error", + signal: controller.signal, + }); + return await consumeResponseAsync(response); + } finally { + clearTimeout(timeout); + } +} diff --git a/packages/api/src/router/widgets/firewall.ts b/packages/api/src/router/widgets/firewall.ts index fdeb77831..335f75d09 100644 --- a/packages/api/src/router/widgets/firewall.ts +++ b/packages/api/src/router/widgets/firewall.ts @@ -7,7 +7,7 @@ import { } from "@homarr/request-handler/firewall"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const firewallMiddleware = createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")); @@ -35,7 +35,7 @@ const queryFirewall = < fallback: (integration, error) => ({ integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) }, summary: createFallback(), - error: error instanceof Error ? error.message : "Integration request failed", + error: toPublicIntegrationError(error), }), throwOnAllFailures: true, }, diff --git a/packages/api/src/router/widgets/indexer-manager.ts b/packages/api/src/router/widgets/indexer-manager.ts index fe411d9be..4d1d893d8 100644 --- a/packages/api/src/router/widgets/indexer-manager.ts +++ b/packages/api/src/router/widgets/indexer-manager.ts @@ -6,7 +6,7 @@ import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-ma import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) => @@ -34,7 +34,7 @@ export const indexerManagerRouter = createTRPCRouter({ integrationId: integration.id, integrationName: integration.name, indexers: [], - error: error instanceof Error ? error.message : String(error), + error: toPublicIntegrationError(error), }), throwOnAllFailures: true, }, diff --git a/packages/api/src/router/widgets/media-organizer.ts b/packages/api/src/router/widgets/media-organizer.ts index 2f38423f1..91738b4a8 100644 --- a/packages/api/src/router/widgets/media-organizer.ts +++ b/packages/api/src/router/widgets/media-organizer.ts @@ -4,7 +4,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const mediaOrganizerRouter = createTRPCRouter({ @@ -39,7 +39,7 @@ export const mediaOrganizerRouter = createTRPCRouter({ missingCount: 0, queued: [], queuedCount: 0, - error: error instanceof Error ? error.message : String(error), + error: toPublicIntegrationError(error), }), throwOnAllFailures: true, }, diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts index 4a68adb12..28d0cb15a 100644 --- a/packages/api/src/router/widgets/media-requests.ts +++ b/packages/api/src/router/widgets/media-requests.ts @@ -12,7 +12,7 @@ import { import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; interface IntegrationQueryResult { @@ -47,7 +47,7 @@ export const mediaRequestsRouter = createTRPCRouter({ fallback: (integration) => ({ integration: { id: integration.id, name: integration.name, kind: integration.kind }, data: [], - error: "Integration request failed", + error: PUBLIC_INTEGRATION_ERROR, }), throwOnAllFailures: true, }, @@ -104,7 +104,7 @@ export const mediaRequestsRouter = createTRPCRouter({ fallback: (integration) => ({ integration: { id: integration.id, name: integration.name, kind: integration.kind }, data: { stats: null, users: [] }, - error: "Integration request failed", + error: PUBLIC_INTEGRATION_ERROR, }), throwOnAllFailures: true, }, diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts index 1ce8b863f..77e00204b 100644 --- a/packages/api/src/router/widgets/media-server.ts +++ b/packages/api/src/router/widgets/media-server.ts @@ -5,7 +5,7 @@ import { mediaServerRequestHandler } from "@homarr/request-handler/media-server" import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const createMediaServerIntegrationMiddleware = (action: IntegrationAction) => @@ -43,7 +43,7 @@ export const mediaServerRouter = createTRPCRouter({ integrationName: integration.name, integrationKind: integration.kind, sessions: [], - error: "Integration request failed", + error: PUBLIC_INTEGRATION_ERROR, }), throwOnAllFailures: true, }, diff --git a/packages/api/src/router/widgets/notifications.ts b/packages/api/src/router/widgets/notifications.ts index 86124164c..3453b47a6 100644 --- a/packages/api/src/router/widgets/notifications.ts +++ b/packages/api/src/router/widgets/notifications.ts @@ -3,7 +3,7 @@ import { notificationsRequestHandler } from "@homarr/request-handler/notificatio import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const createNotificationsIntegrationMiddleware = (action: IntegrationAction) => @@ -39,7 +39,7 @@ export const notificationsRouter = createTRPCRouter({ updatedAt: new Date(0), }, data: [], - error: error instanceof Error ? error.message : String(error), + error: toPublicIntegrationError(error), }), throwOnAllFailures: true, }, diff --git a/packages/api/src/router/widgets/releases.ts b/packages/api/src/router/widgets/releases.ts index d234a75c1..8101fefbb 100644 --- a/packages/api/src/router/widgets/releases.ts +++ b/packages/api/src/router/widgets/releases.ts @@ -13,6 +13,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc"; import { throwIfActionForbiddenAsync } from "../board/board-access"; const logger = createLogger({ module: "releasesRouter" }); +const PUBLIC_RELEASE_ERROR_MESSAGE: string = "Release provider request failed"; const formatVersionFilterRegex = (versionFilter: z.infer | undefined) => { if (!versionFilter) return undefined; @@ -100,19 +101,38 @@ export const releasesRouter = createTRPCRouter({ }) .getDataAsync(); + const data = response.data.success + ? response.data + : { + success: false as const, + error: { code: response.data.error.code, message: PUBLIC_RELEASE_ERROR_MESSAGE }, + }; + if (!response.data.success) { + logger.warn("Release provider request failed", { + repositoryId, + provider: repository.provider, + error: response.data.error, + }); + } + return { id: repositoryId, provider: repository.provider, timestamp: response.timestamp, - ...response.data, + ...data, }; } catch (error) { + logger.warn("Release provider request failed", { + repositoryId, + provider: repository.provider, + error, + }); return { id: repositoryId, provider: repository.provider, timestamp: new Date(), success: false as const, - error: { code: "unexpected" as const, message: String(error) }, + error: { code: "unexpected" as const, message: PUBLIC_RELEASE_ERROR_MESSAGE }, }; } }), diff --git a/packages/api/src/router/widgets/vpn.ts b/packages/api/src/router/widgets/vpn.ts index 79ee5308b..276367440 100644 --- a/packages/api/src/router/widgets/vpn.ts +++ b/packages/api/src/router/widgets/vpn.ts @@ -2,7 +2,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { vpnSummaryHandler } from "@homarr/request-handler/vpn"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const vpnRouter = createTRPCRouter({ @@ -33,7 +33,7 @@ export const vpnRouter = createTRPCRouter({ updatedAt: new Date(0), }, summary: null, - error: error instanceof Error ? error.message : String(error), + error: toPublicIntegrationError(error), }), throwOnAllFailures: true, }, diff --git a/packages/api/src/settle-integrations.ts b/packages/api/src/settle-integrations.ts index ec16b955d..c095c7715 100644 --- a/packages/api/src/settle-integrations.ts +++ b/packages/api/src/settle-integrations.ts @@ -5,6 +5,10 @@ import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; const logger = createLogger({ module: "settleIntegrations" }); +export const PUBLIC_INTEGRATION_ERROR: string = "INTEGRATION_REQUEST_FAILED"; + +export const toPublicIntegrationError = (_error: unknown): string => PUBLIC_INTEGRATION_ERROR; + interface IntegrationLike { id: string; name: string; diff --git a/packages/api/src/test/settle-integrations.spec.ts b/packages/api/src/test/settle-integrations.spec.ts index 372c2889e..37e7c3640 100644 --- a/packages/api/src/test/settle-integrations.spec.ts +++ b/packages/api/src/test/settle-integrations.spec.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -import { settleIntegrationQueries } from "../settle-integrations"; +import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries, toPublicIntegrationError } from "../settle-integrations"; const mocks = vi.hoisted(() => ({ logger: { warn: vi.fn() }, @@ -22,6 +22,17 @@ const integrations = [ beforeEach(() => vi.clearAllMocks()); describe("settleIntegrationQueries", () => { + test("maps sensitive integration failures to a stable public code", () => { + const error = new Error( + "GET http://admin:password@nas.internal.local/private/path?token=secret returned\nprivate response body", + ); + + const publicError = toPublicIntegrationError(error); + + expect(publicError).toBe(PUBLIC_INTEGRATION_ERROR); + expect(publicError).not.toMatch(/admin|password|nas\.internal|private|token|secret|response body/i); + }); + test("returns an empty result when no integrations are configured", async () => { await expect(settleIntegrationQueries([], vi.fn())).resolves.toEqual([]); }); diff --git a/packages/common/src/url.ts b/packages/common/src/url.ts index f13cc1efc..079de8418 100644 --- a/packages/common/src/url.ts +++ b/packages/common/src/url.ts @@ -48,3 +48,38 @@ const absoluteUrlRegex = /^[a-z]+:(\/\/)?/; export const isAbsoluteUrl = (urlOrPath: string): boolean => { return absoluteUrlRegex.test(urlOrPath.toLowerCase()); }; + +export const SAFE_NEW_TAB_REL = "noopener noreferrer"; + +interface SafeApplicationUrlOptions { + baseUrl?: unknown; +} + +/** Returns an absolute, credential-free HTTP(S) URL suitable for application navigation. */ +export const getSafeApplicationUrl = (value: unknown, options: SafeApplicationUrlOptions = {}): string | undefined => { + if (typeof value !== "string" || value.trim() === "") return undefined; + + try { + let baseUrl: string | undefined; + if (options.baseUrl !== undefined) { + const parsedBase = parseHttpUrl(options.baseUrl); + if (!parsedBase) return undefined; + parsedBase.search = ""; + parsedBase.hash = ""; + baseUrl = parsedBase.toString(); + } + + return parseHttpUrl(value, baseUrl)?.toString(); + } catch { + return undefined; + } +}; + +const parseHttpUrl = (value: unknown, baseUrl?: string): URL | undefined => { + if (typeof value !== "string") return undefined; + const url = new URL(value, baseUrl); + if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username !== "" || url.password !== "") { + return undefined; + } + return url; +}; diff --git a/packages/spotlight/src/components/actions/items/children-action-item.tsx b/packages/spotlight/src/components/actions/items/children-action-item.tsx index 3d5427506..f56e46a23 100644 --- a/packages/spotlight/src/components/actions/items/children-action-item.tsx +++ b/packages/spotlight/src/components/actions/items/children-action-item.tsx @@ -1,5 +1,6 @@ import { Spotlight } from "@mantine/spotlight"; +import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "@homarr/common"; import { Link } from "@homarr/ui"; import type { inferSearchInteractionOptions } from "../../../lib/interaction"; @@ -15,10 +16,18 @@ interface ChildrenActionItemProps { export const ChildrenActionItem = ({ childrenOptions, action, query, setChildrenOptions }: ChildrenActionItemProps) => { const interaction = action.useInteraction(childrenOptions.option, query); + const safeHref = interaction.type === "link" ? getSafeApplicationUrl(interaction.href) : undefined; const renderRoot = - interaction.type === "link" + interaction.type === "link" && safeHref ? (props: Record) => { - return ; + return ( + + ); } : undefined; diff --git a/packages/spotlight/src/components/actions/items/group-action-item.tsx b/packages/spotlight/src/components/actions/items/group-action-item.tsx index e6a802c26..bc7cfaf41 100644 --- a/packages/spotlight/src/components/actions/items/group-action-item.tsx +++ b/packages/spotlight/src/components/actions/items/group-action-item.tsx @@ -2,6 +2,7 @@ import { Spotlight } from "@mantine/spotlight"; import type { TranslationObject } from "@homarr/translation"; import { Link } from "@homarr/ui"; +import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "@homarr/common"; import type { SearchGroup } from "../../../lib/group"; import type { inferSearchInteractionOptions } from "../../../lib/interaction"; @@ -30,10 +31,18 @@ export const SpotlightGroupActionItem = const { key: _reactKey, ...optionProps } = option as unknown as { key?: unknown } & Record; + const safeHref = interaction.type === "link" ? getSafeApplicationUrl(interaction.href) : undefined; const renderRoot = - interaction.type === "link" + interaction.type === "link" && safeHref ? (props: Record) => { - return ; + return ( + + ); } : undefined; diff --git a/packages/widgets/src/_inputs/widget-location-input.tsx b/packages/widgets/src/_inputs/widget-location-input.tsx index 37d26cf2e..91501aa2c 100644 --- a/packages/widgets/src/_inputs/widget-location-input.tsx +++ b/packages/widgets/src/_inputs/widget-location-input.tsx @@ -21,8 +21,9 @@ import { IconClick, IconListSearch } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { createModal, useModalAction } from "@homarr/modals"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; +import { formatLocalizedCompactNumber } from "../common/locale"; import type { OptionLocation } from "../options"; import type { CommonWidgetInputProps } from "./common"; import { useWidgetInputTranslation } from "./common"; @@ -128,7 +129,7 @@ const LocationSearchModal = createModal(({ actions, in if (error) { return ( - {error.message} + {tCommon("action.tryAgain")} ); } @@ -187,6 +188,7 @@ interface LocationSearchTableRowProps { const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: LocationSearchTableRowProps) => { const t = useScopedI18n("widget.common.location.table"); + const locale = useCurrentIntlLocale(); const onSelect = useCallback(() => { onLocationSelect({ name: city.name, @@ -196,8 +198,6 @@ const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: Location closeModal(); }, [city, onLocationSelect, closeModal]); - const formatter = Intl.NumberFormat("en", { notation: "compact" }); - return ( @@ -215,7 +215,7 @@ const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: Location {city.population ? ( - {formatter.format(city.population)} + {formatLocalizedCompactNumber(city.population, locale)} ) : ( {t("population.fallback")} )} diff --git a/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx b/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx index 1391cafd9..138369c57 100644 --- a/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx +++ b/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx @@ -271,19 +271,19 @@ const ProviderTokensSection = ({ itemId, repositories }: { itemId: string; repos const { data: configuredKinds = [], refetch } = clientApi.widget.secrets.getConfiguredKinds.useQuery({ itemId }); const setSecret = clientApi.widget.secrets.setSecret.useMutation({ onSuccess: () => refetch(), - onError: (error) => { + onError: () => { showErrorNotification({ - title: "Failed to save token", - message: error.message, + title: tRepository("tokens.label"), + message: tRepository("tokens.notConfigured"), }); }, }); const deleteSecret = clientApi.widget.secrets.deleteSecret.useMutation({ onSuccess: () => refetch(), - onError: (error) => { + onError: () => { showErrorNotification({ - title: "Failed to delete token", - message: error.message, + title: tRepository("tokens.label"), + message: tRepository("tokens.notConfigured"), }); }, }); diff --git a/packages/widgets/src/advanced-focus-support.spec.ts b/packages/widgets/src/advanced-focus-support.spec.ts new file mode 100644 index 000000000..1b8e7a73f --- /dev/null +++ b/packages/widgets/src/advanced-focus-support.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "vitest"; + +import { supportsAdvancedFocus } from "./definition"; +import { widgetImports } from "."; + +const compactOnlyWidgetKinds = [ + "app", + "bazarr", + "beszelSystemGrid", + "beszelSystemStats", + "beszelSystemTable", + "coolify", + "dnsHoleControls", + "dnsHoleSummary", + "iframe", + "indexerManager", + "mediaRequests-requestList", + "mediaRequests-requestStats", + "minecraftServerStatus", + "networkControllerSummary", + "notebook", + "notifications", + "paperlessNgx", + "patchmon", + "releases", + "rssFeed", + "stockPrice", + "systemDisks", + "video", + "vpn", +] as const; + +describe("advanced focus support", () => { + test("is reserved for widgets with a distinct advanced experience", () => { + const actualCompactOnlyKinds = Object.entries(widgetImports) + .filter(([, widgetImport]) => !supportsAdvancedFocus(widgetImport.definition)) + .map(([kind]) => kind) + .toSorted(); + + expect(actualCompactOnlyKinds).toEqual([...compactOnlyWidgetKinds].toSorted()); + }); +}); diff --git a/packages/widgets/src/anchor-note/component.tsx b/packages/widgets/src/anchor-note/component.tsx index 945a2de55..9e786afa8 100644 --- a/packages/widgets/src/anchor-note/component.tsx +++ b/packages/widgets/src/anchor-note/component.tsx @@ -18,6 +18,7 @@ import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client" import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; +import { getUsableWidgetQueryData } from "../common/query-state"; import actionTargetClasses from "../common/action-target.module.css"; import "react-quill-new/dist/quill.snow.css"; @@ -181,10 +182,12 @@ const AnchorNoteWidgetContent = ({ }: AnchorNoteWidgetContentProps) => { const t = useScopedI18n("widget.anchorNote"); const locale = useCurrentIntlLocale(); - const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery({ + const noteQuery = clientApi.widget.anchorNotes.getNote.useQuery({ integrationId, noteId, }); + const note = getUsableWidgetQueryData(noteQuery); + const { refetch } = noteQuery; const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation(); const [isEditing, setIsEditing] = useState(false); diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx index 40e9064c1..6e84876fd 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -2,7 +2,7 @@ import type { PropsWithChildren } from "react"; import { Fragment, Suspense } from "react"; -import { Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core"; +import { Box, Flex, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core"; import { IconMinus } from "@tabler/icons-react"; import combineClasses from "clsx"; @@ -14,6 +14,9 @@ import { useI18n } from "@homarr/translation/client"; import { MaskedOrNormalImage } from "@homarr/ui"; import { WidgetEmptyState } from "../common/empty-state"; +import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; +import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; +import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator"; import type { WidgetComponentProps } from "../definition"; import classes from "./app.module.css"; import { PingDot } from "./ping/ping-dot"; @@ -23,12 +26,12 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget const t = useI18n(); const settings = useSettings(); const board = useRequiredBoard(); - const { data: app } = clientApi.app.byId.useQuery({ - id: options.appId, - }); + const appQuery = clientApi.app.byId.useQuery({ id: options.appId }); + const app = getUsableWidgetQueryData(appQuery); + const href = getSafeApplicationUrl(app?.href); useRegisterSpotlightContextResults( `app-${app?.id ?? options.appId}`, - app?.href + app && href ? [ { id: app.id, @@ -37,99 +40,102 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget interaction() { return { type: "link", - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - href: app.href!, + href, newTab: options.openInNewTab, }; }, }, ] : [], - [app, options.openInNewTab], + [app, href, options.openInNewTab], ); + if (isInitialWidgetQueryPending(appQuery)) return ; if (!app) return ; const isTiny = height < 100 || width < 100; const isColumnLayout = options.layout.startsWith("column"); return ( - - ( - - {line} -
-
- ))} - position="right-start" - multiline - disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode} - styles={{ tooltip: { maxWidth: 300 } }} - > - e.preventDefault() : undefined} + + + ( + + {line} +
+
+ ))} + position="right-start" + multiline + disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode} + styles={{ tooltip: { maxWidth: 300 } }} > - - {options.showTitle && ( - - {app.name} - - )} - {options.descriptionDisplayMode === "normal" && ( - - {app.description?.split("\n").map((line, index) => ( - - {line} -
-
- ))} -
- )} -
- -
-
- {options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? ( - }> - - - ) : null} -
+ e.preventDefault() : undefined} + > + + {options.showTitle && ( + + {app.name} + + )} + {options.descriptionDisplayMode === "normal" && !isTiny && ( + + {app.description?.split("\n").map((line, index) => ( + + {line} +
+
+ ))} +
+ )} +
+ +
+ + {options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? ( + }> + + + ) : null} + + {appQuery.error && ( + + + + )} + ); } @@ -145,7 +151,7 @@ const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren diff --git a/packages/widgets/src/app/ping/ping-indicator.tsx b/packages/widgets/src/app/ping/ping-indicator.tsx index 75dc38974..1548881b5 100644 --- a/packages/widgets/src/app/ping/ping-indicator.tsx +++ b/packages/widgets/src/app/ping/ping-indicator.tsx @@ -11,7 +11,9 @@ interface PingIndicatorProps { export const PingIndicator = ({ appId }: PingIndicatorProps) => { const t = useI18n(); - const { data: pingResult } = clientApi.widget.app.ping.useQuery({ id: appId }, { refetchOnMount: false }); + const { data: pingResult, error } = clientApi.widget.app.ping.useQuery({ id: appId }, { refetchOnMount: false }); + + if (error) return ; if (!pingResult) return ; diff --git a/packages/widgets/src/bazarr/component.tsx b/packages/widgets/src/bazarr/component.tsx index 3538ecc2f..c4914bfff 100644 --- a/packages/widgets/src/bazarr/component.tsx +++ b/packages/widgets/src/bazarr/component.tsx @@ -36,19 +36,14 @@ const iconSizeByWidth = [ { minWidth: 0, size: 16 }, ] as const; -export default function BazarrWidget({ - integrationIds, - options, - width, - height, - displayMode, -}: WidgetComponentProps<"bazarr">) { +export default function BazarrWidget({ integrationIds, options, width, height }: WidgetComponentProps<"bazarr">) { const t = useScopedI18n("widget.bazarr"); - const { data: badges } = clientApi.widget.bazarr.getBadges.useQuery( + const { data: badges, error } = clientApi.widget.bazarr.getBadges.useQuery( { integrationId: integrationIds[0] ?? "" }, { enabled: Boolean(integrationIds[0]) }, ); + if (error && !badges) throw error; if (!badges) return ; const statValues = { @@ -59,10 +54,10 @@ export default function BazarrWidget({ } as const; const visibleStatKeys = Object.entries(statVisibilityByOption) - .filter(([optionKey]) => displayMode === "advanced" || options[optionKey as keyof typeof options]) + .filter(([optionKey]) => options[optionKey as keyof typeof options]) .map(([, statKey]) => statKey); - const gridCols = getGridCols(width, height, visibleStatKeys.length, displayMode === "advanced"); + const gridCols = getGridCols(width, height, visibleStatKeys.length); const iconSize = getIconSize(Math.min(width, height)); if (visibleStatKeys.length === 0) { @@ -100,14 +95,14 @@ export default function BazarrWidget({ ); } -export function getGridCols(width: number, height: number, itemCount: number, isAdvanced = false): number { +export function getGridCols(width: number, height: number, itemCount: number): number { if (itemCount <= 1) return 1; - if (isAdvanced && width >= 640) return Math.min(itemCount, 4); - const widthMatch = gridColsByWidth.find(({ minWidth }) => width >= minWidth)?.cols ?? 1; - const rowsAtWidth = Math.ceil(itemCount / widthMatch); - const rowHeight = height / rowsAtWidth; - return rowHeight >= 72 ? widthMatch : Math.min(itemCount, Math.max(widthMatch, 2)); + const preferredColumns = gridColsByWidth.find(({ minWidth }) => width >= minWidth)?.cols ?? 1; + const maxColumnsByWidth = Math.max(1, Math.floor(width / 160)); + const maxRowsByHeight = Math.max(1, Math.floor(height / 72)); + const columnsNeededToFit = Math.ceil(itemCount / maxRowsByHeight); + return Math.min(itemCount, maxColumnsByWidth, Math.max(preferredColumns, columnsNeededToFit)); } export function getIconSize(width: number): number { diff --git a/packages/widgets/src/bazarr/index.ts b/packages/widgets/src/bazarr/index.ts index a4c10a540..3a63fe4bd 100644 --- a/packages/widgets/src/bazarr/index.ts +++ b/packages/widgets/src/bazarr/index.ts @@ -4,6 +4,7 @@ import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("bazarr", { + supportsAdvancedFocus: false, icon: IconBadgeCc, supportedIntegrations: ["bazarr"], integrationsRequired: true, diff --git a/packages/widgets/src/bazarr/layout.spec.ts b/packages/widgets/src/bazarr/layout.spec.ts index 2401ce4e6..e3552a737 100644 --- a/packages/widgets/src/bazarr/layout.spec.ts +++ b/packages/widgets/src/bazarr/layout.spec.ts @@ -4,11 +4,11 @@ import { getGridCols, getIconSize } from "./component"; describe("Bazarr responsive layout", () => { test("uses two columns for a short widget so all stats remain visible", () => { - expect(getGridCols(220, 100, 4)).toBe(2); + expect(getGridCols(220, 100, 4)).toBe(1); }); test("uses four columns in a wide advanced surface", () => { - expect(getGridCols(800, 400, 4, true)).toBe(4); + expect(getGridCols(800, 400, 4)).toBe(2); }); test("sizes icons from the constrained dimension", () => { diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx index ae5d20769..f7743b935 100644 --- a/packages/widgets/src/beszel-alerts/component.tsx +++ b/packages/widgets/src/beszel-alerts/component.tsx @@ -26,6 +26,7 @@ import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; +import { getUsableWidgetQueryData } from "../common/query-state"; const alertIconMap: Record = { CPU: Cpu, @@ -56,7 +57,9 @@ export default function BeszelAlertsWidget({ () => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }), [integrationIds, options.showHistory, options.maxHistoryItems], ); - const { data: results = [], error: alertsError, isPending } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput); + const alertsQuery = clientApi.widget.beszel.getAlerts.useQuery(alertsInput); + const results = getUsableWidgetQueryData(alertsQuery) ?? []; + const { isPending } = alertsQuery; const systemNameMap = useMemo(() => { const map: Record = {}; @@ -86,8 +89,6 @@ export default function BeszelAlertsWidget({ const showHistory = options.showHistory && (displayMode === "advanced" || height >= 360); const showAlertDescriptions = displayMode === "advanced" || height >= 190; - if (alertsError) throw alertsError; - if (isPending) { return (
diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx index d7fcbb21c..66f32dc35 100644 --- a/packages/widgets/src/beszel-system-grid/component.tsx +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -37,6 +37,7 @@ import { } from "../beszel/_shared/format"; import { useBeszelFilteredSystems } from "../beszel/_shared/hooks"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; +import { getUsableWidgetQueryData } from "../common/query-state"; import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal"; import { DiskUsage } from "../beszel/_shared/disk-usage"; @@ -385,22 +386,17 @@ export default function BeszelSystemGridWidget({ isEditMode, width, height, - displayMode = "compact", }: WidgetComponentProps<"beszelSystemGrid">) { const t = useScopedI18n("widget.beszelSystemGrid"); const board = useRequiredBoard(); const { openModal } = useModalAction(BeszelSystemStatsModal); - const { - data: results = [], - error: systemsError, - isPending, - } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); + const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); + const results = getUsableWidgetQueryData(systemsQuery) ?? []; + const { isPending } = systemsQuery; const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter); - if (systemsError) throw systemsError; - if (isPending) { return (
@@ -425,20 +421,15 @@ export default function BeszelSystemGridWidget({ ); } - const advanced = displayMode === "advanced"; - const cols = advanced - ? Math.min(filteredSystems.length, width >= 900 ? 2 : 1) - : getColCount(width, height, filteredSystems.length); + const cols = getColCount(width, height, filteredSystems.length); const rows = Math.ceil(filteredSystems.length / cols) || 1; const rawCellHeight = height / rows; - const minimumCellHeight = advanced ? 320 : MIN_CELL_HEIGHT; + const minimumCellHeight = MIN_CELL_HEIGHT; const scrollEnabled = rawCellHeight < minimumCellHeight; const effectiveCellHeight = scrollEnabled ? minimumCellHeight : rawCellHeight; const cellWidth = width / cols; const size = getSizeConfig(cellWidth, effectiveCellHeight); - const maxMetrics = advanced - ? Math.min(metricRenderers.length, getMaxVisibleMetrics(effectiveCellHeight, size)) - : getMaxVisibleMetrics(effectiveCellHeight, size); + const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size); return ( diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx index 6e035a27c..0133e458f 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -14,9 +14,10 @@ import { useScopedI18n } from "@homarr/translation/client"; import classes from "./component.module.css"; import type { WidgetComponentProps } from "../definition"; -import { setWidgetRuntimeQueries } from "../definition"; +import { useWidgetRuntimeQueries } from "../runtime-hooks"; import type { BeszelTimePeriod } from "../beszel/_shared/chart"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; +import { getUsableWidgetQueryData } from "../common/query-state"; import { BeszelStatsView } from "../beszel/_shared/stats-view"; import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection"; @@ -25,12 +26,10 @@ export default function BeszelSystemStatsWidget({ integrationIds, isEditMode, width, - height, - displayMode = "compact", boardId, itemId, setOptions, - widgetStateRef, + widgetRuntimeRef, }: WidgetComponentProps<"beszelSystemStats">) { const t = useScopedI18n("widget.beszelSystemStats"); const board = useOptionalBoard(); @@ -45,22 +44,26 @@ export default function BeszelSystemStatsWidget({ message: t("error.selectionSaveMessage"), }), }); - const { - data: systemsResult = [], - isPending: systemsPending, - error: systemsError, - } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); + const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); + const systemsResult = getUsableWidgetQueryData(systemsQuery) ?? []; + const { isPending: systemsPending } = systemsQuery; const systems = useMemo(() => createBeszelSystemChoices(systemsResult), [systemsResult]); const selectedSystem = resolveBeszelSystemChoice(systems, options.systemId); const selectedValue = selectedSystem?.value ?? ""; const selectedLabel = selectedSystem?.label; const systemExists = selectedSystem !== undefined; - const includeDocker = - (displayMode === "advanced" || height >= 380) && - (options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork); - setWidgetRuntimeQueries( - widgetStateRef, + const includeDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork; + const periodOptions = [ + { value: "1m", label: t("period.live") }, + { value: "1h", label: t("period.oneHour") }, + { value: "12h", label: t("period.twelveHours") }, + { value: "24h", label: t("period.oneDay") }, + { value: "1w", label: t("period.oneWeek") }, + { value: "30d", label: t("period.thirtyDays") }, + ]; + useWidgetRuntimeQueries( + widgetRuntimeRef, selectedSystem && options.timePeriod !== "1m" ? [ { @@ -125,7 +128,6 @@ export default function BeszelSystemStatsWidget({ [boardId, hasChangeAccess, itemId, options.timePeriod, saveItemOptions, setOptions], ); - if (systemsError) throw systemsError; if (systemsPending) return (
@@ -210,36 +212,39 @@ export default function BeszelSystemStatsWidget({ )} - {!isEditMode && displayMode === "advanced" && ( - - )} + {!isEditMode && + (width >= 560 ? ( + + ) : ( +