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 type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { UserAvatar } from "@homarr/ui"; import { UserAvatar } from "@homarr/ui";
@@ -30,8 +31,9 @@ import { CreateBoardButton } from "./_components/create-board-button";
export default async function ManageBoardsPage() { export default async function ManageBoardsPage() {
const t = await getScopedI18n("management.page.board"); const t = await getScopedI18n("management.page.board");
const session = await auth();
const boards = await api.board.getAllBoards(); const boards = await api.board.getAllBoards();
const canCreateBoards = session?.user.permissions.includes("board-create");
return ( return (
<ManageContainer> <ManageContainer>
@@ -39,7 +41,7 @@ export default async function ManageBoardsPage() {
<Stack> <Stack>
<Group justify="space-between"> <Group justify="space-between">
<Title mb="md">{t("title")}</Title> <Title mb="md">{t("title")}</Title>
<CreateBoardButton /> {canCreateBoards && <CreateBoardButton />}
</Group> </Group>
<Grid mb={{ base: "xl", md: 0 }}> <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 { 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 { 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";
@@ -16,7 +17,7 @@ interface EditIntegrationPageProps {
export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) { export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) {
const editT = await getScopedI18n("integration.page.edit"); const editT = await getScopedI18n("integration.page.edit");
const t = await getI18n(); 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 }); const integrationPermissions = await api.integration.getIntegrationPermissions({ id: integration.id });
return ( return (

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ import {
IconUsersGroup, IconUsersGroup,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server"; import { isProviderEnabled } from "@homarr/auth/server";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
@@ -33,6 +34,7 @@ import { ClientShell } from "~/components/layout/shell";
export default async function ManageLayout({ children }: PropsWithChildren) { export default async function ManageLayout({ children }: PropsWithChildren) {
const t = await getScopedI18n("management.navbar"); const t = await getScopedI18n("management.navbar");
const session = await auth();
const navigationLinks: NavigationLink[] = [ const navigationLinks: NavigationLink[] = [
{ {
label: t("items.home"), label: t("items.home"),
@@ -62,6 +64,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{ {
icon: IconUser, icon: IconUser,
label: t("items.users.label"), label: t("items.users.label"),
hidden: !session?.user.permissions.includes("admin"),
items: [ items: [
{ {
label: t("items.users.items.manage"), label: t("items.users.items.manage"),
@@ -84,6 +87,7 @@ 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"),
items: [ items: [
{ {
label: t("items.tools.items.docker"), label: t("items.tools.items.docker"),
@@ -111,6 +115,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
label: t("items.settings"), label: t("items.settings"),
href: "/manage/settings", href: "/manage/settings",
icon: IconSettings, icon: IconSettings,
hidden: !session?.user.permissions.includes("admin"),
}, },
{ {
label: t("items.help.label"), 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 { IconArrowRight } from "@tabler/icons-react";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server"; import { isProviderEnabled } from "@homarr/auth/server";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
@@ -28,6 +29,7 @@ export async function generateMetadata() {
export default async function ManagementPage() { export default async function ManagementPage() {
const statistics = await api.home.getStats(); const statistics = await api.home.getStats();
const session = await auth();
const t = await getScopedI18n("management.page.home"); const t = await getScopedI18n("management.page.home");
const links: LinkProps[] = [ const links: LinkProps[] = [
@@ -35,38 +37,40 @@ export default async function ManagementPage() {
count: statistics.countBoards, count: statistics.countBoards,
href: "/manage/boards", href: "/manage/boards",
subtitle: t("statisticLabel.boards"), subtitle: t("statisticLabel.boards"),
title: t("statistic.countBoards"), title: t("statistic.board"),
}, },
{ {
count: statistics.countUsers, count: statistics.countUsers,
href: "/manage/users", href: "/manage/users",
subtitle: t("statisticLabel.authentication"), subtitle: t("statisticLabel.authentication"),
title: t("statistic.createUser"), title: t("statistic.user"),
hidden: !session?.user.permissions.includes("admin"),
}, },
{ {
hidden: !isProviderEnabled("credentials"),
count: statistics.countInvites, count: statistics.countInvites,
href: "/manage/users/invites", href: "/manage/users/invites",
subtitle: t("statisticLabel.authentication"), subtitle: t("statisticLabel.authentication"),
title: t("statistic.createInvite"), title: t("statistic.invite"),
hidden: !isProviderEnabled("credentials") || !session?.user.permissions.includes("admin"),
}, },
{ {
count: statistics.countIntegrations, count: statistics.countIntegrations,
href: "/manage/integrations", href: "/manage/integrations",
subtitle: t("statisticLabel.resources"), subtitle: t("statisticLabel.resources"),
title: t("statistic.addIntegration"), title: t("statistic.integration"),
}, },
{ {
count: statistics.countApps, count: statistics.countApps,
href: "/manage/apps", href: "/manage/apps",
subtitle: t("statisticLabel.resources"), subtitle: t("statisticLabel.resources"),
title: t("statistic.addApp"), title: t("statistic.app"),
}, },
{ {
count: statistics.countGroups, count: statistics.countGroups,
href: "/manage/users/groups", href: "/manage/users/groups",
subtitle: t("statisticLabel.authorization"), subtitle: t("statisticLabel.authorization"),
title: t("statistic.manageRoles"), title: t("statistic.group"),
hidden: !session?.user.permissions.includes("admin"),
}, },
]; ];
return ( return (

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,10 @@
import Link from "next/link"; 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 { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core";
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 } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui"; import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui";
import { z } from "@homarr/validation"; import { z } from "@homarr/validation";
@@ -26,6 +28,12 @@ interface GroupsListPageProps {
} }
export default async function GroupsListPage(props: 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 t = await getI18n();
const searchParams = searchParamsSchema.parse(props.searchParams); const searchParams = searchParamsSchema.parse(props.searchParams);
const { items: groups, totalCount } = await api.group.getPaginated(searchParams); const { items: groups, totalCount } = await api.group.getPaginated(searchParams);

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import { asc, createId, eq, 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, publicProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
export const appRouter = createTRPCRouter({ export const appRouter = createTRPCRouter({
all: publicProcedure all: publicProcedure
@@ -102,7 +102,7 @@ export const appRouter = createTRPCRouter({
return app; return app;
}), }),
create: publicProcedure create: protectedProcedure
.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 } })
@@ -115,7 +115,7 @@ export const appRouter = createTRPCRouter({
href: input.href, href: input.href,
}); });
}), }),
update: publicProcedure.input(validation.app.edit).mutation(async ({ ctx, input }) => { update: protectedProcedure.input(validation.app.edit).mutation(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({ const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id), where: eq(apps.id, input.id),
}); });
@@ -137,7 +137,7 @@ export const appRouter = createTRPCRouter({
}) })
.where(eq(apps.id, input.id)); .where(eq(apps.id, input.id));
}), }),
delete: publicProcedure delete: protectedProcedure
.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

@@ -6,20 +6,23 @@ import { createCronJobStatusChannel } from "@homarr/cron-job-status";
import { jobGroup } from "@homarr/cron-jobs"; import { jobGroup } from "@homarr/cron-jobs";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
import { createTRPCRouter, publicProcedure } from "../trpc"; import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
export const cronJobsRouter = createTRPCRouter({ export const cronJobsRouter = createTRPCRouter({
triggerJob: publicProcedure.input(jobNameSchema).mutation(async ({ input }) => { triggerJob: permissionRequiredProcedure
await triggerCronJobAsync(input); .requiresPermission("admin")
}), .input(jobNameSchema)
getJobs: publicProcedure.query(() => { .mutation(async ({ input }) => {
await triggerCronJobAsync(input);
}),
getJobs: permissionRequiredProcedure.requiresPermission("admin").query(() => {
const registry = jobGroup.getJobRegistry(); const registry = jobGroup.getJobRegistry();
return [...registry.values()].map((job) => ({ return [...registry.values()].map((job) => ({
name: job.name, name: job.name,
expression: job.cronExpression, expression: job.cronExpression,
})); }));
}), }),
subscribeToStatusUpdates: publicProcedure.subscription(() => { subscribeToStatusUpdates: permissionRequiredProcedure.requiresPermission("admin").subscription(() => {
return observable<TaskStatus>((emit) => { return observable<TaskStatus>((emit) => {
const unsubscribes: (() => void)[] = []; const unsubscribes: (() => void)[] = [];

View File

@@ -5,84 +5,92 @@ import { and, createId, eq, like, not, sql } from "@homarr/db";
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema/sqlite"; import { groupMembers, groupPermissions, groups } 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 } from "../trpc";
import { throwIfCredentialsDisabled } from "./invite/checks";
export const groupRouter = createTRPCRouter({ export const groupRouter = createTRPCRouter({
getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => { getPaginated: permissionRequiredProcedure
const whereQuery = input.search ? like(groups.name, `%${input.search.trim()}%`) : undefined; .requiresPermission("admin")
const groupCount = await ctx.db .input(validation.common.paginated)
.select({ .query(async ({ input, ctx }) => {
count: sql<number>`count(*)`, const whereQuery = input.search ? like(groups.name, `%${input.search.trim()}%`) : undefined;
}) const groupCount = await ctx.db
.from(groups) .select({
.where(whereQuery); count: sql<number>`count(*)`,
})
.from(groups)
.where(whereQuery);
const dbGroups = await ctx.db.query.groups.findMany({ const dbGroups = await ctx.db.query.groups.findMany({
with: { with: {
members: { members: {
with: { with: {
user: { user: {
columns: { columns: {
id: true, id: true,
name: true, name: true,
email: true, email: true,
image: true, image: true,
},
}, },
}, },
}, },
}, },
}, limit: input.pageSize,
limit: input.pageSize, offset: (input.page - 1) * input.pageSize,
offset: (input.page - 1) * input.pageSize, where: whereQuery,
where: whereQuery, });
});
return { return {
items: dbGroups.map((group) => ({ items: dbGroups.map((group) => ({
...group,
members: group.members.map((member) => member.user),
})),
totalCount: groupCount[0]?.count ?? 0,
};
}),
getById: permissionRequiredProcedure
.requiresPermission("admin")
.input(validation.common.byId)
.query(async ({ input, ctx }) => {
const group = await ctx.db.query.groups.findFirst({
where: eq(groups.id, input.id),
with: {
members: {
with: {
user: {
columns: {
id: true,
name: true,
email: true,
image: true,
provider: true,
},
},
},
},
permissions: {
columns: {
permission: true,
},
},
},
});
if (!group) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Group not found",
});
}
return {
...group, ...group,
members: group.members.map((member) => member.user), members: group.members.map((member) => member.user),
})), permissions: group.permissions.map((permission) => permission.permission),
totalCount: groupCount[0]?.count ?? 0, };
}; }),
}), // Is protected because also used in board access / integration access forms
getById: protectedProcedure.input(validation.common.byId).query(async ({ input, ctx }) => {
const group = await ctx.db.query.groups.findFirst({
where: eq(groups.id, input.id),
with: {
members: {
with: {
user: {
columns: {
id: true,
name: true,
email: true,
image: true,
provider: true,
},
},
},
},
permissions: {
columns: {
permission: true,
},
},
},
});
if (!group) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Group not found",
});
}
return {
...group,
members: group.members.map((member) => member.user),
permissions: group.permissions.map((permission) => permission.permission),
};
}),
selectable: protectedProcedure.query(async ({ ctx }) => { selectable: protectedProcedure.query(async ({ ctx }) => {
return await ctx.db.query.groups.findMany({ return await ctx.db.query.groups.findMany({
columns: { columns: {
@@ -91,7 +99,8 @@ export const groupRouter = createTRPCRouter({
}, },
}); });
}), }),
search: publicProcedure search: permissionRequiredProcedure
.requiresPermission("admin")
.input( .input(
z.object({ z.object({
query: z.string(), query: z.string(),
@@ -108,85 +117,108 @@ export const groupRouter = createTRPCRouter({
limit: input.limit, limit: input.limit,
}); });
}), }),
createGroup: protectedProcedure.input(validation.group.create).mutation(async ({ input, ctx }) => { createGroup: permissionRequiredProcedure
const normalizedName = normalizeName(input.name); .requiresPermission("admin")
await checkSimilarNameAndThrowAsync(ctx.db, normalizedName); .input(validation.group.create)
.mutation(async ({ input, ctx }) => {
const normalizedName = normalizeName(input.name);
await checkSimilarNameAndThrowAsync(ctx.db, normalizedName);
const id = createId(); const id = createId();
await ctx.db.insert(groups).values({ await ctx.db.insert(groups).values({
id, id,
name: normalizedName,
ownerId: ctx.session.user.id,
});
return id;
}),
updateGroup: protectedProcedure.input(validation.group.update).mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.id);
const normalizedName = normalizeName(input.name);
await checkSimilarNameAndThrowAsync(ctx.db, normalizedName, input.id);
await ctx.db
.update(groups)
.set({
name: normalizedName, name: normalizedName,
}) ownerId: ctx.session.user.id,
.where(eq(groups.id, input.id));
}),
savePermissions: protectedProcedure.input(validation.group.savePermissions).mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await ctx.db.delete(groupPermissions).where(eq(groupPermissions.groupId, input.groupId));
await ctx.db.insert(groupPermissions).values(
input.permissions.map((permission) => ({
groupId: input.groupId,
permission,
})),
);
}),
transferOwnership: protectedProcedure.input(validation.group.groupUser).mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await ctx.db
.update(groups)
.set({
ownerId: input.userId,
})
.where(eq(groups.id, input.groupId));
}),
deleteGroup: protectedProcedure.input(validation.common.byId).mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.id);
await ctx.db.delete(groups).where(eq(groups.id, input.id));
}),
addMember: protectedProcedure.input(validation.group.groupUser).mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
const user = await ctx.db.query.users.findFirst({
where: eq(groups.id, input.userId),
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
}); });
}
await ctx.db.insert(groupMembers).values({ return id;
groupId: input.groupId, }),
userId: input.userId, updateGroup: permissionRequiredProcedure
}); .requiresPermission("admin")
}), .input(validation.group.update)
removeMember: protectedProcedure.input(validation.group.groupUser).mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId); await throwIfGroupNotFoundAsync(ctx.db, input.id);
await ctx.db const normalizedName = normalizeName(input.name);
.delete(groupMembers) await checkSimilarNameAndThrowAsync(ctx.db, normalizedName, input.id);
.where(and(eq(groupMembers.groupId, input.groupId), eq(groupMembers.userId, input.userId)));
}), await ctx.db
.update(groups)
.set({
name: normalizedName,
})
.where(eq(groups.id, input.id));
}),
savePermissions: permissionRequiredProcedure
.requiresPermission("admin")
.input(validation.group.savePermissions)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await ctx.db.delete(groupPermissions).where(eq(groupPermissions.groupId, input.groupId));
await ctx.db.insert(groupPermissions).values(
input.permissions.map((permission) => ({
groupId: input.groupId,
permission,
})),
);
}),
transferOwnership: permissionRequiredProcedure
.requiresPermission("admin")
.input(validation.group.groupUser)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await ctx.db
.update(groups)
.set({
ownerId: input.userId,
})
.where(eq(groups.id, input.groupId));
}),
deleteGroup: permissionRequiredProcedure
.requiresPermission("admin")
.input(validation.common.byId)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.id);
await ctx.db.delete(groups).where(eq(groups.id, input.id));
}),
addMember: permissionRequiredProcedure
.requiresPermission("admin")
.input(validation.group.groupUser)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
throwIfCredentialsDisabled();
const user = await ctx.db.query.users.findFirst({
where: eq(groups.id, input.userId),
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db.insert(groupMembers).values({
groupId: input.groupId,
userId: input.userId,
});
}),
removeMember: permissionRequiredProcedure
.requiresPermission("admin")
.input(validation.group.groupUser)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
throwIfCredentialsDisabled();
await ctx.db
.delete(groupMembers)
.where(and(eq(groupMembers.groupId, input.groupId), eq(groupMembers.userId, input.userId)));
}),
}); });
const normalizeName = (name: string) => name.trim(); const normalizeName = (name: string) => name.trim();

