feat(ui): implement CertificateDownloadModal for enhanced certificate download options

This commit is contained in:
Fu Diwei
2026-05-22 22:42:11 +08:00
committed by RHQYZ
parent 0c6f033d02
commit f57aa24ee8
5 changed files with 336 additions and 47 deletions
@@ -1,12 +1,12 @@
import { CopyToClipboard } from "react-copy-to-clipboard";
import { useTranslation } from "react-i18next";
import { IconClipboard, IconDownload, IconThumbUp } from "@tabler/icons-react";
import { App, Button, Dropdown, Form, Input, Tag, Tooltip } from "antd";
import { IconClipboard, IconDownload } from "@tabler/icons-react";
import { App, Button, Form, Input, Tag, Tooltip } from "antd";
import dayjs from "dayjs";
import { saveAs } from "file-saver";
import { download as downloadCertificate } from "@/api/certificates";
import { CERTIFICATE_FORMATS, type CertificateFormatType, type CertificateModel } from "@/domain/certificate";
import { type CertificateModel } from "@/domain/certificate";
import CertificateDownloadModal from "./CertificateDownloadModal";
export interface CertificateDetailProps {
className?: string;
@@ -19,19 +19,6 @@ const CertificateDetail = ({ data, ...props }: CertificateDetailProps) => {
const { message } = App.useApp();
const handleDownloadClick = async (format: CertificateFormatType) => {
try {
const res = await downloadCertificate(data.id, format);
const bstr = atob(res.data.zipBytes);
const u8arr = Uint8Array.from(bstr, (ch) => ch.charCodeAt(0));
const blob = new Blob([u8arr], { type: "application/zip" });
saveAs(blob, `${data.id}-${data.subjectAltNames}.zip`);
} catch (err) {
console.error(err);
message.warning(t("common.text.operation_failed"));
}
};
return (
<div {...props}>
<Form layout="vertical">
@@ -94,33 +81,14 @@ const CertificateDetail = ({ data, ...props }: CertificateDetailProps) => {
</Form>
<div className="flex items-center justify-end">
<Dropdown
menu={{
items: [
{
key: "PEM",
label: "PEM",
extra: <IconThumbUp size="1.25em" />,
onClick: () => handleDownloadClick(CERTIFICATE_FORMATS.PEM),
},
{
key: "PFX",
label: "PFX",
onClick: () => handleDownloadClick(CERTIFICATE_FORMATS.PFX),
},
{
key: "JKS",
label: "JKS",
onClick: () => handleDownloadClick(CERTIFICATE_FORMATS.JKS),
},
],
}}
trigger={["click", "hover"]}
>
<Button icon={<IconDownload size="1.25em" />} type="primary">
{t("common.button.download")}
</Button>
</Dropdown>
<CertificateDownloadModal
data={data}
trigger={
<Button icon={<IconDownload size="1.25em" />} type="primary">
{t("common.button.download")}
</Button>
}
/>
</div>
</div>
);
@@ -0,0 +1,203 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { useControllableValue } from "ahooks";
import { App, Button, Divider, Form, Input, Modal, Select, Table, type TableProps } from "antd";
import { saveAs } from "file-saver";
import { download as downloadCertificate } from "@/api/certificates";
import { CERTIFICATE_FORMATS, type CertificateFormatType, type CertificateModel } from "@/domain/certificate";
import { useAntdForm, useTriggerElement } from "@/hooks";
export interface CertificateDownloadModalProps {
className?: string;
style?: React.CSSProperties;
afterClose?: () => void;
data: Pick<CertificateModel, "id" | "subjectAltNames">;
open?: boolean;
trigger?: React.ReactNode;
onOpenChange?: (open: boolean) => void;
}
const CertificateDownloadModal = ({ afterClose, data, trigger, ...props }: CertificateDownloadModalProps) => {
const { t } = useTranslation();
const { message } = App.useApp();
const [open, _setOpen] = useControllableValue<boolean>(props, {
valuePropName: "open",
defaultValuePropName: "defaultOpen",
trigger: "onOpenChange",
});
const setOpen = (open: boolean) => {
_setOpen(open);
setTableExpandedKeys([]);
formInst.resetFields();
};
const triggerEl = useTriggerElement(trigger, { onClick: () => setOpen(true) });
const { form: formInst, formProps } = useAntdForm({
name: "certficateDownloadAdvancedForm",
});
type TableRecord = {
format: CertificateFormatType;
advanced?: boolean;
advancedForm?: React.ReactNode;
};
const tableColumns: TableProps<TableRecord>["columns"] = [
{
key: "format",
title: t("certificate.action.download.modal.form.format.label"),
render: (_, record) => (
<div className="space-y-2">
<div>{t(`certificate.action.download.modal.form.format.option.${record.format.toLowerCase()}.label`)}</div>
<div>{t(`certificate.action.download.modal.form.format.option.${record.format.toLowerCase()}.description`)}</div>
</div>
),
},
{
key: "$action",
align: "end",
width: 32,
render: (_, record) => (
<div className="flex items-center justify-end">
<Button color="primary" size="small" variant="link" onClick={() => handleDownloadClick(record.format)}>
{t("common.button.download")}
</Button>
<Divider orientation="vertical" />
<Button
color="primary"
disabled={!record.advanced}
icon={tableExpandedKeys.includes(record.format) ? <IconChevronUp size="1.25em" /> : <IconChevronDown size="1.25em" />}
iconPlacement="end"
size="small"
variant="link"
onClick={() => {
setTableExpandedKeys((prev) => (prev.includes(record.format) ? [] : [record.format]));
formInst.resetFields();
}}
>
{t("certificate.action.download.modal.action.advanced.button")}
</Button>
</div>
),
},
];
const tableData: TableRecord[] = [
{
format: CERTIFICATE_FORMATS.PEM,
},
{
format: CERTIFICATE_FORMATS.PFX,
advanced: true,
advancedForm: (
<Form form={formInst} layout="vertical" {...formProps}>
<div className="flex gap-x-4 not-md:flex-wrap">
<div className="w-1/2 not-md:w-full">
<Form.Item name="pfxPassword" label={t("certificate.action.download.modal.form.pfx_password.label")}>
<Input placeholder={t("certificate.action.download.modal.form.pfx_password.placeholder")} />
</Form.Item>
</div>
<div className="w-1/2 not-md:w-full">
<Form.Item name="pfxEncoder" label={t("certificate.action.download.modal.form.pfx_encoder.label")}>
<Select
options={["LegacyRC2", "LegacyDES", "Modern2023", "Modern2026"].map((s) => ({
key: s,
label: t(`certificate.action.download.modal.form.pfx_encoder.option.${s.toLowerCase()}.label`),
value: s,
}))}
placeholder={t("certificate.action.download.modal.form.pfx_encoder.placeholder")}
/>
</Form.Item>
</div>
</div>
</Form>
),
},
{
format: CERTIFICATE_FORMATS.JKS,
advanced: true,
advancedForm: (
<Form form={formInst} layout="vertical" {...formProps}>
<div className="flex gap-x-4 not-md:flex-wrap">
<div className="w-1/2 not-md:w-full">
<Form.Item name="jksAlias" label={t("certificate.action.download.modal.form.jks_alias.label")}>
<Input placeholder={t("certificate.action.download.modal.form.jks_alias.placeholder")} />
</Form.Item>
</div>
<div className="w-1/2 not-md:w-full">
<Form.Item name="jksKeypass" label={t("certificate.action.download.modal.form.jks_keypass.label")}>
<Input placeholder={t("certificate.action.download.modal.form.jks_keypass.placeholder")} />
</Form.Item>
</div>
</div>
<div className="flex gap-x-4 not-md:flex-wrap">
<div className="w-1/2 not-md:w-full">
<Form.Item name="jksStorepass" label={t("certificate.action.download.modal.form.jks_storepass.label")}>
<Input placeholder={t("certificate.action.download.modal.form.jks_storepass.placeholder")} />
</Form.Item>
</div>
</div>
</Form>
),
},
];
const [tableExpandedKeys, setTableExpandedKeys] = useState<string[]>([]);
const handleCancelClick = () => {};
const handleDownloadClick = async (format: CertificateFormatType) => {
await formInst.validateFields();
try {
const res = await downloadCertificate(data.id, format, formInst.getFieldsValue());
const bstr = atob(res.data.zipBytes);
const u8arr = Uint8Array.from(bstr, (ch) => ch.charCodeAt(0));
const blob = new Blob([u8arr], { type: "application/zip" });
saveAs(blob, `${data.subjectAltNames}_${data.id}_${format}.zip`.toLowerCase());
setOpen(false);
} catch (err) {
console.error(err);
message.warning(t("common.text.operation_failed"));
}
};
return (
<>
{triggerEl}
<Modal
afterClose={afterClose}
closable
destroyOnHidden
footer={null}
open={open}
title={t("certificate.action.download.modal.title")}
width="768px"
onCancel={handleCancelClick}
>
<div className="py-3 pb-0">
<Table<TableRecord>
columns={tableColumns}
dataSource={tableData}
expandable={{
expandedRowKeys: tableExpandedKeys,
expandedRowRender: (record) => record.advancedForm,
rowExpandable: (record) => !!record.advanced,
showExpandColumn: false,
}}
rowHoverable={false}
pagination={false}
rowKey={(record) => record.format}
size="medium"
/>
</div>
</Modal>
</>
);
};
export default CertificateDownloadModal;
@@ -412,7 +412,7 @@ const WorkflowRunArtifacts = ({ runData }: { runData: WorkflowRunModel }) => {
{
key: "$action",
align: "end",
width: 32,
width: 64,
render: (_, record) => (
<div className="flex items-center justify-end">
<CertificateDetailDrawer
@@ -461,7 +461,7 @@ const WorkflowRunArtifacts = ({ runData }: { runData: WorkflowRunModel }) => {
}}
pagination={false}
rowKey={(record) => record.id}
size="small"
size="medium"
/>
);
};
@@ -30,6 +30,65 @@
"batch_delete.modal": {
"title": "Delete certificates",
"content": "Are you sure want to delete these {{count}} selected certificates? <br>This action cannot be undone."
},
"download.modal": {
"title": "Download certificate",
"form": {
"format": {
"label": "Format",
"option": {
"pem": {
"label": "PEM (*.pem, *.crt, *.key)",
"description": "Suitable for most scenarios. Supported by servers like Apache, Nginx."
},
"pfx": {
"label": "PFX (*.pfx, *.p12)",
"description": "Supported by servers like IIS."
},
"jks": {
"label": "JKS (*.jks)",
"description": "Supported by servers like Tomcat."
}
}
},
"pfx_password": {
"label": "PFX Password (Optional)",
"placeholder": "Please enter PFX password"
},
"pfx_encoder": {
"label": "PFX Encoder (Optional)",
"placeholder": "Please select PFX encoder",
"option": {
"legacyrc2": {
"label": "LegacyRC2 (RC2, compatible with older systems, but less secure)"
},
"legacydes": {
"label": "LegacyDES (3DES + SHA-1, default)"
},
"modern2023": {
"label": "Modern2023 (AES-256-CBC + SHA-256 MAC, compatible with OpenSSL v1.1.1+)"
},
"modern2026": {
"label": "Modern2026 (AES-256-CBC + PBMAC1 MAC, compatible with OpenSSL v3.4.0+)"
}
}
},
"jks_alias": {
"label": "JKS Alias (Optional)",
"placeholder": "Please enter JKS alias"
},
"jks_keypass": {
"label": "JKS Key password (Optional)",
"placeholder": "Please enter JKS key password"
},
"jks_storepass": {
"label": "JKS Store password (Optional)",
"placeholder": "Please enter JKS store password"
}
},
"action": {
"advanced.button": "Advanced"
}
}
},
@@ -30,6 +30,65 @@
"batch_delete.modal": {
"title": "删除证书",
"content": "确定要删除这 {{count}} 个被选中的证书吗?<br>注意此操作不可撤销,请谨慎操作。"
},
"download.modal": {
"title": "下载证书",
"form": {
"format": {
"label": "格式",
"option": {
"pem": {
"label": "PEM 格式(*.pem, *.crt, *.key)",
"description": "适用于大部分场景。Apache、Nginx 等服务器支持此格式。"
},
"pfx": {
"label": "PFX 格式(*.pfx, *.p12)",
"description": "IIS 等服务器支持此格式。"
},
"jks": {
"label": "JKS 格式(*.jks)",
"description": "Tomcat 等服务器支持此格式。"
}
}
},
"pfx_password": {
"label": "PFX 密码(可选)",
"placeholder": "请输入 PFX 导出密码"
},
"pfx_encoder": {
"label": "PFX 编码器(可选)",
"placeholder": "请选择 PFX 编码器",
"option": {
"legacyrc2": {
"label": "LegacyRC2(RC2,兼容旧系统,但安全性较低)"
},
"legacydes": {
"label": "LegacyDES(3DES + SHA-1,默认值)"
},
"modern2023": {
"label": "Modern2023(AES-256-CBC + SHA-256 MAC,适配 OpenSSL v1.1.1+)"
},
"modern2026": {
"label": "Modern2026(AES-256-CBC + PBMAC1 MAC,适配 OpenSSL v3.4.0+)"
}
}
},
"jks_alias": {
"label": "JKS 别名(可选)",
"placeholder": "请输入 JKS 别名"
},
"jks_keypass": {
"label": "JKS 私钥访问口令(可选)",
"placeholder": "请输入 JKS 私钥访问口令"
},
"jks_storepass": {
"label": "JKS 密钥库存储口令(可选)",
"placeholder": "请输入 JKS 密钥库存储口令"
}
},
"action": {
"advanced.button": "高级"
}
}
},