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 ( +
void handleSubmit(v))}> + + + + + + + +
+ + {secretsKinds.map((kind, index) => ( + + new Promise((res) => { + // When nothing changed, just close the secret card + if ( + (form.values.secrets[index]?.value ?? "") === + (secretsMap.get(kind)?.value ?? "") + ) { + return res(true); + } + modalEvents.openConfirmModal({ + title: t("integration.secrets.reset.title"), + children: t("integration.secrets.reset.message"), + onCancel: () => res(false), + onConfirm: () => { + form.setFieldValue( + `secrets.${index}.value`, + secretsMap.get(kind)!.value ?? "", + ); + res(true); + }, + }); + }) + } + > + + + ))} + +
+ + + + + + + + +
+
+ ); +}; + +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 ( +
void handleSubmit(value))}> + + + + + + + +
+ + {secretKinds.map((kind, index) => ( + + ))} + +
+ + + + + + + + + +
+
+ ); +}; + +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: