
@@ -34,4 +31,5 @@ import piHoleApp from './img/pi-hole-ping-icon.png';
### Configuration
+
diff --git a/apps/docs/docs/widgets/bookmarks/index.mdx b/apps/docs/docs/widgets/bookmarks/index.mdx
index c3104ad5b..c79629113 100644
--- a/apps/docs/docs/widgets/bookmarks/index.mdx
+++ b/apps/docs/docs/widgets/bookmarks/index.mdx
@@ -1,29 +1,26 @@
---
-title: 'Bookmarks'
-description: 'Displays multiple app links'
+title: "Bookmarks"
+description: "Displays multiple app links"
hide_title: true
---
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { bookmarksWidget } from '.';
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { bookmarksWidget } from ".";
-
-
+
The bookmarks widget allows you to display multiple app links in a single widget.
You can add links to your favorite applications, websites, or any other resources you want quick access to.
+In view mode, right-clicking uses the browser's link menu. Homarr's widget menu remains available in edit mode.
### Screenshots
-import gridIconsOnly from './img/grid-icons-only.png';
-import gridWithTitle from './img/grid-with-title.png';
-import verticalListAll from './img/vertical-list-all.png';
-import vericalListSimple from './img/vertical-list-simple.png';
+import gridIconsOnly from "./img/grid-icons-only.png";
+import gridWithTitle from "./img/grid-with-title.png";
+import verticalListAll from "./img/vertical-list-all.png";
+import vericalListSimple from "./img/vertical-list-simple.png";
@@ -33,12 +30,13 @@ import vericalListSimple from './img/vertical-list-simple.png';

