TwoFactorModal.tsx 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. import { useEffect, useRef, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Button, Divider, Input, Modal, QRCode, message } from 'antd';
  4. import * as OTPAuth from 'otpauth';
  5. import { ClipboardManager } from '@/utils';
  6. import { TotpCodeSchema } from '@/schemas/login';
  7. import './TwoFactorModal.css';
  8. type Type = 'set' | 'confirm';
  9. interface TwoFactorModalProps {
  10. open: boolean;
  11. title?: string;
  12. description?: string;
  13. token?: string;
  14. type?: Type;
  15. onConfirm: (success: boolean, code?: string) => void;
  16. onOpenChange: (open: boolean) => void;
  17. }
  18. export default function TwoFactorModal({
  19. open,
  20. title = '',
  21. description = '',
  22. token = '',
  23. type = 'set',
  24. onConfirm,
  25. onOpenChange,
  26. }: TwoFactorModalProps) {
  27. const { t } = useTranslation();
  28. const [messageApi, messageContextHolder] = message.useMessage();
  29. const [enteredCode, setEnteredCode] = useState('');
  30. const [qrValue, setQrValue] = useState('');
  31. const totpRef = useRef<OTPAuth.TOTP | null>(null);
  32. useEffect(() => {
  33. if (!open) return;
  34. setEnteredCode('');
  35. totpRef.current = null;
  36. setQrValue('');
  37. if (token) {
  38. const totp = new OTPAuth.TOTP({
  39. issuer: '3x-ui',
  40. label: 'Administrator',
  41. algorithm: 'SHA1',
  42. digits: 6,
  43. period: 30,
  44. secret: token,
  45. });
  46. totpRef.current = totp;
  47. setQrValue(totp.toString());
  48. }
  49. }, [open, token]);
  50. function close(success: boolean, code = '') {
  51. onConfirm(success, code);
  52. onOpenChange(false);
  53. setEnteredCode('');
  54. }
  55. function onOk() {
  56. const codeOk = TotpCodeSchema.safeParse(enteredCode);
  57. if (!codeOk.success) {
  58. messageApi.error(t(codeOk.error.issues[0]?.message ?? 'pages.settings.security.twoFactorModalError'));
  59. return;
  60. }
  61. if (type === 'confirm' && !token) {
  62. close(true, codeOk.data);
  63. return;
  64. }
  65. if (!totpRef.current) return;
  66. if (totpRef.current.generate() === codeOk.data) {
  67. close(true);
  68. } else {
  69. messageApi.error(t('pages.settings.security.twoFactorModalError'));
  70. }
  71. }
  72. function onCancel() {
  73. close(false);
  74. }
  75. async function copyToken() {
  76. const ok = await ClipboardManager.copyText(token);
  77. if (ok) messageApi.success(t('copied'));
  78. }
  79. return (
  80. <>
  81. {messageContextHolder}
  82. <Modal
  83. open={open}
  84. title={title}
  85. closable
  86. onCancel={onCancel}
  87. footer={[
  88. <Button key="cancel" onClick={onCancel}>{t('cancel')}</Button>,
  89. <Button key="ok" type="primary" disabled={!TotpCodeSchema.safeParse(enteredCode).success} onClick={onOk}>
  90. {t('confirm')}
  91. </Button>,
  92. ]}
  93. >
  94. {type === 'set' ? (
  95. <>
  96. <p>{t('pages.settings.security.twoFactorModalSteps')}</p>
  97. <Divider />
  98. <p>{t('pages.settings.security.twoFactorModalFirstStep')}</p>
  99. <div className="qr-wrap">
  100. <QRCode
  101. className="qr-code"
  102. value={qrValue}
  103. size={180}
  104. type="svg"
  105. bordered={false}
  106. color="#000000"
  107. bgColor="#ffffff"
  108. errorLevel="L"
  109. title={t('copy')}
  110. onClick={copyToken}
  111. />
  112. <span className="qr-token">{token}</span>
  113. </div>
  114. <Divider />
  115. <p>{t('pages.settings.security.twoFactorModalSecondStep')}</p>
  116. <Input value={enteredCode} onChange={(e) => setEnteredCode(e.target.value)} style={{ width: '100%' }} />
  117. </>
  118. ) : (
  119. <>
  120. <p>{description}</p>
  121. <Input value={enteredCode} onChange={(e) => setEnteredCode(e.target.value)} style={{ width: '100%' }} />
  122. </>
  123. )}
  124. </Modal>
  125. </>
  126. );
  127. }