diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index 709d52182..7ee901ac8 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -5,6 +5,8 @@ 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"; @@ -15,6 +17,7 @@ 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; @@ -59,6 +62,7 @@ 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]; @@ -70,6 +74,16 @@ 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 ; + } + return ( {({ reset }) => ( diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index e7072fea4..771279679 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -3,6 +3,8 @@ 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"; @@ -37,6 +39,7 @@ 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(() => { @@ -91,6 +94,16 @@ export const BoardItemMenu = ({ }); }; + if ( + isWidgetRestricted({ + definition: currentDefinition, + user: session?.user ?? null, + check: (level) => level !== "none", + }) + ) { + return null; + } + return ( diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx index a2fce1264..c93a59497 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -2,6 +2,8 @@ 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"; @@ -15,10 +17,18 @@ export const ItemSelectModal = createModal(({ 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, @@ -26,7 +36,7 @@ export const ItemSelectModal = createModal(({ actions }) => { description: t(`widget.${kind}.description`), })) .sort((itemA, itemB) => itemA.name.localeCompare(itemB.name)), - [t], + [t, session?.user], ); const filteredItems = useMemo( diff --git a/apps/nextjs/src/components/board/items/restricted.tsx b/apps/nextjs/src/components/board/items/restricted.tsx new file mode 100644 index 000000000..494e90015 --- /dev/null +++ b/apps/nextjs/src/components/board/items/restricted.tsx @@ -0,0 +1,28 @@ +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 ( +
+ + + + + {tCommonWidget("restricted.title")} + + + {tCommonWidget("restricted.description", { name: tCurrentWidget("name") })} + +
+ ); +}; diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 80e58393d..4d5233b17 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 { z } from "zod"; -import { constructBoardPermissions } from "@homarr/auth/shared"; +import { constructBoardPermissions, isWidgetRestricted } 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,6 +40,7 @@ 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"; @@ -323,6 +324,13 @@ 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(); @@ -370,8 +378,8 @@ export const boardRouter = createTRPCRouter({ ), ); - const itemMap = new Map(boardItems.map((item) => [item.id, createId()])); - const itemsToInsert: InferInsertModel[] = boardItems.map( + const itemMap = new Map(allowedBoardItems.map((item) => [item.id, createId()])); + const itemsToInsert: InferInsertModel[] = allowedBoardItems.map( ({ integrations: _, layouts: _layouts, ...item }) => ({ ...item, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -380,7 +388,7 @@ export const boardRouter = createTRPCRouter({ }), ); - const itemLayoutsToInsert: InferInsertModel[] = boardItems.flatMap((item) => + const itemLayoutsToInsert: InferInsertModel[] = allowedBoardItems.flatMap((item) => item.layouts.map( (layoutSection): InferInsertModel => ({ ...layoutSection, @@ -413,7 +421,7 @@ export const boardRouter = createTRPCRouter({ ) .then((result) => result.map((row) => row.id)); - const itemIntegrationsToInsert = boardItems.flatMap((item) => + const itemIntegrationsToInsert = allowedBoardItems.flatMap((item) => item.integrations // Restrict integrations to only those the user has access to .filter(({ integrationId }) => integrationIdsWithAccess.includes(integrationId) || hasAccessForAll) @@ -743,105 +751,140 @@ 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 => ({ + 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 => ({ + 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 => ({ + 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 => ({ + 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) => { - 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 => ({ - layoutId: sectionLayout.layoutId, - sectionId: section.id, - parentSectionId: sectionLayout.parentSectionId, - height: sectionLayout.height, - width: sectionLayout.width, - xOffset: sectionLayout.xOffset, - yOffset: sectionLayout.yOffset, - }), - ), - ), - ); - } + if (sectionsToInsert.length > 0) { + await transaction.insert(schema.sections).values(sectionsToInsert); } - 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 => ({ - layoutId: layoutSection.layoutId, - sectionId: layoutSection.sectionId, - itemId: item.id, - height: layoutSection.height, - width: layoutSection.width, - xOffset: layoutSection.xOffset, - yOffset: layoutSection.yOffset, - }), - ), - ), - ); + if (sectionLayoutsToInsert.length > 0) { + await transaction.insert(schema.sectionLayouts).values(sectionLayoutsToInsert); } - 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 (itemsToInsert.length > 0) { + await transaction.insert(schema.items).values(itemsToInsert); + } + if (itemLayoutsToInsert.length > 0) { + await transaction.insert(schema.itemLayouts).values(itemLayoutsToInsert); } - const updatedItems = filterUpdatedItems(input.items, dbBoard.items); + if (integrationItemsToInsert.length > 0) { + await transaction.insert(schema.integrationItems).values(integrationItemsToInsert); + } for (const item of updatedItems) { await transaction @@ -872,8 +915,6 @@ 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 @@ -907,15 +948,6 @@ 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) @@ -927,134 +959,36 @@ export const boardRouter = createTRPCRouter({ ); } - 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 (itemIdsToRemove.length > 0) { + await transaction.delete(schema.items).where(inArray(schema.items.id, itemIdsToRemove)); } - 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)); + if (sectionIdsToRemove.length > 0) { + await transaction.delete(schema.sections).where(inArray(schema.sections.id, sectionIdsToRemove)); } }); }, handleSync(db) { db.transaction((transaction) => { - 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 => ({ - layoutId: sectionLayout.layoutId, - sectionId: section.id, - parentSectionId: sectionLayout.parentSectionId, - height: sectionLayout.height, - width: sectionLayout.width, - xOffset: sectionLayout.xOffset, - yOffset: sectionLayout.yOffset, - }), - ), - ), - ) - .run(); - } + if (sectionsToInsert.length > 0) { + transaction.insert(sections).values(sectionsToInsert).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 => ({ - layoutId: layoutSection.layoutId, - sectionId: layoutSection.sectionId, - itemId: item.id, - height: layoutSection.height, - width: layoutSection.width, - xOffset: layoutSection.xOffset, - yOffset: layoutSection.yOffset, - }), - ), - ), - ) - .run(); + if (sectionLayoutsToInsert.length > 0) { + transaction.insert(sectionLayouts).values(sectionLayoutsToInsert).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 (itemsToInsert.length > 0) { + transaction.insert(items).values(itemsToInsert).run(); } - const updatedItems = filterUpdatedItems(input.items, dbBoard.items); + if (itemLayoutsToInsert.length > 0) { + transaction.insert(itemLayouts).values(itemLayoutsToInsert).run(); + } + + if (integrationItemsToInsert.length > 0) { + transaction.insert(integrationItems).values(integrationItemsToInsert).run(); + } for (const item of updatedItems) { transaction @@ -1082,8 +1016,6 @@ 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 @@ -1116,15 +1048,6 @@ 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) @@ -1137,18 +1060,12 @@ export const boardRouter = createTRPCRouter({ .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 (itemIdsToRemove.length > 0) { + transaction.delete(items).where(inArray(items.id, itemIdsToRemove)).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(); + if (sectionIdsToRemove.length > 0) { + transaction.delete(sections).where(inArray(sections.id, sectionIdsToRemove)).run(); } }); }, @@ -1318,7 +1235,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); + await importOldmarrAsync(ctx.db, oldmarr, input.configuration, ctx.session); }), }); diff --git a/packages/api/src/router/import/import-router.ts b/packages/api/src/router/import/import-router.ts index 4ffc5ca1a..f46cd4009 100644 --- a/packages/api/src/router/import/import-router.ts +++ b/packages/api/src/router/import/import-router.ts @@ -37,7 +37,7 @@ export const importRouter = createTRPCRouter({ .requiresStep("import") .input(importInitialOldmarrInputSchema) .mutation(async ({ ctx, input }) => { - await importInitialOldmarrAsync(ctx.db, input); + await importInitialOldmarrAsync(ctx.db, input, ctx.session); await nextOnboardingStepAsync(ctx.db, undefined); }), }); diff --git a/packages/auth/permissions/index.ts b/packages/auth/permissions/index.ts index dd5b9e462..83b13629a 100644 --- a/packages/auth/permissions/index.ts +++ b/packages/auth/permissions/index.ts @@ -1,2 +1,3 @@ export * from "./board-permissions"; export * from "./integration-permissions"; +export * from "./widget-restriction"; diff --git a/packages/auth/permissions/widget-restriction.ts b/packages/auth/permissions/widget-restriction.ts new file mode 100644 index 000000000..1959ffd0e --- /dev/null +++ b/packages/auth/permissions/widget-restriction.ts @@ -0,0 +1,14 @@ +import type { Session } from "next-auth"; + +import type { WidgetDefinition } from "../../widgets/src"; +import type { RestrictionLevel } from "../../widgets/src/definition"; + +export const isWidgetRestricted = (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 })); +}; diff --git a/packages/old-import/package.json b/packages/old-import/package.json index e2f0e8b01..436f3ddfe 100644 --- a/packages/old-import/package.json +++ b/packages/old-import/package.json @@ -26,6 +26,7 @@ }, "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", diff --git a/packages/old-import/src/import/collections/board-collection.ts b/packages/old-import/src/import/collections/board-collection.ts index 18475ea53..a3259ce08 100644 --- a/packages/old-import/src/import/collections/board-collection.ts +++ b/packages/old-import/src/import/collections/board-collection.ts @@ -1,9 +1,12 @@ +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 } from "@homarr/old-schema"; import { boardSizes, getBoardSizeName } from "@homarr/old-schema"; +import { widgetImports } from "../../../../widgets/src"; import { fixSectionIssues } from "../../fix-section-issues"; import { mapBoard } from "../../mappers/map-board"; import { mapBreakpoint } from "../../mappers/map-breakpoint"; @@ -17,6 +20,7 @@ import type { InitialOldmarrImportSettings } from "../../settings"; export const createBoardInsertCollection = ( { preparedApps, preparedBoards }: Omit, "preparedIntegrations">, settings: InitialOldmarrImportSettings, + session: Session | null, ) => { const insertCollection = createDbInsertCollectionForTransaction([ "apps", @@ -105,10 +109,18 @@ export const createBoardInsertCollection = ( layoutMapping, mappedBoard.id, ); - preparedItems.forEach(({ layouts, ...item }) => { - insertCollection.items.push(item); - insertCollection.itemLayouts.push(...layouts); - }); + 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); + }); logger.debug(`Added items to board insert collection count=${insertCollection.items.length}`); }); diff --git a/packages/old-import/src/import/import-initial-oldmarr.ts b/packages/old-import/src/import/import-initial-oldmarr.ts index fdf1a79da..de35a993b 100644 --- a/packages/old-import/src/import/import-initial-oldmarr.ts +++ b/packages/old-import/src/import/import-initial-oldmarr.ts @@ -1,5 +1,6 @@ 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"; @@ -16,6 +17,7 @@ import { ensureValidTokenOrThrow } from "./validate-token"; export const importInitialOldmarrAsync = async ( db: Database, input: z.infer, + session: Session | null, ) => { const stopwatch = new Stopwatch(); const { checksum, configs, users: importUsers } = await analyseOldmarrImportAsync(input.file); @@ -29,7 +31,7 @@ export const importInitialOldmarrAsync = async ( logger.info("Preparing import data in insert collections for database"); - const boardInsertCollection = createBoardInsertCollection({ preparedApps, preparedBoards }, input.settings); + const boardInsertCollection = createBoardInsertCollection({ preparedApps, preparedBoards }, input.settings, session); const userInsertCollection = createUserInsertCollection(importUsers, input.token); const integrationInsertCollection = createIntegrationInsertCollection(preparedIntegrations, input.token); diff --git a/packages/old-import/src/import/import-single-oldmarr.ts b/packages/old-import/src/import/import-single-oldmarr.ts index 2ca6ed4c1..2ca42870f 100644 --- a/packages/old-import/src/import/import-single-oldmarr.ts +++ b/packages/old-import/src/import/import-single-oldmarr.ts @@ -1,3 +1,4 @@ +import type { Session } from "@homarr/auth"; import { handleTransactionsAsync, inArray } from "@homarr/db"; import type { Database } from "@homarr/db"; import { apps } from "@homarr/db/schema"; @@ -12,6 +13,7 @@ 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({ @@ -29,7 +31,7 @@ export const importSingleOldmarrConfigAsync = async ( return app; }); - const boardInsertCollection = createBoardInsertCollection({ preparedApps, preparedBoards }, settings); + const boardInsertCollection = createBoardInsertCollection({ preparedApps, preparedBoards }, settings, session); await handleTransactionsAsync(db, { async handleAsync(db) { diff --git a/packages/old-import/src/index.ts b/packages/old-import/src/index.ts index d3153a189..c36d9e70b 100644 --- a/packages/old-import/src/index.ts +++ b/packages/old-import/src/index.ts @@ -1,3 +1,4 @@ +import type { Session } from "@homarr/auth"; import type { Database } from "@homarr/db"; import type { OldmarrConfig } from "@homarr/old-schema"; @@ -8,6 +9,7 @@ export const importOldmarrAsync = async ( db: Database, old: OldmarrConfig, configuration: OldmarrImportConfiguration, + session: Session | null, ) => { - await importSingleOldmarrConfigAsync(db, old, configuration); + await importSingleOldmarrConfigAsync(db, old, configuration, session); }; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 966700f5e..b7bb3a9f9 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1724,7 +1724,11 @@ "noIntegration": "No integration selected", "noData": "No integration data available" }, - "option": {} + "option": {}, + "restricted": { + "title": "Restricted", + "description": "You don't have access to the {name} widget." + } }, "video": { "name": "Video Stream", diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts index 85e2de5ad..453ca8c59 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -1,6 +1,7 @@ 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"; @@ -43,8 +44,23 @@ 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 { options: inferOptionsFromCreator>; integrationIds: string[]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0515557ca..cb9280087 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1508,6 +1508,9 @@ importers: packages/old-import: dependencies: + '@homarr/auth': + specifier: workspace:^0.1.0 + version: link:../auth '@homarr/common': specifier: workspace:^0.1.0 version: link:../common