TwoFASetting.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667
  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 { API, showError, showSuccess, showWarning } from '../../helpers';
  16. import { Banner, Button, Card, Checkbox, Divider, Input, Modal, Tag, Typography, Steps, Space, Badge } from '@douyinfe/semi-ui';
  17. import {
  18. IconShield,
  19. IconAlertTriangle,
  20. IconRefresh,
  21. IconCopy
  22. } from '@douyinfe/semi-icons';
  23. import React, { useEffect, useState } from 'react';
  24. import { QRCodeSVG } from 'qrcode.react';
  25. const { Text, Paragraph } = Typography;
  26. const TwoFASetting = () => {
  27. const [loading, setLoading] = useState(false);
  28. const [status, setStatus] = useState({
  29. enabled: false,
  30. locked: false,
  31. backup_codes_remaining: 0
  32. });
  33. // 模态框状态
  34. const [setupModalVisible, setSetupModalVisible] = useState(false);
  35. const [enableModalVisible, setEnableModalVisible] = useState(false);
  36. const [disableModalVisible, setDisableModalVisible] = useState(false);
  37. const [backupModalVisible, setBackupModalVisible] = useState(false);
  38. // 表单数据
  39. const [setupData, setSetupData] = useState(null);
  40. const [verificationCode, setVerificationCode] = useState('');
  41. const [backupCodes, setBackupCodes] = useState([]);
  42. const [confirmDisable, setConfirmDisable] = useState(false);
  43. const [currentStep, setCurrentStep] = useState(0);
  44. // 获取2FA状态
  45. const fetchStatus = async () => {
  46. try {
  47. const res = await API.get('/api/user/2fa/status');
  48. if (res.data.success) {
  49. setStatus(res.data.data);
  50. }
  51. } catch (error) {
  52. showError('获取2FA状态失败');
  53. }
  54. };
  55. useEffect(() => {
  56. fetchStatus();
  57. }, []);
  58. // 初始化2FA设置
  59. const handleSetup2FA = async () => {
  60. setLoading(true);
  61. try {
  62. const res = await API.post('/api/user/2fa/setup');
  63. if (res.data.success) {
  64. setSetupData(res.data.data);
  65. setSetupModalVisible(true);
  66. setCurrentStep(0);
  67. } else {
  68. showError(res.data.message);
  69. }
  70. } catch (error) {
  71. showError('设置2FA失败');
  72. } finally {
  73. setLoading(false);
  74. }
  75. };
  76. // 启用2FA
  77. const handleEnable2FA = async () => {
  78. if (!verificationCode) {
  79. showWarning('请输入验证码');
  80. return;
  81. }
  82. setLoading(true);
  83. try {
  84. const res = await API.post('/api/user/2fa/enable', {
  85. code: verificationCode
  86. });
  87. if (res.data.success) {
  88. showSuccess('两步验证启用成功!');
  89. setEnableModalVisible(false);
  90. setSetupModalVisible(false);
  91. setVerificationCode('');
  92. setCurrentStep(0);
  93. fetchStatus();
  94. } else {
  95. showError(res.data.message);
  96. }
  97. } catch (error) {
  98. showError('启用2FA失败');
  99. } finally {
  100. setLoading(false);
  101. }
  102. };
  103. // 禁用2FA
  104. const handleDisable2FA = async () => {
  105. if (!verificationCode) {
  106. showWarning('请输入验证码或备用码');
  107. return;
  108. }
  109. if (!confirmDisable) {
  110. showWarning('请确认您已了解禁用两步验证的后果');
  111. return;
  112. }
  113. setLoading(true);
  114. try {
  115. const res = await API.post('/api/user/2fa/disable', {
  116. code: verificationCode
  117. });
  118. if (res.data.success) {
  119. showSuccess('两步验证已禁用');
  120. setDisableModalVisible(false);
  121. setVerificationCode('');
  122. setConfirmDisable(false);
  123. fetchStatus();
  124. } else {
  125. showError(res.data.message);
  126. }
  127. } catch (error) {
  128. showError('禁用2FA失败');
  129. } finally {
  130. setLoading(false);
  131. }
  132. };
  133. // 重新生成备用码
  134. const handleRegenerateBackupCodes = async () => {
  135. if (!verificationCode) {
  136. showWarning('请输入验证码');
  137. return;
  138. }
  139. setLoading(true);
  140. try {
  141. const res = await API.post('/api/user/2fa/backup_codes', {
  142. code: verificationCode
  143. });
  144. if (res.data.success) {
  145. setBackupCodes(res.data.data.backup_codes);
  146. showSuccess('备用码重新生成成功');
  147. setVerificationCode('');
  148. fetchStatus();
  149. } else {
  150. showError(res.data.message);
  151. }
  152. } catch (error) {
  153. showError('重新生成备用码失败');
  154. } finally {
  155. setLoading(false);
  156. }
  157. };
  158. // 通用复制函数
  159. const copyTextToClipboard = (text, successMessage = '已复制到剪贴板') => {
  160. navigator.clipboard.writeText(text).then(() => {
  161. showSuccess(successMessage);
  162. }).catch(() => {
  163. showError('复制失败,请手动复制');
  164. });
  165. };
  166. const copyBackupCodes = () => {
  167. const codesText = backupCodes.join('\n');
  168. copyTextToClipboard(codesText, '备用码已复制到剪贴板');
  169. };
  170. // 备用码展示组件
  171. const BackupCodesDisplay = ({ codes, title, onCopy }) => {
  172. return (
  173. <Card
  174. className="!rounded-xl"
  175. style={{ width: '100%' }}
  176. >
  177. <div className="space-y-3">
  178. <div className="flex items-center justify-between">
  179. <Text strong className="text-slate-700 dark:text-slate-200">
  180. {title}
  181. </Text>
  182. </div>
  183. <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
  184. {codes.map((code, index) => (
  185. <div
  186. key={index}
  187. className="rounded-lg p-3"
  188. >
  189. <div className="flex items-center justify-between">
  190. <Text code className="text-sm font-mono text-slate-700 dark:text-slate-200">
  191. {code}
  192. </Text>
  193. <Text type="quaternary" className="text-xs">
  194. #{(index + 1).toString().padStart(2, '0')}
  195. </Text>
  196. </div>
  197. </div>
  198. ))}
  199. </div>
  200. <Divider margin={12} />
  201. <Button
  202. type="primary"
  203. theme="solid"
  204. icon={<IconCopy />}
  205. onClick={onCopy}
  206. className="!rounded-lg !bg-slate-600 hover:!bg-slate-700 w-full"
  207. >
  208. 复制所有代码
  209. </Button>
  210. </div>
  211. </Card>
  212. );
  213. };
  214. // 渲染设置模态框footer
  215. const renderSetupModalFooter = () => {
  216. return (
  217. <>
  218. {currentStep > 0 && (
  219. <Button
  220. onClick={() => setCurrentStep(currentStep - 1)}
  221. className="!rounded-lg"
  222. >
  223. 上一步
  224. </Button>
  225. )}
  226. {currentStep < 2 ? (
  227. <Button
  228. type="primary"
  229. theme="solid"
  230. onClick={() => setCurrentStep(currentStep + 1)}
  231. className="!rounded-lg !bg-slate-600 hover:!bg-slate-700"
  232. >
  233. 下一步
  234. </Button>
  235. ) : (
  236. <Button
  237. type="primary"
  238. theme="solid"
  239. loading={loading}
  240. onClick={() => {
  241. if (!verificationCode) {
  242. showWarning('请输入验证码');
  243. return;
  244. }
  245. handleEnable2FA();
  246. }}
  247. className="!rounded-lg !bg-slate-600 hover:!bg-slate-700"
  248. >
  249. 完成设置并启用两步验证
  250. </Button>
  251. )}
  252. </>
  253. );
  254. };
  255. // 渲染禁用模态框footer
  256. const renderDisableModalFooter = () => {
  257. return (
  258. <>
  259. <Button
  260. onClick={() => {
  261. setDisableModalVisible(false);
  262. setVerificationCode('');
  263. setConfirmDisable(false);
  264. }}
  265. className="!rounded-lg"
  266. >
  267. 取消
  268. </Button>
  269. <Button
  270. type="danger"
  271. theme="solid"
  272. loading={loading}
  273. disabled={!confirmDisable || !verificationCode}
  274. onClick={handleDisable2FA}
  275. className="!rounded-lg !bg-slate-500 hover:!bg-slate-600"
  276. >
  277. 确认禁用
  278. </Button>
  279. </>
  280. );
  281. };
  282. // 渲染重新生成模态框footer
  283. const renderRegenerateModalFooter = () => {
  284. if (backupCodes.length > 0) {
  285. return (
  286. <Button
  287. type="primary"
  288. theme="solid"
  289. onClick={() => {
  290. setBackupModalVisible(false);
  291. setVerificationCode('');
  292. setBackupCodes([]);
  293. }}
  294. className="!rounded-lg !bg-slate-600 hover:!bg-slate-700"
  295. >
  296. 完成
  297. </Button>
  298. );
  299. }
  300. return (
  301. <>
  302. <Button
  303. onClick={() => {
  304. setBackupModalVisible(false);
  305. setVerificationCode('');
  306. setBackupCodes([]);
  307. }}
  308. className="!rounded-lg"
  309. >
  310. 取消
  311. </Button>
  312. <Button
  313. type="primary"
  314. theme="solid"
  315. loading={loading}
  316. disabled={!verificationCode}
  317. onClick={handleRegenerateBackupCodes}
  318. className="!rounded-lg !bg-slate-600 hover:!bg-slate-700"
  319. >
  320. 生成新的备用码
  321. </Button>
  322. </>
  323. );
  324. };
  325. return (
  326. <>
  327. <Card
  328. className="!rounded-xl w-full"
  329. bodyStyle={{ padding: '20px' }}
  330. shadows='hover'
  331. >
  332. <div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4">
  333. <div className="flex items-start w-full sm:w-auto">
  334. <div className="w-12 h-12 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-4 flex-shrink-0">
  335. <IconShield size="large" className="text-slate-600 dark:text-slate-300" />
  336. </div>
  337. <div className="flex-1">
  338. <div className="flex items-center gap-2 mb-1">
  339. <Typography.Title heading={6} className="mb-0">
  340. 两步验证设置
  341. </Typography.Title>
  342. {status.enabled ? (
  343. <Tag color="green" shape="circle" size="small">已启用</Tag>
  344. ) : (
  345. <Tag color="red" shape="circle" size="small">未启用</Tag>
  346. )}
  347. {status.locked && (
  348. <Tag color="orange" shape="circle" size="small">账户已锁定</Tag>
  349. )}
  350. </div>
  351. <Typography.Text type="tertiary" className="text-sm">
  352. 两步验证(2FA)为您的账户提供额外的安全保护。启用后,登录时需要输入密码和验证器应用生成的验证码。
  353. </Typography.Text>
  354. {status.enabled && (
  355. <div className="mt-2">
  356. <Text size="small" type="secondary">剩余备用码:{status.backup_codes_remaining || 0} 个</Text>
  357. </div>
  358. )}
  359. </div>
  360. </div>
  361. <div className="flex flex-col space-y-2 w-full sm:w-auto">
  362. {!status.enabled ? (
  363. <Button
  364. type="primary"
  365. theme="solid"
  366. size="default"
  367. onClick={handleSetup2FA}
  368. loading={loading}
  369. className="!rounded-lg !bg-slate-600 hover:!bg-slate-700"
  370. icon={<IconShield />}
  371. >
  372. 启用验证
  373. </Button>
  374. ) : (
  375. <div className="flex flex-col space-y-2">
  376. <Button
  377. type="danger"
  378. theme="solid"
  379. size="default"
  380. onClick={() => setDisableModalVisible(true)}
  381. className="!rounded-lg !bg-slate-500 hover:!bg-slate-600"
  382. icon={<IconAlertTriangle />}
  383. >
  384. 禁用两步验证
  385. </Button>
  386. <Button
  387. type="primary"
  388. theme="solid"
  389. size="default"
  390. onClick={() => setBackupModalVisible(true)}
  391. className="!rounded-lg"
  392. icon={<IconRefresh />}
  393. >
  394. 重新生成备用码
  395. </Button>
  396. </div>
  397. )}
  398. </div>
  399. </div>
  400. </Card>
  401. {/* 2FA设置模态框 */}
  402. <Modal
  403. title={
  404. <div className="flex items-center">
  405. <IconShield className="mr-2 text-slate-600" />
  406. 设置两步验证
  407. </div>
  408. }
  409. visible={setupModalVisible}
  410. onCancel={() => {
  411. setSetupModalVisible(false);
  412. setSetupData(null);
  413. setCurrentStep(0);
  414. setVerificationCode('');
  415. }}
  416. footer={renderSetupModalFooter()}
  417. width={650}
  418. style={{ maxWidth: '90vw' }}
  419. >
  420. {setupData && (
  421. <div className="space-y-6">
  422. {/* 步骤进度 */}
  423. <Steps type="basic" size="small" current={currentStep}>
  424. <Steps.Step title="扫描二维码" description="使用认证器应用扫描二维码" />
  425. <Steps.Step title="保存备用码" description="保存备用码以备不时之需" />
  426. <Steps.Step title="验证设置" description="输入验证码完成设置" />
  427. </Steps>
  428. {/* 步骤内容 */}
  429. <div className="rounded-xl">
  430. {currentStep === 0 && (
  431. <div>
  432. <Paragraph className="text-gray-600 dark:text-gray-300 mb-4">
  433. 使用认证器应用(如 Google Authenticator、Microsoft Authenticator)扫描下方二维码:
  434. </Paragraph>
  435. <div className="flex justify-center mb-4">
  436. <div className="bg-white p-4 rounded-lg shadow-sm">
  437. <QRCodeSVG value={setupData.qr_code_data} size={180} />
  438. </div>
  439. </div>
  440. <div className="bg-blue-50 dark:bg-blue-900 rounded-lg p-3">
  441. <Text className="text-blue-800 dark:text-blue-200 text-sm">
  442. 或手动输入密钥:<Text code copyable className="ml-2">{setupData.secret}</Text>
  443. </Text>
  444. </div>
  445. </div>
  446. )}
  447. {currentStep === 1 && (
  448. <div className="space-y-4">
  449. {/* 备用码展示 */}
  450. <BackupCodesDisplay
  451. codes={setupData.backup_codes}
  452. title="备用恢复代码"
  453. onCopy={() => {
  454. const codesText = setupData.backup_codes.join('\n');
  455. copyTextToClipboard(codesText, '备用码已复制到剪贴板');
  456. }}
  457. />
  458. </div>
  459. )}
  460. {currentStep === 2 && (
  461. <Input
  462. placeholder="输入认证器应用显示的6位数字验证码"
  463. value={verificationCode}
  464. onChange={setVerificationCode}
  465. size="large"
  466. maxLength={6}
  467. className="!rounded-lg"
  468. />
  469. )}
  470. </div>
  471. </div>
  472. )}
  473. </Modal>
  474. {/* 禁用2FA模态框 */}
  475. <Modal
  476. title={
  477. <div className="flex items-center">
  478. <IconAlertTriangle className="mr-2 text-red-500" />
  479. 禁用两步验证
  480. </div>
  481. }
  482. visible={disableModalVisible}
  483. onCancel={() => {
  484. setDisableModalVisible(false);
  485. setVerificationCode('');
  486. setConfirmDisable(false);
  487. }}
  488. footer={renderDisableModalFooter()}
  489. width={550}
  490. style={{ maxWidth: '90vw' }}
  491. >
  492. <div className="space-y-6">
  493. {/* 警告提示 */}
  494. <div className="rounded-xl">
  495. <Banner
  496. type="warning"
  497. description="警告:禁用两步验证将永久删除您的验证设置和所有备用码,此操作不可撤销!"
  498. className="!rounded-lg"
  499. />
  500. </div>
  501. {/* 内容区域 */}
  502. <div className="space-y-4">
  503. <div>
  504. <Text strong className="block mb-2 text-slate-700 dark:text-slate-200">
  505. 禁用后的影响:
  506. </Text>
  507. <ul className="space-y-2 text-sm text-slate-600 dark:text-slate-300">
  508. <li className="flex items-start gap-2">
  509. <Badge dot type='warning' />
  510. 降低您账户的安全性
  511. </li>
  512. <li className="flex items-start gap-2">
  513. <Badge dot type='warning' />
  514. 需要重新完整设置才能再次启用
  515. </li>
  516. <li className="flex items-start gap-2">
  517. <Badge dot type='danger' />
  518. 永久删除您的两步验证设置
  519. </li>
  520. <li className="flex items-start gap-2">
  521. <Badge dot type='danger' />
  522. 永久删除所有备用码(包括未使用的)
  523. </li>
  524. </ul>
  525. </div>
  526. <Divider margin={16} />
  527. <div className="space-y-4">
  528. <div>
  529. <Text strong className="block mb-2 text-slate-700 dark:text-slate-200">
  530. 验证身份
  531. </Text>
  532. <Input
  533. placeholder="请输入认证器验证码或备用码"
  534. value={verificationCode}
  535. onChange={setVerificationCode}
  536. size="large"
  537. className="!rounded-lg"
  538. />
  539. </div>
  540. <div>
  541. <Checkbox
  542. checked={confirmDisable}
  543. onChange={(e) => setConfirmDisable(e.target.checked)}
  544. className="text-sm"
  545. >
  546. 我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销
  547. </Checkbox>
  548. </div>
  549. </div>
  550. </div>
  551. </div>
  552. </Modal>
  553. {/* 重新生成备用码模态框 */}
  554. <Modal
  555. title={
  556. <div className="flex items-center">
  557. <IconRefresh className="mr-2 text-slate-600" />
  558. 重新生成备用码
  559. </div>
  560. }
  561. visible={backupModalVisible}
  562. onCancel={() => {
  563. setBackupModalVisible(false);
  564. setVerificationCode('');
  565. setBackupCodes([]);
  566. }}
  567. footer={renderRegenerateModalFooter()}
  568. width={500}
  569. style={{ maxWidth: '90vw' }}
  570. >
  571. <div className="space-y-6">
  572. {backupCodes.length === 0 ? (
  573. <>
  574. {/* 警告提示 */}
  575. <div className="rounded-xl">
  576. <Banner
  577. type="warning"
  578. description="重新生成备用码将使现有的备用码失效,请确保您已保存了当前的备用码。"
  579. className="!rounded-lg"
  580. />
  581. </div>
  582. {/* 验证区域 */}
  583. <div className="space-y-4">
  584. <div>
  585. <Text strong className="block mb-2 text-slate-700 dark:text-slate-200">
  586. 验证身份
  587. </Text>
  588. <Input
  589. placeholder="请输入认证器验证码"
  590. value={verificationCode}
  591. onChange={setVerificationCode}
  592. size="large"
  593. className="!rounded-lg"
  594. />
  595. </div>
  596. </div>
  597. </>
  598. ) : (
  599. <>
  600. {/* 成功提示 */}
  601. <Space vertical style={{ width: '100%' }}>
  602. <div className="flex items-center justify-center gap-2">
  603. <Badge dot type='success' />
  604. <Text strong className="text-lg text-slate-700 dark:text-slate-200">
  605. 新的备用码已生成
  606. </Text>
  607. </div>
  608. <Text className="text-slate-500 dark:text-slate-400 text-sm">
  609. 旧的备用码已失效,请保存新的备用码
  610. </Text>
  611. {/* 备用码展示 */}
  612. <BackupCodesDisplay
  613. codes={backupCodes}
  614. title="新的备用恢复代码"
  615. onCopy={copyBackupCodes}
  616. />
  617. </Space>
  618. </>
  619. )}
  620. </div>
  621. </Modal>
  622. </>
  623. );
  624. };
  625. export default TwoFASetting;