LoginForm.jsx 31 KB

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