From 0de26f2950161042862155538d088e3913edddeb Mon Sep 17 00:00:00 2001 From: Jamie Curnow Date: Mon, 27 Oct 2025 18:08:37 +1000 Subject: [PATCH] Certificates react work - renewal and download - table columns rendering - searching - deleting --- frontend/src/api/backend/base.ts | 12 +- .../src/api/backend/downloadCertificate.ts | 12 +- frontend/src/api/backend/responseTypes.ts | 2 - .../Formatter/CertificateInUseFormatter.tsx | 62 +++++++ .../Table/Formatter/DateFormatter.tsx | 15 ++ .../src/components/Table/Formatter/index.ts | 2 + frontend/src/hooks/index.ts | 1 + frontend/src/hooks/useCertificate.ts | 17 ++ frontend/src/locale/lang/en.json | 5 + frontend/src/locale/src/en.json | 15 ++ frontend/src/modals/HTTPCertificateModal.tsx | 3 + frontend/src/modals/RenewCertificateModal.tsx | 74 ++++++++ frontend/src/modals/index.ts | 1 + frontend/src/pages/Certificates/Table.tsx | 80 ++++++--- .../src/pages/Certificates/TableWrapper.tsx | 162 ++++++++++++------ .../pages/Nginx/ProxyHosts/TableWrapper.tsx | 4 +- 16 files changed, 381 insertions(+), 86 deletions(-) create mode 100644 frontend/src/components/Table/Formatter/CertificateInUseFormatter.tsx create mode 100644 frontend/src/components/Table/Formatter/DateFormatter.tsx create mode 100644 frontend/src/hooks/useCertificate.ts create mode 100644 frontend/src/modals/RenewCertificateModal.tsx diff --git a/frontend/src/api/backend/base.ts b/frontend/src/api/backend/base.ts index 9e3ca73d..54eed050 100644 --- a/frontend/src/api/backend/base.ts +++ b/frontend/src/api/backend/base.ts @@ -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 { diff --git a/frontend/src/api/backend/downloadCertificate.ts b/frontend/src/api/backend/downloadCertificate.ts index dc7aa312..079f3359 100644 --- a/frontend/src/api/backend/downloadCertificate.ts +++ b/frontend/src/api/backend/downloadCertificate.ts @@ -1,8 +1,10 @@ import * as api from "./base"; -import type { Binary } from "./responseTypes"; -export async function downloadCertificate(id: number): Promise { - return await api.get({ - url: `/nginx/certificates/${id}/download`, - }); +export async function downloadCertificate(id: number): Promise { + await api.download( + { + url: `/nginx/certificates/${id}/download`, + }, + `certificate-${id}.zip`, + ); } diff --git a/frontend/src/api/backend/responseTypes.ts b/frontend/src/api/backend/responseTypes.ts index 99e95b3a..6eb627f8 100644 --- a/frontend/src/api/backend/responseTypes.ts +++ b/frontend/src/api/backend/responseTypes.ts @@ -15,5 +15,3 @@ export interface ValidatedCertificateResponse { certificate: Record; certificateKey: boolean; } - -export type Binary = number & { readonly __brand: unique symbol }; diff --git a/frontend/src/components/Table/Formatter/CertificateInUseFormatter.tsx b/frontend/src/components/Table/Formatter/CertificateInUseFormatter.tsx new file mode 100644 index 00000000..bb4c314f --- /dev/null +++ b/frontend/src/components/Table/Formatter/CertificateInUseFormatter.tsx @@ -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 ( + <> +
+ + + +
+ {items.map((host) => ( +
+ {host.domainNames.join(", ")} +
+ ))} + + ); +}; + +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 ( + + + + ); + } + + proxyHosts.sort(); + redirectionHosts.sort(); + deadHosts.sort(); + + const popover = ( + + + {getSection("proxy-hosts", proxyHosts)} + {getSection("redirection-hosts", redirectionHosts)} + {getSection("dead-hosts", deadHosts)} + + + ); + + return ( + + + + + + ); +} diff --git a/frontend/src/components/Table/Formatter/DateFormatter.tsx b/frontend/src/components/Table/Formatter/DateFormatter.tsx new file mode 100644 index 00000000..209bbe04 --- /dev/null +++ b/frontend/src/components/Table/Formatter/DateFormatter.tsx @@ -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 {DateTimeFormat(value)}; +} diff --git a/frontend/src/components/Table/Formatter/index.ts b/frontend/src/components/Table/Formatter/index.ts index 7af50356..88a8cee2 100644 --- a/frontend/src/components/Table/Formatter/index.ts +++ b/frontend/src/components/Table/Formatter/index.ts @@ -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"; diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 12e617c7..0ab4a2e6 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -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"; diff --git a/frontend/src/hooks/useCertificate.ts b/frontend/src/hooks/useCertificate.ts new file mode 100644 index 00000000..fc99c840 --- /dev/null +++ b/frontend/src/hooks/useCertificate.ts @@ -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({ + queryKey: ["certificate", id], + queryFn: () => fetchCertificate(id), + staleTime: 60 * 1000, // 1 minute + ...options, + }); +}; + +export { useCertificate }; diff --git a/frontend/src/locale/lang/en.json b/frontend/src/locale/lang/en.json index c5729b49..cf4f8bfe 100644 --- a/frontend/src/locale/lang/en.json +++ b/frontend/src/locale/lang/en.json @@ -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}", diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index 17b83bfc..31684362 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -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" }, diff --git a/frontend/src/modals/HTTPCertificateModal.tsx b/frontend/src/modals/HTTPCertificateModal.tsx index e7be3c70..b996a7d6 100644 --- a/frontend/src/modals/HTTPCertificateModal.tsx +++ b/frontend/src/modals/HTTPCertificateModal.tsx @@ -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(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(); } + queryClient.invalidateQueries({ queryKey: ["certificates"] }); setIsSubmitting(false); setSubmitting(false); }; diff --git a/frontend/src/modals/RenewCertificateModal.tsx b/frontend/src/modals/RenewCertificateModal.tsx new file mode 100644 index 00000000..c7e13dba --- /dev/null +++ b/frontend/src/modals/RenewCertificateModal.tsx @@ -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(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(); + }) + .finally(() => { + setIsSubmitting(false); + }); + }, [id, data, isFresh, isSubmitting, remove, queryClient.invalidateQueries]); + + return ( + + + + + + + + + {errorMsg} + + {isLoading && } + {!isLoading && error && ( + + {error?.message || "Unknown error"} + + )} + {data && isSubmitting && !errorMsg ?

Please wait ...

: null} +
+ + + +
+ ); +}); + +export { showRenewCertificateModal }; diff --git a/frontend/src/modals/index.ts b/frontend/src/modals/index.ts index 32e34198..237a07fe 100644 --- a/frontend/src/modals/index.ts +++ b/frontend/src/modals/index.ts @@ -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"; diff --git a/frontend/src/pages/Certificates/Table.tsx b/frontend/src/pages/Certificates/Table.tsx index d67ea988..ae5f516c 100644 --- a/frontend/src/pages/Certificates/Table.tsx +++ b/frontend/src/pages/Certificates/Table.tsx @@ -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(); 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 ; + } + return ; }, }), - 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 ; }, }), 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 ( + + ); }, }), columnHelper.display({ - id: "id", // todo: not needed for a display? + id: "id", cell: (info: any) => { return ( @@ -75,16 +95,37 @@ export default function Table({ data, isFetching }: Props) { data={{ id: info.row.original.id }} /> - - - + { + e.preventDefault(); + onRenew?.(info.row.original.id); + }} + > + + - - - + { + e.preventDefault(); + onDownload?.(info.row.original.id); + }} + > + +
- + { + e.preventDefault(); + onDelete?.(info.row.original.id); + }} + > @@ -97,7 +138,7 @@ export default function Table({ data, isFetching }: Props) { }, }), ], - [columnHelper], + [columnHelper, onDelete, onRenew, onDownload], ); const tableInstance = useReactTable({ @@ -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} /> diff --git a/frontend/src/pages/Certificates/TableWrapper.tsx b/frontend/src/pages/Certificates/TableWrapper.tsx index a91f7cc4..97534273 100644 --- a/frontend/src/pages/Certificates/TableWrapper.tsx +++ b/frontend/src/pages/Certificates/TableWrapper.tsx @@ -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 {error?.message || "Unknown error"}; } + 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 (
@@ -33,66 +68,83 @@ export default function TableWrapper() {
-
-
-
- - - - -
- - +
+ showDeleteConfirmModal({ + title: , + onConfirm: () => handleDelete(id), + invalidations: [["certificates"], ["certificate", id]], + children: , + }) + } + /> ); diff --git a/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx b/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx index 866c6c77..7a3a5e28 100644 --- a/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx +++ b/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx @@ -89,10 +89,10 @@ export default function TableWrapper() { onEdit={(id: number) => showProxyHostModal(id)} onDelete={(id: number) => showDeleteConfirmModal({ - title: "proxy-host.delete.title", + title: , onConfirm: () => handleDelete(id), invalidations: [["proxy-hosts"], ["proxy-host", id]], - children: , + children: , }) } onDisableToggle={handleDisableToggle}