feat(integration): improve integration test connection (#3005)
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { useMemo } from "react";
|
||||
import { Accordion, Anchor, Card, Stack, Text } from "@mantine/core";
|
||||
import { IconSubtask } from "@tabler/icons-react";
|
||||
|
||||
import { getMantineColor } from "@homarr/common";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CertificateErrorDetails } from "./test-connection-certificate";
|
||||
import type { AnyMappedTestConnectionError, MappedError } from "./types";
|
||||
|
||||
interface IntegrationTestConnectionErrorProps {
|
||||
error: AnyMappedTestConnectionError;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestConnectionErrorProps) => {
|
||||
const t = useI18n();
|
||||
const causeArray = useMemo(() => toCauseArray(error.cause), [error.cause]);
|
||||
|
||||
return (
|
||||
<Card withBorder style={{ borderColor: getMantineColor("red", 8) }}>
|
||||
<Stack>
|
||||
<Stack gap="sm">
|
||||
<Text size="lg" fw={500} c="red.8">
|
||||
{t(`integration.testConnection.error.${error.type}.title`)}
|
||||
</Text>
|
||||
|
||||
{error.type !== "request" && error.type !== "certificate" && error.type !== "statusCode" ? (
|
||||
<Text size="md">{t(`integration.testConnection.error.${error.type}.description`)}</Text>
|
||||
) : null}
|
||||
|
||||
{error.type === "request" ? (
|
||||
<Text size="md">
|
||||
{t(
|
||||
`integration.testConnection.error.request.description.${error.data.type}.${error.data.reason}` as never,
|
||||
)}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{error.type === "statusCode" ? (
|
||||
error.data.reason === "other" ? (
|
||||
<Text size="md">
|
||||
{t.rich("integration.testConnection.error.statusCode.otherDescription", {
|
||||
statusCode: error.data.statusCode.toString(),
|
||||
url: () => <Anchor href={error.data.url}>{error.data.url}</Anchor>,
|
||||
})}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="md">
|
||||
{t.rich("integration.testConnection.error.statusCode.description", {
|
||||
reason: t(`integration.testConnection.error.statusCode.reason.${error.data.reason}`),
|
||||
statusCode: error.data.statusCode.toString(),
|
||||
url: () => <Anchor href={error.data.url}>{error.data.url}</Anchor>,
|
||||
})}
|
||||
</Text>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{error.type === "certificate" ? <CertificateErrorDetails error={error} url={url} /> : null}
|
||||
</Stack>
|
||||
|
||||
{error.cause ? (
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="cause">
|
||||
<Accordion.Control icon={<IconSubtask size={16} stroke={1.5} />}>
|
||||
{t("integration.testConnection.error.common.cause.title")}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<pre style={{ whiteSpace: "pre-wrap" }}>
|
||||
{error.name}: {error.message}
|
||||
{"\n"}
|
||||
{causeArray
|
||||
.map(
|
||||
(cause) =>
|
||||
`caused by ${cause.name}${cause.message ? `: ${cause.message}` : ""} ${cause.metadata.map((item) => `${item.key}=${item.value}`).join(" ")}`,
|
||||
)
|
||||
.join("\n")}
|
||||
</pre>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const toCauseArray = (cause: MappedError | undefined) => {
|
||||
const causeArray: MappedError[] = [];
|
||||
let currentCause: MappedError | undefined = cause;
|
||||
while (currentCause) {
|
||||
causeArray.push(currentCause);
|
||||
currentCause = currentCause.cause;
|
||||
}
|
||||
return causeArray.map(({ cause: _innerCause, ...cause }) => cause);
|
||||
};
|
||||
@@ -0,0 +1,327 @@
|
||||
import { useState } from "react";
|
||||
import { ActionIcon, Alert, Anchor, Button, Card, CopyButton, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconCheck, IconCopy, IconExclamationCircle, IconRepeat } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { getMantineColor } from "@homarr/common";
|
||||
import { createId } from "@homarr/db/client";
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { createModal, useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { AddCertificateModal } from "@homarr/modals-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useCurrentLocale, useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { MappedCertificate, MappedTestConnectionCertificateError } from "./types";
|
||||
|
||||
interface CertificateErrorDetailsProps {
|
||||
error: MappedTestConnectionCertificateError;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsProps) => {
|
||||
const tError = useScopedI18n("integration.testConnection.error");
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
const [showRetryButton, setShowRetryButton] = useState(false);
|
||||
|
||||
const { openModal: openUploadModal } = useModalAction(AddCertificateModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { mutateAsync: trustHostnameAsync } = clientApi.certificates.trustHostnameMismatch.useMutation();
|
||||
const { mutateAsync: addCertificateAsync } = clientApi.certificates.addCertificate.useMutation();
|
||||
|
||||
const handleTrustHostname = () => {
|
||||
const { hostname } = new URL(url);
|
||||
openConfirmModal({
|
||||
title: tError("certificate.hostnameMismatch.confirm.title"),
|
||||
children: tError("certificate.hostnameMismatch.confirm.message"),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
async onConfirm() {
|
||||
await trustHostnameAsync(
|
||||
{
|
||||
hostname,
|
||||
certificate: error.data.certificate.pem,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: tError("certificate.hostnameMismatch.notification.success.title"),
|
||||
message: tError("certificate.hostnameMismatch.notification.success.message"),
|
||||
});
|
||||
setShowRetryButton(true);
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: tError("certificate.hostnameMismatch.notification.error.title"),
|
||||
message: tError("certificate.hostnameMismatch.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleTrustSelfSigned = () => {
|
||||
const { hostname } = new URL(url);
|
||||
openConfirmModal({
|
||||
title: tError("certificate.selfSigned.confirm.title"),
|
||||
children: tError("certificate.selfSigned.confirm.message"),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
async onConfirm() {
|
||||
const formData = new FormData();
|
||||
formData.append(
|
||||
"file",
|
||||
new File([error.data.certificate.pem], `${hostname}-${createId()}.crt`, {
|
||||
type: "application/x-x509-ca-cert",
|
||||
}),
|
||||
);
|
||||
await addCertificateAsync(formData, {
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: tError("certificate.selfSigned.notification.success.title"),
|
||||
message: tError("certificate.selfSigned.notification.success.message"),
|
||||
});
|
||||
setShowRetryButton(true);
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: tError("certificate.selfSigned.notification.error.title"),
|
||||
message: tError("certificate.selfSigned.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const description = <Text size="md">{tError(`certificate.description.${error.data.reason}`)}</Text>;
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<>
|
||||
{description}
|
||||
<NotEnoughPermissionsAlert />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{description}
|
||||
|
||||
<CertificateDetailsCard certificate={error.data.certificate} />
|
||||
|
||||
{error.data.reason === "hostnameMismatch" && <HostnameMismatchAlert />}
|
||||
|
||||
{!error.data.certificate.isSelfSigned && error.data.reason === "untrusted" && <CertificateExtractAlert />}
|
||||
|
||||
{showRetryButton && (
|
||||
<Button
|
||||
variant="default"
|
||||
fullWidth
|
||||
leftSection={<IconRepeat size={16} color={getMantineColor("blue", 6)} stroke={1.5} />}
|
||||
type="submit"
|
||||
>
|
||||
{tError("certificate.action.retry.label")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(error.data.reason === "untrusted" && error.data.certificate.isSelfSigned) ||
|
||||
error.data.reason === "hostnameMismatch" ? (
|
||||
<Button
|
||||
variant="default"
|
||||
fullWidth
|
||||
onClick={error.data.reason === "hostnameMismatch" ? handleTrustHostname : handleTrustSelfSigned}
|
||||
>
|
||||
{tError("certificate.action.trust.label")}
|
||||
</Button>
|
||||
) : null}
|
||||
{error.data.reason === "untrusted" && !error.data.certificate.isSelfSigned ? (
|
||||
<Button
|
||||
variant="default"
|
||||
fullWidth
|
||||
onClick={() =>
|
||||
openUploadModal({
|
||||
onSuccess() {
|
||||
setShowRetryButton(true);
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{tError("certificate.action.upload.label")}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const NotEnoughPermissionsAlert = () => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Alert
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
title={t("integration.testConnection.error.certificate.alert.permission.title")}
|
||||
color="yellow"
|
||||
>
|
||||
{t("integration.testConnection.error.certificate.alert.permission.message")}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
const HostnameMismatchAlert = () => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Alert
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
title={t("integration.testConnection.error.certificate.alert.hostnameMismatch.title")}
|
||||
color="yellow"
|
||||
>
|
||||
{t("integration.testConnection.error.certificate.alert.hostnameMismatch.message")}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
const CertificateExtractAlert = () => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Alert
|
||||
icon={<IconExclamationCircle size={16} />}
|
||||
title={t("integration.testConnection.error.certificate.alert.extract.title")}
|
||||
color="red"
|
||||
>
|
||||
{t.rich("integration.testConnection.error.certificate.alert.extract.message", {
|
||||
docsLink: () => (
|
||||
<Anchor
|
||||
href={createDocumentationLink("/docs/management/certificates", "#obtaining-certificates")}
|
||||
target="_blank"
|
||||
>
|
||||
{t("common.here")}
|
||||
</Anchor>
|
||||
),
|
||||
})}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
interface CertificateDetailsProps {
|
||||
certificate: MappedCertificate;
|
||||
}
|
||||
|
||||
export const CertificateDetailsCard = ({ certificate }: CertificateDetailsProps) => {
|
||||
const { openModal } = useModalAction(PemContentModal);
|
||||
const locale = useCurrentLocale();
|
||||
const tDetails = useScopedI18n("integration.testConnection.error.certificate.details");
|
||||
const tCertificateField = useScopedI18n("certificate.field");
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Text fw={500}>{tDetails("title")}</Text>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{tDetails("description")}
|
||||
</Text>
|
||||
<Anchor
|
||||
size="sm"
|
||||
ta="start"
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => openModal({ content: certificate.pem })}
|
||||
>
|
||||
{tDetails("content.action")}
|
||||
</Anchor>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} mt="md">
|
||||
<Stack gap={0}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{tCertificateField("subject.label")}
|
||||
</Text>
|
||||
<Text size="sm">{certificate.subject}</Text>
|
||||
</Stack>
|
||||
<Stack gap={0}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{tCertificateField("issuer.label")}
|
||||
</Text>
|
||||
<Text size="sm">{certificate.issuer}</Text>
|
||||
</Stack>
|
||||
<Stack gap={0}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{tCertificateField("validFrom.label")}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: "full",
|
||||
timeStyle: "long",
|
||||
}).format(certificate.validFrom)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={0}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{tCertificateField("validTo.label")}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: "full",
|
||||
timeStyle: "long",
|
||||
}).format(certificate.validTo)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={0}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{tCertificateField("serialNumber.label")}
|
||||
</Text>
|
||||
<Text size="sm">{certificate.serialNumber}</Text>
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={1} mt="md">
|
||||
<Stack gap={0}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{tCertificateField("fingerprint.label")}
|
||||
</Text>
|
||||
<Text size="sm">{certificate.fingerprint}</Text>
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const PemContentModal = createModal<{ content: string }>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Card w="100%" pos="relative" bg="dark.6" fz="xs" p="sm">
|
||||
<pre
|
||||
style={{
|
||||
whiteSpace: "pre-wrap",
|
||||
wordWrap: "break-word",
|
||||
}}
|
||||
>
|
||||
{innerProps.content}
|
||||
</pre>
|
||||
<CopyButton value={innerProps.content}>
|
||||
{({ copy, copied }) => (
|
||||
<ActionIcon onClick={copy} pos="absolute" top={8} right={8} variant="default">
|
||||
{copied ? (
|
||||
<IconCheck size={16} stroke={1.5} color={getMantineColor("green", 6)} />
|
||||
) : (
|
||||
<IconCopy size={16} stroke={1.5} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Card>
|
||||
|
||||
<Button variant="light" color="gray" onClick={actions.closeModal}>
|
||||
{t("common.action.close")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("integration.testConnection.error.certificate.details.content.title");
|
||||
},
|
||||
size: "lg",
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
|
||||
export type AnyMappedTestConnectionError = Exclude<RouterOutputs["integration"]["create"], undefined>["error"];
|
||||
export type MappedTestConnectionCertificateError = Extract<AnyMappedTestConnectionError, { type: "certificate" }>;
|
||||
export type MappedCertificate = MappedTestConnectionCertificateError["data"]["certificate"];
|
||||
export type MappedError = Exclude<AnyMappedTestConnectionError["cause"], undefined>;
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button, Fieldset, Group, Stack, TextInput } from "@mantine/core";
|
||||
@@ -10,7 +11,6 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -18,6 +18,8 @@ import { integrationUpdateSchema } from "@homarr/validation/integration";
|
||||
|
||||
import { SecretCard } from "../../_components/secrets/integration-secret-card";
|
||||
import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs";
|
||||
import { IntegrationTestConnectionError } from "../../_components/test-connection/integration-test-connection-error";
|
||||
import type { AnyMappedTestConnectionError } from "../../_components/test-connection/types";
|
||||
|
||||
interface EditIntegrationForm {
|
||||
integration: RouterOutputs["integration"]["byId"];
|
||||
@@ -43,6 +45,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
},
|
||||
});
|
||||
const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
|
||||
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
|
||||
|
||||
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
|
||||
|
||||
@@ -57,26 +60,24 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
})),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
// We do it this way as we are unable to send a typesafe error through onError
|
||||
if (data?.error) {
|
||||
setError(data.error);
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
showSuccessNotification({
|
||||
title: t("integration.page.edit.notification.success.title"),
|
||||
message: t("integration.page.edit.notification.success.message"),
|
||||
});
|
||||
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
||||
},
|
||||
onError: (error) => {
|
||||
const testConnectionError = convertIntegrationTestConnectionError(error.data?.error);
|
||||
|
||||
if (testConnectionError) {
|
||||
showErrorNotification({
|
||||
title: t(`integration.testConnection.notification.${testConnectionError.key}.title`),
|
||||
message:
|
||||
testConnectionError.message ??
|
||||
t(`integration.testConnection.notification.${testConnectionError.key}.message`),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
@@ -128,6 +129,8 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
|
||||
|
||||
<Group justify="end" align="center">
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
|
||||
@@ -24,13 +24,14 @@ import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions
|
||||
import { getAllSecretKindOptions, getIconUrl, getIntegrationName, integrationDefs } from "@homarr/definitions";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { appHrefSchema } from "@homarr/validation/app";
|
||||
import { integrationCreateSchema } from "@homarr/validation/integration";
|
||||
|
||||
import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs";
|
||||
import { IntegrationTestConnectionError } from "../_components/test-connection/integration-test-connection-error";
|
||||
import type { AnyMappedTestConnectionError } from "../_components/test-connection/types";
|
||||
|
||||
interface NewIntegrationFormProps {
|
||||
searchParams: Partial<z.infer<typeof integrationCreateSchema>> & {
|
||||
@@ -73,6 +74,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
clientApi.integration.create.useMutation();
|
||||
const { mutateAsync: createAppAsync, isPending: isPendingApp } = clientApi.app.create.useMutation();
|
||||
const isPending = isPendingIntegration || isPendingApp;
|
||||
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
|
||||
|
||||
const handleSubmitAsync = async (values: FormType) => {
|
||||
await createIntegrationAsync(
|
||||
@@ -81,7 +83,17 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
...values,
|
||||
},
|
||||
{
|
||||
async onSuccess() {
|
||||
async onSuccess(data) {
|
||||
// We do it this way as we are unable to send a typesafe error through onError
|
||||
if (data?.error) {
|
||||
setError(data.error);
|
||||
showErrorNotification({
|
||||
title: t("integration.page.create.notification.error.title"),
|
||||
message: t("integration.page.create.notification.error.message"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
showSuccessNotification({
|
||||
title: t("integration.page.create.notification.success.title"),
|
||||
message: t("integration.page.create.notification.success.message"),
|
||||
@@ -114,19 +126,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
const testConnectionError = convertIntegrationTestConnectionError(error.data?.error);
|
||||
|
||||
if (testConnectionError) {
|
||||
showErrorNotification({
|
||||
title: t(`integration.testConnection.notification.${testConnectionError.key}.title`),
|
||||
message:
|
||||
testConnectionError.message ??
|
||||
t(`integration.testConnection.notification.${testConnectionError.key}.message`),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: t("integration.page.create.notification.error.title"),
|
||||
message: t("integration.page.create.notification.error.message"),
|
||||
@@ -164,6 +164,8 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
|
||||
|
||||
{supportsSearchEngine && (
|
||||
<Checkbox
|
||||
label={t("integration.field.attemptSearchEngineCreation.label")}
|
||||
|
||||
@@ -1,82 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { Button, FileInput, Group, Stack } from "@mantine/core";
|
||||
import { IconCertificate } from "@tabler/icons-react";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddCertificateModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { superRefineCertificateFile } from "@homarr/validation/certificates";
|
||||
|
||||
export const AddCertificateButton = () => {
|
||||
const { openModal } = useModalAction(AddCertificateModal);
|
||||
const t = useI18n();
|
||||
|
||||
const handleClick = () => {
|
||||
openModal({});
|
||||
openModal({
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync("/manage/tools/certificates");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return <Button onClick={handleClick}>{t("certificate.action.create.label")}</Button>;
|
||||
};
|
||||
|
||||
const AddCertificateModal = createModal(({ actions }) => {
|
||||
const t = useI18n();
|
||||
const form = useZodForm(
|
||||
z.object({
|
||||
file: z.instanceof(File).nullable().superRefine(superRefineCertificateFile),
|
||||
}),
|
||||
{
|
||||
initialValues: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
file: null!,
|
||||
},
|
||||
},
|
||||
);
|
||||
const { mutateAsync } = clientApi.certificates.addCertificate.useMutation();
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
const formData = new FormData();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
formData.set("file", values.file!);
|
||||
await mutateAsync(formData, {
|
||||
async onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t("certificate.action.create.notification.success.title"),
|
||||
message: t("certificate.action.create.notification.success.message"),
|
||||
});
|
||||
await revalidatePathActionAsync("/manage/tools/certificates");
|
||||
actions.closeModal();
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("certificate.action.create.notification.error.title"),
|
||||
message: t("certificate.action.create.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<FileInput leftSection={<IconCertificate size={16} />} {...form.getInputProps("file")} />
|
||||
<Group justify="end">
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={form.submitting}>
|
||||
{t("common.action.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("certificate.action.create.label");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface RemoveHostnameActionIconProps {
|
||||
hostname: string;
|
||||
thumbprint: string;
|
||||
}
|
||||
|
||||
export const RemoveHostnameActionIcon = (input: RemoveHostnameActionIconProps) => {
|
||||
const { mutateAsync } = clientApi.certificates.removeTrustedHostname.useMutation();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const t = useI18n();
|
||||
|
||||
const handleRemove = () => {
|
||||
openConfirmModal({
|
||||
title: t("certificate.action.removeHostname.label"),
|
||||
children: t("certificate.action.removeHostname.confirm"),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
async onConfirm() {
|
||||
await mutateAsync(input, {
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync("/manage/tools/certificates/hostnames");
|
||||
showSuccessNotification({
|
||||
title: t("certificate.action.removeHostname.notification.success.title"),
|
||||
message: t("certificate.action.removeHostname.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("certificate.action.removeHostname.notification.error.title"),
|
||||
message: t("certificate.action.removeHostname.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionIcon color="red" variant="subtle" onClick={handleRemove}>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconCertificateOff } from "@tabler/icons-react";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getTrustedCertificateHostnamesAsync } from "@homarr/certificates/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { RemoveHostnameActionIcon } from "./_components/remove-hostname";
|
||||
|
||||
export default async function TrustedHostnamesPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const t = await getI18n();
|
||||
|
||||
const trustedHostnames = await getTrustedCertificateHostnamesAsync().then((hostnames) => {
|
||||
return hostnames.map((hostname) => {
|
||||
let subject: string | null;
|
||||
try {
|
||||
subject = new X509Certificate(hostname.certificate).subject;
|
||||
} catch {
|
||||
subject = null;
|
||||
}
|
||||
return {
|
||||
...hostname,
|
||||
subject,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Stack gap={4}>
|
||||
<Title>{t("certificate.page.hostnames.title")}</Title>
|
||||
<Text>{t("certificate.page.hostnames.description")}</Text>
|
||||
</Stack>
|
||||
|
||||
<Button variant="default" component={Link} href="/manage/tools/certificates">
|
||||
{t("certificate.page.hostnames.toCertificates")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{trustedHostnames.length === 0 && (
|
||||
<NoResults icon={IconCertificateOff} title={t("certificate.page.hostnames.noResults.title")} />
|
||||
)}
|
||||
|
||||
{trustedHostnames.length >= 1 && (
|
||||
<Table>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{t("certificate.field.hostname.label")}</TableTh>
|
||||
<TableTh>{t("certificate.field.subject.label")}</TableTh>
|
||||
<TableTh>{t("certificate.field.fingerprint.label")}</TableTh>
|
||||
<TableTh></TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{trustedHostnames.map(({ hostname, subject, thumbprint }) => (
|
||||
<TableTr key={`${hostname}-${thumbprint}`}>
|
||||
<TableTd>{hostname}</TableTd>
|
||||
<TableTd>{subject}</TableTd>
|
||||
<TableTd>{thumbprint}</TableTd>
|
||||
<TableTd>
|
||||
<Group justify="end">
|
||||
<RemoveHostnameActionIcon hostname={hostname} thumbprint={thumbprint} />
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Card, Group, SimpleGrid, Stack, Text, Title } from "@mantine/core";
|
||||
import { Button, Card, Group, SimpleGrid, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconCertificate, IconCertificateOff } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
@@ -64,7 +65,12 @@ export default async function CertificatesPage({ params }: CertificatesPageProps
|
||||
<Text>{t("certificate.page.list.description")}</Text>
|
||||
</Stack>
|
||||
|
||||
<AddCertificateButton />
|
||||
<Group>
|
||||
<Button variant="default" component={Link} href="/manage/tools/certificates/hostnames">
|
||||
{t("certificate.page.list.toHostnames")}
|
||||
</Button>
|
||||
<AddCertificateButton />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{x509Certificates.length === 0 && (
|
||||
|
||||
Reference in New Issue
Block a user