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)); return processResponse(await baseGet(args, abortController));
} }
export async function download(args: GetArgs, abortController?: AbortController) { export async function download({ url, params }: GetArgs, filename = "download.file") {
return (await baseGet(args, abortController)).text(); 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 { interface PostArgs {

View File

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

View File

@@ -15,5 +15,3 @@ export interface ValidatedCertificateResponse {
certificate: Record<string, any>; certificate: Record<string, any>;
certificateKey: boolean; 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 "./AccessListformatter";
export * from "./CertificateFormatter"; export * from "./CertificateFormatter";
export * from "./CertificateInUseFormatter";
export * from "./DateFormatter";
export * from "./DomainsFormatter"; export * from "./DomainsFormatter";
export * from "./EmailFormatter"; export * from "./EmailFormatter";
export * from "./EnabledFormatter"; export * from "./EnabledFormatter";

View File

@@ -2,6 +2,7 @@ export * from "./useAccessList";
export * from "./useAccessLists"; export * from "./useAccessLists";
export * from "./useAuditLog"; export * from "./useAuditLog";
export * from "./useAuditLogs"; export * from "./useAuditLogs";
export * from "./useCertificate";
export * from "./useCertificates"; export * from "./useCertificates";
export * from "./useDeadHost"; export * from "./useDeadHost";
export * from "./useDeadHosts"; 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.close": "Close",
"action.delete": "Delete", "action.delete": "Delete",
"action.disable": "Disable", "action.disable": "Disable",
"action.download": "Download",
"action.edit": "Edit", "action.edit": "Edit",
"action.enable": "Enable", "action.enable": "Enable",
"action.permissions": "Permissions", "action.permissions": "Permissions",
"action.renew": "Renew",
"action.view-details": "View Details", "action.view-details": "View Details",
"auditlogs": "Audit Logs", "auditlogs": "Audit Logs",
"cancel": "Cancel", "cancel": "Cancel",
"certificate": "Certificate", "certificate": "Certificate",
"certificate.in-use": "In Use",
"certificate.none.subtitle": "No certificate assigned", "certificate.none.subtitle": "No certificate assigned",
"certificate.none.subtitle.for-http": "This host will not use HTTPS", "certificate.none.subtitle.for-http": "This host will not use HTTPS",
"certificate.none.title": "None", "certificate.none.title": "None",
"certificate.not-in-use": "Not Used",
"certificates": "Certificates", "certificates": "Certificates",
"certificates.custom": "Custom Certificate", "certificates.custom": "Custom Certificate",
"certificates.dns.credentials": "Credentials File Content", "certificates.dns.credentials": "Credentials File Content",
@@ -121,6 +125,7 @@
"notification.object-deleted": "{object} has been deleted", "notification.object-deleted": "{object} has been deleted",
"notification.object-disabled": "{object} has been disabled", "notification.object-disabled": "{object} has been disabled",
"notification.object-enabled": "{object} has been enabled", "notification.object-enabled": "{object} has been enabled",
"notification.object-renewed": "{object} has been renewed",
"notification.object-saved": "{object} has been saved", "notification.object-saved": "{object} has been saved",
"notification.success": "Success", "notification.success": "Success",
"object.actions-title": "{object} #{id}", "object.actions-title": "{object} #{id}",

View File

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

View File

@@ -1,4 +1,5 @@
import { IconAlertTriangle } from "@tabler/icons-react"; import { IconAlertTriangle } from "@tabler/icons-react";
import { useQueryClient } from "@tanstack/react-query";
import EasyModal, { type InnerModalProps } from "ez-modal-react"; import EasyModal, { type InnerModalProps } from "ez-modal-react";
import { Form, Formik } from "formik"; import { Form, Formik } from "formik";
import { type ReactNode, useState } from "react"; import { type ReactNode, useState } from "react";
@@ -14,6 +15,7 @@ const showHTTPCertificateModal = () => {
}; };
const HTTPCertificateModal = EasyModal.create(({ visible, remove }: InnerModalProps) => { const HTTPCertificateModal = EasyModal.create(({ visible, remove }: InnerModalProps) => {
const queryClient = useQueryClient();
const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null); const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [domains, setDomains] = useState([] as string[]); const [domains, setDomains] = useState([] as string[]);
@@ -32,6 +34,7 @@ const HTTPCertificateModal = EasyModal.create(({ visible, remove }: InnerModalPr
} catch (err: any) { } catch (err: any) {
setErrorMsg(<T id={err.message} />); setErrorMsg(<T id={err.message} />);
} }
queryClient.invalidateQueries({ queryKey: ["certificates"] });
setIsSubmitting(false); setIsSubmitting(false);
setSubmitting(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 "./PermissionsModal";
export * from "./ProxyHostModal"; export * from "./ProxyHostModal";
export * from "./RedirectionHostModal"; export * from "./RedirectionHostModal";
export * from "./RenewCertificateModal";
export * from "./SetPasswordModal"; export * from "./SetPasswordModal";
export * from "./StreamModal"; export * from "./StreamModal";
export * from "./UserModal"; 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 { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useMemo } from "react"; import { useMemo } from "react";
import type { Certificate } from "src/api/backend"; 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 { TableLayout } from "src/components/Table/TableLayout";
import { intl, T } from "src/locale"; import { intl, T } from "src/locale";
import { showCustomCertificateModal, showDNSCertificateModal, showHTTPCertificateModal } from "src/modals"; import { showCustomCertificateModal, showDNSCertificateModal, showHTTPCertificateModal } from "src/modals";
interface Props { interface Props {
data: Certificate[]; data: Certificate[];
isFiltered?: boolean;
isFetching?: 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 columnHelper = createColumnHelper<Certificate>();
const columns = useMemo( const columns = useMemo(
() => [ () => [
@@ -37,25 +47,35 @@ export default function Table({ data, isFetching }: Props) {
id: "provider", id: "provider",
header: intl.formatMessage({ id: "column.provider" }), header: intl.formatMessage({ id: "column.provider" }),
cell: (info: any) => { 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, { columnHelper.accessor((row: any) => row.expiresOn, {
id: "expires_on", id: "expiresOn",
header: intl.formatMessage({ id: "column.expires" }), header: intl.formatMessage({ id: "column.expires" }),
cell: (info: any) => { cell: (info: any) => {
return info.getValue(); return <DateFormatter value={info.getValue()} highlightPast />;
}, },
}), }),
columnHelper.accessor((row: any) => row, { columnHelper.accessor((row: any) => row, {
id: "id", id: "proxyHosts",
header: intl.formatMessage({ id: "column.status" }), header: intl.formatMessage({ id: "column.status" }),
cell: (info: any) => { cell: (info: any) => {
return info.getValue(); const r = info.getValue();
return (
<CertificateInUseFormatter
proxyHosts={r.proxyHosts}
redirectionHosts={r.redirectionHosts}
deadHosts={r.deadHosts}
/>
);
}, },
}), }),
columnHelper.display({ columnHelper.display({
id: "id", // todo: not needed for a display? id: "id",
cell: (info: any) => { cell: (info: any) => {
return ( return (
<span className="dropdown"> <span className="dropdown">
@@ -75,16 +95,37 @@ export default function Table({ data, isFetching }: Props) {
data={{ id: info.row.original.id }} data={{ id: info.row.original.id }}
/> />
</span> </span>
<a className="dropdown-item" href="#"> <a
<IconEdit size={16} /> className="dropdown-item"
<T id="action.edit" /> href="#"
onClick={(e) => {
e.preventDefault();
onRenew?.(info.row.original.id);
}}
>
<IconRefresh size={16} />
<T id="action.renew" />
</a> </a>
<a className="dropdown-item" href="#"> <a
<IconPower size={16} /> className="dropdown-item"
<T id="action.disable" /> href="#"
onClick={(e) => {
e.preventDefault();
onDownload?.(info.row.original.id);
}}
>
<IconDownload size={16} />
<T id="action.download" />
</a> </a>
<div className="dropdown-divider" /> <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} /> <IconTrash size={16} />
<T id="action.delete" /> <T id="action.delete" />
</a> </a>
@@ -97,7 +138,7 @@ export default function Table({ data, isFetching }: Props) {
}, },
}), }),
], ],
[columnHelper], [columnHelper, onDelete, onRenew, onDownload],
); );
const tableInstance = useReactTable<Certificate>({ const tableInstance = useReactTable<Certificate>({
@@ -160,8 +201,7 @@ export default function Table({ data, isFetching }: Props) {
object="certificate" object="certificate"
objects="certificates" objects="certificates"
tableInstance={tableInstance} tableInstance={tableInstance}
// onNew={onNew} isFiltered={isFiltered}
// isFiltered={isFiltered}
color="pink" color="pink"
customAddBtn={customAddBtn} customAddBtn={customAddBtn}
/> />

View File

@@ -1,12 +1,22 @@
import { IconSearch } from "@tabler/icons-react"; import { IconSearch } from "@tabler/icons-react";
import { useState } from "react";
import Alert from "react-bootstrap/Alert"; import Alert from "react-bootstrap/Alert";
import { deleteCertificate, downloadCertificate } from "src/api/backend";
import { LoadingPage } from "src/components"; import { LoadingPage } from "src/components";
import { useCertificates } from "src/hooks"; import { useCertificates } from "src/hooks";
import { T } from "src/locale"; 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"; import Table from "./Table";
export default function TableWrapper() { export default function TableWrapper() {
const [search, setSearch] = useState("");
const { isFetching, isLoading, isError, error, data } = useCertificates([ const { isFetching, isLoading, isError, error, data } = useCertificates([
"owner", "owner",
"dead_hosts", "dead_hosts",
@@ -22,6 +32,31 @@ export default function TableWrapper() {
return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>; 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 ( return (
<div className="card mt-4"> <div className="card mt-4">
<div className="card-status-top bg-pink" /> <div className="card-status-top bg-pink" />
@@ -33,6 +68,7 @@ export default function TableWrapper() {
<T id="certificates" /> <T id="certificates" />
</h2> </h2>
</div> </div>
{data?.length ? (
<div className="col-md-auto col-sm-12"> <div className="col-md-auto col-sm-12">
<div className="ms-auto d-flex flex-wrap btn-list"> <div className="ms-auto d-flex flex-wrap btn-list">
<div className="input-group input-group-flat w-auto"> <div className="input-group input-group-flat w-auto">
@@ -44,6 +80,7 @@ export default function TableWrapper() {
type="text" type="text"
className="form-control form-control-sm" className="form-control form-control-sm"
autoComplete="off" autoComplete="off"
onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
/> />
</div> </div>
<div className="dropdown"> <div className="dropdown">
@@ -90,9 +127,24 @@ export default function TableWrapper() {
</div> </div>
</div> </div>
</div> </div>
) : null}
</div> </div>
</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>
</div> </div>
); );

View File

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