You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

442 lines
14 KiB

1 year ago
1 year ago
  1. import { useAtom, useAtomValue, useSetAtom } from 'jotai'
  2. import {
  3. deleteSslAtom, getProvider,
  4. KeyTypeEnum,
  5. KeyTypes,
  6. ProviderTypeEnum,
  7. saveOrUpdateSslAtom,
  8. sslListAtom,
  9. sslPageAtom,
  10. sslSearchAtom, uploadSslAtom
  11. } from '@/store/websites/ssl.ts'
  12. import ListPageLayout from '@/layout/ListPageLayout.tsx'
  13. import { BetaSchemaForm, ProColumns, ProFormColumnsType, ProTable } from '@ant-design/pro-components'
  14. import { memo, useEffect, useMemo, useRef, useState } from 'react'
  15. import { useTranslation } from '@/i18n.ts'
  16. import { Button, Form, Popconfirm, Space } from 'antd'
  17. import { PlusOutlined } from '@ant-design/icons'
  18. import DrawerPicker, { DrawerPickerRef } from '@/components/drawer-picker/DrawerPicker.tsx'
  19. import AcmeList from './acme/AcmeList.tsx'
  20. import { acmeListAtom, AcmeType, getAcmeAccountTypeName } from '@/store/websites/acme.ts'
  21. import { dnsListAtom, getDNSTypeName } from '@/store/websites/dns.ts'
  22. import DNSList from './dns/DNSList.tsx'
  23. import CAList from './ca/CAList.tsx'
  24. import { WebSite } from '@/types'
  25. import Switch from '@/components/switch'
  26. import { Else, If, Then } from 'react-if'
  27. import Action from '@/components/action/Action.tsx'
  28. import { Status } from '@/components/status'
  29. import SSLDetail from './components/Detail.tsx'
  30. import { detailAtom } from './components/store.ts'
  31. import Upload from './components/Upload.tsx'
  32. import { FormInstance } from 'antd/lib'
  33. import Download from '@/components/download/Download.tsx'
  34. const SSL = () => {
  35. const { t } = useTranslation()
  36. const [ form ] = Form.useForm()
  37. const uploadFormRef = useRef<FormInstance>()
  38. const [ page, setPage ] = useAtom(sslPageAtom)
  39. const [ search, setSearch ] = useAtom(sslSearchAtom)
  40. const { data: acmeData, isLoading: acmeLoading } = useAtomValue(acmeListAtom)
  41. const { data: dnsData, isLoading: dnsLoading } = useAtomValue(dnsListAtom)
  42. const { data, isLoading, isFetching, refetch } = useAtomValue(sslListAtom)
  43. const { mutate: saveOrUpdate, isSuccess, isPending: isSubmitting } = useAtomValue(saveOrUpdateSslAtom)
  44. const { mutate: deleteSSL, isPending: isDeleting } = useAtomValue(deleteSslAtom)
  45. const { mutate: uploadSSL, isSuccess: isUploadSuccess, isPending: isUploading } = useAtomValue(uploadSslAtom)
  46. const updateDetail = useSetAtom(detailAtom)
  47. const uploadDrawerRef = useRef<DrawerPickerRef>()
  48. const [ open, setOpen ] = useState(false)
  49. const columns = useMemo<ProColumns<WebSite.ISSL>[]>(() => {
  50. return [
  51. {
  52. title: 'ID',
  53. dataIndex: 'id',
  54. hideInTable: true,
  55. hideInSearch: false,
  56. formItemProps: {
  57. hidden: true,
  58. }
  59. },
  60. {
  61. title: t('website.ssl.columns.primaryDomain', '域名'),
  62. dataIndex: 'primary_domain',
  63. formItemProps: {
  64. label: t('website.ssl.form.primaryDomain', '主域名'),
  65. rules: [ { required: true, message: t('message.required', '主域名') } ]
  66. }
  67. },
  68. {
  69. title: t('website.ssl.columns.otherDomains', '其它域名'),
  70. dataIndex: 'domains',
  71. },
  72. {
  73. title: t('website.ssl.columns.acmeAccountId', 'Acme帐号'),
  74. dataIndex: 'acme_account_id',
  75. valueType: 'select',
  76. fieldProps: {
  77. loading: acmeLoading,
  78. options: acmeData?.rows?.map(item => ({
  79. label: `${item.email} [${getAcmeAccountTypeName(item.type as AcmeType)}]`,
  80. value: item.id
  81. }))
  82. },
  83. formItemProps: {
  84. rules: [
  85. { required: true, message: t('message.required', '请选择') }
  86. ]
  87. }
  88. },
  89. {
  90. title: t('website.ssl.columns.status', '状态'),
  91. dataIndex: 'status',
  92. render: (_, record) => {
  93. return <Status status={record.status}/>
  94. },
  95. hideInForm: true,
  96. },
  97. {
  98. title: t('website.ssl.columns.keyType', '密钥算法'),
  99. dataIndex: 'key_type',
  100. hideInTable: true,
  101. valueType: 'select',
  102. fieldProps: {
  103. options: KeyTypes
  104. },
  105. formItemProps: {
  106. rules: [
  107. { required: true, message: t('message.required', '请选择') }
  108. ]
  109. },
  110. },
  111. {
  112. title: t('website.ssl.columns.provider', '申请方式'),
  113. dataIndex: 'provider',
  114. valueType: 'radio',
  115. valueEnum: {
  116. [ProviderTypeEnum.DnsAccount]: {
  117. text: t('website.ssl.providerTypeEnum.DnsAccount', 'DNS帐号'),
  118. },
  119. [ProviderTypeEnum.DnsManual]: {
  120. text: t('website.ssl.providerTypeEnum.DnsManual', '手动验证'),
  121. },
  122. [ProviderTypeEnum.Http]: {
  123. text: t('website.ssl.providerTypeEnum.Http', 'HTTP'),
  124. }
  125. },
  126. dependencies: [ 'provider' ],
  127. renderText: (text) => {
  128. return getProvider(text)
  129. },
  130. formItemProps: (form, config) => {
  131. const val = form.getFieldValue(config.dataIndex)
  132. const help = {
  133. [ProviderTypeEnum.DnsAccount]: t('website.ssl.form.provider_{{v}}', '', { v: val }),
  134. [ProviderTypeEnum.DnsManual]: t('website.ssl.form.provider_{{v}}', '手动解析模式需要在创建完之后点击申请按钮获取 DNS 解析值', { v: val }),
  135. [ProviderTypeEnum.Http]: t('website.ssl.form.provider_{{v}}', 'HTTP 模式需要安装 OpenResty<br/><span style="color:red;">HTTP 模式无法申请泛域名证书</span>', { v: val }),
  136. }
  137. return {
  138. label: t('website.ssl.form.provider', '验证方式'),
  139. help: <span dangerouslySetInnerHTML={{ __html: help[val] }}/>,
  140. rules: [ { required: true, message: t('message.required', '请选择') } ]
  141. }
  142. },
  143. },
  144. {
  145. name: [ 'provider' ],
  146. valueType: 'dependency',
  147. hideInSetting: true,
  148. hideInTable: true,
  149. columns: ({ provider }) => {
  150. if (provider === ProviderTypeEnum.DnsAccount) {
  151. return [ {
  152. title: t('website.ssl.columns.dnsAccountId', 'DNS帐号'),
  153. dataIndex: 'dns_account_id',
  154. valueType: 'select',
  155. formItemProps: {
  156. rules: [ { required: true, message: t('message.required', '请输入DNS帐号') } ]
  157. },
  158. fieldProps: {
  159. loading: dnsLoading,
  160. options: dnsData?.rows?.map(item => ({
  161. label: `${item.name} [${getDNSTypeName(item.type)}]`,
  162. value: item.id
  163. }))
  164. },
  165. } ]
  166. }
  167. return []
  168. }
  169. },
  170. {
  171. title: t('website.ssl.columns.autoRenew', '自动续签'),
  172. dataIndex: 'auto_renew',
  173. valueType: 'switch',
  174. render: (_, record) => {
  175. return <Switch value={record.auto_renew} size={'small'}/>
  176. }
  177. },
  178. {
  179. title: t('website.ssl.columns.pushDir', '推送证书到本地目录'),
  180. dataIndex: 'push_dir',
  181. valueType: 'switch',
  182. hideInTable: true,
  183. hideInSearch: true,
  184. },
  185. {
  186. name: [ 'push_dir' ],
  187. valueType: 'dependency',
  188. hideInSetting: true,
  189. hideInTable: true,
  190. columns: ({ pushDir }) => {
  191. if (pushDir) {
  192. return [ {
  193. title: t('website.ssl.columns.dir', '目录'),
  194. dataIndex: 'dir',
  195. formItemProps: {
  196. help: t('website.ssl.form.dir_help', '会在此目录下生成两个文件,证书文件:fullchain.pem 密钥文件:privkey.pem'),
  197. rules: [ { required: true, message: t('message.required', '请输入目录') } ]
  198. }
  199. } ]
  200. }
  201. return []
  202. }
  203. },
  204. {
  205. title: t('website.ssl.columns.description', '备注'),
  206. dataIndex: 'description',
  207. },
  208. {
  209. title: t('website.ssl.columns.expire_date', '过期时间'),
  210. dataIndex: 'expire_date',
  211. valueType: 'dateTime',
  212. hideInForm: true,
  213. },
  214. {
  215. title: t('website.ssl.columns.option', '操作'), valueType: 'option',
  216. key: 'option',
  217. fixed: 'right',
  218. width: 300,
  219. render: (_, record) => [
  220. <Action key="detail"
  221. as={'a'}
  222. disabled={record.status === 'init' || record.status === 'error'}
  223. onClick={() => {
  224. updateDetail({
  225. open: true,
  226. record
  227. })
  228. }}
  229. >
  230. {t('actions.detail', '详情')}
  231. </Action>,
  232. <If condition={() => record.status !== 'manual'}>
  233. <Then>
  234. <Action key="apply"
  235. as={'a'}
  236. disabled={record.status === 'applying' || record.status === 'manual'}
  237. onClick={() => {
  238. }}
  239. >
  240. {t('actions.apply', '申请')}
  241. </Action>
  242. </Then>
  243. <Else>
  244. <Action key="update"
  245. as={'a'}
  246. onClick={() => {
  247. }}
  248. >
  249. {t('actions.update', '更新')}
  250. </Action>
  251. </Else>
  252. </If>,
  253. <Download key="download"
  254. server={async () => {
  255. }}
  256. >
  257. <a>{t('actions.download', '下载')}</a>
  258. </Download>,
  259. <Popconfirm
  260. key={'del_confirm'}
  261. disabled={isDeleting}
  262. onConfirm={() => {
  263. deleteSSL(record.id)
  264. }}
  265. title={t('message.deleteConfirm')}>
  266. <a key="del">
  267. {t('actions.delete', '删除')}
  268. </a>
  269. </Popconfirm>
  270. ,
  271. ],
  272. },
  273. ]
  274. }, [ acmeData, dnsData ])
  275. useEffect(() => {
  276. if (isSuccess) {
  277. setOpen(false)
  278. }
  279. }, [ isSuccess ])
  280. return (
  281. <ListPageLayout>
  282. <ProTable<WebSite.ISSL>
  283. headerTitle={t('website.ssl.title', '证书列表')}
  284. search={false}
  285. loading={isLoading || isFetching}
  286. rowKey={'id'}
  287. dataSource={data?.rows ?? []}
  288. columns={columns}
  289. columnsState={{
  290. defaultValue: {
  291. option: { fixed: 'right', disable: true },
  292. },
  293. }}
  294. options={{
  295. reload: () => {
  296. refetch()
  297. },
  298. }}
  299. toolbar={{
  300. search: {
  301. loading: isFetching && !!search.key,
  302. onSearch: (value: string) => {
  303. setSearch({ key: value })
  304. },
  305. placeholder: t('website.ssl.search.placeholder', '输入域名')
  306. },
  307. actions: [
  308. <DrawerPicker
  309. maskClosable={false}
  310. title={t('website.ssl.ca.title', '证书颁发机构')}
  311. width={1000}
  312. target={<Button type={'primary'} ghost={true}>
  313. {t('website.ssl.actions.selfSigned', '自签证书')}
  314. </Button>}
  315. >
  316. <CAList/>
  317. </DrawerPicker>,
  318. <DrawerPicker
  319. maskClosable={false}
  320. title={t('website.ssl.acme.title', 'Acme帐户')}
  321. width={1000}
  322. target={<Button type={'primary'} ghost={true}>
  323. {t('website.ssl.actions.acme', 'Acme帐户')}
  324. </Button>}
  325. >
  326. <AcmeList/>
  327. </DrawerPicker>,
  328. <DrawerPicker
  329. maskClosable={false}
  330. title={t('website.ssl.dns.title', 'DNS帐户')}
  331. width={1000}
  332. target={<Button type={'primary'} ghost={true}>
  333. {t('website.ssl.actions.dns', 'DNS帐户')}
  334. </Button>}>
  335. <DNSList/>
  336. </DrawerPicker>,
  337. <Button type={'primary'} onClick={() => {
  338. uploadDrawerRef.current?.open()
  339. }}>
  340. {t('website.ssl.actions.upload', '上传证书')}
  341. </Button>,
  342. <Button
  343. key="button"
  344. icon={<PlusOutlined/>}
  345. onClick={() => {
  346. form.resetFields()
  347. form.setFieldsValue({
  348. id: 0,
  349. keyType: KeyTypeEnum.EC256,
  350. })
  351. setOpen(true)
  352. }}
  353. type="primary"
  354. >
  355. {t('actions.sslApply', '申请证书')}
  356. </Button>,
  357. ]
  358. }}
  359. pagination={{
  360. pageSize: page?.pageSize ?? 10,
  361. total: data?.total ?? 0,
  362. current: page?.page ?? 1,
  363. onChange: (page, pageSize) => {
  364. setPage(prev => ({
  365. ...prev,
  366. page,
  367. pageSize,
  368. }))
  369. },
  370. }}
  371. >
  372. </ProTable>
  373. <BetaSchemaForm<WebSite.ISSL>
  374. shouldUpdate={false}
  375. width={600}
  376. form={form}
  377. layout={'vertical'}
  378. scrollToFirstError={true}
  379. title={t(`website.ssl.title_${form.getFieldValue('id') !== 0 ? 'edit' : 'add'}`, form.getFieldValue('id') !== 0 ? '证书编辑' : '证书添加')}
  380. // colProps={{ span: 24 }}
  381. labelCol={{ span: 12 }}
  382. wrapperCol={{ span: 24 }}
  383. layoutType={'DrawerForm'}
  384. open={open}
  385. drawerProps={{
  386. maskClosable: false,
  387. }}
  388. onOpenChange={(open) => {
  389. setOpen(open)
  390. }}
  391. loading={isSubmitting}
  392. onFinish={async (values) => {
  393. // console.log('values', values)
  394. saveOrUpdate(values)
  395. }}
  396. columns={columns as ProFormColumnsType[]}/>
  397. <DrawerPicker
  398. id={'upload-drawer-picker'}
  399. ref={uploadDrawerRef}
  400. maskClosable={false}
  401. title={t('website.ssl.upload.title', '上传证书')}
  402. width={800}
  403. footer={[
  404. <Space wrap={false} style={{ display: 'flex', justifyContent: 'end' }}>
  405. <Button onClick={() => uploadDrawerRef.current?.close()}>{t('actions.cancel')}</Button>
  406. <Button onClick={() => {
  407. uploadFormRef.current?.validateFields?.().then(values => {
  408. uploadSSL(values)
  409. if (isUploadSuccess) {
  410. uploadDrawerRef.current?.close()
  411. }
  412. })
  413. }}
  414. loading={isUploading}
  415. type={'primary'}>{t('actions.ok')}</Button>
  416. </Space>
  417. ]}
  418. target={false}
  419. >
  420. <Upload formRef={uploadFormRef}/>
  421. </DrawerPicker>
  422. <SSLDetail/>
  423. </ListPageLayout>
  424. )
  425. }
  426. export default memo(SSL)