chore(release): automatic release v1.14.0

This commit is contained in:
homarr-releases[bot]
2025-04-04 19:15:16 +00:00
committed by GitHub
73 changed files with 2145 additions and 1961 deletions

View File

@@ -31,6 +31,7 @@ body:
label: Version
description: What version of Homarr are you running?
options:
- 1.13.1
- 1.13.0
- 1.12.0
- 1.11.0

View File

@@ -56,13 +56,13 @@
"@mantine/tiptap": "^7.17.3",
"@million/lint": "1.0.14",
"@tabler/icons-react": "^3.31.0",
"@tanstack/react-query": "^5.70.0",
"@tanstack/react-query-devtools": "^5.70.0",
"@tanstack/react-query-next-experimental": "^5.70.0",
"@trpc/client": "^11.0.1",
"@trpc/next": "^11.0.1",
"@trpc/react-query": "^11.0.1",
"@trpc/server": "^11.0.1",
"@tanstack/react-query": "^5.71.10",
"@tanstack/react-query-devtools": "^5.71.10",
"@tanstack/react-query-next-experimental": "^5.71.10",
"@trpc/client": "^11.0.2",
"@trpc/next": "^11.0.2",
"@trpc/react-query": "^11.0.2",
"@trpc/server": "^11.0.2",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "^5.5.0",
@@ -81,9 +81,9 @@
"react-dom": "19.1.0",
"react-error-boundary": "^5.0.0",
"react-simple-code-editor": "^0.14.1",
"sass": "^1.86.0",
"sass": "^1.86.3",
"superjson": "2.2.2",
"swagger-ui-react": "^5.20.2",
"swagger-ui-react": "^5.20.6",
"use-deep-compare-effect": "^1.8.1",
"zod": "^3.24.2"
},
@@ -92,10 +92,10 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "3.1.1",
"@types/node": "^22.13.14",
"@types/node": "^22.14.0",
"@types/prismjs": "^1.26.5",
"@types/react": "19.0.12",
"@types/react-dom": "19.0.4",
"@types/react": "19.1.0",
"@types/react-dom": "19.1.1",
"@types/swagger-ui-react": "^5.18.0",
"concurrently": "^9.1.2",
"eslint": "^9.23.0",

View File

