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:
@@ -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 }}>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 = () => ({
|
||||||
|
|||||||
@@ -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]]);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
Reference in New Issue
Block a user