-
+
### Adding the widget
### Configuration
+
diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx
index 6a12a528d..9f3e19da7 100644
--- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx
+++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx
@@ -48,9 +48,9 @@ export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }
const settings = useSettings();
if (!session || !settings.enableRightClickOnWidgets) return <>{children}>;
- // App widgets are links — let the browser's native context menu (open in new tab, copy link)
- // show in view mode. In edit mode keep the Homarr menu so items can still be edited/removed.
- if (item.kind === "app" && !isEditMode) return <>{children}>;
+ const isLinkWidget = item.kind === "app" || item.kind === "bookmarks";
+ // Keep the Homarr menu in edit mode so these widgets can still be edited or removed.
+ if (isLinkWidget && !isEditMode) return <>{children}>;
return (
diff --git a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts
index 74a891d07..38f5fd596 100644
--- a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts
+++ b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts
@@ -301,6 +301,8 @@ interface UseCssVariableConfiguration {
isDynamic: boolean;
}
+const minimumResizeWidthDelta = 20;
+
/**
* This hook is used to configure the css variables for the gridstack
* Those css variables are used to define the size of the gridstack items
@@ -329,13 +331,13 @@ const useCssVariableConfiguration = ({
const currentHeight = wrapperRef.current.clientHeight;
const prevSize = prevSizeRef.current;
- // Only skip recalculation when the width change is insignificant (< 20px)
+ // Only skip recalculation when the width change is insignificant
// and, for dynamic sections, the height is unchanged. This prevents infinite
// feedback loops when the vertical scrollbar appears/disappears, causing
// clientWidth to oscillate by ~15px, while still reacting to height changes.
if (
prevSize !== null &&
- Math.abs(currentWidth - prevSize.width) < 20 &&
+ Math.abs(currentWidth - prevSize.width) < minimumResizeWidthDelta &&
(!isDynamic || currentHeight === prevSize.height)
) {
return;
diff --git a/packages/auth/events.ts b/packages/auth/events.ts
index 1f7098e72..0a729fd52 100644
--- a/packages/auth/events.ts
+++ b/packages/auth/events.ts
@@ -32,22 +32,15 @@ export const createSignInEventHandler = (db: Database): Exclude {
- if (typeof oidcGroups === "string") return [oidcGroups];
- if (Array.isArray(oidcGroups) && oidcGroups.every((group): group is string => typeof group === "string")) {
- return oidcGroups;
- }
- if (oidcGroups !== undefined) {
- logger.warn(`Invalid OIDC groups claim "${groupsKey}"`);
- }
- return undefined;
- })();
- if (!env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT && dbUser.provider === "oidc" && normalizedOidcGroups !== undefined) {
- logger.debug(`Using profile groups (${groupsKey}): ${JSON.stringify(normalizedOidcGroups)}`);
- await synchronizeGroupsWithExternalForUserAsync(db, user.id, normalizedOidcGroups);
+ if (
+ !env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT &&
+ dbUser.provider === "oidc" &&
+ profile &&
+ groupsKey in profile &&
+ Array.isArray(profile[groupsKey])
+ ) {
+ logger.debug(`Using profile groups (${groupsKey}): ${JSON.stringify(profile[groupsKey])}`);
+ await synchronizeGroupsWithExternalForUserAsync(db, user.id, profile[groupsKey] as string[]);
}
// In ldap-authroization we return the groups from ldap, it's not typed.
diff --git a/packages/auth/test/events.spec.ts b/packages/auth/test/events.spec.ts
index a41f13744..c94df69b8 100644
--- a/packages/auth/test/events.spec.ts
+++ b/packages/auth/test/events.spec.ts
@@ -152,48 +152,6 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
});
expect(dbGroupMembers?.groupId).toBe("1");
});
- test("should add membership when the groups claim is a single string", async () => {
- // Arrange
- const db = createDb();
- await createUserAsync(db, "oidc");
- await createGroupAsync(db);
- const eventHandler = createSignInEventHandler(db);
-
- // Act
- await eventHandler?.({
- user: { id: "1", name: "test" },
- profile: { preferred_username: "test", someRandomGroupsKey: "test" },
- account: null,
- });
-
- // Assert
- const dbGroupMembers = await db.query.groupMembers.findFirst({
- where: eq(groupMembers.userId, "1"),
- });
- expect(dbGroupMembers?.groupId).toBe("1");
- });
- test("should not change memberships for an invalid groups claim", async () => {
- // Arrange
- const db = createDb();
- await createUserAsync(db, "oidc");
- await createGroupAsync(db);
- await db.insert(groupMembers).values({ userId: "1", groupId: "1" });
- const eventHandler = createSignInEventHandler(db);
-
- // Act
- await eventHandler?.({
- user: { id: "1", name: "test" },
- profile: { preferred_username: "test", someRandomGroupsKey: 42 },
- account: null,
- });
-
- // Assert
- const dbGroupMembers = await db.query.groupMembers.findMany({
- where: eq(groupMembers.userId, "1"),
- columns: { groupId: true },
- });
- expect(dbGroupMembers).toEqual([{ groupId: "1" }]);
- });
test("should remove group membership", async () => {
// Arrange
const db = createDb();
diff --git a/packages/integrations/src/beszel/beszel-integration.ts b/packages/integrations/src/beszel/beszel-integration.ts
index d3edb6c5e..a9599e216 100644
--- a/packages/integrations/src/beszel/beszel-integration.ts
+++ b/packages/integrations/src/beszel/beszel-integration.ts
@@ -1,4 +1,4 @@
-import { ParseError, ResponseError } from "@homarr/common/server";
+import { ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
@@ -163,20 +163,7 @@ export class BeszelIntegration extends Integration {
throw new ResponseError(response);
}
- let data: BeszelAuthResponse;
- try {
- data = (await response.json()) as BeszelAuthResponse;
- } catch (error) {
- logger.warn("Failed to parse Beszel auth response JSON", {
- integrationId: this.integration.id,
- error: error instanceof Error ? error.message : String(error),
- });
- throw new ErrorWithMetadata(
- "Beszel authentication response was not valid JSON",
- { integrationId: this.integration.id },
- { cause: error instanceof Error ? error : new Error(String(error)) },
- );
- }
+ const data = (await response.json()) as BeszelAuthResponse;
const expiresAt = parseTokenExpiration(data.token) ?? undefined;
// A token that has already lapsed by the time it reaches us means our clock and Beszel's
@@ -621,19 +608,7 @@ export class BeszelIntegration extends Integration {
return TestConnectionError.StatusResult({ status: response.status, url: response.url });
}
- let data: BeszelAuthResponse;
- try {
- data = (await response.json()) as BeszelAuthResponse;
- } catch (error) {
- logger.warn("Failed to parse Beszel testing response JSON", {
- error: error instanceof Error ? error.message : String(error),
- });
- return TestConnectionError.ParseResult(
- new ParseError("Beszel testing response was not valid JSON", {
- cause: error instanceof Error ? error : new Error(String(error)),
- }),
- );
- }
+ const data = (await response.json()) as BeszelAuthResponse;
if (!data.token) {
return TestConnectionError.UnauthorizedResult(401);
}
diff --git a/packages/integrations/src/unraid/unraid-integration.ts b/packages/integrations/src/unraid/unraid-integration.ts
index cbfcb3fc6..006282f3b 100644
--- a/packages/integrations/src/unraid/unraid-integration.ts
+++ b/packages/integrations/src/unraid/unraid-integration.ts
@@ -42,7 +42,10 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
// Each entry in cpus[] is one logical CPU (thread). Dividing the summed per-thread
// utilization by the thread count (not physical cores) gives the correct average and
// avoids over-reporting on SMT systems. Guard against zero to avoid Infinity/NaN.
- const cpuUtilizationNormalized = cpuCount > 0 ? cpuUtilization / cpuCount : 0;
+ let cpuUtilizationNormalized = 0;
+ if (cpuCount > 0) {
+ cpuUtilizationNormalized = cpuUtilization / cpuCount;
+ }
const totalMemory = systemInfo.info.memory.layout.reduce((acc, layout) => layout.size + acc, 0);
const usedMemory = totalMemory * (systemInfo.metrics.memory.percentTotal / 100);