View File

@@ -1,17 +1,32 @@
import type { AnySQLiteTable } from "drizzle-orm/sqlite-core";
import { isProviderEnabled } from "@homarr/auth/server";
import type { Database } from "@homarr/db";
import { count } from "@homarr/db"; import { count } from "@homarr/db";
import { apps, boards, groups, integrations, invites, users } from "@homarr/db/schema/sqlite"; import { apps, boards, groups, integrations, invites, users } from "@homarr/db/schema/sqlite";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, publicProcedure } from "../trpc";
export const homeRouter = createTRPCRouter({ export const homeRouter = createTRPCRouter({
getStats: protectedProcedure.query(async ({ ctx }) => { getStats: publicProcedure.query(async ({ ctx }) => {
const isAdmin = ctx.session?.user.permissions.includes("admin") ?? false;
const isCredentialsEnabled = isProviderEnabled("credentials");
return { return {
countBoards: (await ctx.db.select({ count: count() }).from(boards))[0]?.count ?? 0, countBoards: await getCountForTableAsync(ctx.db, boards, true),
countUsers: (await ctx.db.select({ count: count() }).from(users))[0]?.count ?? 0, countUsers: await getCountForTableAsync(ctx.db, users, isAdmin),
countGroups: (await ctx.db.select({ count: count() }).from(groups))[0]?.count ?? 0, countGroups: await getCountForTableAsync(ctx.db, groups, true),
countInvites: (await ctx.db.select({ count: count() }).from(invites))[0]?.count ?? 0, countInvites: await getCountForTableAsync(ctx.db, invites, isAdmin),
countIntegrations: (await ctx.db.select({ count: count() }).from(integrations))[0]?.count ?? 0, countIntegrations: await getCountForTableAsync(ctx.db, integrations, isCredentialsEnabled && isAdmin),
countApps: (await ctx.db.select({ count: count() }).from(apps))[0]?.count ?? 0, countApps: await getCountForTableAsync(ctx.db, apps, true),
}; };
}), }),
}); });
const getCountForTableAsync = async (db: Database, table: AnySQLiteTable, canView: boolean) => {
if (!canView) {
return 0;
}
return (await db.select({ count: count() }).from(table))[0]?.count ?? 0;
};

