feat: add crud for integrations (#11)
* wip: add crud for services and integrations * feat: remove services * feat: move integration definitions to homarr/definitions, add temporary test connection solution without actual request * feat: add integration count badge * feat: add translation for integrations * feat: add notifications and translate them * feat: add notice to integration forms about test connection * chore: fix ci check issues * feat: add confirm modals for integration deletion and secret card cancellation, change ordering for list page, add name property to integrations * refactor: move revalidate path action * chore: fix ci check issues * chore: install missing dependencies * chore: fix ci check issues * chore: address pull request feedback
This commit is contained in:
@@ -29,6 +29,9 @@ const config = {
|
|||||||
"@mantine/spotlight",
|
"@mantine/spotlight",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
images: {
|
||||||
|
domains: ["cdn.jsdelivr.net"],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -15,7 +15,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/api": "workspace:^0.1.0",
|
"@homarr/api": "workspace:^0.1.0",
|
||||||
"@homarr/auth": "workspace:^0.1.0",
|
"@homarr/auth": "workspace:^0.1.0",
|
||||||
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/db": "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/form": "workspace:^0.1.0",
|
||||||
"@homarr/notifications": "workspace:^0.1.0",
|
"@homarr/notifications": "workspace:^0.1.0",
|
||||||
"@homarr/spotlight": "workspace:^0.1.0",
|
"@homarr/spotlight": "workspace:^0.1.0",
|
||||||
@@ -24,6 +29,7 @@
|
|||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@homarr/widgets": "workspace:^0.1.0",
|
"@homarr/widgets": "workspace:^0.1.0",
|
||||||
"@mantine/hooks": "^7.3.2",
|
"@mantine/hooks": "^7.3.2",
|
||||||
|
"@mantine/modals": "^7.3.2",
|
||||||
"@mantine/tiptap": "^7.3.2",
|
"@mantine/tiptap": "^7.3.2",
|
||||||
"@t3-oss/env-nextjs": "^0.7.1",
|
"@t3-oss/env-nextjs": "^0.7.1",
|
||||||
"@tanstack/react-query": "^5.8.7",
|
"@tanstack/react-query": "^5.8.7",
|
||||||
|
|||||||
@@ -2,8 +2,28 @@
|
|||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { ModalsManager } from "../modals";
|
import { ModalsManager } from "../modals";
|
||||||
|
|
||||||
export const ModalsProvider = ({ children }: PropsWithChildren) => {
|
export const ModalsProvider = ({ children }: PropsWithChildren) => {
|
||||||
return <ModalsManager>{children}</ModalsManager>;
|
const t = useScopedI18n("common.action");
|
||||||
|
return (
|
||||||
|
<ModalsManager
|
||||||
|
labels={{
|
||||||
|
cancel: t("cancel"),
|
||||||
|
confirm: t("confirm"),
|
||||||
|
}}
|
||||||
|
modalProps={{
|
||||||
|
styles: {
|
||||||
|
title: {
|
||||||
|
fontSize: "1.25rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ModalsManager>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { loggerLink, unstable_httpBatchStreamLink } from "@trpc/client";
|
|||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
|
||||||
import { env } from "~/env.mjs";
|
import { env } from "~/env.mjs";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
const getBaseUrl = () => {
|
const getBaseUrl = () => {
|
||||||
if (typeof window !== "undefined") return ""; // browser should use relative url
|
if (typeof window !== "undefined") return ""; // browser should use relative url
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Button, PasswordInput, Stack, TextInput } from "@homarr/ui";
|
|||||||
import type { z } from "@homarr/validation";
|
import type { z } from "@homarr/validation";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
export const InitUserForm = () => {
|
export const InitUserForm = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
32
apps/nextjs/src/app/[locale]/integrations/_accordion.tsx
Normal file
32
apps/nextjs/src/app/[locale]/integrations/_accordion.tsx
Normal file
@@ -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 (
|
||||||
|
<Accordion
|
||||||
|
variant="separated"
|
||||||
|
defaultValue={activeTab}
|
||||||
|
onChange={(tab) =>
|
||||||
|
tab
|
||||||
|
? router.replace(`?tab=${tab}`, {})
|
||||||
|
: router.replace("/integrations")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
};
|
||||||
18
apps/nextjs/src/app/[locale]/integrations/_avatar.tsx
Normal file
18
apps/nextjs/src/app/[locale]/integrations/_avatar.tsx
Normal file
@@ -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 <Avatar size={size} src={url} />;
|
||||||
|
};
|
||||||
68
apps/nextjs/src/app/[locale]/integrations/_buttons.tsx
Normal file
68
apps/nextjs/src/app/[locale]/integrations/_buttons.tsx
Normal file
@@ -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 (
|
||||||
|
<ActionIcon
|
||||||
|
loading={isPending}
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
onClick={() => {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<IconTrash color="red" size={16} stroke={1.5} />
|
||||||
|
</ActionIcon>
|
||||||
|
);
|
||||||
|
};
|
||||||
95
apps/nextjs/src/app/[locale]/integrations/_secret-card.tsx
Normal file
95
apps/nextjs/src/app/[locale]/integrations/_secret-card.tsx
Normal file
@@ -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<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card>
|
||||||
|
<Stack>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Group>
|
||||||
|
<Avatar>
|
||||||
|
<KindIcon size={16} />
|
||||||
|
</Avatar>
|
||||||
|
<Text fw={500}>
|
||||||
|
{t(`integration.secrets.kind.${secret.kind}.label`)}
|
||||||
|
</Text>
|
||||||
|
{publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null}
|
||||||
|
</Group>
|
||||||
|
<Group>
|
||||||
|
<Text c="gray.6" size="sm">
|
||||||
|
{t("integration.secrets.lastUpdated", {
|
||||||
|
date: dayjs().locale(params.locale).to(dayjs(secret.updatedAt)),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
{isPublic ? (
|
||||||
|
<ActionIcon
|
||||||
|
color="gray"
|
||||||
|
variant="subtle"
|
||||||
|
onClick={togglePublicSecretDisplay}
|
||||||
|
>
|
||||||
|
<DisplayIcon size={16} stroke={1.5} />
|
||||||
|
</ActionIcon>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!editMode) {
|
||||||
|
setEditMode(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldCancel = await onCancel();
|
||||||
|
if (!shouldCancel) return;
|
||||||
|
setEditMode(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{editMode ? t("common.action.cancel") : t("common.action.edit")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
<Collapse in={editMode}>{children}</Collapse>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
12
apps/nextjs/src/app/[locale]/integrations/_secret-icons.ts
Normal file
12
apps/nextjs/src/app/[locale]/integrations/_secret-icons.ts
Normal file
@@ -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
|
||||||
|
>;
|
||||||
60
apps/nextjs/src/app/[locale]/integrations/_secret-inputs.tsx
Normal file
60
apps/nextjs/src/app/[locale]/integrations/_secret-inputs.tsx
Normal file
@@ -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<HTMLInputElement>;
|
||||||
|
onFocus?: FocusEventHandler<HTMLInputElement>;
|
||||||
|
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IntegrationSecretInput = (props: IntegrationSecretInputProps) => {
|
||||||
|
const { isPublic } = integrationSecretKindObject[props.kind];
|
||||||
|
|
||||||
|
if (isPublic) return <PublicSecretInput {...props} />;
|
||||||
|
|
||||||
|
return <PrivateSecretInput {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const Icon = integrationSecretIcons[kind];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
{...props}
|
||||||
|
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
|
||||||
|
w="100%"
|
||||||
|
leftSection={<Icon size={20} stroke={1.5} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PrivateSecretInput = ({
|
||||||
|
kind,
|
||||||
|
...props
|
||||||
|
}: IntegrationSecretInputProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const Icon = integrationSecretIcons[kind];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PasswordInput
|
||||||
|
{...props}
|
||||||
|
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
|
||||||
|
description={t(`integration.secrets.secureNotice`)}
|
||||||
|
w="100%"
|
||||||
|
leftSection={<Icon size={20} stroke={1.5} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
162
apps/nextjs/src/app/[locale]/integrations/_test-connection.tsx
Normal file
162
apps/nextjs/src/app/[locale]/integrations/_test-connection.tsx
Normal file
@@ -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 (
|
||||||
|
<Group>
|
||||||
|
<Anchor
|
||||||
|
type="button"
|
||||||
|
component="button"
|
||||||
|
onClick={async () => {
|
||||||
|
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")}
|
||||||
|
</Anchor>
|
||||||
|
<TestConnectionIcon isDirty={isDirty} {...mutation} size={20} />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TestConnectionIconProps {
|
||||||
|
isDirty: boolean;
|
||||||
|
isPending: boolean;
|
||||||
|
isSuccess: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TestConnectionIcon = ({
|
||||||
|
isDirty,
|
||||||
|
isPending,
|
||||||
|
isSuccess,
|
||||||
|
isError,
|
||||||
|
size,
|
||||||
|
}: TestConnectionIconProps) => {
|
||||||
|
if (isPending) return <Loader color="blue" size={size} />;
|
||||||
|
if (isDirty) return null;
|
||||||
|
if (isSuccess) return <IconCheck size={size} stroke={1.5} color="green" />;
|
||||||
|
if (isError) return <IconX size={size} stroke={1.5} color="red" />;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TestConnectionNoticeAlert = () => {
|
||||||
|
const t = useI18n();
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
variant="light"
|
||||||
|
color="yellow"
|
||||||
|
title="Test Connection"
|
||||||
|
icon={<IconInfoCircle />}
|
||||||
|
>
|
||||||
|
{t("integration.testConnection.alertNotice")}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
175
apps/nextjs/src/app/[locale]/integrations/edit/[id]/_form.tsx
Normal file
175
apps/nextjs/src/app/[locale]/integrations/edit/[id]/_form.tsx
Normal file
@@ -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<FormType>({
|
||||||
|
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 (
|
||||||
|
<form onSubmit={form.onSubmit((v) => void handleSubmit(v))}>
|
||||||
|
<Stack>
|
||||||
|
<TestConnectionNoticeAlert />
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label={t("integration.field.name.label")}
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label={t("integration.field.url.label")}
|
||||||
|
{...form.getInputProps("url")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Fieldset legend={t("integration.secrets.title")}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{secretsKinds.map((kind, index) => (
|
||||||
|
<SecretCard
|
||||||
|
key={kind}
|
||||||
|
secret={secretsMap.get(kind)!}
|
||||||
|
onCancel={() =>
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IntegrationSecretInput
|
||||||
|
label={t(`integration.secrets.kind.${kind}.newLabel`)}
|
||||||
|
key={kind}
|
||||||
|
kind={kind}
|
||||||
|
{...form.getInputProps(`secrets.${index}.value`)}
|
||||||
|
/>
|
||||||
|
</SecretCard>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Fieldset>
|
||||||
|
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<TestConnection
|
||||||
|
isDirty={isDirty}
|
||||||
|
removeDirty={removeDirty}
|
||||||
|
integration={{
|
||||||
|
id: integration.id,
|
||||||
|
kind: integration.kind,
|
||||||
|
...form.values,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Group>
|
||||||
|
<Button variant="default" component={Link} href="/integrations">
|
||||||
|
{t("common.action.backToOverview")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={isPending} disabled={isDirty}>
|
||||||
|
{t("common.action.save")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormType = Omit<z.infer<typeof validation.integration.update>, "id">;
|
||||||
32
apps/nextjs/src/app/[locale]/integrations/edit/[id]/page.tsx
Normal file
32
apps/nextjs/src/app/[locale]/integrations/edit/[id]/page.tsx
Normal file
@@ -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 (
|
||||||
|
<Container>
|
||||||
|
<Stack>
|
||||||
|
<Group align="center">
|
||||||
|
<IntegrationAvatar kind={integration.kind} size="md" />
|
||||||
|
<Title>
|
||||||
|
{t("title", { name: getIntegrationName(integration.kind) })}
|
||||||
|
</Title>
|
||||||
|
</Group>
|
||||||
|
<EditIntegrationForm integration={integration} />
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
apps/nextjs/src/app/[locale]/integrations/new/_dropdown.tsx
Normal file
59
apps/nextjs/src/app/[locale]/integrations/new/_dropdown.tsx
Normal file
@@ -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 (
|
||||||
|
<Stack>
|
||||||
|
<TextInput
|
||||||
|
leftSection={<IconSearch stroke={1.5} size={20} />}
|
||||||
|
placeholder={t("integration.page.list.search")}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{filteredKinds.length > 0 ? (
|
||||||
|
<ScrollArea.Autosize mah={384}>
|
||||||
|
{filteredKinds.map((kind) => (
|
||||||
|
<Menu.Item
|
||||||
|
component={Link}
|
||||||
|
href={`/integrations/new?kind=${kind}`}
|
||||||
|
key={kind}
|
||||||
|
>
|
||||||
|
<Group>
|
||||||
|
<IntegrationAvatar kind={kind} size="sm" />
|
||||||
|
<Text size="sm">{getIntegrationName(kind)}</Text>
|
||||||
|
</Group>
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
) : (
|
||||||
|
<Menu.Item disabled>{t("common.noResults")}</Menu.Item>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
137
apps/nextjs/src/app/[locale]/integrations/new/_form.tsx
Normal file
137
apps/nextjs/src/app/[locale]/integrations/new/_form.tsx
Normal file
@@ -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<z.infer<typeof validation.integration.create>> & {
|
||||||
|
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<FormType>({
|
||||||
|
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 (
|
||||||
|
<form onSubmit={form.onSubmit((value) => void handleSubmit(value))}>
|
||||||
|
<Stack>
|
||||||
|
<TestConnectionNoticeAlert />
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label={t("integration.field.name.label")}
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label={t("integration.field.url.label")}
|
||||||
|
{...form.getInputProps("url")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Fieldset legend={t("integration.secrets.title")}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{secretKinds.map((kind, index) => (
|
||||||
|
<IntegrationSecretInput
|
||||||
|
key={kind}
|
||||||
|
kind={kind}
|
||||||
|
{...form.getInputProps(`secrets.${index}.value`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Fieldset>
|
||||||
|
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<TestConnection
|
||||||
|
isDirty={isDirty}
|
||||||
|
removeDirty={removeDirty}
|
||||||
|
integration={{
|
||||||
|
id: null,
|
||||||
|
kind: searchParams.kind,
|
||||||
|
...form.values,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group>
|
||||||
|
<Button variant="default" component={Link} href="/integrations">
|
||||||
|
{t("common.action.backToOverview")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={isPending} disabled={isDirty}>
|
||||||
|
{t("common.action.create")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormType = Omit<z.infer<typeof validation.integration.create>, "kind">;
|
||||||
44
apps/nextjs/src/app/[locale]/integrations/new/page.tsx
Normal file
44
apps/nextjs/src/app/[locale]/integrations/new/page.tsx
Normal file
@@ -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<z.infer<typeof validation.integration.create>> & {
|
||||||
|
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 (
|
||||||
|
<Container>
|
||||||
|
<Stack>
|
||||||
|
<Group align="center">
|
||||||
|
<IntegrationAvatar kind={currentKind} size="md" />
|
||||||
|
<Title>{t("title", { name: getIntegrationName(currentKind) })}</Title>
|
||||||
|
</Group>
|
||||||
|
<NewIntegrationForm searchParams={searchParams} />
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
apps/nextjs/src/app/[locale]/integrations/page.tsx
Normal file
175
apps/nextjs/src/app/[locale]/integrations/page.tsx
Normal file
@@ -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 (
|
||||||
|
<Container>
|
||||||
|
<Stack>
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Title>{t("page.list.title")}</Title>
|
||||||
|
<Menu
|
||||||
|
width={256}
|
||||||
|
trapFocus
|
||||||
|
position="bottom-start"
|
||||||
|
withinPortal
|
||||||
|
shadow="md"
|
||||||
|
keepMounted={false}
|
||||||
|
>
|
||||||
|
<MenuTarget>
|
||||||
|
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>
|
||||||
|
{t("action.create")}
|
||||||
|
</Button>
|
||||||
|
</MenuTarget>
|
||||||
|
<MenuDropdown>
|
||||||
|
<IntegrationCreateDropdownContent />
|
||||||
|
</MenuDropdown>
|
||||||
|
</Menu>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<IntegrationList
|
||||||
|
integrations={integrations}
|
||||||
|
activeTab={searchParams.tab}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IntegrationListProps {
|
||||||
|
integrations: RouterOutputs["integration"]["all"];
|
||||||
|
activeTab?: IntegrationKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IntegrationList = async ({
|
||||||
|
integrations,
|
||||||
|
activeTab,
|
||||||
|
}: IntegrationListProps) => {
|
||||||
|
const t = await getScopedI18n("integration");
|
||||||
|
|
||||||
|
if (integrations.length === 0) {
|
||||||
|
return <div>{t("page.list.empty")}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouppedIntegrations = integrations.reduce(
|
||||||
|
(acc, integration) => {
|
||||||
|
if (!acc[integration.kind]) {
|
||||||
|
acc[integration.kind] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
acc[integration.kind].push(integration);
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<IntegrationKind, RouterOutputs["integration"]["all"]>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IntegrationGroupAccordion activeTab={activeTab}>
|
||||||
|
{objectEntries(grouppedIntegrations).map(([kind, integrations]) => (
|
||||||
|
<AccordionItem key={kind} value={kind}>
|
||||||
|
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} />}>
|
||||||
|
<Group>
|
||||||
|
<Text>{getIntegrationName(kind)}</Text>
|
||||||
|
<CountBadge count={integrations.length} />
|
||||||
|
</Group>
|
||||||
|
</AccordionControl>
|
||||||
|
<AccordionPanel>
|
||||||
|
<Table>
|
||||||
|
<TableThead>
|
||||||
|
<TableTr>
|
||||||
|
<TableTh>{t("field.name.label")}</TableTh>
|
||||||
|
<TableTh>{t("field.url.label")}</TableTh>
|
||||||
|
<TableTh />
|
||||||
|
</TableTr>
|
||||||
|
</TableThead>
|
||||||
|
<TableTbody>
|
||||||
|
{integrations.map((integration) => (
|
||||||
|
<TableTr key={integration.id}>
|
||||||
|
<TableTd>{integration.name}</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
<Anchor
|
||||||
|
href={integration.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{integration.url}
|
||||||
|
</Anchor>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
<Group justify="end">
|
||||||
|
<ActionIconGroup>
|
||||||
|
<ActionIcon
|
||||||
|
component={Link}
|
||||||
|
href={`/integrations/edit/${integration.id}`}
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label="Edit integration"
|
||||||
|
>
|
||||||
|
<IconPencil size={16} stroke={1.5} />
|
||||||
|
</ActionIcon>
|
||||||
|
<DeleteIntegrationActionButton
|
||||||
|
integration={integration}
|
||||||
|
count={integrations.length}
|
||||||
|
/>
|
||||||
|
</ActionIconGroup>
|
||||||
|
</Group>
|
||||||
|
</TableTd>
|
||||||
|
</TableTr>
|
||||||
|
))}
|
||||||
|
</TableTbody>
|
||||||
|
</Table>
|
||||||
|
</AccordionPanel>
|
||||||
|
</AccordionItem>
|
||||||
|
))}
|
||||||
|
</IntegrationGroupAccordion>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -27,7 +27,8 @@ const handler = auth(async (req) => {
|
|||||||
endpoint: "/api/trpc",
|
endpoint: "/api/trpc",
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
req,
|
req,
|
||||||
createContext: () => createTRPCContext({ auth: req.auth, req }),
|
createContext: () =>
|
||||||
|
createTRPCContext({ auth: req.auth, headers: req.headers }),
|
||||||
onError({ error, path }) {
|
onError({ error, path }) {
|
||||||
console.error(`>>> tRPC Error on '${path}'`, error);
|
console.error(`>>> tRPC Error on '${path}'`, error);
|
||||||
},
|
},
|
||||||
|
|||||||
7
apps/nextjs/src/app/revalidatePathAction.ts
Normal file
7
apps/nextjs/src/app/revalidatePathAction.ts
Normal file
@@ -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")));
|
||||||
|
}
|
||||||
61
apps/nextjs/src/trpc/server.ts
Normal file
61
apps/nextjs/src/trpc/server.ts
Normal file
@@ -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<typeof appRouter>({
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/auth": "workspace:^0.1.0",
|
"@homarr/auth": "workspace:^0.1.0",
|
||||||
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@trpc/client": "next",
|
"@trpc/client": "next",
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { integrationRouter } from "./router/integration";
|
||||||
import { userRouter } from "./router/user";
|
import { userRouter } from "./router/user";
|
||||||
import { createTRPCRouter } from "./trpc";
|
import { createTRPCRouter } from "./trpc";
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
user: userRouter,
|
user: userRouter,
|
||||||
|
integration: integrationRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
243
packages/api/src/router/integration.ts
Normal file
243
packages/api/src/router/integration.ts
Normal file
@@ -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<void>;
|
||||||
|
// }
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@
|
|||||||
import { initTRPC, TRPCError } from "@trpc/server";
|
import { initTRPC, TRPCError } from "@trpc/server";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
|
||||||
import { auth } from "@homarr/auth";
|
|
||||||
import type { Session } from "@homarr/auth";
|
import type { Session } from "@homarr/auth";
|
||||||
|
import { auth } from "@homarr/auth";
|
||||||
import { db } from "@homarr/db";
|
import { db } from "@homarr/db";
|
||||||
import { ZodError } from "@homarr/validation";
|
import { ZodError } from "@homarr/validation";
|
||||||
|
|
||||||
@@ -49,11 +49,11 @@ const createInnerTRPCContext = (opts: CreateContextOptions) => {
|
|||||||
* @link https://trpc.io/docs/context
|
* @link https://trpc.io/docs/context
|
||||||
*/
|
*/
|
||||||
export const createTRPCContext = async (opts: {
|
export const createTRPCContext = async (opts: {
|
||||||
req?: Request;
|
headers?: Headers;
|
||||||
auth: Session | null;
|
auth: Session | null;
|
||||||
}) => {
|
}) => {
|
||||||
const session = opts.auth ?? (await auth());
|
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);
|
console.log(">>> tRPC Request from", source, "by", session?.user);
|
||||||
|
|
||||||
|
|||||||
1
packages/common/index.ts
Normal file
1
packages/common/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./src";
|
||||||
35
packages/common/package.json
Normal file
35
packages/common/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
2
packages/common/src/index.ts
Normal file
2
packages/common/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./object";
|
||||||
|
export * from "./string";
|
||||||
10
packages/common/src/object.ts
Normal file
10
packages/common/src/object.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export function objectKeys<O extends object>(obj: O): (keyof O)[] {
|
||||||
|
return Object.keys(obj) as (keyof O)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Entries<T> = {
|
||||||
|
[K in keyof T]: [K, T[K]];
|
||||||
|
}[keyof T][];
|
||||||
|
|
||||||
|
export const objectEntries = <T extends object>(obj: T) =>
|
||||||
|
Object.entries(obj) as Entries<T>;
|
||||||
3
packages/common/src/string.ts
Normal file
3
packages/common/src/string.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const capitalize = <T extends string>(str: T) => {
|
||||||
|
return (str.charAt(0).toUpperCase() + str.slice(1)) as Capitalize<T>;
|
||||||
|
};
|
||||||
8
packages/common/tsconfig.json
Normal file
8
packages/common/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@homarr/tsconfig/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||||
|
},
|
||||||
|
"include": ["*.ts", "src"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
"@paralleldrive/cuid2": "^2.2.2",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
"better-sqlite3": "^9.2.2",
|
"better-sqlite3": "^9.2.2",
|
||||||
"drizzle-orm": "^0.29.1"
|
"drizzle-orm": "^0.29.1"
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import {
|
|||||||
text,
|
text,
|
||||||
} from "drizzle-orm/sqlite-core";
|
} from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
IntegrationKind,
|
||||||
|
IntegrationSecretKind,
|
||||||
|
} from "@homarr/definitions";
|
||||||
|
|
||||||
export const users = sqliteTable("user", {
|
export const users = sqliteTable("user", {
|
||||||
id: text("id").notNull().primaryKey(),
|
id: text("id").notNull().primaryKey(),
|
||||||
name: text("name"),
|
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<IntegrationKind>().notNull(),
|
||||||
|
},
|
||||||
|
(i) => ({
|
||||||
|
kindIdx: index("integration__kind_idx").on(i.kind),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const integrationSecrets = sqliteTable(
|
||||||
|
"integrationSecret",
|
||||||
|
{
|
||||||
|
kind: text("kind").$type<IntegrationSecretKind>().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 }) => ({
|
export const accountRelations = relations(accounts, ({ one }) => ({
|
||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [accounts.userId],
|
fields: [accounts.userId],
|
||||||
@@ -81,7 +118,23 @@ export const userRelations = relations(users, ({ many }) => ({
|
|||||||
accounts: many(accounts),
|
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<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
export type Account = InferSelectModel<typeof accounts>;
|
export type Account = InferSelectModel<typeof accounts>;
|
||||||
export type Session = InferSelectModel<typeof sessions>;
|
export type Session = InferSelectModel<typeof sessions>;
|
||||||
export type VerificationToken = InferSelectModel<typeof verificationTokens>;
|
export type VerificationToken = InferSelectModel<typeof verificationTokens>;
|
||||||
|
export type Integration = InferSelectModel<typeof integrations>;
|
||||||
|
export type IntegrationSecret = InferSelectModel<typeof integrationSecrets>;
|
||||||
|
|||||||
1
packages/definitions/index.ts
Normal file
1
packages/definitions/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./src";
|
||||||
38
packages/definitions/package.json
Normal file
38
packages/definitions/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
packages/definitions/src/index.ts
Normal file
1
packages/definitions/src/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./integration";
|
||||||
155
packages/definitions/src/integration.ts
Normal file
155
packages/definitions/src/integration.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { objectKeys } from "@homarr/common";
|
||||||
|
|
||||||
|
export const integrationSecretKindObject = {
|
||||||
|
apiKey: { isPublic: false },
|
||||||
|
username: { isPublic: true },
|
||||||
|
password: { isPublic: false },
|
||||||
|
} satisfies Record<string, { isPublic: boolean }>;
|
||||||
|
|
||||||
|
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";
|
||||||
8
packages/definitions/tsconfig.json
Normal file
8
packages/definitions/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@homarr/tsconfig/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||||
|
},
|
||||||
|
"include": ["*.ts", "src"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NotificationData } from "@mantine/notifications";
|
import type { NotificationData } from "@mantine/notifications";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
|
|
||||||
import { IconCheck, IconX, rem } from "@homarr/ui";
|
import { IconCheck, IconX } from "@homarr/ui";
|
||||||
|
|
||||||
type CommonNotificationProps = Pick<NotificationData, "title" | "message">;
|
type CommonNotificationProps = Pick<NotificationData, "title" | "message">;
|
||||||
|
|
||||||
@@ -9,12 +9,12 @@ export const showSuccessNotification = (props: CommonNotificationProps) =>
|
|||||||
notifications.show({
|
notifications.show({
|
||||||
...props,
|
...props,
|
||||||
color: "teal",
|
color: "teal",
|
||||||
icon: <IconCheck size={rem(20)} />,
|
icon: <IconCheck size={20} />,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const showErrorNotification = (props: CommonNotificationProps) =>
|
export const showErrorNotification = (props: CommonNotificationProps) =>
|
||||||
notifications.show({
|
notifications.show({
|
||||||
...props,
|
...props,
|
||||||
color: "red",
|
color: "red",
|
||||||
icon: <IconX size={rem(20)} />,
|
icon: <IconX size={20} />,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import "dayjs/locale/de";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
user: {
|
user: {
|
||||||
page: {
|
page: {
|
||||||
@@ -26,6 +28,132 @@ export default {
|
|||||||
create: "Benutzer erstellen",
|
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: {
|
widget: {
|
||||||
clock: {
|
clock: {
|
||||||
option: {
|
option: {
|
||||||
@@ -52,10 +180,4 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
common: {
|
|
||||||
search: {
|
|
||||||
placeholder: "Suche nach etwas...",
|
|
||||||
nothingFound: "Nichts gefunden",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import "dayjs/locale/en";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
user: {
|
user: {
|
||||||
page: {
|
page: {
|
||||||
@@ -26,6 +28,131 @@ export default {
|
|||||||
create: "Create user",
|
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: {
|
widget: {
|
||||||
clock: {
|
clock: {
|
||||||
option: {
|
option: {
|
||||||
@@ -52,12 +179,6 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
common: {
|
|
||||||
search: {
|
|
||||||
placeholder: "Search for anything...",
|
|
||||||
nothingFound: "Nothing found",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
management: {
|
management: {
|
||||||
metaTitle: "Management",
|
metaTitle: "Management",
|
||||||
title: {
|
title: {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
|
"@types/css-modules": "^1.0.5",
|
||||||
"eslint": "^8.53.0",
|
"eslint": "^8.53.0",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
|
|||||||
11
packages/ui/src/components/count-badge.module.css
Normal file
11
packages/ui/src/components/count-badge.module.css
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
packages/ui/src/components/count-badge.tsx
Normal file
11
packages/ui/src/components/count-badge.tsx
Normal file
@@ -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 <Badge className={classes.badge}>{count}</Badge>;
|
||||||
|
};
|
||||||
1
packages/ui/src/components/index.tsx
Normal file
1
packages/ui/src/components/index.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./count-badge";
|
||||||
@@ -2,6 +2,8 @@ import type { MantineProviderProps } from "@mantine/core";
|
|||||||
|
|
||||||
import { theme } from "./theme";
|
import { theme } from "./theme";
|
||||||
|
|
||||||
|
export * from "./components";
|
||||||
|
|
||||||
export const uiConfiguration = {
|
export const uiConfiguration = {
|
||||||
theme,
|
theme,
|
||||||
} satisfies MantineProviderProps;
|
} satisfies MantineProviderProps;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^3.22.2"
|
"zod": "^3.22.2",
|
||||||
|
"@homarr/definitions": "workspace:^0.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
packages/validation/src/enums.ts
Normal file
4
packages/validation/src/enums.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const zodEnumFromArray = <T extends string>(arr: T[]) =>
|
||||||
|
z.enum([arr[0]!, ...arr.slice(1)]);
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { integrationSchemas } from "./integration";
|
||||||
import { userSchemas } from "./user";
|
import { userSchemas } from "./user";
|
||||||
|
|
||||||
export const validation = {
|
export const validation = {
|
||||||
user: userSchemas,
|
user: userSchemas,
|
||||||
|
integration: integrationSchemas,
|
||||||
};
|
};
|
||||||
|
|||||||
53
packages/validation/src/integration.ts
Normal file
53
packages/validation/src/integration.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
82
pnpm-lock.yaml
generated
82
pnpm-lock.yaml
generated
@@ -35,9 +35,15 @@ importers:
|
|||||||
'@homarr/auth':
|
'@homarr/auth':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../../packages/auth
|
version: link:../../packages/auth
|
||||||
|
'@homarr/common':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../../packages/common
|
||||||
'@homarr/db':
|
'@homarr/db':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../../packages/db
|
version: link:../../packages/db
|
||||||
|
'@homarr/definitions':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../../packages/definitions
|
||||||
'@homarr/form':
|
'@homarr/form':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../../packages/form
|
version: link:../../packages/form
|
||||||
@@ -62,6 +68,9 @@ importers:
|
|||||||
'@mantine/hooks':
|
'@mantine/hooks':
|
||||||
specifier: ^7.3.2
|
specifier: ^7.3.2
|
||||||
version: 7.3.2(react@18.2.0)
|
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':
|
'@mantine/tiptap':
|
||||||
specifier: ^7.3.2
|
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)
|
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':
|
'@homarr/db':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../db
|
version: link:../db
|
||||||
|
'@homarr/definitions':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../definitions
|
||||||
'@homarr/validation':
|
'@homarr/validation':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../validation
|
version: link:../validation
|
||||||
@@ -255,8 +267,32 @@ importers:
|
|||||||
specifier: ^5.3.3
|
specifier: ^5.3.3
|
||||||
version: 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:
|
packages/db:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@homarr/common':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../common
|
||||||
|
'@homarr/definitions':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../definitions
|
||||||
'@paralleldrive/cuid2':
|
'@paralleldrive/cuid2':
|
||||||
specifier: ^2.2.2
|
specifier: ^2.2.2
|
||||||
version: 2.2.2
|
version: 2.2.2
|
||||||
@@ -295,6 +331,28 @@ importers:
|
|||||||
specifier: ^5.3.3
|
specifier: ^5.3.3
|
||||||
version: 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:
|
packages/form:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mantine/form':
|
'@mantine/form':
|
||||||
@@ -407,6 +465,9 @@ importers:
|
|||||||
'@homarr/tsconfig':
|
'@homarr/tsconfig':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../../tooling/typescript
|
version: link:../../tooling/typescript
|
||||||
|
'@types/css-modules':
|
||||||
|
specifier: ^1.0.5
|
||||||
|
version: 1.0.5
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^8.53.0
|
specifier: ^8.53.0
|
||||||
version: 8.53.0
|
version: 8.53.0
|
||||||
@@ -416,6 +477,9 @@ importers:
|
|||||||
|
|
||||||
packages/validation:
|
packages/validation:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@homarr/definitions':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../definitions
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.22.2
|
specifier: ^3.22.2
|
||||||
version: 3.22.2
|
version: 3.22.2
|
||||||
@@ -1481,6 +1545,20 @@ packages:
|
|||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
dev: false
|
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):
|
/@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==}
|
resolution: {integrity: sha512-XOzgm4pm4XszavVN0QUjN+IP0xiG2IochxJSz/FduTI0r3u1WxdpvDYlOvEJpHhtWvyqI8W8rx6cPJaD2HdAwQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2212,6 +2290,10 @@ packages:
|
|||||||
'@types/node': 18.18.13
|
'@types/node': 18.18.13
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@types/css-modules@1.0.5:
|
||||||
|
resolution: {integrity: sha512-oeKafs/df9lwOvtfiXVliZsocFVOexK9PZtLQWuPeuVCFR7jwiqlg60lu80JTe5NFNtH3tnV6Fs/ySR8BUPHAw==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/eslint@8.44.7:
|
/@types/eslint@8.44.7:
|
||||||
resolution: {integrity: sha512-f5ORu2hcBbKei97U73mf+l9t4zTGl74IqZ0GQk4oVea/VS8tQZYkUveSYojk+frraAVYId0V2WC9O4PTNru2FQ==}
|
resolution: {integrity: sha512-f5ORu2hcBbKei97U73mf+l9t4zTGl74IqZ0GQk4oVea/VS8tQZYkUveSYojk+frraAVYId0V2WC9O4PTNru2FQ==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user