feat(widget): add restriction callback to restrict visibility and modification of widget kinds (#2658)

* feat(widget): add restriction callback to restrict visibility and modification of widget kinds

* fix: typecheck issue

* chore: address pull request feedback
This commit is contained in:
Meier Lukas
2025-03-28 10:16:46 +01:00
committed by GitHub
parent 78b55202e7
commit 84f73d33a0
16 changed files with 292 additions and 253 deletions

View File

@@ -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 <RestrictedWidgetContent kind={item.kind} />;
}
return (
<QueryErrorResetBoundary>
{({ reset }) => (

View File

@@ -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 (
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
<Menu.Target>

View File

@@ -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<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,
@@ -26,7 +36,7 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
description: t(`widget.${kind}.description`),
}))
.sort((itemA, itemB) => itemA.name.localeCompare(itemB.name)),
[t],
[t, session?.user],
);
const filteredItems = useMemo(

View File

@@ -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 (
<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

@@ -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<string, string>(boardItems.map((item) => [item.id, createId()]));
const itemsToInsert: InferInsertModel<typeof items>[] = boardItems.map(
const itemMap = new Map<string, string>(allowedBoardItems.map((item) => [item.id, createId()]));
const itemsToInsert: InferInsertModel<typeof items>[] = 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<typeof itemLayouts>[] = boardItems.flatMap((item) =>
const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = allowedBoardItems.flatMap((item) =>
item.layouts.map(
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
...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<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) => {
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 (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<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 (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<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 (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<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 (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);
}),
});

View File

@@ -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);
}),
});

View File

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

View File

@@ -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 = <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

@@ -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",

View File

@@ -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<ReturnType<typeof prepareMultipleImports>, "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}`);
});

View File

@@ -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<typeof importInitialOldmarrInputSchema>,
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);

View File

@@ -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) {

View File

@@ -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);
};

View File

@@ -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",

View File

@@ -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<TKind extends WidgetKind> {
options: inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>;
integrationIds: string[];

3
pnpm-lock.yaml generated
View File

@@ -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