feat(certificates): handle self signed certificates (#1951)
* wip: add page and loading of certificates in folder * wip: add certificate addition and removal * feat: add removal ui for certificates * feat: migrate integrations to fetch or agent with trusted certificates * fix: lock file issues * fix: typecheck issue * fix: inconsistent package versions * chore: address pull request feedback * fix: add missing navigation item and restrict access to page * chore: address pull request feedback * fix: inconsistent undici dependency version * fix: inconsistent undici dependency version
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
IconBrandDiscord,
|
||||
IconBrandDocker,
|
||||
IconBrandGithub,
|
||||
IconCertificate,
|
||||
IconGitFork,
|
||||
IconHome,
|
||||
IconInfoSmall,
|
||||
@@ -119,6 +120,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
href: "/manage/tools/logs",
|
||||
hidden: !session?.user.permissions.includes("other-view-logs"),
|
||||
},
|
||||
{
|
||||
label: t("items.tools.items.certificates"),
|
||||
icon: IconCertificate,
|
||||
href: "/manage/tools/certificates",
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
label: t("items.tools.items.tasks"),
|
||||
icon: IconReport,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { Button, FileInput, Group, Stack } from "@mantine/core";
|
||||
import { IconCertificate } from "@tabler/icons-react";
|
||||
|
||||
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 { useI18n } from "@homarr/translation/client";
|
||||
import { superRefineCertificateFile, z } from "@homarr/validation";
|
||||
|
||||
export const AddCertificateButton = () => {
|
||||
const { openModal } = useModalAction(AddCertificateModal);
|
||||
const t = useI18n();
|
||||
|
||||
const handleClick = () => {
|
||||
openModal({});
|
||||
};
|
||||
|
||||
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,54 @@
|
||||
"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 RemoveCertificateProps {
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export const RemoveCertificate = ({ fileName }: RemoveCertificateProps) => {
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { mutateAsync } = clientApi.certificates.removeCertificate.useMutation();
|
||||
const t = useI18n();
|
||||
|
||||
const handleClick = () => {
|
||||
openConfirmModal({
|
||||
title: t("certificate.action.remove.label"),
|
||||
children: t("certificate.action.remove.confirm"),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
async onConfirm() {
|
||||
await mutateAsync(
|
||||
{ fileName },
|
||||
{
|
||||
async onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t("certificate.action.remove.notification.success.title"),
|
||||
message: t("certificate.action.remove.notification.success.message"),
|
||||
});
|
||||
await revalidatePathActionAsync("/manage/tools/certificates");
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("certificate.action.remove.notification.error.title"),
|
||||
message: t("certificate.action.remove.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionIcon onClick={handleClick} color="red" variant="subtle">
|
||||
<IconTrash color="red" size={16} />
|
||||
</ActionIcon>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Card, Group, SimpleGrid, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconCertificate, IconCertificateOff } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { loadCustomRootCertificatesAsync } from "@homarr/certificates/server";
|
||||
import { getMantineColor } from "@homarr/common";
|
||||
import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { AddCertificateButton } from "./_components/add-certificate";
|
||||
import { RemoveCertificate } from "./_components/remove-certificate";
|
||||
|
||||
interface CertificatesPageProps {
|
||||
params: Promise<{
|
||||
locale: SupportedLanguage;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function CertificatesPage({ params }: CertificatesPageProps) {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { locale } = await params;
|
||||
const t = await getI18n();
|
||||
const certificates = await loadCustomRootCertificatesAsync();
|
||||
const x509Certificates = certificates
|
||||
.map((cert) => ({
|
||||
...cert,
|
||||
x509: new X509Certificate(cert.content),
|
||||
}))
|
||||
.sort((certA, certB) => certA.x509.validToDate.getTime() - certB.x509.validToDate.getTime());
|
||||
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Stack gap={4}>
|
||||
<Title>{t("certificate.page.list.title")}</Title>
|
||||
<Text>{t("certificate.page.list.description")}</Text>
|
||||
</Stack>
|
||||
|
||||
<AddCertificateButton />
|
||||
</Group>
|
||||
|
||||
{x509Certificates.length === 0 && (
|
||||
<NoResults icon={IconCertificateOff} title={t("certificate.page.list.noResults.title")} />
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ sm: 1, lg: 2, xl: 3 }} spacing="lg">
|
||||
{x509Certificates.map((cert) => (
|
||||
<Card key={cert.x509.fingerprint} withBorder>
|
||||
<Group wrap="nowrap">
|
||||
<IconCertificate color={getMantineColor(iconColor(cert.x509.validToDate), 6)} size={32} stroke={1.5} />
|
||||
<Stack flex={1} gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>{cert.x509.subject}</Text>
|
||||
<Text c="gray.6" ta="end" size="sm">
|
||||
{cert.fileName}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="gray.6" title={cert.x509.validToDate.toISOString()}>
|
||||
{t("certificate.page.list.expires", {
|
||||
when: new Intl.RelativeTimeFormat(locale).format(
|
||||
dayjs(cert.x509.validToDate).diff(dayjs(), "days"),
|
||||
"days",
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
<RemoveCertificate fileName={cert.fileName} />
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const iconColor = (validTo: Date) => {
|
||||
const daysUntilInvalid = dayjs(validTo).diff(new Date(), "days");
|
||||
if (daysUntilInvalid < 1) return "red";
|
||||
if (daysUntilInvalid < 7) return "orange";
|
||||
if (daysUntilInvalid < 30) return "yellow";
|
||||
return "green";
|
||||
};
|
||||
Reference in New Issue
Block a user