@@ -5,8 +5,6 @@ import combineClasses from "clsx";
import { NoIntegrationSelectedError } from "node_modules/@homarr/widgets/src/errors";
import { ErrorBoundary } from "react-error-boundary";
import { useSession } from "@homarr/auth/client";
import { isWidgetRestricted } from "@homarr/auth/shared";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useSettings } from "@homarr/settings";
@@ -17,7 +15,6 @@ import type { SectionItem } from "~/app/[locale]/boards/_types";
import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions";
import { BoardItemMenu } from "./item-menu";
import { RestrictedWidgetContent } from "./restricted";
interface BoardItemContentProps {
item: SectionItem;
@@ -62,7 +59,6 @@ interface InnerContentProps {
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
const settings = useSettings();
const board = useRequiredBoard();
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const Comp = loadWidgetDynamic(item.kind);
const { definition } = widgetImports[item.kind];
@@ -74,16 +70,6 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
const widgetSupportsIntegrations =
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
if (
isWidgetRestricted({
definition,
user: session?.user ?? null,
check: (level) => level === "all",
})
) {
return <RestrictedWidgetContent kind={item.kind} />;
}
return (
<QueryErrorResetBoundary>
{({ reset }) => (

View File

@@ -3,8 +3,6 @@ import { ActionIcon, Menu } from "@mantine/core";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { isWidgetRestricted } from "@homarr/auth/shared";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
@@ -39,7 +37,6 @@ export const BoardItemMenu = ({
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
const { gridstack } = useSectionContext().refs;
const settings = useSettings();
const { data: session } = useSession();
// Reset error boundary on next render if item has been edited
useEffect(() => {
@@ -94,16 +91,6 @@ export const BoardItemMenu = ({
});
};
if (
isWidgetRestricted({
definition: currentDefinition,
user: session?.user ?? null,
check: (level) => level !== "none",
})
) {
return null;
}
return (
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
<Menu.Target>

View File

@@ -2,8 +2,6 @@ import { useMemo, useState } from "react";
import { Button, Card, Center, Grid, Input, Stack, Text } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { isWidgetRestricted } from "@homarr/auth/shared";
import { objectEntries } from "@homarr/common";
import type { WidgetKind } from "@homarr/definitions";
import { createModal } from "@homarr/modals";
@@ -17,18 +15,10 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
const [search, setSearch] = useState("");
const t = useI18n();
const { createItem } = useItemActions();
const { data: session } = useSession();
const items = useMemo(
() =>
objectEntries(widgetImports)
.filter(([, value]) => {
return !isWidgetRestricted({
definition: value.definition,
user: session?.user ?? null,
check: (level) => level !== "none",
});
})
.map(([kind, value]) => ({
kind,
icon: value.definition.icon,
@@ -36,7 +26,7 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
description: t(`widget.${kind}.description`),
}))
.sort((itemA, itemB) => itemA.name.localeCompare(itemB.name)),
[t, session?.user],
[t],
);
const filteredItems = useMemo(

View File

@@ -1,28 +0,0 @@
import { Center, Group, Stack, Text } from "@mantine/core";
import { IconShield } from "@tabler/icons-react";
import type { WidgetKind } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
interface RestrictedWidgetProps {
kind: WidgetKind;
}
export const RestrictedWidgetContent = ({ kind }: RestrictedWidgetProps) => {
const tCurrentWidget = useScopedI18n(`widget.${kind}`);
const tCommonWidget = useScopedI18n("widget.common");
return (
<Center h="100%">
<Stack ta="center" gap="xs" align="center">
<Group gap="sm">
<IconShield size={16} />
<Text size="sm" fw="bold">
{tCommonWidget("restricted.title")}
</Text>
</Group>
<Text size="sm">{tCommonWidget("restricted.description", { name: tCurrentWidget("name") })}</Text>
</Stack>
</Center>
);
};

View File

@@ -0,0 +1,93 @@
"use client";
import type { PropsWithChildren } from "react";
import { Suspense, use } from "react";
import { Indicator, Menu, Text } from "@mantine/core";
import { IconBellRinging } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { useScopedI18n } from "@homarr/translation/client";
interface UpdateIndicatorProps extends PropsWithChildren {
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]> | undefined;
disabled: boolean;
}
export const UpdateIndicator = ({ children, availableUpdatesPromise, disabled }: UpdateIndicatorProps) => {
if (disabled || availableUpdatesPromise === undefined) {
return children;
}
return (
<Suspense fallback={children}>
<InnerUpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={disabled}>
{children}
</InnerUpdateIndicator>
</Suspense>
);
};
interface InnerUpdateIndicatorProps extends PropsWithChildren {
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
disabled: boolean;
}
const InnerUpdateIndicator = ({ children, disabled, availableUpdatesPromise }: InnerUpdateIndicatorProps) => {
const availableUpdates = use(availableUpdatesPromise);
return (
<Indicator
disabled={!availableUpdates || availableUpdates.length === 0 || disabled}
size={15}
processing
withBorder
>
{children}
</Indicator>
);
};
interface AvailableUpdatesMenuItemProps {
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]> | undefined;
}
export const AvailableUpdatesMenuItem = ({ availableUpdatesPromise }: AvailableUpdatesMenuItemProps) => {
if (availableUpdatesPromise === undefined) {
return null;
}
return (
<Suspense fallback={null}>
<InnerAvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
</Suspense>
);
};
interface InnerAvailableUpdatesMenuItemProps {
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
}
const InnerAvailableUpdatesMenuItem = ({ availableUpdatesPromise }: InnerAvailableUpdatesMenuItemProps) => {
const t = useScopedI18n("common.userAvatar.menu");
const availableUpdates = use(availableUpdatesPromise);
if (availableUpdates === undefined || availableUpdates.length === 0) {
return null;
}
const latestUpdate = availableUpdates.at(0);
if (!latestUpdate) return null;
return (
<>
<Menu.Item component={"a"} href={latestUpdate.url} target="_blank" leftSection={<IconBellRinging size="1rem" />}>
<Text fw="bold" size="sm">
{t("updateAvailable", {
countUpdates: String(availableUpdates.length),
tag: latestUpdate.tagName,
})}
</Text>
</Menu.Item>
<Menu.Divider />
</>
);
};

View File

@@ -1,21 +1,25 @@
import { Indicator, UnstyledButton } from "@mantine/core";
import { Suspense } from "react";
import { UnstyledButton } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { CurrentUserAvatar } from "~/components/user-avatar";
import { UserAvatarMenu } from "~/components/user-avatar-menu";
import { UpdateIndicator } from "./update";
export const UserButton = async () => {
const session = await auth();
const isAdmin = session?.user.permissions.includes("admin");
const data = isAdmin ? await api.updateChecker.getAvailableUpdates() : undefined;
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
return (
<UserAvatarMenu availableUpdates={data}>
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise}>
<UnstyledButton>
<Indicator disabled={data?.length === 0 || !isAdmin} size={15} processing withBorder>
<CurrentUserAvatar size="md" />
</Indicator>
<Suspense fallback={<CurrentUserAvatar size="md" />}>
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
<CurrentUserAvatar size="md" />
</UpdateIndicator>
</Suspense>
</UnstyledButton>
</UserAvatarMenu>
);

View File

@@ -7,7 +7,6 @@ import { useRouter } from "next/navigation";
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { useHotkeys, useTimeout } from "@mantine/hooks";
import {
IconBellRinging,
IconCheck,
IconHome,
IconLogin,
@@ -25,13 +24,14 @@ import { useScopedI18n } from "@homarr/translation/client";
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
import { AvailableUpdatesMenuItem } from "./layout/header/update";
interface UserAvatarMenuProps {
children: ReactNode;
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
availableUpdatesPromise?: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
}
export const UserAvatarMenu = ({ children, availableUpdates }: UserAvatarMenuProps) => {
export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu");
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
useHotkeys([["mod+J", toggleColorScheme]]);
@@ -65,24 +65,7 @@ export const UserAvatarMenu = ({ children, availableUpdates }: UserAvatarMenuPro
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
<Menu width={300} withArrow withinPortal keepMounted>
<Menu.Dropdown>
{availableUpdates && availableUpdates.length > 0 && availableUpdates[0] && (
<>
<Menu.Item
component={"a"}
href={availableUpdates[0].url}
target="_blank"
leftSection={<IconBellRinging size="1rem" />}
>
<Text fw="bold" size="sm">
{t("updateAvailable", {
countUpdates: String(availableUpdates.length),
tag: availableUpdates[0].tagName,
})}
</Text>
</Menu.Item>
<Menu.Divider />
</>
)}
<AvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
<Menu.Item onClick={toggleColorScheme} leftSection={<ColorSchemeIcon size="1rem" />}>
{colorSchemeText}
</Menu.Item>

View File

@@ -38,13 +38,13 @@
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"superjson": "2.2.2",
"undici": "7.6.0"
"undici": "7.7.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^22.13.14",
"@types/node": "^22.14.0",
"dotenv-cli": "^8.0.0",
"eslint": "^9.23.0",
"prettier": "^3.5.3",

View File

@@ -33,7 +33,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "^8.18.0",
"@types/ws": "^8.18.1",
"eslint": "^9.23.0",
"prettier": "^3.5.3",
"typescript": "^5.8.2"

View File

@@ -38,22 +38,22 @@
"@semantic-release/github": "^11.0.1",
"@semantic-release/npm": "^12.0.1",
"@semantic-release/release-notes-generator": "^14.0.3",
"@turbo/gen": "^2.4.4",
"@turbo/gen": "^2.5.0",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.0.9",
"@vitest/ui": "^3.0.9",
"@vitest/coverage-v8": "^3.1.1",
"@vitest/ui": "^3.1.1",
"conventional-changelog-conventionalcommits": "^8.0.0",
"cross-env": "^7.0.3",
"jsdom": "^26.0.0",
"prettier": "^3.5.3",
"semantic-release": "^24.2.3",
"testcontainers": "^10.23.0",
"turbo": "^2.4.4",
"testcontainers": "^10.24.0",
"turbo": "^2.5.0",
"typescript": "^5.8.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.9"
"vitest": "^3.1.1"
},
"packageManager": "pnpm@10.7.0",
"packageManager": "pnpm@10.7.1",
"engines": {
"node": ">=22.14.0"
},
@@ -70,7 +70,7 @@
"tree-sitter-json"
],
"overrides": {
"proxmox-api>undici": "7.6.0"
"proxmox-api>undici": "7.7.0"
},
"patchedDependencies": {
"pretty-print-error": "patches/pretty-print-error.patch"

View File

@@ -40,17 +40,17 @@
"@homarr/request-handler": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@kubernetes/client-node": "^1.1.0",
"@trpc/client": "^11.0.1",
"@trpc/react-query": "^11.0.1",
"@trpc/server": "^11.0.1",
"@kubernetes/client-node": "^1.1.1",
"@trpc/client": "^11.0.2",
"@trpc/react-query": "^11.0.2",
"@trpc/server": "^11.0.2",
"lodash.clonedeep": "^4.5.0",
"next": "15.2.4",
"pretty-print-error": "^1.1.2",
"react": "19.1.0",
"react-dom": "19.1.0",
"superjson": "2.2.2",
"trpc-to-openapi": "^2.1.3",
"trpc-to-openapi": "^2.1.5",
"zod": "^3.24.2"
},
"devDependencies": {

View File

@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { z } from "zod";
import { constructBoardPermissions, isWidgetRestricted } from "@homarr/auth/shared";
import { constructBoardPermissions } from "@homarr/auth/shared";
import type { DeviceType } from "@homarr/common/server";
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
import { and, asc, createId, eq, handleTransactionsAsync, inArray, isNull, like, not, or, sql } from "@homarr/db";
@@ -40,7 +40,6 @@ import { oldmarrConfigSchema } from "@homarr/old-schema";
import type { BoardItemAdvancedOptions } from "@homarr/validation";
import { sectionSchema, sharedItemSchema, validation, zodUnionFromArray } from "@homarr/validation";
import { widgetImports } from "../../../widgets/src";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
import { generateResponsiveGridFor } from "./board/grid-algorithm";
@@ -324,13 +323,6 @@ export const boardRouter = createTRPCRouter({
}
const { sections: boardSections, items: boardItems, layouts: boardLayouts, ...boardProps } = board;
const allowedBoardItems = boardItems.filter((item) => {
return !isWidgetRestricted({
definition: widgetImports[item.kind].definition,
user: ctx.session.user,
check: (level) => level !== "none",
});
});
const newBoardId = createId();
@@ -378,8 +370,8 @@ export const boardRouter = createTRPCRouter({
),
);
const itemMap = new Map<string, string>(allowedBoardItems.map((item) => [item.id, createId()]));
const itemsToInsert: InferInsertModel<typeof items>[] = allowedBoardItems.map(
const itemMap = new Map<string, string>(boardItems.map((item) => [item.id, createId()]));
const itemsToInsert: InferInsertModel<typeof items>[] = boardItems.map(
({ integrations: _, layouts: _layouts, ...item }) => ({
...item,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -388,7 +380,7 @@ export const boardRouter = createTRPCRouter({
}),
);
const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = allowedBoardItems.flatMap((item) =>
const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = boardItems.flatMap((item) =>
item.layouts.map(
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
...layoutSection,
@@ -421,7 +413,7 @@ export const boardRouter = createTRPCRouter({
)
.then((result) => result.map((row) => row.id));
const itemIntegrationsToInsert = allowedBoardItems.flatMap((item) =>
const itemIntegrationsToInsert = boardItems.flatMap((item) =>
item.integrations
// Restrict integrations to only those the user has access to
.filter(({ integrationId }) => integrationIdsWithAccess.includes(integrationId) || hasAccessForAll)
@@ -751,140 +743,105 @@ export const boardRouter = createTRPCRouter({
const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
const sectionsToInsert = addedSections.map(
(section): InferInsertModel<typeof sections> => ({
id: section.id,
kind: section.kind,
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
xOffset: section.kind === "dynamic" ? null : 0,
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
name: "name" in section ? section.name : null,
boardId: dbBoard.id,
}),
);
const sectionLayoutsToInsert = addedSections
.filter((section) => section.kind === "dynamic")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
);
const addedItems = filterAddedItems(input.items, dbBoard.items).filter((item) => {
return !isWidgetRestricted({
definition: widgetImports[item.kind].definition,
user: ctx.session.user,
check: (level) => level !== "none",
});
});
const itemsToInsert = addedItems.map(
(item): InferInsertModel<typeof items> => ({
id: item.id,
kind: item.kind,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
boardId: dbBoard.id,
}),
);
const itemLayoutsToInsert = addedItems.flatMap((item) =>
item.layouts.map(
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
layoutId: layoutSection.layoutId,
sectionId: layoutSection.sectionId,
itemId: item.id,
height: layoutSection.height,
width: layoutSection.width,
xOffset: layoutSection.xOffset,
yOffset: layoutSection.yOffset,
}),
),
);
const inputIntegrationRelations = input.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbBoard.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId && dbRelation.integrationId === inputRelation.integrationId,
),
);
const integrationItemsToInsert = addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
}));
const updatedItems = filterUpdatedItems(input.items, dbBoard.items).filter((item) => {
return !isWidgetRestricted({
definition: widgetImports[item.kind].definition,
user: ctx.session.user,
check: (level) => level !== "none",
});
});
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId && dbRelation.integrationId === inputRelation.integrationId,
),
);
const removedItems = filterRemovedItems(input.items, dbBoard.items).filter((item) => {
return !isWidgetRestricted({
definition: widgetImports[item.kind].definition,
user: ctx.session.user,
check: (level) => level !== "none",
});
});
const itemIdsToRemove = removedItems.map((item) => item.id);
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
const sectionIdsToRemove = removedSections.map((section) => section.id);
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
if (sectionsToInsert.length > 0) {
await transaction.insert(schema.sections).values(sectionsToInsert);
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
if (addedSections.length > 0) {
await transaction.insert(schema.sections).values(
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
xOffset: section.kind === "dynamic" ? null : 0,
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
name: "name" in section ? section.name : null,
boardId: dbBoard.id,
})),
);
if (addedSections.some((section) => section.kind === "dynamic")) {
await transaction.insert(schema.sectionLayouts).values(
addedSections
.filter((section) => section.kind === "dynamic")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
),
);
}
}
if (sectionLayoutsToInsert.length > 0) {
await transaction.insert(schema.sectionLayouts).values(sectionLayoutsToInsert);
const addedItems = filterAddedItems(input.items, dbBoard.items);
if (addedItems.length > 0) {
await transaction.insert(schema.items).values(
addedItems.map((item) => ({
id: item.id,
kind: item.kind,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
boardId: dbBoard.id,
})),
);
await transaction.insert(schema.itemLayouts).values(
addedItems.flatMap((item) =>
item.layouts.map(
(layoutSection): InferInsertModel<typeof schema.itemLayouts> => ({
layoutId: layoutSection.layoutId,
sectionId: layoutSection.sectionId,
itemId: item.id,
height: layoutSection.height,
width: layoutSection.width,
xOffset: layoutSection.xOffset,
yOffset: layoutSection.yOffset,
}),
),
),
);
}
if (itemsToInsert.length > 0) {
await transaction.insert(schema.items).values(itemsToInsert);
}
if (itemLayoutsToInsert.length > 0) {
await transaction.insert(schema.itemLayouts).values(itemLayoutsToInsert);
const inputIntegrationRelations = input.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbBoard.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
if (addedIntegrationRelations.length > 0) {
await transaction.insert(schema.integrationItems).values(
addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
})),
);
}
if (integrationItemsToInsert.length > 0) {
await transaction.insert(schema.integrationItems).values(integrationItemsToInsert);
}
const updatedItems = filterUpdatedItems(input.items, dbBoard.items);
for (const item of updatedItems) {
await transaction
@@ -915,6 +872,8 @@ export const boardRouter = createTRPCRouter({
}
}
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
await transaction
@@ -948,6 +907,15 @@ export const boardRouter = createTRPCRouter({
}
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
await transaction
.delete(schema.integrationItems)
@@ -959,36 +927,134 @@ export const boardRouter = createTRPCRouter({
);
}
if (itemIdsToRemove.length > 0) {
await transaction.delete(schema.items).where(inArray(schema.items.id, itemIdsToRemove));
const removedItems = filterRemovedItems(input.items, dbBoard.items);
const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) {
await transaction.delete(schema.items).where(inArray(schema.items.id, itemIds));
}
if (sectionIdsToRemove.length > 0) {
await transaction.delete(schema.sections).where(inArray(schema.sections.id, sectionIdsToRemove));
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
const sectionIds = removedSections.map((section) => section.id);
if (sectionIds.length > 0) {
await transaction.delete(schema.sections).where(inArray(schema.sections.id, sectionIds));
}
});
},
handleSync(db) {
db.transaction((transaction) => {
if (sectionsToInsert.length > 0) {
transaction.insert(sections).values(sectionsToInsert).run();
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
if (addedSections.length > 0) {
transaction
.insert(sections)
.values(
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
xOffset: section.kind === "dynamic" ? null : 0,
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
name: "name" in section ? section.name : null,
boardId: dbBoard.id,
})),
)
.run();
if (addedSections.some((section) => section.kind === "dynamic")) {
transaction
.insert(sectionLayouts)
.values(
addedSections
.filter((section) => section.kind === "dynamic")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
),
)
.run();
}
}
if (sectionLayoutsToInsert.length > 0) {
transaction.insert(sectionLayouts).values(sectionLayoutsToInsert).run();
const addedItems = filterAddedItems(input.items, dbBoard.items);
if (addedItems.length > 0) {
transaction
.insert(items)
.values(
addedItems.map((item) => ({
id: item.id,
kind: item.kind,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
boardId: dbBoard.id,
})),
)
.run();
transaction
.insert(itemLayouts)
.values(
addedItems.flatMap((item) =>
item.layouts.map(
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
layoutId: layoutSection.layoutId,
sectionId: layoutSection.sectionId,
itemId: item.id,
height: layoutSection.height,
width: layoutSection.width,
xOffset: layoutSection.xOffset,
yOffset: layoutSection.yOffset,
}),
),
),
)
.run();
}
if (itemsToInsert.length > 0) {
transaction.insert(items).values(itemsToInsert).run();
const inputIntegrationRelations = input.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbBoard.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
if (addedIntegrationRelations.length > 0) {
transaction
.insert(integrationItems)
.values(
addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
})),
)
.run();
}
if (itemLayoutsToInsert.length > 0) {
transaction.insert(itemLayouts).values(itemLayoutsToInsert).run();
}
if (integrationItemsToInsert.length > 0) {
transaction.insert(integrationItems).values(integrationItemsToInsert).run();
}
const updatedItems = filterUpdatedItems(input.items, dbBoard.items);
for (const item of updatedItems) {
transaction
@@ -1016,6 +1082,8 @@ export const boardRouter = createTRPCRouter({
}
}
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
transaction
@@ -1048,6 +1116,15 @@ export const boardRouter = createTRPCRouter({
}
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
transaction
.delete(integrationItems)
@@ -1060,12 +1137,18 @@ export const boardRouter = createTRPCRouter({
.run();
}
if (itemIdsToRemove.length > 0) {
transaction.delete(items).where(inArray(items.id, itemIdsToRemove)).run();
const removedItems = filterRemovedItems(input.items, dbBoard.items);
const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) {
transaction.delete(items).where(inArray(items.id, itemIds)).run();
}
if (sectionIdsToRemove.length > 0) {
transaction.delete(sections).where(inArray(sections.id, sectionIdsToRemove)).run();
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
const sectionIds = removedSections.map((section) => section.id);
if (sectionIds.length > 0) {
transaction.delete(sections).where(inArray(sections.id, sectionIds)).run();
}
});
},
@@ -1235,7 +1318,7 @@ export const boardRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
const content = await input.file.text();
const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
await importOldmarrAsync(ctx.db, oldmarr, input.configuration, ctx.session);
await importOldmarrAsync(ctx.db, oldmarr, input.configuration);
}),
});

View File

@@ -37,7 +37,7 @@ export const importRouter = createTRPCRouter({
.requiresStep("import")
.input(importInitialOldmarrInputSchema)
.mutation(async ({ ctx, input }) => {
await importInitialOldmarrAsync(ctx.db, input, ctx.session);
await importInitialOldmarrAsync(ctx.db, input);
await nextOnboardingStepAsync(ctx.db, undefined);
}),
});

View File

@@ -1,3 +1,2 @@
export * from "./board-permissions";
export * from "./integration-permissions";
export * from "./widget-restriction";

View File

@@ -1,14 +0,0 @@
import type { Session } from "next-auth";
import type { WidgetDefinition } from "../../widgets/src";
import type { RestrictionLevel } from "../../widgets/src/definition";
export const isWidgetRestricted = <TDefinition extends WidgetDefinition>(props: {
definition: TDefinition;
user: Session["user"] | null;
check: (level: RestrictionLevel) => boolean;
}) => {
if (!("restrict" in props.definition)) return false;
if (props.definition.restrict === undefined) return false;
return props.check(props.definition.restrict({ user: props.user ?? null }));
};

View File

@@ -23,7 +23,7 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"undici": "7.6.0"
"undici": "7.7.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -1,6 +1,6 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import { Agent } from "node:https";
import { Agent as HttpsAgent } from "node:https";
import path from "node:path";
import { rootCertificates } from "node:tls";
import axios from "axios";
@@ -70,12 +70,16 @@ export const createCertificateAgentAsync = async () => {
});
};
export const createAxiosCertificateInstanceAsync = async () => {
export const createHttpsAgentAsync = async () => {
const customCertificates = await loadCustomRootCertificatesAsync();
return new HttpsAgent({
ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)),
});
};
export const createAxiosCertificateInstanceAsync = async () => {
return axios.create({
httpsAgent: new Agent({
ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)),
}),
httpsAgent: await createHttpsAgentAsync(),
});
};

