RegisterForm.jsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706
  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, { useContext, useEffect, useState } from 'react';
  16. import { Link, useNavigate } from 'react-router-dom';
  17. import {
  18. API,
  19. getLogo,
  20. showError,
  21. showInfo,
  22. showSuccess,
  23. updateAPI,
  24. getSystemName,
  25. setUserData,
  26. } from '../../helpers';
  27. import Turnstile from 'react-turnstile';
  28. import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
  29. import Title from '@douyinfe/semi-ui/lib/es/typography/title';
  30. import Text from '@douyinfe/semi-ui/lib/es/typography/text';
  31. import {
  32. IconGithubLogo,
  33. IconMail,
  34. IconUser,
  35. IconLock,
  36. IconKey,
  37. } from '@douyinfe/semi-icons';
  38. import {
  39. onGitHubOAuthClicked,
  40. onLinuxDOOAuthClicked,
  41. onOIDCClicked,
  42. } from '../../helpers';
  43. import OIDCIcon from '../common/logo/OIDCIcon';
  44. import LinuxDoIcon from '../common/logo/LinuxDoIcon';
  45. import WeChatIcon from '../common/logo/WeChatIcon';
  46. import TelegramLoginButton from 'react-telegram-login/src';
  47. import { UserContext } from '../../context/User';
  48. import { useTranslation } from 'react-i18next';
  49. const RegisterForm = () => {
  50. let navigate = useNavigate();
  51. const { t } = useTranslation();
  52. const [inputs, setInputs] = useState({
  53. username: '',
  54. password: '',
  55. password2: '',
  56. email: '',
  57. verification_code: '',
  58. wechat_verification_code: '',
  59. });
  60. const { username, password, password2 } = inputs;
  61. const [userState, userDispatch] = useContext(UserContext);
  62. const [turnstileEnabled, setTurnstileEnabled] = useState(false);
  63. const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
  64. const [turnstileToken, setTurnstileToken] = useState('');
  65. const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
  66. const [showEmailRegister, setShowEmailRegister] = useState(false);
  67. const [wechatLoading, setWechatLoading] = useState(false);
  68. const [githubLoading, setGithubLoading] = useState(false);
  69. const [oidcLoading, setOidcLoading] = useState(false);
  70. const [linuxdoLoading, setLinuxdoLoading] = useState(false);
  71. const [emailRegisterLoading, setEmailRegisterLoading] = useState(false);
  72. const [registerLoading, setRegisterLoading] = useState(false);
  73. const [verificationCodeLoading, setVerificationCodeLoading] = useState(false);
  74. const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] =
  75. useState(false);
  76. const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
  77. const [disableButton, setDisableButton] = useState(false);
  78. const [countdown, setCountdown] = useState(30);
  79. const [agreedToTerms, setAgreedToTerms] = useState(false);
  80. const [hasUserAgreement, setHasUserAgreement] = useState(false);
  81. const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false);
  82. const logo = getLogo();
  83. const systemName = getSystemName();
  84. let affCode = new URLSearchParams(window.location.search).get('aff');
  85. if (affCode) {
  86. localStorage.setItem('aff', affCode);
  87. }
  88. const [status] = useState(() => {
  89. const savedStatus = localStorage.getItem('status');
  90. return savedStatus ? JSON.parse(savedStatus) : {};
  91. });
  92. const [showEmailVerification, setShowEmailVerification] = useState(() => {
  93. return status.email_verification ?? false;
  94. });
  95. useEffect(() => {
  96. setShowEmailVerification(status.email_verification);
  97. if (status.turnstile_check) {
  98. setTurnstileEnabled(true);
  99. setTurnstileSiteKey(status.turnstile_site_key);
  100. }
  101. // 检查用户协议和隐私政策是否已设置
  102. const checkTermsAvailability = async () => {
  103. try {
  104. const [userAgreementRes, privacyPolicyRes] = await Promise.all([
  105. API.get('/api/user-agreement'),
  106. API.get('/api/privacy-policy')
  107. ]);
  108. if (userAgreementRes.data.success && userAgreementRes.data.data) {
  109. setHasUserAgreement(true);
  110. }
  111. if (privacyPolicyRes.data.success && privacyPolicyRes.data.data) {
  112. setHasPrivacyPolicy(true);
  113. }
  114. } catch (error) {
  115. console.error('检查用户协议和隐私政策失败:', error);
  116. }
  117. };
  118. checkTermsAvailability();
  119. }, [status]);
  120. useEffect(() => {
  121. let countdownInterval = null;
  122. if (disableButton && countdown > 0) {
  123. countdownInterval = setInterval(() => {
  124. setCountdown(countdown - 1);
  125. }, 1000);
  126. } else if (countdown === 0) {
  127. setDisableButton(false);
  128. setCountdown(30);
  129. }
  130. return () => clearInterval(countdownInterval); // Clean up on unmount
  131. }, [disableButton, countdown]);
  132. const onWeChatLoginClicked = () => {
  133. setWechatLoading(true);
  134. setShowWeChatLoginModal(true);
  135. setWechatLoading(false);
  136. };
  137. const onSubmitWeChatVerificationCode = async () => {
  138. if (turnstileEnabled && turnstileToken === '') {
  139. showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
  140. return;
  141. }
  142. setWechatCodeSubmitLoading(true);
  143. try {
  144. const res = await API.get(
  145. `/api/oauth/wechat?code=${inputs.wechat_verification_code}`,
  146. );
  147. const { success, message, data } = res.data;
  148. if (success) {
  149. userDispatch({ type: 'login', payload: data });
  150. localStorage.setItem('user', JSON.stringify(data));
  151. setUserData(data);
  152. updateAPI();
  153. navigate('/');
  154. showSuccess('登录成功!');
  155. setShowWeChatLoginModal(false);
  156. } else {
  157. showError(message);
  158. }
  159. } catch (error) {
  160. showError('登录失败,请重试');
  161. } finally {
  162. setWechatCodeSubmitLoading(false);
  163. }
  164. };
  165. function handleChange(name, value) {
  166. setInputs((inputs) => ({ ...inputs, [name]: value }));
  167. }
  168. async function handleSubmit(e) {
  169. if (password.length < 8) {
  170. showInfo('密码长度不得小于 8 位!');
  171. return;
  172. }
  173. if (password !== password2) {
  174. showInfo('两次输入的密码不一致');
  175. return;
  176. }
  177. if (username && password) {
  178. if (turnstileEnabled && turnstileToken === '') {
  179. showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
  180. return;
  181. }
  182. setRegisterLoading(true);
  183. try {
  184. if (!affCode) {
  185. affCode = localStorage.getItem('aff');
  186. }
  187. inputs.aff_code = affCode;
  188. const res = await API.post(
  189. `/api/user/register?turnstile=${turnstileToken}`,
  190. inputs,
  191. );
  192. const { success, message } = res.data;
  193. if (success) {
  194. navigate('/login');
  195. showSuccess('注册成功!');
  196. } else {
  197. showError(message);
  198. }
  199. } catch (error) {
  200. showError('注册失败,请重试');
  201. } finally {
  202. setRegisterLoading(false);
  203. }
  204. }
  205. }
  206. const sendVerificationCode = async () => {
  207. if (inputs.email === '') return;
  208. if (turnstileEnabled && turnstileToken === '') {
  209. showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
  210. return;
  211. }
  212. setVerificationCodeLoading(true);
  213. try {
  214. const res = await API.get(
  215. `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`,
  216. );
  217. const { success, message } = res.data;
  218. if (success) {
  219. showSuccess('验证码发送成功,请检查你的邮箱!');
  220. setDisableButton(true); // 发送成功后禁用按钮,开始倒计时
  221. } else {
  222. showError(message);
  223. }
  224. } catch (error) {
  225. showError('发送验证码失败,请重试');
  226. } finally {
  227. setVerificationCodeLoading(false);
  228. }
  229. };
  230. const handleGitHubClick = () => {
  231. setGithubLoading(true);
  232. try {
  233. onGitHubOAuthClicked(status.github_client_id);
  234. } finally {
  235. setTimeout(() => setGithubLoading(false), 3000);
  236. }
  237. };
  238. const handleOIDCClick = () => {
  239. setOidcLoading(true);
  240. try {
  241. onOIDCClicked(status.oidc_authorization_endpoint, status.oidc_client_id);
  242. } finally {
  243. setTimeout(() => setOidcLoading(false), 3000);
  244. }
  245. };
  246. const handleLinuxDOClick = () => {
  247. setLinuxdoLoading(true);
  248. try {
  249. onLinuxDOOAuthClicked(status.linuxdo_client_id);
  250. } finally {
  251. setTimeout(() => setLinuxdoLoading(false), 3000);
  252. }
  253. };
  254. const handleEmailRegisterClick = () => {
  255. setEmailRegisterLoading(true);
  256. setShowEmailRegister(true);
  257. setEmailRegisterLoading(false);
  258. };
  259. const handleOtherRegisterOptionsClick = () => {
  260. setOtherRegisterOptionsLoading(true);
  261. setShowEmailRegister(false);
  262. setOtherRegisterOptionsLoading(false);
  263. };
  264. const onTelegramLoginClicked = async (response) => {
  265. const fields = [
  266. 'id',
  267. 'first_name',
  268. 'last_name',
  269. 'username',
  270. 'photo_url',
  271. 'auth_date',
  272. 'hash',
  273. 'lang',
  274. ];
  275. const params = {};
  276. fields.forEach((field) => {
  277. if (response[field]) {
  278. params[field] = response[field];
  279. }
  280. });
  281. try {
  282. const res = await API.get(`/api/oauth/telegram/login`, { params });
  283. const { success, message, data } = res.data;
  284. if (success) {
  285. userDispatch({ type: 'login', payload: data });
  286. localStorage.setItem('user', JSON.stringify(data));
  287. showSuccess('登录成功!');
  288. setUserData(data);
  289. updateAPI();
  290. navigate('/');
  291. } else {
  292. showError(message);
  293. }
  294. } catch (error) {
  295. showError('登录失败,请重试');
  296. }
  297. };
  298. const renderOAuthOptions = () => {
  299. return (
  300. <div className='flex flex-col items-center'>
  301. <div className='w-full max-w-md'>
  302. <div className='flex items-center justify-center mb-6 gap-2'>
  303. <img src={logo} alt='Logo' className='h-10 rounded-full' />
  304. <Title heading={3} className='!text-gray-800'>
  305. {systemName}
  306. </Title>
  307. </div>
  308. <Card className='border-0 !rounded-2xl overflow-hidden'>
  309. <div className='flex justify-center pt-6 pb-2'>
  310. <Title heading={3} className='text-gray-800 dark:text-gray-200'>
  311. {t('注 册')}
  312. </Title>
  313. </div>
  314. <div className='px-2 py-8'>
  315. <div className='space-y-3'>
  316. {status.wechat_login && (
  317. <Button
  318. theme='outline'
  319. className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
  320. type='tertiary'
  321. icon={
  322. <Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />
  323. }
  324. onClick={onWeChatLoginClicked}
  325. loading={wechatLoading}
  326. >
  327. <span className='ml-3'>{t('使用 微信 继续')}</span>
  328. </Button>
  329. )}
  330. {status.github_oauth && (
  331. <Button
  332. theme='outline'
  333. className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
  334. type='tertiary'
  335. icon={<IconGithubLogo size='large' />}
  336. onClick={handleGitHubClick}
  337. loading={githubLoading}
  338. >
  339. <span className='ml-3'>{t('使用 GitHub 继续')}</span>
  340. </Button>
  341. )}
  342. {status.oidc_enabled && (
  343. <Button
  344. theme='outline'
  345. className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
  346. type='tertiary'
  347. icon={<OIDCIcon style={{ color: '#1877F2' }} />}
  348. onClick={handleOIDCClick}
  349. loading={oidcLoading}
  350. >
  351. <span className='ml-3'>{t('使用 OIDC 继续')}</span>
  352. </Button>
  353. )}
  354. {status.linuxdo_oauth && (
  355. <Button
  356. theme='outline'
  357. className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
  358. type='tertiary'
  359. icon={
  360. <LinuxDoIcon
  361. style={{
  362. color: '#E95420',
  363. width: '20px',
  364. height: '20px',
  365. }}
  366. />
  367. }
  368. onClick={handleLinuxDOClick}
  369. loading={linuxdoLoading}
  370. >
  371. <span className='ml-3'>{t('使用 LinuxDO 继续')}</span>
  372. </Button>
  373. )}
  374. {status.telegram_oauth && (
  375. <div className='flex justify-center my-2'>
  376. <TelegramLoginButton
  377. dataOnauth={onTelegramLoginClicked}
  378. botName={status.telegram_bot_name}
  379. />
  380. </div>
  381. )}
  382. <Divider margin='12px' align='center'>
  383. {t('或')}
  384. </Divider>
  385. <Button
  386. theme='solid'
  387. type='primary'
  388. className='w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors'
  389. icon={<IconMail size='large' />}
  390. onClick={handleEmailRegisterClick}
  391. loading={emailRegisterLoading}
  392. >
  393. <span className='ml-3'>{t('使用 用户名 注册')}</span>
  394. </Button>
  395. </div>
  396. <div className='mt-6 text-center text-sm'>
  397. <Text>
  398. {t('已有账户?')}{' '}
  399. <Link
  400. to='/login'
  401. className='text-blue-600 hover:text-blue-800 font-medium'
  402. >
  403. {t('登录')}
  404. </Link>
  405. </Text>
  406. </div>
  407. </div>
  408. </Card>
  409. </div>
  410. </div>
  411. );
  412. };
  413. const renderEmailRegisterForm = () => {
  414. return (
  415. <div className='flex flex-col items-center'>
  416. <div className='w-full max-w-md'>
  417. <div className='flex items-center justify-center mb-6 gap-2'>
  418. <img src={logo} alt='Logo' className='h-10 rounded-full' />
  419. <Title heading={3} className='!text-gray-800'>
  420. {systemName}
  421. </Title>
  422. </div>
  423. <Card className='border-0 !rounded-2xl overflow-hidden'>
  424. <div className='flex justify-center pt-6 pb-2'>
  425. <Title heading={3} className='text-gray-800 dark:text-gray-200'>
  426. {t('注 册')}
  427. </Title>
  428. </div>
  429. <div className='px-2 py-8'>
  430. <Form className='space-y-3'>
  431. <Form.Input
  432. field='username'
  433. label={t('用户名')}
  434. placeholder={t('请输入用户名')}
  435. name='username'
  436. onChange={(value) => handleChange('username', value)}
  437. prefix={<IconUser />}
  438. />
  439. <Form.Input
  440. field='password'
  441. label={t('密码')}
  442. placeholder={t('输入密码,最短 8 位,最长 20 位')}
  443. name='password'
  444. mode='password'
  445. onChange={(value) => handleChange('password', value)}
  446. prefix={<IconLock />}
  447. />
  448. <Form.Input
  449. field='password2'
  450. label={t('确认密码')}
  451. placeholder={t('确认密码')}
  452. name='password2'
  453. mode='password'
  454. onChange={(value) => handleChange('password2', value)}
  455. prefix={<IconLock />}
  456. />
  457. {showEmailVerification && (
  458. <>
  459. <Form.Input
  460. field='email'
  461. label={t('邮箱')}
  462. placeholder={t('输入邮箱地址')}
  463. name='email'
  464. type='email'
  465. onChange={(value) => handleChange('email', value)}
  466. prefix={<IconMail />}
  467. suffix={
  468. <Button
  469. onClick={sendVerificationCode}
  470. loading={verificationCodeLoading}
  471. disabled={disableButton || verificationCodeLoading}
  472. >
  473. {disableButton
  474. ? `${t('重新发送')} (${countdown})`
  475. : t('获取验证码')}
  476. </Button>
  477. }
  478. />
  479. <Form.Input
  480. field='verification_code'
  481. label={t('验证码')}
  482. placeholder={t('输入验证码')}
  483. name='verification_code'
  484. onChange={(value) =>
  485. handleChange('verification_code', value)
  486. }
  487. prefix={<IconKey />}
  488. />
  489. </>
  490. )}
  491. {(hasUserAgreement || hasPrivacyPolicy) && (
  492. <div className='pt-4'>
  493. <Form.Checkbox
  494. checked={agreedToTerms}
  495. onChange={(checked) => setAgreedToTerms(checked)}
  496. >
  497. <Text size='small' className='text-gray-600'>
  498. {t('我已阅读并同意')}
  499. {hasUserAgreement && (
  500. <>
  501. <a
  502. href='/user-agreement'
  503. target='_blank'
  504. rel='noopener noreferrer'
  505. className='text-blue-600 hover:text-blue-800 mx-1'
  506. >
  507. {t('用户协议')}
  508. </a>
  509. </>
  510. )}
  511. {hasUserAgreement && hasPrivacyPolicy && t('和')}
  512. {hasPrivacyPolicy && (
  513. <>
  514. <a
  515. href='/privacy-policy'
  516. target='_blank'
  517. rel='noopener noreferrer'
  518. className='text-blue-600 hover:text-blue-800 mx-1'
  519. >
  520. {t('隐私政策')}
  521. </a>
  522. </>
  523. )}
  524. </Text>
  525. </Form.Checkbox>
  526. </div>
  527. )}
  528. <div className='space-y-2 pt-2'>
  529. <Button
  530. theme='solid'
  531. className='w-full !rounded-full'
  532. type='primary'
  533. htmlType='submit'
  534. onClick={handleSubmit}
  535. loading={registerLoading}
  536. disabled={(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms}
  537. >
  538. {t('注册')}
  539. </Button>
  540. </div>
  541. </Form>
  542. {(status.github_oauth ||
  543. status.oidc_enabled ||
  544. status.wechat_login ||
  545. status.linuxdo_oauth ||
  546. status.telegram_oauth) && (
  547. <>
  548. <Divider margin='12px' align='center'>
  549. {t('或')}
  550. </Divider>
  551. <div className='mt-4 text-center'>
  552. <Button
  553. theme='outline'
  554. type='tertiary'
  555. className='w-full !rounded-full'
  556. onClick={handleOtherRegisterOptionsClick}
  557. loading={otherRegisterOptionsLoading}
  558. >
  559. {t('其他注册选项')}
  560. </Button>
  561. </div>
  562. </>
  563. )}
  564. <div className='mt-6 text-center text-sm'>
  565. <Text>
  566. {t('已有账户?')}{' '}
  567. <Link
  568. to='/login'
  569. className='text-blue-600 hover:text-blue-800 font-medium'
  570. >
  571. {t('登录')}
  572. </Link>
  573. </Text>
  574. </div>
  575. </div>
  576. </Card>
  577. </div>
  578. </div>
  579. );
  580. };
  581. const renderWeChatLoginModal = () => {
  582. return (
  583. <Modal
  584. title={t('微信扫码登录')}
  585. visible={showWeChatLoginModal}
  586. maskClosable={true}
  587. onOk={onSubmitWeChatVerificationCode}
  588. onCancel={() => setShowWeChatLoginModal(false)}
  589. okText={t('登录')}
  590. centered={true}
  591. okButtonProps={{
  592. loading: wechatCodeSubmitLoading,
  593. }}
  594. >
  595. <div className='flex flex-col items-center'>
  596. <img src={status.wechat_qrcode} alt='微信二维码' className='mb-4' />
  597. </div>
  598. <div className='text-center mb-4'>
  599. <p>
  600. {t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}
  601. </p>
  602. </div>
  603. <Form>
  604. <Form.Input
  605. field='wechat_verification_code'
  606. placeholder={t('验证码')}
  607. label={t('验证码')}
  608. value={inputs.wechat_verification_code}
  609. onChange={(value) =>
  610. handleChange('wechat_verification_code', value)
  611. }
  612. />
  613. </Form>
  614. </Modal>
  615. );
  616. };
  617. return (
  618. <div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
  619. {/* 背景模糊晕染球 */}
  620. <div
  621. className='blur-ball blur-ball-indigo'
  622. style={{ top: '-80px', right: '-80px', transform: 'none' }}
  623. />
  624. <div
  625. className='blur-ball blur-ball-teal'
  626. style={{ top: '50%', left: '-120px' }}
  627. />
  628. <div className='w-full max-w-sm mt-[60px]'>
  629. {showEmailRegister ||
  630. !(
  631. status.github_oauth ||
  632. status.oidc_enabled ||
  633. status.wechat_login ||
  634. status.linuxdo_oauth ||
  635. status.telegram_oauth
  636. )
  637. ? renderEmailRegisterForm()
  638. : renderOAuthOptions()}
  639. {renderWeChatLoginModal()}
  640. {turnstileEnabled && (
  641. <div className='flex justify-center mt-6'>
  642. <Turnstile
  643. sitekey={turnstileSiteKey}
  644. onVerify={(token) => {
  645. setTurnstileToken(token);
  646. }}
  647. />
  648. </div>
  649. )}
  650. </div>
  651. </div>
  652. );
  653. };
  654. export default RegisterForm;