AccountManagement.jsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React from 'react';
  16. import {
  17. Button,
  18. Card,
  19. Input,
  20. Space,
  21. Typography,
  22. Avatar,
  23. Tabs,
  24. TabPane,
  25. Popover,
  26. Modal,
  27. } from '@douyinfe/semi-ui';
  28. import {
  29. IconMail,
  30. IconShield,
  31. IconGithubLogo,
  32. IconKey,
  33. IconLock,
  34. IconDelete,
  35. } from '@douyinfe/semi-icons';
  36. import { SiTelegram, SiWechat, SiLinux } from 'react-icons/si';
  37. import { UserPlus, ShieldCheck } from 'lucide-react';
  38. import TelegramLoginButton from 'react-telegram-login';
  39. import {
  40. onGitHubOAuthClicked,
  41. onOIDCClicked,
  42. onLinuxDOOAuthClicked,
  43. } from '../../../../helpers';
  44. import TwoFASetting from '../components/TwoFASetting';
  45. const AccountManagement = ({
  46. t,
  47. userState,
  48. status,
  49. systemToken,
  50. setShowEmailBindModal,
  51. setShowWeChatBindModal,
  52. generateAccessToken,
  53. handleSystemTokenClick,
  54. setShowChangePasswordModal,
  55. setShowAccountDeleteModal,
  56. passkeyStatus,
  57. passkeySupported,
  58. passkeyRegisterLoading,
  59. passkeyDeleteLoading,
  60. onPasskeyRegister,
  61. onPasskeyDelete,
  62. }) => {
  63. const renderAccountInfo = (accountId, label) => {
  64. if (!accountId || accountId === '') {
  65. return <span className='text-gray-500'>{t('未绑定')}</span>;
  66. }
  67. const popContent = (
  68. <div className='text-xs p-2'>
  69. <Typography.Paragraph copyable={{ content: accountId }}>
  70. {accountId}
  71. </Typography.Paragraph>
  72. {label ? (
  73. <div className='mt-1 text-[11px] text-gray-500'>{label}</div>
  74. ) : null}
  75. </div>
  76. );
  77. return (
  78. <Popover content={popContent} position='top' trigger='hover'>
  79. <span className='block max-w-full truncate text-gray-600 hover:text-blue-600 cursor-pointer'>
  80. {accountId}
  81. </span>
  82. </Popover>
  83. );
  84. };
  85. const isBound = (accountId) => Boolean(accountId);
  86. const [showTelegramBindModal, setShowTelegramBindModal] =
  87. React.useState(false);
  88. const passkeyEnabled = passkeyStatus?.enabled;
  89. const lastUsedLabel = passkeyStatus?.last_used_at
  90. ? new Date(passkeyStatus.last_used_at).toLocaleString()
  91. : t('尚未使用');
  92. return (
  93. <Card className='!rounded-2xl'>
  94. {/* 卡片头部 */}
  95. <div className='flex items-center mb-4'>
  96. <Avatar size='small' color='teal' className='mr-3 shadow-md'>
  97. <UserPlus size={16} />
  98. </Avatar>
  99. <div>
  100. <Typography.Text className='text-lg font-medium'>
  101. {t('账户管理')}
  102. </Typography.Text>
  103. <div className='text-xs text-gray-600'>
  104. {t('账户绑定、安全设置和身份验证')}
  105. </div>
  106. </div>
  107. </div>
  108. <Tabs type='card' defaultActiveKey='binding'>
  109. {/* 账户绑定 Tab */}
  110. <TabPane
  111. tab={
  112. <div className='flex items-center'>
  113. <UserPlus size={16} className='mr-2' />
  114. {t('账户绑定')}
  115. </div>
  116. }
  117. itemKey='binding'
  118. >
  119. <div className='py-4'>
  120. <div className='grid grid-cols-1 lg:grid-cols-2 gap-4'>
  121. {/* 邮箱绑定 */}
  122. <Card className='!rounded-xl'>
  123. <div className='flex items-center justify-between gap-3'>
  124. <div className='flex items-center flex-1 min-w-0'>
  125. <div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
  126. <IconMail
  127. size='default'
  128. className='text-slate-600 dark:text-slate-300'
  129. />
  130. </div>
  131. <div className='flex-1 min-w-0'>
  132. <div className='font-medium text-gray-900'>
  133. {t('邮箱')}
  134. </div>
  135. <div className='text-sm text-gray-500 truncate'>
  136. {renderAccountInfo(
  137. userState.user?.email,
  138. t('邮箱地址'),
  139. )}
  140. </div>
  141. </div>
  142. </div>
  143. <div className='flex-shrink-0'>
  144. <Button
  145. type='primary'
  146. theme='outline'
  147. size='small'
  148. onClick={() => setShowEmailBindModal(true)}
  149. >
  150. {isBound(userState.user?.email)
  151. ? t('修改绑定')
  152. : t('绑定')}
  153. </Button>
  154. </div>
  155. </div>
  156. </Card>
  157. {/* 微信绑定 */}
  158. <Card className='!rounded-xl'>
  159. <div className='flex items-center justify-between gap-3'>
  160. <div className='flex items-center flex-1 min-w-0'>
  161. <div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
  162. <SiWechat
  163. size={20}
  164. className='text-slate-600 dark:text-slate-300'
  165. />
  166. </div>
  167. <div className='flex-1 min-w-0'>
  168. <div className='font-medium text-gray-900'>
  169. {t('微信')}
  170. </div>
  171. <div className='text-sm text-gray-500 truncate'>
  172. {!status.wechat_login
  173. ? t('未启用')
  174. : isBound(userState.user?.wechat_id)
  175. ? t('已绑定')
  176. : t('未绑定')}
  177. </div>
  178. </div>
  179. </div>
  180. <div className='flex-shrink-0'>
  181. <Button
  182. type='primary'
  183. theme='outline'
  184. size='small'
  185. disabled={!status.wechat_login}
  186. onClick={() => setShowWeChatBindModal(true)}
  187. >
  188. {isBound(userState.user?.wechat_id)
  189. ? t('修改绑定')
  190. : status.wechat_login
  191. ? t('绑定')
  192. : t('未启用')}
  193. </Button>
  194. </div>
  195. </div>
  196. </Card>
  197. {/* GitHub绑定 */}
  198. <Card className='!rounded-xl'>
  199. <div className='flex items-center justify-between gap-3'>
  200. <div className='flex items-center flex-1 min-w-0'>
  201. <div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
  202. <IconGithubLogo
  203. size='default'
  204. className='text-slate-600 dark:text-slate-300'
  205. />
  206. </div>
  207. <div className='flex-1 min-w-0'>
  208. <div className='font-medium text-gray-900'>
  209. {t('GitHub')}
  210. </div>
  211. <div className='text-sm text-gray-500 truncate'>
  212. {renderAccountInfo(
  213. userState.user?.github_id,
  214. t('GitHub ID'),
  215. )}
  216. </div>
  217. </div>
  218. </div>
  219. <div className='flex-shrink-0'>
  220. <Button
  221. type='primary'
  222. theme='outline'
  223. size='small'
  224. onClick={() =>
  225. onGitHubOAuthClicked(status.github_client_id)
  226. }
  227. disabled={
  228. isBound(userState.user?.github_id) ||
  229. !status.github_oauth
  230. }
  231. >
  232. {status.github_oauth ? t('绑定') : t('未启用')}
  233. </Button>
  234. </div>
  235. </div>
  236. </Card>
  237. {/* OIDC绑定 */}
  238. <Card className='!rounded-xl'>
  239. <div className='flex items-center justify-between gap-3'>
  240. <div className='flex items-center flex-1 min-w-0'>
  241. <div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
  242. <IconShield
  243. size='default'
  244. className='text-slate-600 dark:text-slate-300'
  245. />
  246. </div>
  247. <div className='flex-1 min-w-0'>
  248. <div className='font-medium text-gray-900'>
  249. {t('OIDC')}
  250. </div>
  251. <div className='text-sm text-gray-500 truncate'>
  252. {renderAccountInfo(
  253. userState.user?.oidc_id,
  254. t('OIDC ID'),
  255. )}
  256. </div>
  257. </div>
  258. </div>
  259. <div className='flex-shrink-0'>
  260. <Button
  261. type='primary'
  262. theme='outline'
  263. size='small'
  264. onClick={() =>
  265. onOIDCClicked(
  266. status.oidc_authorization_endpoint,
  267. status.oidc_client_id,
  268. )
  269. }
  270. disabled={
  271. isBound(userState.user?.oidc_id) || !status.oidc_enabled
  272. }
  273. >
  274. {status.oidc_enabled ? t('绑定') : t('未启用')}
  275. </Button>
  276. </div>
  277. </div>
  278. </Card>
  279. {/* Telegram绑定 */}
  280. <Card className='!rounded-xl'>
  281. <div className='flex items-center justify-between gap-3'>
  282. <div className='flex items-center flex-1 min-w-0'>
  283. <div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
  284. <SiTelegram
  285. size={20}
  286. className='text-slate-600 dark:text-slate-300'
  287. />
  288. </div>
  289. <div className='flex-1 min-w-0'>
  290. <div className='font-medium text-gray-900'>
  291. {t('Telegram')}
  292. </div>
  293. <div className='text-sm text-gray-500 truncate'>
  294. {renderAccountInfo(
  295. userState.user?.telegram_id,
  296. t('Telegram ID'),
  297. )}
  298. </div>
  299. </div>
  300. </div>
  301. <div className='flex-shrink-0'>
  302. {status.telegram_oauth ? (
  303. isBound(userState.user?.telegram_id) ? (
  304. <Button
  305. disabled
  306. size='small'
  307. type='primary'
  308. theme='outline'
  309. >
  310. {t('已绑定')}
  311. </Button>
  312. ) : (
  313. <Button
  314. type='primary'
  315. theme='outline'
  316. size='small'
  317. onClick={() => setShowTelegramBindModal(true)}
  318. >
  319. {t('绑定')}
  320. </Button>
  321. )
  322. ) : (
  323. <Button
  324. disabled
  325. size='small'
  326. type='primary'
  327. theme='outline'
  328. >
  329. {t('未启用')}
  330. </Button>
  331. )}
  332. </div>
  333. </div>
  334. </Card>
  335. <Modal
  336. title={t('绑定 Telegram')}
  337. visible={showTelegramBindModal}
  338. onCancel={() => setShowTelegramBindModal(false)}
  339. footer={null}
  340. >
  341. <div className='my-3 text-sm text-gray-600'>
  342. {t('点击下方按钮通过 Telegram 完成绑定')}
  343. </div>
  344. <div className='flex justify-center'>
  345. <div className='scale-90'>
  346. <TelegramLoginButton
  347. dataAuthUrl='/api/oauth/telegram/bind'
  348. botName={status.telegram_bot_name}
  349. />
  350. </div>
  351. </div>
  352. </Modal>
  353. {/* LinuxDO绑定 */}
  354. <Card className='!rounded-xl'>
  355. <div className='flex items-center justify-between gap-3'>
  356. <div className='flex items-center flex-1 min-w-0'>
  357. <div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
  358. <SiLinux
  359. size={20}
  360. className='text-slate-600 dark:text-slate-300'
  361. />
  362. </div>
  363. <div className='flex-1 min-w-0'>
  364. <div className='font-medium text-gray-900'>
  365. {t('LinuxDO')}
  366. </div>
  367. <div className='text-sm text-gray-500 truncate'>
  368. {renderAccountInfo(
  369. userState.user?.linux_do_id,
  370. t('LinuxDO ID'),
  371. )}
  372. </div>
  373. </div>
  374. </div>
  375. <div className='flex-shrink-0'>
  376. <Button
  377. type='primary'
  378. theme='outline'
  379. size='small'
  380. onClick={() =>
  381. onLinuxDOOAuthClicked(status.linuxdo_client_id)
  382. }
  383. disabled={
  384. isBound(userState.user?.linux_do_id) ||
  385. !status.linuxdo_oauth
  386. }
  387. >
  388. {status.linuxdo_oauth ? t('绑定') : t('未启用')}
  389. </Button>
  390. </div>
  391. </div>
  392. </Card>
  393. </div>
  394. </div>
  395. </TabPane>
  396. {/* 安全设置 Tab */}
  397. <TabPane
  398. tab={
  399. <div className='flex items-center'>
  400. <ShieldCheck size={16} className='mr-2' />
  401. {t('安全设置')}
  402. </div>
  403. }
  404. itemKey='security'
  405. >
  406. <div className='py-4'>
  407. <div className='space-y-6'>
  408. <Space vertical className='w-full'>
  409. {/* 系统访问令牌 */}
  410. <Card className='!rounded-xl w-full'>
  411. <div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
  412. <div className='flex items-start w-full sm:w-auto'>
  413. <div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
  414. <IconKey size='large' className='text-slate-600' />
  415. </div>
  416. <div className='flex-1'>
  417. <Typography.Title heading={6} className='mb-1'>
  418. {t('系统访问令牌')}
  419. </Typography.Title>
  420. <Typography.Text type='tertiary' className='text-sm'>
  421. {t('用于API调用的身份验证令牌,请妥善保管')}
  422. </Typography.Text>
  423. {systemToken && (
  424. <div className='mt-3'>
  425. <Input
  426. readonly
  427. value={systemToken}
  428. onClick={handleSystemTokenClick}
  429. size='large'
  430. prefix={<IconKey />}
  431. />
  432. </div>
  433. )}
  434. </div>
  435. </div>
  436. <Button
  437. type='primary'
  438. theme='solid'
  439. onClick={generateAccessToken}
  440. className='!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto'
  441. icon={<IconKey />}
  442. >
  443. {systemToken ? t('重新生成') : t('生成令牌')}
  444. </Button>
  445. </div>
  446. </Card>
  447. {/* 密码管理 */}
  448. <Card className='!rounded-xl w-full'>
  449. <div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
  450. <div className='flex items-start w-full sm:w-auto'>
  451. <div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
  452. <IconLock size='large' className='text-slate-600' />
  453. </div>
  454. <div>
  455. <Typography.Title heading={6} className='mb-1'>
  456. {t('密码管理')}
  457. </Typography.Title>
  458. <Typography.Text type='tertiary' className='text-sm'>
  459. {t('定期更改密码可以提高账户安全性')}
  460. </Typography.Text>
  461. </div>
  462. </div>
  463. <Button
  464. type='primary'
  465. theme='solid'
  466. onClick={() => setShowChangePasswordModal(true)}
  467. className='!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto'
  468. icon={<IconLock />}
  469. >
  470. {t('修改密码')}
  471. </Button>
  472. </div>
  473. </Card>
  474. {/* Passkey 设置 */}
  475. <Card className='!rounded-xl w-full'>
  476. <div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
  477. <div className='flex items-start w-full sm:w-auto'>
  478. <div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
  479. <IconKey size='large' className='text-slate-600' />
  480. </div>
  481. <div>
  482. <Typography.Title heading={6} className='mb-1'>
  483. {t('Passkey 登录')}
  484. </Typography.Title>
  485. <Typography.Text type='tertiary' className='text-sm'>
  486. {passkeyEnabled
  487. ? t('已启用 Passkey,无需密码即可登录')
  488. : t('使用 Passkey 实现免密且更安全的登录体验')}
  489. </Typography.Text>
  490. <div className='mt-2 text-xs text-gray-500 space-y-1'>
  491. <div>
  492. {t('最后使用时间')}:{lastUsedLabel}
  493. </div>
  494. {/*{passkeyEnabled && (*/}
  495. {/* <div>*/}
  496. {/* {t('备份支持')}:*/}
  497. {/* {passkeyStatus?.backup_eligible*/}
  498. {/* ? t('支持备份')*/}
  499. {/* : t('不支持')}*/}
  500. {/* ,{t('备份状态')}:*/}
  501. {/* {passkeyStatus?.backup_state ? t('已备份') : t('未备份')}*/}
  502. {/* </div>*/}
  503. {/*)}*/}
  504. {!passkeySupported && (
  505. <div className='text-amber-600'>
  506. {t('当前设备不支持 Passkey')}
  507. </div>
  508. )}
  509. </div>
  510. </div>
  511. </div>
  512. <Button
  513. type={passkeyEnabled ? 'danger' : 'primary'}
  514. theme={passkeyEnabled ? 'solid' : 'solid'}
  515. onClick={
  516. passkeyEnabled
  517. ? () => {
  518. Modal.confirm({
  519. title: t('确认解绑 Passkey'),
  520. content: t('解绑后将无法使用 Passkey 登录,确定要继续吗?'),
  521. okText: t('确认解绑'),
  522. cancelText: t('取消'),
  523. okType: 'danger',
  524. onOk: onPasskeyDelete,
  525. });
  526. }
  527. : onPasskeyRegister
  528. }
  529. className={`w-full sm:w-auto ${passkeyEnabled ? '!bg-slate-500 hover:!bg-slate-600' : ''}`}
  530. icon={<IconKey />}
  531. disabled={!passkeySupported && !passkeyEnabled}
  532. loading={passkeyEnabled ? passkeyDeleteLoading : passkeyRegisterLoading}
  533. >
  534. {passkeyEnabled ? t('解绑 Passkey') : t('注册 Passkey')}
  535. </Button>
  536. </div>
  537. </Card>
  538. {/* 两步验证设置 */}
  539. <TwoFASetting t={t} />
  540. {/* 危险区域 */}
  541. <Card className='!rounded-xl w-full'>
  542. <div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
  543. <div className='flex items-start w-full sm:w-auto'>
  544. <div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
  545. <IconDelete size='large' className='text-slate-600' />
  546. </div>
  547. <div>
  548. <Typography.Title
  549. heading={6}
  550. className='mb-1 text-slate-700'
  551. >
  552. {t('删除账户')}
  553. </Typography.Title>
  554. <Typography.Text type='tertiary' className='text-sm'>
  555. {t('此操作不可逆,所有数据将被永久删除')}
  556. </Typography.Text>
  557. </div>
  558. </div>
  559. <Button
  560. type='danger'
  561. theme='solid'
  562. onClick={() => setShowAccountDeleteModal(true)}
  563. className='w-full sm:w-auto !bg-slate-500 hover:!bg-slate-600'
  564. icon={<IconDelete />}
  565. >
  566. {t('删除账户')}
  567. </Button>
  568. </div>
  569. </Card>
  570. </Space>
  571. </div>
  572. </div>
  573. </TabPane>
  574. </Tabs>
  575. </Card>
  576. );
  577. };
  578. export default AccountManagement;