LoginForm.jsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645
  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, useSearchParams } from 'react-router-dom';
  17. import { UserContext } from '../../context/User';
  18. import {
  19. API,
  20. getLogo,
  21. showError,
  22. showInfo,
  23. showSuccess,
  24. updateAPI,
  25. getSystemName,
  26. setUserData,
  27. onGitHubOAuthClicked,
  28. onOIDCClicked,
  29. onLinuxDOOAuthClicked,
  30. } from '../../helpers';
  31. import Turnstile from 'react-turnstile';
  32. import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
  33. import Title from '@douyinfe/semi-ui/lib/es/typography/title';
  34. import Text from '@douyinfe/semi-ui/lib/es/typography/text';
  35. import TelegramLoginButton from 'react-telegram-login';
  36. import { IconGithubLogo, IconMail, IconLock } from '@douyinfe/semi-icons';
  37. import OIDCIcon from '../common/logo/OIDCIcon';
  38. import WeChatIcon from '../common/logo/WeChatIcon';
  39. import LinuxDoIcon from '../common/logo/LinuxDoIcon';
  40. import TwoFAVerification from './TwoFAVerification';
  41. import { useTranslation } from 'react-i18next';
  42. const LoginForm = () => {
  43. let navigate = useNavigate();
  44. const { t } = useTranslation();
  45. const [inputs, setInputs] = useState({
  46. username: '',
  47. password: '',
  48. wechat_verification_code: '',
  49. });
  50. const { username, password } = inputs;
  51. const [searchParams, setSearchParams] = useSearchParams();
  52. const [submitted, setSubmitted] = useState(false);
  53. const [userState, userDispatch] = useContext(UserContext);
  54. const [turnstileEnabled, setTurnstileEnabled] = useState(false);
  55. const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
  56. const [turnstileToken, setTurnstileToken] = useState('');
  57. const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
  58. const [showEmailLogin, setShowEmailLogin] = useState(false);
  59. const [wechatLoading, setWechatLoading] = useState(false);
  60. const [githubLoading, setGithubLoading] = useState(false);
  61. const [oidcLoading, setOidcLoading] = useState(false);
  62. const [linuxdoLoading, setLinuxdoLoading] = useState(false);
  63. const [emailLoginLoading, setEmailLoginLoading] = useState(false);
  64. const [loginLoading, setLoginLoading] = useState(false);
  65. const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
  66. const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] =
  67. useState(false);
  68. const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
  69. const [showTwoFA, setShowTwoFA] = useState(false);
  70. const logo = getLogo();
  71. const systemName = getSystemName();
  72. let affCode = new URLSearchParams(window.location.search).get('aff');
  73. if (affCode) {
  74. localStorage.setItem('aff', affCode);
  75. }
  76. const [status] = useState(() => {
  77. const savedStatus = localStorage.getItem('status');
  78. return savedStatus ? JSON.parse(savedStatus) : {};
  79. });
  80. useEffect(() => {
  81. if (status.turnstile_check) {
  82. setTurnstileEnabled(true);
  83. setTurnstileSiteKey(status.turnstile_site_key);
  84. }
  85. }, [status]);
  86. useEffect(() => {
  87. if (searchParams.get('expired')) {
  88. showError(t('未登录或登录已过期,请重新登录'));
  89. }
  90. }, []);
  91. const onWeChatLoginClicked = () => {
  92. setWechatLoading(true);
  93. setShowWeChatLoginModal(true);
  94. setWechatLoading(false);
  95. };
  96. const onSubmitWeChatVerificationCode = async () => {
  97. if (turnstileEnabled && turnstileToken === '') {
  98. showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
  99. return;
  100. }
  101. setWechatCodeSubmitLoading(true);
  102. try {
  103. const res = await API.get(
  104. `/api/oauth/wechat?code=${inputs.wechat_verification_code}`,
  105. );
  106. const { success, message, data } = res.data;
  107. if (success) {
  108. userDispatch({ type: 'login', payload: data });
  109. localStorage.setItem('user', JSON.stringify(data));
  110. setUserData(data);
  111. updateAPI();
  112. navigate('/');
  113. showSuccess('登录成功!');
  114. setShowWeChatLoginModal(false);
  115. } else {
  116. showError(message);
  117. }
  118. } catch (error) {
  119. showError('登录失败,请重试');
  120. } finally {
  121. setWechatCodeSubmitLoading(false);
  122. }
  123. };
  124. function handleChange(name, value) {
  125. setInputs((inputs) => ({ ...inputs, [name]: value }));
  126. }
  127. async function handleSubmit(e) {
  128. if (turnstileEnabled && turnstileToken === '') {
  129. showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
  130. return;
  131. }
  132. setSubmitted(true);
  133. setLoginLoading(true);
  134. try {
  135. if (username && password) {
  136. const res = await API.post(
  137. `/api/user/login?turnstile=${turnstileToken}`,
  138. {
  139. username,
  140. password,
  141. },
  142. );
  143. const { success, message, data } = res.data;
  144. if (success) {
  145. // 检查是否需要2FA验证
  146. if (data && data.require_2fa) {
  147. setShowTwoFA(true);
  148. setLoginLoading(false);
  149. return;
  150. }
  151. userDispatch({ type: 'login', payload: data });
  152. setUserData(data);
  153. updateAPI();
  154. showSuccess('登录成功!');
  155. if (username === 'root' && password === '123456') {
  156. Modal.error({
  157. title: '您正在使用默认密码!',
  158. content: '请立刻修改默认密码!',
  159. centered: true,
  160. });
  161. }
  162. navigate('/console');
  163. } else {
  164. showError(message);
  165. }
  166. } else {
  167. showError('请输入用户名和密码!');
  168. }
  169. } catch (error) {
  170. showError('登录失败,请重试');
  171. } finally {
  172. setLoginLoading(false);
  173. }
  174. }
  175. // 添加Telegram登录处理函数
  176. const onTelegramLoginClicked = async (response) => {
  177. const fields = [
  178. 'id',
  179. 'first_name',
  180. 'last_name',
  181. 'username',
  182. 'photo_url',
  183. 'auth_date',
  184. 'hash',
  185. 'lang',
  186. ];
  187. const params = {};
  188. fields.forEach((field) => {
  189. if (response[field]) {
  190. params[field] = response[field];
  191. }
  192. });
  193. try {
  194. const res = await API.get(`/api/oauth/telegram/login`, { params });
  195. const { success, message, data } = res.data;
  196. if (success) {
  197. userDispatch({ type: 'login', payload: data });
  198. localStorage.setItem('user', JSON.stringify(data));
  199. showSuccess('登录成功!');
  200. setUserData(data);
  201. updateAPI();
  202. navigate('/');
  203. } else {
  204. showError(message);
  205. }
  206. } catch (error) {
  207. showError('登录失败,请重试');
  208. }
  209. };
  210. // 包装的GitHub登录点击处理
  211. const handleGitHubClick = () => {
  212. setGithubLoading(true);
  213. try {
  214. onGitHubOAuthClicked(status.github_client_id);
  215. } finally {
  216. // 由于重定向,这里不会执行到,但为了完整性添加
  217. setTimeout(() => setGithubLoading(false), 3000);
  218. }
  219. };
  220. // 包装的OIDC登录点击处理
  221. const handleOIDCClick = () => {
  222. setOidcLoading(true);
  223. try {
  224. onOIDCClicked(status.oidc_authorization_endpoint, status.oidc_client_id);
  225. } finally {
  226. // 由于重定向,这里不会执行到,但为了完整性添加
  227. setTimeout(() => setOidcLoading(false), 3000);
  228. }
  229. };
  230. // 包装的LinuxDO登录点击处理
  231. const handleLinuxDOClick = () => {
  232. setLinuxdoLoading(true);
  233. try {
  234. onLinuxDOOAuthClicked(status.linuxdo_client_id);
  235. } finally {
  236. // 由于重定向,这里不会执行到,但为了完整性添加
  237. setTimeout(() => setLinuxdoLoading(false), 3000);
  238. }
  239. };
  240. // 包装的邮箱登录选项点击处理
  241. const handleEmailLoginClick = () => {
  242. setEmailLoginLoading(true);
  243. setShowEmailLogin(true);
  244. setEmailLoginLoading(false);
  245. };
  246. // 包装的重置密码点击处理
  247. const handleResetPasswordClick = () => {
  248. setResetPasswordLoading(true);
  249. navigate('/reset');
  250. setResetPasswordLoading(false);
  251. };
  252. // 包装的其他登录选项点击处理
  253. const handleOtherLoginOptionsClick = () => {
  254. setOtherLoginOptionsLoading(true);
  255. setShowEmailLogin(false);
  256. setOtherLoginOptionsLoading(false);
  257. };
  258. // 2FA验证成功处理
  259. const handle2FASuccess = (data) => {
  260. userDispatch({ type: 'login', payload: data });
  261. setUserData(data);
  262. updateAPI();
  263. showSuccess('登录成功!');
  264. navigate('/console');
  265. };
  266. // 返回登录页面
  267. const handleBackToLogin = () => {
  268. setShowTwoFA(false);
  269. setInputs({ username: '', password: '', wechat_verification_code: '' });
  270. };
  271. const renderOAuthOptions = () => {
  272. return (
  273. <div className='flex flex-col items-center'>
  274. <div className='w-full max-w-md'>
  275. <div className='flex items-center justify-center mb-6 gap-2'>
  276. <img src={logo} alt='Logo' className='h-10 rounded-full' />
  277. <Title heading={3} className='!text-gray-800'>
  278. {systemName}
  279. </Title>
  280. </div>
  281. <Card className='border-0 !rounded-2xl overflow-hidden'>
  282. <div className='flex justify-center pt-6 pb-2'>
  283. <Title heading={3} className='text-gray-800 dark:text-gray-200'>
  284. {t('登 录')}
  285. </Title>
  286. </div>
  287. <div className='px-2 py-8'>
  288. <div className='space-y-3'>
  289. {status.wechat_login && (
  290. <Button
  291. theme='outline'
  292. className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
  293. type='tertiary'
  294. icon={
  295. <Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />
  296. }
  297. onClick={onWeChatLoginClicked}
  298. loading={wechatLoading}
  299. >
  300. <span className='ml-3'>{t('使用 微信 继续')}</span>
  301. </Button>
  302. )}
  303. {status.github_oauth && (
  304. <Button
  305. theme='outline'
  306. className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
  307. type='tertiary'
  308. icon={<IconGithubLogo size='large' />}
  309. onClick={handleGitHubClick}
  310. loading={githubLoading}
  311. >
  312. <span className='ml-3'>{t('使用 GitHub 继续')}</span>
  313. </Button>
  314. )}
  315. {status.oidc_enabled && (
  316. <Button
  317. theme='outline'
  318. className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
  319. type='tertiary'
  320. icon={<OIDCIcon style={{ color: '#1877F2' }} />}
  321. onClick={handleOIDCClick}
  322. loading={oidcLoading}
  323. >
  324. <span className='ml-3'>{t('使用 OIDC 继续')}</span>
  325. </Button>
  326. )}
  327. {status.linuxdo_oauth && (
  328. <Button
  329. theme='outline'
  330. className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
  331. type='tertiary'
  332. icon={
  333. <LinuxDoIcon
  334. style={{
  335. color: '#E95420',
  336. width: '20px',
  337. height: '20px',
  338. }}
  339. />
  340. }
  341. onClick={handleLinuxDOClick}
  342. loading={linuxdoLoading}
  343. >
  344. <span className='ml-3'>{t('使用 LinuxDO 继续')}</span>
  345. </Button>
  346. )}
  347. {status.telegram_oauth && (
  348. <div className='flex justify-center my-2'>
  349. <TelegramLoginButton
  350. dataOnauth={onTelegramLoginClicked}
  351. botName={status.telegram_bot_name}
  352. />
  353. </div>
  354. )}
  355. <Divider margin='12px' align='center'>
  356. {t('或')}
  357. </Divider>
  358. <Button
  359. theme='solid'
  360. type='primary'
  361. className='w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors'
  362. icon={<IconMail size='large' />}
  363. onClick={handleEmailLoginClick}
  364. loading={emailLoginLoading}
  365. >
  366. <span className='ml-3'>{t('使用 邮箱或用户名 登录')}</span>
  367. </Button>
  368. </div>
  369. {!status.self_use_mode_enabled && (
  370. <div className='mt-6 text-center text-sm'>
  371. <Text>
  372. {t('没有账户?')}{' '}
  373. <Link
  374. to='/register'
  375. className='text-blue-600 hover:text-blue-800 font-medium'
  376. >
  377. {t('注册')}
  378. </Link>
  379. </Text>
  380. </div>
  381. )}
  382. </div>
  383. </Card>
  384. </div>
  385. </div>
  386. );
  387. };
  388. const renderEmailLoginForm = () => {
  389. return (
  390. <div className='flex flex-col items-center'>
  391. <div className='w-full max-w-md'>
  392. <div className='flex items-center justify-center mb-6 gap-2'>
  393. <img src={logo} alt='Logo' className='h-10 rounded-full' />
  394. <Title heading={3}>{systemName}</Title>
  395. </div>
  396. <Card className='border-0 !rounded-2xl overflow-hidden'>
  397. <div className='flex justify-center pt-6 pb-2'>
  398. <Title heading={3} className='text-gray-800 dark:text-gray-200'>
  399. {t('登 录')}
  400. </Title>
  401. </div>
  402. <div className='px-2 py-8'>
  403. <Form className='space-y-3'>
  404. <Form.Input
  405. field='username'
  406. label={t('用户名或邮箱')}
  407. placeholder={t('请输入您的用户名或邮箱地址')}
  408. name='username'
  409. onChange={(value) => handleChange('username', value)}
  410. prefix={<IconMail />}
  411. />
  412. <Form.Input
  413. field='password'
  414. label={t('密码')}
  415. placeholder={t('请输入您的密码')}
  416. name='password'
  417. mode='password'
  418. onChange={(value) => handleChange('password', value)}
  419. prefix={<IconLock />}
  420. />
  421. <div className='space-y-2 pt-2'>
  422. <Button
  423. theme='solid'
  424. className='w-full !rounded-full'
  425. type='primary'
  426. htmlType='submit'
  427. onClick={handleSubmit}
  428. loading={loginLoading}
  429. >
  430. {t('继续')}
  431. </Button>
  432. <Button
  433. theme='borderless'
  434. type='tertiary'
  435. className='w-full !rounded-full'
  436. onClick={handleResetPasswordClick}
  437. loading={resetPasswordLoading}
  438. >
  439. {t('忘记密码?')}
  440. </Button>
  441. </div>
  442. </Form>
  443. {(status.github_oauth ||
  444. status.oidc_enabled ||
  445. status.wechat_login ||
  446. status.linuxdo_oauth ||
  447. status.telegram_oauth) && (
  448. <>
  449. <Divider margin='12px' align='center'>
  450. {t('或')}
  451. </Divider>
  452. <div className='mt-4 text-center'>
  453. <Button
  454. theme='outline'
  455. type='tertiary'
  456. className='w-full !rounded-full'
  457. onClick={handleOtherLoginOptionsClick}
  458. loading={otherLoginOptionsLoading}
  459. >
  460. {t('其他登录选项')}
  461. </Button>
  462. </div>
  463. </>
  464. )}
  465. {!status.self_use_mode_enabled && (
  466. <div className='mt-6 text-center text-sm'>
  467. <Text>
  468. {t('没有账户?')}{' '}
  469. <Link
  470. to='/register'
  471. className='text-blue-600 hover:text-blue-800 font-medium'
  472. >
  473. {t('注册')}
  474. </Link>
  475. </Text>
  476. </div>
  477. )}
  478. </div>
  479. </Card>
  480. </div>
  481. </div>
  482. );
  483. };
  484. // 微信登录模态框
  485. const renderWeChatLoginModal = () => {
  486. return (
  487. <Modal
  488. title={t('微信扫码登录')}
  489. visible={showWeChatLoginModal}
  490. maskClosable={true}
  491. onOk={onSubmitWeChatVerificationCode}
  492. onCancel={() => setShowWeChatLoginModal(false)}
  493. okText={t('登录')}
  494. centered={true}
  495. okButtonProps={{
  496. loading: wechatCodeSubmitLoading,
  497. }}
  498. >
  499. <div className='flex flex-col items-center'>
  500. <img src={status.wechat_qrcode} alt='微信二维码' className='mb-4' />
  501. </div>
  502. <div className='text-center mb-4'>
  503. <p>
  504. {t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}
  505. </p>
  506. </div>
  507. <Form>
  508. <Form.Input
  509. field='wechat_verification_code'
  510. placeholder={t('验证码')}
  511. label={t('验证码')}
  512. value={inputs.wechat_verification_code}
  513. onChange={(value) =>
  514. handleChange('wechat_verification_code', value)
  515. }
  516. />
  517. </Form>
  518. </Modal>
  519. );
  520. };
  521. // 2FA验证弹窗
  522. const render2FAModal = () => {
  523. return (
  524. <Modal
  525. title={
  526. <div className='flex items-center'>
  527. <div className='w-8 h-8 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mr-3'>
  528. <svg
  529. className='w-4 h-4 text-green-600 dark:text-green-400'
  530. fill='currentColor'
  531. viewBox='0 0 20 20'
  532. >
  533. <path
  534. fillRule='evenodd'
  535. d='M6 8a2 2 0 11-4 0 2 2 0 014 0zM8 7a1 1 0 100 2h8a1 1 0 100-2H8zM6 14a2 2 0 11-4 0 2 2 0 014 0zM8 13a1 1 0 100 2h8a1 1 0 100-2H8z'
  536. clipRule='evenodd'
  537. />
  538. </svg>
  539. </div>
  540. 两步验证
  541. </div>
  542. }
  543. visible={showTwoFA}
  544. onCancel={handleBackToLogin}
  545. footer={null}
  546. width={450}
  547. centered
  548. >
  549. <TwoFAVerification
  550. onSuccess={handle2FASuccess}
  551. onBack={handleBackToLogin}
  552. isModal={true}
  553. />
  554. </Modal>
  555. );
  556. };
  557. return (
  558. <div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
  559. {/* 背景模糊晕染球 */}
  560. <div
  561. className='blur-ball blur-ball-indigo'
  562. style={{ top: '-80px', right: '-80px', transform: 'none' }}
  563. />
  564. <div
  565. className='blur-ball blur-ball-teal'
  566. style={{ top: '50%', left: '-120px' }}
  567. />
  568. <div className='w-full max-w-sm mt-[60px]'>
  569. {showEmailLogin ||
  570. !(
  571. status.github_oauth ||
  572. status.oidc_enabled ||
  573. status.wechat_login ||
  574. status.linuxdo_oauth ||
  575. status.telegram_oauth
  576. )
  577. ? renderEmailLoginForm()
  578. : renderOAuthOptions()}
  579. {renderWeChatLoginModal()}
  580. {render2FAModal()}
  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 LoginForm;