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 9b3551e43..f5fbc5d6f 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 @@ -1,6 +1,8 @@ +import { notFound } from "next/navigation"; import { Container, Stack, Title } from "@mantine/core"; import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; import { getI18n } from "@homarr/translation/server"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; @@ -11,6 +13,11 @@ interface AppEditPageProps { } export default async function AppEditPage({ params }: AppEditPageProps) { + const session = await auth(); + + if (!session?.user.permissions.includes("app-modify-all")) { + notFound(); + } const app = await api.app.byId({ id: params.id }); const t = await getI18n(); 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 9fe1a2de6..6b0cd029d 100644 --- a/apps/nextjs/src/app/[locale]/manage/apps/new/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/apps/new/page.tsx @@ -1,11 +1,19 @@ +import { notFound } from "next/navigation"; import { Container, Stack, Title } from "@mantine/core"; +import { auth } from "@homarr/auth/next"; import { getI18n } from "@homarr/translation/server"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { AppNewForm } from "./_app-new-form"; export default async function AppNewPage() { + const session = await auth(); + + if (!session?.user.permissions.includes("app-create")) { + notFound(); + } + const t = await getI18n(); return ( diff --git a/apps/nextjs/src/app/[locale]/manage/apps/page.tsx b/apps/nextjs/src/app/[locale]/manage/apps/page.tsx index 11aa2e8eb..2029fbafa 100644 --- a/apps/nextjs/src/app/[locale]/manage/apps/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/apps/page.tsx @@ -1,9 +1,11 @@ import Link from "next/link"; +import { redirect } from "next/navigation"; import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core"; import { IconApps, IconPencil } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; import { parseAppHrefWithVariablesServer } from "@homarr/common/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server"; @@ -13,6 +15,12 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { AppDeleteButton } from "./_app-delete-button"; export default async function AppsPage() { + const session = await auth(); + + if (!session) { + redirect("/auth/login"); + } + const apps = await api.app.all(); const t = await getScopedI18n("app"); @@ -22,9 +30,11 @@ export default async function AppsPage() { {t("page.list.title")} - - {t("page.create.title")} - + {session.user.permissions.includes("app-create") && ( + + {t("page.create.title")} + + )} {apps.length === 0 && } {apps.length > 0 && ( @@ -45,6 +55,7 @@ interface AppCardProps { const AppCard = async ({ app }: AppCardProps) => { const t = await getScopedI18n("app"); + const session = await auth(); return ( @@ -78,16 +89,18 @@ const AppCard = async ({ app }: AppCardProps) => { - - - - + {session?.user.permissions.includes("app-modify-all") && ( + + + + )} + {session?.user.permissions.includes("app-full-all") && } @@ -97,6 +110,7 @@ const AppCard = async ({ app }: AppCardProps) => { const AppNoResults = async () => { const t = await getI18n(); + const session = await auth(); return ( @@ -105,7 +119,9 @@ const AppNoResults = async () => { {t("app.page.list.noResults.title")} - {t("app.page.list.noResults.action")} + {session?.user.permissions.includes("app-create") && ( + {t("app.page.list.noResults.action")} + )} ); diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx index 4783014e2..ede3d7a71 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx @@ -6,7 +6,7 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { IntegrationAvatar } from "@homarr/ui"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; -import { catchTrpcNotFound } from "~/errors/trpc-not-found"; +import { catchTrpcNotFound } from "~/errors/trpc-catch-error"; import { IntegrationAccessSettings } from "../../_components/integration-access-settings"; import { EditIntegrationForm } from "./_integration-edit-form"; diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx index fafc49fc8..a600effed 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx @@ -1,6 +1,7 @@ import { Fragment } from "react"; import type { PropsWithChildren } from "react"; import Link from "next/link"; +import { redirect } from "next/navigation"; import { AccordionControl, AccordionItem, @@ -50,11 +51,16 @@ interface IntegrationsPageProps { } export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) { - const integrations = await api.integration.all(); const session = await auth(); + + if (!session) { + redirect("/auth/login"); + } + + const integrations = await api.integration.all(); const t = await getScopedI18n("integration"); - const canCreateIntegrations = session?.user.permissions.includes("integration-create") ?? false; + const canCreateIntegrations = session.user.permissions.includes("integration-create"); return ( diff --git a/apps/nextjs/src/app/[locale]/manage/layout.tsx b/apps/nextjs/src/app/[locale]/manage/layout.tsx index f9a397e38..0613e46d6 100644 --- a/apps/nextjs/src/app/[locale]/manage/layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/layout.tsx @@ -52,16 +52,19 @@ export default async function ManageLayout({ children }: PropsWithChildren) { icon: IconBox, href: "/manage/apps", label: t("items.apps"), + hidden: !session, }, { icon: IconPlug, href: "/manage/integrations", label: t("items.integrations"), + hidden: !session, }, { icon: IconSearch, href: "/manage/search-engines", label: t("items.searchEngies"), + hidden: !session, }, { icon: IconPhoto, @@ -95,27 +98,32 @@ export default async function ManageLayout({ children }: PropsWithChildren) { { label: t("items.tools.label"), icon: IconTool, - hidden: !session?.user.permissions.includes("admin"), + // As permissions always include there children permissions, we can check other-view-logs as admin includes it + hidden: !session?.user.permissions.includes("other-view-logs"), items: [ { label: t("items.tools.items.docker"), icon: IconBrandDocker, href: "/manage/tools/docker", + hidden: !session?.user.permissions.includes("admin"), }, { label: t("items.tools.items.api"), icon: IconPlug, href: "/manage/tools/api", + hidden: !session?.user.permissions.includes("admin"), }, { label: t("items.tools.items.logs"), icon: IconLogs, href: "/manage/tools/logs", + hidden: !session?.user.permissions.includes("other-view-logs"), }, { label: t("items.tools.items.tasks"), icon: IconReport, href: "/manage/tools/tasks", + hidden: !session?.user.permissions.includes("admin"), }, ], }, diff --git a/apps/nextjs/src/app/[locale]/manage/medias/page.tsx b/apps/nextjs/src/app/[locale]/manage/medias/page.tsx index c46701935..5052194c9 100644 --- a/apps/nextjs/src/app/[locale]/manage/medias/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/medias/page.tsx @@ -47,7 +47,6 @@ export default async function GroupsListPage(props: MediaListPageProps) { const t = await getI18n(); const searchParams = searchParamsSchema.parse(props.searchParams); const { items: medias, totalCount } = await api.media.getPaginated(searchParams); - const isAdmin = session.user.permissions.includes("admin"); return ( @@ -57,10 +56,12 @@ export default async function GroupsListPage(props: MediaListPageProps) { - {isAdmin && } + {session.user.permissions.includes("media-view-all") && ( + + )} - + {session.user.permissions.includes("media-upload") && } @@ -91,7 +92,10 @@ interface RowProps { media: RouterOutputs["media"]["getPaginated"]["items"][number]; } -const Row = ({ media }: RowProps) => { +const Row = async ({ media }: RowProps) => { + const session = await auth(); + const canDelete = media.creatorId === session?.user.id || session?.user.permissions.includes("media-full-all"); + return ( @@ -120,7 +124,7 @@ const Row = ({ media }: RowProps) => { - + {canDelete && } diff --git a/apps/nextjs/src/app/[locale]/manage/page.tsx b/apps/nextjs/src/app/[locale]/manage/page.tsx index 31137d121..6d2b03880 100644 --- a/apps/nextjs/src/app/[locale]/manage/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/page.tsx @@ -64,6 +64,7 @@ export default async function ManagementPage() { href: "/manage/apps", subtitle: t("statisticLabel.resources"), title: t("statistic.app"), + hidden: !session?.user, }, { count: statistics.countGroups, diff --git a/apps/nextjs/src/app/[locale]/manage/search-engines/edit/[id]/page.tsx b/apps/nextjs/src/app/[locale]/manage/search-engines/edit/[id]/page.tsx index a130fb4fa..39dfd8f63 100644 --- a/apps/nextjs/src/app/[locale]/manage/search-engines/edit/[id]/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/search-engines/edit/[id]/page.tsx @@ -1,6 +1,8 @@ +import { notFound } from "next/navigation"; import { Stack, Title } from "@mantine/core"; import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; import { getI18n } from "@homarr/translation/server"; import { ManageContainer } from "~/components/manage/manage-container"; @@ -12,6 +14,12 @@ interface SearchEngineEditPageProps { } export default async function SearchEngineEditPage({ params }: SearchEngineEditPageProps) { + const session = await auth(); + + if (!session?.user.permissions.includes("search-engine-modify-all")) { + notFound(); + } + const searchEngine = await api.searchEngine.byId({ id: params.id }); const t = await getI18n(); diff --git a/apps/nextjs/src/app/[locale]/manage/search-engines/new/page.tsx b/apps/nextjs/src/app/[locale]/manage/search-engines/new/page.tsx index fb1c51340..488f1c3af 100644 --- a/apps/nextjs/src/app/[locale]/manage/search-engines/new/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/search-engines/new/page.tsx @@ -1,5 +1,7 @@ +import { notFound } from "next/navigation"; import { Stack, Title } from "@mantine/core"; +import { auth } from "@homarr/auth/next"; import { getI18n } from "@homarr/translation/server"; import { ManageContainer } from "~/components/manage/manage-container"; @@ -7,6 +9,12 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { SearchEngineNewForm } from "./_search-engine-new-form"; export default async function SearchEngineNewPage() { + const session = await auth(); + + if (!session?.user.permissions.includes("search-engine-create")) { + notFound(); + } + const t = await getI18n(); return ( diff --git a/apps/nextjs/src/app/[locale]/manage/search-engines/page.tsx b/apps/nextjs/src/app/[locale]/manage/search-engines/page.tsx index 423c7fe9c..8f66abbf1 100644 --- a/apps/nextjs/src/app/[locale]/manage/search-engines/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/search-engines/page.tsx @@ -1,9 +1,11 @@ import Link from "next/link"; +import { redirect } from "next/navigation"; import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core"; import { IconPencil, IconSearch } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { SearchInput, TablePagination } from "@homarr/ui"; import { z } from "@homarr/validation"; @@ -28,6 +30,12 @@ interface SearchEnginesPageProps { } export default async function SearchEnginesPage(props: SearchEnginesPageProps) { + const session = await auth(); + + if (!session) { + redirect("/auth/login"); + } + const searchParams = searchParamsSchema.parse(props.searchParams); const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams); @@ -40,9 +48,11 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) { {tEngine("page.list.title")} - - {tEngine("page.create.title")} - + {session.user.permissions.includes("search-engine-create") && ( + + {tEngine("page.create.title")} + + )} {searchEngines.length === 0 && } {searchEngines.length > 0 && ( @@ -67,6 +77,7 @@ interface SearchEngineCardProps { const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => { const t = await getScopedI18n("search.engine"); + const session = await auth(); return ( @@ -105,16 +116,20 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => { - - - - + {session?.user.permissions.includes("search-engine-modify-all") && ( + + + + )} + {session?.user.permissions.includes("search-engine-full-all") && ( + + )} @@ -124,6 +139,7 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => { const SearchEngineNoResults = async () => { const t = await getI18n(); + const session = await auth(); return ( @@ -132,7 +148,9 @@ const SearchEngineNoResults = async () => { {t("search.engine.page.list.noResults.title")} - {t("search.engine.page.list.noResults.action")} + {session?.user.permissions.includes("search-engine-create") && ( + {t("search.engine.page.list.noResults.action")} + )} ); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx index 131ce7859..946238d3e 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx @@ -27,7 +27,7 @@ export async function generateMetadata() { export default async function LogsManagementPage() { const session = await auth(); - if (!session?.user || !session.user.permissions.includes("admin")) { + if (!session?.user || !session.user.permissions.includes("other-view-logs")) { notFound(); } diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx index ede4d8df9..e41d38536 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx @@ -8,7 +8,7 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox"; import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone"; -import { catchTrpcNotFound } from "~/errors/trpc-not-found"; +import { catchTrpcNotFound } from "~/errors/trpc-catch-error"; import { createMetaTitle } from "~/metadata"; import { canAccessUserEditPage } from "../access"; import { ChangeHomeBoardForm } from "./_components/_change-home-board"; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/layout.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/layout.tsx index 06bbbf5ee..c7158379d 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/layout.tsx @@ -10,7 +10,7 @@ import { UserAvatar } from "@homarr/ui"; import { ManageContainer } from "~/components/manage/manage-container"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; -import { catchTrpcNotFound } from "~/errors/trpc-not-found"; +import { catchTrpcNotFound } from "~/errors/trpc-catch-error"; import { NavigationLink } from "../groups/[id]/_navigation"; import { canAccessUserEditPage } from "./access"; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/security/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/security/page.tsx index 99061405d..9897268b2 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/security/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/security/page.tsx @@ -5,7 +5,7 @@ import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; import { getScopedI18n } from "@homarr/translation/server"; -import { catchTrpcNotFound } from "~/errors/trpc-not-found"; +import { catchTrpcNotFound } from "~/errors/trpc-catch-error"; import { canAccessUserEditPage } from "../access"; import { ChangePasswordForm } from "./_components/_change-password-form"; diff --git a/apps/nextjs/src/errors/trpc-catch-error.ts b/apps/nextjs/src/errors/trpc-catch-error.ts new file mode 100644 index 000000000..1fb766745 --- /dev/null +++ b/apps/nextjs/src/errors/trpc-catch-error.ts @@ -0,0 +1,23 @@ +import "server-only"; + +import { notFound, redirect } from "next/navigation"; +import { TRPCError } from "@trpc/server"; + +import { logger } from "@homarr/log"; + +export const catchTrpcNotFound = (err: unknown) => { + if (err instanceof TRPCError && err.code === "NOT_FOUND") { + notFound(); + } + + throw err; +}; + +export const catchTrpcUnauthorized = (err: unknown) => { + if (err instanceof TRPCError && err.code === "UNAUTHORIZED") { + logger.info("Somebody tried to access a protected route without being authenticated, redirecting to login page"); + redirect("/auth/login"); + } + + throw err; +}; diff --git a/apps/nextjs/src/errors/trpc-not-found.ts b/apps/nextjs/src/errors/trpc-not-found.ts deleted file mode 100644 index 3ae693203..000000000 --- a/apps/nextjs/src/errors/trpc-not-found.ts +++ /dev/null @@ -1,12 +0,0 @@ -import "server-only"; - -import { notFound } from "next/navigation"; -import { TRPCError } from "@trpc/server"; - -export const catchTrpcNotFound = (err: unknown) => { - if (err instanceof TRPCError && err.code === "NOT_FOUND") { - notFound(); - } - - throw err; -}; diff --git a/packages/api/src/router/app.ts b/packages/api/src/router/app.ts index 1ce137b72..62e3f2674 100644 --- a/packages/api/src/router/app.ts +++ b/packages/api/src/router/app.ts @@ -4,10 +4,11 @@ import { asc, createId, eq, inArray, like } from "@homarr/db"; import { apps } from "@homarr/db/schema/sqlite"; import { validation, z } from "@homarr/validation"; -import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; +import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc"; +import { canUserSeeAppAsync } from "./app/app-access-control"; export const appRouter = createTRPCRouter({ - all: publicProcedure + all: protectedProcedure .input(z.void()) .output( z.array( @@ -26,7 +27,7 @@ export const appRouter = createTRPCRouter({ orderBy: asc(apps.name), }); }), - search: publicProcedure + search: protectedProcedure .input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) })) .output( z.array( @@ -47,7 +48,7 @@ export const appRouter = createTRPCRouter({ limit: input.limit, }); }), - selectable: publicProcedure + selectable: protectedProcedure .input(z.void()) .output( z.array( @@ -104,14 +105,23 @@ export const appRouter = createTRPCRouter({ }); } + const canUserSeeApp = await canUserSeeAppAsync(ctx.session?.user ?? null, app.id); + if (!canUserSeeApp) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "App not found", + }); + } + return app; }), - byIds: publicProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => { + byIds: protectedProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => { return await ctx.db.query.apps.findMany({ where: inArray(apps.id, input), }); }), - create: protectedProcedure + create: permissionRequiredProcedure + .requiresPermission("app-create") .input(validation.app.manage) .output(z.void()) .meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } }) @@ -124,29 +134,33 @@ export const appRouter = createTRPCRouter({ href: input.href, }); }), - update: protectedProcedure.input(validation.app.edit).mutation(async ({ ctx, input }) => { - const app = await ctx.db.query.apps.findFirst({ - where: eq(apps.id, input.id), - }); - - if (!app) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "App not found", + update: permissionRequiredProcedure + .requiresPermission("app-modify-all") + .input(validation.app.edit) + .mutation(async ({ ctx, input }) => { + const app = await ctx.db.query.apps.findFirst({ + where: eq(apps.id, input.id), }); - } - await ctx.db - .update(apps) - .set({ - name: input.name, - description: input.description, - iconUrl: input.iconUrl, - href: input.href, - }) - .where(eq(apps.id, input.id)); - }), - delete: protectedProcedure + if (!app) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "App not found", + }); + } + + await ctx.db + .update(apps) + .set({ + name: input.name, + description: input.description, + iconUrl: input.iconUrl, + href: input.href, + }) + .where(eq(apps.id, input.id)); + }), + delete: permissionRequiredProcedure + .requiresPermission("app-full-all") .output(z.void()) .meta({ openapi: { method: "DELETE", path: "/api/apps/{id}", tags: ["apps"], protect: true } }) .input(validation.common.byId) diff --git a/packages/api/src/router/app/app-access-control.ts b/packages/api/src/router/app/app-access-control.ts new file mode 100644 index 000000000..b395cea09 --- /dev/null +++ b/packages/api/src/router/app/app-access-control.ts @@ -0,0 +1,50 @@ +import SuperJSON from "superjson"; + +import type { Session } from "@homarr/auth"; +import { db, eq, or } from "@homarr/db"; +import { items } from "@homarr/db/schema/sqlite"; + +import type { WidgetComponentProps } from "../../../../widgets/src"; + +export const canUserSeeAppAsync = async (user: Session["user"] | null, appId: string) => { + return await canUserSeeAppsAsync(user, [appId]); +}; + +export const canUserSeeAppsAsync = async (user: Session["user"] | null, appIds: string[]) => { + if (user) return true; + + const appIdsOnPublicBoards = await getAllAppIdsOnPublicBoardsAsync(); + return appIds.every((appId) => appIdsOnPublicBoards.includes(appId)); +}; + +const getAllAppIdsOnPublicBoardsAsync = async () => { + const itemsWithApps = await db.query.items.findMany({ + where: or(eq(items.kind, "app"), eq(items.kind, "bookmarks")), + with: { + section: { + columns: {}, // Nothing + with: { + board: { + columns: { + isPublic: true, + }, + }, + }, + }, + }, + }); + + return itemsWithApps + .filter((item) => item.section.board.isPublic) + .flatMap((item) => { + if (item.kind === "app") { + const parsedOptions = SuperJSON.parse["options"]>(item.options); + return [parsedOptions.appId]; + } else if (item.kind === "bookmarks") { + const parsedOptions = SuperJSON.parse["options"]>(item.options); + return parsedOptions.items; + } + + throw new Error("Failed to get app ids from board. Invalid item kind: 'test'"); + }); +}; diff --git a/packages/api/src/router/log.ts b/packages/api/src/router/log.ts index d48196a19..f6794db13 100644 --- a/packages/api/src/router/log.ts +++ b/packages/api/src/router/log.ts @@ -7,7 +7,7 @@ import { loggingChannel } from "@homarr/redis"; import { createTRPCRouter, permissionRequiredProcedure } from "../trpc"; export const logRouter = createTRPCRouter({ - subscribe: permissionRequiredProcedure.requiresPermission("admin").subscription(() => { + subscribe: permissionRequiredProcedure.requiresPermission("other-view-logs").subscription(() => { return observable((emit) => { const unsubscribe = loggingChannel.subscribe((data) => { emit.next(data); diff --git a/packages/api/src/router/medias/media-router.ts b/packages/api/src/router/medias/media-router.ts index ebf8d256c..90f497523 100644 --- a/packages/api/src/router/medias/media-router.ts +++ b/packages/api/src/router/medias/media-router.ts @@ -4,7 +4,7 @@ import { and, createId, desc, eq, like } from "@homarr/db"; import { medias } from "@homarr/db/schema/sqlite"; import { validation, z } from "@homarr/validation"; -import { createTRPCRouter, protectedProcedure } from "../../trpc"; +import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc"; export const mediaRouter = createTRPCRouter({ getPaginated: protectedProcedure @@ -14,7 +14,7 @@ export const mediaRouter = createTRPCRouter({ ), ) .query(async ({ ctx, input }) => { - const includeFromAllUsers = ctx.session.user.permissions.includes("admin") && input.includeFromAllUsers; + const includeFromAllUsers = ctx.session.user.permissions.includes("media-view-all") && input.includeFromAllUsers; const where = and( input.search.length >= 1 ? like(medias.name, `%${input.search}%`) : undefined, @@ -46,20 +46,23 @@ export const mediaRouter = createTRPCRouter({ totalCount, }; }), - uploadMedia: protectedProcedure.input(validation.media.uploadMedia).mutation(async ({ ctx, input }) => { - const content = Buffer.from(await input.file.arrayBuffer()); - const id = createId(); - await ctx.db.insert(medias).values({ - id, - creatorId: ctx.session.user.id, - content, - size: input.file.size, - contentType: input.file.type, - name: input.file.name, - }); + uploadMedia: permissionRequiredProcedure + .requiresPermission("media-upload") + .input(validation.media.uploadMedia) + .mutation(async ({ ctx, input }) => { + const content = Buffer.from(await input.file.arrayBuffer()); + const id = createId(); + await ctx.db.insert(medias).values({ + id, + creatorId: ctx.session.user.id, + content, + size: input.file.size, + contentType: input.file.type, + name: input.file.name, + }); - return id; - }), + return id; + }), deleteMedia: protectedProcedure.input(validation.common.byId).mutation(async ({ ctx, input }) => { const dbMedia = await ctx.db.query.medias.findFirst({ where: eq(medias.id, input.id), @@ -75,8 +78,8 @@ export const mediaRouter = createTRPCRouter({ }); } - // Only allow admins and the creator of the media to delete it - if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== dbMedia.creatorId) { + // Only allow users with media-full-all permission and the creator of the media to delete it + if (!ctx.session.user.permissions.includes("media-full-all") && ctx.session.user.id !== dbMedia.creatorId) { throw new TRPCError({ code: "FORBIDDEN", message: "You don't have permission to delete this media", diff --git a/packages/api/src/router/search-engine/search-engine-router.ts b/packages/api/src/router/search-engine/search-engine-router.ts index 2bc1584b6..63c7c3bda 100644 --- a/packages/api/src/router/search-engine/search-engine-router.ts +++ b/packages/api/src/router/search-engine/search-engine-router.ts @@ -4,7 +4,7 @@ import { createId, eq, like, sql } from "@homarr/db"; import { searchEngines } from "@homarr/db/schema/sqlite"; import { validation } from "@homarr/validation"; -import { createTRPCRouter, protectedProcedure } from "../../trpc"; +import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc"; export const searchEngineRouter = createTRPCRouter({ getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => { @@ -59,43 +59,52 @@ export const searchEngineRouter = createTRPCRouter({ limit: input.limit, }); }), - create: protectedProcedure.input(validation.searchEngine.manage).mutation(async ({ ctx, input }) => { - await ctx.db.insert(searchEngines).values({ - id: createId(), - name: input.name, - short: input.short.toLowerCase(), - iconUrl: input.iconUrl, - urlTemplate: "urlTemplate" in input ? input.urlTemplate : null, - description: input.description, - type: input.type, - integrationId: "integrationId" in input ? input.integrationId : null, - }); - }), - update: protectedProcedure.input(validation.searchEngine.edit).mutation(async ({ ctx, input }) => { - const searchEngine = await ctx.db.query.searchEngines.findFirst({ - where: eq(searchEngines.id, input.id), - }); - - if (!searchEngine) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Search engine not found", - }); - } - - await ctx.db - .update(searchEngines) - .set({ + create: permissionRequiredProcedure + .requiresPermission("search-engine-create") + .input(validation.searchEngine.manage) + .mutation(async ({ ctx, input }) => { + await ctx.db.insert(searchEngines).values({ + id: createId(), name: input.name, + short: input.short.toLowerCase(), iconUrl: input.iconUrl, urlTemplate: "urlTemplate" in input ? input.urlTemplate : null, description: input.description, - integrationId: "integrationId" in input ? input.integrationId : null, type: input.type, - }) - .where(eq(searchEngines.id, input.id)); - }), - delete: protectedProcedure.input(validation.common.byId).mutation(async ({ ctx, input }) => { - await ctx.db.delete(searchEngines).where(eq(searchEngines.id, input.id)); - }), + integrationId: "integrationId" in input ? input.integrationId : null, + }); + }), + update: permissionRequiredProcedure + .requiresPermission("search-engine-modify-all") + .input(validation.searchEngine.edit) + .mutation(async ({ ctx, input }) => { + const searchEngine = await ctx.db.query.searchEngines.findFirst({ + where: eq(searchEngines.id, input.id), + }); + + if (!searchEngine) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Search engine not found", + }); + } + + await ctx.db + .update(searchEngines) + .set({ + name: input.name, + iconUrl: input.iconUrl, + urlTemplate: "urlTemplate" in input ? input.urlTemplate : null, + description: input.description, + integrationId: "integrationId" in input ? input.integrationId : null, + type: input.type, + }) + .where(eq(searchEngines.id, input.id)); + }), + delete: permissionRequiredProcedure + .requiresPermission("search-engine-full-all") + .input(validation.common.byId) + .mutation(async ({ ctx, input }) => { + await ctx.db.delete(searchEngines).where(eq(searchEngines.id, input.id)); + }), }); diff --git a/packages/api/src/router/test/app.spec.ts b/packages/api/src/router/test/app.spec.ts index 4fdb07d56..0354ad74e 100644 --- a/packages/api/src/router/test/app.spec.ts +++ b/packages/api/src/router/test/app.spec.ts @@ -5,23 +5,26 @@ import type { Session } from "@homarr/auth"; import { createId } from "@homarr/db"; import { apps } from "@homarr/db/schema/sqlite"; import { createDb } from "@homarr/db/test"; +import type { GroupPermissionKey } from "@homarr/definitions"; import { appRouter } from "../app"; +import * as appAccessControl from "../app/app-access-control"; // Mock the auth module to return an empty session vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); -const defaultSession: Session = { - user: { id: createId(), permissions: [], colorScheme: "light" }, +const createDefaultSession = (permissions: GroupPermissionKey[] = []): Session => ({ + user: { id: createId(), permissions, colorScheme: "light" }, expires: new Date().toISOString(), -}; +}); describe("all should return all apps", () => { - test("should return all apps", async () => { + test("should return all apps with session", async () => { + // Arrange const db = createDb(); const caller = appRouter.createCaller({ db, - session: null, + session: createDefaultSession(), }); await db.insert(apps).values([ @@ -48,15 +51,30 @@ describe("all should return all apps", () => { expect(result[1]!.href).toBeNull(); expect(result[1]!.description).toBeNull(); }); + test("should throw UNAUTHORIZED if the user is not authenticated", async () => { + // Arrange + const caller = appRouter.createCaller({ + db: createDb(), + session: null, + }); + + // Act + const actAsync = async () => await caller.all(); + + // Assert + await expect(actAsync()).rejects.toThrow("UNAUTHORIZED"); + }); }); describe("byId should return an app by id", () => { - test("should return an app by id", async () => { + test("should return an app by id when canUserSeeAppAsync returns true", async () => { + // Arrange const db = createDb(); const caller = appRouter.createCaller({ db, session: null, }); + vi.spyOn(appAccessControl, "canUserSeeAppAsync").mockReturnValue(Promise.resolve(true)); await db.insert(apps).values([ { @@ -73,28 +91,61 @@ describe("byId should return an app by id", () => { }, ]); + // Act const result = await caller.byId({ id: "2" }); + + // Assert expect(result.name).toBe("Mantine"); }); + test("should throw NOT_FOUND error when canUserSeeAppAsync returns false", async () => { + // Arrange + const db = createDb(); + const caller = appRouter.createCaller({ + db, + session: null, + }); + await db.insert(apps).values([ + { + id: "2", + name: "Mantine", + description: "React components and hooks library", + iconUrl: "https://mantine.dev/favicon.svg", + href: "https://mantine.dev", + }, + ]); + vi.spyOn(appAccessControl, "canUserSeeAppAsync").mockReturnValue(Promise.resolve(false)); + + // Act + const actAsync = async () => await caller.byId({ id: "2" }); + + // Assert + await expect(actAsync()).rejects.toThrow("App not found"); + }); + test("should throw an error if the app does not exist", async () => { + // Arrange const db = createDb(); const caller = appRouter.createCaller({ db, session: null, }); + // Act const actAsync = async () => await caller.byId({ id: "2" }); + + // Assert await expect(actAsync()).rejects.toThrow("App not found"); }); }); describe("create should create a new app with all arguments", () => { test("should create a new app", async () => { + // Arrange const db = createDb(); const caller = appRouter.createCaller({ db, - session: defaultSession, + session: createDefaultSession(["app-create"]), }); const input = { name: "Mantine", @@ -103,8 +154,10 @@ describe("create should create a new app with all arguments", () => { href: "https://mantine.dev", }; + // Act await caller.create(input); + // Assert const dbApp = await db.query.apps.findFirst(); expect(dbApp).toBeDefined(); expect(dbApp!.name).toBe(input.name); @@ -114,10 +167,11 @@ describe("create should create a new app with all arguments", () => { }); test("should create a new app only with required arguments", async () => { + // Arrange const db = createDb(); const caller = appRouter.createCaller({ db, - session: defaultSession, + session: createDefaultSession(["app-create"]), }); const input = { name: "Mantine", @@ -126,8 +180,10 @@ describe("create should create a new app with all arguments", () => { href: null, }; + // Act await caller.create(input); + // Assert const dbApp = await db.query.apps.findFirst(); expect(dbApp).toBeDefined(); expect(dbApp!.name).toBe(input.name); @@ -139,10 +195,11 @@ describe("create should create a new app with all arguments", () => { describe("update should update an app", () => { test("should update an app", async () => { + // Arrange const db = createDb(); const caller = appRouter.createCaller({ db, - session: defaultSession, + session: createDefaultSession(["app-modify-all"]), }); const appId = createId(); @@ -162,8 +219,10 @@ describe("update should update an app", () => { href: "https://mantine.dev", }; + // Act await caller.update(input); + // Assert const dbApp = await db.query.apps.findFirst(); expect(dbApp).toBeDefined(); @@ -174,12 +233,14 @@ describe("update should update an app", () => { }); test("should throw an error if the app does not exist", async () => { + // Arrange const db = createDb(); const caller = appRouter.createCaller({ db, - session: defaultSession, + session: createDefaultSession(["app-modify-all"]), }); + // Act const actAsync = async () => await caller.update({ id: createId(), @@ -188,16 +249,19 @@ describe("update should update an app", () => { description: null, href: null, }); + + // Assert await expect(actAsync()).rejects.toThrow("App not found"); }); }); describe("delete should delete an app", () => { test("should delete an app", async () => { + // Arrange const db = createDb(); const caller = appRouter.createCaller({ db, - session: defaultSession, + session: createDefaultSession(["app-full-all"]), }); const appId = createId(); @@ -207,8 +271,10 @@ describe("delete should delete an app", () => { iconUrl: "https://mantine.dev/favicon.svg", }); + // Act await caller.delete({ id: appId }); + // Assert const dbApp = await db.query.apps.findFirst(); expect(dbApp).toBeUndefined(); }); diff --git a/packages/definitions/src/permissions.ts b/packages/definitions/src/permissions.ts index 14ff21c46..0ceb648be 100644 --- a/packages/definitions/src/permissions.ts +++ b/packages/definitions/src/permissions.ts @@ -36,8 +36,13 @@ export type IntegrationPermission = (typeof integrationPermissions)[number]; * For example "board-create" is a generated key */ export const groupPermissions = { + // Order is the same in the UI, inspired from order in navigation here board: ["create", "view-all", "modify-all", "full-all"], + app: ["create", "use-all", "modify-all", "full-all"], integration: ["create", "use-all", "interact-all", "full-all"], + "search-engine": ["create", "modify-all", "full-all"], + media: ["upload", "view-all", "full-all"], + other: ["view-logs"], admin: true, } as const; @@ -49,9 +54,21 @@ export const groupPermissions = { const groupPermissionParents = { "board-modify-all": ["board-view-all"], "board-full-all": ["board-modify-all", "board-create"], + "app-modify-all": ["app-create"], + "app-full-all": ["app-modify-all", "app-use-all"], "integration-interact-all": ["integration-use-all"], "integration-full-all": ["integration-interact-all", "integration-create"], - admin: ["board-full-all", "integration-full-all"], + "search-engine-modify-all": ["search-engine-create"], + "search-engine-full-all": ["search-engine-modify-all"], + "media-full-all": ["media-upload", "media-view-all"], + admin: [ + "board-full-all", + "app-full-all", + "integration-full-all", + "search-engine-full-all", + "media-full-all", + "other-view-logs", + ], } satisfies Partial>; export const getPermissionsWithParents = (permissions: GroupPermissionKey[]): GroupPermissionKey[] => { diff --git a/packages/spotlight/src/components/spotlight.tsx b/packages/spotlight/src/components/spotlight.tsx index 2de76db8c..02251fe32 100644 --- a/packages/spotlight/src/components/spotlight.tsx +++ b/packages/spotlight/src/components/spotlight.tsx @@ -1,5 +1,6 @@ "use client"; +import type { Dispatch, SetStateAction } from "react"; import { useMemo, useRef, useState } from "react"; import { ActionIcon, Center, Group, Kbd } from "@mantine/core"; import { Spotlight as MantineSpotlight } from "@mantine/spotlight"; @@ -9,23 +10,42 @@ import type { TranslationObject } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import type { inferSearchInteractionOptions } from "../lib/interaction"; +import type { SearchMode } from "../lib/mode"; import { searchModes } from "../modes"; import { selectAction, spotlightStore } from "../spotlight-store"; import { SpotlightChildrenActions } from "./actions/children-actions"; import { SpotlightActionGroups } from "./actions/groups/action-group"; +type SearchModeKey = keyof TranslationObject["search"]["mode"]; + export const Spotlight = () => { - const [query, setQuery] = useState(""); - const [mode, setMode] = useState("help"); - const [childrenOptions, setChildrenOptions] = useState | null>(null); - const t = useI18n(); - const inputRef = useRef(null); + const searchModeState = useState("help"); + const mode = searchModeState[0]; const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]); if (!activeMode) { return null; } + // We use the "key" below to prevent the 'Different amounts of hooks' error + return ; +}; + +interface SpotlightWithActiveModeProps { + modeState: [SearchModeKey, Dispatch>]; + activeMode: SearchMode; +} + +const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveModeProps) => { + const [query, setQuery] = useState(""); + const [mode, setMode] = modeState; + const [childrenOptions, setChildrenOptions] = useState | null>(null); + const t = useI18n(); + const inputRef = useRef(null); + // Works as always the same amount of hooks are executed + const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups; + const groups = useGroups(); + return ( { }); }} query={query} - groups={activeMode.groups} + groups={groups} /> )} diff --git a/packages/spotlight/src/lib/mode.ts b/packages/spotlight/src/lib/mode.ts index a0b480f3c..358432107 100644 --- a/packages/spotlight/src/lib/mode.ts +++ b/packages/spotlight/src/lib/mode.ts @@ -2,8 +2,14 @@ import type { TranslationObject } from "@homarr/translation"; import type { SearchGroup } from "./group"; -export interface SearchMode { +export type SearchMode = { modeKey: keyof TranslationObject["search"]["mode"]; character: string; - groups: SearchGroup[]; -} +} & ( + | { + groups: SearchGroup[]; + } + | { + useGroups: () => SearchGroup[]; + } +); diff --git a/packages/spotlight/src/modes/app-integration-board/index.tsx b/packages/spotlight/src/modes/app-integration-board/index.tsx index 417fe967c..4fe5de9fd 100644 --- a/packages/spotlight/src/modes/app-integration-board/index.tsx +++ b/packages/spotlight/src/modes/app-integration-board/index.tsx @@ -1,3 +1,6 @@ +import { useSession } from "@homarr/auth/client"; + +import type { SearchGroup } from "../../lib/group"; import type { SearchMode } from "../../lib/mode"; import { appsSearchGroup } from "./apps-search-group"; import { boardsSearchGroup } from "./boards-search-group"; @@ -6,5 +9,14 @@ import { integrationsSearchGroup } from "./integrations-search-group"; export const appIntegrationBoardMode = { modeKey: "appIntegrationBoard", character: "#", - groups: [appsSearchGroup, integrationsSearchGroup, boardsSearchGroup], + useGroups() { + const { data: session } = useSession(); + const groups: SearchGroup[] = [boardsSearchGroup]; + + if (!session?.user) { + return groups; + } + + return groups.concat([appsSearchGroup, integrationsSearchGroup]); + }, } satisfies SearchMode; diff --git a/packages/spotlight/src/modes/command/index.tsx b/packages/spotlight/src/modes/command/index.tsx index 6d57a9a90..4290c7c1e 100644 --- a/packages/spotlight/src/modes/command/index.tsx +++ b/packages/spotlight/src/modes/command/index.tsx @@ -1,11 +1,11 @@ import { Group, Text, useMantineColorScheme } from "@mantine/core"; import { + IconBox, IconCategoryPlus, IconFileImport, IconLanguage, IconMailForward, IconMoon, - IconPackage, IconPlug, IconSun, IconUserPlus, @@ -113,9 +113,10 @@ export const commandMode = { }, { commandKey: "newApp", - icon: IconPackage, + icon: IconBox, name: tOption("newApp.label"), useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })), + hidden: !session?.user.permissions.includes("app-create"), }, { commandKey: "newIntegration", diff --git a/packages/spotlight/src/modes/index.tsx b/packages/spotlight/src/modes/index.tsx index eb286a1cd..f95b1145e 100644 --- a/packages/spotlight/src/modes/index.tsx +++ b/packages/spotlight/src/modes/index.tsx @@ -1,6 +1,7 @@ import { Group, Kbd, Text } from "@mantine/core"; import { IconBook2, IconBrandDiscord, IconBrandGithub } from "@tabler/icons-react"; +import { useSession } from "@homarr/auth/client"; import { createDocumentationLink } from "@homarr/definitions"; import { useScopedI18n } from "@homarr/translation/client"; @@ -18,58 +19,67 @@ const searchModesWithoutHelp = [userGroupMode, appIntegrationBoardMode, external const helpMode = { modeKey: "help", character: "?", - groups: [ - createGroup({ - keyPath: "character", - title: (t) => t("search.mode.help.group.mode.title"), - options: searchModesWithoutHelp.map(({ character, modeKey }) => ({ character, modeKey })), - Component: ({ modeKey, character }) => { - const t = useScopedI18n(`search.mode.${modeKey}`); + useGroups() { + const { data: session } = useSession(); + const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, commandMode, pageMode]; - return ( - - {t("help")} - {character} + if (session?.user.permissions.includes("admin")) { + visibleSearchModes.unshift(userGroupMode); + } + + return [ + createGroup({ + keyPath: "character", + title: (t) => t("search.mode.help.group.mode.title"), + options: visibleSearchModes.map(({ character, modeKey }) => ({ character, modeKey })), + Component: ({ modeKey, character }) => { + const t = useScopedI18n(`search.mode.${modeKey}`); + + return ( + + {t("help")} + {character} + + ); + }, + filter: () => true, + useInteraction: interaction.mode(({ modeKey }) => ({ mode: modeKey })), + }), + createGroup({ + keyPath: "href", + title: (t) => t("search.mode.help.group.help.title"), + useOptions() { + const t = useScopedI18n("search.mode.help.group.help.option"); + + return [ + { + label: t("documentation.label"), + icon: IconBook2, + href: createDocumentationLink("/docs/getting-started"), + }, + { + label: t("submitIssue.label"), + icon: IconBrandGithub, + href: "https://github.com/ajnart/homarr/issues/new/choose", + }, + { + label: t("discord.label"), + icon: IconBrandDiscord, + href: "https://discord.com/invite/aCsmEV5RgA", + }, + ]; + }, + Component: (props) => ( + + + {props.label} - ); - }, - filter: () => true, - useInteraction: interaction.mode(({ modeKey }) => ({ mode: modeKey })), - }), - createGroup({ - keyPath: "href", - title: (t) => t("search.mode.help.group.help.title"), - useOptions() { - const t = useScopedI18n("search.mode.help.group.help.option"); - - return [ - { - label: t("documentation.label"), - icon: IconBook2, - href: createDocumentationLink("/docs/getting-started"), - }, - { - label: t("submitIssue.label"), - icon: IconBrandGithub, - href: "https://github.com/ajnart/homarr/issues/new/choose", - }, - { - label: t("discord.label"), - icon: IconBrandDiscord, - href: "https://discord.com/invite/aCsmEV5RgA", - }, - ]; - }, - Component: (props) => ( - - - {props.label} - - ), - filter: () => true, - useInteraction: interaction.link(({ href }) => ({ href, newTab: true })), - }), - ], + ), + filter: () => true, + useInteraction: interaction.link(({ href }) => ({ href, newTab: true })), + }), + ]; + }, } satisfies SearchMode; export const searchModes = [...searchModesWithoutHelp, helpMode] as const; diff --git a/packages/spotlight/src/modes/page/pages-search-group.tsx b/packages/spotlight/src/modes/page/pages-search-group.tsx index 9383631ef..d9b419340 100644 --- a/packages/spotlight/src/modes/page/pages-search-group.tsx +++ b/packages/spotlight/src/modes/page/pages-search-group.tsx @@ -130,7 +130,7 @@ export const pagesSearchGroup = createGroup<{ icon: IconLogs, path: "/manage/tools/logs", name: t("manageLog.label"), - hidden: !session?.user.permissions.includes("admin"), + hidden: !session?.user.permissions.includes("other-view-logs"), }, { icon: IconReport, diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index f693daaa7..7910e01ea 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -196,6 +196,27 @@ } } }, + "app": { + "title": "Apps", + "item": { + "create": { + "label": "Create apps", + "description": "Allow members to create apps" + }, + "use-all": { + "label": "Use all apps", + "description": "Allow members to add any apps to their boards" + }, + "modify-all": { + "label": "Modify all apps", + "description": "Allow members to modify all apps" + }, + "full-all": { + "label": "Full app access", + "description": "Allow members to manage, use and delete any app" + } + } + }, "board": { "title": "Boards", "item": { @@ -237,6 +258,49 @@ "description": "Allow members to manage, use and interact with any integration" } } + }, + "media": { + "title": "Medias", + "item": { + "upload": { + "label": "Upload medias", + "description": "Allow members to upload medias" + }, + "view-all": { + "label": "View all medias", + "description": "Allow members to view all medias" + }, + "full-all": { + "label": "Full media access", + "description": "Allow members to manage and delete any media" + } + } + }, + "other": { + "title": "Other", + "item": { + "view-logs": { + "label": "View logs", + "description": "Allow members to view logs" + } + } + }, + "search-engine": { + "title": "Search engines", + "item": { + "create": { + "label": "Create search engines", + "description": "Allow members to create search engines" + }, + "modify-all": { + "label": "Modify all search engines", + "description": "Allow members to modify all search engines" + }, + "full-all": { + "label": "Full search engine access", + "description": "Allow members to manage and delete any search engine" + } + } } }, "memberNotice": {