Certificates react work

- renewal and download
- table columns rendering
- searching
- deleting
This commit is contained in:
Jamie Curnow
2025-10-27 18:08:37 +10:00
parent 7b5c70ed35
commit 0de26f2950
16 changed files with 381 additions and 86 deletions

View File

@@ -80,8 +80,16 @@ export async function get(args: GetArgs, abortController?: AbortController) {
return processResponse(await baseGet(args, abortController));
}
export async function download(args: GetArgs, abortController?: AbortController) {
return (await baseGet(args, abortController)).text();
export async function download({ url, params }: GetArgs, filename = "download.file") {
const headers = buildAuthHeader();
const res = await fetch(buildUrl({ url, params }), { headers });
const bl = await res.blob();
const u = window.URL.createObjectURL(bl);
const a = document.createElement("a");
a.href = u;
a.download = filename;
a.click();
window.URL.revokeObjectURL(url);
}
interface PostArgs {

View File

@@ -1,8 +1,10 @@
import * as api from "./base";
import type { Binary } from "./responseTypes";
export async function downloadCertificate(id: number): Promise<Binary> {
return await api.get({
url: `/nginx/certificates/${id}/download`,
});
export async function downloadCertificate(id: number): Promise<void> {
await api.download(
{
url: `/nginx/certificates/${id}/download`,
},
`certificate-${id}.zip`,
);
}

View File

@@ -15,5 +15,3 @@ export interface ValidatedCertificateResponse {
certificate: Record<string, any>;
certificateKey: boolean;
}
export type Binary = number & { readonly __brand: unique symbol };

View File

@@ -0,0 +1,62 @@
import OverlayTrigger from "react-bootstrap/OverlayTrigger";
import Popover from "react-bootstrap/Popover";
import type { DeadHost, ProxyHost, RedirectionHost } from "src/api/backend";
import { T } from "src/locale";
const getSection = (title: string, items: ProxyHost[] | RedirectionHost[] | DeadHost[]) => {
if (items.length === 0) {
return null;
}
return (
<>
<div>
<strong>
<T id={title} />
</strong>
</div>
{items.map((host) => (
<div key={host.id} className="ms-1">
{host.domainNames.join(", ")}
</div>
))}
</>
);
};
interface Props {
proxyHosts: ProxyHost[];
redirectionHosts: RedirectionHost[];
deadHosts: DeadHost[];
}
export function CertificateInUseFormatter({ proxyHosts, redirectionHosts, deadHosts }: Props) {
const totalCount = proxyHosts?.length + redirectionHosts?.length + deadHosts?.length;
if (totalCount === 0) {
return (
<span className="badge bg-red-lt">
<T id="certificate.not-in-use" />
</span>
);
}
proxyHosts.sort();
redirectionHosts.sort();
deadHosts.sort();
const popover = (
<Popover id="popover-basic">
<Popover.Body>
{getSection("proxy-hosts", proxyHosts)}
{getSection("redirection-hosts", redirectionHosts)}
{getSection("dead-hosts", deadHosts)}
</Popover.Body>
</Popover>
);
return (
<OverlayTrigger trigger="hover" placement="bottom" overlay={popover}>
<span className="badge bg-lime-lt">
<T id="certificate.in-use" />
</span>
</OverlayTrigger>
);
}

View File

@@ -0,0 +1,15 @@
import cn from "classnames";
import { isPast, parseISO } from "date-fns";
import { DateTimeFormat } from "src/locale";
interface Props {
value: string;
highlightPast?: boolean;
}
export function DateFormatter({ value, highlightPast }: Props) {
const dateIsPast = isPast(parseISO(value));
const cl = cn({
"text-danger": highlightPast && dateIsPast,
});
return <span className={cl}>{DateTimeFormat(value)}</span>;
}

View File

@@ -1,5 +1,7 @@
export * from "./AccessListformatter";
export * from "./CertificateFormatter";
export * from "./CertificateInUseFormatter";
export * from "./DateFormatter";
export * from "./DomainsFormatter";
export * from "./EmailFormatter";
export * from "./EnabledFormatter";

View File

@@ -2,6 +2,7 @@ export * from "./useAccessList";
export * from "./useAccessLists";
export * from "./useAuditLog";
export * from "./useAuditLogs";
export * from "./useCertificate";
export * from "./useCertificates";
export * from "./useDeadHost";
export * from "./useDeadHosts";

View File

@@ -0,0 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { type Certificate, getCertificate } from "src/api/backend";
const fetchCertificate = (id: number) => {
return getCertificate(id, ["owner"]);
};
const useCertificate = (id: number, options = {}) => {
return useQuery<Certificate, Error>({
queryKey: ["certificate", id],
queryFn: () => fetchCertificate(id),
staleTime: 60 * 1000, // 1 minute
...options,
});
};
export { useCertificate };

View File

@@ -15,16 +15,20 @@
"action.close": "Close",
"action.delete": "Delete",
"action.disable": "Disable",
"action.download": "Download",
"action.edit": "Edit",
"action.enable": "Enable",
"action.permissions": "Permissions",
"action.renew": "Renew",
"action.view-details": "View Details",
"auditlogs": "Audit Logs",
"cancel": "Cancel",
"certificate": "Certificate",
"certificate.in-use": "In Use",
"certificate.none.subtitle": "No certificate assigned",
"certificate.none.subtitle.for-http": "This host will not use HTTPS",
"certificate.none.title": "None",
"certificate.not-in-use": "Not Used",
"certificates": "Certificates",
"certificates.custom": "Custom Certificate",
"certificates.dns.credentials": "Credentials File Content",
@@ -121,6 +125,7 @@
"notification.object-deleted": "{object} has been deleted",
"notification.object-disabled": "{object} has been disabled",
"notification.object-enabled": "{object} has been enabled",
"notification.object-renewed": "{object} has been renewed",
"notification.object-saved": "{object} has been saved",
"notification.success": "Success",
"object.actions-title": "{object} #{id}",

View File

@@ -47,6 +47,9 @@
"action.disable": {
"defaultMessage": "Disable"
},
"action.download": {
"defaultMessage": "Download"
},
"action.edit": {
"defaultMessage": "Edit"
},
@@ -56,6 +59,9 @@
"action.permissions": {
"defaultMessage": "Permissions"
},
"action.renew": {
"defaultMessage": "Renew"
},
"action.view-details": {
"defaultMessage": "View Details"
},
@@ -68,6 +74,9 @@
"certificate": {
"defaultMessage": "Certificate"
},
"certificate.in-use": {
"defaultMessage": "In Use"
},
"certificate.none.subtitle": {
"defaultMessage": "No certificate assigned"
},
@@ -77,6 +86,9 @@
"certificate.none.title": {
"defaultMessage": "None"
},
"certificate.not-in-use": {
"defaultMessage": "Not Used"
},
"certificates": {
"defaultMessage": "Certificates"
},
@@ -365,6 +377,9 @@
"notification.object-enabled": {
"defaultMessage": "{object} has been enabled"
},
"notification.object-renewed": {
"defaultMessage": "{object} has been renewed"
},
"notification.object-saved": {
"defaultMessage": "{object} has been saved"
},

View File

@@ -1,4 +1,5 @@
import { IconAlertTriangle } from "@tabler/icons-react";
import { useQueryClient } from "@tanstack/react-query";
import EasyModal, { type InnerModalProps } from "ez-modal-react";
import { Form, Formik } from "formik";
import { type ReactNode, useState } from "react";
@@ -14,6 +15,7 @@ const showHTTPCertificateModal = () => {
};
const HTTPCertificateModal = EasyModal.create(({ visible, remove }: InnerModalProps) => {
const queryClient = useQueryClient();
const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [domains, setDomains] = useState([] as string[]);
@@ -32,6 +34,7 @@ const HTTPCertificateModal = EasyModal.create(({ visible, remove }: InnerModalPr
} catch (err: any) {
setErrorMsg(<T id={err.message} />);
}
queryClient.invalidateQueries({ queryKey: ["certificates"] });
setIsSubmitting(false);
setSubmitting(false);
};

View File

@@ -0,0 +1,74 @@
import { useQueryClient } from "@tanstack/react-query";
import EasyModal, { type InnerModalProps } from "ez-modal-react";
import { type ReactNode, useEffect, useState } from "react";
import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal";
import { renewCertificate } from "src/api/backend";
import { Button, Loading } from "src/components";
import { useCertificate } from "src/hooks";
import { T } from "src/locale";
import { showObjectSuccess } from "src/notifications";
interface Props extends InnerModalProps {
id: number;
}
const showRenewCertificateModal = (id: number) => {
EasyModal.show(RenewCertificateModal, { id });
};
const RenewCertificateModal = EasyModal.create(({ id, visible, remove }: Props) => {
const queryClient = useQueryClient();
const { data, isLoading, error } = useCertificate(id);
const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
const [isFresh, setIsFresh] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (!data || !isFresh || isSubmitting) return;
setIsFresh(false);
setIsSubmitting(true);
renewCertificate(id)
.then(() => {
showObjectSuccess("certificate", "renewed");
queryClient.invalidateQueries({ queryKey: ["certificates"] });
remove();
})
.catch((err: any) => {
setErrorMsg(<T id={err.message} />);
})
.finally(() => {
setIsSubmitting(false);
});
}, [id, data, isFresh, isSubmitting, remove, queryClient.invalidateQueries]);
return (
<Modal show={visible} onHide={isSubmitting ? undefined : remove}>
<Modal.Header closeButton={!isSubmitting}>
<Modal.Title>
<T id="renew-certificate" />
</Modal.Title>
</Modal.Header>
<Modal.Body>
<Alert variant="danger" show={!!errorMsg}>
{errorMsg}
</Alert>
{isLoading && <Loading noLogo />}
{!isLoading && error && (
<Alert variant="danger" className="m-3">
{error?.message || "Unknown error"}
</Alert>
)}
{data && isSubmitting && !errorMsg ? <p className="text-center mt-3">Please wait ...</p> : null}
</Modal.Body>
<Modal.Footer>
<Button data-bs-dismiss="modal" onClick={remove} disabled={isSubmitting}>
<T id="action.close" />
</Button>
</Modal.Footer>
</Modal>
);
});
export { showRenewCertificateModal };

View File

@@ -9,6 +9,7 @@ export * from "./HTTPCertificateModal";
export * from "./PermissionsModal";
export * from "./ProxyHostModal";
export * from "./RedirectionHostModal";
export * from "./RenewCertificateModal";
export * from "./SetPasswordModal";
export * from "./StreamModal";
export * from "./UserModal";

View File

@@ -1,17 +1,27 @@
import { IconDotsVertical, IconEdit, IconPower, IconTrash } from "@tabler/icons-react";
import { IconDotsVertical, IconDownload, IconRefresh, IconTrash } from "@tabler/icons-react";
import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useMemo } from "react";
import type { Certificate } from "src/api/backend";
import { DomainsFormatter, EmptyData, GravatarFormatter } from "src/components";
import {
CertificateInUseFormatter,
DateFormatter,
DomainsFormatter,
EmptyData,
GravatarFormatter,
} from "src/components";
import { TableLayout } from "src/components/Table/TableLayout";
import { intl, T } from "src/locale";
import { showCustomCertificateModal, showDNSCertificateModal, showHTTPCertificateModal } from "src/modals";
interface Props {
data: Certificate[];
isFiltered?: boolean;
isFetching?: boolean;
onDelete?: (id: number) => void;
onRenew?: (id: number) => void;
onDownload?: (id: number) => void;
}
export default function Table({ data, isFetching }: Props) {
export default function Table({ data, isFetching, onDelete, onRenew, onDownload, isFiltered }: Props) {
const columnHelper = createColumnHelper<Certificate>();
const columns = useMemo(
() => [
@@ -37,25 +47,35 @@ export default function Table({ data, isFetching }: Props) {
id: "provider",
header: intl.formatMessage({ id: "column.provider" }),
cell: (info: any) => {
return info.getValue();
if (info.getValue() === "letsencrypt") {
return <T id="lets-encrypt" />;
}
return <T id={info.getValue()} />;
},
}),
columnHelper.accessor((row: any) => row.expires_on, {
id: "expires_on",
columnHelper.accessor((row: any) => row.expiresOn, {
id: "expiresOn",
header: intl.formatMessage({ id: "column.expires" }),
cell: (info: any) => {
return info.getValue();
return <DateFormatter value={info.getValue()} highlightPast />;
},
}),
columnHelper.accessor((row: any) => row, {
id: "id",
id: "proxyHosts",
header: intl.formatMessage({ id: "column.status" }),
cell: (info: any) => {
return info.getValue();
const r = info.getValue();
return (
<CertificateInUseFormatter
proxyHosts={r.proxyHosts}
redirectionHosts={r.redirectionHosts}
deadHosts={r.deadHosts}
/>
);
},
}),
columnHelper.display({
id: "id", // todo: not needed for a display?
id: "id",
cell: (info: any) => {
return (
<span className="dropdown">
@@ -75,16 +95,37 @@ export default function Table({ data, isFetching }: Props) {
data={{ id: info.row.original.id }}
/>
</span>
<a className="dropdown-item" href="#">
<IconEdit size={16} />
<T id="action.edit" />
<a
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
onRenew?.(info.row.original.id);
}}
>
<IconRefresh size={16} />
<T id="action.renew" />
</a>
<a className="dropdown-item" href="#">
<IconPower size={16} />
<T id="action.disable" />
<a
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
onDownload?.(info.row.original.id);
}}
>
<IconDownload size={16} />
<T id="action.download" />
</a>
<div className="dropdown-divider" />
<a className="dropdown-item" href="#">
<a
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
onDelete?.(info.row.original.id);
}}
>
<IconTrash size={16} />
<T id="action.delete" />
</a>
@@ -97,7 +138,7 @@ export default function Table({ data, isFetching }: Props) {
},
}),
],
[columnHelper],
[columnHelper, onDelete, onRenew, onDownload],
);
const tableInstance = useReactTable<Certificate>({
@@ -160,8 +201,7 @@ export default function Table({ data, isFetching }: Props) {
object="certificate"
objects="certificates"
tableInstance={tableInstance}
// onNew={onNew}
// isFiltered={isFiltered}
isFiltered={isFiltered}
color="pink"
customAddBtn={customAddBtn}
/>

View File

@@ -1,12 +1,22 @@
import { IconSearch } from "@tabler/icons-react";
import { useState } from "react";
import Alert from "react-bootstrap/Alert";
import { deleteCertificate, downloadCertificate } from "src/api/backend";
import { LoadingPage } from "src/components";
import { useCertificates } from "src/hooks";
import { T } from "src/locale";
import { showCustomCertificateModal, showDNSCertificateModal, showHTTPCertificateModal } from "src/modals";
import {
showCustomCertificateModal,
showDeleteConfirmModal,
showDNSCertificateModal,
showHTTPCertificateModal,
showRenewCertificateModal,
} from "src/modals";
import { showError, showObjectSuccess } from "src/notifications";
import Table from "./Table";
export default function TableWrapper() {
const [search, setSearch] = useState("");
const { isFetching, isLoading, isError, error, data } = useCertificates([
"owner",
"dead_hosts",
@@ -22,6 +32,31 @@ export default function TableWrapper() {
return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>;
}
const handleDelete = async (id: number) => {
await deleteCertificate(id);
showObjectSuccess("certificate", "deleted");
};
const handleDownload = async (id: number) => {
try {
await downloadCertificate(id);
} catch (err: any) {
showError(err.message);
}
};
let filtered = null;
if (search && data) {
filtered = data?.filter(
(item) =>
item.domainNames.some((domain: string) => domain.toLowerCase().includes(search)) ||
item.niceName.toLowerCase().includes(search),
);
} else if (search !== "") {
// this can happen if someone deletes the last item while searching
setSearch("");
}
return (
<div className="card mt-4">
<div className="card-status-top bg-pink" />
@@ -33,66 +68,83 @@ export default function TableWrapper() {
<T id="certificates" />
</h2>
</div>
<div className="col-md-auto col-sm-12">
<div className="ms-auto d-flex flex-wrap btn-list">
<div className="input-group input-group-flat w-auto">
<span className="input-group-text input-group-text-sm">
<IconSearch size={16} />
</span>
<input
id="advanced-table-search"
type="text"
className="form-control form-control-sm"
autoComplete="off"
/>
</div>
<div className="dropdown">
<button
type="button"
className="btn btn-sm dropdown-toggle btn-pink mt-1"
data-bs-toggle="dropdown"
>
<T id="object.add" tData={{ object: "certificate" }} />
</button>
<div className="dropdown-menu">
<a
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
showHTTPCertificateModal();
}}
{data?.length ? (
<div className="col-md-auto col-sm-12">
<div className="ms-auto d-flex flex-wrap btn-list">
<div className="input-group input-group-flat w-auto">
<span className="input-group-text input-group-text-sm">
<IconSearch size={16} />
</span>
<input
id="advanced-table-search"
type="text"
className="form-control form-control-sm"
autoComplete="off"
onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
/>
</div>
<div className="dropdown">
<button
type="button"
className="btn btn-sm dropdown-toggle btn-pink mt-1"
data-bs-toggle="dropdown"
>
<T id="lets-encrypt-via-http" />
</a>
<a
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
showDNSCertificateModal();
}}
>
<T id="lets-encrypt-via-dns" />
</a>
<div className="dropdown-divider" />
<a
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
showCustomCertificateModal();
}}
>
<T id="certificates.custom" />
</a>
<T id="object.add" tData={{ object: "certificate" }} />
</button>
<div className="dropdown-menu">
<a
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
showHTTPCertificateModal();
}}
>
<T id="lets-encrypt-via-http" />
</a>
<a
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
showDNSCertificateModal();
}}
>
<T id="lets-encrypt-via-dns" />
</a>
<div className="dropdown-divider" />
<a
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
showCustomCertificateModal();
}}
>
<T id="certificates.custom" />
</a>
</div>
</div>
</div>
</div>
</div>
) : null}
</div>
</div>
<Table data={data ?? []} isFetching={isFetching} />
<Table
data={filtered ?? data ?? []}
isFiltered={!!search}
isFetching={isFetching}
onRenew={showRenewCertificateModal}
onDownload={handleDownload}
onDelete={(id: number) =>
showDeleteConfirmModal({
title: <T id="object.delete" tData={{ object: "certificate" }} />,
onConfirm: () => handleDelete(id),
invalidations: [["certificates"], ["certificate", id]],
children: <T id="object.delete.content" tData={{ object: "certificate" }} />,
})
}
/>
</div>
</div>
);

View File

@@ -89,10 +89,10 @@ export default function TableWrapper() {
onEdit={(id: number) => showProxyHostModal(id)}
onDelete={(id: number) =>
showDeleteConfirmModal({
title: "proxy-host.delete.title",
title: <T id="object.delete" tData={{ object: "proxy-host" }} />,
onConfirm: () => handleDelete(id),
invalidations: [["proxy-hosts"], ["proxy-host", id]],
children: <T id="proxy-host.delete.content" />,
children: <T id="object.delete.content" tData={{ object: "proxy-host" }} />,
})
}
onDisableToggle={handleDisableToggle}