Bladeren bron

Certificates react work

- renewal and download
- table columns rendering
- searching
- deleting
Jamie Curnow 1 maand geleden
bovenliggende
commit
0de26f2950

+ 10 - 2
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 {

+ 7 - 5
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<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`,
+	);
 }

+ 0 - 2
frontend/src/api/backend/responseTypes.ts

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

+ 62 - 0
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 (
+		<>
+			<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>
+	);
+}

+ 15 - 0
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 <span className={cl}>{DateTimeFormat(value)}</span>;
+}

+ 2 - 0
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";

+ 1 - 0
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";

+ 17 - 0
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<Certificate, Error>({
+		queryKey: ["certificate", id],
+		queryFn: () => fetchCertificate(id),
+		staleTime: 60 * 1000, // 1 minute
+		...options,
+	});
+};
+
+export { useCertificate };

+ 5 - 0
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}",

+ 15 - 0
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"
 	},

+ 3 - 0
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<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);
 	};

+ 74 - 0
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<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 };

+ 1 - 0
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";

+ 60 - 20
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<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}
 				/>

+ 107 - 55
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 <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();
-											}}
-										>
-											<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();
-											}}
+						{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="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>
 	);

+ 2 - 2
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: <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}