feat: add more group permissions (#1453)

* feat: add more group permissions

* feat: restrict access with app permissions

* feat: restrict access with search-engine permissions

* feat: restrict access with media permissions

* refactor: remove permissions for users, groups and invites

* test: adjust app router tests with app permissions

* fix: integration page accessible without session

* fix: search for users, groups and integrations shown to unauthenticated users

* chore: address pull request feedback
This commit is contained in:
Meier Lukas
2024-11-17 21:31:25 +01:00
committed by GitHub
parent 879aa1152f
commit 0ee343b99e
31 changed files with 575 additions and 208 deletions

View File

@@ -1,6 +1,8 @@
import { notFound } from "next/navigation";
import { Container, Stack, Title } from "@mantine/core"; import { Container, Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
@@ -11,6 +13,11 @@ interface AppEditPageProps {
} }
export default async function AppEditPage({ params }: 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 app = await api.app.byId({ id: params.id });
const t = await getI18n(); const t = await getI18n();

View File

@@ -1,11 +1,19 @@
import { notFound } from "next/navigation";
import { Container, Stack, Title } from "@mantine/core"; import { Container, Stack, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AppNewForm } from "./_app-new-form"; import { AppNewForm } from "./_app-new-form";
export default async function AppNewPage() { export default async function AppNewPage() {
const session = await auth();
if (!session?.user.permissions.includes("app-create")) {
notFound();
}
const t = await getI18n(); const t = await getI18n();
return ( return (

View File

@@ -1,9 +1,11 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core"; import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconApps, IconPencil } from "@tabler/icons-react"; import { IconApps, IconPencil } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { parseAppHrefWithVariablesServer } from "@homarr/common/server"; import { parseAppHrefWithVariablesServer } from "@homarr/common/server";
import { getI18n, getScopedI18n } from "@homarr/translation/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"; import { AppDeleteButton } from "./_app-delete-button";
export default async function AppsPage() { export default async function AppsPage() {
const session = await auth();
if (!session) {
redirect("/auth/login");
}
const apps = await api.app.all(); const apps = await api.app.all();
const t = await getScopedI18n("app"); const t = await getScopedI18n("app");
@@ -22,9 +30,11 @@ export default async function AppsPage() {
<Stack> <Stack>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Title>{t("page.list.title")}</Title> <Title>{t("page.list.title")}</Title>
<MobileAffixButton component={Link} href="/manage/apps/new"> {session.user.permissions.includes("app-create") && (
{t("page.create.title")} <MobileAffixButton component={Link} href="/manage/apps/new">
</MobileAffixButton> {t("page.create.title")}
</MobileAffixButton>
)}
</Group> </Group>
{apps.length === 0 && <AppNoResults />} {apps.length === 0 && <AppNoResults />}
{apps.length > 0 && ( {apps.length > 0 && (
@@ -45,6 +55,7 @@ interface AppCardProps {
const AppCard = async ({ app }: AppCardProps) => { const AppCard = async ({ app }: AppCardProps) => {
const t = await getScopedI18n("app"); const t = await getScopedI18n("app");
const session = await auth();
return ( return (
<Card> <Card>
@@ -78,16 +89,18 @@ const AppCard = async ({ app }: AppCardProps) => {
</Group> </Group>
<Group> <Group>
<ActionIconGroup> <ActionIconGroup>
<ActionIcon {session?.user.permissions.includes("app-modify-all") && (
component={Link} <ActionIcon
href={`/manage/apps/edit/${app.id}`} component={Link}
variant="subtle" href={`/manage/apps/edit/${app.id}`}
color="gray" variant="subtle"
aria-label={t("page.edit.title")} color="gray"
> aria-label={t("page.edit.title")}
<IconPencil size={16} stroke={1.5} /> >
</ActionIcon> <IconPencil size={16} stroke={1.5} />
<AppDeleteButton app={app} /> </ActionIcon>
)}
{session?.user.permissions.includes("app-full-all") && <AppDeleteButton app={app} />}
</ActionIconGroup> </ActionIconGroup>
</Group> </Group>
</Group> </Group>
@@ -97,6 +110,7 @@ const AppCard = async ({ app }: AppCardProps) => {
const AppNoResults = async () => { const AppNoResults = async () => {
const t = await getI18n(); const t = await getI18n();
const session = await auth();
return ( return (
<Card withBorder bg="transparent"> <Card withBorder bg="transparent">
@@ -105,7 +119,9 @@ const AppNoResults = async () => {
<Text fw={500} size="lg"> <Text fw={500} size="lg">
{t("app.page.list.noResults.title")} {t("app.page.list.noResults.title")}
</Text> </Text>
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.action")}</Anchor> {session?.user.permissions.includes("app-create") && (
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.action")}</Anchor>
)}
</Stack> </Stack>
</Card> </Card>
); );

View File

@@ -6,7 +6,7 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "@homarr/ui"; import { IntegrationAvatar } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; 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 { IntegrationAccessSettings } from "../../_components/integration-access-settings";
import { EditIntegrationForm } from "./_integration-edit-form"; import { EditIntegrationForm } from "./_integration-edit-form";

View File

@@ -1,6 +1,7 @@
import { Fragment } from "react"; import { Fragment } from "react";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation";
import { import {
AccordionControl, AccordionControl,
AccordionItem, AccordionItem,
@@ -50,11 +51,16 @@ interface IntegrationsPageProps {
} }
export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) { export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
const integrations = await api.integration.all();
const session = await auth(); const session = await auth();
if (!session) {
redirect("/auth/login");
}
const integrations = await api.integration.all();
const t = await getScopedI18n("integration"); const t = await getScopedI18n("integration");
const canCreateIntegrations = session?.user.permissions.includes("integration-create") ?? false; const canCreateIntegrations = session.user.permissions.includes("integration-create");
return ( return (
<ManageContainer> <ManageContainer>

View File

@@ -52,16 +52,19 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconBox, icon: IconBox,
href: "/manage/apps", href: "/manage/apps",
label: t("items.apps"), label: t("items.apps"),
hidden: !session,
}, },
{ {
icon: IconPlug, icon: IconPlug,
href: "/manage/integrations", href: "/manage/integrations",
label: t("items.integrations"), label: t("items.integrations"),
hidden: !session,
}, },
{ {
icon: IconSearch, icon: IconSearch,
href: "/manage/search-engines", href: "/manage/search-engines",
label: t("items.searchEngies"), label: t("items.searchEngies"),
hidden: !session,
}, },
{ {
icon: IconPhoto, icon: IconPhoto,
@@ -95,27 +98,32 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{ {
label: t("items.tools.label"), label: t("items.tools.label"),
icon: IconTool, 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: [ items: [
{ {
label: t("items.tools.items.docker"), label: t("items.tools.items.docker"),
icon: IconBrandDocker, icon: IconBrandDocker,
href: "/manage/tools/docker", href: "/manage/tools/docker",
hidden: !session?.user.permissions.includes("admin"),
}, },
{ {
label: t("items.tools.items.api"), label: t("items.tools.items.api"),
icon: IconPlug, icon: IconPlug,
href: "/manage/tools/api", href: "/manage/tools/api",
hidden: !session?.user.permissions.includes("admin"),
}, },
{ {
label: t("items.tools.items.logs"), label: t("items.tools.items.logs"),
icon: IconLogs, icon: IconLogs,
href: "/manage/tools/logs", href: "/manage/tools/logs",
hidden: !session?.user.permissions.includes("other-view-logs"),
}, },
{ {
label: t("items.tools.items.tasks"), label: t("items.tools.items.tasks"),
icon: IconReport, icon: IconReport,
href: "/manage/tools/tasks", href: "/manage/tools/tasks",
hidden: !session?.user.permissions.includes("admin"),
}, },
], ],
}, },

View File

@@ -47,7 +47,6 @@ export default async function GroupsListPage(props: MediaListPageProps) {
const t = await getI18n(); const t = await getI18n();
const searchParams = searchParamsSchema.parse(props.searchParams); const searchParams = searchParamsSchema.parse(props.searchParams);
const { items: medias, totalCount } = await api.media.getPaginated(searchParams); const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
const isAdmin = session.user.permissions.includes("admin");
return ( return (
<ManageContainer size="xl"> <ManageContainer size="xl">
@@ -57,10 +56,12 @@ export default async function GroupsListPage(props: MediaListPageProps) {
<Group justify="space-between"> <Group justify="space-between">
<Group> <Group>
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} /> <SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
{isAdmin && <IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />} {session.user.permissions.includes("media-view-all") && (
<IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />
)}
</Group> </Group>
<UploadMedia /> {session.user.permissions.includes("media-upload") && <UploadMedia />}
</Group> </Group>
<Table striped highlightOnHover> <Table striped highlightOnHover>
<TableThead> <TableThead>
@@ -91,7 +92,10 @@ interface RowProps {
media: RouterOutputs["media"]["getPaginated"]["items"][number]; 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 ( return (
<TableTr> <TableTr>
<TableTd w={64}> <TableTd w={64}>
@@ -120,7 +124,7 @@ const Row = ({ media }: RowProps) => {
<TableTd w={64}> <TableTd w={64}>
<Group wrap="nowrap" gap="xs"> <Group wrap="nowrap" gap="xs">
<CopyMedia media={media} /> <CopyMedia media={media} />
<DeleteMedia media={media} /> {canDelete && <DeleteMedia media={media} />}
</Group> </Group>
</TableTd> </TableTd>
</TableTr> </TableTr>

View File

@@ -64,6 +64,7 @@ export default async function ManagementPage() {
href: "/manage/apps", href: "/manage/apps",
subtitle: t("statisticLabel.resources"), subtitle: t("statisticLabel.resources"),
title: t("statistic.app"), title: t("statistic.app"),
hidden: !session?.user,
}, },
{ {
count: statistics.countGroups, count: statistics.countGroups,

View File

@@ -1,6 +1,8 @@
import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core"; import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { ManageContainer } from "~/components/manage/manage-container"; import { ManageContainer } from "~/components/manage/manage-container";
@@ -12,6 +14,12 @@ interface SearchEngineEditPageProps {
} }
export default async function SearchEngineEditPage({ params }: 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 searchEngine = await api.searchEngine.byId({ id: params.id });
const t = await getI18n(); const t = await getI18n();

View File

@@ -1,5 +1,7 @@
import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core"; import { Stack, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { ManageContainer } from "~/components/manage/manage-container"; 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"; import { SearchEngineNewForm } from "./_search-engine-new-form";
export default async function SearchEngineNewPage() { export default async function SearchEngineNewPage() {
const session = await auth();
if (!session?.user.permissions.includes("search-engine-create")) {
notFound();
}
const t = await getI18n(); const t = await getI18n();
return ( return (

View File

@@ -1,9 +1,11 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core"; import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconPencil, IconSearch } from "@tabler/icons-react"; import { IconPencil, IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination } from "@homarr/ui"; import { SearchInput, TablePagination } from "@homarr/ui";
import { z } from "@homarr/validation"; import { z } from "@homarr/validation";
@@ -28,6 +30,12 @@ interface SearchEnginesPageProps {
} }
export default async function SearchEnginesPage(props: SearchEnginesPageProps) { export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
const session = await auth();
if (!session) {
redirect("/auth/login");
}
const searchParams = searchParamsSchema.parse(props.searchParams); const searchParams = searchParamsSchema.parse(props.searchParams);
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams); const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams);
@@ -40,9 +48,11 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
<Title>{tEngine("page.list.title")}</Title> <Title>{tEngine("page.list.title")}</Title>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} /> <SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} />
<MobileAffixButton component={Link} href="/manage/search-engines/new"> {session.user.permissions.includes("search-engine-create") && (
{tEngine("page.create.title")} <MobileAffixButton component={Link} href="/manage/search-engines/new">
</MobileAffixButton> {tEngine("page.create.title")}
</MobileAffixButton>
)}
</Group> </Group>
{searchEngines.length === 0 && <SearchEngineNoResults />} {searchEngines.length === 0 && <SearchEngineNoResults />}
{searchEngines.length > 0 && ( {searchEngines.length > 0 && (
@@ -67,6 +77,7 @@ interface SearchEngineCardProps {
const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => { const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
const t = await getScopedI18n("search.engine"); const t = await getScopedI18n("search.engine");
const session = await auth();
return ( return (
<Card> <Card>
@@ -105,16 +116,20 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
</Group> </Group>
<Group> <Group>
<ActionIconGroup> <ActionIconGroup>
<ActionIcon {session?.user.permissions.includes("search-engine-modify-all") && (
component={Link} <ActionIcon
href={`/manage/search-engines/edit/${searchEngine.id}`} component={Link}
variant="subtle" href={`/manage/search-engines/edit/${searchEngine.id}`}
color="gray" variant="subtle"
aria-label={t("page.edit.title")} color="gray"
> aria-label={t("page.edit.title")}
<IconPencil size={16} stroke={1.5} /> >
</ActionIcon> <IconPencil size={16} stroke={1.5} />
<SearchEngineDeleteButton searchEngine={searchEngine} /> </ActionIcon>
)}
{session?.user.permissions.includes("search-engine-full-all") && (
<SearchEngineDeleteButton searchEngine={searchEngine} />
)}
</ActionIconGroup> </ActionIconGroup>
</Group> </Group>
</Group> </Group>
@@ -124,6 +139,7 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
const SearchEngineNoResults = async () => { const SearchEngineNoResults = async () => {
const t = await getI18n(); const t = await getI18n();
const session = await auth();
return ( return (
<Card withBorder bg="transparent"> <Card withBorder bg="transparent">
@@ -132,7 +148,9 @@ const SearchEngineNoResults = async () => {
<Text fw={500} size="lg"> <Text fw={500} size="lg">
{t("search.engine.page.list.noResults.title")} {t("search.engine.page.list.noResults.title")}
</Text> </Text>
<Anchor href="/manage/search-engines/new">{t("search.engine.page.list.noResults.action")}</Anchor> {session?.user.permissions.includes("search-engine-create") && (
<Anchor href="/manage/search-engines/new">{t("search.engine.page.list.noResults.action")}</Anchor>
)}
</Stack> </Stack>
</Card> </Card>
); );

View File

@@ -27,7 +27,7 @@ export async function generateMetadata() {
export default async function LogsManagementPage() { export default async function LogsManagementPage() {
const session = await auth(); const session = await auth();
if (!session?.user || !session.user.permissions.includes("admin")) { if (!session?.user || !session.user.permissions.includes("other-view-logs")) {
notFound(); notFound();
} }

View File

@@ -8,7 +8,7 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox"; import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone"; 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 { createMetaTitle } from "~/metadata";
import { canAccessUserEditPage } from "../access"; import { canAccessUserEditPage } from "../access";
import { ChangeHomeBoardForm } from "./_components/_change-home-board"; import { ChangeHomeBoardForm } from "./_components/_change-home-board";

View File

@@ -10,7 +10,7 @@ import { UserAvatar } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container"; import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; 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 { NavigationLink } from "../groups/[id]/_navigation";
import { canAccessUserEditPage } from "./access"; import { canAccessUserEditPage } from "./access";

View File

@@ -5,7 +5,7 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server"; 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 { canAccessUserEditPage } from "../access";
import { ChangePasswordForm } from "./_components/_change-password-form"; import { ChangePasswordForm } from "./_components/_change-password-form";

View File

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

View File

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

View File

@@ -4,10 +4,11 @@ import { asc, createId, eq, inArray, like } from "@homarr/db";
import { apps } from "@homarr/db/schema/sqlite"; import { apps } from "@homarr/db/schema/sqlite";
import { validation, z } from "@homarr/validation"; 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({ export const appRouter = createTRPCRouter({
all: publicProcedure all: protectedProcedure
.input(z.void()) .input(z.void())
.output( .output(
z.array( z.array(
@@ -26,7 +27,7 @@ export const appRouter = createTRPCRouter({
orderBy: asc(apps.name), orderBy: asc(apps.name),
}); });
}), }),
search: publicProcedure search: protectedProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) })) .input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.output( .output(
z.array( z.array(
@@ -47,7 +48,7 @@ export const appRouter = createTRPCRouter({
limit: input.limit, limit: input.limit,
}); });
}), }),
selectable: publicProcedure selectable: protectedProcedure
.input(z.void()) .input(z.void())
.output( .output(
z.array( 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; 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({ return await ctx.db.query.apps.findMany({
where: inArray(apps.id, input), where: inArray(apps.id, input),
}); });
}), }),
create: protectedProcedure create: permissionRequiredProcedure
.requiresPermission("app-create")
.input(validation.app.manage) .input(validation.app.manage)
.output(z.void()) .output(z.void())
.meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } }) .meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } })
@@ -124,29 +134,33 @@ export const appRouter = createTRPCRouter({
href: input.href, href: input.href,
}); });
}), }),
update: protectedProcedure.input(validation.app.edit).mutation(async ({ ctx, input }) => { update: permissionRequiredProcedure
const app = await ctx.db.query.apps.findFirst({ .requiresPermission("app-modify-all")
where: eq(apps.id, input.id), .input(validation.app.edit)
}); .mutation(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
if (!app) { where: eq(apps.id, input.id),
throw new TRPCError({
code: "NOT_FOUND",
message: "App not found",
}); });
}
await ctx.db if (!app) {
.update(apps) throw new TRPCError({
.set({ code: "NOT_FOUND",
name: input.name, message: "App not found",
description: input.description, });
iconUrl: input.iconUrl, }
href: input.href,
}) await ctx.db
.where(eq(apps.id, input.id)); .update(apps)
}), .set({
delete: protectedProcedure 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()) .output(z.void())
.meta({ openapi: { method: "DELETE", path: "/api/apps/{id}", tags: ["apps"], protect: true } }) .meta({ openapi: { method: "DELETE", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
.input(validation.common.byId) .input(validation.common.byId)

View File

@@ -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<WidgetComponentProps<"app">["options"]>(item.options);
return [parsedOptions.appId];
} else if (item.kind === "bookmarks") {
const parsedOptions = SuperJSON.parse<WidgetComponentProps<"bookmarks">["options"]>(item.options);
return parsedOptions.items;
}
throw new Error("Failed to get app ids from board. Invalid item kind: 'test'");
});
};

View File

@@ -7,7 +7,7 @@ import { loggingChannel } from "@homarr/redis";
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc"; import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
export const logRouter = createTRPCRouter({ export const logRouter = createTRPCRouter({
subscribe: permissionRequiredProcedure.requiresPermission("admin").subscription(() => { subscribe: permissionRequiredProcedure.requiresPermission("other-view-logs").subscription(() => {
return observable<LoggerMessage>((emit) => { return observable<LoggerMessage>((emit) => {
const unsubscribe = loggingChannel.subscribe((data) => { const unsubscribe = loggingChannel.subscribe((data) => {
emit.next(data); emit.next(data);

View File

@@ -4,7 +4,7 @@ import { and, createId, desc, eq, like } from "@homarr/db";
import { medias } from "@homarr/db/schema/sqlite"; import { medias } from "@homarr/db/schema/sqlite";
import { validation, z } from "@homarr/validation"; import { validation, z } from "@homarr/validation";
import { createTRPCRouter, protectedProcedure } from "../../trpc"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
export const mediaRouter = createTRPCRouter({ export const mediaRouter = createTRPCRouter({
getPaginated: protectedProcedure getPaginated: protectedProcedure
@@ -14,7 +14,7 @@ export const mediaRouter = createTRPCRouter({
), ),
) )
.query(async ({ ctx, input }) => { .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( const where = and(
input.search.length >= 1 ? like(medias.name, `%${input.search}%`) : undefined, input.search.length >= 1 ? like(medias.name, `%${input.search}%`) : undefined,
@@ -46,20 +46,23 @@ export const mediaRouter = createTRPCRouter({
totalCount, totalCount,
}; };
}), }),
uploadMedia: protectedProcedure.input(validation.media.uploadMedia).mutation(async ({ ctx, input }) => { uploadMedia: permissionRequiredProcedure
const content = Buffer.from(await input.file.arrayBuffer()); .requiresPermission("media-upload")
const id = createId(); .input(validation.media.uploadMedia)
await ctx.db.insert(medias).values({ .mutation(async ({ ctx, input }) => {
id, const content = Buffer.from(await input.file.arrayBuffer());
creatorId: ctx.session.user.id, const id = createId();
content, await ctx.db.insert(medias).values({
size: input.file.size, id,
contentType: input.file.type, creatorId: ctx.session.user.id,
name: input.file.name, 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 }) => { deleteMedia: protectedProcedure.input(validation.common.byId).mutation(async ({ ctx, input }) => {
const dbMedia = await ctx.db.query.medias.findFirst({ const dbMedia = await ctx.db.query.medias.findFirst({
where: eq(medias.id, input.id), 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 // Only allow users with media-full-all permission and the creator of the media to delete it
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== dbMedia.creatorId) { if (!ctx.session.user.permissions.includes("media-full-all") && ctx.session.user.id !== dbMedia.creatorId) {
throw new TRPCError({ throw new TRPCError({
code: "FORBIDDEN", code: "FORBIDDEN",
message: "You don't have permission to delete this media", message: "You don't have permission to delete this media",

View File

@@ -4,7 +4,7 @@ import { createId, eq, like, sql } from "@homarr/db";
import { searchEngines } from "@homarr/db/schema/sqlite"; import { searchEngines } from "@homarr/db/schema/sqlite";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
import { createTRPCRouter, protectedProcedure } from "../../trpc"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
export const searchEngineRouter = createTRPCRouter({ export const searchEngineRouter = createTRPCRouter({
getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => { getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => {
@@ -59,43 +59,52 @@ export const searchEngineRouter = createTRPCRouter({
limit: input.limit, limit: input.limit,
}); });
}), }),
create: protectedProcedure.input(validation.searchEngine.manage).mutation(async ({ ctx, input }) => { create: permissionRequiredProcedure
await ctx.db.insert(searchEngines).values({ .requiresPermission("search-engine-create")
id: createId(), .input(validation.searchEngine.manage)
name: input.name, .mutation(async ({ ctx, input }) => {
short: input.short.toLowerCase(), await ctx.db.insert(searchEngines).values({
iconUrl: input.iconUrl, id: createId(),
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({
name: input.name, name: input.name,
short: input.short.toLowerCase(),
iconUrl: input.iconUrl, iconUrl: input.iconUrl,
urlTemplate: "urlTemplate" in input ? input.urlTemplate : null, urlTemplate: "urlTemplate" in input ? input.urlTemplate : null,
description: input.description, description: input.description,
integrationId: "integrationId" in input ? input.integrationId : null,
type: input.type, type: input.type,
}) integrationId: "integrationId" in input ? input.integrationId : null,
.where(eq(searchEngines.id, input.id)); });
}), }),
delete: protectedProcedure.input(validation.common.byId).mutation(async ({ ctx, input }) => { update: permissionRequiredProcedure
await ctx.db.delete(searchEngines).where(eq(searchEngines.id, input.id)); .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));
}),
}); });

View File

@@ -5,23 +5,26 @@ import type { Session } from "@homarr/auth";
import { createId } from "@homarr/db"; import { createId } from "@homarr/db";
import { apps } from "@homarr/db/schema/sqlite"; import { apps } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test"; import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
import { appRouter } from "../app"; import { appRouter } from "../app";
import * as appAccessControl from "../app/app-access-control";
// Mock the auth module to return an empty session // Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
const defaultSession: Session = { const createDefaultSession = (permissions: GroupPermissionKey[] = []): Session => ({
user: { id: createId(), permissions: [], colorScheme: "light" }, user: { id: createId(), permissions, colorScheme: "light" },
expires: new Date().toISOString(), expires: new Date().toISOString(),
}; });
describe("all should return all apps", () => { 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 db = createDb();
const caller = appRouter.createCaller({ const caller = appRouter.createCaller({
db, db,
session: null, session: createDefaultSession(),
}); });
await db.insert(apps).values([ await db.insert(apps).values([
@@ -48,15 +51,30 @@ describe("all should return all apps", () => {
expect(result[1]!.href).toBeNull(); expect(result[1]!.href).toBeNull();
expect(result[1]!.description).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", () => { 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 db = createDb();
const caller = appRouter.createCaller({ const caller = appRouter.createCaller({
db, db,
session: null, session: null,
}); });
vi.spyOn(appAccessControl, "canUserSeeAppAsync").mockReturnValue(Promise.resolve(true));
await db.insert(apps).values([ 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" }); const result = await caller.byId({ id: "2" });
// Assert
expect(result.name).toBe("Mantine"); 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 () => { test("should throw an error if the app does not exist", async () => {
// Arrange
const db = createDb(); const db = createDb();
const caller = appRouter.createCaller({ const caller = appRouter.createCaller({
db, db,
session: null, session: null,
}); });
// Act
const actAsync = async () => await caller.byId({ id: "2" }); const actAsync = async () => await caller.byId({ id: "2" });
// Assert
await expect(actAsync()).rejects.toThrow("App not found"); await expect(actAsync()).rejects.toThrow("App not found");
}); });
}); });
describe("create should create a new app with all arguments", () => { describe("create should create a new app with all arguments", () => {
test("should create a new app", async () => { test("should create a new app", async () => {
// Arrange
const db = createDb(); const db = createDb();
const caller = appRouter.createCaller({ const caller = appRouter.createCaller({
db, db,
session: defaultSession, session: createDefaultSession(["app-create"]),
}); });
const input = { const input = {
name: "Mantine", name: "Mantine",
@@ -103,8 +154,10 @@ describe("create should create a new app with all arguments", () => {
href: "https://mantine.dev", href: "https://mantine.dev",
}; };
// Act
await caller.create(input); await caller.create(input);
// Assert
const dbApp = await db.query.apps.findFirst(); const dbApp = await db.query.apps.findFirst();
expect(dbApp).toBeDefined(); expect(dbApp).toBeDefined();
expect(dbApp!.name).toBe(input.name); 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 () => { test("should create a new app only with required arguments", async () => {
// Arrange
const db = createDb(); const db = createDb();
const caller = appRouter.createCaller({ const caller = appRouter.createCaller({
db, db,
session: defaultSession, session: createDefaultSession(["app-create"]),
}); });
const input = { const input = {
name: "Mantine", name: "Mantine",
@@ -126,8 +180,10 @@ describe("create should create a new app with all arguments", () => {
href: null, href: null,
}; };
// Act
await caller.create(input); await caller.create(input);
// Assert
const dbApp = await db.query.apps.findFirst(); const dbApp = await db.query.apps.findFirst();
expect(dbApp).toBeDefined(); expect(dbApp).toBeDefined();
expect(dbApp!.name).toBe(input.name); 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", () => { describe("update should update an app", () => {
test("should update an app", async () => { test("should update an app", async () => {
// Arrange
const db = createDb(); const db = createDb();
const caller = appRouter.createCaller({ const caller = appRouter.createCaller({
db, db,
session: defaultSession, session: createDefaultSession(["app-modify-all"]),
}); });
const appId = createId(); const appId = createId();
@@ -162,8 +219,10 @@ describe("update should update an app", () => {
href: "https://mantine.dev", href: "https://mantine.dev",
}; };
// Act
await caller.update(input); await caller.update(input);
// Assert
const dbApp = await db.query.apps.findFirst(); const dbApp = await db.query.apps.findFirst();
expect(dbApp).toBeDefined(); 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 () => { test("should throw an error if the app does not exist", async () => {
// Arrange
const db = createDb(); const db = createDb();
const caller = appRouter.createCaller({ const caller = appRouter.createCaller({
db, db,
session: defaultSession, session: createDefaultSession(["app-modify-all"]),
}); });
// Act
const actAsync = async () => const actAsync = async () =>
await caller.update({ await caller.update({
id: createId(), id: createId(),
@@ -188,16 +249,19 @@ describe("update should update an app", () => {
description: null, description: null,
href: null, href: null,
}); });
// Assert
await expect(actAsync()).rejects.toThrow("App not found"); await expect(actAsync()).rejects.toThrow("App not found");
}); });
}); });
describe("delete should delete an app", () => { describe("delete should delete an app", () => {
test("should delete an app", async () => { test("should delete an app", async () => {
// Arrange
const db = createDb(); const db = createDb();
const caller = appRouter.createCaller({ const caller = appRouter.createCaller({
db, db,
session: defaultSession, session: createDefaultSession(["app-full-all"]),
}); });
const appId = createId(); const appId = createId();
@@ -207,8 +271,10 @@ describe("delete should delete an app", () => {
iconUrl: "https://mantine.dev/favicon.svg", iconUrl: "https://mantine.dev/favicon.svg",
}); });
// Act
await caller.delete({ id: appId }); await caller.delete({ id: appId });
// Assert
const dbApp = await db.query.apps.findFirst(); const dbApp = await db.query.apps.findFirst();
expect(dbApp).toBeUndefined(); expect(dbApp).toBeUndefined();
}); });

View File

@@ -36,8 +36,13 @@ export type IntegrationPermission = (typeof integrationPermissions)[number];
* For example "board-create" is a generated key * For example "board-create" is a generated key
*/ */
export const groupPermissions = { export const groupPermissions = {
// Order is the same in the UI, inspired from order in navigation here
board: ["create", "view-all", "modify-all", "full-all"], board: ["create", "view-all", "modify-all", "full-all"],
app: ["create", "use-all", "modify-all", "full-all"],
integration: ["create", "use-all", "interact-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, admin: true,
} as const; } as const;
@@ -49,9 +54,21 @@ export const groupPermissions = {
const groupPermissionParents = { const groupPermissionParents = {
"board-modify-all": ["board-view-all"], "board-modify-all": ["board-view-all"],
"board-full-all": ["board-modify-all", "board-create"], "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-interact-all": ["integration-use-all"],
"integration-full-all": ["integration-interact-all", "integration-create"], "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<Record<GroupPermissionKey, GroupPermissionKey[]>>; } satisfies Partial<Record<GroupPermissionKey, GroupPermissionKey[]>>;
export const getPermissionsWithParents = (permissions: GroupPermissionKey[]): GroupPermissionKey[] => { export const getPermissionsWithParents = (permissions: GroupPermissionKey[]): GroupPermissionKey[] => {

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import type { Dispatch, SetStateAction } from "react";
import { useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { ActionIcon, Center, Group, Kbd } from "@mantine/core"; import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
import { Spotlight as MantineSpotlight } from "@mantine/spotlight"; import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
@@ -9,23 +10,42 @@ import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { inferSearchInteractionOptions } from "../lib/interaction"; import type { inferSearchInteractionOptions } from "../lib/interaction";
import type { SearchMode } from "../lib/mode";
import { searchModes } from "../modes"; import { searchModes } from "../modes";
import { selectAction, spotlightStore } from "../spotlight-store"; import { selectAction, spotlightStore } from "../spotlight-store";
import { SpotlightChildrenActions } from "./actions/children-actions"; import { SpotlightChildrenActions } from "./actions/children-actions";
import { SpotlightActionGroups } from "./actions/groups/action-group"; import { SpotlightActionGroups } from "./actions/groups/action-group";
type SearchModeKey = keyof TranslationObject["search"]["mode"];
export const Spotlight = () => { export const Spotlight = () => {
const [query, setQuery] = useState(""); const searchModeState = useState<SearchModeKey>("help");
const [mode, setMode] = useState<keyof TranslationObject["search"]["mode"]>("help"); const mode = searchModeState[0];
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
const t = useI18n();
const inputRef = useRef<HTMLInputElement>(null);
const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]); const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]);
if (!activeMode) { if (!activeMode) {
return null; return null;
} }
// We use the "key" below to prevent the 'Different amounts of hooks' error
return <SpotlightWithActiveMode key={mode} modeState={searchModeState} activeMode={activeMode} />;
};
interface SpotlightWithActiveModeProps {
modeState: [SearchModeKey, Dispatch<SetStateAction<SearchModeKey>>];
activeMode: SearchMode;
}
const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveModeProps) => {
const [query, setQuery] = useState("");
const [mode, setMode] = modeState;
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
const t = useI18n();
const inputRef = useRef<HTMLInputElement>(null);
// Works as always the same amount of hooks are executed
const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups;
const groups = useGroups();
return ( return (
<MantineSpotlight.Root <MantineSpotlight.Root
yOffset={8} yOffset={8}
@@ -115,7 +135,7 @@ export const Spotlight = () => {
}); });
}} }}
query={query} query={query}
groups={activeMode.groups} groups={groups}
/> />
)} )}
</MantineSpotlight.ActionsList> </MantineSpotlight.ActionsList>

View File

@@ -2,8 +2,14 @@ import type { TranslationObject } from "@homarr/translation";
import type { SearchGroup } from "./group"; import type { SearchGroup } from "./group";
export interface SearchMode { export type SearchMode = {
modeKey: keyof TranslationObject["search"]["mode"]; modeKey: keyof TranslationObject["search"]["mode"];
character: string; character: string;
groups: SearchGroup[]; } & (
} | {
groups: SearchGroup[];
}
| {
useGroups: () => SearchGroup[];
}
);

View File

@@ -1,3 +1,6 @@
import { useSession } from "@homarr/auth/client";
import type { SearchGroup } from "../../lib/group";
import type { SearchMode } from "../../lib/mode"; import type { SearchMode } from "../../lib/mode";
import { appsSearchGroup } from "./apps-search-group"; import { appsSearchGroup } from "./apps-search-group";
import { boardsSearchGroup } from "./boards-search-group"; import { boardsSearchGroup } from "./boards-search-group";
@@ -6,5 +9,14 @@ import { integrationsSearchGroup } from "./integrations-search-group";
export const appIntegrationBoardMode = { export const appIntegrationBoardMode = {
modeKey: "appIntegrationBoard", modeKey: "appIntegrationBoard",
character: "#", 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; } satisfies SearchMode;

View File

@@ -1,11 +1,11 @@
import { Group, Text, useMantineColorScheme } from "@mantine/core"; import { Group, Text, useMantineColorScheme } from "@mantine/core";
import { import {
IconBox,
IconCategoryPlus, IconCategoryPlus,
IconFileImport, IconFileImport,
IconLanguage, IconLanguage,
IconMailForward, IconMailForward,
IconMoon, IconMoon,
IconPackage,
IconPlug, IconPlug,
IconSun, IconSun,
IconUserPlus, IconUserPlus,
@@ -113,9 +113,10 @@ export const commandMode = {
}, },
{ {
commandKey: "newApp", commandKey: "newApp",
icon: IconPackage, icon: IconBox,
name: tOption("newApp.label"), name: tOption("newApp.label"),
useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })), useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })),
hidden: !session?.user.permissions.includes("app-create"),
}, },
{ {
commandKey: "newIntegration", commandKey: "newIntegration",

View File

@@ -1,6 +1,7 @@
import { Group, Kbd, Text } from "@mantine/core"; import { Group, Kbd, Text } from "@mantine/core";
import { IconBook2, IconBrandDiscord, IconBrandGithub } from "@tabler/icons-react"; import { IconBook2, IconBrandDiscord, IconBrandGithub } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { createDocumentationLink } from "@homarr/definitions"; import { createDocumentationLink } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
@@ -18,58 +19,67 @@ const searchModesWithoutHelp = [userGroupMode, appIntegrationBoardMode, external
const helpMode = { const helpMode = {
modeKey: "help", modeKey: "help",
character: "?", character: "?",
groups: [ useGroups() {
createGroup({ const { data: session } = useSession();
keyPath: "character", const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, commandMode, pageMode];
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}`);
return ( if (session?.user.permissions.includes("admin")) {
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between"> visibleSearchModes.unshift(userGroupMode);
<Text>{t("help")}</Text> }
<Kbd size="sm">{character}</Kbd>
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 (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between">
<Text>{t("help")}</Text>
<Kbd size="sm">{character}</Kbd>
</Group>
);
},
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) => (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center">
<props.icon />
<Text>{props.label}</Text>
</Group> </Group>
); ),
}, filter: () => true,
filter: () => true, useInteraction: interaction.link(({ href }) => ({ href, newTab: 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) => (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center">
<props.icon />
<Text>{props.label}</Text>
</Group>
),
filter: () => true,
useInteraction: interaction.link(({ href }) => ({ href, newTab: true })),
}),
],
} satisfies SearchMode; } satisfies SearchMode;
export const searchModes = [...searchModesWithoutHelp, helpMode] as const; export const searchModes = [...searchModesWithoutHelp, helpMode] as const;

View File

@@ -130,7 +130,7 @@ export const pagesSearchGroup = createGroup<{
icon: IconLogs, icon: IconLogs,
path: "/manage/tools/logs", path: "/manage/tools/logs",
name: t("manageLog.label"), name: t("manageLog.label"),
hidden: !session?.user.permissions.includes("admin"), hidden: !session?.user.permissions.includes("other-view-logs"),
}, },
{ {
icon: IconReport, icon: IconReport,

View File

@@ -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": { "board": {
"title": "Boards", "title": "Boards",
"item": { "item": {
@@ -237,6 +258,49 @@
"description": "Allow members to manage, use and interact with any integration" "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": { "memberNotice": {