Browse Source

完善上传证书

main
dark 6 months ago
parent
commit
a71a864cc6
  1. 1
      package.json
  2. 23
      src/components/action/Action.tsx
  3. 1
      src/components/action/index.ts
  4. 17
      src/components/action/style.ts
  5. 52
      src/components/drawer-picker/DrawerPicker.tsx
  6. 59
      src/components/status/Status.tsx
  7. 1
      src/components/status/index.ts
  8. 1
      src/locales/lang/en-US.ts
  9. 41
      src/locales/lang/status/zh-CN.ts
  10. 3
      src/locales/lang/zh-CN.ts
  11. 11
      src/pages/websites/ssl/ca/Detail.tsx
  12. 127
      src/pages/websites/ssl/components/Detail.tsx
  13. 77
      src/pages/websites/ssl/components/Upload.tsx
  14. 23
      src/pages/websites/ssl/components/store.ts
  15. 23
      src/pages/websites/ssl/components/style.ts
  16. 111
      src/pages/websites/ssl/index.tsx
  17. 7
      src/service/websites.ts
  18. 14
      src/store/websites/ssl.ts
  19. 2
      src/types/index.d.ts
  20. 10
      src/types/website/ssl.d.ts
  21. 1
      vite.config.ts
  22. 5
      yarn.lock

1
package.json

@ -36,6 +36,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^14.1.0",
"react-if": "^4.1.5",
"react-layout-kit": "^1.9.0",
"react-rnd": "^10.4.2-test2",
"react-use": "^17.5.0",

23
src/components/action/Action.tsx

@ -0,0 +1,23 @@
import { Button, ButtonProps } from 'antd'
import { useStyle } from './style'
export interface ActionProps extends ButtonProps {
as?: string
}
const Action = ({ title, as, children, ...props }: ActionProps) => {
const { styles } = useStyle()
const isLink = as === 'a' || props.type === 'link'
return (
<span className={styles.container}>
<Button {...props}
type={isLink ? 'link' : props.type}
className={as === 'a' ? styles.actionA : ''}>{title ?? children}</Button>
</span>
)
}
export default Action

1
src/components/action/index.ts

@ -0,0 +1 @@
export * from './Action'

17
src/components/action/style.ts

@ -0,0 +1,17 @@
import { createStyles } from '@/theme'
export const useStyle = createStyles(({ token, css, cx, prefixCls }, props: any) => {
const prefix = `${prefixCls}-${token?.proPrefix}-action`
const container = css`
`
const actionA = css`
padding: 0 2px;
`
return {
container: cx(prefix, props?.className, container),
actionA,
}
})

52
src/components/drawer-picker/DrawerPicker.tsx

@ -1,42 +1,60 @@
import { Button, Drawer, DrawerProps } from 'antd'
import React, { useState } from 'react'
import React, { forwardRef, useState, useImperativeHandle, memo } from 'react'
import { useStyle } from './style'
import { generateUUID } from '@/utils/uuid.ts'
export interface DrawerPickerProps extends DrawerProps {
target?: React.ReactNode
target?: React.ReactNode | boolean
children?: React.ReactNode
key?: string
id?: string
foreRender?: boolean
}
const DrawerPicker = ({ children, target, foreRender, ...props }: DrawerPickerProps) => {
export interface DrawerPickerRef {
open: () => void,
close: () => void
}
const { styles } = useStyle()
const DrawerPicker = forwardRef<DrawerPickerRef | undefined, DrawerPickerProps>((
{
children,
target,
foreRender,
...props
}: DrawerPickerProps, ref) => {
const [ open, setOpen ] = useState(false)
const { styles, cx } = useStyle()
const getTarget = () => {
const def = <Button>{props.title ?? 'Target'}</Button>
return <span className={styles.target}>{target ?? def}</span>
const [ open, setOpen ] = useState(() => false)
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true)
},
close: () => {
setOpen(false)
}
}), [])
return (
<div className={styles.container} key={props.key ?? generateUUID()}>
<div className={styles.target} onClick={() => {
const getTarget = () => {
if (target === false) return null
const def = <Button>{props.title ?? 'Target'}</Button>
return <div className={styles.target} onClick={() => {
setOpen(true)
}}>
{getTarget()}
<span className={styles.target}>{target ?? def}</span>
</div>
}
return (
<div className={cx(styles.container, props.id)}>
{getTarget()}
{
(foreRender || open) && <Drawer {...props}
open={open}
onClose={() => setOpen(false)}
>{children}</Drawer>
}
</div>
)
}
})
export default DrawerPicker
export default memo(DrawerPicker)

