diff --git a/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx b/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx
index dd7794db8..eef7dedb2 100644
--- a/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx
@@ -3,12 +3,14 @@
import { useCallback } from "react";
import Link from "next/link";
import { Menu } from "@mantine/core";
-import { IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
+import { IconCopy, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
+import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
-import { useConfirmModal } from "@homarr/modals";
+import { useConfirmModal, useModalAction } from "@homarr/modals";
+import { DuplicateBoardModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { useBoardPermissions } from "~/components/board/permissions/client";
@@ -30,8 +32,10 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
const tCommon = useScopedI18n("common");
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
+ const { data: session } = useSession();
const { openConfirmModal } = useConfirmModal();
+ const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal);
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
onSettled: async () => {
@@ -64,11 +68,28 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
await setHomeBoardMutation.mutateAsync({ id: board.id });
}, [board.id, setHomeBoardMutation]);
+ const handleDuplicateBoard = useCallback(() => {
+ openDuplicateModal({
+ board: {
+ id: board.id,
+ name: board.name,
+ },
+ onSuccess: async () => {
+ await revalidatePathActionAsync("/manage/boards");
+ },
+ });
+ }, [board.id, board.name, openDuplicateModal]);
+
return (
}>
{t("setHomeBoard.label")}
+ {session?.user.permissions.includes("board-create") && (
+ }>
+ {t("duplicate.label")}
+
+ )}
{hasChangeAccess && (
<>
diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts
index 291ce54a4..182fc90d4 100644
--- a/packages/api/src/router/board.ts
+++ b/packages/api/src/router/board.ts
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { constructBoardPermissions } from "@homarr/auth/shared";
-import type { Database, SQL } from "@homarr/db";
+import type { Database, InferInsertModel, SQL } from "@homarr/db";
import { and, createId, eq, inArray, like, or } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import {
@@ -11,7 +11,9 @@ import {
boardUserPermissions,
groupMembers,
groupPermissions,
+ integrationGroupPermissions,
integrationItems,
+ integrationUserPermissions,
items,
sections,
users,
@@ -216,6 +218,111 @@ export const boardRouter = createTRPCRouter({
});
});
}),
+ duplicateBoard: permissionRequiredProcedure
+ .requiresPermission("board-create")
+ .input(validation.board.duplicate)
+ .mutation(async ({ ctx, input }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
+ await noBoardWithSimilarNameAsync(ctx.db, input.name);
+
+ const board = await ctx.db.query.boards.findFirst({
+ where: eq(boards.id, input.id),
+ with: {
+ sections: {
+ with: {
+ items: {
+ with: {
+ integrations: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ if (!board) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Board not found",
+ });
+ }
+
+ const { sections: boardSections, ...boardProps } = board;
+
+ const newBoardId = createId();
+ const sectionMap = new Map(boardSections.map((section) => [section.id, createId()]));
+ const sectionsToInsert: InferInsertModel[] = boardSections.map(({ items: _, ...section }) => ({
+ ...section,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ id: sectionMap.get(section.id)!,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ parentSectionId: section.parentSectionId ? sectionMap.get(section.parentSectionId)! : null,
+ boardId: newBoardId,
+ }));
+ const flatItems = boardSections.flatMap((section) => section.items);
+ const itemMap = new Map(flatItems.map((item) => [item.id, createId()]));
+ const itemsToInsert: InferInsertModel[] = flatItems.map(({ integrations: _, ...item }) => ({
+ ...item,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ id: itemMap.get(item.id)!,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ sectionId: sectionMap.get(item.sectionId)!,
+ }));
+
+ // Creates a list with all integration ids the user has access to
+ const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all");
+ const integrationIdsWithAccess = hasAccessForAll
+ ? []
+ : await ctx.db
+ .selectDistinct({
+ id: integrationGroupPermissions.integrationId,
+ })
+ .from(integrationGroupPermissions)
+ .leftJoin(groupMembers, eq(integrationGroupPermissions.groupId, groupMembers.groupId))
+ .where(eq(groupMembers.userId, ctx.session.user.id))
+ .union(
+ ctx.db
+ .selectDistinct({ id: integrationUserPermissions.integrationId })
+ .from(integrationUserPermissions)
+ .where(eq(integrationUserPermissions.userId, ctx.session.user.id)),
+ )
+ .then((result) => result.map((row) => row.id));
+
+ const itemIntegrationsToInsert = flatItems.flatMap((item) =>
+ item.integrations
+ // Restrict integrations to only those the user has access to
+ .filter(({ integrationId }) => integrationIdsWithAccess.includes(integrationId) || hasAccessForAll)
+ .map((integration) => ({
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ itemId: itemMap.get(item.id)!,
+ integrationId: integration.integrationId,
+ })),
+ );
+
+ ctx.db.transaction((transaction) => {
+ transaction
+ .insert(boards)
+ .values({
+ ...boardProps,
+ id: newBoardId,
+ name: input.name,
+ creatorId: ctx.session.user.id,
+ })
+ .run();
+
+ if (sectionsToInsert.length > 0) {
+ transaction.insert(sections).values(sectionsToInsert).run();
+ }
+
+ if (itemsToInsert.length > 0) {
+ transaction.insert(items).values(itemsToInsert).run();
+ }
+
+ if (itemIntegrationsToInsert.length > 0) {
+ transaction.insert(integrationItems).values(itemIntegrationsToInsert).run();
+ }
+ });
+ }),
renameBoard: protectedProcedure.input(validation.board.rename).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
diff --git a/packages/modals-collection/src/boards/duplicate-board-modal.tsx b/packages/modals-collection/src/boards/duplicate-board-modal.tsx
new file mode 100644
index 000000000..083cb716b
--- /dev/null
+++ b/packages/modals-collection/src/boards/duplicate-board-modal.tsx
@@ -0,0 +1,96 @@
+import { Button, Group, Stack, Text, TextInput } from "@mantine/core";
+
+import { clientApi } from "@homarr/api/client";
+import type { MaybePromise } from "@homarr/common/types";
+import { useZodForm } from "@homarr/form";
+import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
+import { useI18n } from "@homarr/translation/client";
+import { validation } from "@homarr/validation";
+
+import { createModal } from "../../../modals/src/creator";
+import { useBoardNameStatus } from "./add-board-modal";
+
+interface InnerProps {
+ board: {
+ id: string;
+ name: string;
+ };
+ onSuccess: () => MaybePromise;
+}
+
+export const DuplicateBoardModal = createModal(({ actions, innerProps }) => {
+ const t = useI18n();
+ const form = useZodForm(validation.board.duplicate.omit({ id: true }), {
+ mode: "controlled",
+ initialValues: {
+ name: innerProps.board.name,
+ },
+ });
+ const boardNameStatus = useBoardNameStatus(form.values.name);
+ const { mutateAsync, isPending } = clientApi.board.duplicateBoard.useMutation();
+
+ return (
+
+ );
+}).withOptions({
+ defaultTitle(t) {
+ return t("board.action.duplicate.title");
+ },
+});
diff --git a/packages/modals-collection/src/boards/index.ts b/packages/modals-collection/src/boards/index.ts
index 3da35c853..8594bb424 100644
--- a/packages/modals-collection/src/boards/index.ts
+++ b/packages/modals-collection/src/boards/index.ts
@@ -1,2 +1,3 @@
export { AddBoardModal } from "./add-board-modal";
export { ImportBoardModal } from "./import-board-modal";
+export { DuplicateBoardModal } from "./duplicate-board-modal";
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index 9a87ad643..5e0754650 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -1760,6 +1760,20 @@
},
"board": {
"action": {
+ "duplicate": {
+ "title": "Duplicate board",
+ "message": "This will duplicate the board {name} with all its content. If widgets reference integrations, that you are not allowed to use, they will be removed.",
+ "notification": {
+ "success": {
+ "title": "Board duplicated",
+ "message": "The board was successfully duplicated"
+ },
+ "error": {
+ "title": "Unable to duplicate board",
+ "message": "The board could not be duplicated"
+ }
+ }
+ },
"edit": {
"notification": {
"success": {
@@ -2125,6 +2139,9 @@
"tooltip": "This board will show as your home board"
}
},
+ "duplicate": {
+ "label": "Duplicate board"
+ },
"delete": {
"label": "Delete permanently",
"confirm": {
diff --git a/packages/validation/src/board.ts b/packages/validation/src/board.ts
index 2783f7a88..1fd121a95 100644
--- a/packages/validation/src/board.ts
+++ b/packages/validation/src/board.ts
@@ -28,6 +28,11 @@ const renameSchema = z.object({
name: boardNameSchema,
});
+const duplicateSchema = z.object({
+ id: z.string(),
+ name: boardNameSchema,
+});
+
const changeVisibilitySchema = z.object({
id: z.string(),
visibility: z.enum(["public", "private"]),
@@ -85,6 +90,7 @@ export const boardSchemas = {
savePartialSettings: savePartialSettingsSchema,
save: saveSchema,
create: createSchema,
+ duplicate: duplicateSchema,
rename: renameSchema,
changeVisibility: changeVisibilitySchema,
permissions: permissionsSchema,