LoginForm.jsx 31 KB

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