mirror of
https://github.com/ajnart/homarr.git
synced 2026-03-01 09:50:56 +01:00
chore(release): automatic release v0.1.0
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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)} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
41
packages/api/src/router/apiKeys.ts
Normal file
41
packages/api/src/router/apiKeys.ts
Normal 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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -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));
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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)[] = [];
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = () => ({
|
||||
|
||||
@@ -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]]);
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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!,
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
131
packages/auth/events.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
67
packages/auth/test/adapter.spec.ts
Normal file
67
packages/auth/test/adapter.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
190
packages/auth/test/events.spec.ts
Normal file
190
packages/auth/test/events.spec.ts
Normal 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",
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
9
packages/db/migrations/mysql/0009_wakeful_tenebrous.sql
Normal file
9
packages/db/migrations/mysql/0009_wakeful_tenebrous.sql
Normal 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;
|
||||
1
packages/db/migrations/mysql/0010_melted_pestilence.sql
Normal file
1
packages/db/migrations/mysql/0010_melted_pestilence.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `user` ADD `firstDayOfWeek` tinyint DEFAULT 1 NOT NULL;
|
||||
1481
packages/db/migrations/mysql/meta/0009_snapshot.json
Normal file
1481
packages/db/migrations/mysql/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1489
packages/db/migrations/mysql/meta/0010_snapshot.json
Normal file
1489
packages/db/migrations/mysql/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
7
packages/db/migrations/sqlite/0009_stale_roulette.sql
Normal file
7
packages/db/migrations/sqlite/0009_stale_roulette.sql
Normal 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
|
||||
);
|
||||
1
packages/db/migrations/sqlite/0010_gorgeous_stingray.sql
Normal file
1
packages/db/migrations/sqlite/0010_gorgeous_stingray.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `user` ADD `firstDayOfWeek` integer DEFAULT 1 NOT NULL;
|
||||
1414
packages/db/migrations/sqlite/meta/0009_snapshot.json
Normal file
1414
packages/db/migrations/sqlite/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1422
packages/db/migrations/sqlite/meta/0010_snapshot.json
Normal file
1422
packages/db/migrations/sqlite/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user