chore(release): automatic release v0.1.0

This commit is contained in:
homarr-releases[bot]
2024-10-11 19:12:56 +00:00
committed by GitHub
119 changed files with 8590 additions and 1537 deletions

View File

@@ -1,4 +1,4 @@
FROM node:20.17.0-alpine AS base
FROM node:20.18.0-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat

View File

@@ -42,12 +42,12 @@
"@mantine/hooks": "^7.13.2",
"@mantine/modals": "^7.13.2",
"@mantine/tiptap": "^7.13.2",
"@million/lint": "1.0.8",
"@million/lint": "1.0.9",
"@t3-oss/env-nextjs": "^0.11.1",
"@tabler/icons-react": "^3.19.0",
"@tanstack/react-query": "^5.59.0",
"@tanstack/react-query-devtools": "^5.59.0",
"@tanstack/react-query-next-experimental": "5.59.0",
"@tanstack/react-query": "^5.59.9",
"@tanstack/react-query-devtools": "^5.59.9",
"@tanstack/react-query-next-experimental": "5.59.9",
"@trpc/client": "next",
"@trpc/next": "next",
"@trpc/react-query": "next",
@@ -63,14 +63,14 @@
"glob": "^11.0.0",
"jotai": "^2.10.0",
"mantine-react-table": "2.0.0-beta.6",
"next": "^14.2.14",
"next": "^14.2.15",
"postcss-preset-mantine": "^1.17.0",
"prismjs": "^1.29.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.0.13",
"react-simple-code-editor": "^0.14.1",
"sass": "^1.79.4",
"sass": "^1.79.5",
"superjson": "2.2.1",
"swagger-ui-react": "^5.17.14",
"use-deep-compare-effect": "^1.8.1"
@@ -80,15 +80,15 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "2.4.4",
"@types/node": "^20.16.10",
"@types/node": "^20.16.11",
"@types/prismjs": "^1.26.4",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@types/swagger-ui-react": "^4.18.3",
"concurrently": "^9.0.1",
"eslint": "^9.11.1",
"eslint": "^9.12.0",
"node-loader": "^2.0.0",
"prettier": "^3.3.3",
"typescript": "^5.6.2"
"typescript": "^5.6.3"
}
}

View File

@@ -3,7 +3,7 @@
import type { PropsWithChildren } from "react";
import { useState } from "react";
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
import { createTheme, isMantineColorScheme, MantineProvider } from "@mantine/core";
import { createTheme, DirectionProvider, isMantineColorScheme, MantineProvider } from "@mantine/core";
import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
@@ -14,16 +14,18 @@ export const CustomMantineProvider = ({ children }: PropsWithChildren) => {
const manager = useColorSchemeManager();
return (
<MantineProvider
defaultColorScheme="auto"
colorSchemeManager={manager}
theme={createTheme({
primaryColor: "red",
autoContrast: true,
})}
>
{children}
</MantineProvider>
<DirectionProvider>
<MantineProvider
defaultColorScheme="auto"
colorSchemeManager={manager}
theme={createTheme({
primaryColor: "red",
autoContrast: true,
})}
>
{children}
</MantineProvider>
</DirectionProvider>
);
};

View File

