LoginForm.jsx 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854
  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. prepareCredentialRequestOptions,
  31. buildAssertionResult,
  32. isPasskeySupported,
  33. } from '../../helpers';
  34. import Turnstile from 'react-turnstile';
  35. import { Button, Card, Checkbox, Divider, Form, Icon, Modal } 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 TelegramLoginButton from 'react-telegram-login';
  39. import {
  40. IconGithubLogo,
  41. IconMail,
  42. IconLock,
  43. IconKey,
  44. } from '@douyinfe/semi-icons';
  45. import OIDCIcon from '../common/logo/OIDCIcon';
  46. import WeChatIcon from '../common/logo/WeChatIcon';
  47. import LinuxDoIcon from '../common/logo/LinuxDoIcon';
  48. import TwoFAVerification from './TwoFAVerification';
  49. import { useTranslation } from 'react-i18next';
  50. const LoginForm = () => {
  51. let navigate = useNavigate();
  52. const { t } = useTranslation();
  53. const [inputs, setInputs] = useState({
  54. username: '',
  55. password: '',
  56. wechat_verification_code: '',
  57. });
  58. const { username, password } = inputs;
  59. const [searchParams, setSearchParams] = useSearchParams();
  60. const [submitted, setSubmitted] = useState(false);
  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 [showEmailLogin, setShowEmailLogin] = 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 [emailLoginLoading, setEmailLoginLoading] = useState(false);
  72. const [loginLoading, setLoginLoading] = useState(false);
  73. const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
  74. const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] =
  75. useState(false);
  76. const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
  77. const [showTwoFA, setShowTwoFA] = useState(false);
  78. const [passkeySupported, setPasskeySupported] = useState(false);
  79. const [passkeyLoading, setPasskeyLoading] = useState(false);
  80. const [agreedToTerms, setAgreedToTerms] = useState(false);
  81. const [hasUserAgreement, setHasUserAgreement] = useState(false);
  82. const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false);
  83. const logo = getLogo();
  84. const systemName = getSystemName();
  85. let affCode = new URLSearchParams(window.location.search).get('aff');
  86. if (affCode) {
  87. localStorage.setItem('aff', affCode);
  88. }
  89. const [status] = useState(() => {
  90. const savedStatus = localStorage.getItem('status');
  91. return savedStatus ? JSON.parse(savedStatus) : {};
  92. });
  93. useEffect(() => {
  94. if (status.turnstile_check) {
  95. setTurnstileEnabled(true);
  96. setTurnstileSiteKey(status.turnstile_site_key);
  97. }
  98. // 从 status 获取用户协议和隐私政策的启用状态
  99. setHasUserAgreement(status.user_agreement_enabled || false);
  100. setHasPrivacyPolicy(status.privacy_policy_enabled || false);
  101. }, [status]);
  102. useEffect(() => {
  103. isPasskeySupported()
  104. .then(setPasskeySupported)
  105. .catch(() => setPasskeySupported(false));
  106. }, []);
  107. useEffect(() => {
  108. if (searchParams.get('expired')) {
  109. showError(t('未登录或登录已过期,请重新登录'));
  110. }
  111. }, []);
  112. const onWeChatLoginClicked = () => {
  113. if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
  114. showInfo(t('请先阅读并同意用户协议和隐私政策'));
  115. return;
  116. }
  117. setWechatLoading(true);
  118. setShowWeChatLoginModal(true);
  119. setWechatLoading(false);
  120. };
  121. const onSubmitWeChatVerificationCode = async () => {
  122. if (turnstileEnabled && turnstileToken === '') {
  123. showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
  124. return;
  125. }
  126. setWechatCodeSubmitLoading(true);
  127. try {
  128. const res = await API.get(
  129. `/api/oauth/wechat?code=${inputs.wechat_verification_code}`,
  130. );
  131. const { success, message, data } = res.data;
  132. if (success) {
  133. userDispatch({ type: 'login', payload: data });
  134. localStorage.setItem('user', JSON.stringify(data));
  135. setUserData(data);
  136. updateAPI();
  137. navigate('/');
  138. showSuccess('登录成功!');
  139. setShowWeChatLoginModal(false);
  140. } else {
  141. showError(message);
  142. }
  143. } catch (error) {
  144. showError('登录失败,请重试');
  145. } finally {
  146. setWechatCodeSubmitLoading(false);
  147. }
  148. };
  149. function handleChange(name, value) {
  150. setInputs((inputs) => ({ ...inputs, [name]: value }));
  151. }
  152. async function handleSubmit(e) {
  153. if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
  154. showInfo(t('请先阅读并同意用户协议和隐私政策'));
  155. return;
  156. }
  157. if (turnstileEnabled && turnstileToken === '') {
  158. showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
  159. return;
  160. }
  161. setSubmitted(true);
  162. setLoginLoading(true);
  163. try {
  164. if (username && password) {
  165. const res = await API.post(
  166. `/api/user/login?turnstile=${turnstileToken}`,
  167. {
  168. username,
  169. password,
  170. },
  171. );
  172. const { success, message, data } = res.data;
  173. if (success) {
  174. // 检查是否需要2FA验证
  175. if (data && data.require_2fa) {
  176. setShowTwoFA(true);
  177. setLoginLoading(false);
  178. return;
  179. }
  180. userDispatch({ type: 'login', payload: data });
  181. setUserData(data);
  182. updateAPI();
  183. showSuccess('登录成功!');
  184. if (username === 'root' && password === '123456') {
  185. Modal.error({
  186. title: '您正在使用默认密码!',
  187. content: '请立刻修改默认密码!',
  188. centered: true,
  189. });
  190. }
  191. navigate('/console');
  192. } else {
  193. showError(message);
  194. }
  195. } else {
  196. showError('请输入用户名和密码!');
  197. }
  198. } catch (error) {
  199. showError('登录失败,请重试');
  200. } finally {
  201. setLoginLoading(false);
  202. }
  203. }
  204. // 添加Telegram登录处理函数
  205. const onTelegramLoginClicked = async (response) => {
  206. if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
  207. showInfo(t('请先阅读并同意用户协议和隐私政策'));
  208. return;
  209. }
  210. const fields = [
  211. 'id',
  212. 'first_name',
  213. 'last_name',
  214. 'username',
  215. 'photo_url',
  216. 'auth_date',
  217. 'hash',
  218. 'lang',
  219. ];
  220. const params = {};
  221. fields.forEach((field) => {
  222. if (response[field]) {
  223. params[field] = response[field];
  224. }
  225. });
  226. try {
  227. const res = await API.get(`/api/oauth/telegram/login`, { params });
  228. const { success, message, data } = res.data;
  229. if (success) {
  230. userDispatch({ type: 'login', payload: data });
  231. localStorage.setItem('user', JSON.stringify(data));
  232. showSuccess('登录成功!');
  233. setUserData(data);
  234. updateAPI();
  235. navigate('/');
  236. } else {
  237. showError(message);
  238. }
  239. } catch (error) {
  240. showError('登录失败,请重试');
  241. }
  242. };
  243. // 包装的GitHub登录点击处理
  244. const handleGitHubClick = () => {
  245. if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
  246. showInfo(t('请先阅读并同意用户协议和隐私政策'));
  247. return;
  248. }
  249. setGithubLoading(true);
  250. try {
  251. onGitHubOAuthClicked(status.github_client_id);
  252. } finally {
  253. // 由于重定向,这里不会执行到,但为了完整性添加
  254. setTimeout(() => setGithubLoading(false), 3000);
  255. }
  256. };
  257. // 包装的OIDC登录点击处理
  258. const handleOIDCClick = () => {
  259. if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
  260. showInfo(t('请先阅读并同意用户协议和隐私政策'));
  261. return;
  262. }
  263. setOidcLoading(true);
  264. try {
  265. onOIDCClicked(status.oidc_authorization_endpoint, status.oidc_client_id);
  266. } finally {
  267. // 由于重定向,这里不会执行到,但为了完整性添加
  268. setTimeout(() => setOidcLoading(false), 3000);
  269. }
  270. };
  271. // 包装的LinuxDO登录点击处理
  272. const handleLinuxDOClick = () => {
  273. if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
  274. showInfo(t('请先阅读并同意用户协议和隐私政策'));
  275. return;
  276. }
  277. setLinuxdoLoading(true);
  278. try {
  279. onLinuxDOOAuthClicked(status.linuxdo_client_id);
  280. } finally {
  281. // 由于重定向,这里不会执行到,但为了完整性添加
  282. setTimeout(() => setLinuxdoLoading(false), 3000);
  283. }
  284. };
  285. // 包装的邮箱登录选项点击处理
  286. const handleEmailLoginClick = () => {
  287. setEmailLoginLoading(true);
  288. setShowEmailLogin(true);
  289. setEmailLoginLoading(false);
  290. };
  291. const handlePasskeyLogin = async () => {
  292. if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
  293. showInfo(t('请先阅读并同意用户协议和隐私政策'));
  294. return;
  295. }
  296. if (!passkeySupported) {
  297. showInfo('当前环境无法使用 Passkey 登录');
  298. return;
  299. }
  300. if (!window.PublicKeyCredential) {
  301. showInfo('当前浏览器不支持 Passkey');
  302. return;
  303. }
  304. setPasskeyLoading(true);
  305. try {
  306. const beginRes = await API.post('/api/user/passkey/login/begin');
  307. const { success, message, data } = beginRes.data;
  308. if (!success) {
  309. showError(message || '无法发起 Passkey 登录');
  310. return;
  311. }
  312. const publicKeyOptions = prepareCredentialRequestOptions(
  313. data?.options || data?.publicKey || data,
  314. );
  315. const assertion = await navigator.credentials.get({
  316. publicKey: publicKeyOptions,
  317. });
  318. const payload = buildAssertionResult(assertion);
  319. if (!payload) {
  320. showError('Passkey 验证失败,请重试');
  321. return;
  322. }
  323. const finishRes = await API.post(
  324. '/api/user/passkey/login/finish',
  325. payload,
  326. );
  327. const finish = finishRes.data;
  328. if (finish.success) {
  329. userDispatch({ type: 'login', payload: finish.data });
  330. setUserData(finish.data);
  331. updateAPI();
  332. showSuccess('登录成功!');
  333. navigate('/console');
  334. } else {
  335. showError(finish.message || 'Passkey 登录失败,请重试');
  336. }
  337. } catch (error) {
  338. if (error?.name === 'AbortError') {
  339. showInfo('已取消 Passkey 登录');
  340. } else {
  341. showError('Passkey 登录失败,请重试');
  342. }
  343. } finally {
  344. setPasskeyLoading(false);
  345. }
  346. };
  347. // 包装的重置密码点击处理
  348. const handleResetPasswordClick = () => {
  349. setResetPasswordLoading(true);
  350. navigate('/reset');
  351. setResetPasswordLoading(false);
  352. };
  353. // 包装的其他登录选项点击处理
  354. const handleOtherLoginOptionsClick = () => {
  355. setOtherLoginOptionsLoading(true);
  356. setShowEmailLogin(false);
  357. setOtherLoginOptionsLoading(false);
  358. };
  359. // 2FA验证成功处理
  360. const handle2FASuccess = (data) => {
  361. userDispatch({ type: 'login', payload: data });
  362. setUserData(data);
  363. updateAPI();
  364. showSuccess('登录成功!');
  365. navigate('/console');
  366. };
  367. // 返回登录页面
  368. const handleBackToLogin = () => {
  369. setShowTwoFA(false);
  370. setInputs({ username: '', password: '', wechat_verification_code: '' });
  371. };
  372. const renderOAuthOptions = () => {
  373. return (
  374. <div className='flex flex-col items-center'>
  375. <div className='w-full max-w-md'>
  376. <div className='flex items-center justify-center mb-6 gap-2'>
  377. <img src={logo} alt='Logo' className='h-10 rounded-full' />
  378. <Title heading={3} className='!text-gray-800'>
  379. {systemName}
  380. </Title>
  381. </div>
  382. <Card className='border-0 !rounded-2xl overflow-hidden'>
  383. <div className='flex justify-center pt-6 pb-2'>
  384. <Title heading={3} className='text-gray-800 dark:text-gray-200'>
  385. {t('登 录')}
  386. </Title>
  387. </div>
  388. <div className='px-2 py-8'>
  389. <div className='space-y-3'>
  390. {status.wechat_login && (
  391. <Button
  392. theme='outline'
  393. className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
  394. type='tertiary'
  395. icon={
  396. <Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />
  397. }
  398. onClick={onWeChatLoginClicked}
  399. loading={wechatLoading}
  400. >
  401. <span className='ml-3'>{t('使用 微信 继续')}</span>
  402. </Button>
  403. )}
  404. {status.github_oauth && (
  405. <Button
  406. theme='outline'
  407. className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
  408. type='tertiary'
  409. icon={<IconGithubLogo size='large' />}
  410. onClick={handleGitHubClick}
  411. loading={githubLoading}
  412. >
  413. <span className='ml-3'>{t('使用 GitHub 继续')}</span>
  414. </Button>
  415. )}
  416. {status.oidc_enabled && (
  417. <Button
  418. theme='outline'
  419. className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
  420. type='tertiary'
  421. icon={<OIDCIcon style={{ color: '#1877F2' }} />}
  422. onClick={handleOIDCClick}
  423. loading={oidcLoading}
  424. >
  425. <span className='ml-3'>{t('使用 OIDC 继续')}</span>
  426. </Button>
  427. )}
  428. {status.linuxdo_oauth && (
  429. <Button
  430. theme='outline'
  431. className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
  432. type='tertiary'
  433. icon={
  434. <LinuxDoIcon
  435. style={{
  436. color: '#E95420',
  437. width: '20px',
  438. height: '20px',
  439. }}
  440. />
  441. }
  442. onClick={handleLinuxDOClick}
  443. loading={linuxdoLoading}
  444. >
  445. <span className='ml-3'>{t('使用 LinuxDO 继续')}</span>
  446. </Button>
  447. )}
  448. {status.telegram_oauth && (
  449. <div className='flex justify-center my-2'>
  450. <TelegramLoginButton
  451. dataOnauth={onTelegramLoginClicked}
  452. botName={status.telegram_bot_name}
  453. />
  454. </div>
  455. )}
  456. {status.passkey_login && passkeySupported && (
  457. <Button
  458. theme='outline'
  459. className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
  460. type='tertiary'
  461. icon={<IconKey size='large' />}
  462. onClick={handlePasskeyLogin}
  463. loading={passkeyLoading}
  464. >
  465. <span className='ml-3'>{t('使用 Passkey 登录')}</span>
  466. </Button>
  467. )}
  468. <Divider margin='12px' align='center'>
  469. {t('或')}
  470. </Divider>
  471. <Button
  472. theme='solid'
  473. type='primary'
  474. className='w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors'
  475. icon={<IconMail size='large' />}
  476. onClick={handleEmailLoginClick}
  477. loading={emailLoginLoading}
  478. >
  479. <span className='ml-3'>{t('使用 邮箱或用户名 登录')}</span>
  480. </Button>
  481. </div>
  482. {(hasUserAgreement || hasPrivacyPolicy) && (
  483. <div className='mt-6'>
  484. <Checkbox
  485. checked={agreedToTerms}
  486. onChange={(e) => setAgreedToTerms(e.target.checked)}
  487. >
  488. <Text size='small' className='text-gray-600'>
  489. {t('我已阅读并同意')}
  490. {hasUserAgreement && (
  491. <>
  492. <a
  493. href='/user-agreement'
  494. target='_blank'
  495. rel='noopener noreferrer'
  496. className='text-blue-600 hover:text-blue-800 mx-1'
  497. >
  498. {t('用户协议')}
  499. </a>
  500. </>
  501. )}
  502. {hasUserAgreement && hasPrivacyPolicy && t('和')}
  503. {hasPrivacyPolicy && (
  504. <>
  505. <a
  506. href='/privacy-policy'
  507. target='_blank'
  508. rel='noopener noreferrer'
  509. className='text-blue-600 hover:text-blue-800 mx-1'
  510. >
  511. {t('隐私政策')}
  512. </a>
  513. </>
  514. )}
  515. </Text>
  516. </Checkbox>
  517. </div>
  518. )}
  519. {!status.self_use_mode_enabled && (
  520. <div className='mt-6 text-center text-sm'>
  521. <Text>
  522. {t('没有账户?')}{' '}
  523. <Link
  524. to='/register'
  525. className='text-blue-600 hover:text-blue-800 font-medium'
  526. >
  527. {t('注册')}
  528. </Link>
  529. </Text>
  530. </div>
  531. )}
  532. </div>
  533. </Card>
  534. </div>
  535. </div>
  536. );
  537. };
  538. const renderEmailLoginForm = () => {
  539. return (
  540. <div className='flex flex-col items-center'>
  541. <div className='w-full max-w-md'>
  542. <div className='flex items-center justify-center mb-6 gap-2'>
  543. <img src={logo} alt='Logo' className='h-10 rounded-full' />
  544. <Title heading={3}>{systemName}</Title>
  545. </div>
  546. <Card className='border-0 !rounded-2xl overflow-hidden'>
  547. <div className='flex justify-center pt-6 pb-2'>
  548. <Title heading={3} className='text-gray-800 dark:text-gray-200'>
  549. {t('登 录')}
  550. </Title>
  551. </div>
  552. <div className='px-2 py-8'>
  553. {status.passkey_login && passkeySupported && (
  554. <Button
  555. theme='outline'
  556. type='tertiary'
  557. className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors mb-4'
  558. icon={<IconKey size='large' />}
  559. onClick={handlePasskeyLogin}
  560. loading={passkeyLoading}
  561. >
  562. <span className='ml-3'>{t('使用 Passkey 登录')}</span>
  563. </Button>
  564. )}
  565. <Form className='space-y-3'>
  566. <Form.Input
  567. field='username'
  568. label={t('用户名或邮箱')}
  569. placeholder={t('请输入您的用户名或邮箱地址')}
  570. name='username'
  571. onChange={(value) => handleChange('username', value)}
  572. prefix={<IconMail />}
  573. />
  574. <Form.Input
  575. field='password'
  576. label={t('密码')}
  577. placeholder={t('请输入您的密码')}
  578. name='password'
  579. mode='password'
  580. onChange={(value) => handleChange('password', value)}
  581. prefix={<IconLock />}
  582. />
  583. {(hasUserAgreement || hasPrivacyPolicy) && (
  584. <div className='pt-4'>
  585. <Checkbox
  586. checked={agreedToTerms}
  587. onChange={(e) => setAgreedToTerms(e.target.checked)}
  588. >
  589. <Text size='small' className='text-gray-600'>
  590. {t('我已阅读并同意')}
  591. {hasUserAgreement && (
  592. <>
  593. <a
  594. href='/user-agreement'
  595. target='_blank'
  596. rel='noopener noreferrer'
  597. className='text-blue-600 hover:text-blue-800 mx-1'
  598. >
  599. {t('用户协议')}
  600. </a>
  601. </>
  602. )}
  603. {hasUserAgreement && hasPrivacyPolicy && t('和')}
  604. {hasPrivacyPolicy && (
  605. <>
  606. <a
  607. href='/privacy-policy'
  608. target='_blank'
  609. rel='noopener noreferrer'
  610. className='text-blue-600 hover:text-blue-800 mx-1'
  611. >
  612. {t('隐私政策')}
  613. </a>
  614. </>
  615. )}
  616. </Text>
  617. </Checkbox>
  618. </div>
  619. )}
  620. <div className='space-y-2 pt-2'>
  621. <Button
  622. theme='solid'
  623. className='w-full !rounded-full'
  624. type='primary'
  625. htmlType='submit'
  626. onClick={handleSubmit}
  627. loading={loginLoading}
  628. disabled={(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms}
  629. >
  630. {t('继续')}
  631. </Button>
  632. <Button
  633. theme='borderless'
  634. type='tertiary'
  635. className='w-full !rounded-full'
  636. onClick={handleResetPasswordClick}
  637. loading={resetPasswordLoading}
  638. >
  639. {t('忘记密码?')}
  640. </Button>
  641. </div>
  642. </Form>
  643. {(status.github_oauth ||
  644. status.oidc_enabled ||
  645. status.wechat_login ||
  646. status.linuxdo_oauth ||
  647. status.telegram_oauth) && (
  648. <>
  649. <Divider margin='12px' align='center'>
  650. {t('或')}
  651. </Divider>
  652. <div className='mt-4 text-center'>
  653. <Button
  654. theme='outline'
  655. type='tertiary'
  656. className='w-full !rounded-full'
  657. onClick={handleOtherLoginOptionsClick}
  658. loading={otherLoginOptionsLoading}
  659. >
  660. {t('其他登录选项')}
  661. </Button>
  662. </div>
  663. </>
  664. )}
  665. {!status.self_use_mode_enabled && (
  666. <div className='mt-6 text-center text-sm'>
  667. <Text>
  668. {t('没有账户?')}{' '}
  669. <Link
  670. to='/register'
  671. className='text-blue-600 hover:text-blue-800 font-medium'
  672. >
  673. {t('注册')}
  674. </Link>
  675. </Text>
  676. </div>
  677. )}
  678. </div>
  679. </Card>
  680. </div>
  681. </div>
  682. );
  683. };
  684. // 微信登录模态框
  685. const renderWeChatLoginModal = () => {
  686. return (
  687. <Modal
  688. title={t('微信扫码登录')}
  689. visible={showWeChatLoginModal}
  690. maskClosable={true}
  691. onOk={onSubmitWeChatVerificationCode}
  692. onCancel={() => setShowWeChatLoginModal(false)}
  693. okText={t('登录')}
  694. centered={true}
  695. okButtonProps={{
  696. loading: wechatCodeSubmitLoading,
  697. }}
  698. >
  699. <div className='flex flex-col items-center'>
  700. <img src={status.wechat_qrcode} alt='微信二维码' className='mb-4' />
  701. </div>
  702. <div className='text-center mb-4'>
  703. <p>
  704. {t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}
  705. </p>
  706. </div>
  707. <Form>
  708. <Form.Input
  709. field='wechat_verification_code'
  710. placeholder={t('验证码')}
  711. label={t('验证码')}
  712. value={inputs.wechat_verification_code}
  713. onChange={(value) =>
  714. handleChange('wechat_verification_code', value)
  715. }
  716. />
  717. </Form>
  718. </Modal>
  719. );
  720. };
  721. // 2FA验证弹窗
  722. const render2FAModal = () => {
  723. return (
  724. <Modal
  725. title={
  726. <div className='flex items-center'>
  727. <div className='w-8 h-8 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mr-3'>
  728. <svg
  729. className='w-4 h-4 text-green-600 dark:text-green-400'
  730. fill='currentColor'
  731. viewBox='0 0 20 20'
  732. >
  733. <path
  734. fillRule='evenodd'
  735. 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'
  736. clipRule='evenodd'
  737. />
  738. </svg>
  739. </div>
  740. 两步验证
  741. </div>
  742. }
  743. visible={showTwoFA}
  744. onCancel={handleBackToLogin}
  745. footer={null}
  746. width={450}
  747. centered
  748. >
  749. <TwoFAVerification
  750. onSuccess={handle2FASuccess}
  751. onBack={handleBackToLogin}
  752. isModal={true}
  753. />
  754. </Modal>
  755. );
  756. };
  757. return (
  758. <div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
  759. {/* 背景模糊晕染球 */}
  760. <div
  761. className='blur-ball blur-ball-indigo'
  762. style={{ top: '-80px', right: '-80px', transform: 'none' }}
  763. />
  764. <div
  765. className='blur-ball blur-ball-teal'
  766. style={{ top: '50%', left: '-120px' }}
  767. />
  768. <div className='w-full max-w-sm mt-[60px]'>
  769. {showEmailLogin ||
  770. !(
  771. status.github_oauth ||
  772. status.oidc_enabled ||
  773. status.wechat_login ||
  774. status.linuxdo_oauth ||
  775. status.telegram_oauth
  776. )
  777. ? renderEmailLoginForm()
  778. : renderOAuthOptions()}
  779. {renderWeChatLoginModal()}
  780. {render2FAModal()}
  781. {turnstileEnabled && (
  782. <div className='flex justify-center mt-6'>
  783. <Turnstile
  784. sitekey={turnstileSiteKey}
  785. onVerify={(token) => {
  786. setTurnstileToken(token);
  787. }}
  788. />
  789. </div>
  790. )}
  791. </div>
  792. </div>
  793. );
  794. };
  795. export default LoginForm;