fix: permissions not restricted for certain management pages / actions (#1219)

* fix: restrict parts of manage navigation to admins

* fix: restrict stats cards on manage home page

* fix: restrict access to amount of certain stats for manage home

* fix: restrict visibility of board create button

* fix: restrict access to integration pages

* fix: restrict access to tools pages for admins

* fix: restrict access to user and group pages

* test: adjust tests to match permission changes for routes

* fix: remove certain pages from spotlight without admin

* fix: app management not restricted
This commit is contained in:
Meier Lukas
2024-10-05 17:03:32 +02:00
committed by GitHub
parent 770768eb21
commit 1421ccc917
28 changed files with 756 additions and 322 deletions

View File

@@ -19,6 +19,7 @@ import { IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/i
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { UserAvatar } from "@homarr/ui";
@@ -30,8 +31,9 @@ import { CreateBoardButton } from "./_components/create-board-button";
export default async function ManageBoardsPage() {
const t = await getScopedI18n("management.page.board");
const session = await auth();
const boards = await api.board.getAllBoards();
const canCreateBoards = session?.user.permissions.includes("board-create");
return (
<ManageContainer>
@@ -39,7 +41,7 @@ export default async function ManageBoardsPage() {
<Stack>
<Group justify="space-between">
<Title mb="md">{t("title")}</Title>
<CreateBoardButton />
{canCreateBoards && <CreateBoardButton />}
</Group>
<Grid mb={{ base: "xl", md: 0 }}>

View File

@@ -6,6 +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 { IntegrationAccessSettings } from "../../_components/integration-access-settings";
import { EditIntegrationForm } from "./_integration-edit-form";
@@ -16,7 +17,7 @@ interface EditIntegrationPageProps {
export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) {
const editT = await getScopedI18n("integration.page.edit");
const t = await getI18n();
const integration = await api.integration.byId({ id: params.id });
const integration = await api.integration.byId({ id: params.id }).catch(catchTrpcNotFound);
const integrationPermissions = await api.integration.getIntegrationPermissions({ id: integration.id });
return (

View File

@@ -1,6 +1,7 @@
import { notFound } from "next/navigation";
import { Container, Group, Stack, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
@@ -18,6 +19,11 @@ interface NewIntegrationPageProps {
}
export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) {
const session = await auth();
if (!session?.user.permissions.includes("integration-create")) {
notFound();
}
const result = z.enum(integrationKinds).safeParse(searchParams.kind);
if (!result.success) {
notFound();

View File

@@ -30,6 +30,7 @@ import { IconChevronDown, IconChevronUp, IconPencil } from "@tabler/icons-react"
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { objectEntries } from "@homarr/common";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions";
@@ -50,8 +51,11 @@ interface IntegrationsPageProps {
export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
const integrations = await api.integration.all();
const session = await auth();
const t = await getScopedI18n("integration");
const canCreateIntegrations = session?.user.permissions.includes("integration-create") ?? false;
return (
<ManageContainer>
<DynamicBreadcrumb />
@@ -59,23 +63,27 @@ export default async function IntegrationsPage({ searchParams }: IntegrationsPag
<Group justify="space-between" align="center">
<Title>{t("page.list.title")}</Title>
<Box>
<IntegrationSelectMenu>
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
<MenuTarget>
<Button rightSection={<IconChevronUp size={16} stroke={1.5} />}>{t("action.create")}</Button>
</MenuTarget>
</Affix>
</IntegrationSelectMenu>
</Box>
{canCreateIntegrations && (
<>
<Box>
<IntegrationSelectMenu>
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
<MenuTarget>
<Button rightSection={<IconChevronUp size={16} stroke={1.5} />}>{t("action.create")}</Button>
</MenuTarget>
</Affix>
</IntegrationSelectMenu>
</Box>
<Box visibleFrom="md">
<IntegrationSelectMenu>
<MenuTarget>
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
</MenuTarget>
</IntegrationSelectMenu>
</Box>
<Box visibleFrom="md">
<IntegrationSelectMenu>
<MenuTarget>
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
</MenuTarget>
</IntegrationSelectMenu>
</Box>
</>
)}
</Group>
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
@@ -102,6 +110,8 @@ interface IntegrationListProps {
const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps) => {
const t = await getScopedI18n("integration");
const session = await auth();
const hasFullAccess = session?.user.permissions.includes("integration-full-all") ?? false;
if (integrations.length === 0) {
return <div>{t("page.list.empty")}</div>;
@@ -151,18 +161,21 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
</TableTd>
<TableTd>
<Group justify="end">
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/integrations/edit/${integration.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
</ActionIconGroup>
{hasFullAccess ||
(integration.permissions.hasFullAccess && (
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/integrations/edit/${integration.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
</ActionIconGroup>
))}
</Group>
</TableTd>
</TableTr>
@@ -177,18 +190,21 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
<Stack gap={0}>
<Group justify="space-between" align="center" wrap="nowrap">
<Text>{integration.name}</Text>
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/integrations/edit/${integration.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
</ActionIconGroup>
{hasFullAccess ||
(integration.permissions.hasFullAccess && (
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/integrations/edit/${integration.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
</ActionIconGroup>
))}
</Group>
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
{integration.url}

View File

@@ -23,6 +23,7 @@ import {
IconUsersGroup,
} from "@tabler/icons-react";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { getScopedI18n } from "@homarr/translation/server";
@@ -33,6 +34,7 @@ import { ClientShell } from "~/components/layout/shell";
export default async function ManageLayout({ children }: PropsWithChildren) {
const t = await getScopedI18n("management.navbar");
const session = await auth();
const navigationLinks: NavigationLink[] = [
{
label: t("items.home"),
@@ -62,6 +64,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{
icon: IconUser,
label: t("items.users.label"),
hidden: !session?.user.permissions.includes("admin"),
items: [
{
label: t("items.users.items.manage"),
@@ -84,6 +87,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{
label: t("items.tools.label"),
icon: IconTool,
hidden: !session?.user.permissions.includes("admin"),
items: [
{
label: t("items.tools.items.docker"),
@@ -111,6 +115,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
label: t("items.settings"),
href: "/manage/settings",
icon: IconSettings,
hidden: !session?.user.permissions.includes("admin"),
},
{
label: t("items.help.label"),

View File

@@ -3,6 +3,7 @@ import { Card, Group, SimpleGrid, Space, Stack, Text } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { getScopedI18n } from "@homarr/translation/server";
@@ -28,6 +29,7 @@ export async function generateMetadata() {
export default async function ManagementPage() {
const statistics = await api.home.getStats();
const session = await auth();
const t = await getScopedI18n("management.page.home");
const links: LinkProps[] = [
@@ -35,38 +37,40 @@ export default async function ManagementPage() {
count: statistics.countBoards,
href: "/manage/boards",
subtitle: t("statisticLabel.boards"),
title: t("statistic.countBoards"),
title: t("statistic.board"),
},
{
count: statistics.countUsers,
href: "/manage/users",
subtitle: t("statisticLabel.authentication"),
title: t("statistic.createUser"),
title: t("statistic.user"),
hidden: !session?.user.permissions.includes("admin"),
},
{
hidden: !isProviderEnabled("credentials"),
count: statistics.countInvites,
href: "/manage/users/invites",
subtitle: t("statisticLabel.authentication"),
title: t("statistic.createInvite"),
title: t("statistic.invite"),
hidden: !isProviderEnabled("credentials") || !session?.user.permissions.includes("admin"),
},
{
count: statistics.countIntegrations,
href: "/manage/integrations",
subtitle: t("statisticLabel.resources"),
title: t("statistic.addIntegration"),
title: t("statistic.integration"),
},
{
count: statistics.countApps,
href: "/manage/apps",
subtitle: t("statisticLabel.resources"),
title: t("statistic.addApp"),
title: t("statistic.app"),
},
{
count: statistics.countGroups,
href: "/manage/users/groups",
subtitle: t("statisticLabel.authorization"),
title: t("statistic.manageRoles"),
title: t("statistic.group"),
hidden: !session?.user.permissions.includes("admin"),
},
];
return (

View File

@@ -1,8 +1,10 @@
import { headers } from "next/headers";
import { notFound } from "next/navigation";
import { Stack, Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
import { openApiDocument } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { getScopedI18n } from "@homarr/translation/server";
@@ -11,6 +13,11 @@ import { createMetaTitle } from "~/metadata";
import { ApiKeysManagement } from "./components/api-keys";
export async function generateMetadata() {
const session = await auth();
if (!session?.user || !session.user.permissions.includes("admin")) {
return {};
}
const t = await getScopedI18n("management");
return {
@@ -19,6 +26,10 @@ export async function generateMetadata() {
}
export default async function ApiPage() {
const session = await auth();
if (!session?.user || !session.user.permissions.includes("admin")) {
notFound();
}
const document = openApiDocument(extractBaseUrlFromHeaders(headers()));
const apiKeys = await api.apiKeys.getAll();
const t = await getScopedI18n("management.page.tool.api.tab");

View File

@@ -4,12 +4,20 @@ import { getScopedI18n } from "@homarr/translation/server";
import "@xterm/xterm/css/xterm.css";
import { notFound } from "next/navigation";
import { auth } from "@homarr/auth/next";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
import { createMetaTitle } from "~/metadata";
import { ClientSideTerminalComponent } from "./client";
export async function generateMetadata() {
const session = await auth();
if (!session?.user || !session.user.permissions.includes("admin")) {
return {};
}
const t = await getScopedI18n("management");
return {
@@ -17,7 +25,12 @@ export async function generateMetadata() {
};
}
export default function LogsManagementPage() {
export default async function LogsManagementPage() {
const session = await auth();
if (!session?.user || !session.user.permissions.includes("admin")) {
notFound();
}
return (
<>
<DynamicBreadcrumb />

View File

@@ -1,12 +1,18 @@
import { notFound } from "next/navigation";
import { Box, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { JobsList } from "./_components/jobs-list";
export async function generateMetadata() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
return {};
}
const t = await getScopedI18n("management");
return {
@@ -15,6 +21,11 @@ export async function generateMetadata() {
}
export default async function TasksPage() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
notFound();
}
const jobs = await api.cronJobs.getJobs();
return (
<Box>

View File

@@ -1,5 +1,6 @@
import { notFound } from "next/navigation";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { getScopedI18n } from "@homarr/translation/server";
@@ -10,6 +11,11 @@ import { UserCreateStepperComponent } from "./_components/create-user-stepper";
export async function generateMetadata() {
if (!isProviderEnabled("credentials")) return {};
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
return {};
}
const t = await getScopedI18n("management.page.user.create");
return {
@@ -17,11 +23,16 @@ export async function generateMetadata() {
};
}
export default function CreateUserPage() {
export default async function CreateUserPage() {
if (!isProviderEnabled("credentials")) {
notFound();
}
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
return notFound();
}
return (
<>
<DynamicBreadcrumb />

View File

@@ -1,8 +1,10 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui";
import { z } from "@homarr/validation";
@@ -26,6 +28,12 @@ interface GroupsListPageProps {
}
export default async function GroupsListPage(props: GroupsListPageProps) {
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
return notFound();
}
const t = await getI18n();
const searchParams = searchParamsSchema.parse(props.searchParams);
const { items: groups, totalCount } = await api.group.getPaginated(searchParams);

View File

@@ -1,6 +1,7 @@
import { notFound } from "next/navigation";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
@@ -11,6 +12,11 @@ export default async function InvitesOverviewPage() {
notFound();
}
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
return notFound();
}
const initialInvites = await api.invite.getAll();
return (
<>

View File

@@ -1,4 +1,7 @@
import { notFound } from "next/navigation";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { getScopedI18n } from "@homarr/translation/server";
@@ -7,6 +10,10 @@ import { createMetaTitle } from "~/metadata";
import { UserListComponent } from "./_components/user-list";
export async function generateMetadata() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
return {};
}
const t = await getScopedI18n("management.page.user.list");
return {
@@ -15,6 +22,11 @@ export async function generateMetadata() {
}
export default async function UsersPage() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
return notFound();
}
const userList = await api.user.getAll();
const credentialsProviderEnabled = isProviderEnabled("credentials");