RegisterForm.jsx 21 KB

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