AccountManagement.jsx 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774
  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, SiDiscord } from 'react-icons/si';
  37. import { UserPlus, ShieldCheck } from 'lucide-react';
  38. import TelegramLoginButton from 'react-telegram-login';
  39. import {
  40. API,
  41. showError,
  42. showSuccess,
  43. onGitHubOAuthClicked,
  44. onOIDCClicked,
  45. onLinuxDOOAuthClicked,
  46. onDiscordOAuthClicked,
  47. onCustomOAuthClicked,
  48. getOAuthProviderIcon,
  49. } from '../../../../helpers';
  50. import TwoFASetting from '../components/TwoFASetting';
  51. const AccountManagement = ({
  52. t,
  53. userState,
  54. status,
  55. systemToken,
  56. setShowEmailBindModal,
  57. setShowWeChatBindModal,
  58. generateAccessToken,
  59. handleSystemTokenClick,
  60. setShowChangePasswordModal,
  61. setShowAccountDeleteModal,
  62. passkeyStatus,
  63. passkeySupported,
  64. passkeyRegisterLoading,
  65. passkeyDeleteLoading,
  66. onPasskeyRegister,
  67. onPasskeyDelete,
  68. }) => {
  69. const renderAccountInfo = (accountId, label) => {
  70. if (!accountId || accountId === '') {
  71. return <span className='text-gray-500'>{t('未绑定')}</span>;
  72. }
  73. const popContent = (
  74. <div className='text-xs p-2'>
  75. <Typography.Paragraph copyable={{ content: accountId }}>
  76. {accountId}
  77. </Typography.Paragraph>
  78. {label ? (
  79. <div className='mt-1 text-[11px] text-gray-500'>{label}</div>
  80. ) : null}
  81. </div>
  82. );
  83. return (
  84. <Popover content={popContent} position='top' trigger='hover'>
  85. <span className='block max-w-full truncate text-gray-600 hover:text-blue-600 cursor-pointer'>
  86. {accountId}
  87. </span>
  88. </Popover>
  89. );
  90. };
  91. const isBound = (accountId) => Boolean(accountId);
  92. const [showTelegramBindModal, setShowTelegramBindModal] =
  93. React.useState(false);
  94. const [customOAuthBindings, setCustomOAuthBindings] = React.useState([]);
  95. const [customOAuthLoading, setCustomOAuthLoading] = React.useState({});
  96. // Fetch custom OAuth bindings
  97. const loadCustomOAuthBindings = async () => {
  98. try {
  99. const res = await API.get('/api/user/oauth/bindings');
  100. if (res.data.success) {
  101. setCustomOAuthBindings(res.data.data || []);
  102. } else {
  103. showError(res.data.message || t('获取绑定信息失败'));
  104. }
  105. } catch (error) {
  106. showError(error.response?.data?.message || error.message || t('获取绑定信息失败'));
  107. }
  108. };
  109. // Unbind custom OAuth provider
  110. const handleUnbindCustomOAuth = async (providerId, providerName) => {
  111. Modal.confirm({
  112. title: t('确认解绑'),
  113. content: t('确定要解绑 {{name}} 吗?', { name: providerName }),
  114. okText: t('确认'),
  115. cancelText: t('取消'),
  116. onOk: async () => {
  117. setCustomOAuthLoading((prev) => ({ ...prev, [providerId]: true }));
  118. try {
  119. const res = await API.delete(`/api/user/oauth/bindings/${providerId}`);
  120. if (res.data.success) {
  121. showSuccess(t('解绑成功'));
  122. await loadCustomOAuthBindings();
  123. } else {
  124. showError(res.data.message);
  125. }
  126. } catch (error) {
  127. showError(error.response?.data?.message || error.message || t('操作失败'));
  128. } finally {
  129. setCustomOAuthLoading((prev) => ({ ...prev, [providerId]: false }));
  130. }
  131. },
  132. });
  133. };
  134. // Handle bind custom OAuth
  135. const handleBindCustomOAuth = (provider) => {
  136. onCustomOAuthClicked(provider);
  137. };
  138. // Check if custom OAuth provider is bound
  139. const isCustomOAuthBound = (providerId) => {
  140. const normalizedId = Number(providerId);
  141. return customOAuthBindings.some((b) => Number(b.provider_id) === normalizedId);
  142. };
  143. // Get binding info for a provider
  144. const getCustomOAuthBinding = (providerId) => {
  145. const normalizedId = Number(providerId);
  146. return customOAuthBindings.find((b) => Number(b.provider_id) === normalizedId);
  147. };
  148. React.useEffect(() => {
  149. loadCustomOAuthBindings();
  150. }, []);
  151. const passkeyEnabled = passkeyStatus?.enabled;
  152. const lastUsedLabel = passkeyStatus?.last_used_at
  153. ? new Date(passkeyStatus.last_used_at).toLocaleString()
  154. : t('尚未使用');
  155. return (
  156. <Card className='!rounded-2xl'>
  157. {/* 卡片头部 */}
  158. <div className='flex items-center mb-4'>
  159. <Avatar size='small' color='teal' className='mr-3 shadow-md'>
  160. <UserPlus size={16} />
  161. </Avatar>
  162. <div>
  163. <Typography.Text className='text-lg font-medium'>
  164. {t('账户管理')}
  165. </Typography.Text>
  166. <div className='text-xs text-gray-600'>
  167. {t('账户绑定、安全设置和身份验证')}
  168. </div>
  169. </div>
  170. </div>
  171. <Tabs type='card' defaultActiveKey='binding'>
  172. {/* 账户绑定 Tab */}
  173. <TabPane
  174. tab={
  175. <div className='flex items-center'>
  176. <UserPlus size={16} className='mr-2' />
  177. {t('账户绑定')}
  178. </div>
  179. }
  180. itemKey='binding'
  181. >
  182. <div className='py-4'>
  183. <div className='grid grid-cols-1 lg:grid-cols-2 gap-4'>
  184. {/* 邮箱绑定 */}
  185. <Card className='!rounded-xl'>
  186. <div className='flex items-center justify-between gap-3'>
  187. <div className='flex items-center flex-1 min-w-0'>
  188. <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'>
  189. <IconMail
  190. size='default'
  191. className='text-slate-600 dark:text-slate-300'
  192. />
  193. </div>
  194. <div className='flex-1 min-w-0'>
  195. <div className='font-medium text-gray-900'>
  196. {t('邮箱')}
  197. </div>
  198. <div className='text-sm text-gray-500 truncate'>
  199. {renderAccountInfo(
  200. userState.user?.email,
  201. t('邮箱地址'),
  202. )}
  203. </div>
  204. </div>
  205. </div>
  206. <div className='flex-shrink-0'>
  207. <Button
  208. type='primary'
  209. theme='outline'
  210. size='small'
  211. onClick={() => setShowEmailBindModal(true)}
  212. >
  213. {isBound(userState.user?.email)
  214. ? t('修改绑定')
  215. : t('绑定')}
  216. </Button>
  217. </div>
  218. </div>
  219. </Card>
  220. {/* 微信绑定 */}
  221. <Card className='!rounded-xl'>
  222. <div className='flex items-center justify-between gap-3'>
  223. <div className='flex items-center flex-1 min-w-0'>
  224. <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'>
  225. <SiWechat
  226. size={20}
  227. className='text-slate-600 dark:text-slate-300'
  228. />
  229. </div>
  230. <div className='flex-1 min-w-0'>
  231. <div className='font-medium text-gray-900'>
  232. {t('微信')}
  233. </div>
  234. <div className='text-sm text-gray-500 truncate'>
  235. {!status.wechat_login
  236. ? t('未启用')
  237. : isBound(userState.user?.wechat_id)
  238. ? t('已绑定')
  239. : t('未绑定')}
  240. </div>
  241. </div>
  242. </div>
  243. <div className='flex-shrink-0'>
  244. <Button
  245. type='primary'
  246. theme='outline'
  247. size='small'
  248. disabled={!status.wechat_login}
  249. onClick={() => setShowWeChatBindModal(true)}
  250. >
  251. {isBound(userState.user?.wechat_id)
  252. ? t('修改绑定')
  253. : status.wechat_login
  254. ? t('绑定')
  255. : t('未启用')}
  256. </Button>
  257. </div>
  258. </div>
  259. </Card>
  260. {/* GitHub绑定 */}
  261. <Card className='!rounded-xl'>
  262. <div className='flex items-center justify-between gap-3'>
  263. <div className='flex items-center flex-1 min-w-0'>
  264. <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'>
  265. <IconGithubLogo
  266. size='default'
  267. className='text-slate-600 dark:text-slate-300'
  268. />
  269. </div>
  270. <div className='flex-1 min-w-0'>
  271. <div className='font-medium text-gray-900'>
  272. {t('GitHub')}
  273. </div>
  274. <div className='text-sm text-gray-500 truncate'>
  275. {renderAccountInfo(
  276. userState.user?.github_id,
  277. t('GitHub ID'),
  278. )}
  279. </div>
  280. </div>
  281. </div>
  282. <div className='flex-shrink-0'>
  283. <Button
  284. type='primary'
  285. theme='outline'
  286. size='small'
  287. onClick={() =>
  288. onGitHubOAuthClicked(status.github_client_id)
  289. }
  290. disabled={
  291. isBound(userState.user?.github_id) ||
  292. !status.github_oauth
  293. }
  294. >
  295. {status.github_oauth ? t('绑定') : t('未启用')}
  296. </Button>
  297. </div>
  298. </div>
  299. </Card>
  300. {/* Discord绑定 */}
  301. <Card className='!rounded-xl'>
  302. <div className='flex items-center justify-between gap-3'>
  303. <div className='flex items-center flex-1 min-w-0'>
  304. <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'>
  305. <SiDiscord
  306. size={20}
  307. className='text-slate-600 dark:text-slate-300'
  308. />
  309. </div>
  310. <div className='flex-1 min-w-0'>
  311. <div className='font-medium text-gray-900'>
  312. {t('Discord')}
  313. </div>
  314. <div className='text-sm text-gray-500 truncate'>
  315. {renderAccountInfo(
  316. userState.user?.discord_id,
  317. t('Discord ID'),
  318. )}
  319. </div>
  320. </div>
  321. </div>
  322. <div className='flex-shrink-0'>
  323. <Button
  324. type='primary'
  325. theme='outline'
  326. size='small'
  327. onClick={() =>
  328. onDiscordOAuthClicked(status.discord_client_id)
  329. }
  330. disabled={
  331. isBound(userState.user?.discord_id) ||
  332. !status.discord_oauth
  333. }
  334. >
  335. {status.discord_oauth ? t('绑定') : t('未启用')}
  336. </Button>
  337. </div>
  338. </div>
  339. </Card>
  340. {/* OIDC绑定 */}
  341. <Card className='!rounded-xl'>
  342. <div className='flex items-center justify-between gap-3'>
  343. <div className='flex items-center flex-1 min-w-0'>
  344. <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'>
  345. <IconShield
  346. size='default'
  347. className='text-slate-600 dark:text-slate-300'
  348. />
  349. </div>
  350. <div className='flex-1 min-w-0'>
  351. <div className='font-medium text-gray-900'>
  352. {t('OIDC')}
  353. </div>
  354. <div className='text-sm text-gray-500 truncate'>
  355. {renderAccountInfo(
  356. userState.user?.oidc_id,
  357. t('OIDC ID'),
  358. )}
  359. </div>
  360. </div>
  361. </div>
  362. <div className='flex-shrink-0'>
  363. <Button
  364. type='primary'
  365. theme='outline'
  366. size='small'
  367. onClick={() =>
  368. onOIDCClicked(
  369. status.oidc_authorization_endpoint,
  370. status.oidc_client_id,
  371. )
  372. }
  373. disabled={
  374. isBound(userState.user?.oidc_id) || !status.oidc_enabled
  375. }
  376. >
  377. {status.oidc_enabled ? t('绑定') : t('未启用')}
  378. </Button>
  379. </div>
  380. </div>
  381. </Card>
  382. {/* Telegram绑定 */}
  383. <Card className='!rounded-xl'>
  384. <div className='flex items-center justify-between gap-3'>
  385. <div className='flex items-center flex-1 min-w-0'>
  386. <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'>
  387. <SiTelegram
  388. size={20}
  389. className='text-slate-600 dark:text-slate-300'
  390. />
  391. </div>
  392. <div className='flex-1 min-w-0'>
  393. <div className='font-medium text-gray-900'>
  394. {t('Telegram')}
  395. </div>
  396. <div className='text-sm text-gray-500 truncate'>
  397. {renderAccountInfo(
  398. userState.user?.telegram_id,
  399. t('Telegram ID'),
  400. )}
  401. </div>
  402. </div>
  403. </div>
  404. <div className='flex-shrink-0'>
  405. {status.telegram_oauth ? (
  406. isBound(userState.user?.telegram_id) ? (
  407. <Button
  408. disabled
  409. size='small'
  410. type='primary'
  411. theme='outline'
  412. >
  413. {t('已绑定')}
  414. </Button>
  415. ) : (
  416. <Button
  417. type='primary'
  418. theme='outline'
  419. size='small'
  420. onClick={() => setShowTelegramBindModal(true)}
  421. >
  422. {t('绑定')}
  423. </Button>
  424. )
  425. ) : (
  426. <Button
  427. disabled
  428. size='small'
  429. type='primary'
  430. theme='outline'
  431. >
  432. {t('未启用')}
  433. </Button>
  434. )}
  435. </div>
  436. </div>
  437. </Card>
  438. <Modal
  439. title={t('绑定 Telegram')}
  440. visible={showTelegramBindModal}
  441. onCancel={() => setShowTelegramBindModal(false)}
  442. footer={null}
  443. >
  444. <div className='my-3 text-sm text-gray-600'>
  445. {t('点击下方按钮通过 Telegram 完成绑定')}
  446. </div>
  447. <div className='flex justify-center'>
  448. <div className='scale-90'>
  449. <TelegramLoginButton
  450. dataAuthUrl='/api/oauth/telegram/bind'
  451. botName={status.telegram_bot_name}
  452. />
  453. </div>
  454. </div>
  455. </Modal>
  456. {/* LinuxDO绑定 */}
  457. <Card className='!rounded-xl'>
  458. <div className='flex items-center justify-between gap-3'>
  459. <div className='flex items-center flex-1 min-w-0'>
  460. <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'>
  461. <SiLinux
  462. size={20}
  463. className='text-slate-600 dark:text-slate-300'
  464. />
  465. </div>
  466. <div className='flex-1 min-w-0'>
  467. <div className='font-medium text-gray-900'>
  468. {t('LinuxDO')}
  469. </div>
  470. <div className='text-sm text-gray-500 truncate'>
  471. {renderAccountInfo(
  472. userState.user?.linux_do_id,
  473. t('LinuxDO ID'),
  474. )}
  475. </div>
  476. </div>
  477. </div>
  478. <div className='flex-shrink-0'>
  479. <Button
  480. type='primary'
  481. theme='outline'
  482. size='small'
  483. onClick={() =>
  484. onLinuxDOOAuthClicked(status.linuxdo_client_id)
  485. }
  486. disabled={
  487. isBound(userState.user?.linux_do_id) ||
  488. !status.linuxdo_oauth
  489. }
  490. >
  491. {status.linuxdo_oauth ? t('绑定') : t('未启用')}
  492. </Button>
  493. </div>
  494. </div>
  495. </Card>
  496. {/* 自定义 OAuth 提供商绑定 */}
  497. {status.custom_oauth_providers &&
  498. status.custom_oauth_providers.map((provider) => {
  499. const bound = isCustomOAuthBound(provider.id);
  500. const binding = getCustomOAuthBinding(provider.id);
  501. return (
  502. <Card key={provider.slug} className='!rounded-xl'>
  503. <div className='flex items-center justify-between gap-3'>
  504. <div className='flex items-center flex-1 min-w-0'>
  505. <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'>
  506. {getOAuthProviderIcon(
  507. provider.icon || binding?.provider_icon || '',
  508. 20,
  509. )}
  510. </div>
  511. <div className='flex-1 min-w-0'>
  512. <div className='font-medium text-gray-900'>
  513. {provider.name}
  514. </div>
  515. <div className='text-sm text-gray-500 truncate'>
  516. {bound
  517. ? renderAccountInfo(
  518. binding?.provider_user_id,
  519. t('{{name}} ID', { name: provider.name }),
  520. )
  521. : t('未绑定')}
  522. </div>
  523. </div>
  524. </div>
  525. <div className='flex-shrink-0'>
  526. {bound ? (
  527. <Button
  528. type='danger'
  529. theme='outline'
  530. size='small'
  531. loading={customOAuthLoading[provider.id]}
  532. onClick={() =>
  533. handleUnbindCustomOAuth(provider.id, provider.name)
  534. }
  535. >
  536. {t('解绑')}
  537. </Button>
  538. ) : (
  539. <Button
  540. type='primary'
  541. theme='outline'
  542. size='small'
  543. onClick={() => handleBindCustomOAuth(provider)}
  544. >
  545. {t('绑定')}
  546. </Button>
  547. )}
  548. </div>
  549. </div>
  550. </Card>
  551. );
  552. })}
  553. </div>
  554. </div>
  555. </TabPane>
  556. {/* 安全设置 Tab */}
  557. <TabPane
  558. tab={
  559. <div className='flex items-center'>
  560. <ShieldCheck size={16} className='mr-2' />
  561. {t('安全设置')}
  562. </div>
  563. }
  564. itemKey='security'
  565. >
  566. <div className='py-4'>
  567. <div className='space-y-6'>
  568. <Space vertical className='w-full'>
  569. {/* 系统访问令牌 */}
  570. <Card className='!rounded-xl w-full'>
  571. <div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
  572. <div className='flex items-start w-full sm:w-auto'>
  573. <div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
  574. <IconKey size='large' className='text-slate-600' />
  575. </div>
  576. <div className='flex-1'>
  577. <Typography.Title heading={6} className='mb-1'>
  578. {t('系统访问令牌')}
  579. </Typography.Title>
  580. <Typography.Text type='tertiary' className='text-sm'>
  581. {t('用于API调用的身份验证令牌,请妥善保管')}
  582. </Typography.Text>
  583. {systemToken && (
  584. <div className='mt-3'>
  585. <Input
  586. readonly
  587. value={systemToken}
  588. onClick={handleSystemTokenClick}
  589. size='large'
  590. prefix={<IconKey />}
  591. />
  592. </div>
  593. )}
  594. </div>
  595. </div>
  596. <Button
  597. type='primary'
  598. theme='solid'
  599. onClick={generateAccessToken}
  600. className='!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto'
  601. icon={<IconKey />}
  602. >
  603. {systemToken ? t('重新生成') : t('生成令牌')}
  604. </Button>
  605. </div>
  606. </Card>
  607. {/* 密码管理 */}
  608. <Card className='!rounded-xl w-full'>
  609. <div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
  610. <div className='flex items-start w-full sm:w-auto'>
  611. <div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
  612. <IconLock size='large' className='text-slate-600' />
  613. </div>
  614. <div>
  615. <Typography.Title heading={6} className='mb-1'>
  616. {t('密码管理')}
  617. </Typography.Title>
  618. <Typography.Text type='tertiary' className='text-sm'>
  619. {t('定期更改密码可以提高账户安全性')}
  620. </Typography.Text>
  621. </div>
  622. </div>
  623. <Button
  624. type='primary'
  625. theme='solid'
  626. onClick={() => setShowChangePasswordModal(true)}
  627. className='!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto'
  628. icon={<IconLock />}
  629. >
  630. {t('修改密码')}
  631. </Button>
  632. </div>
  633. </Card>
  634. {/* Passkey 设置 */}
  635. <Card className='!rounded-xl w-full'>
  636. <div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
  637. <div className='flex items-start w-full sm:w-auto'>
  638. <div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
  639. <IconKey size='large' className='text-slate-600' />
  640. </div>
  641. <div>
  642. <Typography.Title heading={6} className='mb-1'>
  643. {t('Passkey 登录')}
  644. </Typography.Title>
  645. <Typography.Text type='tertiary' className='text-sm'>
  646. {passkeyEnabled
  647. ? t('已启用 Passkey,无需密码即可登录')
  648. : t('使用 Passkey 实现免密且更安全的登录体验')}
  649. </Typography.Text>
  650. <div className='mt-2 text-xs text-gray-500 space-y-1'>
  651. <div>
  652. {t('最后使用时间')}:{lastUsedLabel}
  653. </div>
  654. {/*{passkeyEnabled && (*/}
  655. {/* <div>*/}
  656. {/* {t('备份支持')}:*/}
  657. {/* {passkeyStatus?.backup_eligible*/}
  658. {/* ? t('支持备份')*/}
  659. {/* : t('不支持')}*/}
  660. {/* ,{t('备份状态')}:*/}
  661. {/* {passkeyStatus?.backup_state ? t('已备份') : t('未备份')}*/}
  662. {/* </div>*/}
  663. {/*)}*/}
  664. {!passkeySupported && (
  665. <div className='text-amber-600'>
  666. {t('当前设备不支持 Passkey')}
  667. </div>
  668. )}
  669. </div>
  670. </div>
  671. </div>
  672. <Button
  673. type={passkeyEnabled ? 'danger' : 'primary'}
  674. theme={passkeyEnabled ? 'solid' : 'solid'}
  675. onClick={
  676. passkeyEnabled
  677. ? () => {
  678. Modal.confirm({
  679. title: t('确认解绑 Passkey'),
  680. content: t(
  681. '解绑后将无法使用 Passkey 登录,确定要继续吗?',
  682. ),
  683. okText: t('确认解绑'),
  684. cancelText: t('取消'),
  685. okType: 'danger',
  686. onOk: onPasskeyDelete,
  687. });
  688. }
  689. : onPasskeyRegister
  690. }
  691. className={`w-full sm:w-auto ${passkeyEnabled ? '!bg-slate-500 hover:!bg-slate-600' : ''}`}
  692. icon={<IconKey />}
  693. disabled={!passkeySupported && !passkeyEnabled}
  694. loading={
  695. passkeyEnabled
  696. ? passkeyDeleteLoading
  697. : passkeyRegisterLoading
  698. }
  699. >
  700. {passkeyEnabled ? t('解绑 Passkey') : t('注册 Passkey')}
  701. </Button>
  702. </div>
  703. </Card>
  704. {/* 两步验证设置 */}
  705. <TwoFASetting t={t} />
  706. {/* 危险区域 */}
  707. <Card className='!rounded-xl w-full'>
  708. <div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
  709. <div className='flex items-start w-full sm:w-auto'>
  710. <div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
  711. <IconDelete size='large' className='text-slate-600' />
  712. </div>
  713. <div>
  714. <Typography.Title
  715. heading={6}
  716. className='mb-1 text-slate-700'
  717. >
  718. {t('删除账户')}
  719. </Typography.Title>
  720. <Typography.Text type='tertiary' className='text-sm'>
  721. {t('此操作不可逆,所有数据将被永久删除')}
  722. </Typography.Text>
  723. </div>
  724. </div>
  725. <Button
  726. type='danger'
  727. theme='solid'
  728. onClick={() => setShowAccountDeleteModal(true)}
  729. className='w-full sm:w-auto !bg-slate-500 hover:!bg-slate-600'
  730. icon={<IconDelete />}
  731. >
  732. {t('删除账户')}
  733. </Button>
  734. </div>
  735. </Card>
  736. </Space>
  737. </div>
  738. </div>
  739. </TabPane>
  740. </Tabs>
  741. </Card>
  742. );
  743. };
  744. export default AccountManagement;