@@ -12,8 +12,7 @@ import type { useForm } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { validation, z } from "@homarr/validation";
interface LoginFormProps {
providers: string[];
@@ -22,15 +21,17 @@ interface LoginFormProps {
callbackUrl: string;
}
const extendedValidation = validation.user.signIn.extend({ provider: z.enum(["credentials", "ldap"]) });
export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, callbackUrl }: LoginFormProps) => {
const t = useScopedI18n("user");
const router = useRouter();
const [isPending, setIsPending] = useState(false);
const form = useZodForm(validation.user.signIn, {
const form = useZodForm(extendedValidation, {
initialValues: {
name: "",
password: "",
credentialType: "basic",
provider: "credentials",
},
});
@@ -95,14 +96,14 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
<Stack gap="lg">
{credentialInputsVisible && (
<>
<form onSubmit={form.onSubmit((credentials) => void signInAsync("credentials", credentials))}>
<form onSubmit={form.onSubmit((credentials) => void signInAsync(credentials.provider, credentials))}>
<Stack gap="lg">
<TextInput label={t("field.username.label")} {...form.getInputProps("name")} />
<PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
{providers.includes("credentials") && (
<Stack gap="sm">
<SubmitButton isPending={isPending} form={form} credentialType="basic">
<SubmitButton isPending={isPending} form={form} provider="credentials">
{t("action.login.label")}
</SubmitButton>
<PasswordForgottenCollapse username={form.values.name} />
@@ -110,7 +111,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
)}
{providers.includes("ldap") && (
<SubmitButton isPending={isPending} form={form} credentialType="ldap">
<SubmitButton isPending={isPending} form={form} provider="ldap">
{t("action.login.labelWith", { provider: "LDAP" })}
</SubmitButton>
)}
@@ -133,18 +134,18 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
interface SubmitButtonProps {
isPending: boolean;
form: ReturnType<typeof useForm<FormType, (values: FormType) => FormType>>;
credentialType: "basic" | "ldap";
provider: "credentials" | "ldap";
}
const SubmitButton = ({ isPending, form, credentialType, children }: PropsWithChildren<SubmitButtonProps>) => {
const isCurrentProviderActive = form.getValues().credentialType === credentialType;
const SubmitButton = ({ isPending, form, provider, children }: PropsWithChildren<SubmitButtonProps>) => {
const isCurrentProviderActive = form.getValues().provider === provider;
return (
<Button
type="submit"
name={credentialType}
name={provider}
fullWidth
onClick={() => form.setFieldValue("credentialType", credentialType)}
onClick={() => form.setFieldValue("provider", provider)}
loading={isPending && isCurrentProviderActive}
disabled={isPending && !isCurrentProviderActive}
>
@@ -181,4 +182,4 @@ const PasswordForgottenCollapse = ({ username }: PasswordForgottenCollapseProps)
);
};
type FormType = z.infer<typeof validation.user.signIn>;
type FormType = z.infer<typeof extendedValidation>;

View File

@@ -12,6 +12,7 @@ import { env } from "@homarr/auth/env.mjs";
import { auth } from "@homarr/auth/next";
import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications";
import { getScopedI18n } from "@homarr/translation/server";
import { Analytics } from "~/components/layout/analytics";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
@@ -56,6 +57,8 @@ export const viewport: Viewport = {
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
const session = await auth();
const colorScheme = cookies().get("homarr-color-scheme")?.value ?? "light";
const tCommon = await getScopedI18n("common");
const direction = tCommon("direction");
const StackedProvider = composeWrappers([
(innerProps) => {
@@ -70,7 +73,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
return (
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
<html lang="en" data-mantine-color-scheme={colorScheme} suppressHydrationWarning>
<html lang="en" dir={direction} data-mantine-color-scheme={colorScheme} suppressHydrationWarning>
<head>
<Analytics />
<SearchEngineOptimization />

View File

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

View File

@@ -6,6 +6,7 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
import { IntegrationAccessSettings } from "../../_components/integration-access-settings";
import { EditIntegrationForm } from "./_integration-edit-form";
@@ -16,7 +17,7 @@ interface EditIntegrationPageProps {
export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) {
const editT = await getScopedI18n("integration.page.edit");
const t = await getI18n();
const integration = await api.integration.byId({ id: params.id });
const integration = await api.integration.byId({ id: params.id }).catch(catchTrpcNotFound);
const integrationPermissions = await api.integration.getIntegrationPermissions({ id: integration.id });
return (

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,7 +31,6 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
const searchParams = searchParamsSchema.parse(props.searchParams);
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams);
const t = await getI18n();
const tEngine = await getScopedI18n("search.engine");
return (
@@ -40,13 +39,7 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
<Stack>
<Title>{tEngine("page.list.title")}</Title>
<Group justify="space-between" align="center">
<SearchInput
placeholder={t("common.rtl", {
value: tEngine("search"),
symbol: "...",
})}
defaultValue={searchParams.search}
/>
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} />
<MobileAffixButton component={Link} href="/manage/search-engines/new">
{tEngine("page.create.title")}
</MobileAffixButton>

View File

@@ -0,0 +1,78 @@
"use client";
import { useMemo } from "react";
import { Button, Group, Stack, Text, Title } from "@mantine/core";
import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import { CopyApiKeyModal } from "~/app/[locale]/manage/tools/api/components/copy-api-key-modal";
interface ApiKeysManagementProps {
apiKeys: RouterOutputs["apiKeys"]["getAll"];
}
export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
const { openModal } = useModalAction(CopyApiKeyModal);
const { mutate, isPending } = clientApi.apiKeys.create.useMutation({
async onSuccess(data) {
openModal({
apiKey: data.randomToken,
});
await revalidatePathActionAsync("/manage/tools/api");
},
});
const t = useScopedI18n("management.page.tool.api.tab.apiKey");
const columns = useMemo<MRT_ColumnDef<RouterOutputs["apiKeys"]["getAll"][number]>[]>(
() => [
{
accessorKey: "id",
header: t("table.header.id"),
},
{
accessorKey: "user",
header: t("table.header.createdBy"),
Cell: ({ row }) => (
<Group gap={"xs"}>
<UserAvatar user={row.original.user} size={"sm"} />
<Text>{row.original.user.name}</Text>
</Group>
),
},
],
[],
);
const table = useMantineReactTable({
columns,
data: apiKeys,
renderTopToolbarCustomActions: () => (
<Button
onClick={() => {
mutate();
}}
loading={isPending}
>
{t("button.createApiToken")}
</Button>
),
enableDensityToggle: false,
state: {
density: "xs",
},
});
return (
<Stack>
<Title>{t("title")}</Title>
<MantineReactTable table={table} />
</Stack>
);
};

View File

@@ -0,0 +1,34 @@
import { Button, CopyButton, PasswordInput, Stack, Text } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { createModal } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
export const CopyApiKeyModal = createModal<{ apiKey: string }>(({ actions, innerProps }) => {
const t = useScopedI18n("management.page.tool.api.modal.createApiToken");
const [visible, { toggle }] = useDisclosure(false);
return (
<Stack>
<Text>{t("description")}</Text>
<PasswordInput value={innerProps.apiKey} visible={visible} onVisibilityChange={toggle} readOnly />
<CopyButton value={innerProps.apiKey}>
{({ copy }) => (
<Button
onClick={() => {
copy();
actions.closeModal();
}}
variant="default"
fullWidth
>
{t("button")}
</Button>
)}
</CopyButton>
</Stack>
);
}).withOptions({
defaultTitle(t) {
return t("management.page.tool.api.modal.createApiToken.title");
},
});

View File

@@ -0,0 +1,23 @@
"use client";
import type { OpenAPIV3 } from "openapi-types";
import SwaggerUI from "swagger-ui-react";
// workaround for CSS that cannot be processed by next.js, https://github.com/swagger-api/swagger-ui/issues/10045
import "../swagger-ui-dark.css";
import "../swagger-ui-overrides.css";
import "../swagger-ui.css";
interface SwaggerUIClientProps {
document: OpenAPIV3.Document;
}
export const SwaggerUIClient = ({ document }: SwaggerUIClientProps) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const requestInterceptor = (req: Record<string, any>) => {
req.credentials = "omit";
return req;
};
return <SwaggerUI requestInterceptor={requestInterceptor} spec={document} />;
};

View File

@@ -1,19 +1,23 @@
import { getScopedI18n } from "@homarr/translation/server";
// workaround for CSS that cannot be processed by next.js, https://github.com/swagger-api/swagger-ui/issues/10045
import "./swagger-ui-dark.css";
import "./swagger-ui-overrides.css";
import "./swagger-ui.css";
import { headers } from "next/headers";
import SwaggerUI from "swagger-ui-react";
import { notFound } from "next/navigation";
import { Stack, Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
import { openApiDocument } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { getScopedI18n } from "@homarr/translation/server";
import { SwaggerUIClient } from "~/app/[locale]/manage/tools/api/components/swagger-ui";
import { createMetaTitle } from "~/metadata";
import { ApiKeysManagement } from "./components/api-keys";
export async function generateMetadata() {
const session = await auth();
if (!session?.user || !session.user.permissions.includes("admin")) {
return {};
}
const t = await getScopedI18n("management");
return {
@@ -21,8 +25,29 @@ export async function generateMetadata() {
};
}
export default 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 apiKeys = await api.apiKeys.getAll();
const t = await getScopedI18n("management.page.tool.api.tab");
return <SwaggerUI spec={document} />;
return (
<Stack>
<Tabs defaultValue={"documentation"}>
<TabsList>
<TabsTab value={"documentation"}>{t("documentation.label")}</TabsTab>
<TabsTab value={"authentication"}>{t("apiKey.label")}</TabsTab>
</TabsList>
<TabsPanel value={"authentication"}>
<ApiKeysManagement apiKeys={apiKeys} />
</TabsPanel>
<TabsPanel value={"documentation"}>
<SwaggerUIClient document={document} />
</TabsPanel>
</Tabs>
</Stack>
);
}

View File

@@ -7146,9 +7146,6 @@
}
.swagger-ui .wrapper {
box-sizing: border-box;
margin: 0 auto;
max-width: 1460px;
padding: 0 20px;
width: 100%;
}
.swagger-ui .opblock-tag-section {
@@ -7734,7 +7731,7 @@
background: #fff;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.15);
margin: 0 0 20px;
padding: 30px 0;
padding: 30px 20px;
}
.swagger-ui .scheme-container .schemes {
align-items: flex-end;

View File

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

View File

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

View File

@@ -0,0 +1,82 @@
"use client";
import { Button, Group, Radio, Stack } from "@mantine/core";
import dayjs from "dayjs";
import localeData from "dayjs/plugin/localeData";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
dayjs.extend(localeData);
interface FirstDayOfWeekProps {
user: RouterOutputs["user"]["getById"];
}
const weekDays = dayjs.weekdays(false);
export const FirstDayOfWeek = ({ user }: FirstDayOfWeekProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changeFirstDayOfWeek.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
firstDayOfWeek: variables.firstDayOfWeek,
});
showSuccessNotification({
message: t("user.action.changeFirstDayOfWeek.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changeFirstDayOfWeek.notification.error.message"),
});
},
});
const form = useZodForm(validation.user.firstDayOfWeek, {
initialValues: {
firstDayOfWeek: user.firstDayOfWeek,
},
});
const handleSubmit = (values: FormType) => {
mutate({
id: user.id,
...values,
});
};
const inputProps = form.getInputProps("firstDayOfWeek");
const onChange = inputProps.onChange as (value: number) => void;
const value = (inputProps.value as number).toString();
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Radio.Group {...inputProps} value={value} onChange={(value) => onChange(parseInt(value))}>
<Group mt="xs">
<Radio value="1" label={weekDays[1]} />
<Radio value="6" label={weekDays[6]} />
<Radio value="0" label={weekDays[0]} />
</Group>
</Radio.Group>
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = z.infer<typeof validation.user.firstDayOfWeek>;

View File

@@ -13,6 +13,7 @@ import { createMetaTitle } from "~/metadata";
import { canAccessUserEditPage } from "../access";
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
import { DeleteUserButton } from "./_components/_delete-user-button";
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { UserProfileForm } from "./_components/_profile-form";
@@ -93,6 +94,11 @@ export default async function EditUserPage({ params }: Props) {
/>
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.firstDayOfWeek")}</Title>
<FirstDayOfWeek user={user} />
</Stack>
{isCredentialsUser && (
<DangerZoneRoot>
<DangerZoneItem

View File

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

View File

@@ -48,13 +48,7 @@ export default async function GroupsDetailPage({ params, searchParams }: GroupsD
)}
<Group justify="space-between">
<SearchInput
placeholder={t("common.rtl", {
value: tMembers("search"),
symbol: "...",
})}
defaultValue={searchParams.search}
/>
<SearchInput placeholder={`${tMembers("search")}...`} defaultValue={searchParams.search} />
{isProviderEnabled("credentials") && (
<AddGroupMember groupId={group.id} presentUserIds={group.members.map((member) => member.id)} />
)}

View File

@@ -1,8 +1,10 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui";
import { z } from "@homarr/validation";
@@ -26,6 +28,12 @@ interface GroupsListPageProps {
}
export default async function GroupsListPage(props: GroupsListPageProps) {
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
return notFound();
}
const t = await getI18n();
const searchParams = searchParamsSchema.parse(props.searchParams);
const { items: groups, totalCount } = await api.group.getPaginated(searchParams);
@@ -36,13 +44,7 @@ export default async function GroupsListPage(props: GroupsListPageProps) {
<Stack>
<Title>{t("group.title")}</Title>
<Group justify="space-between">
<SearchInput
placeholder={t("common.rtl", {
value: t("group.search"),
symbol: "...",
})}
defaultValue={searchParams.search}
/>
<SearchInput placeholder={`${t("group.search")}...`} defaultValue={searchParams.search} />
<AddGroup />
</Group>
<Table striped highlightOnHover>

View File

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

View File

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

View File

@@ -1,14 +1,59 @@
import { createOpenApiFetchHandler } from "trpc-swagger/build/index.mjs";
import { appRouter, createTRPCContext } from "@homarr/api";
import type { Session } from "@homarr/auth";
import { createSessionAsync } from "@homarr/auth/server";
import { db, eq } from "@homarr/db";
import { apiKeys } from "@homarr/db/schema/sqlite";
import { logger } from "@homarr/log";
const handlerAsync = async (req: Request) => {
const apiKeyHeaderValue = req.headers.get("ApiKey");
const session: Session | null = await getSessionOrDefaultFromHeadersAsync(apiKeyHeaderValue);
const handler = (req: Request) => {
return createOpenApiFetchHandler({
req,
endpoint: "/",
router: appRouter,
createContext: () => createTRPCContext({ session: null, headers: req.headers }),
createContext: () => createTRPCContext({ session, headers: req.headers }),
});
};
export { handler as GET, handler as POST };
const getSessionOrDefaultFromHeadersAsync = async (apiKeyHeaderValue: string | null): Promise<Session | null> => {
logger.info(
`Creating OpenAPI fetch handler for user ${apiKeyHeaderValue ? "with an api key" : "without an api key"}`,
);
if (apiKeyHeaderValue === null) {
return null;
}
const apiKeyFromDb = await db.query.apiKeys.findFirst({
where: eq(apiKeys.apiKey, apiKeyHeaderValue),
columns: {
id: true,
apiKey: false,
salt: false,
},
with: {
user: {
columns: {
id: true,
name: true,
email: true,
emailVerified: true,
},
},
},
});
if (apiKeyFromDb === undefined) {
logger.warn("An attempt to authenticate over API has failed");
return null;
}
logger.info(`Read session from API request and found user ${apiKeyFromDb.user.name} (${apiKeyFromDb.user.id})`);
return await createSessionAsync(db, apiKeyFromDb.user);
};
export { handlerAsync as GET, handlerAsync as POST };

View File

@@ -1,17 +1,37 @@
import { NextRequest } from "next/server";
import { createHandlers } from "@homarr/auth";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { logger } from "@homarr/log";
export const GET = async (req: NextRequest) => {
return await createHandlers(isCredentialsRequest(req)).handlers.GET(reqWithTrustedOrigin(req));
return await createHandlers(extractProvider(req)).handlers.GET(reqWithTrustedOrigin(req));
};
export const POST = async (req: NextRequest) => {
return await createHandlers(isCredentialsRequest(req)).handlers.POST(reqWithTrustedOrigin(req));
return await createHandlers(extractProvider(req)).handlers.POST(reqWithTrustedOrigin(req));
};
const isCredentialsRequest = (req: NextRequest) => {
return req.url.includes("credentials") && req.method === "POST";
/**
* This method extracts the used provider from the url and allows us to override the getUserByEmail method in the adapter.
* @param req request containing the url
* @returns the provider or "unknown" if the provider could not be extracted
*/
const extractProvider = (req: NextRequest): SupportedAuthProvider | "unknown" => {
const url = new URL(req.url);
if (url.pathname.includes("oidc")) {
return "oidc";
}
if (url.pathname.includes("credentials")) {
return "credentials";
}
if (url.pathname.includes("ldap")) {
return "ldap";
}
return "unknown";
};
/**

View File

@@ -21,10 +21,7 @@ export const DesktopSearchInput = () => {
leftSection={<IconSearch size={20} stroke={1.5} />}
onClick={openSpotlight}
>
{t("common.rtl", {
value: t("search.placeholder"),
symbol: "...",
})}
{`${t("search.placeholder")}...`}
</TextInput>
);
};

View File

@@ -38,17 +38,17 @@
"dayjs": "^1.11.13",
"dotenv": "^16.4.5",
"superjson": "2.2.1",
"undici": "6.19.8"
"undici": "6.20.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^20.16.10",
"@types/node": "^20.16.11",
"dotenv-cli": "^7.4.2",
"eslint": "^9.11.1",
"eslint": "^9.12.0",
"prettier": "^3.3.3",
"tsx": "4.13.3",
"typescript": "^5.6.2"
"typescript": "^5.6.3"
}
}

View File

@@ -34,8 +34,8 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "^8.5.12",
"eslint": "^9.11.1",
"eslint": "^9.12.0",
"prettier": "^3.3.3",
"typescript": "^5.6.2"
"typescript": "^5.6.3"
}
}

View File

@@ -36,11 +36,11 @@
"prettier": "^3.3.3",
"testcontainers": "^10.13.2",
"turbo": "^2.1.3",
"typescript": "^5.6.2",
"typescript": "^5.6.3",
"vite-tsconfig-paths": "^5.0.1",
"vitest": "^2.1.2"
},
"packageManager": "pnpm@9.12.0",
"packageManager": "pnpm@9.12.1",
"engines": {
"node": ">=20.18.0"
},

View File

@@ -32,7 +32,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.1",
"typescript": "^5.6.2"
"eslint": "^9.12.0",
"typescript": "^5.6.3"
}
}

View File

@@ -39,7 +39,7 @@
"@trpc/react-query": "next",
"@trpc/server": "next",
"dockerode": "^4.0.2",
"next": "^14.2.14",
"next": "^14.2.15",
"react": "^18.3.1",
"superjson": "2.2.1",
"trpc-swagger": "^1.2.6"
@@ -49,8 +49,8 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "^3.3.31",
"eslint": "^9.11.1",
"eslint": "^9.12.0",
"prettier": "^3.3.3",
"typescript": "^5.6.2"
"typescript": "^5.6.3"
}
}

View File

@@ -8,4 +8,12 @@ export const openApiDocument = (base: string) =>
version: "1.0.0",
baseUrl: base,
docsUrl: "https://homarr.dev",
securitySchemes: {
apikey: {
type: "apiKey",
name: "ApiKey",
description: "API key which can be obtained in the Homarr administration dashboard",
in: "header",
},
},
});

View File

@@ -1,3 +1,4 @@
import { apiKeysRouter } from "./router/apiKeys";
import { appRouter as innerAppRouter } from "./router/app";
import { boardRouter } from "./router/board";
import { cronJobsRouter } from "./router/cron-jobs";
@@ -31,6 +32,7 @@ export const appRouter = createTRPCRouter({
docker: dockerRouter,
serverSettings: serverSettingsRouter,
cronJobs: cronJobsRouter,
apiKeys: apiKeysRouter,
});
// export type definition of API

View File

@@ -0,0 +1,41 @@
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
import { generateSecureRandomToken } from "@homarr/common/server";
import { createId, db } from "@homarr/db";
import { apiKeys } from "@homarr/db/schema/sqlite";
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
export const apiKeysRouter = createTRPCRouter({
getAll: permissionRequiredProcedure.requiresPermission("admin").query(() => {
return db.query.apiKeys.findMany({
columns: {
id: true,
apiKey: false,
salt: false,
},
with: {
user: {
columns: {
id: true,
name: true,
image: true,
},
},
},
});
}),
create: permissionRequiredProcedure.requiresPermission("admin").mutation(async ({ ctx }) => {
const salt = await createSaltAsync();
const randomToken = generateSecureRandomToken(64);
const hashedRandomToken = await hashPasswordAsync(randomToken, salt);
await db.insert(apiKeys).values({
id: createId(),
apiKey: hashedRandomToken,
salt,
userId: ctx.session.user.id,
});
return {
randomToken,
};
}),
});

View File

@@ -4,57 +4,118 @@ import { asc, createId, eq, like } from "@homarr/db";
import { apps } from "@homarr/db/schema/sqlite";
import { validation, z } from "@homarr/validation";
import { createTRPCRouter, publicProcedure } from "../trpc";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
export const appRouter = createTRPCRouter({
all: publicProcedure.query(async ({ ctx }) => {
return await ctx.db.query.apps.findMany({
orderBy: asc(apps.name),
});
}),
selectable: publicProcedure.query(async ({ ctx }) => {
return await ctx.db.query.apps.findMany({
columns: {
id: true,
name: true,
iconUrl: true,
},
orderBy: asc(apps.name),
});
}),
all: publicProcedure
.input(z.void())
.output(
z.array(
z.object({
name: z.string(),
id: z.string(),
description: z.string().nullable(),
iconUrl: z.string(),
href: z.string().nullable(),
}),
),
)
.meta({ openapi: { method: "GET", path: "/api/apps", tags: ["apps"], protect: true } })
.query(({ ctx }) => {
return ctx.db.query.apps.findMany({
orderBy: asc(apps.name),
});
}),
search: publicProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => {
return await ctx.db.query.apps.findMany({
.output(
z.array(
z.object({
name: z.string(),
id: z.string(),
description: z.string().nullable(),
iconUrl: z.string(),
href: z.string().nullable(),
}),
),
)
.meta({ openapi: { method: "GET", path: "/api/apps/search", tags: ["apps"], protect: true } })
.query(({ ctx, input }) => {
return ctx.db.query.apps.findMany({
where: like(apps.name, `%${input.query}%`),
orderBy: asc(apps.name),
limit: input.limit,
});
}),
byId: publicProcedure.input(validation.common.byId).query(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id),
});
if (!app) {
throw new TRPCError({
code: "NOT_FOUND",
message: "App not found",
selectable: publicProcedure
.input(z.void())
.output(
z.array(
z.object({
name: z.string(),
id: z.string(),
iconUrl: z.string(),
}),
),
)
.meta({
openapi: {
method: "GET",
path: "/api/apps/selectable",
tags: ["apps"],
protect: true,
},
})
.query(({ ctx }) => {
return ctx.db.query.apps.findMany({
columns: {
id: true,
name: true,
iconUrl: true,
},
orderBy: asc(apps.name),
});
}),
byId: publicProcedure
.input(validation.common.byId)
.output(
z.object({
name: z.string(),
id: z.string(),
description: z.string().nullable(),
iconUrl: z.string(),
href: z.string().nullable(),
}),
)
.meta({ openapi: { method: "GET", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
.query(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id),
});
}
return app;
}),
create: publicProcedure.input(validation.app.manage).mutation(async ({ ctx, input }) => {
await ctx.db.insert(apps).values({
id: createId(),
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
href: input.href,
});
}),
update: publicProcedure.input(validation.app.edit).mutation(async ({ ctx, input }) => {
if (!app) {
throw new TRPCError({
code: "NOT_FOUND",
message: "App not found",
});
}
return app;
}),
create: protectedProcedure
.input(validation.app.manage)
.output(z.void())
.meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } })
.mutation(async ({ ctx, input }) => {
await ctx.db.insert(apps).values({
id: createId(),
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
href: input.href,
});
}),
update: protectedProcedure.input(validation.app.edit).mutation(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id),
});
@@ -76,7 +137,11 @@ export const appRouter = createTRPCRouter({
})
.where(eq(apps.id, input.id));
}),
delete: publicProcedure.input(validation.common.byId).mutation(async ({ ctx, input }) => {
await ctx.db.delete(apps).where(eq(apps.id, input.id));
}),
delete: protectedProcedure
.output(z.void())
.meta({ openapi: { method: "DELETE", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
.input(validation.common.byId)
.mutation(async ({ ctx, input }) => {
await ctx.db.delete(apps).where(eq(apps.id, input.id));
}),
});

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import { decryptSecret, encryptSecret } from "@homarr/common/server";
import type { Database } from "@homarr/db";
import { and, asc, createId, eq, inArray, like } from "@homarr/db";
import {
groupMembers,
groupPermissions,
integrationGroupPermissions,
integrations,
@@ -14,20 +15,48 @@ import type { IntegrationSecretKind } from "@homarr/definitions";
import { getPermissionsWithParents, integrationKinds, integrationSecretKindObject } from "@homarr/definitions";
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 { testConnectionAsync } from "./integration-test-connection";
export const integrationRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => {
const integrations = await ctx.db.query.integrations.findMany();
all: publicProcedure.query(async ({ ctx }) => {
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
.map((integration) => ({
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
}))
.map((integration) => {
const permissions = integration.userPermissions
.map(({ permission }) => permission)
.concat(integration.groupPermissions.map(({ permission }) => permission));
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(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),

View File

@@ -4,10 +4,10 @@ import { logger } from "@homarr/log";
import type { LoggerMessage } from "@homarr/redis";
import { loggingChannel } from "@homarr/redis";
import { createTRPCRouter, publicProcedure } from "../trpc";
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
export const logRouter = createTRPCRouter({
subscribe: publicProcedure.subscription(() => {
subscribe: permissionRequiredProcedure.requiresPermission("admin").subscription(() => {
return observable<LoggerMessage>((emit) => {
const unsubscribe = loggingChannel.subscribe((data) => {
emit.next(data);

View File

@@ -11,6 +11,11 @@ import { appRouter } from "../app";
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
const defaultSession: Session = {
user: { id: createId(), permissions: [], colorScheme: "light" },
expires: new Date().toISOString(),
};
describe("all should return all apps", () => {
test("should return all apps", async () => {
const db = createDb();
@@ -89,7 +94,7 @@ describe("create should create a new app with all arguments", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
session: null,
session: defaultSession,
});
const input = {
name: "Mantine",
@@ -112,7 +117,7 @@ describe("create should create a new app with all arguments", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
session: null,
session: defaultSession,
});
const input = {
name: "Mantine",
@@ -137,7 +142,7 @@ describe("update should update an app", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
session: null,
session: defaultSession,
});
const appId = createId();
@@ -172,7 +177,7 @@ describe("update should update an app", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
session: null,
session: defaultSession,
});
const actAsync = async () =>
@@ -192,7 +197,7 @@ describe("delete should delete an app", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
session: null,
session: defaultSession,
});
const appId = createId();

View File

@@ -1,21 +1,26 @@
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import * as env from "@homarr/auth/env.mjs";
import { createId, eq } from "@homarr/db";
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
import { groupRouter } from "../group";
const defaultOwnerId = createId();
const defaultSession = {
user: {
id: defaultOwnerId,
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
const createSession = (permissions: GroupPermissionKey[]) =>
({
user: {
id: defaultOwnerId,
permissions,
colorScheme: "light",
},
expires: new Date().toISOString(),
}) satisfies Session;
const defaultSession = createSession([]);
const adminSession = createSession(["admin"]);
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", async () => {
@@ -32,7 +37,7 @@ describe("paginated should return a list of groups with pagination", () => {
async (page, expectedCount) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, session: adminSession });
await db.insert(groups).values(
[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 () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, session: adminSession });
await db.insert(groups).values(
[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 () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, session: adminSession });
const user = createDummyUser();
await db.insert(users).values(user);
@@ -112,7 +117,7 @@ describe("paginated should return a list of groups with pagination", () => {
async (query, expectedCount, firstKey) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, session: adminSession });
await db.insert(groups).values(
["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);
},
);
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", () => {
test('should return group with id "1" with members and permissions', async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, session: adminSession });
const user = createDummyUser();
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 () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, session: adminSession });
await db.insert(groups).values({
id: "2",
@@ -193,13 +210,25 @@ describe("byId should return group by id including members and permissions", ()
// Assert
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", () => {
test("with valid input (64 character name) and non existing name it should be successful", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, session: adminSession });
const name = "a".repeat(64);
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 () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, session: adminSession });
const longName = "a".repeat(65);
// 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) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, session: adminSession });
await db.insert(groups).values({
id: createId(),
@@ -257,6 +286,18 @@ describe("create should create group in database", () => {
// Assert
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", () => {
@@ -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) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, session: adminSession });
const groupId = createId();
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) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, session: adminSession });
const groupId = createId();
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 () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, session: adminSession });
await db.insert(groups).values({
id: createId(),
@@ -344,13 +385,29 @@ describe("update should update name with value that is no duplicate", () => {
// Assert
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", () => {
test("with existing group and permissions it should save permissions", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, session: adminSession });
const groupId = createId();
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 () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, session: adminSession });
await db.insert(groups).values({
id: createId(),
@@ -397,13 +454,29 @@ describe("savePermissions should save permissions for group", () => {
// Assert
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", () => {
test("with existing group and user it should transfer ownership", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, session: adminSession });
const groupId = 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 () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, session: adminSession });
await db.insert(groups).values({
id: createId(),
@@ -457,13 +530,29 @@ describe("transferOwnership should transfer ownership of group", () => {
// Assert
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", () => {
test("with existing group it should delete group", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, session: adminSession });
const groupId = createId();
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 () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, session: adminSession });
await db.insert(groups).values({
id: createId(),
@@ -508,13 +597,30 @@ describe("deleteGroup should delete group", () => {
// Assert
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", () => {
test("with existing group and user it should add member", async () => {
// Arrange
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 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 () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, session: adminSession });
await db.insert(users).values({
id: createId(),
@@ -569,13 +675,67 @@ describe("addMember should add member to group", () => {
// Assert
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", () => {
test("with existing group and user it should remove member", async () => {
// Arrange
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 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 () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, session: adminSession });
await db.insert(users).values({
id: createId(),
@@ -633,6 +793,62 @@ describe("removeMember should remove member from group", () => {
// Assert
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 = () => ({

View File

@@ -4,9 +4,22 @@ import type { Session } from "@homarr/auth";
import { createId, eq, schema } from "@homarr/db";
import { users } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
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
vi.mock("@homarr/auth", async () => {
const mod = await import("@homarr/auth/security");
@@ -212,14 +225,13 @@ describe("editProfile shoud update user", () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
session: null,
session: defaultSession,
});
const id = createId();
const emailVerified = new Date(2024, 0, 5);
await db.insert(schema.users).values({
id,
id: defaultOwnerId,
name: "TEST 1",
email: "abc@gmail.com",
emailVerified,
@@ -227,17 +239,17 @@ describe("editProfile shoud update user", () => {
// act
await caller.editProfile({
id: id,
id: defaultOwnerId,
name: "ABC",
email: "",
});
// 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[0]).toStrictEqual({
id,
id: defaultOwnerId,
name: "ABC",
email: "abc@gmail.com",
emailVerified,
@@ -247,6 +259,7 @@ describe("editProfile shoud update user", () => {
homeBoardId: null,
provider: "credentials",
colorScheme: "auto",
firstDayOfWeek: 1,
});
});
@@ -255,13 +268,11 @@ describe("editProfile shoud update user", () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
session: null,
session: defaultSession,
});
const id = createId();
await db.insert(schema.users).values({
id,
id: defaultOwnerId,
name: "TEST 1",
email: "abc@gmail.com",
emailVerified: new Date(2024, 0, 5),
@@ -269,17 +280,17 @@ describe("editProfile shoud update user", () => {
// act
await caller.editProfile({
id,
id: defaultOwnerId,
name: "ABC",
email: "myNewEmail@gmail.com",
});
// 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[0]).toStrictEqual({
id,
id: defaultOwnerId,
name: "ABC",
email: "myNewEmail@gmail.com",
emailVerified: null,
@@ -289,6 +300,7 @@ describe("editProfile shoud update user", () => {
homeBoardId: null,
provider: "credentials",
colorScheme: "auto",
firstDayOfWeek: 1,
});
});
});
@@ -298,11 +310,9 @@ describe("delete should delete user", () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
session: null,
session: defaultSession,
});
const userToDelete = createId();
const initialUsers = [
{
id: createId(),
@@ -315,9 +325,10 @@ describe("delete should delete user", () => {
homeBoardId: null,
provider: "ldap" as const,
colorScheme: "auto" as const,
firstDayOfWeek: 1 as const,
},
{
id: userToDelete,
id: defaultOwnerId,
name: "User 2",
email: null,
emailVerified: null,
@@ -326,6 +337,7 @@ describe("delete should delete user", () => {
salt: null,
homeBoardId: null,
colorScheme: "auto" as const,
firstDayOfWeek: 1 as const,
},
{
id: createId(),
@@ -338,12 +350,13 @@ describe("delete should delete user", () => {
homeBoardId: null,
provider: "oidc" as const,
colorScheme: "auto" as const,
firstDayOfWeek: 1 as const,
},
];
await db.insert(schema.users).values(initialUsers);
await caller.delete(userToDelete);
await caller.delete(defaultOwnerId);
const usersInDb = await db.select().from(schema.users);
expect(usersInDb).toStrictEqual([initialUsers[0], initialUsers[2]]);

View File

@@ -8,7 +8,7 @@ import type { SupportedAuthProvider } from "@homarr/definitions";
import { logger } from "@homarr/log";
import { validation, z } from "@homarr/validation";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { throwIfCredentialsDisabled } from "./invite/checks";
export const userRouter = createTRPCRouter({
@@ -69,8 +69,9 @@ export const userRouter = createTRPCRouter({
// Delete invite as it's used
await ctx.db.delete(invites).where(inviteWhere);
}),
create: publicProcedure
.meta({ openapi: { method: "POST", path: "/api/users", tags: ["users"] } })
create: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ openapi: { method: "POST", path: "/api/users", tags: ["users"], protect: true } })
.input(validation.user.create)
.output(z.void())
.mutation(async ({ ctx, input }) => {
@@ -130,7 +131,8 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, input.userId));
}),
getAll: publicProcedure
getAll: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.void())
.output(
z.array(
@@ -143,7 +145,7 @@ export const userRouter = createTRPCRouter({
}),
),
)
.meta({ openapi: { method: "GET", path: "/api/users", tags: ["users"] } })
.meta({ openapi: { method: "GET", path: "/api/users", tags: ["users"], protect: true } })
.query(({ ctx }) => {
return ctx.db.query.users.findMany({
columns: {
@@ -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({
columns: {
id: true,
@@ -164,7 +167,8 @@ export const userRouter = createTRPCRouter({
},
});
}),
search: publicProcedure
search: permissionRequiredProcedure
.requiresPermission("admin")
.input(
z.object({
query: z.string(),
@@ -187,7 +191,14 @@ export const userRouter = createTRPCRouter({
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({
columns: {
id: true,
@@ -197,6 +208,7 @@ export const userRouter = createTRPCRouter({
image: true,
provider: true,
homeBoardId: true,
firstDayOfWeek: true,
},
where: eq(users.id, input.userId),
});
@@ -210,7 +222,15 @@ export const userRouter = createTRPCRouter({
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({
columns: { email: true, provider: true },
where: eq(users.id, input.id),
@@ -242,7 +262,15 @@ export const userRouter = createTRPCRouter({
})
.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));
}),
changePassword: protectedProcedure.input(validation.user.changePasswordApi).mutation(async ({ ctx, input }) => {
@@ -311,7 +339,7 @@ export const userRouter = createTRPCRouter({
.input(validation.user.changeHomeBoard.and(z.object({ userId: z.string() })))
.mutation(async ({ input, ctx }) => {
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) {
throw new TRPCError({
code: "NOT_FOUND",
@@ -348,6 +376,53 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, ctx.session.user.id));
}),
getFirstDayOfWeekForUserOrDefault: publicProcedure.query(async ({ ctx }) => {
if (!ctx.session?.user) {
return 1 as const;
}
const user = await ctx.db.query.users.findFirst({
columns: {
id: true,
firstDayOfWeek: true,
},
where: eq(users.id, ctx.session.user.id),
});
return user?.firstDayOfWeek ?? (1 as const);
}),
changeFirstDayOfWeek: protectedProcedure
.input(validation.user.firstDayOfWeek.and(validation.common.byId))
.mutation(async ({ input, ctx }) => {
// Only admins can change other users' passwords
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const dbUser = await ctx.db.query.users.findFirst({
columns: {
id: true,
},
where: eq(users.id, input.id),
});
if (!dbUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
firstDayOfWeek: input.firstDayOfWeek,
})
.where(eq(users.id, ctx.session.user.id));
}),
});
const createUserAsync = async (db: Database, input: z.infer<typeof validation.user.create>) => {

View File

@@ -1,5 +1,45 @@
import type { Adapter } from "@auth/core/adapters";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@homarr/db";
import type { Database } from "@homarr/db";
import { and, eq } from "@homarr/db";
import { accounts, users } from "@homarr/db/schema/sqlite";
import type { SupportedAuthProvider } from "@homarr/definitions";
export const adapter = DrizzleAdapter(db);
export const createAdapter = (db: Database, provider: SupportedAuthProvider | "unknown"): Adapter => {
const drizzleAdapter = DrizzleAdapter(db, { usersTable: users, accountsTable: accounts });
return {
...drizzleAdapter,
// We override the default implementation as we want to have a provider
// flag in the user instead of the account to not intermingle users from different providers
// eslint-disable-next-line no-restricted-syntax
getUserByEmail: async (email) => {
if (provider === "unknown") {
throw new Error("Unable to get user by email for unknown provider");
}
const user = await db.query.users.findFirst({
where: and(eq(users.email, email), eq(users.provider, provider)),
columns: {
id: true,
name: true,
email: true,
emailVerified: true,
image: true,
},
});
if (!user) {
return null;
}
return {
...user,
// We allow null as email for credentials provider
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
email: user.email!,
};
},
};
};

View File

@@ -1,16 +1,12 @@
import { cookies } from "next/headers";
import type { Adapter } from "@auth/core/adapters";
import dayjs from "dayjs";
import type { NextAuthConfig } from "next-auth";
import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { eq, inArray } from "@homarr/db";
import { groupMembers, groupPermissions, users } from "@homarr/db/schema/sqlite";
import { getPermissionsWithChildren } from "@homarr/definitions";
import { env } from "./env.mjs";
import { expireDateAfter, generateSessionToken, sessionTokenCookieName } from "./session";
export const getCurrentUserPermissionsAsync = async (db: Database, userId: string) => {
const dbGroupMembers = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId),
@@ -30,6 +26,21 @@ export const getCurrentUserPermissionsAsync = async (db: Database, userId: strin
return getPermissionsWithChildren(permissionKeys);
};
export const createSessionAsync = async (
db: Database,
user: { id: string; email: string | null },
): Promise<Session> => {
return {
expires: dayjs().add(1, "day").toISOString(),
user: {
...user,
email: user.email ?? "",
permissions: await getCurrentUserPermissionsAsync(db, user.id),
colorScheme: "auto",
},
} as Session;
};
export const createSessionCallback = (db: Database): NextAuthCallbackOf<"session"> => {
return async ({ session, user }) => {
const additionalProperties = await db.query.users.findFirst({
@@ -52,51 +63,6 @@ export const createSessionCallback = (db: Database): NextAuthCallbackOf<"session
};
};
export const createSignInCallback =
(adapter: Adapter, db: Database, isCredentialsRequest: boolean): NextAuthCallbackOf<"signIn"> =>
async ({ user }) => {
if (!isCredentialsRequest) return true;
// https://github.com/nextauthjs/next-auth/issues/6106
if (!adapter.createSession || !user.id) {
return false;
}
const sessionToken = generateSessionToken();
const sessionExpires = expireDateAfter(env.AUTH_SESSION_EXPIRY_TIME);
await adapter.createSession({
sessionToken,
userId: user.id,
expires: sessionExpires,
});
cookies().set(sessionTokenCookieName, sessionToken, {
path: "/",
expires: sessionExpires,
httpOnly: true,
sameSite: "lax",
secure: true,
});
const dbUser = await db.query.users.findFirst({
where: eq(users.id, user.id),
columns: {
colorScheme: true,
},
});
if (!dbUser) return false;
// We use a cookie as localStorage is not shared with server (otherwise flickering would occur)
cookies().set("homarr-color-scheme", dbUser.colorScheme, {
path: "/",
expires: dayjs().add(1, "year").toDate(),
});
return true;
};
type NextAuthCallbackRecord = Exclude<NextAuthConfig["callbacks"], undefined>;
export type NextAuthCallbackOf<TKey extends keyof NextAuthCallbackRecord> = Exclude<
NextAuthCallbackRecord[TKey],

View File

@@ -4,18 +4,21 @@ import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { db } from "@homarr/db";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { adapter } from "./adapter";
import { createSessionCallback, createSignInCallback } from "./callbacks";
import { createAdapter } from "./adapter";
import { createSessionCallback } from "./callbacks";
import { env } from "./env.mjs";
import { createCredentialsConfiguration } from "./providers/credentials/credentials-provider";
import { createSignInEventHandler } from "./events";
import { createCredentialsConfiguration, createLdapConfiguration } from "./providers/credentials/credentials-provider";
import { EmptyNextAuthProvider } from "./providers/empty/empty-provider";
import { filterProviders } from "./providers/filter-providers";
import { OidcProvider } from "./providers/oidc/oidc-provider";
import { createRedirectUri } from "./redirect";
import { sessionTokenCookieName } from "./session";
import { generateSessionToken, sessionTokenCookieName } from "./session";
export const createConfiguration = (isCredentialsRequest: boolean, headers: ReadonlyHeaders | null) =>
// See why it's unknown in the [...nextauth]/route.ts file
export const createConfiguration = (provider: SupportedAuthProvider | "unknown", headers: ReadonlyHeaders | null) =>
NextAuth({
logger: {
error: (code, ...message) => {
@@ -30,21 +33,25 @@ export const createConfiguration = (isCredentialsRequest: boolean, headers: Read
},
},
trustHost: true,
adapter,
adapter: createAdapter(db, provider),
providers: filterProviders([
Credentials(createCredentialsConfiguration(db)),
Credentials(createLdapConfiguration(db)),
EmptyNextAuthProvider(),
OidcProvider(headers),
]),
callbacks: {
session: createSessionCallback(db),
signIn: createSignInCallback(adapter, db, isCredentialsRequest),
},
events: {
signIn: createSignInEventHandler(db),
},
redirectProxyUrl: createRedirectUri(headers, "/api/auth"),
secret: "secret-is-not-defined-yet", // TODO: This should be added later
session: {
strategy: "database",
maxAge: env.AUTH_SESSION_EXPIRY_TIME,
generateSessionToken,
},
pages: {
signIn: "/auth/login",

View File

@@ -74,6 +74,7 @@ export const env = createEnv({
AUTH_OIDC_CLIENT_NAME: z.string().min(1).default("OIDC"),
AUTH_OIDC_AUTO_LOGIN: booleanSchema,
AUTH_OIDC_SCOPE_OVERWRITE: z.string().min(1).default("openid email profile groups"),
AUTH_OIDC_GROUPS_ATTRIBUTE: z.string().default("groups"), // Is used in the signIn event to assign the correct groups, key is from object of decoded id_token
}
: {}),
...(authProviders.includes("ldap")
@@ -113,6 +114,7 @@ export const env = createEnv({
AUTH_OIDC_CLIENT_SECRET: process.env.AUTH_OIDC_CLIENT_SECRET,
AUTH_OIDC_ISSUER: process.env.AUTH_OIDC_ISSUER,
AUTH_OIDC_SCOPE_OVERWRITE: process.env.AUTH_OIDC_SCOPE_OVERWRITE,
AUTH_OIDC_GROUPS_ATTRIBUTE: process.env.AUTH_OIDC_GROUPS_ATTRIBUTE,
AUTH_LDAP_USERNAME_ATTRIBUTE: process.env.AUTH_LDAP_USERNAME_ATTRIBUTE,
AUTH_LDAP_USER_MAIL_ATTRIBUTE: process.env.AUTH_LDAP_USER_MAIL_ATTRIBUTE,
AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG: process.env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG,

131
packages/auth/events.ts Normal file
View File

@@ -0,0 +1,131 @@
import { cookies } from "next/headers";
import dayjs from "dayjs";
import type { NextAuthConfig } from "next-auth";
import { and, eq, inArray } from "@homarr/db";
import type { Database } from "@homarr/db";
import { groupMembers, groups, users } from "@homarr/db/schema/sqlite";
import { logger } from "@homarr/log";
import { env } from "./env.mjs";
export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["events"], undefined>["signIn"] => {
return async ({ user, profile }) => {
if (!user.id) throw new Error("User ID is missing");
const dbUser = await db.query.users.findFirst({
where: eq(users.id, user.id),
columns: {
name: true,
colorScheme: true,
},
});
if (!dbUser) throw new Error("User not found");
const groupsKey = env.AUTH_OIDC_GROUPS_ATTRIBUTE;
// Groups from oidc provider are provided from the profile, it's not typed.
if (profile && groupsKey in profile && Array.isArray(profile[groupsKey])) {
await synchronizeGroupsWithExternalForUserAsync(db, user.id, profile[groupsKey] as string[]);
}
// In ldap-authroization we return the groups from ldap, it's not typed.
if ("groups" in user && Array.isArray(user.groups)) {
await synchronizeGroupsWithExternalForUserAsync(db, user.id, user.groups as string[]);
}
if (dbUser.name !== user.name) {
await db.update(users).set({ name: user.name }).where(eq(users.id, user.id));
logger.info(
`Username for user of credentials provider has changed. user=${user.id} old=${dbUser.name} new=${user.name}`,
);
}
const profileUsername = profile?.preferred_username?.includes("@") ? profile.name : profile?.preferred_username;
if (profileUsername && dbUser.name !== profileUsername) {
await db.update(users).set({ name: profileUsername }).where(eq(users.id, user.id));
logger.info(
`Username for user of oidc provider has changed. user=${user.id} old='${dbUser.name}' new='${profileUsername}'`,
);
}
// We use a cookie as localStorage is not shared with server (otherwise flickering would occur)
cookies().set("homarr-color-scheme", dbUser.colorScheme, {
path: "/",
expires: dayjs().add(1, "year").toDate(),
});
};
};
const synchronizeGroupsWithExternalForUserAsync = async (db: Database, userId: string, externalGroups: string[]) => {
const dbGroupMembers = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId),
with: {
group: { columns: { name: true } },
},
});
/**
* The below groups are those groups the user is part of in the external system, but not in Homarr.
* So he has to be added to those groups.
*/
const missingExternalGroupsForUser = externalGroups.filter(
(externalGroup) => !dbGroupMembers.some(({ group }) => group.name === externalGroup),
);
if (missingExternalGroupsForUser.length > 0) {
logger.debug(
`Homarr does not have the user in certain groups. user=${userId} count=${missingExternalGroupsForUser.length}`,
);
const groupIds = await db.query.groups.findMany({
columns: {
id: true,
},
where: inArray(groups.name, missingExternalGroupsForUser),
});
logger.debug(`Homarr has found groups in the database user is not in. user=${userId} count=${groupIds.length}`);
if (groupIds.length > 0) {
await db.insert(groupMembers).values(
groupIds.map((group) => ({
userId,
groupId: group.id,
})),
);
logger.info(`Added user to groups successfully. user=${userId} count=${groupIds.length}`);
} else {
logger.debug(`User is already in all groups of Homarr. user=${userId}`);
}
}
/**
* The below groups are those groups the user is part of in Homarr, but not in the external system.
* So he has to be removed from those groups.
*/
const groupsUserIsNoLongerMemberOfExternally = dbGroupMembers.filter(
({ group }) => !externalGroups.includes(group.name),
);
if (groupsUserIsNoLongerMemberOfExternally.length > 0) {
logger.debug(
`Homarr has the user in certain groups that LDAP does not have. user=${userId} count=${groupsUserIsNoLongerMemberOfExternally.length}`,
);
await db.delete(groupMembers).where(
and(
eq(groupMembers.userId, userId),
inArray(
groupMembers.groupId,
groupsUserIsNoLongerMemberOfExternally.map(({ groupId }) => groupId),
),
),
);
logger.info(
`Removed user from groups successfully. user=${userId} count=${groupsUserIsNoLongerMemberOfExternally.length}`,
);
}
};

View File

@@ -1,7 +1,7 @@
import { headers } from "next/headers";
import type { DefaultSession } from "@auth/core/types";
import type { ColorScheme, GroupPermissionKey } from "@homarr/definitions";
import type { ColorScheme, GroupPermissionKey, SupportedAuthProvider } from "@homarr/definitions";
import { createConfiguration } from "./configuration";
@@ -19,6 +19,7 @@ declare module "next-auth" {
export * from "./security";
export const createHandlers = (isCredentialsRequest: boolean) => createConfiguration(isCredentialsRequest, headers());
// See why it's unknown in the [...nextauth]/route.ts file
export const createHandlers = (provider: SupportedAuthProvider | "unknown") => createConfiguration(provider, headers());
export { getSessionFromTokenAsync as getSessionFromToken, sessionTokenCookieName } from "./session";

View File

@@ -2,7 +2,7 @@ import { cache } from "react";
import { createConfiguration } from "./configuration";
const { auth: defaultAuth } = createConfiguration(false, null);
const { auth: defaultAuth } = createConfiguration("unknown", null);
/**
* This is the main way to get session data for your RSCs.

View File

@@ -23,8 +23,8 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@auth/core": "^0.35.3",
"@auth/drizzle-adapter": "^1.5.3",
"@auth/core": "^0.37.0",
"@auth/drizzle-adapter": "^1.7.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
@@ -34,7 +34,7 @@
"bcrypt": "^5.1.1",
"cookies": "^0.9.1",
"ldapts": "7.2.1",
"next": "^14.2.14",
"next": "^14.2.15",
"next-auth": "5.0.0-beta.22",
"react": "^18.3.1",
"react-dom": "^18.3.1"
@@ -45,8 +45,8 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/bcrypt": "5.0.2",
"@types/cookies": "0.9.0",
"eslint": "^9.11.1",
"eslint": "^9.12.0",
"prettier": "^3.3.3",
"typescript": "^5.6.2"
"typescript": "^5.6.3"
}
}

View File

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

View File

@@ -1,8 +1,8 @@
import { CredentialsSignin } from "@auth/core/errors";
import type { Database, InferInsertModel } from "@homarr/db";
import { and, createId, eq, inArray } from "@homarr/db";
import { groupMembers, groups, users } from "@homarr/db/schema/sqlite";
import { and, createId, eq } from "@homarr/db";
import { users } from "@homarr/db/schema/sqlite";
import { logger } from "@homarr/log";
import type { validation } from "@homarr/validation";
import { z } from "@homarr/validation";
@@ -99,18 +99,6 @@ export const authorizeWithLdapCredentialsAsync = async (
emailVerified: true,
provider: true,
},
with: {
groups: {
with: {
group: {
columns: {
id: true,
name: true,
},
},
},
},
},
where: and(eq(users.email, mailResult.data), eq(users.provider, "ldap")),
});
@@ -128,79 +116,16 @@ export const authorizeWithLdapCredentialsAsync = async (
await db.insert(users).values(insertUser);
user = {
...insertUser,
groups: [],
};
user = insertUser;
logger.info(`User ${credentials.name} created successfully.`);
}
if (user.name !== credentials.name) {
logger.warn(`User ${credentials.name} found in the database but with different name. Updating...`);
user.name = credentials.name;
await db.update(users).set({ name: user.name }).where(eq(users.id, user.id));
logger.info(`User ${credentials.name} updated successfully.`);
}
const ldapGroupsUserIsNotIn = userGroups.filter(
(group) => !user.groups.some((userGroup) => userGroup.group.name === group),
);
if (ldapGroupsUserIsNotIn.length > 0) {
logger.debug(
`Homarr does not have the user in certain groups. user=${user.name} count=${ldapGroupsUserIsNotIn.length}`,
);
const groupIds = await db.query.groups.findMany({
columns: {
id: true,
},
where: inArray(groups.name, ldapGroupsUserIsNotIn),
});
logger.debug(`Homarr has found groups in the database user is not in. user=${user.name} count=${groupIds.length}`);
if (groupIds.length > 0) {
await db.insert(groupMembers).values(
groupIds.map((group) => ({
userId: user.id,
groupId: group.id,
})),
);
logger.info(`Added user to groups successfully. user=${user.name} count=${groupIds.length}`);
} else {
logger.debug(`User is already in all groups of Homarr. user=${user.name}`);
}
}
const homarrGroupsUserIsNotIn = user.groups.filter((userGroup) => !userGroups.includes(userGroup.group.name));
if (homarrGroupsUserIsNotIn.length > 0) {
logger.debug(
`Homarr has the user in certain groups that LDAP does not have. user=${user.name} count=${homarrGroupsUserIsNotIn.length}`,
);
await db.delete(groupMembers).where(
and(
eq(groupMembers.userId, user.id),
inArray(
groupMembers.groupId,
homarrGroupsUserIsNotIn.map(({ groupId }) => groupId),
),
),
);
logger.info(`Removed user from groups successfully. user=${user.name} count=${homarrGroupsUserIsNotIn.length}`);
}
return {
id: user.id,
name: user.name,
name: credentials.name,
// Groups is used in events.ts to synchronize groups with external systems
groups: userGroups,
};
};

View File

@@ -10,30 +10,25 @@ type CredentialsConfiguration = Parameters<typeof Credentials>[0];
export const createCredentialsConfiguration = (db: Database) =>
({
id: "credentials",
type: "credentials",
name: "Credentials",
credentials: {
name: {
label: "Username",
type: "text",
},
password: {
label: "Password",
type: "password",
},
isLdap: {
label: "LDAP",
type: "checkbox",
},
},
// eslint-disable-next-line no-restricted-syntax
async authorize(credentials) {
const data = await validation.user.signIn.parseAsync(credentials);
if (data.credentialType === "ldap") {
return await authorizeWithLdapCredentialsAsync(db, data).catch(() => null);
}
return await authorizeWithBasicCredentialsAsync(db, data);
},
}) satisfies CredentialsConfiguration;
export const createLdapConfiguration = (db: Database) =>
({
id: "ldap",
type: "credentials",
name: "Ldap",
// eslint-disable-next-line no-restricted-syntax
async authorize(credentials) {
const data = await validation.user.signIn.parseAsync(credentials);
return await authorizeWithLdapCredentialsAsync(db, data).catch(() => null);
},
}) satisfies CredentialsConfiguration;

View File

@@ -25,7 +25,6 @@ describe("authorizeWithBasicCredentials", () => {
const result = await authorizeWithBasicCredentialsAsync(db, {
name: "test",
password: "test",
credentialType: "basic",
});
// Assert
@@ -47,7 +46,6 @@ describe("authorizeWithBasicCredentials", () => {
const result = await authorizeWithBasicCredentialsAsync(db, {
name: "test",
password: "wrong",
credentialType: "basic",
});
// Assert
@@ -69,7 +67,6 @@ describe("authorizeWithBasicCredentials", () => {
const result = await authorizeWithBasicCredentialsAsync(db, {
name: "wrong",
password: "test",
credentialType: "basic",
});
// Assert
@@ -88,7 +85,6 @@ describe("authorizeWithBasicCredentials", () => {
const result = await authorizeWithBasicCredentialsAsync(db, {
name: "test",
password: "test",
credentialType: "basic",
});
// Assert

View File

@@ -3,7 +3,7 @@ import { describe, expect, test, vi } from "vitest";
import type { Database } from "@homarr/db";
import { and, createId, eq } from "@homarr/db";
import { groupMembers, groups, users } from "@homarr/db/schema/sqlite";
import { groups, users } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import { authorizeWithLdapCredentialsAsync } from "../credentials/authorization/ldap-authorization";
@@ -34,7 +34,6 @@ describe("authorizeWithLdapCredentials", () => {
authorizeWithLdapCredentialsAsync(null as unknown as Database, {
name: "test",
password: "test",
credentialType: "ldap",
});
// Assert
@@ -57,7 +56,6 @@ describe("authorizeWithLdapCredentials", () => {
authorizeWithLdapCredentialsAsync(null as unknown as Database, {
name: "test",
password: "test",
credentialType: "ldap",
});
// Assert
@@ -87,7 +85,6 @@ describe("authorizeWithLdapCredentials", () => {
authorizeWithLdapCredentialsAsync(null as unknown as Database, {
name: "test",
password: "test",
credentialType: "ldap",
});
// Assert
@@ -120,7 +117,6 @@ describe("authorizeWithLdapCredentials", () => {
authorizeWithLdapCredentialsAsync(null as unknown as Database, {
name: "test",
password: "test",
credentialType: "ldap",
});
// Assert
@@ -152,11 +148,11 @@ describe("authorizeWithLdapCredentials", () => {
const result = await authorizeWithLdapCredentialsAsync(db, {
name: "test",
password: "test",
credentialType: "ldap",
});
// Assert
expect(result.name).toBe("test");
expect(result.groups).toHaveLength(0); // Groups are needed in signIn events callback
const dbUser = await db.query.users.findFirst({
where: eq(users.name, "test"),
});
@@ -197,11 +193,11 @@ describe("authorizeWithLdapCredentials", () => {
const result = await authorizeWithLdapCredentialsAsync(db, {
name: "test",
password: "test",
credentialType: "ldap",
});
// Assert
expect(result.name).toBe("test");
expect(result.groups).toHaveLength(0); // Groups are needed in signIn events callback
const dbUser = await db.query.users.findFirst({
where: and(eq(users.name, "test"), eq(users.provider, "ldap")),
});
@@ -219,7 +215,8 @@ describe("authorizeWithLdapCredentials", () => {
expect(credentialsUser?.id).not.toBe(result.id);
});
test("should authorize user with correct credentials and update name", async () => {
// The name update occurs in the signIn event callback
test("should authorize user with correct credentials and return updated name", async () => {
// Arrange
const spy = vi.spyOn(ldapClient, "LdapClient");
spy.mockImplementation(
@@ -251,11 +248,10 @@ describe("authorizeWithLdapCredentials", () => {
const result = await authorizeWithLdapCredentialsAsync(db, {
name: "test",
password: "test",
credentialType: "ldap",
});
// Assert
expect(result).toEqual({ id: userId, name: "test" });
expect(result).toEqual({ id: userId, name: "test", groups: [] });
const dbUser = await db.query.users.findFirst({
where: eq(users.id, userId),
@@ -263,12 +259,12 @@ describe("authorizeWithLdapCredentials", () => {
expect(dbUser).toBeDefined();
expect(dbUser?.id).toBe(userId);
expect(dbUser?.name).toBe("test");
expect(dbUser?.name).toBe("test-old");
expect(dbUser?.email).toBe("test@gmail.com");
expect(dbUser?.provider).toBe("ldap");
});
test("should authorize user with correct credentials and add him to the groups that he is in LDAP but not in Homar", async () => {
test("should authorize user with correct credentials and return his groups", async () => {
// Arrange
const spy = vi.spyOn(ldapClient, "LdapClient");
spy.mockImplementation(
@@ -311,83 +307,9 @@ describe("authorizeWithLdapCredentials", () => {
const result = await authorizeWithLdapCredentialsAsync(db, {
name: "test",
password: "test",
credentialType: "ldap",
});
// Assert
expect(result).toEqual({ id: userId, name: "test" });
const dbGroupMembers = await db.query.groupMembers.findMany();
expect(dbGroupMembers).toHaveLength(1);
});
test("should authorize user with correct credentials and remove him from groups he is in Homarr but not in LDAP", async () => {
// Arrange
const spy = vi.spyOn(ldapClient, "LdapClient");
spy.mockImplementation(
() =>
({
bindAsync: vi.fn(() => Promise.resolve()),
searchAsync: vi.fn((argument: { options: { filter: string } }) =>
argument.options.filter.includes("group")
? Promise.resolve([
{
cn: "homarr_example",
},
])
: Promise.resolve([
{
dn: "test55",
mail: "test@gmail.com",
},
]),
),
disconnectAsync: vi.fn(),
}) as unknown as ldapClient.LdapClient,
);
const db = createDb();
const userId = createId();
await db.insert(users).values({
id: userId,
name: "test",
email: "test@gmail.com",
provider: "ldap",
});
const groupIds = [createId(), createId()] as const;
await db.insert(groups).values([
{
id: groupIds[0],
name: "homarr_example",
},
{
id: groupIds[1],
name: "homarr_no_longer_member",
},
]);
await db.insert(groupMembers).values([
{
userId,
groupId: groupIds[0],
},
{
userId,
groupId: groupIds[1],
},
]);
// Act
const result = await authorizeWithLdapCredentialsAsync(db, {
name: "test",
password: "test",
credentialType: "ldap",
});
// Assert
expect(result).toEqual({ id: userId, name: "test" });
const dbGroupMembers = await db.query.groupMembers.findMany();
expect(dbGroupMembers).toHaveLength(1);
expect(dbGroupMembers[0]?.groupId).toBe(groupIds[0]);
expect(result).toEqual({ id: userId, name: "test", groups: ["homarr_example"] });
});
});

View File

@@ -1,3 +1,4 @@
export { hasQueryAccessToIntegrationsAsync } from "./permissions/integration-query-permissions";
export { getIntegrationsWithPermissionsAsync } from "./permissions/integrations-with-permissions";
export { isProviderEnabled } from "./providers/check-provider";
export { createSessionCallback, createSessionAsync } from "./callbacks";

View File

@@ -5,7 +5,8 @@ import type { Database } from "@homarr/db";
import { getCurrentUserPermissionsAsync } from "./callbacks";
export const sessionTokenCookieName = "next-auth.session-token";
// Default of authjs
export const sessionTokenCookieName = "authjs.session-token";
export const expireDateAfter = (seconds: number) => {
return new Date(Date.now() + seconds * 1000);

View File

@@ -0,0 +1,67 @@
import { describe, expect, test } from "vitest";
import { users } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import { createAdapter } from "../adapter";
describe("createAdapter should create drizzle adapter", () => {
test.each([["credentials" as const], ["ldap" as const], ["oidc" as const]])(
"createAdapter getUserByEmail should return user for provider %s when this provider provided",
async (provider) => {
// Arrange
const db = createDb();
const adapter = createAdapter(db, provider);
const email = "test@example.com";
await db.insert(users).values({ id: "1", name: "test", email, provider });
// Act
const user = await adapter.getUserByEmail?.(email);
// Assert
expect(user).toEqual({
id: "1",
name: "test",
email,
emailVerified: null,
image: null,
});
},
);
test.each([
["credentials", ["ldap", "oidc"]],
["ldap", ["credentials", "oidc"]],
["oidc", ["credentials", "ldap"]],
] as const)(
"createAdapter getUserByEmail should return null if only for other providers than %s exist",
async (requestedProvider, existingProviders) => {
// Arrange
const db = createDb();
const adapter = createAdapter(db, requestedProvider);
const email = "test@example.com";
for (const provider of existingProviders) {
await db.insert(users).values({ id: provider, name: `test-${provider}`, email, provider });
}
// Act
const user = await adapter.getUserByEmail?.(email);
// Assert
expect(user).toBeNull();
},
);
test("createAdapter getUserByEmail should throw error if provider is unknown", async () => {
// Arrange
const db = createDb();
const adapter = createAdapter(db, "unknown");
const email = "test@example.com";
// Act
const actAsync = async () => await adapter.getUserByEmail?.(email);
// Assert
await expect(actAsync()).rejects.toThrow("Unable to get user by email for unknown provider");
});
});

View File

@@ -1,9 +1,5 @@
/* 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 type { Adapter, AdapterUser } from "@auth/core/adapters";
import type { Account } from "next-auth";
import type { AdapterUser } from "@auth/core/adapters";
import type { JWT } from "next-auth/jwt";
import { describe, expect, test, vi } from "vitest";
@@ -11,7 +7,15 @@ import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema
import { createDb } from "@homarr/db/test";
import * as definitions from "@homarr/definitions";
import { createSessionCallback, createSignInCallback, getCurrentUserPermissionsAsync } from "../callbacks";
import { createSessionCallback, 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", () => {
test("should return empty permissions when non existing user requested", async () => {
@@ -135,167 +139,3 @@ describe("session callback", () => {
expect(result.user!.name).toEqual(user.name);
});
});
type AdapterSessionInput = Parameters<Exclude<Adapter["createSession"], undefined>>[0];
const createAdapter = () => {
const result = {
createSession: (input: AdapterSessionInput) => input,
};
vi.spyOn(result, "createSession");
return result;
};
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
type SessionExport = typeof import("../session");
const mockSessionToken = "e9ef3010-6981-4a81-b9d6-8495d09cf3b5";
const mockSessionExpiry = new Date("2023-07-01");
vi.mock("../env.mjs", () => {
return {
env: {
AUTH_SESSION_EXPIRY_TIME: 60 * 60 * 24 * 7,
},
};
});
vi.mock("../session", async (importOriginal) => {
const mod = await importOriginal<SessionExport>();
const generateSessionToken = (): typeof mockSessionToken => mockSessionToken;
const expireDateAfter = (_seconds: number) => mockSessionExpiry;
return {
...mod,
generateSessionToken,
expireDateAfter,
} 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", () => {
test("should return true if not credentials request and set colorScheme & sessionToken cookie", async () => {
// Arrange
const isCredentialsRequest = false;
const db = await prepareDbForSigninAsync("1");
const signInCallback = createSignInCallback(createAdapter(), db, isCredentialsRequest);
// Act
const result = await signInCallback({
user: { id: "1", emailVerified: new Date("2023-01-13") },
account: {} as Account,
});
// Assert
expect(result).toBe(true);
});
test("should return false if no adapter.createSession", async () => {
// Arrange
const isCredentialsRequest = true;
const db = await prepareDbForSigninAsync("1");
const signInCallback = createSignInCallback(
// https://github.com/nextauthjs/next-auth/issues/6106
{ createSession: undefined } as unknown as Adapter,
db,
isCredentialsRequest,
);
// Act
const result = await signInCallback({
user: { id: "1", emailVerified: new Date("2023-01-13") },
account: {} as Account,
});
// Assert
expect(result).toBe(false);
});
test("should call adapter.createSession with correct input", async () => {
// Arrange
const adapter = createAdapter();
const isCredentialsRequest = true;
const db = await prepareDbForSigninAsync("1");
const signInCallback = createSignInCallback(adapter, db, isCredentialsRequest);
const user = { id: "1", emailVerified: new Date("2023-01-13") };
const account = {} as Account;
// Act
await signInCallback({ user, account });
// Assert
expect(adapter.createSession).toHaveBeenCalledWith({
sessionToken: mockSessionToken,
userId: user.id,
expires: mockSessionExpiry,
});
expect(cookies().set).toHaveBeenCalledWith("next-auth.session-token", mockSessionToken, {
path: "/",
expires: mockSessionExpiry,
httpOnly: true,
sameSite: "lax",
secure: true,
});
});
test("should set colorScheme from db as cookie", async () => {
// Arrange
const isCredentialsRequest = false;
const db = await prepareDbForSigninAsync("1");
const signInCallback = createSignInCallback(createAdapter(), db, isCredentialsRequest);
// Act
const result = await signInCallback({
user: { id: "1", emailVerified: new Date("2023-01-13") },
account: {} as Account,
});
// Assert
expect(result).toBe(true);
expect(cookies().set).toHaveBeenCalledWith(
"homarr-color-scheme",
"dark",
expect.objectContaining({
path: "/",
}),
);
});
test("should return false if user not found in db", async () => {
// Arrange
const isCredentialsRequest = true;
const db = await prepareDbForSigninAsync("other-id");
const signInCallback = createSignInCallback(createAdapter(), db, isCredentialsRequest);
// Act
const result = await signInCallback({
user: { id: "1", emailVerified: new Date("2023-01-13") },
account: {} as Account,
});
// Assert
expect(result).toBe(false);
});
});
const prepareDbForSigninAsync = async (userId: string) => {
const db = createDb();
await db.insert(users).values({
id: userId,
colorScheme: "dark",
});
return db;
};

View File

@@ -0,0 +1,190 @@
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 { describe, expect, test, vi } from "vitest";
import { eq } from "@homarr/db";
import type { Database } from "@homarr/db";
import { groupMembers, groups, users } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import { createSignInEventHandler } from "../events";
vi.mock("../env.mjs", () => {
return {
env: {
AUTH_OIDC_GROUPS_ATTRIBUTE: "someRandomGroupsKey",
},
};
});
// 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("createSignInEventHandler should create signInEventHandler", () => {
describe("signInEventHandler should synchronize ldap groups", () => {
test("should add missing group membership", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createGroupAsync(db);
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test", groups: ["test"] } as never,
profile: undefined,
account: null,
});
// Assert
const dbGroupMembers = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "1"),
});
expect(dbGroupMembers?.groupId).toBe("1");
});
test("should remove group membership", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createGroupAsync(db);
await db.insert(groupMembers).values({
userId: "1",
groupId: "1",
});
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test", groups: [] } as never,
profile: undefined,
account: null,
});
// Assert
const dbGroupMembers = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "1"),
});
expect(dbGroupMembers).toBeUndefined();
});
});
describe("signInEventHandler should synchronize oidc groups", () => {
test("should add missing group membership", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createGroupAsync(db);
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test" },
profile: { preferred_username: "test", someRandomGroupsKey: ["test"] },
account: null,
});
// Assert
const dbGroupMembers = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "1"),
});
expect(dbGroupMembers?.groupId).toBe("1");
});
test("should remove group membership", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createGroupAsync(db);
await db.insert(groupMembers).values({
userId: "1",
groupId: "1",
});
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test" },
profile: { preferred_username: "test", someRandomGroupsKey: [] },
account: null,
});
// Assert
const dbGroupMembers = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "1"),
});
expect(dbGroupMembers).toBeUndefined();
});
});
test.each([
["ldap" as const, { name: "test-new" }, undefined],
["oidc" as const, { name: "test" }, { preferred_username: "test-new" }],
["oidc" as const, { name: "test" }, { preferred_username: "test@example.com", name: "test-new" }],
])("signInEventHandler should update username for %s provider", async (_provider, user, profile) => {
// Arrange
const db = createDb();
await createUserAsync(db);
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", ...user },
profile,
account: null,
});
// Assert
const dbUser = await db.query.users.findFirst({
where: eq(users.id, "1"),
columns: {
name: true,
},
});
expect(dbUser?.name).toBe("test-new");
});
test("signInEventHandler should set homarr-color-scheme cookie", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test" },
profile: undefined,
account: null,
});
// Assert
expect(cookies().set).toHaveBeenCalledWith(
"homarr-color-scheme",
"dark",
expect.objectContaining({
path: "/",
}),
);
});
});
const createUserAsync = async (db: Database) =>
await db.insert(users).values({
id: "1",
name: "test",
colorScheme: "dark",
});
const createGroupAsync = async (db: Database) =>
await db.insert(groups).values({
id: "1",
name: "test",
});

View File

@@ -33,7 +33,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.1",
"typescript": "^5.6.2"
"eslint": "^9.12.0",
"typescript": "^5.6.3"
}
}

View File

@@ -27,7 +27,7 @@
"dependencies": {
"@homarr/log": "workspace:^0.1.0",
"dayjs": "^1.11.13",
"next": "^14.2.14",
"next": "^14.2.15",
"react": "^18.3.1",
"tldts": "^6.1.50"
},
@@ -35,7 +35,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.1",
"typescript": "^5.6.2"
"eslint": "^9.12.0",
"typescript": "^5.6.3"
}
}

View File

@@ -30,7 +30,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.1",
"typescript": "^5.6.2"
"eslint": "^9.12.0",
"typescript": "^5.6.3"
}
}

View File

@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.1",
"typescript": "^5.6.2"
"eslint": "^9.12.0",
"typescript": "^5.6.3"
}
}

View File

@@ -32,7 +32,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "^3.0.11",
"eslint": "^9.11.1",
"typescript": "^5.6.2"
"eslint": "^9.12.0",
"typescript": "^5.6.3"
}
}

View File

@@ -41,7 +41,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.1",
"typescript": "^5.6.2"
"eslint": "^9.12.0",
"typescript": "^5.6.3"
}
}

View File

@@ -0,0 +1,9 @@
CREATE TABLE `apiKey` (
`id` varchar(64) NOT NULL,
`apiKey` text NOT NULL,
`salt` text NOT NULL,
`userId` varchar(64) NOT NULL,
CONSTRAINT `apiKey_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `apiKey` ADD CONSTRAINT `apiKey_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TABLE `user` ADD `firstDayOfWeek` tinyint DEFAULT 1 NOT NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,20 @@
"when": 1727532165317,
"tag": "0008_far_lifeguard",
"breakpoints": true
},
{
"idx": 9,
"version": "5",
"when": 1728074730696,
"tag": "0009_wakeful_tenebrous",
"breakpoints": true
},
{
"idx": 10,
"version": "5",
"when": 1728142597094,
"tag": "0010_melted_pestilence",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,7 @@
CREATE TABLE `apiKey` (
`id` text PRIMARY KEY NOT NULL,
`apiKey` text NOT NULL,
`salt` text NOT NULL,
`userId` text NOT NULL,
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);

View File

@@ -0,0 +1 @@
ALTER TABLE `user` ADD `firstDayOfWeek` integer DEFAULT 1 NOT NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,20 @@
"when": 1727526190343,
"tag": "0008_third_thor",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1728074724956,
"tag": "0009_stale_roulette",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1728142590232,
"tag": "0010_gorgeous_stingray",
"breakpoints": true
}
]
}

View File

@@ -31,7 +31,7 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@auth/core": "^0.35.3",
"@auth/core": "^0.37.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
@@ -39,8 +39,8 @@
"@testcontainers/mysql": "^10.13.2",
"better-sqlite3": "^11.3.0",
"dotenv": "^16.4.5",
"drizzle-kit": "^0.24.2",
"drizzle-orm": "^0.33.0",
"drizzle-kit": "^0.25.0",
"drizzle-orm": "^0.34.1",
"mysql2": "3.11.3"
},
"devDependencies": {
@@ -49,8 +49,8 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/better-sqlite3": "7.6.11",
"dotenv-cli": "^7.4.2",
"eslint": "^9.11.1",
"eslint": "^9.12.0",
"prettier": "^3.3.3",
"typescript": "^5.6.2"
"typescript": "^5.6.3"
}
}

View File

@@ -1,7 +1,8 @@
import type { AdapterAccount } from "@auth/core/adapters";
import type { DayOfWeek } from "@mantine/dates";
import { relations } from "drizzle-orm";
import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
import { boolean, index, int, mysqlTable, primaryKey, text, timestamp, varchar } from "drizzle-orm/mysql-core";
import { boolean, index, int, mysqlTable, primaryKey, text, timestamp, tinyint, varchar } from "drizzle-orm/mysql-core";
import type {
BackgroundImageAttachment,
@@ -19,6 +20,17 @@ import type {
} from "@homarr/definitions";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
export const apiKeys = mysqlTable("apiKey", {
id: varchar("id", { length: 64 }).notNull().primaryKey(),
apiKey: text("apiKey").notNull(),
salt: text("salt").notNull(),
userId: varchar("userId", { length: 64 })
.notNull()
.references((): AnyMySqlColumn => users.id, {
onDelete: "cascade",
}),
});
export const users = mysqlTable("user", {
id: varchar("id", { length: 64 }).notNull().primaryKey(),
name: text("name"),
@@ -32,6 +44,7 @@ export const users = mysqlTable("user", {
onDelete: "set null",
}),
colorScheme: varchar("colorScheme", { length: 5 }).$type<ColorScheme>().default("auto").notNull(),
firstDayOfWeek: tinyint("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
});
export const accounts = mysqlTable(
@@ -341,6 +354,13 @@ export const serverSettings = mysqlTable("serverSetting", {
value: text("value").default('{"json": {}}').notNull(), // empty superjson object
});
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
user: one(users, {
fields: [apiKeys.userId],
references: [users.id],
}),
}));
export const searchEngines = mysqlTable("search_engine", {
id: varchar("id", { length: 64 }).notNull().primaryKey(),
iconUrl: text("icon_url").notNull(),

View File

@@ -1,4 +1,5 @@
import type { AdapterAccount } from "@auth/core/adapters";
import type { DayOfWeek } from "@mantine/dates";
import type { InferSelectModel } from "drizzle-orm";
import { relations } from "drizzle-orm";
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
@@ -20,6 +21,17 @@ import type {
WidgetKind,
} from "@homarr/definitions";
export const apiKeys = sqliteTable("apiKey", {
id: text("id").notNull().primaryKey(),
apiKey: text("apiKey").notNull(),
salt: text("salt").notNull(),
userId: text("userId")
.notNull()
.references((): AnySQLiteColumn => users.id, {
onDelete: "cascade",
}),
});
export const users = sqliteTable("user", {
id: text("id").notNull().primaryKey(),
name: text("name"),
@@ -33,6 +45,7 @@ export const users = sqliteTable("user", {
onDelete: "set null",
}),
colorScheme: text("colorScheme").$type<ColorScheme>().default("auto").notNull(),
firstDayOfWeek: int("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
});
export const accounts = sqliteTable(
@@ -343,6 +356,13 @@ export const serverSettings = sqliteTable("serverSetting", {
value: text("value").default('{"json": {}}').notNull(), // empty superjson object
});
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
user: one(users, {
fields: [apiKeys.userId],
references: [users.id],
}),
}));
export const searchEngines = sqliteTable("search_engine", {
id: text("id").notNull().primaryKey(),
iconUrl: text("icon_url").notNull(),

View File

@@ -28,7 +28,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.1",
"typescript": "^5.6.2"
"eslint": "^9.12.0",
"typescript": "^5.6.3"
}
}

View File

@@ -30,7 +30,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.1",
"typescript": "^5.6.2"
"eslint": "^9.12.0",
"typescript": "^5.6.3"
}
}

View File

@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.1",
"typescript": "^5.6.2"
"eslint": "^9.12.0",
"typescript": "^5.6.3"
}
}

View File

@@ -39,7 +39,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.1",
"typescript": "^5.6.2"
"eslint": "^9.12.0",
"typescript": "^5.6.3"
}
}

View File

@@ -28,13 +28,13 @@
"dependencies": {
"ioredis": "5.4.1",
"superjson": "2.2.1",
"winston": "3.14.2"
"winston": "3.15.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.1",
"typescript": "^5.6.2"
"eslint": "^9.12.0",
"typescript": "^5.6.3"
}
}

View File

@@ -33,15 +33,15 @@
"@mantine/core": "^7.13.2",
"@tabler/icons-react": "^3.19.0",
"dayjs": "^1.11.13",
"next": "^14.2.14",
"next": "^14.2.15",
"react": "^18.3.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.1",
"typescript": "^5.6.2"
"eslint": "^9.12.0",
"typescript": "^5.6.3"
},
"prettier": "@homarr/prettier-config"
}

View File

@@ -32,7 +32,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.1",
"typescript": "^5.6.2"
"eslint": "^9.12.0",
"typescript": "^5.6.3"
}
}

View File

@@ -31,7 +31,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.1",
"typescript": "^5.6.2"
"eslint": "^9.12.0",
"typescript": "^5.6.3"
}
}

View File

@@ -33,8 +33,8 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.1",
"typescript": "^5.6.2"
"eslint": "^9.12.0",
"typescript": "^5.6.3"
},
"prettier": "@homarr/prettier-config"
}

View File

@@ -27,8 +27,8 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.1",
"typescript": "^5.6.2"
"eslint": "^9.12.0",
"typescript": "^5.6.3"
},
"prettier": "@homarr/prettier-config"
}

View File

@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.1",
"typescript": "^5.6.2"
"eslint": "^9.12.0",
"typescript": "^5.6.3"
}
}

View File

@@ -33,7 +33,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.1",
"typescript": "^5.6.2"
"eslint": "^9.12.0",
"typescript": "^5.6.3"
}
}

View File

@@ -25,7 +25,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.1",
"typescript": "^5.6.2"
"eslint": "^9.12.0",
"typescript": "^5.6.3"
}
}

View File

@@ -35,7 +35,7 @@
"@mantine/spotlight": "^7.13.2",
"@tabler/icons-react": "^3.19.0",
"jotai": "^2.10.0",
"next": "^14.2.14",
"next": "^14.2.15",
"react": "^18.3.1",
"use-deep-compare-effect": "^1.8.1"
},
@@ -43,8 +43,8 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.11.1",
"typescript": "^5.6.2"
"eslint": "^9.12.0",
"typescript": "^5.6.3"
},
"prettier": "@homarr/prettier-config"
}

View File

@@ -51,10 +51,7 @@ export const Spotlight = () => {
store={spotlightStore}
>
<MantineSpotlight.Search
placeholder={t("common.rtl", {
value: t("search.placeholder"),
symbol: "...",
})}
placeholder={`${t("search.placeholder")}...`}
ref={inputRef}
leftSectionWidth={activeMode.modeKey !== "help" ? 80 : 48}
leftSection={

View File

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

Some files were not shown because too many files have changed in this diff Show More