View File

@@ -4,6 +4,7 @@ import { decryptSecret, encryptSecret } from "@homarr/common/server";
import type { Database } from "@homarr/db"; import type { Database } from "@homarr/db";
import { and, asc, createId, eq, inArray, like } from "@homarr/db"; import { and, asc, createId, eq, inArray, like } from "@homarr/db";
import { import {
groupMembers,
groupPermissions, groupPermissions,
integrationGroupPermissions, integrationGroupPermissions,
integrations, integrations,
@@ -14,20 +15,48 @@ import type { IntegrationSecretKind } from "@homarr/definitions";
import { getPermissionsWithParents, integrationKinds, integrationSecretKindObject } from "@homarr/definitions"; import { getPermissionsWithParents, integrationKinds, integrationSecretKindObject } from "@homarr/definitions";
import { validation, z } from "@homarr/validation"; import { validation, z } from "@homarr/validation";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "./integration-access"; import { throwIfActionForbiddenAsync } from "./integration-access";
import { testConnectionAsync } from "./integration-test-connection"; import { testConnectionAsync } from "./integration-test-connection";
export const integrationRouter = createTRPCRouter({ export const integrationRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => { all: publicProcedure.query(async ({ ctx }) => {
const integrations = await ctx.db.query.integrations.findMany(); const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
});
const integrations = await ctx.db.query.integrations.findMany({
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
},
},
});
return integrations return integrations
.map((integration) => ({ .map((integration) => {
id: integration.id, const permissions = integration.userPermissions
name: integration.name, .map(({ permission }) => permission)
kind: integration.kind, .concat(integration.groupPermissions.map(({ permission }) => permission));
url: integration.url,
})) return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
hasFullAccess: permissions.includes("full"),
},
};
})
.sort( .sort(
(integrationA, integrationB) => (integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),

View File

@@ -4,10 +4,10 @@ import { logger } from "@homarr/log";
import type { LoggerMessage } from "@homarr/redis"; import type { LoggerMessage } from "@homarr/redis";
import { loggingChannel } from "@homarr/redis"; import { loggingChannel } from "@homarr/redis";
import { createTRPCRouter, publicProcedure } from "../trpc"; import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
export const logRouter = createTRPCRouter({ export const logRouter = createTRPCRouter({
subscribe: publicProcedure.subscription(() => { subscribe: permissionRequiredProcedure.requiresPermission("admin").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

@@ -11,6 +11,11 @@ import { appRouter } from "../app";
// 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 = {
user: { id: createId(), permissions: [], colorScheme: "light" },
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", async () => {
const db = createDb(); const db = createDb();
@@ -89,7 +94,7 @@ describe("create should create a new app with all arguments", () => {
const db = createDb(); const db = createDb();
const caller = appRouter.createCaller({ const caller = appRouter.createCaller({
db, db,
session: null, session: defaultSession,
}); });
const input = { const input = {
name: "Mantine", name: "Mantine",
@@ -112,7 +117,7 @@ describe("create should create a new app with all arguments", () => {
const db = createDb(); const db = createDb();
const caller = appRouter.createCaller({ const caller = appRouter.createCaller({
db, db,
session: null, session: defaultSession,
}); });
const input = { const input = {
name: "Mantine", name: "Mantine",
@@ -137,7 +142,7 @@ describe("update should update an app", () => {
const db = createDb(); const db = createDb();
const caller = appRouter.createCaller({ const caller = appRouter.createCaller({
db, db,
session: null, session: defaultSession,
}); });
const appId = createId(); const appId = createId();
@@ -172,7 +177,7 @@ describe("update should update an app", () => {
const db = createDb(); const db = createDb();
const caller = appRouter.createCaller({ const caller = appRouter.createCaller({
db, db,
session: null, session: defaultSession,
}); });
const actAsync = async () => const actAsync = async () =>
@@ -192,7 +197,7 @@ describe("delete should delete an app", () => {
const db = createDb(); const db = createDb();
const caller = appRouter.createCaller({ const caller = appRouter.createCaller({
db, db,
session: null, session: defaultSession,
}); });
const appId = createId(); const appId = createId();

View File

@@ -1,21 +1,26 @@
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth"; import type { Session } from "@homarr/auth";
import * as env from "@homarr/auth/env.mjs";
import { createId, eq } from "@homarr/db"; import { createId, eq } from "@homarr/db";
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema/sqlite"; import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test"; import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
import { groupRouter } from "../group"; import { groupRouter } from "../group";
const defaultOwnerId = createId(); const defaultOwnerId = createId();
const defaultSession = { const createSession = (permissions: GroupPermissionKey[]) =>
user: { ({
id: defaultOwnerId, user: {
permissions: [], id: defaultOwnerId,
colorScheme: "light", permissions,
}, colorScheme: "light",
expires: new Date().toISOString(), },
} satisfies Session; expires: new Date().toISOString(),
}) satisfies Session;
const defaultSession = createSession([]);
const adminSession = createSession(["admin"]);
// Mock the auth module to return an empty session // Mock the auth module to return an empty session
vi.mock("@homarr/auth", async () => { vi.mock("@homarr/auth", async () => {
@@ -32,7 +37,7 @@ describe("paginated should return a list of groups with pagination", () => {
async (page, expectedCount) => { async (page, expectedCount) => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession }); const caller = groupRouter.createCaller({ db, session: adminSession });
await db.insert(groups).values( await db.insert(groups).values(
[1, 2, 3, 4, 5].map((number) => ({ [1, 2, 3, 4, 5].map((number) => ({
@@ -55,7 +60,7 @@ describe("paginated should return a list of groups with pagination", () => {
test("with 5 groups in database and pagesize set to 3 it should return total count 5", async () => { test("with 5 groups in database and pagesize set to 3 it should return total count 5", async () => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession }); const caller = groupRouter.createCaller({ db, session: adminSession });
await db.insert(groups).values( await db.insert(groups).values(
[1, 2, 3, 4, 5].map((number) => ({ [1, 2, 3, 4, 5].map((number) => ({
@@ -76,7 +81,7 @@ describe("paginated should return a list of groups with pagination", () => {
test("groups should contain id, name, email and image of members", async () => { test("groups should contain id, name, email and image of members", async () => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession }); const caller = groupRouter.createCaller({ db, session: adminSession });
const user = createDummyUser(); const user = createDummyUser();
await db.insert(users).values(user); await db.insert(users).values(user);
@@ -112,7 +117,7 @@ describe("paginated should return a list of groups with pagination", () => {
async (query, expectedCount, firstKey) => { async (query, expectedCount, firstKey) => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession }); const caller = groupRouter.createCaller({ db, session: adminSession });
await db.insert(groups).values( await db.insert(groups).values(
["first", "second", "third", "forth", "fifth"].map((key, index) => ({ ["first", "second", "third", "forth", "fifth"].map((key, index) => ({
@@ -131,13 +136,25 @@ describe("paginated should return a list of groups with pagination", () => {
expect(result.items.at(0)?.name).toBe(firstKey); expect(result.items.at(0)?.name).toBe(firstKey);
}, },
); );
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
// Act
const actAsync = async () => await caller.getPaginated({});
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
}); });
describe("byId should return group by id including members and permissions", () => { describe("byId should return group by id including members and permissions", () => {
test('should return group with id "1" with members and permissions', async () => { test('should return group with id "1" with members and permissions', async () => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession }); const caller = groupRouter.createCaller({ db, session: adminSession });
const user = createDummyUser(); const user = createDummyUser();
const groupId = "1"; const groupId = "1";
@@ -180,7 +197,7 @@ describe("byId should return group by id including members and permissions", ()
test("with group id 1 and group 2 in database it should throw NOT_FOUND error", async () => { test("with group id 1 and group 2 in database it should throw NOT_FOUND error", async () => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession }); const caller = groupRouter.createCaller({ db, session: adminSession });
await db.insert(groups).values({ await db.insert(groups).values({
id: "2", id: "2",
@@ -193,13 +210,25 @@ describe("byId should return group by id including members and permissions", ()
// Assert // Assert
await expect(actAsync()).rejects.toThrow("Group not found"); await expect(actAsync()).rejects.toThrow("Group not found");
}); });
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
// Act
const actAsync = async () => await caller.getById({ id: "1" });
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
}); });
describe("create should create group in database", () => { describe("create should create group in database", () => {
test("with valid input (64 character name) and non existing name it should be successful", async () => { test("with valid input (64 character name) and non existing name it should be successful", async () => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession }); const caller = groupRouter.createCaller({ db, session: adminSession });
const name = "a".repeat(64); const name = "a".repeat(64);
await db.insert(users).values(defaultSession.user); await db.insert(users).values(defaultSession.user);
@@ -223,7 +252,7 @@ describe("create should create group in database", () => {
test("with more than 64 characters name it should fail while validation", async () => { test("with more than 64 characters name it should fail while validation", async () => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession }); const caller = groupRouter.createCaller({ db, session: adminSession });
const longName = "a".repeat(65); const longName = "a".repeat(65);
// Act // Act
@@ -244,7 +273,7 @@ describe("create should create group in database", () => {
])("with similar name %s it should fail to create %s", async (similarName, nameToCreate) => { ])("with similar name %s it should fail to create %s", async (similarName, nameToCreate) => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession }); const caller = groupRouter.createCaller({ db, session: adminSession });
await db.insert(groups).values({ await db.insert(groups).values({
id: createId(), id: createId(),
@@ -257,6 +286,18 @@ describe("create should create group in database", () => {
// Assert // Assert
await expect(actAsync()).rejects.toThrow("similar name"); await expect(actAsync()).rejects.toThrow("similar name");
}); });
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
// Act
const actAsync = async () => await caller.createGroup({ name: "test" });
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
}); });
describe("update should update name with value that is no duplicate", () => { describe("update should update name with value that is no duplicate", () => {
@@ -266,7 +307,7 @@ describe("update should update name with value that is no duplicate", () => {
])("update should update name from %s to %s normalized", async (initialValue, updateValue, expectedValue) => { ])("update should update name from %s to %s normalized", async (initialValue, updateValue, expectedValue) => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession }); const caller = groupRouter.createCaller({ db, session: adminSession });
const groupId = createId(); const groupId = createId();
await db.insert(groups).values([ await db.insert(groups).values([
@@ -299,7 +340,7 @@ describe("update should update name with value that is no duplicate", () => {
])("with similar name %s it should fail to update %s", async (updateValue, initialDuplicate) => { ])("with similar name %s it should fail to update %s", async (updateValue, initialDuplicate) => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession }); const caller = groupRouter.createCaller({ db, session: adminSession });
const groupId = createId(); const groupId = createId();
await db.insert(groups).values([ await db.insert(groups).values([
@@ -327,7 +368,7 @@ describe("update should update name with value that is no duplicate", () => {
test("with non existing id it should throw not found error", async () => { test("with non existing id it should throw not found error", async () => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession }); const caller = groupRouter.createCaller({ db, session: adminSession });
await db.insert(groups).values({ await db.insert(groups).values({
id: createId(), id: createId(),
@@ -344,13 +385,29 @@ describe("update should update name with value that is no duplicate", () => {
// Assert // Assert
await expect(act()).rejects.toThrow("Group not found"); await expect(act()).rejects.toThrow("Group not found");
}); });
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
// Act
const actAsync = async () =>
await caller.updateGroup({
id: createId(),
name: "test",
});
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
}); });
describe("savePermissions should save permissions for group", () => { describe("savePermissions should save permissions for group", () => {
test("with existing group and permissions it should save permissions", async () => { test("with existing group and permissions it should save permissions", async () => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession }); const caller = groupRouter.createCaller({ db, session: adminSession });
const groupId = createId(); const groupId = createId();
await db.insert(groups).values({ await db.insert(groups).values({
@@ -380,7 +437,7 @@ describe("savePermissions should save permissions for group", () => {
test("with non existing group it should throw not found error", async () => { test("with non existing group it should throw not found error", async () => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession }); const caller = groupRouter.createCaller({ db, session: adminSession });
await db.insert(groups).values({ await db.insert(groups).values({
id: createId(), id: createId(),
@@ -397,13 +454,29 @@ describe("savePermissions should save permissions for group", () => {
// Assert // Assert
await expect(actAsync()).rejects.toThrow("Group not found"); await expect(actAsync()).rejects.toThrow("Group not found");
}); });
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
// Act
const actAsync = async () =>
await caller.savePermissions({
groupId: createId(),
permissions: ["integration-create", "board-full-all"],
});
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
}); });
describe("transferOwnership should transfer ownership of group", () => { describe("transferOwnership should transfer ownership of group", () => {
test("with existing group and user it should transfer ownership", async () => { test("with existing group and user it should transfer ownership", async () => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession }); const caller = groupRouter.createCaller({ db, session: adminSession });
const groupId = createId(); const groupId = createId();
const newUserId = createId(); const newUserId = createId();
@@ -440,7 +513,7 @@ describe("transferOwnership should transfer ownership of group", () => {
test("with non existing group it should throw not found error", async () => { test("with non existing group it should throw not found error", async () => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession }); const caller = groupRouter.createCaller({ db, session: adminSession });
await db.insert(groups).values({ await db.insert(groups).values({
id: createId(), id: createId(),
@@ -457,13 +530,29 @@ describe("transferOwnership should transfer ownership of group", () => {
// Assert // Assert
await expect(actAsync()).rejects.toThrow("Group not found"); await expect(actAsync()).rejects.toThrow("Group not found");
}); });
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
// Act
const actAsync = async () =>
await caller.transferOwnership({
groupId: createId(),
userId: createId(),
});
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
}); });
describe("deleteGroup should delete group", () => { describe("deleteGroup should delete group", () => {
test("with existing group it should delete group", async () => { test("with existing group it should delete group", async () => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession }); const caller = groupRouter.createCaller({ db, session: adminSession });
const groupId = createId(); const groupId = createId();
await db.insert(groups).values([ await db.insert(groups).values([
@@ -492,7 +581,7 @@ describe("deleteGroup should delete group", () => {
test("with non existing group it should throw not found error", async () => { test("with non existing group it should throw not found error", async () => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession }); const caller = groupRouter.createCaller({ db, session: adminSession });
await db.insert(groups).values({ await db.insert(groups).values({
id: createId(), id: createId(),
@@ -508,13 +597,30 @@ describe("deleteGroup should delete group", () => {
// Assert // Assert
await expect(actAsync()).rejects.toThrow("Group not found"); await expect(actAsync()).rejects.toThrow("Group not found");
}); });
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
// Act
const actAsync = async () =>
await caller.deleteGroup({
id: createId(),
});
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
}); });
describe("addMember should add member to group", () => { describe("addMember should add member to group", () => {
test("with existing group and user it should add member", async () => { test("with existing group and user it should add member", async () => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession }); const spy = vi.spyOn(env, "env", "get");
spy.mockReturnValue({ AUTH_PROVIDERS: ["credentials"] } as never);
const caller = groupRouter.createCaller({ db, session: adminSession });
const groupId = createId(); const groupId = createId();
const userId = createId(); const userId = createId();
@@ -552,7 +658,7 @@ describe("addMember should add member to group", () => {
test("with non existing group it should throw not found error", async () => { test("with non existing group it should throw not found error", async () => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession }); const caller = groupRouter.createCaller({ db, session: adminSession });
await db.insert(users).values({ await db.insert(users).values({
id: createId(), id: createId(),
@@ -569,13 +675,67 @@ describe("addMember should add member to group", () => {
// Assert // Assert
await expect(actAsync()).rejects.toThrow("Group not found"); await expect(actAsync()).rejects.toThrow("Group not found");
}); });
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
// Act
const actAsync = async () =>
await caller.addMember({
groupId: createId(),
userId: createId(),
});
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
test("without credentials provider it should throw FORBIDDEN error", async () => {
// Arrange
const db = createDb();
const spy = vi.spyOn(env, "env", "get");
spy.mockReturnValue({ AUTH_PROVIDERS: ["ldap"] } as never);
const caller = groupRouter.createCaller({ db, session: adminSession });
const groupId = createId();
const userId = createId();
await db.insert(users).values([
{
id: userId,
name: "User",
},
{
id: defaultOwnerId,
name: "Creator",
},
]);
await db.insert(groups).values({
id: groupId,
name: "Group",
ownerId: defaultOwnerId,
});
// Act
const actAsync = async () =>
await caller.addMember({
groupId,
userId,
});
// Assert
await expect(actAsync()).rejects.toThrow("Credentials provider is disabled");
});
}); });
describe("removeMember should remove member from group", () => { describe("removeMember should remove member from group", () => {
test("with existing group and user it should remove member", async () => { test("with existing group and user it should remove member", async () => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession }); const spy = vi.spyOn(env, "env", "get");
spy.mockReturnValue({ AUTH_PROVIDERS: ["credentials"] } as never);
const caller = groupRouter.createCaller({ db, session: adminSession });
const groupId = createId(); const groupId = createId();
const userId = createId(); const userId = createId();
@@ -616,7 +776,7 @@ describe("removeMember should remove member from group", () => {
test("with non existing group it should throw not found error", async () => { test("with non existing group it should throw not found error", async () => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession }); const caller = groupRouter.createCaller({ db, session: adminSession });
await db.insert(users).values({ await db.insert(users).values({
id: createId(), id: createId(),
@@ -633,6 +793,62 @@ describe("removeMember should remove member from group", () => {
// Assert // Assert
await expect(actAsync()).rejects.toThrow("Group not found"); await expect(actAsync()).rejects.toThrow("Group not found");
}); });
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
// Act
const actAsync = async () =>
await caller.removeMember({
groupId: createId(),
userId: createId(),
});
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
test("without credentials provider it should throw FORBIDDEN error", async () => {
// Arrange
const db = createDb();
const spy = vi.spyOn(env, "env", "get");
spy.mockReturnValue({ AUTH_PROVIDERS: ["ldap"] } as never);
const caller = groupRouter.createCaller({ db, session: adminSession });
const groupId = createId();
const userId = createId();
await db.insert(users).values([
{
id: userId,
name: "User",
},
{
id: defaultOwnerId,
name: "Creator",
},
]);
await db.insert(groups).values({
id: groupId,
name: "Group",
ownerId: defaultOwnerId,
});
await db.insert(groupMembers).values({
groupId,
userId,
});
// Act
const actAsync = async () =>
await caller.removeMember({
groupId,
userId,
});
// Assert
await expect(actAsync()).rejects.toThrow("Credentials provider is disabled");
});
}); });
const createDummyUser = () => ({ const createDummyUser = () => ({

View File

@@ -4,9 +4,22 @@ import type { Session } from "@homarr/auth";
import { createId, eq, schema } from "@homarr/db"; import { createId, eq, schema } from "@homarr/db";
import { users } from "@homarr/db/schema/sqlite"; import { users } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test"; import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
import { userRouter } from "../user"; import { userRouter } from "../user";
const defaultOwnerId = createId();
const createSession = (permissions: GroupPermissionKey[]) =>
({
user: {
id: defaultOwnerId,
permissions,
colorScheme: "light",
},
expires: new Date().toISOString(),
}) satisfies Session;
const defaultSession = createSession([]);
// Mock the auth module to return an empty session // Mock the auth module to return an empty session
vi.mock("@homarr/auth", async () => { vi.mock("@homarr/auth", async () => {
const mod = await import("@homarr/auth/security"); const mod = await import("@homarr/auth/security");
@@ -212,14 +225,13 @@ describe("editProfile shoud update user", () => {
const db = createDb(); const db = createDb();
const caller = userRouter.createCaller({ const caller = userRouter.createCaller({
db, db,
session: null, session: defaultSession,
}); });
const id = createId();
const emailVerified = new Date(2024, 0, 5); const emailVerified = new Date(2024, 0, 5);
await db.insert(schema.users).values({ await db.insert(schema.users).values({
id, id: defaultOwnerId,
name: "TEST 1", name: "TEST 1",
email: "abc@gmail.com", email: "abc@gmail.com",
emailVerified, emailVerified,
@@ -227,17 +239,17 @@ describe("editProfile shoud update user", () => {
// act // act
await caller.editProfile({ await caller.editProfile({
id: id, id: defaultOwnerId,
name: "ABC", name: "ABC",
email: "", email: "",
}); });
// assert // assert
const user = await db.select().from(schema.users).where(eq(schema.users.id, id)); const user = await db.select().from(schema.users).where(eq(schema.users.id, defaultOwnerId));
expect(user).toHaveLength(1); expect(user).toHaveLength(1);
expect(user[0]).toStrictEqual({ expect(user[0]).toStrictEqual({
id, id: defaultOwnerId,
name: "ABC", name: "ABC",
email: "abc@gmail.com", email: "abc@gmail.com",
emailVerified, emailVerified,
@@ -255,13 +267,11 @@ describe("editProfile shoud update user", () => {
const db = createDb(); const db = createDb();
const caller = userRouter.createCaller({ const caller = userRouter.createCaller({
db, db,
session: null, session: defaultSession,
}); });
const id = createId();
await db.insert(schema.users).values({ await db.insert(schema.users).values({
id, id: defaultOwnerId,
name: "TEST 1", name: "TEST 1",
email: "abc@gmail.com", email: "abc@gmail.com",
emailVerified: new Date(2024, 0, 5), emailVerified: new Date(2024, 0, 5),
@@ -269,17 +279,17 @@ describe("editProfile shoud update user", () => {
// act // act
await caller.editProfile({ await caller.editProfile({
id, id: defaultOwnerId,
name: "ABC", name: "ABC",
email: "myNewEmail@gmail.com", email: "myNewEmail@gmail.com",
}); });
// assert // assert
const user = await db.select().from(schema.users).where(eq(schema.users.id, id)); const user = await db.select().from(schema.users).where(eq(schema.users.id, defaultOwnerId));
expect(user).toHaveLength(1); expect(user).toHaveLength(1);
expect(user[0]).toStrictEqual({ expect(user[0]).toStrictEqual({
id, id: defaultOwnerId,
name: "ABC", name: "ABC",
email: "myNewEmail@gmail.com", email: "myNewEmail@gmail.com",
emailVerified: null, emailVerified: null,
@@ -298,11 +308,9 @@ describe("delete should delete user", () => {
const db = createDb(); const db = createDb();
const caller = userRouter.createCaller({ const caller = userRouter.createCaller({
db, db,
session: null, session: defaultSession,
}); });
const userToDelete = createId();
const initialUsers = [ const initialUsers = [
{ {
id: createId(), id: createId(),
@@ -317,7 +325,7 @@ describe("delete should delete user", () => {
colorScheme: "auto" as const, colorScheme: "auto" as const,
}, },
{ {
id: userToDelete, id: defaultOwnerId,
name: "User 2", name: "User 2",
email: null, email: null,
emailVerified: null, emailVerified: null,
@@ -343,7 +351,7 @@ describe("delete should delete user", () => {
await db.insert(schema.users).values(initialUsers); await db.insert(schema.users).values(initialUsers);
await caller.delete(userToDelete); await caller.delete(defaultOwnerId);
const usersInDb = await db.select().from(schema.users); const usersInDb = await db.select().from(schema.users);
expect(usersInDb).toStrictEqual([initialUsers[0], initialUsers[2]]); expect(usersInDb).toStrictEqual([initialUsers[0], initialUsers[2]]);

View File

@@ -8,7 +8,7 @@ import type { SupportedAuthProvider } from "@homarr/definitions";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
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 { throwIfCredentialsDisabled } from "./invite/checks"; import { throwIfCredentialsDisabled } from "./invite/checks";
export const userRouter = createTRPCRouter({ export const userRouter = createTRPCRouter({
@@ -69,7 +69,8 @@ export const userRouter = createTRPCRouter({
// Delete invite as it's used // Delete invite as it's used
await ctx.db.delete(invites).where(inviteWhere); await ctx.db.delete(invites).where(inviteWhere);
}), }),
create: publicProcedure create: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ openapi: { method: "POST", path: "/api/users", tags: ["users"], protect: true } }) .meta({ openapi: { method: "POST", path: "/api/users", tags: ["users"], protect: true } })
.input(validation.user.create) .input(validation.user.create)
.output(z.void()) .output(z.void())
@@ -130,7 +131,8 @@ export const userRouter = createTRPCRouter({
}) })
.where(eq(users.id, input.userId)); .where(eq(users.id, input.userId));
}), }),
getAll: publicProcedure getAll: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.void()) .input(z.void())
.output( .output(
z.array( z.array(
@@ -155,7 +157,8 @@ export const userRouter = createTRPCRouter({
}, },
}); });
}), }),
selectable: publicProcedure.query(({ ctx }) => { // Is protected because also used in board access / integration access forms
selectable: protectedProcedure.query(({ ctx }) => {
return ctx.db.query.users.findMany({ return ctx.db.query.users.findMany({
columns: { columns: {
id: true, id: true,
@@ -164,7 +167,8 @@ export const userRouter = createTRPCRouter({
}, },
}); });
}), }),
search: publicProcedure search: permissionRequiredProcedure
.requiresPermission("admin")
.input( .input(
z.object({ z.object({
query: z.string(), query: z.string(),
@@ -187,7 +191,14 @@ export const userRouter = createTRPCRouter({
image: user.image, image: user.image,
})); }));
}), }),
getById: publicProcedure.input(z.object({ userId: z.string() })).query(async ({ input, ctx }) => { getById: protectedProcedure.input(z.object({ userId: z.string() })).query(async ({ input, ctx }) => {
// Only admins can view other users details
if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to view other users details",
});
}
const user = await ctx.db.query.users.findFirst({ const user = await ctx.db.query.users.findFirst({
columns: { columns: {
id: true, id: true,
@@ -210,7 +221,15 @@ export const userRouter = createTRPCRouter({
return user; return user;
}), }),
editProfile: publicProcedure.input(validation.user.editProfile).mutation(async ({ input, ctx }) => { editProfile: protectedProcedure.input(validation.user.editProfile).mutation(async ({ input, ctx }) => {
// Only admins can view other users details
if (ctx.session.user.id !== input.id && !ctx.session.user.permissions.includes("admin")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to edit other users details",
});
}
const user = await ctx.db.query.users.findFirst({ const user = await ctx.db.query.users.findFirst({
columns: { email: true, provider: true }, columns: { email: true, provider: true },
where: eq(users.id, input.id), where: eq(users.id, input.id),
@@ -242,7 +261,15 @@ export const userRouter = createTRPCRouter({
}) })
.where(eq(users.id, input.id)); .where(eq(users.id, input.id));
}), }),
delete: publicProcedure.input(z.string()).mutation(async ({ input, ctx }) => { delete: protectedProcedure.input(z.string()).mutation(async ({ input, ctx }) => {
// Only admins and user itself can delete a user
if (ctx.session.user.id !== input && !ctx.session.user.permissions.includes("admin")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to delete other users",
});
}
await ctx.db.delete(users).where(eq(users.id, input)); await ctx.db.delete(users).where(eq(users.id, input));
}), }),
changePassword: protectedProcedure.input(validation.user.changePasswordApi).mutation(async ({ ctx, input }) => { changePassword: protectedProcedure.input(validation.user.changePasswordApi).mutation(async ({ ctx, input }) => {
@@ -311,7 +338,7 @@ export const userRouter = createTRPCRouter({
.input(validation.user.changeHomeBoard.and(z.object({ userId: z.string() }))) .input(validation.user.changeHomeBoard.and(z.object({ userId: z.string() })))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const user = ctx.session.user; const user = ctx.session.user;
// Only admins can change other users' passwords // Only admins can change other users passwords
if (!user.permissions.includes("admin") && user.id !== input.userId) { if (!user.permissions.includes("admin") && user.id !== input.userId) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",

View File

@@ -12,15 +12,17 @@ export interface IntegrationPermissionsProps {
} }
export const constructIntegrationPermissions = (integration: IntegrationPermissionsProps, session: Session | null) => { export const constructIntegrationPermissions = (integration: IntegrationPermissionsProps, session: Session | null) => {
const permissions = integration.userPermissions
.concat(integration.groupPermissions)
.map(({ permission }) => permission);
return { return {
hasFullAccess: session?.user.permissions.includes("integration-full-all") ?? false, hasFullAccess:
(session?.user.permissions.includes("integration-full-all") ?? false) || permissions.includes("full"),
hasInteractAccess: hasInteractAccess:
integration.userPermissions.some(({ permission }) => permission === "interact") || permissions.includes("full") ||
integration.groupPermissions.some(({ permission }) => permission === "interact") || permissions.includes("interact") ||
(session?.user.permissions.includes("integration-interact-all") ?? false), (session?.user.permissions.includes("integration-interact-all") ?? false),
hasUseAccess: hasUseAccess: permissions.length >= 1 || (session?.user.permissions.includes("integration-use-all") ?? false),
integration.userPermissions.length >= 1 ||
integration.groupPermissions.length >= 1 ||
(session?.user.permissions.includes("integration-use-all") ?? false),
}; };
}; };

View File

@@ -1,6 +1,4 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import type { ResponseCookie } from "next/dist/compiled/@edge-runtime/cookies";
import type { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import type { Adapter, AdapterUser } from "@auth/core/adapters"; import type { Adapter, AdapterUser } from "@auth/core/adapters";
import type { Account } from "next-auth"; import type { Account } from "next-auth";
@@ -13,6 +11,14 @@ import * as definitions from "@homarr/definitions";
import { createSessionCallback, createSignInCallback, getCurrentUserPermissionsAsync } from "../callbacks"; import { createSessionCallback, createSignInCallback, getCurrentUserPermissionsAsync } from "../callbacks";
// This one is placed here because it's used in multiple tests and needs to be the same reference
const setCookies = vi.fn();
vi.mock("next/headers", () => ({
cookies: () => ({
set: setCookies,
}),
}));
describe("getCurrentUserPermissions", () => { describe("getCurrentUserPermissions", () => {
test("should return empty permissions when non existing user requested", async () => { test("should return empty permissions when non existing user requested", async () => {
// Arrange // Arrange
@@ -170,21 +176,6 @@ vi.mock("../session", async (importOriginal) => {
expireDateAfter, expireDateAfter,
} satisfies SessionExport; } satisfies SessionExport;
}); });
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
type HeadersExport = typeof import("next/headers");
vi.mock("next/headers", async (importOriginal) => {
const mod = await importOriginal<HeadersExport>();
const result = {
set: (name: string, value: string, options: Partial<ResponseCookie>) => options as ResponseCookie,
} as unknown as ReadonlyRequestCookies;
vi.spyOn(result, "set");
const cookies = () => result;
return { ...mod, cookies } satisfies HeadersExport;
});
describe("createSignInCallback", () => { describe("createSignInCallback", () => {
test("should return true if not credentials request and set colorScheme & sessionToken cookie", async () => { test("should return true if not credentials request and set colorScheme & sessionToken cookie", async () => {
@@ -232,7 +223,6 @@ describe("createSignInCallback", () => {
const signInCallback = createSignInCallback(adapter, db, isCredentialsRequest); const signInCallback = createSignInCallback(adapter, db, isCredentialsRequest);
const user = { id: "1", emailVerified: new Date("2023-01-13") }; const user = { id: "1", emailVerified: new Date("2023-01-13") };
const account = {} as Account; const account = {} as Account;
// Act // Act
await signInCallback({ user, account }); await signInCallback({ user, account });
@@ -253,7 +243,7 @@ describe("createSignInCallback", () => {
test("should set colorScheme from db as cookie", async () => { test("should set colorScheme from db as cookie", async () => {
// Arrange // Arrange
const isCredentialsRequest = false; const isCredentialsRequest = true;
const db = await prepareDbForSigninAsync("1"); const db = await prepareDbForSigninAsync("1");
const signInCallback = createSignInCallback(createAdapter(), db, isCredentialsRequest); const signInCallback = createSignInCallback(createAdapter(), db, isCredentialsRequest);

View File

@@ -93,7 +93,7 @@ export const pagesSearchGroup = createGroup<{
icon: IconUsers, icon: IconUsers,
path: "/manage/users", path: "/manage/users",
name: t("manageUser.label"), name: t("manageUser.label"),
hidden: !session, hidden: !session?.user.permissions.includes("admin"),
}, },
{ {
icon: IconMailForward, icon: IconMailForward,
@@ -105,7 +105,7 @@ export const pagesSearchGroup = createGroup<{
icon: IconUsersGroup, icon: IconUsersGroup,
path: "/manage/users/groups", path: "/manage/users/groups",
name: t("manageGroup.label"), name: t("manageGroup.label"),
hidden: !session, hidden: !session?.user.permissions.includes("admin"),
}, },
{ {
icon: IconBrandDocker, icon: IconBrandDocker,
@@ -117,7 +117,7 @@ export const pagesSearchGroup = createGroup<{
icon: IconPlug, icon: IconPlug,
path: "/manage/tools/api", path: "/manage/tools/api",
name: t("manageApi.label"), name: t("manageApi.label"),
hidden: !session, hidden: !session?.user.permissions.includes("admin"),
}, },
{ {
icon: IconLogs, icon: IconLogs,

View File

@@ -1627,12 +1627,12 @@ export default {
page: { page: {
home: { home: {
statistic: { statistic: {
countBoards: "Boards", board: "Boards",
createUser: "Create new user", user: "Users",
createInvite: "Create new invite", invite: "Invites",
addIntegration: "Create integration", integration: "Integrations",
addApp: "Add app", app: "Apps",
manageRoles: "Manage roles", group: "Groups",
}, },
statisticLabel: { statisticLabel: {
boards: "Boards", boards: "Boards",

View File

@@ -8,6 +8,7 @@ export default defineConfig({
setupFiles: ["./vitest.setup.ts"], setupFiles: ["./vitest.setup.ts"],
environment: "jsdom", environment: "jsdom",
include: ["**/*.spec.ts"], include: ["**/*.spec.ts"],
clearMocks: true,
poolOptions: { poolOptions: {
threads: { threads: {
singleThread: false, singleThread: false,
@@ -18,7 +19,7 @@ export default defineConfig({
reporter: ["html", "json-summary", "json"], reporter: ["html", "json-summary", "json"],
all: true, all: true,
exclude: ["apps/nextjs/.next/"], exclude: ["apps/nextjs/.next/"],
reportOnFailure: true reportOnFailure: true,
}, },
exclude: [...configDefaults.exclude, "apps/nextjs/.next"], exclude: [...configDefaults.exclude, "apps/nextjs/.next"],