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.

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