diff --git a/apps/nextjs/next.config.mjs b/apps/nextjs/next.config.mjs
index 95b601a66..d48c97617 100644
--- a/apps/nextjs/next.config.mjs
+++ b/apps/nextjs/next.config.mjs
@@ -29,6 +29,9 @@ const config = {
"@mantine/spotlight",
],
},
+ images: {
+ domains: ["cdn.jsdelivr.net"],
+ },
};
export default config;
diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json
index 3531dcd6f..770c54a2d 100644
--- a/apps/nextjs/package.json
+++ b/apps/nextjs/package.json
@@ -15,7 +15,12 @@
"dependencies": {
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
+ "@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
+ "@homarr/definitions": "workspace:^0.1.0",
+ "@homarr/form": "workspace:^0.1.0",
+ "@homarr/notifications": "workspace:^0.1.0",
+ "@homarr/spotlight": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/spotlight": "workspace:^0.1.0",
@@ -24,6 +29,7 @@
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/hooks": "^7.3.2",
+ "@mantine/modals": "^7.3.2",
"@mantine/tiptap": "^7.3.2",
"@t3-oss/env-nextjs": "^0.7.1",
"@tanstack/react-query": "^5.8.7",
diff --git a/apps/nextjs/src/app/[locale]/_client-providers/modals.tsx b/apps/nextjs/src/app/[locale]/_client-providers/modals.tsx
index 1287f756a..c5f8ca826 100644
--- a/apps/nextjs/src/app/[locale]/_client-providers/modals.tsx
+++ b/apps/nextjs/src/app/[locale]/_client-providers/modals.tsx
@@ -2,8 +2,28 @@
import type { PropsWithChildren } from "react";
+import { useScopedI18n } from "@homarr/translation/client";
+
import { ModalsManager } from "../modals";
export const ModalsProvider = ({ children }: PropsWithChildren) => {
- return {children};
+ const t = useScopedI18n("common.action");
+ return (
+
+ {children}
+
+ );
};
diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx
index 0260e6323..731c027ae 100644
--- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx
+++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx
@@ -8,7 +8,7 @@ import { loggerLink, unstable_httpBatchStreamLink } from "@trpc/client";
import superjson from "superjson";
import { env } from "~/env.mjs";
-import { api } from "~/utils/api";
+import { api } from "~/trpc/react";
const getBaseUrl = () => {
if (typeof window !== "undefined") return ""; // browser should use relative url
diff --git a/apps/nextjs/src/app/[locale]/init/user/_components/init-user-form.tsx b/apps/nextjs/src/app/[locale]/init/user/_components/init-user-form.tsx
index 34732bdb9..913d6ee57 100644
--- a/apps/nextjs/src/app/[locale]/init/user/_components/init-user-form.tsx
+++ b/apps/nextjs/src/app/[locale]/init/user/_components/init-user-form.tsx
@@ -12,7 +12,7 @@ import { Button, PasswordInput, Stack, TextInput } from "@homarr/ui";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
-import { api } from "~/utils/api";
+import { api } from "~/trpc/react";
export const InitUserForm = () => {
const router = useRouter();
diff --git a/apps/nextjs/src/app/[locale]/integrations/_accordion.tsx b/apps/nextjs/src/app/[locale]/integrations/_accordion.tsx
new file mode 100644
index 000000000..389bed980
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/integrations/_accordion.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import type { PropsWithChildren } from "react";
+import { useRouter } from "next/navigation";
+
+import type { IntegrationKind } from "@homarr/definitions";
+import { Accordion } from "@homarr/ui";
+
+type IntegrationGroupAccordionControlProps = PropsWithChildren<{
+ activeTab: IntegrationKind | undefined;
+}>;
+
+export const IntegrationGroupAccordion = ({
+ children,
+ activeTab,
+}: IntegrationGroupAccordionControlProps) => {
+ const router = useRouter();
+
+ return (
+
+ tab
+ ? router.replace(`?tab=${tab}`, {})
+ : router.replace("/integrations")
+ }
+ >
+ {children}
+
+ );
+};
diff --git a/apps/nextjs/src/app/[locale]/integrations/_avatar.tsx b/apps/nextjs/src/app/[locale]/integrations/_avatar.tsx
new file mode 100644
index 000000000..0d9eb5fdf
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/integrations/_avatar.tsx
@@ -0,0 +1,18 @@
+import { getIconUrl } from "@homarr/definitions";
+import type { IntegrationKind } from "@homarr/definitions";
+import { Avatar } from "@homarr/ui";
+import type { MantineSize } from "@homarr/ui";
+
+interface IntegrationAvatarProps {
+ size: MantineSize;
+ kind: IntegrationKind | null;
+}
+
+export const IntegrationAvatar = ({ kind, size }: IntegrationAvatarProps) => {
+ const url = kind ? getIconUrl(kind) : null;
+ if (!url) {
+ return null;
+ }
+
+ return ;
+};
diff --git a/apps/nextjs/src/app/[locale]/integrations/_buttons.tsx b/apps/nextjs/src/app/[locale]/integrations/_buttons.tsx
new file mode 100644
index 000000000..0378d3c0c
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/integrations/_buttons.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+
+import {
+ showErrorNotification,
+ showSuccessNotification,
+} from "@homarr/notifications";
+import { useScopedI18n } from "@homarr/translation/client";
+import { ActionIcon, IconTrash } from "@homarr/ui";
+
+import { api } from "~/trpc/react";
+import { revalidatePathAction } from "../../revalidatePathAction";
+import { modalEvents } from "../modals";
+
+interface DeleteIntegrationActionButtonProps {
+ count: number;
+ integration: { id: string; name: string };
+}
+
+export const DeleteIntegrationActionButton = ({
+ count,
+ integration,
+}: DeleteIntegrationActionButtonProps) => {
+ const t = useScopedI18n("integration.page.delete");
+ const router = useRouter();
+ const { mutateAsync, isPending } = api.integration.delete.useMutation();
+
+ return (
+ {
+ modalEvents.openConfirmModal({
+ title: t("title"),
+ children: t("message", integration),
+ onConfirm: () => {
+ void mutateAsync(
+ { id: integration.id },
+ {
+ onSuccess: () => {
+ showSuccessNotification({
+ title: t("notification.success.title"),
+ message: t("notification.success.message"),
+ });
+ if (count === 1) {
+ router.replace("/integrations");
+ }
+ void revalidatePathAction("/integrations");
+ },
+ onError: () => {
+ showErrorNotification({
+ title: t("notification.error.title"),
+ message: t("notification.error.message"),
+ });
+ },
+ },
+ );
+ },
+ });
+ }}
+ aria-label="Delete integration"
+ >
+
+
+ );
+};
diff --git a/apps/nextjs/src/app/[locale]/integrations/_secret-card.tsx b/apps/nextjs/src/app/[locale]/integrations/_secret-card.tsx
new file mode 100644
index 000000000..1f0ac74c4
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/integrations/_secret-card.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import { useState } from "react";
+import { useParams } from "next/navigation";
+import { useDisclosure } from "@mantine/hooks";
+import dayjs from "dayjs";
+import relativeTime from "dayjs/plugin/relativeTime";
+
+import type { RouterOutputs } from "@homarr/api";
+import { integrationSecretKindObject } from "@homarr/definitions";
+import { useI18n } from "@homarr/translation/client";
+import {
+ ActionIcon,
+ Avatar,
+ Button,
+ Card,
+ Collapse,
+ Group,
+ IconEye,
+ IconEyeOff,
+ Kbd,
+ Stack,
+ Text,
+} from "@homarr/ui";
+
+import { integrationSecretIcons } from "./_secret-icons";
+
+dayjs.extend(relativeTime);
+
+interface SecretCardProps {
+ secret: RouterOutputs["integration"]["byId"]["secrets"][number];
+ children: React.ReactNode;
+ onCancel: () => Promise;
+}
+
+export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
+ const params = useParams<{ locale: string }>();
+ const t = useI18n();
+ const { isPublic } = integrationSecretKindObject[secret.kind];
+ const [publicSecretDisplayOpened, { toggle: togglePublicSecretDisplay }] =
+ useDisclosure(false);
+ const [editMode, setEditMode] = useState(false);
+ const DisplayIcon = publicSecretDisplayOpened ? IconEye : IconEyeOff;
+ const KindIcon = integrationSecretIcons[secret.kind];
+
+ return (
+
+
+
+
+
+
+
+
+ {t(`integration.secrets.kind.${secret.kind}.label`)}
+
+ {publicSecretDisplayOpened ? {secret.value} : null}
+
+
+
+ {t("integration.secrets.lastUpdated", {
+ date: dayjs().locale(params.locale).to(dayjs(secret.updatedAt)),
+ })}
+
+ {isPublic ? (
+
+
+
+ ) : null}
+
+
+
+ {children}
+
+
+ );
+};
diff --git a/apps/nextjs/src/app/[locale]/integrations/_secret-icons.ts b/apps/nextjs/src/app/[locale]/integrations/_secret-icons.ts
new file mode 100644
index 000000000..f0d35442f
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/integrations/_secret-icons.ts
@@ -0,0 +1,12 @@
+import type { IntegrationSecretKind } from "@homarr/definitions";
+import type { TablerIconsProps } from "@homarr/ui";
+import { IconKey, IconPassword, IconUser } from "@homarr/ui";
+
+export const integrationSecretIcons = {
+ username: IconUser,
+ apiKey: IconKey,
+ password: IconPassword,
+} satisfies Record<
+ IntegrationSecretKind,
+ (props: TablerIconsProps) => JSX.Element
+>;
diff --git a/apps/nextjs/src/app/[locale]/integrations/_secret-inputs.tsx b/apps/nextjs/src/app/[locale]/integrations/_secret-inputs.tsx
new file mode 100644
index 000000000..a40f25b81
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/integrations/_secret-inputs.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import type { ChangeEventHandler, FocusEventHandler } from "react";
+
+import { integrationSecretKindObject } from "@homarr/definitions";
+import type { IntegrationSecretKind } from "@homarr/definitions";
+import { useI18n } from "@homarr/translation/client";
+import { PasswordInput, TextInput } from "@homarr/ui";
+
+import { integrationSecretIcons } from "./_secret-icons";
+
+interface IntegrationSecretInputProps {
+ label?: string;
+ kind: IntegrationSecretKind;
+ value: string;
+ onChange: ChangeEventHandler;
+ onFocus?: FocusEventHandler;
+ onBlur?: FocusEventHandler;
+ error?: string;
+}
+
+export const IntegrationSecretInput = (props: IntegrationSecretInputProps) => {
+ const { isPublic } = integrationSecretKindObject[props.kind];
+
+ if (isPublic) return ;
+
+ return ;
+};
+
+const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
+ const t = useI18n();
+ const Icon = integrationSecretIcons[kind];
+
+ return (
+ }
+ />
+ );
+};
+
+const PrivateSecretInput = ({
+ kind,
+ ...props
+}: IntegrationSecretInputProps) => {
+ const t = useI18n();
+ const Icon = integrationSecretIcons[kind];
+
+ return (
+ }
+ />
+ );
+};
diff --git a/apps/nextjs/src/app/[locale]/integrations/_test-connection.tsx b/apps/nextjs/src/app/[locale]/integrations/_test-connection.tsx
new file mode 100644
index 000000000..206367657
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/integrations/_test-connection.tsx
@@ -0,0 +1,162 @@
+"use client";
+
+import { useRef, useState } from "react";
+
+import type { RouterInputs } from "@homarr/api";
+import {
+ showErrorNotification,
+ showSuccessNotification,
+} from "@homarr/notifications";
+import { useI18n, useScopedI18n } from "@homarr/translation/client";
+import {
+ Alert,
+ Anchor,
+ Group,
+ IconCheck,
+ IconInfoCircle,
+ IconX,
+ Loader,
+} from "@homarr/ui";
+
+import { api } from "~/trpc/react";
+
+interface UseTestConnectionDirtyProps {
+ defaultDirty: boolean;
+ initialFormValue: {
+ url: string;
+ secrets: { kind: string; value: string | null }[];
+ };
+}
+
+export const useTestConnectionDirty = ({
+ defaultDirty,
+ initialFormValue,
+}: UseTestConnectionDirtyProps) => {
+ const [isDirty, setIsDirty] = useState(defaultDirty);
+ const prevFormValueRef = useRef(initialFormValue);
+
+ return {
+ onValuesChange: (values: typeof initialFormValue) => {
+ if (isDirty) return;
+
+ // If relevant values changed, set dirty
+ if (
+ prevFormValueRef.current.url !== values.url ||
+ !prevFormValueRef.current.secrets
+ .map((secret) => secret.value)
+ .every(
+ (secretValue, index) =>
+ values.secrets[index]?.value === secretValue,
+ )
+ ) {
+ setIsDirty(true);
+ return;
+ }
+
+ // If relevant values changed back to last tested, set not dirty
+ setIsDirty(false);
+ },
+ isDirty,
+ removeDirty: () => {
+ prevFormValueRef.current = initialFormValue;
+ setIsDirty(false);
+ },
+ };
+};
+
+interface TestConnectionProps {
+ isDirty: boolean;
+ removeDirty: () => void;
+ integration: RouterInputs["integration"]["testConnection"] & { name: string };
+}
+
+export const TestConnection = ({
+ integration,
+ removeDirty,
+ isDirty,
+}: TestConnectionProps) => {
+ const t = useScopedI18n("integration.testConnection");
+ const { mutateAsync, ...mutation } =
+ api.integration.testConnection.useMutation();
+
+ return (
+
+ {
+ await mutateAsync(integration, {
+ onSuccess: () => {
+ removeDirty();
+ showSuccessNotification({
+ title: t("notification.success.title"),
+ message: t("notification.success.message"),
+ });
+ },
+ onError: (error) => {
+ if (error.data?.zodError?.fieldErrors.url) {
+ showErrorNotification({
+ title: t("notification.invalidUrl.title"),
+ message: t("notification.invalidUrl.message"),
+ });
+ return;
+ }
+
+ if (error.message === "SECRETS_NOT_DEFINED") {
+ showErrorNotification({
+ title: t("notification.notAllSecretsProvided.title"),
+ message: t("notification.notAllSecretsProvided.message"),
+ });
+ return;
+ }
+
+ showErrorNotification({
+ title: t("notification.commonError.title"),
+ message: t("notification.commonError.message"),
+ });
+ },
+ });
+ }}
+ >
+ {t("action")}
+
+
+
+ );
+};
+
+interface TestConnectionIconProps {
+ isDirty: boolean;
+ isPending: boolean;
+ isSuccess: boolean;
+ isError: boolean;
+ size: number;
+}
+
+const TestConnectionIcon = ({
+ isDirty,
+ isPending,
+ isSuccess,
+ isError,
+ size,
+}: TestConnectionIconProps) => {
+ if (isPending) return ;
+ if (isDirty) return null;
+ if (isSuccess) return ;
+ if (isError) return ;
+ return null;
+};
+
+export const TestConnectionNoticeAlert = () => {
+ const t = useI18n();
+ return (
+ }
+ >
+ {t("integration.testConnection.alertNotice")}
+
+ );
+};
diff --git a/apps/nextjs/src/app/[locale]/integrations/edit/[id]/_form.tsx b/apps/nextjs/src/app/[locale]/integrations/edit/[id]/_form.tsx
new file mode 100644
index 000000000..7b8bd8490
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/integrations/edit/[id]/_form.tsx
@@ -0,0 +1,175 @@
+"use client";
+
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+
+import type { RouterOutputs } from "@homarr/api";
+import { getSecretKinds } from "@homarr/definitions";
+import { useForm, zodResolver } from "@homarr/form";
+import {
+ showErrorNotification,
+ showSuccessNotification,
+} from "@homarr/notifications";
+import { useI18n } from "@homarr/translation/client";
+import { Button, Fieldset, Group, Stack, TextInput } from "@homarr/ui";
+import type { z } from "@homarr/validation";
+import { validation } from "@homarr/validation";
+
+import { modalEvents } from "~/app/[locale]/modals";
+import { api } from "~/trpc/react";
+import { SecretCard } from "../../_secret-card";
+import { IntegrationSecretInput } from "../../_secret-inputs";
+import {
+ TestConnection,
+ TestConnectionNoticeAlert,
+ useTestConnectionDirty,
+} from "../../_test-connection";
+import { revalidatePathAction } from "../../../../revalidatePathAction";
+
+interface EditIntegrationForm {
+ integration: RouterOutputs["integration"]["byId"];
+}
+
+export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
+ const t = useI18n();
+ const secretsKinds = getSecretKinds(integration.kind);
+ const initialFormValues = {
+ name: integration.name,
+ url: integration.url,
+ secrets: secretsKinds.map((kind) => ({
+ kind,
+ value:
+ integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
+ })),
+ };
+ const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({
+ defaultDirty: true,
+ initialFormValue: initialFormValues,
+ });
+
+ const router = useRouter();
+ const form = useForm({
+ initialValues: initialFormValues,
+ validate: zodResolver(
+ validation.integration.update.omit({ id: true, kind: true }),
+ ),
+ onValuesChange,
+ });
+ const { mutateAsync, isPending } = api.integration.update.useMutation();
+
+ const secretsMap = new Map(
+ integration.secrets.map((secret) => [secret.kind, secret]),
+ );
+
+ const handleSubmit = async (values: FormType) => {
+ if (isDirty) return;
+ await mutateAsync(
+ {
+ id: integration.id,
+ ...values,
+ secrets: values.secrets.map((secret) => ({
+ kind: secret.kind,
+ value: secret.value === "" ? null : secret.value,
+ })),
+ },
+ {
+ onSuccess: () => {
+ showSuccessNotification({
+ title: t("integration.page.edit.notification.success.title"),
+ message: t("integration.page.edit.notification.success.message"),
+ });
+ void revalidatePathAction("/integrations").then(() =>
+ router.push("/integrations"),
+ );
+ },
+ onError: () => {
+ showErrorNotification({
+ title: t("integration.page.edit.notification.error.title"),
+ message: t("integration.page.edit.notification.error.message"),
+ });
+ },
+ },
+ );
+ };
+
+ return (
+
+ );
+};
+
+type FormType = Omit, "id">;
diff --git a/apps/nextjs/src/app/[locale]/integrations/edit/[id]/page.tsx b/apps/nextjs/src/app/[locale]/integrations/edit/[id]/page.tsx
new file mode 100644
index 000000000..b6a1441a8
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/integrations/edit/[id]/page.tsx
@@ -0,0 +1,32 @@
+import { getIntegrationName } from "@homarr/definitions";
+import { getScopedI18n } from "@homarr/translation/server";
+import { Container, Group, Stack, Title } from "@homarr/ui";
+
+import { api } from "~/trpc/server";
+import { IntegrationAvatar } from "../../_avatar";
+import { EditIntegrationForm } from "./_form";
+
+interface EditIntegrationPageProps {
+ params: { id: string };
+}
+
+export default async function EditIntegrationPage({
+ params,
+}: EditIntegrationPageProps) {
+ const t = await getScopedI18n("integration.page.edit");
+ const integration = await api.integration.byId.query({ id: params.id });
+
+ return (
+
+
+
+
+
+ {t("title", { name: getIntegrationName(integration.kind) })}
+
+
+
+
+
+ );
+}
diff --git a/apps/nextjs/src/app/[locale]/integrations/new/_dropdown.tsx b/apps/nextjs/src/app/[locale]/integrations/new/_dropdown.tsx
new file mode 100644
index 000000000..0609d92e6
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/integrations/new/_dropdown.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import Link from "next/link";
+
+import { getIntegrationName, integrationKinds } from "@homarr/definitions";
+import { useI18n } from "@homarr/translation/client";
+import {
+ Group,
+ IconSearch,
+ Menu,
+ ScrollArea,
+ Stack,
+ Text,
+ TextInput,
+} from "@homarr/ui";
+
+import { IntegrationAvatar } from "../_avatar";
+
+export const IntegrationCreateDropdownContent = () => {
+ const t = useI18n();
+ const [search, setSearch] = useState("");
+
+ const filteredKinds = useMemo(() => {
+ return integrationKinds.filter((kind) =>
+ kind.includes(search.toLowerCase()),
+ );
+ }, [search]);
+
+ return (
+
+ }
+ placeholder={t("integration.page.list.search")}
+ value={search}
+ onChange={(e) => setSearch(e.target.value)}
+ />
+
+ {filteredKinds.length > 0 ? (
+
+ {filteredKinds.map((kind) => (
+
+
+
+ {getIntegrationName(kind)}
+
+
+ ))}
+
+ ) : (
+ {t("common.noResults")}
+ )}
+
+ );
+};
diff --git a/apps/nextjs/src/app/[locale]/integrations/new/_form.tsx b/apps/nextjs/src/app/[locale]/integrations/new/_form.tsx
new file mode 100644
index 000000000..8fcad8a5f
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/integrations/new/_form.tsx
@@ -0,0 +1,137 @@
+"use client";
+
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+
+import type { IntegrationKind } from "@homarr/definitions";
+import { getSecretKinds } from "@homarr/definitions";
+import { useForm, zodResolver } from "@homarr/form";
+import {
+ showErrorNotification,
+ showSuccessNotification,
+} from "@homarr/notifications";
+import { useI18n } from "@homarr/translation/client";
+import { Button, Fieldset, Group, Stack, TextInput } from "@homarr/ui";
+import type { z } from "@homarr/validation";
+import { validation } from "@homarr/validation";
+
+import { api } from "~/trpc/react";
+import { IntegrationSecretInput } from "../_secret-inputs";
+import {
+ TestConnection,
+ TestConnectionNoticeAlert,
+ useTestConnectionDirty,
+} from "../_test-connection";
+import { revalidatePathAction } from "../../../revalidatePathAction";
+
+interface NewIntegrationFormProps {
+ searchParams: Partial> & {
+ kind: IntegrationKind;
+ };
+}
+
+export const NewIntegrationForm = ({
+ searchParams,
+}: NewIntegrationFormProps) => {
+ const t = useI18n();
+ const secretKinds = getSecretKinds(searchParams.kind);
+ const initialFormValues = {
+ name: searchParams.name ?? "",
+ url: searchParams.url ?? "",
+ secrets: secretKinds.map((kind) => ({
+ kind,
+ value: "",
+ })),
+ };
+ const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({
+ defaultDirty: true,
+ initialFormValue: initialFormValues,
+ });
+ const router = useRouter();
+ const form = useForm({
+ initialValues: initialFormValues,
+ validate: zodResolver(validation.integration.create.omit({ kind: true })),
+ onValuesChange,
+ });
+ const { mutateAsync, isPending } = api.integration.create.useMutation();
+
+ const handleSubmit = async (values: FormType) => {
+ if (isDirty) return;
+ await mutateAsync(
+ {
+ kind: searchParams.kind,
+ ...values,
+ },
+ {
+ onSuccess: () => {
+ showSuccessNotification({
+ title: t("integration.page.create.notification.success.title"),
+ message: t("integration.page.create.notification.success.message"),
+ });
+ void revalidatePathAction("/integrations").then(() =>
+ router.push("/integrations"),
+ );
+ },
+ onError: () => {
+ showErrorNotification({
+ title: t("integration.page.create.notification.error.title"),
+ message: t("integration.page.create.notification.error.message"),
+ });
+ },
+ },
+ );
+ };
+
+ return (
+
+ );
+};
+
+type FormType = Omit, "kind">;
diff --git a/apps/nextjs/src/app/[locale]/integrations/new/page.tsx b/apps/nextjs/src/app/[locale]/integrations/new/page.tsx
new file mode 100644
index 000000000..5affefb09
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/integrations/new/page.tsx
@@ -0,0 +1,44 @@
+import { notFound } from "next/navigation";
+
+import type { IntegrationKind } from "@homarr/definitions";
+import { getIntegrationName, integrationKinds } from "@homarr/definitions";
+import { getScopedI18n } from "@homarr/translation/server";
+import { Container, Group, Stack, Title } from "@homarr/ui";
+import type { validation } from "@homarr/validation";
+import { z } from "@homarr/validation";
+
+import { IntegrationAvatar } from "../_avatar";
+import { NewIntegrationForm } from "./_form";
+
+interface NewIntegrationPageProps {
+ searchParams: Partial> & {
+ kind: IntegrationKind;
+ };
+}
+
+export default async function IntegrationsNewPage({
+ searchParams,
+}: NewIntegrationPageProps) {
+ const result = z
+ .enum([integrationKinds[0]!, ...integrationKinds.slice(1)])
+ .safeParse(searchParams.kind);
+ if (!result.success) {
+ notFound();
+ }
+
+ const t = await getScopedI18n("integration.page.create");
+
+ const currentKind = result.data;
+
+ return (
+
+
+
+
+ {t("title", { name: getIntegrationName(currentKind) })}
+
+
+
+
+ );
+}
diff --git a/apps/nextjs/src/app/[locale]/integrations/page.tsx b/apps/nextjs/src/app/[locale]/integrations/page.tsx
new file mode 100644
index 000000000..b3e2226be
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/integrations/page.tsx
@@ -0,0 +1,175 @@
+import Link from "next/link";
+
+import type { RouterOutputs } from "@homarr/api";
+import { objectEntries } from "@homarr/common";
+import { getIntegrationName } from "@homarr/definitions";
+import type { IntegrationKind } from "@homarr/definitions";
+import { getScopedI18n } from "@homarr/translation/server";
+import {
+ AccordionControl,
+ AccordionItem,
+ AccordionPanel,
+ ActionIcon,
+ ActionIconGroup,
+ Anchor,
+ Button,
+ Container,
+ CountBadge,
+ Group,
+ IconChevronDown,
+ IconPencil,
+ Menu,
+ MenuDropdown,
+ MenuTarget,
+ Stack,
+ Table,
+ TableTbody,
+ TableTd,
+ TableTh,
+ TableThead,
+ TableTr,
+ Text,
+ Title,
+} from "@homarr/ui";
+
+import { api } from "~/trpc/server";
+import { IntegrationGroupAccordion } from "./_accordion";
+import { IntegrationAvatar } from "./_avatar";
+import { DeleteIntegrationActionButton } from "./_buttons";
+import { IntegrationCreateDropdownContent } from "./new/_dropdown";
+
+interface IntegrationsPageProps {
+ searchParams: {
+ tab?: IntegrationKind;
+ };
+}
+
+export default async function IntegrationsPage({
+ searchParams,
+}: IntegrationsPageProps) {
+ const integrations = await api.integration.all.query();
+ const t = await getScopedI18n("integration");
+
+ return (
+
+
+
+ {t("page.list.title")}
+
+
+
+
+
+
+ );
+}
+
+interface IntegrationListProps {
+ integrations: RouterOutputs["integration"]["all"];
+ activeTab?: IntegrationKind;
+}
+
+const IntegrationList = async ({
+ integrations,
+ activeTab,
+}: IntegrationListProps) => {
+ const t = await getScopedI18n("integration");
+
+ if (integrations.length === 0) {
+ return {t("page.list.empty")}
;
+ }
+
+ const grouppedIntegrations = integrations.reduce(
+ (acc, integration) => {
+ if (!acc[integration.kind]) {
+ acc[integration.kind] = [];
+ }
+
+ acc[integration.kind].push(integration);
+
+ return acc;
+ },
+ {} as Record,
+ );
+
+ return (
+
+ {objectEntries(grouppedIntegrations).map(([kind, integrations]) => (
+
+ }>
+
+ {getIntegrationName(kind)}
+
+
+
+
+
+
+
+ {t("field.name.label")}
+ {t("field.url.label")}
+
+
+
+
+ {integrations.map((integration) => (
+
+ {integration.name}
+
+
+ {integration.url}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+ ))}
+
+ );
+};
diff --git a/apps/nextjs/src/app/api/trpc/[trpc]/route.ts b/apps/nextjs/src/app/api/trpc/[trpc]/route.ts
index 9cdd9ab92..ed3dcfaaa 100644
--- a/apps/nextjs/src/app/api/trpc/[trpc]/route.ts
+++ b/apps/nextjs/src/app/api/trpc/[trpc]/route.ts
@@ -27,7 +27,8 @@ const handler = auth(async (req) => {
endpoint: "/api/trpc",
router: appRouter,
req,
- createContext: () => createTRPCContext({ auth: req.auth, req }),
+ createContext: () =>
+ createTRPCContext({ auth: req.auth, headers: req.headers }),
onError({ error, path }) {
console.error(`>>> tRPC Error on '${path}'`, error);
},
diff --git a/apps/nextjs/src/app/revalidatePathAction.ts b/apps/nextjs/src/app/revalidatePathAction.ts
new file mode 100644
index 000000000..d8ce6420b
--- /dev/null
+++ b/apps/nextjs/src/app/revalidatePathAction.ts
@@ -0,0 +1,7 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+
+export async function revalidatePathAction(path: string) {
+ return new Promise((resolve) => resolve(revalidatePath(path, "page")));
+}
diff --git a/apps/nextjs/src/utils/api.ts b/apps/nextjs/src/trpc/react.ts
similarity index 100%
rename from apps/nextjs/src/utils/api.ts
rename to apps/nextjs/src/trpc/react.ts
diff --git a/apps/nextjs/src/trpc/server.ts b/apps/nextjs/src/trpc/server.ts
new file mode 100644
index 000000000..ac38dce73
--- /dev/null
+++ b/apps/nextjs/src/trpc/server.ts
@@ -0,0 +1,61 @@
+import { cache } from "react";
+import { headers } from "next/headers";
+import { createTRPCClient, loggerLink, TRPCClientError } from "@trpc/client";
+import { callProcedure } from "@trpc/server";
+import { observable } from "@trpc/server/observable";
+import type { TRPCErrorResponse } from "@trpc/server/rpc";
+import SuperJSON from "superjson";
+
+import { appRouter, createTRPCContext } from "@homarr/api";
+import { auth } from "@homarr/auth";
+
+/**
+ * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
+ * handling a tRPC call from a React Server Component.
+ */
+const createContext = cache(async () => {
+ const heads = new Headers(headers());
+ heads.set("x-trpc-source", "rsc");
+
+ return createTRPCContext({
+ auth: await auth(),
+ headers: heads,
+ });
+});
+
+export const api = createTRPCClient({
+ transformer: SuperJSON,
+ links: [
+ loggerLink({
+ enabled: (op) =>
+ process.env.NODE_ENV === "development" ||
+ (op.direction === "down" && op.result instanceof Error),
+ }),
+ /**
+ * Custom RSC link that invokes procedures directly in the server component Don't be too afraid
+ * about the complexity here, it's just wrapping `callProcedure` with an observable to make it a
+ * valid ending link for tRPC.
+ */
+ () =>
+ ({ op }) =>
+ observable((observer) => {
+ createContext()
+ .then((ctx) => {
+ return callProcedure({
+ procedures: appRouter._def.procedures,
+ path: op.path,
+ getRawInput: () => Promise.resolve(op.input),
+ ctx,
+ type: op.type,
+ });
+ })
+ .then((data) => {
+ observer.next({ result: { data } });
+ observer.complete();
+ })
+ .catch((cause: TRPCErrorResponse) => {
+ observer.error(TRPCClientError.from(cause));
+ });
+ }),
+ ],
+});
diff --git a/packages/api/package.json b/packages/api/package.json
index ba3f800b3..c2f341e53 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -13,6 +13,7 @@
},
"dependencies": {
"@homarr/auth": "workspace:^0.1.0",
+ "@homarr/definitions": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@trpc/client": "next",
diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts
index 72ccce389..f0b54cfc8 100644
--- a/packages/api/src/root.ts
+++ b/packages/api/src/root.ts
@@ -1,8 +1,10 @@
+import { integrationRouter } from "./router/integration";
import { userRouter } from "./router/user";
import { createTRPCRouter } from "./trpc";
export const appRouter = createTRPCRouter({
user: userRouter,
+ integration: integrationRouter,
});
// export type definition of API
diff --git a/packages/api/src/router/integration.ts b/packages/api/src/router/integration.ts
new file mode 100644
index 000000000..1cfe85cca
--- /dev/null
+++ b/packages/api/src/router/integration.ts
@@ -0,0 +1,243 @@
+import crypto from "crypto";
+import { TRPCError } from "@trpc/server";
+
+import { and, createId, eq } from "@homarr/db";
+import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
+import type { IntegrationSecretKind } from "@homarr/definitions";
+import {
+ getSecretKinds,
+ integrationKinds,
+ integrationSecretKindObject,
+} from "@homarr/definitions";
+import { validation } from "@homarr/validation";
+
+import { createTRPCRouter, publicProcedure } from "../trpc";
+
+export const integrationRouter = createTRPCRouter({
+ all: publicProcedure.query(async ({ ctx }) => {
+ const integrations = await ctx.db.query.integrations.findMany();
+ return integrations
+ .map((integration) => ({
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ url: integration.url,
+ }))
+ .sort(
+ (integrationA, integrationB) =>
+ integrationKinds.indexOf(integrationA.kind) -
+ integrationKinds.indexOf(integrationB.kind),
+ );
+ }),
+ byId: publicProcedure
+ .input(validation.integration.byId)
+ .query(async ({ ctx, input }) => {
+ const integration = await ctx.db.query.integrations.findFirst({
+ where: eq(integrations.id, input.id),
+ with: {
+ secrets: {
+ columns: {
+ kind: true,
+ value: true,
+ updatedAt: true,
+ },
+ },
+ },
+ });
+
+ if (!integration) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Integration not found",
+ });
+ }
+
+ return {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ url: integration.url,
+ secrets: integration.secrets.map((secret) => ({
+ kind: secret.kind,
+ // Only return the value if the secret is public, so for example the username
+ value: integrationSecretKindObject[secret.kind].isPublic
+ ? decryptSecret(secret.value)
+ : null,
+ updatedAt: secret.updatedAt,
+ })),
+ };
+ }),
+ create: publicProcedure
+ .input(validation.integration.create)
+ .mutation(async ({ ctx, input }) => {
+ const integrationId = createId();
+ await ctx.db.insert(integrations).values({
+ id: integrationId,
+ name: input.name,
+ url: input.url,
+ kind: input.kind,
+ });
+
+ for (const secret of input.secrets) {
+ await ctx.db.insert(integrationSecrets).values({
+ kind: secret.kind,
+ value: encryptSecret(secret.value),
+ updatedAt: new Date(),
+ integrationId,
+ });
+ }
+ }),
+ update: publicProcedure
+ .input(validation.integration.update)
+ .mutation(async ({ ctx, input }) => {
+ const integration = await ctx.db.query.integrations.findFirst({
+ where: eq(integrations.id, input.id),
+ with: {
+ secrets: true,
+ },
+ });
+
+ if (!integration) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Integration not found",
+ });
+ }
+
+ await ctx.db
+ .update(integrations)
+ .set({
+ name: input.name,
+ url: input.url,
+ })
+ .where(eq(integrations.id, input.id));
+
+ const decryptedSecrets = integration.secrets.map((secret) => ({
+ ...secret,
+ value: decryptSecret(secret.value),
+ }));
+
+ const changedSecrets = input.secrets.filter(
+ (secret): secret is { kind: IntegrationSecretKind; value: string } =>
+ secret.value !== null && // only update secrets that have a value
+ !decryptedSecrets.find(
+ (dSecret) =>
+ dSecret.kind === secret.kind && dSecret.value === secret.value,
+ ),
+ );
+
+ if (changedSecrets.length > 0) {
+ for (const changedSecret of changedSecrets) {
+ await ctx.db
+ .update(integrationSecrets)
+ .set({
+ value: encryptSecret(changedSecret.value),
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(integrationSecrets.integrationId, input.id),
+ eq(integrationSecrets.kind, changedSecret.kind),
+ ),
+ );
+ }
+ }
+ }),
+ delete: publicProcedure
+ .input(validation.integration.delete)
+ .mutation(async ({ ctx, input }) => {
+ const integration = await ctx.db.query.integrations.findFirst({
+ where: eq(integrations.id, input.id),
+ });
+
+ if (!integration) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Integration not found",
+ });
+ }
+
+ await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
+ }),
+ testConnection: publicProcedure
+ .input(validation.integration.testConnection)
+ .mutation(async ({ ctx, input }) => {
+ const secretKinds = getSecretKinds(input.kind);
+ const secrets = input.secrets.filter(
+ (secret): secret is { kind: IntegrationSecretKind; value: string } =>
+ !!secret.value,
+ );
+ const everyInputSecretDefined = secretKinds.every((secretKind) =>
+ secrets.some((secret) => secret.kind === secretKind),
+ );
+ if (!everyInputSecretDefined && input.id === null) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "SECRETS_NOT_DEFINED",
+ });
+ }
+
+ if (!everyInputSecretDefined && input.id !== null) {
+ const integration = await ctx.db.query.integrations.findFirst({
+ where: eq(integrations.id, input.id),
+ with: {
+ secrets: true,
+ },
+ });
+ if (!integration) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "SECRETS_NOT_DEFINED",
+ });
+ }
+ const decryptedSecrets = integration.secrets.map((secret) => ({
+ ...secret,
+ value: decryptSecret(secret.value),
+ }));
+
+ // Add secrets that are not defined in the input from the database
+ for (const dbSecret of decryptedSecrets) {
+ if (!secrets.find((secret) => secret.kind === dbSecret.kind)) {
+ secrets.push({
+ kind: dbSecret.kind,
+ value: dbSecret.value,
+ });
+ }
+ }
+ }
+
+ // TODO: actually test the connection
+ // Probably by calling a function on the integration class
+ // getIntegration(input.kind).testConnection(secrets)
+ // getIntegration(kind: IntegrationKind): Integration
+ // interface Integration {
+ // testConnection(): Promise;
+ // }
+ }),
+});
+
+const algorithm = "aes-256-cbc"; //Using AES encryption
+const key = Buffer.from(
+ "1d71cceced68159ba59a277d056a66173613052cbeeccbfbd15ab1c909455a4d",
+ "hex",
+); // TODO: generate with const data = crypto.randomBytes(32).toString('hex')
+
+//Encrypting text
+function encryptSecret(text: string): `${string}.${string}` {
+ const iv = crypto.randomBytes(16);
+ const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), iv);
+ let encrypted = cipher.update(text);
+ encrypted = Buffer.concat([encrypted, cipher.final()]);
+ return `${encrypted.toString("hex")}.${iv.toString("hex")}`;
+}
+
+// Decrypting text
+function decryptSecret(value: `${string}.${string}`) {
+ const [data, dataIv] = value.split(".") as [string, string];
+ const iv = Buffer.from(dataIv, "hex");
+ const encryptedText = Buffer.from(data, "hex");
+ const decipher = crypto.createDecipheriv(algorithm, Buffer.from(key), iv);
+ let decrypted = decipher.update(encryptedText);
+ decrypted = Buffer.concat([decrypted, decipher.final()]);
+ return decrypted.toString();
+}
diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts
index 4d64e1c35..4a13bb468 100644
--- a/packages/api/src/trpc.ts
+++ b/packages/api/src/trpc.ts
@@ -9,8 +9,8 @@
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
-import { auth } from "@homarr/auth";
import type { Session } from "@homarr/auth";
+import { auth } from "@homarr/auth";
import { db } from "@homarr/db";
import { ZodError } from "@homarr/validation";
@@ -49,11 +49,11 @@ const createInnerTRPCContext = (opts: CreateContextOptions) => {
* @link https://trpc.io/docs/context
*/
export const createTRPCContext = async (opts: {
- req?: Request;
+ headers?: Headers;
auth: Session | null;
}) => {
const session = opts.auth ?? (await auth());
- const source = opts.req?.headers.get("x-trpc-source") ?? "unknown";
+ const source = opts.headers?.get("x-trpc-source") ?? "unknown";
console.log(">>> tRPC Request from", source, "by", session?.user);
diff --git a/packages/common/index.ts b/packages/common/index.ts
new file mode 100644
index 000000000..3bd16e178
--- /dev/null
+++ b/packages/common/index.ts
@@ -0,0 +1 @@
+export * from "./src";
diff --git a/packages/common/package.json b/packages/common/package.json
new file mode 100644
index 000000000..b4092d51f
--- /dev/null
+++ b/packages/common/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@homarr/common",
+ "private": true,
+ "version": "0.1.0",
+ "exports": {
+ ".": "./index.ts"
+ },
+ "typesVersions": {
+ "*": {
+ "*": [
+ "src/*"
+ ]
+ }
+ },
+ "license": "MIT",
+ "scripts": {
+ "clean": "rm -rf .turbo node_modules",
+ "lint": "eslint .",
+ "format": "prettier --check . --ignore-path ../../.gitignore",
+ "typecheck": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "@homarr/eslint-config": "workspace:^0.2.0",
+ "@homarr/prettier-config": "workspace:^0.1.0",
+ "@homarr/tsconfig": "workspace:^0.1.0",
+ "eslint": "^8.53.0",
+ "typescript": "^5.3.3"
+ },
+ "eslintConfig": {
+ "extends": [
+ "@homarr/eslint-config/base"
+ ]
+ },
+ "prettier": "@homarr/prettier-config"
+}
diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts
new file mode 100644
index 000000000..e136ba1f5
--- /dev/null
+++ b/packages/common/src/index.ts
@@ -0,0 +1,2 @@
+export * from "./object";
+export * from "./string";
diff --git a/packages/common/src/object.ts b/packages/common/src/object.ts
new file mode 100644
index 000000000..6bcb87a62
--- /dev/null
+++ b/packages/common/src/object.ts
@@ -0,0 +1,10 @@
+export function objectKeys(obj: O): (keyof O)[] {
+ return Object.keys(obj) as (keyof O)[];
+}
+
+type Entries = {
+ [K in keyof T]: [K, T[K]];
+}[keyof T][];
+
+export const objectEntries = (obj: T) =>
+ Object.entries(obj) as Entries;
diff --git a/packages/common/src/string.ts b/packages/common/src/string.ts
new file mode 100644
index 000000000..b829514ff
--- /dev/null
+++ b/packages/common/src/string.ts
@@ -0,0 +1,3 @@
+export const capitalize = (str: T) => {
+ return (str.charAt(0).toUpperCase() + str.slice(1)) as Capitalize;
+};
diff --git a/packages/common/tsconfig.json b/packages/common/tsconfig.json
new file mode 100644
index 000000000..cbe8483d9
--- /dev/null
+++ b/packages/common/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@homarr/tsconfig/base.json",
+ "compilerOptions": {
+ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
+ },
+ "include": ["*.ts", "src"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/db/package.json b/packages/db/package.json
index 9fba6bd25..2ec0c4f97 100644
--- a/packages/db/package.json
+++ b/packages/db/package.json
@@ -14,6 +14,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
+ "@homarr/common": "workspace:^0.1.0",
+ "@homarr/definitions": "workspace:^0.1.0",
"@paralleldrive/cuid2": "^2.2.2",
"better-sqlite3": "^9.2.2",
"drizzle-orm": "^0.29.1"
diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts
index a667697fa..b25b99dce 100644
--- a/packages/db/schema/sqlite.ts
+++ b/packages/db/schema/sqlite.ts
@@ -9,6 +9,11 @@ import {
text,
} from "drizzle-orm/sqlite-core";
+import type {
+ IntegrationKind,
+ IntegrationSecretKind,
+} from "@homarr/definitions";
+
export const users = sqliteTable("user", {
id: text("id").notNull().primaryKey(),
name: text("name"),
@@ -70,6 +75,38 @@ export const verificationTokens = sqliteTable(
}),
);
+export const integrations = sqliteTable(
+ "integration",
+ {
+ id: text("id").notNull().primaryKey(),
+ name: text("name").notNull(),
+ url: text("url").notNull(),
+ kind: text("kind").$type().notNull(),
+ },
+ (i) => ({
+ kindIdx: index("integration__kind_idx").on(i.kind),
+ }),
+);
+
+export const integrationSecrets = sqliteTable(
+ "integrationSecret",
+ {
+ kind: text("kind").$type().notNull(),
+ value: text("value").$type<`${string}.${string}`>().notNull(),
+ updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
+ integrationId: text("integration_id")
+ .notNull()
+ .references(() => integrations.id, { onDelete: "cascade" }),
+ },
+ (is) => ({
+ compoundKey: primaryKey({
+ columns: [is.integrationId, is.kind],
+ }),
+ kindIdx: index("integration_secret__kind_idx").on(is.kind),
+ updatedAtIdx: index("integration_secret__updated_at_idx").on(is.updatedAt),
+ }),
+);
+
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -81,7 +118,23 @@ export const userRelations = relations(users, ({ many }) => ({
accounts: many(accounts),
}));
+export const integrationRelations = relations(integrations, ({ many }) => ({
+ secrets: many(integrationSecrets),
+}));
+
+export const integrationSecretRelations = relations(
+ integrationSecrets,
+ ({ one }) => ({
+ integration: one(integrations, {
+ fields: [integrationSecrets.integrationId],
+ references: [integrations.id],
+ }),
+ }),
+);
+
export type User = InferSelectModel;
export type Account = InferSelectModel;
export type Session = InferSelectModel;
export type VerificationToken = InferSelectModel;
+export type Integration = InferSelectModel;
+export type IntegrationSecret = InferSelectModel;
diff --git a/packages/definitions/index.ts b/packages/definitions/index.ts
new file mode 100644
index 000000000..3bd16e178
--- /dev/null
+++ b/packages/definitions/index.ts
@@ -0,0 +1 @@
+export * from "./src";
diff --git a/packages/definitions/package.json b/packages/definitions/package.json
new file mode 100644
index 000000000..fcdf49ef3
--- /dev/null
+++ b/packages/definitions/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "@homarr/definitions",
+ "private": true,
+ "version": "0.1.0",
+ "exports": {
+ ".": "./index.ts"
+ },
+ "typesVersions": {
+ "*": {
+ "*": [
+ "src/*"
+ ]
+ }
+ },
+ "license": "MIT",
+ "scripts": {
+ "clean": "rm -rf .turbo node_modules",
+ "lint": "eslint .",
+ "format": "prettier --check . --ignore-path ../../.gitignore",
+ "typecheck": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "@homarr/eslint-config": "workspace:^0.2.0",
+ "@homarr/prettier-config": "workspace:^0.1.0",
+ "@homarr/tsconfig": "workspace:^0.1.0",
+ "eslint": "^8.53.0",
+ "typescript": "^5.3.3"
+ },
+ "eslintConfig": {
+ "extends": [
+ "@homarr/eslint-config/base"
+ ]
+ },
+ "prettier": "@homarr/prettier-config",
+ "dependencies": {
+ "@homarr/common": "workspace:^0.1.0"
+ }
+}
diff --git a/packages/definitions/src/index.ts b/packages/definitions/src/index.ts
new file mode 100644
index 000000000..852c5e31b
--- /dev/null
+++ b/packages/definitions/src/index.ts
@@ -0,0 +1 @@
+export * from "./integration";
diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts
new file mode 100644
index 000000000..4c33ceb85
--- /dev/null
+++ b/packages/definitions/src/integration.ts
@@ -0,0 +1,155 @@
+import { objectKeys } from "@homarr/common";
+
+export const integrationSecretKindObject = {
+ apiKey: { isPublic: false },
+ username: { isPublic: true },
+ password: { isPublic: false },
+} satisfies Record;
+
+export const integrationSecretKinds = objectKeys(integrationSecretKindObject);
+
+export const integrationDefs = {
+ sabNzbd: {
+ name: "SABnzbd",
+ secretKinds: ["apiKey"],
+ iconUrl:
+ "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png",
+ category: ["useNetClient"],
+ },
+ nzbGet: {
+ name: "NZBGet",
+ secretKinds: ["username", "password"],
+ iconUrl:
+ "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png",
+ category: ["useNetClient"],
+ },
+ deluge: {
+ name: "Deluge",
+ secretKinds: ["password"],
+ iconUrl:
+ "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/deluge.png",
+ category: ["downloadClient"],
+ },
+ transmission: {
+ name: "Transmission",
+ secretKinds: ["username", "password"],
+ iconUrl:
+ "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/transmission.png",
+ category: ["downloadClient"],
+ },
+ qBittorrent: {
+ name: "qBittorrent",
+ secretKinds: ["username", "password"],
+ iconUrl:
+ "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/qbittorrent.png",
+ category: ["downloadClient"],
+ },
+ sonarr: {
+ name: "Sonarr",
+ secretKinds: ["apiKey"],
+ iconUrl:
+ "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sonarr.png",
+ category: ["calendar"],
+ },
+ radarr: {
+ name: "Radarr",
+ secretKinds: ["apiKey"],
+ iconUrl:
+ "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/radarr.png",
+ category: ["calendar"],
+ },
+ lidarr: {
+ name: "Lidarr",
+ secretKinds: ["apiKey"],
+ iconUrl:
+ "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/lidarr.png",
+ category: ["calendar"],
+ },
+ readarr: {
+ name: "Readarr",
+ secretKinds: ["apiKey"],
+ iconUrl:
+ "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png",
+ category: ["calendar"],
+ },
+ jellyfin: {
+ name: "Jellyfin",
+ secretKinds: ["username", "password"],
+ iconUrl:
+ "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png",
+ category: ["mediaService"],
+ },
+ plex: {
+ name: "Plex",
+ secretKinds: ["apiKey"],
+ iconUrl:
+ "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png",
+ category: ["mediaService"],
+ },
+ jellyseerr: {
+ name: "Jellyseerr",
+ secretKinds: ["apiKey"],
+ iconUrl:
+ "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png",
+ category: ["mediaSearch", "mediaRequest"],
+ },
+ overseerr: {
+ name: "Overseerr",
+ secretKinds: ["apiKey"],
+ iconUrl:
+ "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/overseerr.png",
+ category: ["mediaSearch", "mediaRequest"],
+ },
+ piHole: {
+ name: "Pi-hole",
+ secretKinds: ["apiKey"],
+ iconUrl:
+ "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/pi-hole.png",
+ category: ["dnsHole"],
+ },
+ adGuardHome: {
+ name: "AdGuard Home",
+ secretKinds: ["username", "password"],
+ iconUrl:
+ "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png",
+ category: ["dnsHole"],
+ },
+ homeAssistant: {
+ name: "Home Assistant",
+ secretKinds: ["apiKey"],
+ iconUrl:
+ "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/home-assistant.png",
+ category: [],
+ },
+} satisfies Record<
+ string,
+ {
+ name: string;
+ iconUrl: string;
+ secretKinds: IntegrationSecretKind[];
+ category: IntegrationCategory[];
+ }
+>;
+
+export const getIconUrl = (integration: IntegrationKind) =>
+ integrationDefs[integration]?.iconUrl ?? null;
+
+export const getIntegrationName = (integration: IntegrationKind) =>
+ integrationDefs[integration].name;
+
+export const getSecretKinds = (
+ integration: IntegrationKind,
+): IntegrationSecretKind[] => integrationDefs[integration]?.secretKinds ?? null;
+
+export const integrationKinds = objectKeys(integrationDefs);
+
+export type IntegrationSecretKind = (typeof integrationSecretKinds)[number];
+export type IntegrationKind = (typeof integrationKinds)[number];
+export type IntegrationCategory =
+ | "dnsHole"
+ | "mediaService"
+ | "calendar"
+ | "mediaSearch"
+ | "mediaRequest"
+ | "downloadClient"
+ | "useNetClient";
diff --git a/packages/definitions/tsconfig.json b/packages/definitions/tsconfig.json
new file mode 100644
index 000000000..cbe8483d9
--- /dev/null
+++ b/packages/definitions/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@homarr/tsconfig/base.json",
+ "compilerOptions": {
+ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
+ },
+ "include": ["*.ts", "src"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/notifications/src/index.tsx b/packages/notifications/src/index.tsx
index 1f274ae48..d0bf3514c 100644
--- a/packages/notifications/src/index.tsx
+++ b/packages/notifications/src/index.tsx
@@ -1,7 +1,7 @@
import type { NotificationData } from "@mantine/notifications";
import { notifications } from "@mantine/notifications";
-import { IconCheck, IconX, rem } from "@homarr/ui";
+import { IconCheck, IconX } from "@homarr/ui";
type CommonNotificationProps = Pick;
@@ -9,12 +9,12 @@ export const showSuccessNotification = (props: CommonNotificationProps) =>
notifications.show({
...props,
color: "teal",
- icon: ,
+ icon: ,
});
export const showErrorNotification = (props: CommonNotificationProps) =>
notifications.show({
...props,
color: "red",
- icon: ,
+ icon: ,
});
diff --git a/packages/translation/src/lang/de.ts b/packages/translation/src/lang/de.ts
index 768899288..90946cf00 100644
--- a/packages/translation/src/lang/de.ts
+++ b/packages/translation/src/lang/de.ts
@@ -1,3 +1,5 @@
+import "dayjs/locale/de";
+
export default {
user: {
page: {
@@ -26,6 +28,132 @@ export default {
create: "Benutzer erstellen",
},
},
+ integration: {
+ page: {
+ list: {
+ title: "Integrationen",
+ search: "Integration suchen",
+ empty: "Keine Integrationen gefunden",
+ },
+ create: {
+ title: "Neue {name} Integration erstellen",
+ notification: {
+ success: {
+ title: "Erstellung erfolgreich",
+ message: "Die Integration wurde erfolgreich erstellt",
+ },
+ error: {
+ title: "Erstellung fehlgeschlagen",
+ message: "Die Integration konnte nicht erstellt werden",
+ },
+ },
+ },
+ edit: {
+ title: "{name} Integration bearbeiten",
+ notification: {
+ success: {
+ title: "Änderungen erfolgreich angewendet",
+ message: "Die Integration wurde erfolgreich gespeichert",
+ },
+ error: {
+ title: "Änderungen konnten nicht angewendet werden",
+ message: "Die Integration konnte nicht gespeichert werden",
+ },
+ },
+ },
+ delete: {
+ title: "Integration entfernen",
+ message: "Möchtest du die Integration {name} wirklich entfernen?",
+ notification: {
+ success: {
+ title: "Entfernen erfolgreich",
+ message: "Die Integration wurde erfolgreich entfernt",
+ },
+ error: {
+ title: "Entfernen fehlgeschlagen",
+ message: "Die Integration konnte nicht entfernt werden",
+ },
+ },
+ },
+ },
+ field: {
+ name: {
+ label: "Name",
+ },
+ url: {
+ label: "Url",
+ },
+ },
+ action: {
+ create: "Neue Integration",
+ },
+ testConnection: {
+ action: "Verbindung überprüfen",
+ alertNotice:
+ "Der Button zum Speichern wird aktiviert, sobald die Verbindung erfolgreich überprüft wurde",
+ notification: {
+ success: {
+ title: "Verbindung erfolgreich",
+ message: "Die Verbindung wurde erfolgreich hergestellt",
+ },
+ invalidUrl: {
+ title: "Ungültige URL",
+ message: "Die URL ist ungültig",
+ },
+ notAllSecretsProvided: {
+ title: "Fehlende Zugangsdaten",
+ message: "Es wurden nicht alle Zugangsdaten angegeben",
+ },
+ invalidCredentials: {
+ title: "Ungültige Zugangsdaten",
+ message: "Die Zugangsdaten sind ungültig",
+ },
+ commonError: {
+ title: "Verbindung fehlgeschlagen",
+ message: "Die Verbindung konnte nicht hergestellt werden",
+ },
+ },
+ },
+ secrets: {
+ title: "Zugangsdaten",
+ lastUpdated: "Zuletzt geändert {date}",
+ secureNotice:
+ "Diese Zugangsdaten können nach der Erstellung nicht mehr ausgelesen werden",
+ reset: {
+ title: "Zugangsdaten zurücksetzen",
+ message: "Möchtest du diese Zugangsdaten wirklich zurücksetzen?",
+ },
+ kind: {
+ username: {
+ label: "Benutzername",
+ newLabel: "Neuer Benutzername",
+ },
+ apiKey: {
+ label: "API Key",
+ newLabel: "Neuer API Key",
+ },
+ password: {
+ label: "Passwort",
+ newLabel: "Neues Passwort",
+ },
+ },
+ },
+ },
+ common: {
+ action: {
+ backToOverview: "Zurück zur Übersicht",
+ create: "Erstellen",
+ edit: "Bearbeiten",
+ save: "Speichern",
+ cancel: "Abbrechen",
+ confirm: "Bestätigen",
+ },
+ noResults: "Keine Ergebnisse gefunden",
+ search: {
+ placeholder: "Suche nach etwas...",
+ nothingFound: "Nichts gefunden",
+ },
+ },
widget: {
clock: {
option: {
@@ -52,10 +180,4 @@ export default {
},
},
},
- common: {
- search: {
- placeholder: "Suche nach etwas...",
- nothingFound: "Nichts gefunden",
- },
- },
} as const;
diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts
index c8d93cd93..9384e3988 100644
--- a/packages/translation/src/lang/en.ts
+++ b/packages/translation/src/lang/en.ts
@@ -1,3 +1,5 @@
+import "dayjs/locale/en";
+
export default {
user: {
page: {
@@ -26,6 +28,131 @@ export default {
create: "Create user",
},
},
+ integration: {
+ page: {
+ list: {
+ title: "Integrations",
+ search: "Search integrations",
+ empty: "No integrations found",
+ },
+ create: {
+ title: "New {name} integration",
+ notification: {
+ success: {
+ title: "Creation successful",
+ message: "The integration was successfully created",
+ },
+ error: {
+ title: "Creation failed",
+ message: "The integration could not be created",
+ },
+ },
+ },
+ edit: {
+ title: "Edit {name} integration",
+ notification: {
+ success: {
+ title: "Changes applied successfully",
+ message: "The integration was successfully saved",
+ },
+ error: {
+ title: "Unable to apply changes",
+ message: "The integration could not be saved",
+ },
+ },
+ },
+ delete: {
+ title: "Delete integration",
+ message: "Are you sure you want to delete the integration {name}?",
+ notification: {
+ success: {
+ title: "Deletion successful",
+ message: "The integration was successfully deleted",
+ },
+ error: {
+ title: "Deletion failed",
+ message: "Unable to delete the integration",
+ },
+ },
+ },
+ },
+ field: {
+ name: {
+ label: "Name",
+ },
+ url: {
+ label: "Url",
+ },
+ },
+ action: {
+ create: "New integration",
+ },
+ testConnection: {
+ action: "Test connection",
+ alertNotice:
+ "The Save button is enabled once a successful connection is established",
+ notification: {
+ success: {
+ title: "Connection successful",
+ message: "The connection was successfully established",
+ },
+ invalidUrl: {
+ title: "Invalid URL",
+ message: "The URL is invalid",
+ },
+ notAllSecretsProvided: {
+ title: "Missing credentials",
+ message: "Not all credentials were provided",
+ },
+ invalidCredentials: {
+ title: "Invalid credentials",
+ message: "The credentials are invalid",
+ },
+ commonError: {
+ title: "Connection failed",
+ message: "The connection could not be established",
+ },
+ },
+ },
+ secrets: {
+ title: "Secrets",
+ lastUpdated: "Last updated {date}",
+ secureNotice: "This secret cannot be retrieved after creation",
+ reset: {
+ title: "Reset secret",
+ message: "Are you sure you want to reset this secret?",
+ },
+ kind: {
+ username: {
+ label: "Username",
+ newLabel: "New username",
+ },
+ apiKey: {
+ label: "API Key",
+ newLabel: "New API Key",
+ },
+ password: {
+ label: "Password",
+ newLabel: "New password",
+ },
+ },
+ },
+ },
+ common: {
+ action: {
+ backToOverview: "Back to overview",
+ create: "Create",
+ edit: "Edit",
+ save: "Save",
+ cancel: "Cancel",
+ confirm: "Confirm",
+ },
+ search: {
+ placeholder: "Search for anything...",
+ nothingFound: "Nothing found",
+ },
+ noResults: "No results found",
+ },
widget: {
clock: {
option: {
@@ -52,12 +179,6 @@ export default {
},
},
},
- common: {
- search: {
- placeholder: "Search for anything...",
- nothingFound: "Nothing found",
- },
- },
management: {
metaTitle: "Management",
title: {
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 2dd0b65d7..043e295ef 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -24,6 +24,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
+ "@types/css-modules": "^1.0.5",
"eslint": "^8.53.0",
"typescript": "^5.3.3"
},
diff --git a/packages/ui/src/components/count-badge.module.css b/packages/ui/src/components/count-badge.module.css
new file mode 100644
index 000000000..8e414b834
--- /dev/null
+++ b/packages/ui/src/components/count-badge.module.css
@@ -0,0 +1,11 @@
+.badge {
+ @mixin light {
+ --badge-bg: rgba(30, 34, 39, 0.08);
+ --badge-color: var(--mantine-color-black);
+ }
+
+ @mixin dark {
+ --badge-bg: #363c44;
+ --badge-color: var(--mantine-color-white);
+ }
+}
diff --git a/packages/ui/src/components/count-badge.tsx b/packages/ui/src/components/count-badge.tsx
new file mode 100644
index 000000000..2eef48f20
--- /dev/null
+++ b/packages/ui/src/components/count-badge.tsx
@@ -0,0 +1,11 @@
+import { Badge } from "@mantine/core";
+
+import classes from "./count-badge.module.css";
+
+interface CountBadgeProps {
+ count: number;
+}
+
+export const CountBadge = ({ count }: CountBadgeProps) => {
+ return {count};
+};
diff --git a/packages/ui/src/components/index.tsx b/packages/ui/src/components/index.tsx
new file mode 100644
index 000000000..1cefa3819
--- /dev/null
+++ b/packages/ui/src/components/index.tsx
@@ -0,0 +1 @@
+export * from "./count-badge";
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index f7f062c0b..2a4bb91a1 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -2,6 +2,8 @@ import type { MantineProviderProps } from "@mantine/core";
import { theme } from "./theme";
+export * from "./components";
+
export const uiConfiguration = {
theme,
} satisfies MantineProviderProps;
diff --git a/packages/validation/package.json b/packages/validation/package.json
index 2815b7b2f..6d7c1e761 100644
--- a/packages/validation/package.json
+++ b/packages/validation/package.json
@@ -33,6 +33,7 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
- "zod": "^3.22.2"
+ "zod": "^3.22.2",
+ "@homarr/definitions": "workspace:^0.1.0"
}
}
diff --git a/packages/validation/src/enums.ts b/packages/validation/src/enums.ts
new file mode 100644
index 000000000..a59c32796
--- /dev/null
+++ b/packages/validation/src/enums.ts
@@ -0,0 +1,4 @@
+import { z } from "zod";
+
+export const zodEnumFromArray = (arr: T[]) =>
+ z.enum([arr[0]!, ...arr.slice(1)]);
diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts
index 5918fa94d..7c2ed8269 100644
--- a/packages/validation/src/index.ts
+++ b/packages/validation/src/index.ts
@@ -1,5 +1,7 @@
+import { integrationSchemas } from "./integration";
import { userSchemas } from "./user";
export const validation = {
user: userSchemas,
+ integration: integrationSchemas,
};
diff --git a/packages/validation/src/integration.ts b/packages/validation/src/integration.ts
new file mode 100644
index 000000000..332d4c541
--- /dev/null
+++ b/packages/validation/src/integration.ts
@@ -0,0 +1,53 @@
+import { z } from "zod";
+
+import { integrationKinds, integrationSecretKinds } from "@homarr/definitions";
+
+import { zodEnumFromArray } from "./enums";
+
+const integrationCreateSchema = z.object({
+ name: z.string().nonempty().max(127),
+ url: z.string().url(),
+ kind: zodEnumFromArray(integrationKinds),
+ secrets: z.array(
+ z.object({
+ kind: zodEnumFromArray(integrationSecretKinds),
+ value: z.string().nonempty(),
+ }),
+ ),
+});
+
+const integrationUpdateSchema = z.object({
+ id: z.string().cuid2(),
+ name: z.string().nonempty().max(127),
+ url: z.string().url(),
+ secrets: z.array(
+ z.object({
+ kind: zodEnumFromArray(integrationSecretKinds),
+ value: z.string().nullable(),
+ }),
+ ),
+});
+
+const idSchema = z.object({
+ id: z.string(),
+});
+
+const testConnectionSchema = z.object({
+ id: z.string().cuid2().nullable(), // Is used to use existing secrets if they have not been updated
+ url: z.string().url(),
+ kind: zodEnumFromArray(integrationKinds),
+ secrets: z.array(
+ z.object({
+ kind: zodEnumFromArray(integrationSecretKinds),
+ value: z.string().nullable(),
+ }),
+ ),
+});
+
+export const integrationSchemas = {
+ create: integrationCreateSchema,
+ update: integrationUpdateSchema,
+ delete: idSchema,
+ byId: idSchema,
+ testConnection: testConnectionSchema,
+};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5fa4c4189..7b111c9e5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -35,9 +35,15 @@ importers:
'@homarr/auth':
specifier: workspace:^0.1.0
version: link:../../packages/auth
+ '@homarr/common':
+ specifier: workspace:^0.1.0
+ version: link:../../packages/common
'@homarr/db':
specifier: workspace:^0.1.0
version: link:../../packages/db
+ '@homarr/definitions':
+ specifier: workspace:^0.1.0
+ version: link:../../packages/definitions
'@homarr/form':
specifier: workspace:^0.1.0
version: link:../../packages/form
@@ -62,6 +68,9 @@ importers:
'@mantine/hooks':
specifier: ^7.3.2
version: 7.3.2(react@18.2.0)
+ '@mantine/modals':
+ specifier: ^7.3.2
+ version: 7.3.2(@mantine/core@7.3.2)(@mantine/hooks@7.3.2)(react-dom@18.2.0)(react@18.2.0)
'@mantine/tiptap':
specifier: ^7.3.2
version: 7.3.2(@mantine/core@7.3.2)(@mantine/hooks@7.3.2)(@tabler/icons-react@2.42.0)(@tiptap/extension-link@2.1.13)(react-dom@18.2.0)(react@18.2.0)
@@ -162,6 +171,9 @@ importers:
'@homarr/db':
specifier: workspace:^0.1.0
version: link:../db
+ '@homarr/definitions':
+ specifier: workspace:^0.1.0
+ version: link:../definitions
'@homarr/validation':
specifier: workspace:^0.1.0
version: link:../validation
@@ -255,8 +267,32 @@ importers:
specifier: ^5.3.3
version: 5.3.3
+ packages/common:
+ devDependencies:
+ '@homarr/eslint-config':
+ specifier: workspace:^0.2.0
+ version: link:../../tooling/eslint
+ '@homarr/prettier-config':
+ specifier: workspace:^0.1.0
+ version: link:../../tooling/prettier
+ '@homarr/tsconfig':
+ specifier: workspace:^0.1.0
+ version: link:../../tooling/typescript
+ eslint:
+ specifier: ^8.53.0
+ version: 8.53.0
+ typescript:
+ specifier: ^5.3.3
+ version: 5.3.3
+
packages/db:
dependencies:
+ '@homarr/common':
+ specifier: workspace:^0.1.0
+ version: link:../common
+ '@homarr/definitions':
+ specifier: workspace:^0.1.0
+ version: link:../definitions
'@paralleldrive/cuid2':
specifier: ^2.2.2
version: 2.2.2
@@ -295,6 +331,28 @@ importers:
specifier: ^5.3.3
version: 5.3.3
+ packages/definitions:
+ dependencies:
+ '@homarr/common':
+ specifier: workspace:^0.1.0
+ version: link:../common
+ devDependencies:
+ '@homarr/eslint-config':
+ specifier: workspace:^0.2.0
+ version: link:../../tooling/eslint
+ '@homarr/prettier-config':
+ specifier: workspace:^0.1.0
+ version: link:../../tooling/prettier
+ '@homarr/tsconfig':
+ specifier: workspace:^0.1.0
+ version: link:../../tooling/typescript
+ eslint:
+ specifier: ^8.53.0
+ version: 8.53.0
+ typescript:
+ specifier: ^5.3.3
+ version: 5.3.3
+
packages/form:
dependencies:
'@mantine/form':
@@ -407,6 +465,9 @@ importers:
'@homarr/tsconfig':
specifier: workspace:^0.1.0
version: link:../../tooling/typescript
+ '@types/css-modules':
+ specifier: ^1.0.5
+ version: 1.0.5
eslint:
specifier: ^8.53.0
version: 8.53.0
@@ -416,6 +477,9 @@ importers:
packages/validation:
dependencies:
+ '@homarr/definitions':
+ specifier: workspace:^0.1.0
+ version: link:../definitions
zod:
specifier: ^3.22.2
version: 3.22.2
@@ -1481,6 +1545,20 @@ packages:
react: 18.2.0
dev: false
+ /@mantine/modals@7.3.2(@mantine/core@7.3.2)(@mantine/hooks@7.3.2)(react-dom@18.2.0)(react@18.2.0):
+ resolution: {integrity: sha512-vhpcp0Yqgm+K/vorDbuweTjzDO4pJaG2POc00cSTV3zJdsbeMAzVClovTuseJT+UO2lUdUP3RG1cInaZqSclhA==}
+ peerDependencies:
+ '@mantine/core': 7.3.2
+ '@mantine/hooks': 7.3.2
+ react: ^18.2.0
+ react-dom: ^18.2.0
+ dependencies:
+ '@mantine/core': 7.3.2(@mantine/hooks@7.3.2)(@types/react@18.2.42)(react-dom@18.2.0)(react@18.2.0)
+ '@mantine/hooks': 7.3.2(react@18.2.0)
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ dev: false
+
/@mantine/notifications@7.3.2(@mantine/core@7.3.2)(@mantine/hooks@7.3.2)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-XOzgm4pm4XszavVN0QUjN+IP0xiG2IochxJSz/FduTI0r3u1WxdpvDYlOvEJpHhtWvyqI8W8rx6cPJaD2HdAwQ==}
peerDependencies:
@@ -2212,6 +2290,10 @@ packages:
'@types/node': 18.18.13
dev: true
+ /@types/css-modules@1.0.5:
+ resolution: {integrity: sha512-oeKafs/df9lwOvtfiXVliZsocFVOexK9PZtLQWuPeuVCFR7jwiqlg60lu80JTe5NFNtH3tnV6Fs/ySR8BUPHAw==}
+ dev: true
+
/@types/eslint@8.44.7:
resolution: {integrity: sha512-f5ORu2hcBbKei97U73mf+l9t4zTGl74IqZ0GQk4oVea/VS8tQZYkUveSYojk+frraAVYId0V2WC9O4PTNru2FQ==}
dependencies: