AccountManagement.jsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  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] = React.useState(false);
  87. const passkeyEnabled = passkeyStatus?.enabled;
  88. const lastUsedLabel = passkeyStatus?.last_used_at
  89. ? new Date(passkeyStatus.last_used_at).toLocaleString()
  90. : t('尚未使用');
  91. return (
  92. <Card className='!rounded-2xl'>
  93. {/* 卡片头部 */}
  94. <div className='flex items-center mb-4'>
  95. <Avatar size='small' color='teal' className='mr-3 shadow-md'>
  96. <UserPlus size={16} />
  97. </Avatar>
  98. <div>
  99. <Typography.Text className='text-lg font-medium'>
  100. {t('账户管理')}
  101. </Typography.Text>
  102. <div className='text-xs text-gray-600'>
  103. {t('账户绑定、安全设置和身份验证')}
  104. </div>
  105. </div>
  106. </div>
  107. <Tabs type='card' defaultActiveKey='binding'>
  108. {/* 账户绑定 Tab */}
  109. <TabPane
  110. tab={
  111. <div className='flex items-center'>
  112. <UserPlus size={16} className='mr-2' />
  113. {t('账户绑定')}
  114. </div>
  115. }
  116. itemKey='binding'
  117. >
  118. <div className='py-4'>
  119. <div className='grid grid-cols-1 lg:grid-cols-2 gap-4'>
  120. {/* 邮箱绑定 */}
  121. <Card className='!rounded-xl'>
  122. <div className='flex items-center justify-between gap-3'>
  123. <div className='flex items-center flex-1 min-w-0'>
  124. <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'>
  125. <IconMail
  126. size='default'
  127. className='text-slate-600 dark:text-slate-300'
  128. />
  129. </div>
  130. <div className='flex-1 min-w-0'>
  131. <div className='font-medium text-gray-900'>
  132. {t('邮箱')}
  133. </div>
  134. <div className='text-sm text-gray-500 truncate'>
  135. {renderAccountInfo(
  136. userState.user?.email,
  137. t('邮箱地址'),
  138. )}
  139. </div>
  140. </div>
  141. </div>
  142. <div className='flex-shrink-0'>
  143. <Button
  144. type='primary'
  145. theme='outline'
  146. size='small'
  147. onClick={() => setShowEmailBindModal(true)}
  148. >
  149. {isBound(userState.user?.email)
  150. ? t('修改绑定')
  151. : t('绑定')}
  152. </Button>
  153. </div>
  154. </div>
  155. </Card>
  156. {/* 微信绑定 */}
  157. <Card className='!rounded-xl'>
  158. <div className='flex items-center justify-between gap-3'>
  159. <div className='flex items-center flex-1 min-w-0'>
  160. <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'>
  161. <SiWechat
  162. size={20}
  163. className='text-slate-600 dark:text-slate-300'
  164. />
  165. </div>
  166. <div className='flex-1 min-w-0'>
  167. <div className='font-medium text-gray-900'>
  168. {t('微信')}
  169. </div>
  170. <div className='text-sm text-gray-500 truncate'>
  171. {!status.wechat_login
  172. ? t('未启用')
  173. : isBound(userState.user?.wechat_id)
  174. ? t('已绑定')
  175. : t('未绑定')}
  176. </div>
  177. </div>
  178. </div>
  179. <div className='flex-shrink-0'>
  180. <Button
  181. type='primary'
  182. theme='outline'
  183. size='small'
  184. disabled={!status.wechat_login}
  185. onClick={() => setShowWeChatBindModal(true)}
  186. >
  187. {isBound(userState.user?.wechat_id)
  188. ? t('修改绑定')
  189. : status.wechat_login
  190. ? t('绑定')
  191. : t('未启用')}
  192. </Button>
  193. </div>
  194. </div>
  195. </Card>
  196. {/* GitHub绑定 */}
  197. <Card className='!rounded-xl'>
  198. <div className='flex items-center justify-between gap-3'>
  199. <div className='flex items-center flex-1 min-w-0'>
  200. <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'>
  201. <IconGithubLogo
  202. size='default'
  203. className='text-slate-600 dark:text-slate-300'
  204. />
  205. </div>
  206. <div className='flex-1 min-w-0'>
  207. <div className='font-medium text-gray-900'>
  208. {t('GitHub')}
  209. </div>
  210. <div className='text-sm text-gray-500 truncate'>
  211. {renderAccountInfo(
  212. userState.user?.github_id,
  213. t('GitHub ID'),
  214. )}
  215. </div>
  216. </div>
  217. </div>
  218. <div className='flex-shrink-0'>
  219. <Button
  220. type='primary'
  221. theme='outline'
  222. size='small'
  223. onClick={() =>
  224. onGitHubOAuthClicked(status.github_client_id)
  225. }
  226. disabled={
  227. isBound(userState.user?.github_id) || !status.github_oauth
  228. }
  229. >
  230. {status.github_oauth ? t('绑定') : t('未启用')}
  231. </Button>
  232. </div>
  233. </div>
  234. </Card>
  235. {/* OIDC绑定 */}
  236. <Card className='!rounded-xl'>
  237. <div className='flex items-center justify-between gap-3'>
  238. <div className='flex items-center flex-1 min-w-0'>
  239. <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'>
  240. <IconShield
  241. size='default'
  242. className='text-slate-600 dark:text-slate-300'
  243. />
  244. </div>
  245. <div className='flex-1 min-w-0'>
  246. <div className='font-medium text-gray-900'>
  247. {t('OIDC')}
  248. </div>
  249. <div className='text-sm text-gray-500 truncate'>
  250. {renderAccountInfo(
  251. userState.user?.oidc_id,
  252. t('OIDC ID'),
  253. )}
  254. </div>
  255. </div>
  256. </div>
  257. <div className='flex-shrink-0'>
  258. <Button
  259. type='primary'
  260. theme='outline'
  261. size='small'
  262. onClick={() =>
  263. onOIDCClicked(
  264. status.oidc_authorization_endpoint,
  265. status.oidc_client_id,
  266. )
  267. }
  268. disabled={
  269. isBound(userState.user?.oidc_id) || !status.oidc_enabled
  270. }
  271. >
  272. {status.oidc_enabled ? t('绑定') : t('未启用')}
  273. </Button>
  274. </div>
  275. </div>
  276. </Card>
  277. {/* Telegram绑定 */}
  278. <Card className='!rounded-xl'>
  279. <div className='flex items-center justify-between gap-3'>
  280. <div className='flex items-center flex-1 min-w-0'>
  281. <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'>
  282. <SiTelegram
  283. size={20}
  284. className='text-slate-600 dark:text-slate-300'
  285. />
  286. </div>
  287. <div className='flex-1 min-w-0'>
  288. <div className='font-medium text-gray-900'>
  289. {t('Telegram')}
  290. </div>
  291. <div className='text-sm text-gray-500 truncate'>
  292. {renderAccountInfo(
  293. userState.user?.telegram_id,
  294. t('Telegram ID'),
  295. )}
  296. </div>
  297. </div>
  298. </div>
  299. <div className='flex-shrink-0'>
  300. {status.telegram_oauth ? (
  301. isBound(userState.user?.telegram_id) ? (
  302. <Button
  303. disabled
  304. size='small'
  305. type='primary'
  306. theme='outline'
  307. >
  308. {t('已绑定')}
  309. </Button>
  310. ) : (
  311. <Button
  312. type='primary'
  313. theme='outline'
  314. size='small'
  315. onClick={() => setShowTelegramBindModal(true)}
  316. >
  317. {t('绑定')}
  318. </Button>
  319. )
  320. ) : (
  321. <Button
  322. disabled
  323. size='small'
  324. type='primary'
  325. theme='outline'
  326. >
  327. {t('未启用')}
  328. </Button>
  329. )}
  330. </div>
  331. </div>
  332. </Card>
  333. <Modal
  334. title={t('绑定 Telegram')}
  335. visible={showTelegramBindModal}
  336. onCancel={() => setShowTelegramBindModal(false)}
  337. footer={null}
  338. >
  339. <div className='my-3 text-sm text-gray-600'>
  340. {t('点击下方按钮通过 Telegram 完成绑定')}
  341. </div>
  342. <div className='flex justify-center'>
  343. <div className='scale-90'>
  344. <TelegramLoginButton
  345. dataAuthUrl='/api/oauth/telegram/bind'
  346. botName={status.telegram_bot_name}
  347. />
  348. </div>
  349. </div>
  350. </Modal>
  351. {/* LinuxDO绑定 */}
  352. <Card className='!rounded-xl'>
  353. <div className='flex items-center justify-between gap-3'>
  354. <div className='flex items-center flex-1 min-w-0'>
  355. <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'>
  356. <SiLinux
  357. size={20}
  358. className='text-slate-600 dark:text-slate-300'
  359. />
  360. </div>
  361. <div className='flex-1 min-w-0'>
  362. <div className='font-medium text-gray-900'>
  363. {t('LinuxDO')}
  364. </div>
  365. <div className='text-sm text-gray-500 truncate'>
  366. {renderAccountInfo(
  367. userState.user?.linux_do_id,
  368. t('LinuxDO ID'),
  369. )}
  370. </div>
  371. </div>
  372. </div>
  373. <div className='flex-shrink-0'>
  374. <Button
  375. type='primary'
  376. theme='outline'
  377. size='small'
  378. onClick={() =>
  379. onLinuxDOOAuthClicked(status.linuxdo_client_id)
  380. }
  381. disabled={
  382. isBound(userState.user?.linux_do_id) || !status.linuxdo_oauth
  383. }
  384. >
  385. {status.linuxdo_oauth ? t('绑定') : t('未启用')}
  386. </Button>
  387. </div>
  388. </div>
  389. </Card>
  390. </div>
  391. </div>
  392. </TabPane>
  393. {/* 安全设置 Tab */}
  394. <TabPane
  395. tab={
  396. <div className='flex items-center'>
  397. <ShieldCheck size={16} className='mr-2' />
  398. {t('安全设置')}
  399. </div>
  400. }
  401. itemKey='security'
  402. >
  403. <div className='py-4'>
  404. <div className='space-y-6'>
  405. <Space vertical className='w-full'>
  406. {/* 系统访问令牌 */}
  407. <Card className='!rounded-xl w-full'>
  408. <div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
  409. <div className='flex items-start w-full sm:w-auto'>
  410. <div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
  411. <IconKey size='large' className='text-slate-600' />
  412. </div>
  413. <div className='flex-1'>
  414. <Typography.Title heading={6} className='mb-1'>
  415. {t('系统访问令牌')}
  416. </Typography.Title>
  417. <Typography.Text type='tertiary' className='text-sm'>
  418. {t('用于API调用的身份验证令牌,请妥善保管')}
  419. </Typography.Text>
  420. {systemToken && (
  421. <div className='mt-3'>
  422. <Input
  423. readonly
  424. value={systemToken}
  425. onClick={handleSystemTokenClick}
  426. size='large'
  427. prefix={<IconKey />}
  428. />
  429. </div>
  430. )}
  431. </div>
  432. </div>
  433. <Button
  434. type='primary'
  435. theme='solid'
  436. onClick={generateAccessToken}
  437. className='!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto'
  438. icon={<IconKey />}
  439. >
  440. {systemToken ? t('重新生成') : t('生成令牌')}
  441. </Button>
  442. </div>
  443. </Card>
  444. {/* 密码管理 */}
  445. <Card className='!rounded-xl w-full'>
  446. <div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
  447. <div className='flex items-start w-full sm:w-auto'>
  448. <div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
  449. <IconLock size='large' className='text-slate-600' />
  450. </div>
  451. <div>
  452. <Typography.Title heading={6} className='mb-1'>
  453. {t('密码管理')}
  454. </Typography.Title>
  455. <Typography.Text type='tertiary' className='text-sm'>
  456. {t('定期更改密码可以提高账户安全性')}
  457. </Typography.Text>
  458. </div>
  459. </div>
  460. <Button
  461. type='primary'
  462. theme='solid'
  463. onClick={() => setShowChangePasswordModal(true)}
  464. className='!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto'
  465. icon={<IconLock />}
  466. >
  467. {t('修改密码')}
  468. </Button>
  469. </div>
  470. </Card>
  471. {/* Passkey 设置 */}
  472. <Card className='!rounded-xl w-full'>
  473. <div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
  474. <div className='flex items-start w-full sm:w-auto'>
  475. <div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
  476. <IconKey size='large' className='text-slate-600' />
  477. </div>
  478. <div>
  479. <Typography.Title heading={6} className='mb-1'>
  480. {t('Passkey 登录')}
  481. </Typography.Title>
  482. <Typography.Text type='tertiary' className='text-sm'>
  483. {passkeyEnabled
  484. ? t('已启用 Passkey,无需密码即可登录')
  485. : t('使用 Passkey 实现免密且更安全的登录体验')}
  486. </Typography.Text>
  487. <div className='mt-2 text-xs text-gray-500 space-y-1'>
  488. <div>
  489. {t('最后使用时间')}:{lastUsedLabel}
  490. </div>
  491. {/*{passkeyEnabled && (*/}
  492. {/* <div>*/}
  493. {/* {t('备份支持')}:*/}
  494. {/* {passkeyStatus?.backup_eligible*/}
  495. {/* ? t('支持备份')*/}
  496. {/* : t('不支持')}*/}
  497. {/* ,{t('备份状态')}:*/}
  498. {/* {passkeyStatus?.backup_state ? t('已备份') : t('未备份')}*/}
  499. {/* </div>*/}
  500. {/*)}*/}
  501. {!passkeySupported && (
  502. <div className='text-amber-600'>
  503. {t('当前设备不支持 Passkey')}
  504. </div>
  505. )}
  506. </div>
  507. </div>
  508. </div>
  509. <Button
  510. type='primary'
  511. theme={passkeyEnabled ? 'outline' : 'solid'}
  512. onClick={passkeyEnabled ? onPasskeyDelete : onPasskeyRegister}
  513. className='w-full sm:w-auto'
  514. icon={<IconKey />}
  515. disabled={!passkeySupported && !passkeyEnabled}
  516. loading={passkeyEnabled ? passkeyDeleteLoading : passkeyRegisterLoading}
  517. >
  518. {passkeyEnabled ? t('解绑 Passkey') : t('注册 Passkey')}
  519. </Button>
  520. </div>
  521. </Card>
  522. {/* 两步验证设置 */}
  523. <TwoFASetting t={t} />
  524. {/* 危险区域 */}
  525. <Card className='!rounded-xl w-full'>
  526. <div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
  527. <div className='flex items-start w-full sm:w-auto'>
  528. <div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
  529. <IconDelete size='large' className='text-slate-600' />
  530. </div>
  531. <div>
  532. <Typography.Title
  533. heading={6}
  534. className='mb-1 text-slate-700'
  535. >
  536. {t('删除账户')}
  537. </Typography.Title>
  538. <Typography.Text type='tertiary' className='text-sm'>
  539. {t('此操作不可逆,所有数据将被永久删除')}
  540. </Typography.Text>
  541. </div>
  542. </div>
  543. <Button
  544. type='danger'
  545. theme='solid'
  546. onClick={() => setShowAccountDeleteModal(true)}
  547. className='w-full sm:w-auto !bg-slate-500 hover:!bg-slate-600'
  548. icon={<IconDelete />}
  549. >
  550. {t('删除账户')}
  551. </Button>
  552. </div>
  553. </Card>
  554. </Space>
  555. </div>
  556. </div>
  557. </TabPane>
  558. </Tabs>
  559. </Card>
  560. );
  561. };
  562. export default AccountManagement;