59
src/components/status/Status.tsx

@ -0,0 +1,59 @@
import { Tag, TagProps } from 'antd'
import { useTranslation } from '@/i18n.ts'
import { SyncOutlined } from '@ant-design/icons'
export interface StatusProps extends TagProps {
status: string
}
const getColor = (status: string) => {
if (status.includes('error') || status.includes('err')) {
return 'danger'
}
switch (status) {
case 'running':
return 'success'
case 'stopped':
return 'danger'
case 'unhealthy':
case 'paused':
case 'exited':
case 'dead':
case 'removing':
return 'warning'
default:
return 'primary'
}
}
const loadingStatus = [
'installing',
'building',
'restarting',
'upgrading',
'rebuilding',
'recreating',
'creating',
'starting',
'removing',
'applying',
]
const loadingIcon = (status: string): boolean => {
return loadingStatus.indexOf(status) > -1
}
export const Status = ({ status = 'running', ...props }: StatusProps) => {
const { t } = useTranslation()
const icon = loadingIcon(status) ? <SyncOutlined spin/> : null
return (
<>
<Tag {...props}
color={getColor(status)}
icon={icon}>{t(`status.${status}`, status)}</Tag>
</>
)
}
export default Status

1
src/components/status/index.ts

@ -0,0 +1 @@
export * from './Status.tsx'

1
src/locales/lang/en-US.ts

