From cf0e4694652f50c029c514334f3f4d187c6f94a6 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 21 Jul 2026 16:56:33 +0200 Subject: [PATCH] feat: restrict apps management to board modifiers (#6425) --- .../[locale]/manage/apps/edit/[id]/page.tsx | 5 +++- .../src/app/[locale]/manage/apps/new/page.tsx | 2 +- .../src/app/[locale]/manage/apps/page.tsx | 5 +++- .../nextjs/src/app/[locale]/manage/layout.tsx | 2 +- packages/api/src/router/app.ts | 3 ++- packages/api/src/router/home.ts | 2 +- packages/api/src/router/test/app.spec.ts | 27 +++++++++++++++++++ .../src/modes/app-integration-board/index.tsx | 6 ++++- .../src/modes/command/global-group.tsx | 3 ++- .../src/modes/page/pages-search-group.tsx | 2 +- 10 files changed, 48 insertions(+), 9 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/manage/apps/edit/[id]/page.tsx b/apps/nextjs/src/app/[locale]/manage/apps/edit/[id]/page.tsx index a931b2ece..e717e5787 100644 --- a/apps/nextjs/src/app/[locale]/manage/apps/edit/[id]/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/apps/edit/[id]/page.tsx @@ -16,7 +16,10 @@ export default async function AppEditPage(props: AppEditPageProps) { const params = await props.params; const session = await auth(); - if (!session?.user.permissions.includes("app-modify-all")) { + if ( + !session?.user.permissions.includes("board-modify-all") || + !session?.user.permissions.includes("app-modify-all") + ) { notFound(); } const app = await api.app.byId({ id: params.id }); diff --git a/apps/nextjs/src/app/[locale]/manage/apps/new/page.tsx b/apps/nextjs/src/app/[locale]/manage/apps/new/page.tsx index ace72f5fa..989273d7c 100644 --- a/apps/nextjs/src/app/[locale]/manage/apps/new/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/apps/new/page.tsx @@ -10,7 +10,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; export default async function AppNewPage() { const session = await auth(); - if (!session?.user.permissions.includes("app-create")) { + if (!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create")) { notFound(); } diff --git a/apps/nextjs/src/app/[locale]/manage/apps/page.tsx b/apps/nextjs/src/app/[locale]/manage/apps/page.tsx index e01a8633e..083951b9c 100644 --- a/apps/nextjs/src/app/[locale]/manage/apps/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/apps/page.tsx @@ -1,5 +1,5 @@ import { Fragment } from "react"; -import { redirect } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core"; import { IconBox, IconPencil } from "@tabler/icons-react"; import { z } from "zod/v4"; @@ -33,6 +33,9 @@ export default async function AppsPage(props: AppsPageProps) { if (!session) { redirect("/auth/login"); } + if (!session.user.permissions.includes("board-modify-all")) { + notFound(); + } const searchParams = searchParamsSchema.parse(await props.searchParams); diff --git a/apps/nextjs/src/app/[locale]/manage/layout.tsx b/apps/nextjs/src/app/[locale]/manage/layout.tsx index 040db0d78..2634944f1 100644 --- a/apps/nextjs/src/app/[locale]/manage/layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/layout.tsx @@ -61,7 +61,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) { icon: IconBox, href: "/manage/apps", label: t("items.apps"), - hidden: !session, + hidden: !session?.user.permissions.includes("board-modify-all"), iconProps: { strokeWidth: 2.5, }, diff --git a/packages/api/src/router/app.ts b/packages/api/src/router/app.ts index 1b55d0260..3d0285689 100644 --- a/packages/api/src/router/app.ts +++ b/packages/api/src/router/app.ts @@ -18,7 +18,8 @@ import { AppAccessControl } from "./app/app-access-control"; const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg"; export const appRouter = createTRPCRouter({ - getPaginated: protectedProcedure + getPaginated: permissionRequiredProcedure + .requiresPermission("board-modify-all") .input(paginatedSchema) .output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() })) .meta({ diff --git a/packages/api/src/router/home.ts b/packages/api/src/router/home.ts index fac941fa8..4b63f9326 100644 --- a/packages/api/src/router/home.ts +++ b/packages/api/src/router/home.ts @@ -101,7 +101,7 @@ export const homeRouter = createTRPCRouter({ }); } - if (ctx.session?.user) { + if (ctx.session?.user.permissions.includes("board-modify-all")) { statistics.push({ titleKey: "app", subtitleKey: "resources", diff --git a/packages/api/src/router/test/app.spec.ts b/packages/api/src/router/test/app.spec.ts index 3002ef1a9..44d2f6ce5 100644 --- a/packages/api/src/router/test/app.spec.ts +++ b/packages/api/src/router/test/app.spec.ts @@ -76,6 +76,33 @@ describe("all should return all apps", () => { }); }); +describe("getPaginated should require board modification access", () => { + test("should throw FORBIDDEN without board-modify-all", async () => { + const caller = appRouter.createCaller({ + db: createDb(), + deviceType: undefined, + session: createDefaultSession(), + }); + + await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("Permission denied"); + }); + + test("should return apps with board-modify-all", async () => { + const db = createDb(); + const caller = appRouter.createCaller({ + db, + deviceType: undefined, + session: createDefaultSession(["board-modify-all"]), + }); + await db.insert(apps).values({ id: "1", name: "Homarr", iconUrl: "https://homarr.dev/icon.svg" }); + + await expect(caller.getPaginated({ page: 1, pageSize: 10 })).resolves.toMatchObject({ + totalCount: 1, + items: [{ id: "1" }], + }); + }); +}); + describe("byId should return an app by id", () => { test("should return an app by id when canUserSeeAppAsync returns true", async () => { // Arrange diff --git a/packages/spotlight/src/modes/app-integration-board/index.tsx b/packages/spotlight/src/modes/app-integration-board/index.tsx index 4fe5de9fd..5578bb648 100644 --- a/packages/spotlight/src/modes/app-integration-board/index.tsx +++ b/packages/spotlight/src/modes/app-integration-board/index.tsx @@ -17,6 +17,10 @@ export const appIntegrationBoardMode = { return groups; } - return groups.concat([appsSearchGroup, integrationsSearchGroup]); + return groups.concat( + session.user.permissions.includes("board-modify-all") + ? [appsSearchGroup, integrationsSearchGroup] + : [integrationsSearchGroup], + ); }, } satisfies SearchMode; diff --git a/packages/spotlight/src/modes/command/global-group.tsx b/packages/spotlight/src/modes/command/global-group.tsx index f1d44e434..fd277b55e 100644 --- a/packages/spotlight/src/modes/command/global-group.tsx +++ b/packages/spotlight/src/modes/command/global-group.tsx @@ -87,7 +87,8 @@ export const globalCommandGroup = createGroup({ icon: IconBox, name: tOption("newApp.label"), useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })), - hidden: !session?.user.permissions.includes("app-create"), + hidden: + !session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create"), }, { commandKey: "newIntegration", diff --git a/packages/spotlight/src/modes/page/pages-search-group.tsx b/packages/spotlight/src/modes/page/pages-search-group.tsx index d9b419340..66fe1ce4f 100644 --- a/packages/spotlight/src/modes/page/pages-search-group.tsx +++ b/packages/spotlight/src/modes/page/pages-search-group.tsx @@ -76,7 +76,7 @@ export const pagesSearchGroup = createGroup<{ icon: IconBox, path: "/manage/apps", name: t("manageApp.label"), - hidden: !session, + hidden: !session?.user.permissions.includes("board-modify-all"), }, { icon: IconPlug,