RegisterForm.js 20 KB

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