mirror of
https://github.com/certimate-go/certimate.git
synced 2026-09-24 23:10:13 +08:00
feat(ui): implement CertificateDownloadModal for enhanced certificate download options
This commit is contained in:
@@ -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": "高级"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user