View File

@@ -27,6 +27,7 @@
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"dotenv": "^16.4.7"
},
"devDependencies": {

View File

@@ -0,0 +1,95 @@
import { command, string } from "@drizzle-team/brocli";
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
import { generateSecureRandomToken } from "@homarr/common/server";
import { and, count, createId, db, eq } from "@homarr/db";
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema";
import { usernameSchema } from "@homarr/validation";
export const recreateAdmin = command({
name: "recreate-admin",
desc: "Recreate credentials admin user if none exists anymore",
options: {
username: string("username").required().alias("u").desc("Name of the admin"),
},
// eslint-disable-next-line no-restricted-syntax
handler: async (options) => {
if (!process.env.AUTH_PROVIDERS?.toLowerCase().includes("credentials")) {
console.error("Credentials provider is not enabled");
return;
}
const result = await usernameSchema.safeParseAsync(options.username);
if (!result.success) {
console.error("Invalid username:");
console.error(result.error.errors.map((error) => `- ${error.message}`).join("\n"));
return;
}
const totalCount = await db
.select({
count: count(),
})
.from(groupPermissions)
.leftJoin(groupMembers, eq(groupMembers.groupId, groupPermissions.groupId))
.leftJoin(users, eq(users.id, groupMembers.userId))
.where(and(eq(groupPermissions.permission, "admin"), eq(users.provider, "credentials")))
.then((rows) => rows.at(0)?.count ?? 0);
if (totalCount > 0) {
console.error("Credentials admin user exists");
return;
}
const existingUser = await db.query.users.findFirst({
where: eq(users.name, result.data),
});
if (existingUser) {
console.error("User with this name already exists");
return;
}
const temporaryGroupId = createId();
const maxPosition = await getMaxGroupPositionAsync(db);
await db.insert(groups).values({
id: temporaryGroupId,
name: temporaryGroupId,
position: maxPosition + 1,
});
await db.insert(groupPermissions).values({
groupId: temporaryGroupId,
permission: "admin",
});
const salt = await createSaltAsync();
const password = generateSecureRandomToken(24);
const hashedPassword = await hashPasswordAsync(password, salt);
const userId = createId();
await db.insert(users).values({
id: userId,
name: result.data,
provider: "credentials",
password: hashedPassword,
salt,
});
await db.insert(groupMembers).values({
groupId: temporaryGroupId,
userId,
});
console.log(
"We created a new admin user for you. Please keep in mind, that the admin group of it has a temporary name. You should change it to something more meaningful.",
);
console.log(`\tUsername: ${result.data}`);
console.log(`\tPassword: ${password}`);
console.log(`\tGroup: ${temporaryGroupId}`);
console.log(""); // Empty line for better readability
},
});

View File

@@ -1,9 +1,10 @@
import { run } from "@drizzle-team/brocli";
import { fixUsernames } from "./commands/fix-usernames";
import { recreateAdmin } from "./commands/recreate-admin";
import { resetPassword } from "./commands/reset-password";
const commands = [resetPassword, fixUsernames];
const commands = [resetPassword, fixUsernames, recreateAdmin];
void run(commands, {
name: "homarr-cli",

View File

@@ -33,7 +33,7 @@
"next": "15.2.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"undici": "7.6.0",
"undici": "7.7.0",
"zod": "^3.24.2"
},
"devDependencies": {

View File

@@ -46,7 +46,7 @@
"@homarr/server-settings": "workspace:^0.1.0",
"@mantine/core": "^7.17.3",
"@paralleldrive/cuid2": "^2.2.2",
"@testcontainers/mysql": "^10.23.0",
"@testcontainers/mysql": "^10.24.0",
"better-sqlite3": "^11.9.1",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.6",
@@ -58,7 +58,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/better-sqlite3": "7.6.12",
"@types/better-sqlite3": "7.6.13",
"dotenv-cli": "^8.0.0",
"eslint": "^9.23.0",
"prettier": "^3.5.3",

View File

@@ -379,6 +379,183 @@ export type HomarrDocumentationPath =
| "/docs/1.11.0/widgets/rss"
| "/docs/1.11.0/widgets/video"
| "/docs/1.11.0/widgets/weather"
| "/docs/1.12.0/tags"
| "/docs/1.12.0/tags/active-directory"
| "/docs/1.12.0/tags/ad-guard"
| "/docs/1.12.0/tags/ad-guard-home"
| "/docs/1.12.0/tags/administration"
| "/docs/1.12.0/tags/advanced"
| "/docs/1.12.0/tags/analytics"
| "/docs/1.12.0/tags/api"
| "/docs/1.12.0/tags/apps"
| "/docs/1.12.0/tags/banner"
| "/docs/1.12.0/tags/blocking"
| "/docs/1.12.0/tags/boards"
| "/docs/1.12.0/tags/bookmark"
| "/docs/1.12.0/tags/bookmarks"
| "/docs/1.12.0/tags/caddy"
| "/docs/1.12.0/tags/certificates"
| "/docs/1.12.0/tags/checklist"
| "/docs/1.12.0/tags/code"
| "/docs/1.12.0/tags/community"
| "/docs/1.12.0/tags/configuration"
| "/docs/1.12.0/tags/connections"
| "/docs/1.12.0/tags/customization"
| "/docs/1.12.0/tags/data-sources"
| "/docs/1.12.0/tags/database"
| "/docs/1.12.0/tags/developer"
| "/docs/1.12.0/tags/development"
| "/docs/1.12.0/tags/dns"
| "/docs/1.12.0/tags/docker"
| "/docs/1.12.0/tags/donation"
| "/docs/1.12.0/tags/edit-mode"
| "/docs/1.12.0/tags/env"
| "/docs/1.12.0/tags/environment-variables"
| "/docs/1.12.0/tags/feeds"
| "/docs/1.12.0/tags/finance"
| "/docs/1.12.0/tags/getting-started"
| "/docs/1.12.0/tags/google"
| "/docs/1.12.0/tags/grafana"
| "/docs/1.12.0/tags/groups"
| "/docs/1.12.0/tags/hardware"
| "/docs/1.12.0/tags/health"
| "/docs/1.12.0/tags/help"
| "/docs/1.12.0/tags/icon-picker"
| "/docs/1.12.0/tags/icon-repositories"
| "/docs/1.12.0/tags/icons"
| "/docs/1.12.0/tags/iframe"
| "/docs/1.12.0/tags/images"
| "/docs/1.12.0/tags/installation"
| "/docs/1.12.0/tags/integrade"
| "/docs/1.12.0/tags/integration"
| "/docs/1.12.0/tags/integrations"
| "/docs/1.12.0/tags/interface"
| "/docs/1.12.0/tags/jellyserr"
| "/docs/1.12.0/tags/layout"
| "/docs/1.12.0/tags/ldap"
| "/docs/1.12.0/tags/links"
| "/docs/1.12.0/tags/lists"
| "/docs/1.12.0/tags/management"
| "/docs/1.12.0/tags/market"
| "/docs/1.12.0/tags/media"
| "/docs/1.12.0/tags/minecraft"
| "/docs/1.12.0/tags/monitoring"
| "/docs/1.12.0/tags/news"
| "/docs/1.12.0/tags/notebook"
| "/docs/1.12.0/tags/notes"
| "/docs/1.12.0/tags/oidc"
| "/docs/1.12.0/tags/open-collective"
| "/docs/1.12.0/tags/open-media-vault"
| "/docs/1.12.0/tags/overseerr"
| "/docs/1.12.0/tags/permissions"
| "/docs/1.12.0/tags/pgid"
| "/docs/1.12.0/tags/pi-hole"
| "/docs/1.12.0/tags/ping"
| "/docs/1.12.0/tags/programming"
| "/docs/1.12.0/tags/proxmox"
| "/docs/1.12.0/tags/proxy"
| "/docs/1.12.0/tags/puid"
| "/docs/1.12.0/tags/responsive"
| "/docs/1.12.0/tags/roles"
| "/docs/1.12.0/tags/rss"
| "/docs/1.12.0/tags/search"
| "/docs/1.12.0/tags/search-engines"
| "/docs/1.12.0/tags/security"
| "/docs/1.12.0/tags/self-signed"
| "/docs/1.12.0/tags/seo"
| "/docs/1.12.0/tags/server"
| "/docs/1.12.0/tags/settings"
| "/docs/1.12.0/tags/sinkhole"
| "/docs/1.12.0/tags/sso"
| "/docs/1.12.0/tags/stocks"
| "/docs/1.12.0/tags/system"
| "/docs/1.12.0/tags/table"
| "/docs/1.12.0/tags/technical-documentation"
| "/docs/1.12.0/tags/text"
| "/docs/1.12.0/tags/torrent"
| "/docs/1.12.0/tags/traefik"
| "/docs/1.12.0/tags/translations"
| "/docs/1.12.0/tags/unraid"
| "/docs/1.12.0/tags/uploads"
| "/docs/1.12.0/tags/usenet"
| "/docs/1.12.0/tags/users"
| "/docs/1.12.0/tags/variables"
| "/docs/1.12.0/tags/widgets"
| "/docs/1.12.0/advanced/command-line"
| "/docs/1.12.0/advanced/command-line/fix-usernames"
| "/docs/1.12.0/advanced/command-line/password-recovery"
| "/docs/1.12.0/advanced/development/getting-started"
| "/docs/1.12.0/advanced/development/kubernetes"
| "/docs/1.12.0/advanced/environment-variables"
| "/docs/1.12.0/advanced/icons"
| "/docs/1.12.0/advanced/keyboard-shortcuts"
| "/docs/1.12.0/advanced/proxy"
| "/docs/1.12.0/advanced/running-as-different-user"
| "/docs/1.12.0/advanced/single-sign-on"
| "/docs/1.12.0/category/advanced"
| "/docs/1.12.0/category/community"
| "/docs/1.12.0/category/developer-guides"
| "/docs/1.12.0/category/getting-started"
| "/docs/1.12.0/category/installation"
| "/docs/1.12.0/category/installation-1"
| "/docs/1.12.0/category/integrations"
| "/docs/1.12.0/category/management"
| "/docs/1.12.0/category/widgets"
| "/docs/1.12.0/community/donate"
| "/docs/1.12.0/community/faq"
| "/docs/1.12.0/community/get-in-touch"
| "/docs/1.12.0/community/license"
| "/docs/1.12.0/community/translations"
| "/docs/1.12.0/getting-started"
| "/docs/1.12.0/getting-started/after-the-installation"
| "/docs/1.12.0/getting-started/glossary"
| "/docs/1.12.0/getting-started/installation/docker"
| "/docs/1.12.0/getting-started/installation/easy-panel"
| "/docs/1.12.0/getting-started/installation/helm"
| "/docs/1.12.0/getting-started/installation/home-assistant"
| "/docs/1.12.0/getting-started/installation/portainer"
| "/docs/1.12.0/getting-started/installation/proxmox"
| "/docs/1.12.0/getting-started/installation/qnap"
| "/docs/1.12.0/getting-started/installation/railway"
| "/docs/1.12.0/getting-started/installation/saltbox"
| "/docs/1.12.0/getting-started/installation/source"
| "/docs/1.12.0/getting-started/installation/synology"
| "/docs/1.12.0/getting-started/installation/unraid"
| "/docs/1.12.0/integrations/containers"
| "/docs/1.12.0/integrations/dns"
| "/docs/1.12.0/integrations/hardware"
| "/docs/1.12.0/integrations/kubernetes"
| "/docs/1.12.0/integrations/media-requester"
| "/docs/1.12.0/integrations/media-server"
| "/docs/1.12.0/integrations/servarr"
| "/docs/1.12.0/integrations/torrent"
| "/docs/1.12.0/integrations/usenet"
| "/docs/1.12.0/management/api"
| "/docs/1.12.0/management/apps"
| "/docs/1.12.0/management/boards"
| "/docs/1.12.0/management/certificates"
| "/docs/1.12.0/management/integrations"
| "/docs/1.12.0/management/media"
| "/docs/1.12.0/management/search-engines"
| "/docs/1.12.0/management/settings"
| "/docs/1.12.0/management/users"
| "/docs/1.12.0/widgets/bookmarks"
| "/docs/1.12.0/widgets/calendar"
| "/docs/1.12.0/widgets/clock"
| "/docs/1.12.0/widgets/dns-hole"
| "/docs/1.12.0/widgets/downloads"
| "/docs/1.12.0/widgets/health-monitoring"
| "/docs/1.12.0/widgets/home-assistant"
| "/docs/1.12.0/widgets/iframe"
| "/docs/1.12.0/widgets/indexer-manager"
| "/docs/1.12.0/widgets/media-requests"
| "/docs/1.12.0/widgets/media-server"
| "/docs/1.12.0/widgets/minecraft-server-status"
| "/docs/1.12.0/widgets/notebook"
| "/docs/1.12.0/widgets/rss"
| "/docs/1.12.0/widgets/stocks"
| "/docs/1.12.0/widgets/video"
| "/docs/1.12.0/widgets/weather"
| "/docs/next/tags"
| "/docs/next/tags/active-directory"
| "/docs/next/tags/ad-guard"
@@ -671,6 +848,7 @@ export type HomarrDocumentationPath =
| "/docs/advanced/proxy"
| "/docs/advanced/running-as-different-user"
| "/docs/advanced/single-sign-on"
| "/docs/advanced/styling"
| "/docs/category/advanced"
| "/docs/category/community"
| "/docs/category/developer-guides"
@@ -700,6 +878,7 @@ export type HomarrDocumentationPath =
| "/docs/getting-started/installation/source"
| "/docs/getting-started/installation/synology"
| "/docs/getting-started/installation/unraid"
| "/docs/integrations/cloud"
| "/docs/integrations/containers"
| "/docs/integrations/dns"
| "/docs/integrations/hardware"

View File

@@ -25,13 +25,13 @@
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/env": "workspace:^0.1.0",
"dockerode": "^4.0.4"
"dockerode": "^4.0.5"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "^3.3.36",
"@types/dockerode": "^3.3.37",
"eslint": "^9.23.0",
"typescript": "^5.8.2"
}

View File

@@ -25,7 +25,7 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@ctrl/deluge": "^7.1.0",
"@ctrl/qbittorrent": "^9.4.0",
"@ctrl/qbittorrent": "^9.5.2",
"@ctrl/transmission": "^7.2.0",
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
@@ -39,7 +39,7 @@
"node-ical": "^0.20.1",
"proxmox-api": "1.1.1",
"tsdav": "^2.1.3",
"undici": "7.6.0",
"undici": "7.7.0",
"xml2js": "^0.6.2",
"zod": "^3.24.2"
},

View File

@@ -3,7 +3,9 @@ import objectSupport from "dayjs/plugin/objectSupport";
import utc from "dayjs/plugin/utc";
import * as ical from "node-ical";
import { DAVClient } from "tsdav";
import type { RequestInit as UndiciFetchRequestInit } from "undici";
import { createCertificateAgentAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import { Integration } from "../base/integration";
@@ -14,12 +16,12 @@ dayjs.extend(objectSupport);
export class NextcloudIntegration extends Integration {
public async testConnectionAsync(): Promise<void> {
const client = this.createCalendarClient();
const client = await this.createCalendarClientAsync();
await client.login();
}
public async getCalendarEventsAsync(start: Date, end: Date): Promise<CalendarEvent[]> {
const client = this.createCalendarClient();
const client = await this.createCalendarClientAsync();
await client.login();
const calendars = await client.fetchCalendars();
@@ -83,7 +85,7 @@ export class NextcloudIntegration extends Integration {
});
}
private createCalendarClient() {
private async createCalendarClientAsync() {
return new DAVClient({
serverUrl: this.integration.url,
credentials: {
@@ -92,6 +94,10 @@ export class NextcloudIntegration extends Integration {
},
authMethod: "Basic",
defaultAccountType: "caldav",
fetchOptions: {
// We can use the undici options as the global fetch is used instead of the polyfilled.
dispatcher: await createCertificateAgentAsync(),
} satisfies UndiciFetchRequestInit as RequestInit,
});
}
}

View File

@@ -26,7 +26,6 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",

View File

@@ -1,12 +1,9 @@
import type { Session } from "@homarr/auth";
import { isWidgetRestricted } from "@homarr/auth/shared";
import { createId } from "@homarr/db";
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
import { logger } from "@homarr/log";
import type { BoardSize, OldmarrConfig } from "@homarr/old-schema";
import { boardSizes, getBoardSizeName } from "@homarr/old-schema";
import { widgetImports } from "../../../../widgets/src";
import { fixSectionIssues } from "../../fix-section-issues";
import { OldHomarrImportError } from "../../import-error";
import { mapBoard } from "../../mappers/map-board";
@@ -21,7 +18,6 @@ import type { InitialOldmarrImportSettings } from "../../settings";
export const createBoardInsertCollection = (
{ preparedApps, preparedBoards }: Omit<ReturnType<typeof prepareMultipleImports>, "preparedIntegrations">,
settings: InitialOldmarrImportSettings,
session: Session | null,
) => {
const insertCollection = createDbInsertCollectionForTransaction([
"apps",
@@ -117,18 +113,10 @@ export const createBoardInsertCollection = (
layoutMapping,
mappedBoard.id,
);
preparedItems
.filter((item) => {
return !isWidgetRestricted({
definition: widgetImports[item.kind].definition,
user: session?.user ?? null,
check: (level) => level !== "none",
});
})
.forEach(({ layouts, ...item }) => {
insertCollection.items.push(item);
insertCollection.itemLayouts.push(...layouts);
});
preparedItems.forEach(({ layouts, ...item }) => {
insertCollection.items.push(item);
insertCollection.itemLayouts.push(...layouts);
});
logger.debug(`Added items to board insert collection count=${insertCollection.items.length}`);
});

View File

@@ -1,6 +1,5 @@
import type { z } from "zod";
import type { Session } from "@homarr/auth";
import { Stopwatch } from "@homarr/common";
import { handleTransactionsAsync } from "@homarr/db";
import type { Database } from "@homarr/db";
@@ -17,7 +16,6 @@ import { ensureValidTokenOrThrow } from "./validate-token";
export const importInitialOldmarrAsync = async (
db: Database,
input: z.infer<typeof importInitialOldmarrInputSchema>,
session: Session | null,
) => {
const stopwatch = new Stopwatch();
const { checksum, configs, users: importUsers } = await analyseOldmarrImportAsync(input.file);
@@ -31,7 +29,7 @@ export const importInitialOldmarrAsync = async (
logger.info("Preparing import data in insert collections for database");
const boardInsertCollection = createBoardInsertCollection({ preparedApps, preparedBoards }, input.settings, session);
const boardInsertCollection = createBoardInsertCollection({ preparedApps, preparedBoards }, input.settings);
const userInsertCollection = createUserInsertCollection(importUsers, input.token);
const integrationInsertCollection = createIntegrationInsertCollection(preparedIntegrations, input.token);

View File

@@ -1,4 +1,3 @@
import type { Session } from "@homarr/auth";
import { handleTransactionsAsync, inArray } from "@homarr/db";
import type { Database } from "@homarr/db";
import { apps } from "@homarr/db/schema";
@@ -13,7 +12,6 @@ export const importSingleOldmarrConfigAsync = async (
db: Database,
config: OldmarrConfig,
settings: OldmarrImportConfiguration,
session: Session | null,
) => {
const { preparedApps, preparedBoards } = prepareSingleImport(config, settings);
const existingApps = await db.query.apps.findMany({
@@ -31,7 +29,7 @@ export const importSingleOldmarrConfigAsync = async (
return app;
});
const boardInsertCollection = createBoardInsertCollection({ preparedApps, preparedBoards }, settings, session);
const boardInsertCollection = createBoardInsertCollection({ preparedApps, preparedBoards }, settings);
await handleTransactionsAsync(db, {
async handleAsync(db) {

View File

@@ -1,4 +1,3 @@
import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
import type { OldmarrConfig } from "@homarr/old-schema";
@@ -9,7 +8,6 @@ export const importOldmarrAsync = async (
db: Database,
old: OldmarrConfig,
configuration: OldmarrImportConfiguration,
session: Session | null,
) => {
await importSingleOldmarrConfigAsync(db, old, configuration, session);
await importSingleOldmarrConfigAsync(db, old, configuration);
};

View File

@@ -2,6 +2,7 @@ import dayjs from "dayjs";
import { Octokit } from "octokit";
import { compareSemVer, isValidSemVer } from "semver-parser";
import { fetchWithTimeout } from "@homarr/common";
import { logger } from "@homarr/log";
import { createChannelWithLatestAndEvents } from "@homarr/redis";
import { createCachedRequestHandler } from "@homarr/request-handler/lib/cached-request-handler";
@@ -12,7 +13,11 @@ export const updateCheckerRequestHandler = createCachedRequestHandler({
queryKey: "homarr-update-checker",
cacheDuration: dayjs.duration(1, "hour"),
async requestAsync(_) {
const octokit = new Octokit();
const octokit = new Octokit({
request: {
fetch: fetchWithTimeout,
},
});
const releases = await octokit.rest.repos.listReleases({
owner: "homarr-labs",
repo: "homarr",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "",
"noData": ""
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "",

View File

@@ -677,11 +677,11 @@
"description": "集成“{kind}”可以与搜索引擎一起使用。勾选此项可自动配置搜索引擎。"
},
"createApp": {
"label": "",
"description": ""
"label": "创建应用",
"description": "创建一个具有与集成相同名称和图标的应用程序。 留空下面的输入字段使用集成URL创建应用程序。"
},
"appHref": {
"placeholder": ""
"placeholder": "自定义应用URL"
}
},
"action": {
@@ -981,7 +981,7 @@
},
"option": {
"title": {
"label": ""
"label": "标题"
},
"borderColor": {
"label": "边界颜色"
@@ -1428,76 +1428,76 @@
}
},
"stockPrice": {
"name": "",
"description": "",
"name": "股票价格",
"description": "显示公司的当前股价",
"option": {
"stock": {
"label": ""
"label": "股票代码"
},
"timeRange": {
"label": "",
"label": "时间范围",
"option": {
"1d": {
"label": ""
"label": "1天"
},
"5d": {
"label": ""
"label": "5天"
},
"1mo": {
"label": ""
"label": "1个月"
},
"3mo": {
"label": ""
"label": "3 个月"
},
"6mo": {
"label": ""
"label": "6 个月"
},
"ytd": {
"label": ""
"label": "年初至今"
},
"1y": {
"label": ""
"label": "1 年"
},
"2y": {
"label": ""
"label": "2 年"
},
"5y": {
"label": ""
"label": "5 年"
},
"10y": {
"label": ""
"label": "10 年"
},
"max": {
"label": ""
"label": "最大"
}
}
},
"timeInterval": {
"label": "",
"label": "时间间隔",
"option": {
"5m": {
"label": ""
"label": "5 分钟"
},
"15m": {
"label": ""
"label": "15 分钟"
},
"30m": {
"label": ""
"label": "30 分钟"
},
"1h": {
"label": ""
"label": "1 小时"
},
"1d": {
"label": ""
"label": "1 天"
},
"5d": {
"label": ""
"label": "5 天"
},
"1wk": {
"label": ""
"label": "1 周"
},
"1mo": {
"label": ""
"label": "1 个月"
}
}
}
@@ -1724,11 +1724,7 @@
"noIntegration": "未选择集成",
"noData": "没有可用的集成数据"
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "视频流",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "Nebyla vybrána žádná integrace",
"noData": "Nejsou k dispozici žádná data o integraci"
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "Streamování videa",

View File

@@ -981,7 +981,7 @@
},
"option": {
"title": {
"label": ""
"label": "Titel"
},
"borderColor": {
"label": "Kantfarve"
@@ -1724,11 +1724,7 @@
"noIntegration": "Ingen integration valgt",
"noData": "Ingen tilgængelige integrationsdata"
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "Video Stream",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "Keine Integration ausgewählt",
"noData": "Keine Integrationsdaten verfügbar"
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "Videostream",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "Keine Integration ausgewählt",
"noData": "Keine Integrationsdaten verfügbar"
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "Videostream",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "",
"noData": ""
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "Ροή Βίντεο",

File diff suppressed because it is too large Load Diff

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "No integration selected",
"noData": "No integration data available"
},
"option": {},
"restricted": {
"title": "Restricted",
"description": "You don't have access to the {name} widget."
}
"option": {}
},
"video": {
"name": "Video Stream",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "",
"noData": ""
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "Video en directo",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "",
"noData": ""
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "Aucune intégration sélectionnée",
"noData": "Aucune donnée dinteraction disponible"
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "Flux vidéo",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "לא נבחרה אינטגרציה",
"noData": "אין נתוני אינטגרציה זמינים"
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "זרם וידאו",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "",
"noData": ""
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "",
"noData": ""
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "Videófolyam",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "",
"noData": ""
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "Stream Video",

View File

@@ -3,7 +3,7 @@
"step": {
"start": {
"title": "Homarrへようこそ",
"subtitle": "",
"subtitle": "Homarr を設定しましょう。",
"description": "",
"action": {
"scratch": "ゼロからスタート",
@@ -92,17 +92,17 @@
},
"finish": {
"title": "",
"subtitle": "",
"description": "",
"subtitle": "準備ができています!",
"description": "セットアップが正常に完了しました。使い始めることができます。次のアクションを選択してください:",
"action": {
"goToBoard": "",
"createBoard": "",
"inviteUser": "",
"inviteUser": "他のユーザーを招待する",
"docs": ""
}
}
},
"backToStart": ""
"backToStart": "戻る"
},
"user": {
"title": "ユーザー",
@@ -114,7 +114,7 @@
},
"invite": {
"title": "",
"subtitle": "",
"subtitle": "Homarr へようこそ!アカウントを作成してください",
"description": ""
},
"init": {
@@ -133,7 +133,7 @@
"password": {
"label": "パスワード",
"requirement": {
"length": "",
"length": "少なくとも 8 文字以上を含む",
"lowercase": "小文字を含む",
"uppercase": "大文字を含む",
"number": "番号を含む",
@@ -172,13 +172,13 @@
"message": ""
},
"error": {
"title": "",
"message": ""
"title": "ログイン失敗",
"message": "ログイン失敗"
}
},
"forgotPassword": {
"label": "",
"description": ""
"label": "パスワードをお忘れですか?",
"description": "管理者は、次のコマンドを使用してパスワードをリセットできます:"
}
},
"register": {
@@ -865,7 +865,7 @@
"delete": "削除",
"discard": "",
"confirm": "確認",
"continue": "",
"continue": "次へ",
"previous": "前へ",
"next": "次へ",
"checkoutDocs": "",
@@ -1724,11 +1724,7 @@
"noIntegration": "",
"noData": ""
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "ビデオストリーム",
@@ -2655,11 +2651,11 @@
"text": ""
},
"integrationData": {
"title": "",
"text": ""
"title": "インテグレーション情報",
"text": "あなたが設定したインテグレーションおよびその数量を送信します。URL、名前、その他のデータは含まれません。"
},
"usersData": {
"title": "",
"title": "ユーザー情報",
"text": ""
}
},
@@ -2672,15 +2668,15 @@
},
"noFollow": {
"title": "",
"text": ""
"text": "インデックス登録中はリンクを追跡しない。これを無効にすると、クローラーが、Homarr 上のすべてのリンクを追跡しようとします。"
},
"noTranslate": {
"title": "",
"text": ""
"text": "サイトの言語がユーザーの読みたい言語ではないとき、Google は検索結果に翻訳のリンクを表示します"
},
"noSiteLinksSearchBox": {
"title": "",
"text": ""
"text": "Googleはクローラーによるリンクからサイトリンク検索ボックスを構築します。これを有効にすると Google にそのサイトリンク検索ボックスを無効にするよう求められます。"
}
},
"board": {
@@ -2715,7 +2711,7 @@
"label": "",
"options": {
"light": "",
"dark": ""
"dark": "ダーク"
}
}
},
@@ -3393,7 +3389,7 @@
"option": {
"colorScheme": {
"light": "",
"dark": ""
"dark": "ダークモードに切り替え"
},
"language": {
"label": "",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "",
"noData": ""
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "비디오 스트림",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "",
"noData": ""
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "",
"noData": ""
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "Videostraume",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "Geen integratie geselecteerd",
"noData": "Geen integratiegegevens beschikbaar"
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "Video stream",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "Ingen integrasjon valgt",
"noData": "Ingen integrasjonsdata er tilgjengelig"
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "Videostrømming",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "Nie wybrano integracji",
"noData": "Brak danych dotyczących integracji"
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "Strumień wideo",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "",
"noData": ""
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "Transmissão de vídeo",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "",
"noData": ""
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "Stream video",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "Интеграция не выбрана",
"noData": "Данные интеграции недоступны"
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "Видеотрансляция",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "Nie je vybraná žiadna integrácia",
"noData": "Nie sú k dispozícii žiadne údaje o integrácii"
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "Video stream",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "",
"noData": ""
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "Video tok",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "",
"noData": ""
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "Videoström",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "Hiçbir entegrasyon seçilmedi",
"noData": "Entegrasyon verisi mevcut değil"
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "Video Akışı",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "Інтеграція не вибрана",
"noData": "Немає даних про інтеграцію"
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "Потокова трансляція відео",

View File

@@ -1724,11 +1724,7 @@
"noIntegration": "",
"noData": ""
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "Luồng video",

View File

@@ -981,7 +981,7 @@
},
"option": {
"title": {
"label": ""
"label": "標題"
},
"borderColor": {
"label": "邊框顏色"
@@ -1428,76 +1428,76 @@
}
},
"stockPrice": {
"name": "",
"description": "",
"name": "股票價格",
"description": "顯示公司當前股票價格",
"option": {
"stock": {
"label": ""
"label": "股票代碼"
},
"timeRange": {
"label": "",
"label": "時間範圍",
"option": {
"1d": {
"label": ""
"label": "單日"
},
"5d": {
"label": ""
"label": "五日"
},
"1mo": {
"label": ""
"label": "一月"
},
"3mo": {
"label": ""
"label": "三月"
},
"6mo": {
"label": ""
"label": "六月"
},
"ytd": {
"label": ""
"label": "今年至今"
},
"1y": {
"label": ""
"label": "一年"
},
"2y": {
"label": ""
"label": "兩年"
},
"5y": {
"label": ""
"label": "五年"
},
"10y": {
"label": ""
"label": "十年"
},
"max": {
"label": ""
"label": "最大"
}
}
},
"timeInterval": {
"label": "",
"label": "時間間隔",
"option": {
"5m": {
"label": ""
"label": "五分"
},
"15m": {
"label": ""
"label": "十五分"
},
"30m": {
"label": ""
"label": "三十分"
},
"1h": {
"label": ""
"label": "一小時"
},
"1d": {
"label": ""
"label": "一日"
},
"5d": {
"label": ""
"label": "五日"
},
"1wk": {
"label": ""
"label": "一周"
},
"1mo": {
"label": ""
"label": "一月"
}
}
}
@@ -1724,11 +1724,7 @@
"noIntegration": "未選擇集成",
"noData": "無可用的集成數據"
},
"option": {},
"restricted": {
"title": "",
"description": ""
}
"option": {}
},
"video": {
"name": "串流影音",

View File

@@ -37,6 +37,6 @@ export {
type BoardItemIntegration,
} from "./shared";
export { superRefineCertificateFile } from "./certificates";
export { passwordRequirements } from "./user";
export { passwordRequirements, usernameSchema } from "./user";
export { supportedMediaUploadFormats } from "./media";
export { zodEnumFromArray, zodUnionFromArray } from "./enums";

View File

@@ -8,7 +8,7 @@ import { zodEnumFromArray } from "./enums";
import { createCustomErrorParams } from "./form/i18n";
// We always want the lowercase version of the username to compare it in a case-insensitive way
const usernameSchema = z.string().trim().toLowerCase().min(3).max(255);
export const usernameSchema = z.string().trim().toLowerCase().min(3).max(255);
const regexCheck = (regex: RegExp) => (value: string) => regex.test(value);
export const passwordRequirements = [

View File

@@ -48,28 +48,28 @@
"@mantine/core": "^7.17.3",
"@mantine/hooks": "^7.17.3",
"@tabler/icons-react": "^3.31.0",
"@tiptap/extension-color": "2.11.6",
"@tiptap/extension-highlight": "2.11.6",
"@tiptap/extension-image": "2.11.6",
"@tiptap/extension-link": "^2.11.6",
"@tiptap/extension-table": "2.11.6",
"@tiptap/extension-table-cell": "2.11.6",
"@tiptap/extension-table-header": "2.11.6",
"@tiptap/extension-table-row": "2.11.6",
"@tiptap/extension-task-item": "2.11.6",
"@tiptap/extension-task-list": "2.11.6",
"@tiptap/extension-text-align": "2.11.6",
"@tiptap/extension-text-style": "2.11.6",
"@tiptap/extension-underline": "2.11.6",
"@tiptap/react": "^2.11.6",
"@tiptap/starter-kit": "^2.11.6",
"@tiptap/extension-color": "2.11.7",
"@tiptap/extension-highlight": "2.11.7",
"@tiptap/extension-image": "2.11.7",
"@tiptap/extension-link": "^2.11.7",
"@tiptap/extension-table": "2.11.7",
"@tiptap/extension-table-cell": "2.11.7",
"@tiptap/extension-table-header": "2.11.7",
"@tiptap/extension-table-row": "2.11.7",
"@tiptap/extension-task-item": "2.11.7",
"@tiptap/extension-task-list": "2.11.7",
"@tiptap/extension-text-align": "2.11.7",
"@tiptap/extension-text-style": "2.11.7",
"@tiptap/extension-underline": "2.11.7",
"@tiptap/react": "^2.11.7",
"@tiptap/starter-kit": "^2.11.7",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"mantine-react-table": "2.0.0-beta.9",
"next": "15.2.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"recharts": "^2.15.1",
"recharts": "^2.15.2",
"video.js": "^8.22.0",
"zod": "^3.24.2"
},

View File

@@ -1,7 +1,6 @@
import type { LoaderComponent } from "next/dynamic";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
import type { Session } from "@homarr/auth";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { ServerSettings } from "@homarr/server-settings";
import type { SettingsContextProps } from "@homarr/settings";
@@ -44,23 +43,8 @@ export interface WidgetDefinition {
}
>
>;
/**
* Callback that returns wheter or not the widget should be available to the user.
* The widget will not be available in the widget picker and saving with a new one of this kind will not be possible.
*
* @param props contain user information
* @returns restriction type
*/
restrict?: (props: { user: Session["user"] | null }) => RestrictionLevel;
}
/**
* none: The widget is fully available to the user.
* select: The widget is available to the user but not in the widget picker.
* all: The widget is not available to the user. As replacement a message will be shown at the widgets position.
*/
export type RestrictionLevel = "none" | "select" | "all";
export interface WidgetProps<TKind extends WidgetKind> {
options: inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>;
integrationIds: string[];

1786
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,12 +19,12 @@
"dependencies": {
"@next/eslint-plugin-next": "15.2.4",
"eslint-config-prettier": "^10.1.1",
"eslint-config-turbo": "^2.4.4",
"eslint-config-turbo": "^2.5.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"typescript-eslint": "^8.28.0"
"typescript-eslint": "^8.29.0"
},
"devDependencies": {
"@homarr/prettier-config": "workspace:^0.1.0",