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": {