@ -48,6 +48,7 @@ export default {
news: 'Add newly',
add: 'Add',
cancel: 'Cancel',
ok: 'OK',
edit: 'Edit',
delete: 'Delete',
batchDel: 'Batch Delete',

41
src/locales/lang/status/zh-CN.ts

@ -0,0 +1,41 @@
export default {
running: '已启动',
done: '已完成',
success: '成功',
waiting: '执行中',
waiting1: '等待中',
failed: '失败',
stopped: '已停止',
error: '失败',
created: '已创建',
restarting: '重启中',
uploading: '上传中',
unhealthy: '异常',
removing: '移除中',
paused: '已暂停',
exited: '已停止',
dead: '已结束',
installing: '安装中',
enabled: '已启用',
disabled: '已停止',
normal: '正常',
building: '制作镜像中',
downloaderr: '下载失败',
upgrading: '升级中',
upgradeerr: '升级失败',
pullerr: '镜像拉取失败',
rebuilding: '重建中',
deny: '已屏蔽',
accept: '已放行',
used: '已使用',
unUsed: '未使用',
starting: '启动中',
recreating: '重建中',
creating: '创建中',
systemrestart: '中断',
init: '等待申请',
ready: '正常',
applying: '申请中',
applyerror: '失败',
syncerr: '失败',
}

3
src/locales/lang/zh-CN.ts

@ -1,9 +1,11 @@
import antdZh from 'antd/locale/zh_CN'
import menus from './pages/system/menus/zh-CN.ts'
import roles from './pages/system/roles/zh-CN.ts'
import status from './status/zh-CN.ts'
export default {
...antdZh,
status,
error: {
'404': {
title: '无法找到',
@ -48,6 +50,7 @@ export default {
news: '新增',
add: '添加',
edit: '编辑',
ok: '确定',
cancel: '取消',
delete: '删除',
batchDel: '批量删除',

11
src/pages/websites/ssl/ca/Detail.tsx

@ -18,7 +18,7 @@ const Detail = (props: DrawerProps) => {
const options = useMemo(() => {
return [
{ label: t(`${prefix}.base`, '机构详情'), value: 'base' },
{ label: t(`${prefix}.src`, 'src'), value: 'csr' },
{ label: t(`${prefix}.csr`, 'csr'), value: 'csr' },
{ label: t(`${prefix}.private_key`, '私钥'), value: 'private_key' },
]
}, [])
@ -31,6 +31,15 @@ const Detail = (props: DrawerProps) => {
}
}, [ copyState ])
useEffect(() => {
return () => {
setUI({
open: false,
record: {},
})
}
}, [])
const render = useMemo(() => {
switch (key) {
case 'base':

127
src/pages/websites/ssl/components/Detail.tsx

@ -0,0 +1,127 @@
import { Segmented, Drawer, DrawerProps, Input, Button, Flex, message, Descriptions } from 'antd'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from '@/i18n.ts'
import { useAtom } from 'jotai'
import { detailAtom } from './store.ts'
import { useStyle } from './style.ts'
import { useCopyToClipboard } from 'react-use'
import { getProvider } from '@/store/websites/ssl.ts'
const Detail = (props: DrawerProps) => {
const prefix = 'website.ssl.detail'
const { t } = useTranslation()
const { styles } = useStyle()
const [ key, setKey ] = useState('base')
const [ ui, setUI ] = useAtom(detailAtom)
const [ copyState, setCopy ] = useCopyToClipboard()
const options = useMemo(() => {
return [
{ label: t(`${prefix}.base`, '证书信息'), value: 'base' },
{ label: t(`${prefix}.pem`, '证书'), value: 'pem' },
{ label: t(`${prefix}.private_key`, '私钥'), value: 'private_key' },
]
}, [])
useEffect(() => {
if (copyState.error) {
message.error(t('message.copyError', '复制失败'))
} else if (copyState.value) {
message.success(t('message.copySuccess', '复制成功'))
}
}, [ copyState ])
useEffect(() => {
return () => {
setUI({
open: false,
record: {},
})
}
}, [])
const render = useMemo(() => {
switch (key) {
case 'base':
return <div>
<Descriptions bordered={true} column={1}>
<Descriptions.Item label={t(`${prefix}.primary_domain`, '主域名')}>
{ui.record?.primary_domain}
</Descriptions.Item>
<Descriptions.Item label={t(`${prefix}.domains`, '其它域名')}>
{ui.record?.domains}
</Descriptions.Item>
<Descriptions.Item label={t(`${prefix}.type`, '证书主体名称(CN)')}>
{ui.record?.type}
</Descriptions.Item>
<Descriptions.Item label={t(`${prefix}.organization`, '颁发组织')}>
{ui.record?.organization}
</Descriptions.Item>
<Descriptions.Item label={t(`${prefix}.start_date`, '生效时间')}>
{ui.record?.start_date}
</Descriptions.Item>
<Descriptions.Item label={t(`${prefix}.expire_date`, '过期时间')}>
{ui.record?.expire_date}
</Descriptions.Item>
<Descriptions.Item label={t(`${prefix}.provider`, '申请方式')}>
{getProvider(ui.record?.provider)}
</Descriptions.Item>
</Descriptions>
</div>
case 'pem':
return <Flex gap={15} vertical={true}>
<Input.TextArea
rows={20}
value={ui.record?.pem}
/>
<span>
<Button type={'primary'} onClick={() => {
setCopy(ui.record?.pem)
}}> {t('actions.copy', '复制')}</Button>
</span>
</Flex>
case 'private_key':
return <Flex gap={15} vertical={true}>
<Input.TextArea
rows={20}
value={ui.record?.private_key}
/>
<span>
<Button type={'primary'} onClick={() => {
setCopy(ui.record?.private_key)
}}> {t('actions.copy', '复制')}</Button>
</span>
</Flex>
default:
return null
}
}, [ key, ui.record ])
return (
<Drawer {...props}
destroyOnClose={true}
title={t(`${prefix}.title`, '详情')}
width={800}
open={ui.open}
onClose={() =>
setUI(prev => ({
...prev,
open: false
}))
}
>
<Segmented
value={key}
options={options}
onChange={setKey}
/>
<div className={styles.content}>
{render}
</div>
</Drawer>
)
}
export default Detail

77
src/pages/websites/ssl/components/Upload.tsx

@ -0,0 +1,77 @@
import { Select, Form, Input } from 'antd'
import { useTranslation } from '@/i18n.ts'
import { useAtom } from 'jotai'
import { uploadAtom, uploadType } from './store.ts'
import { Else, If, Then } from 'react-if'
import { MutableRefObject, useEffect } from 'react'
import { FormInstance } from 'antd/lib'
export interface UploadProps {
form?: FormInstance
formRef?: MutableRefObject<FormInstance | undefined>
}
const Upload = (props: UploadProps) => {
const { t } = useTranslation()
const prefix = 'website.ssl.upload'
const [ form ] = Form.useForm()
const [ dto, updateDto ] = useAtom(uploadAtom)
useEffect(() => {
form.setFieldsValue(dto)
}, [])
props.formRef!.current = props.form ?? form
return (
<Form
form={props.form ?? form}
layout={'vertical'}
onValuesChange={(_change) => {
updateDto(prev => ({
...prev,
..._change
}))
}}
scrollToFirstError={true}
>
<Form.Item name={'type'} label={t(`${prefix}.type`, '导入方式')}
rules={[ { required: true } ]}
>
<Select options={uploadType}/>
</Form.Item>
<If condition={dto.type === 'paste'}>
<Then>
<Form.Item name={'private_key'} label={t(`${prefix}.private_key`, '私钥(KEY)')}
rules={[ { required: true } ]}
>
<Input.TextArea rows={10}/>
</Form.Item>
<Form.Item name={'pem'} label={t(`${prefix}.pem`, '证书(PEM格式)')}
rules={[ { required: true } ]}
>
<Input.TextArea rows={10}/>
</Form.Item>
</Then>
<Else>
<Form.Item name={'private_key_path'} label={t(`${prefix}.private_key_path`, '私钥文件')}
rules={[ { required: true } ]}
>
<Input/>
</Form.Item> <Form.Item name={'pem_path'} label={t(`${prefix}.pem_path`, '证书文件')}
rules={[ { required: true } ]}
>
<Input/>
</Form.Item>
</Else>
</If>
<Form.Item name={'description'} label={t(`${prefix}.description`, '备注')}>
<Input/>
</Form.Item>
</Form>
)
}
export default Upload

23
src/pages/websites/ssl/components/store.ts

@ -0,0 +1,23 @@
import { atom } from 'jotai'
import { WebSite } from '@/types'
import { t } from 'i18next'
type DetailUI = {
open: boolean,
record?: WebSite.ISSL,
}
export const detailAtom = atom<DetailUI>({
open: false,
})
export const uploadType = [
{ label: t('website.ssl.upload.type_paste', '粘贴代码'), value: 'paste' },
{ label: t('website.ssl.upload.type_local', '选择服务器文件'), value: 'local' },
]
export const uploadAtom = atom<WebSite.SSLUploadDto>({
type: 'paste'
})

23
src/pages/websites/ssl/components/style.ts

@ -0,0 +1,23 @@
import { createStyles } from '@/theme'
export const useStyle = createStyles(({ token, css, cx, prefixCls }, props: any) => {
const prefix = `${prefixCls}-${token?.proPrefix}-ssl-detail`
const container = css`
`
const content = css`
padding: 20px 0;
.ant-descriptions-item-label{
font-weight: bold;
width: 50%;
}
`
return {
container: cx(prefix, props?.className, container),
content,
}
})

111
src/pages/websites/ssl/index.tsx

@ -1,4 +1,4 @@
import { useAtom, useAtomValue } from 'jotai'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import {
deleteSslAtom, getProvider,
KeyTypeEnum,
@ -7,15 +7,15 @@ import {
saveOrUpdateSslAtom,
sslListAtom,
sslPageAtom,
sslSearchAtom
sslSearchAtom, uploadSslAtom
} from '@/store/websites/ssl.ts'
import ListPageLayout from '@/layout/ListPageLayout.tsx'
import { BetaSchemaForm, ProColumns, ProFormColumnsType, ProTable } from '@ant-design/pro-components'
import { memo, useMemo, useState } from 'react'
import { memo, useMemo, useRef, useState } from 'react'
import { useTranslation } from '@/i18n.ts'
import { Button, Form, Popconfirm } from 'antd'
import { Button, Form, Popconfirm, Space } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import DrawerPicker from '@/components/drawer-picker/DrawerPicker.tsx'
import DrawerPicker, { DrawerPickerRef } from '@/components/drawer-picker/DrawerPicker.tsx'
import AcmeList from './acme/AcmeList.tsx'
import { acmeListAtom, AcmeType, getAcmeAccountTypeName } from '@/store/websites/acme.ts'
import { dnsListAtom, getDNSTypeName } from '@/store/websites/dns.ts'
@ -23,12 +23,20 @@ import DNSList from './dns/DNSList.tsx'
import CAList from './ca/CAList.tsx'
import { WebSite } from '@/types'
import Switch from '@/components/switch'
import { Else, If, Then } from 'react-if'
import Action from '@/components/action/Action.tsx'
import { Status } from '@/components/status'
import SSLDetail from './components/Detail.tsx'
import { detailAtom } from './components/store.ts'
import Upload from './components/Upload.tsx'
import { FormInstance } from 'antd/lib'
const SSL = () => {
const { t } = useTranslation()
const [ form ] = Form.useForm()
const uploadFormRef = useRef<FormInstance>()
const [ page, setPage ] = useAtom(sslPageAtom)
const [ search, setSearch ] = useAtom(sslSearchAtom)
const { data: acmeData, isLoading: acmeLoading } = useAtomValue(acmeListAtom)
@ -36,7 +44,9 @@ const SSL = () => {
const { data, isLoading, isFetching, refetch } = useAtomValue(sslListAtom)
const { mutate: saveOrUpdate, isSuccess, isPending: isSubmitting } = useAtomValue(saveOrUpdateSslAtom)
const { mutate: deleteSSL, isPending: isDeleting } = useAtomValue(deleteSslAtom)
const { mutate: uploadSSL, isSuccess: isUploadSuccess, isPending: isUploading } = useAtomValue(uploadSslAtom)
const updateDetail = useSetAtom(detailAtom)
const uploadDrawerRef = useRef<DrawerPickerRef>()
const [ open, setOpen ] = useState(false)
const columns = useMemo<ProColumns<WebSite.ISSL>[]>(() => {
@ -80,6 +90,14 @@ const SSL = () => {
}
},
{
title: t('website.ssl.columns.status', '状态'),
dataIndex: 'status',
render: (_, record) => {
return <Status status={record.status}/>
},
hideInForm: true,
},
{
title: t('website.ssl.columns.keyType', '密钥算法'),
dataIndex: 'key_type',
hideInTable: true,
@ -196,19 +214,61 @@ const SSL = () => {
{
title: t('website.ssl.columns.expire_date', '过期时间'),
dataIndex: 'expire_date',
valueType: 'dateTime'
valueType: 'dateTime',
hideInForm: true,
},
{
title: t('website.ssl.columns.option', '操作'), valueType: 'option',
key: 'option',
fixed: 'right',
width: 300,
render: (_, record) => [
<a key="editable"
<Action key="detail"
as={'a'}
disabled={record.status === 'init' || record.status === 'error'}
onClick={() => {
updateDetail({
open: true,
record
})
}}
>
{t('actions.detail', '详情')}
</Action>,
<If condition={() => record.status !== 'manual'}>
<Then>
<Action key="apply"
as={'a'}
disabled={record.status === 'applying' || record.status === 'manual'}
onClick={() => {
}}
>
{t('actions.edit', '编辑')}
</a>,
{t('actions.apply', '申请')}
</Action>
</Then>
<Else>
<Action key="update"
as={'a'}
onClick={() => {
}}
>
{t('actions.update', '更新')}
</Action>
</Else>
</If>,
<Action key="download"
as={'a'}
onClick={() => {
}}
>
{t('actions.download', '下载')}
</Action>,
<Popconfirm
key={'del_confirm'}
disabled={isDeleting}
@ -283,6 +343,11 @@ const SSL = () => {
</Button>}>
<DNSList/>
</DrawerPicker>,
<Button type={'primary'} onClick={()=>{
uploadDrawerRef.current?.open()
}}>
{t('website.ssl.actions.upload', '上传证书')}
</Button>,
<Button
key="button"
icon={<PlusOutlined/>}
@ -340,6 +405,32 @@ const SSL = () => {
return isSuccess
}}
columns={columns as ProFormColumnsType[]}/>
<DrawerPicker
id={'upload-drawer-picker'}
ref={uploadDrawerRef}
maskClosable={false}
title={t('website.ssl.upload.title', '上传证书')}
width={800}
footer={[
<Space wrap={false} style={{ display: 'flex', justifyContent: 'end' }}>
<Button onClick={() => uploadDrawerRef.current?.close()}>{t('actions.cancel')}</Button>
<Button onClick={() => {
uploadFormRef.current?.validateFields?.().then(values => {
uploadSSL(values)
if (isUploadSuccess) {
uploadDrawerRef.current?.close()
}
})
}}
loading={isUploading}
type={'primary'}>{t('actions.ok')}</Button>
</Space>
]}
target={false}
>
<Upload formRef={uploadFormRef}/>
</DrawerPicker>
<SSLDetail/>
</ListPageLayout>
)
}

7
src/service/websites.ts

@ -4,7 +4,11 @@ import request from '@/request.ts'
const websitesServ = {
ssl: {
...createCURD<any, WebSite.ISSL>('/website/ssl')
...createCURD<any, WebSite.ISSL>('/website/ssl'),
upload: async(params: WebSite.SSLUploadDto)=>{
return request.post<any, WebSite.SSLUploadDto>('/website/ssl/upload', params)
}
},
acme: {
...createCURD<any, WebSite.IAcmeAccount>('/website/acme')
@ -17,7 +21,6 @@ const websitesServ = {
obtainSsl: async (params: WebSite.ISSLObtainByCA) => {
return request.post<any, WebSite.ISSLObtainByCA>('/website/ca/obtain_ssl', params)
},
}
}

14
src/store/websites/ssl.ts

@ -4,7 +4,7 @@ import { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query'
import websitesServ from '@/service/websites.ts'
import { message } from 'antd'
import { t } from 'i18next'
import { ISSL, SSLSearchParam } from '@/types/website/ssl'
import { ISSL, SSLSearchParam, SSLUploadDto } from '@/types/website/ssl'
export enum ProviderTypeEnum {
DnsAccount = 'dnsAccount',
@ -97,3 +97,15 @@ export const deleteSslAtom = atomWithMutation<IApiResult, number>(get => ({
get(sslListAtom).refetch()
}
}))
//upload
export const uploadSslAtom = atomWithMutation<IApiResult, SSLUploadDto>(get => ({
mutationKey: [ 'sslUpload' ],
mutationFn: async (data) => {
return await websitesServ.ssl.upload(data)
},
onSuccess: () => {
message.success(t('message.saveSuccess', '上传成功'))
get(sslListAtom).refetch()
},
}))

2
src/types/index.d.ts

@ -10,5 +10,5 @@ export namespace WebSite {
export { IAcmeAccount } from './website/acme'
export { ICA, ISSLObtainByCA } from './website/ca'
export { IDnsAccount } from './website/dns'
export { ISSL, ProviderType, SSLSearchParam } from './website/ssl'
export { ISSL, ProviderType, SSLSearchParam, SSLUploadDto } from './website/ssl'
}

10
src/types/website/ssl.d.ts

@ -36,4 +36,14 @@ export type SSLSearchParam = {
prop?: string
}
export interface SSLUploadDto {
type: 'paste' | 'local'
private_key: string
private_key_path:string
pem: string
pem_path:string
description: string
}

1
vite.config.ts

@ -7,7 +7,6 @@ import jotaiReactRefresh from 'jotai/babel/plugin-react-refresh'
//import { TanStackRouterVite } from '@tanstack/router-vite-plugin'
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {

5
yarn.lock

@ -3521,6 +3521,11 @@ react-i18next@^14.1.0:
"@babel/runtime" "^7.23.9"
html-parse-stringify "^3.0.1"
react-if@^4.1.5:
version "4.1.5"
resolved "https://registry.npmmirror.com/react-if/-/react-if-4.1.5.tgz#f23f49277779e07240c61bdc7ab12671ff3fc20f"
integrity sha512-Uk+Ub2gC83PAakuU4+7iLdTEP4LPi2ihNEPCtz/vr8SLGbzkMApbpYbkDZ5z9zYXurd0gg+EK/bpOLFFC1r1eQ==
react-is@^16.12.0, react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1"
resolved "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"

Loading…
Cancel
Save