LoginForm.js 21 KB

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