初始化导入 new-api 源码
This commit is contained in:
983
web/src/components/auth/LoginForm.jsx
Normal file
983
web/src/components/auth/LoginForm.jsx
Normal file
@@ -0,0 +1,983 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { UserContext } from '../../context/User';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import {
|
||||
API,
|
||||
getLogo,
|
||||
showError,
|
||||
showInfo,
|
||||
showSuccess,
|
||||
updateAPI,
|
||||
getSystemName,
|
||||
getOAuthProviderIcon,
|
||||
setUserData,
|
||||
onGitHubOAuthClicked,
|
||||
onDiscordOAuthClicked,
|
||||
onOIDCClicked,
|
||||
onLinuxDOOAuthClicked,
|
||||
onCustomOAuthClicked,
|
||||
prepareCredentialRequestOptions,
|
||||
buildAssertionResult,
|
||||
isPasskeySupported,
|
||||
} from '../../helpers';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Form,
|
||||
Icon,
|
||||
Modal,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
import TelegramLoginButton from 'react-telegram-login';
|
||||
|
||||
import {
|
||||
IconGithubLogo,
|
||||
IconMail,
|
||||
IconLock,
|
||||
IconKey,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import OIDCIcon from '../common/logo/OIDCIcon';
|
||||
import WeChatIcon from '../common/logo/WeChatIcon';
|
||||
import LinuxDoIcon from '../common/logo/LinuxDoIcon';
|
||||
import TwoFAVerification from './TwoFAVerification';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SiDiscord } from 'react-icons/si';
|
||||
|
||||
const LoginForm = () => {
|
||||
let navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const githubButtonTextKeyByState = {
|
||||
idle: '使用 GitHub 继续',
|
||||
redirecting: '正在跳转 GitHub...',
|
||||
timeout: '请求超时,请刷新页面后重新发起 GitHub 登录',
|
||||
};
|
||||
const [inputs, setInputs] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
wechat_verification_code: '',
|
||||
});
|
||||
const { username, password } = inputs;
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
||||
const [turnstileToken, setTurnstileToken] = useState('');
|
||||
const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
|
||||
const [showEmailLogin, setShowEmailLogin] = useState(false);
|
||||
const [wechatLoading, setWechatLoading] = useState(false);
|
||||
const [githubLoading, setGithubLoading] = useState(false);
|
||||
const [discordLoading, setDiscordLoading] = useState(false);
|
||||
const [oidcLoading, setOidcLoading] = useState(false);
|
||||
const [linuxdoLoading, setLinuxdoLoading] = useState(false);
|
||||
const [emailLoginLoading, setEmailLoginLoading] = useState(false);
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
|
||||
const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] =
|
||||
useState(false);
|
||||
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
||||
const [showTwoFA, setShowTwoFA] = useState(false);
|
||||
const [passkeySupported, setPasskeySupported] = useState(false);
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
const [hasUserAgreement, setHasUserAgreement] = useState(false);
|
||||
const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false);
|
||||
const [githubButtonState, setGithubButtonState] = useState('idle');
|
||||
const [githubButtonDisabled, setGithubButtonDisabled] = useState(false);
|
||||
const githubTimeoutRef = useRef(null);
|
||||
const githubButtonText = t(githubButtonTextKeyByState[githubButtonState]);
|
||||
const [customOAuthLoading, setCustomOAuthLoading] = useState({});
|
||||
|
||||
const logo = getLogo();
|
||||
const systemName = getSystemName();
|
||||
|
||||
let affCode = new URLSearchParams(window.location.search).get('aff');
|
||||
if (affCode) {
|
||||
localStorage.setItem('aff', affCode);
|
||||
}
|
||||
|
||||
const status = useMemo(() => {
|
||||
if (statusState?.status) return statusState.status;
|
||||
const savedStatus = localStorage.getItem('status');
|
||||
if (!savedStatus) return {};
|
||||
try {
|
||||
return JSON.parse(savedStatus) || {};
|
||||
} catch (err) {
|
||||
return {};
|
||||
}
|
||||
}, [statusState?.status]);
|
||||
const hasCustomOAuthProviders =
|
||||
(status.custom_oauth_providers || []).length > 0;
|
||||
const hasOAuthLoginOptions = Boolean(
|
||||
status.github_oauth ||
|
||||
status.discord_oauth ||
|
||||
status.oidc_enabled ||
|
||||
status.wechat_login ||
|
||||
status.linuxdo_oauth ||
|
||||
status.telegram_oauth ||
|
||||
hasCustomOAuthProviders,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (status?.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
|
||||
// 从 status 获取用户协议和隐私政策的启用状态
|
||||
setHasUserAgreement(status?.user_agreement_enabled || false);
|
||||
setHasPrivacyPolicy(status?.privacy_policy_enabled || false);
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
isPasskeySupported()
|
||||
.then(setPasskeySupported)
|
||||
.catch(() => setPasskeySupported(false));
|
||||
|
||||
return () => {
|
||||
if (githubTimeoutRef.current) {
|
||||
clearTimeout(githubTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('expired')) {
|
||||
showError(t('未登录或登录已过期,请重新登录'));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onWeChatLoginClicked = () => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
setWechatLoading(true);
|
||||
setShowWeChatLoginModal(true);
|
||||
setWechatLoading(false);
|
||||
};
|
||||
|
||||
const onSubmitWeChatVerificationCode = async () => {
|
||||
if (turnstileEnabled && turnstileToken === '') {
|
||||
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
||||
return;
|
||||
}
|
||||
setWechatCodeSubmitLoading(true);
|
||||
try {
|
||||
const res = await API.get(
|
||||
`/api/oauth/wechat?code=${inputs.wechat_verification_code}`,
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
localStorage.setItem('user', JSON.stringify(data));
|
||||
setUserData(data);
|
||||
updateAPI();
|
||||
navigate('/');
|
||||
showSuccess('登录成功!');
|
||||
setShowWeChatLoginModal(false);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('登录失败,请重试');
|
||||
} finally {
|
||||
setWechatCodeSubmitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
function handleChange(name, value) {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
if (turnstileEnabled && turnstileToken === '') {
|
||||
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
||||
return;
|
||||
}
|
||||
setSubmitted(true);
|
||||
setLoginLoading(true);
|
||||
try {
|
||||
if (username && password) {
|
||||
const res = await API.post(
|
||||
`/api/user/login?turnstile=${turnstileToken}`,
|
||||
{
|
||||
username,
|
||||
password,
|
||||
},
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
// 检查是否需要2FA验证
|
||||
if (data && data.require_2fa) {
|
||||
setShowTwoFA(true);
|
||||
setLoginLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
setUserData(data);
|
||||
updateAPI();
|
||||
showSuccess('登录成功!');
|
||||
if (username === 'root' && password === '123456') {
|
||||
Modal.error({
|
||||
title: '您正在使用默认密码!',
|
||||
content: '请立刻修改默认密码!',
|
||||
centered: true,
|
||||
});
|
||||
}
|
||||
navigate('/console');
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} else {
|
||||
showError('请输入用户名和密码!');
|
||||
}
|
||||
} catch (error) {
|
||||
showError('登录失败,请重试');
|
||||
} finally {
|
||||
setLoginLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加Telegram登录处理函数
|
||||
const onTelegramLoginClicked = async (response) => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
const fields = [
|
||||
'id',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'username',
|
||||
'photo_url',
|
||||
'auth_date',
|
||||
'hash',
|
||||
'lang',
|
||||
];
|
||||
const params = {};
|
||||
fields.forEach((field) => {
|
||||
if (response[field]) {
|
||||
params[field] = response[field];
|
||||
}
|
||||
});
|
||||
try {
|
||||
const res = await API.get(`/api/oauth/telegram/login`, { params });
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
localStorage.setItem('user', JSON.stringify(data));
|
||||
showSuccess('登录成功!');
|
||||
setUserData(data);
|
||||
updateAPI();
|
||||
navigate('/');
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('登录失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 包装的GitHub登录点击处理
|
||||
const handleGitHubClick = () => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
if (githubButtonDisabled) {
|
||||
return;
|
||||
}
|
||||
setGithubLoading(true);
|
||||
setGithubButtonDisabled(true);
|
||||
setGithubButtonState('redirecting');
|
||||
if (githubTimeoutRef.current) {
|
||||
clearTimeout(githubTimeoutRef.current);
|
||||
}
|
||||
githubTimeoutRef.current = setTimeout(() => {
|
||||
setGithubLoading(false);
|
||||
setGithubButtonState('timeout');
|
||||
setGithubButtonDisabled(true);
|
||||
}, 20000);
|
||||
try {
|
||||
onGitHubOAuthClicked(status.github_client_id, { shouldLogout: true });
|
||||
} finally {
|
||||
// 由于重定向,这里不会执行到,但为了完整性添加
|
||||
setTimeout(() => setGithubLoading(false), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// 包装的Discord登录点击处理
|
||||
const handleDiscordClick = () => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
setDiscordLoading(true);
|
||||
try {
|
||||
onDiscordOAuthClicked(status.discord_client_id, { shouldLogout: true });
|
||||
} finally {
|
||||
// 由于重定向,这里不会执行到,但为了完整性添加
|
||||
setTimeout(() => setDiscordLoading(false), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// 包装的OIDC登录点击处理
|
||||
const handleOIDCClick = () => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
onOIDCClicked(
|
||||
status.oidc_authorization_endpoint,
|
||||
status.oidc_client_id,
|
||||
false,
|
||||
{ shouldLogout: true },
|
||||
);
|
||||
} finally {
|
||||
// 由于重定向,这里不会执行到,但为了完整性添加
|
||||
setTimeout(() => setOidcLoading(false), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// 包装的LinuxDO登录点击处理
|
||||
const handleLinuxDOClick = () => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
setLinuxdoLoading(true);
|
||||
try {
|
||||
onLinuxDOOAuthClicked(status.linuxdo_client_id, { shouldLogout: true });
|
||||
} finally {
|
||||
// 由于重定向,这里不会执行到,但为了完整性添加
|
||||
setTimeout(() => setLinuxdoLoading(false), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// 包装的自定义OAuth登录点击处理
|
||||
const handleCustomOAuthClick = (provider) => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: true }));
|
||||
try {
|
||||
onCustomOAuthClicked(provider, { shouldLogout: true });
|
||||
} finally {
|
||||
// 由于重定向,这里不会执行到,但为了完整性添加
|
||||
setTimeout(() => {
|
||||
setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: false }));
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// 包装的邮箱登录选项点击处理
|
||||
const handleEmailLoginClick = () => {
|
||||
setEmailLoginLoading(true);
|
||||
setShowEmailLogin(true);
|
||||
setEmailLoginLoading(false);
|
||||
};
|
||||
|
||||
const handlePasskeyLogin = async () => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
if (!passkeySupported) {
|
||||
showInfo('当前环境无法使用 Passkey 登录');
|
||||
return;
|
||||
}
|
||||
if (!window.PublicKeyCredential) {
|
||||
showInfo('当前浏览器不支持 Passkey');
|
||||
return;
|
||||
}
|
||||
|
||||
setPasskeyLoading(true);
|
||||
try {
|
||||
const beginRes = await API.post('/api/user/passkey/login/begin');
|
||||
const { success, message, data } = beginRes.data;
|
||||
if (!success) {
|
||||
showError(message || '无法发起 Passkey 登录');
|
||||
return;
|
||||
}
|
||||
|
||||
const publicKeyOptions = prepareCredentialRequestOptions(
|
||||
data?.options || data?.publicKey || data,
|
||||
);
|
||||
const assertion = await navigator.credentials.get({
|
||||
publicKey: publicKeyOptions,
|
||||
});
|
||||
const payload = buildAssertionResult(assertion);
|
||||
if (!payload) {
|
||||
showError('Passkey 验证失败,请重试');
|
||||
return;
|
||||
}
|
||||
|
||||
const finishRes = await API.post(
|
||||
'/api/user/passkey/login/finish',
|
||||
payload,
|
||||
);
|
||||
const finish = finishRes.data;
|
||||
if (finish.success) {
|
||||
userDispatch({ type: 'login', payload: finish.data });
|
||||
setUserData(finish.data);
|
||||
updateAPI();
|
||||
showSuccess('登录成功!');
|
||||
navigate('/console');
|
||||
} else {
|
||||
showError(finish.message || 'Passkey 登录失败,请重试');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
showInfo('已取消 Passkey 登录');
|
||||
} else {
|
||||
showError('Passkey 登录失败,请重试');
|
||||
}
|
||||
} finally {
|
||||
setPasskeyLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 包装的重置密码点击处理
|
||||
const handleResetPasswordClick = () => {
|
||||
setResetPasswordLoading(true);
|
||||
navigate('/reset');
|
||||
setResetPasswordLoading(false);
|
||||
};
|
||||
|
||||
// 包装的其他登录选项点击处理
|
||||
const handleOtherLoginOptionsClick = () => {
|
||||
setOtherLoginOptionsLoading(true);
|
||||
setShowEmailLogin(false);
|
||||
setOtherLoginOptionsLoading(false);
|
||||
};
|
||||
|
||||
// 2FA验证成功处理
|
||||
const handle2FASuccess = (data) => {
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
setUserData(data);
|
||||
updateAPI();
|
||||
showSuccess('登录成功!');
|
||||
navigate('/console');
|
||||
};
|
||||
|
||||
// 返回登录页面
|
||||
const handleBackToLogin = () => {
|
||||
setShowTwoFA(false);
|
||||
setInputs({ username: '', password: '', wechat_verification_code: '' });
|
||||
};
|
||||
|
||||
const renderOAuthOptions = () => {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
||||
<Title heading={3} className='!text-gray-800'>
|
||||
{systemName}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
||||
<div className='flex justify-center pt-6 pb-2'>
|
||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
||||
{t('登 录')}
|
||||
</Title>
|
||||
</div>
|
||||
<div className='px-2 py-8'>
|
||||
<div className='space-y-3'>
|
||||
{status.wechat_login && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={
|
||||
<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />
|
||||
}
|
||||
onClick={onWeChatLoginClicked}
|
||||
loading={wechatLoading}
|
||||
>
|
||||
<span className='ml-3'>{t('使用 微信 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.github_oauth && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={<IconGithubLogo size='large' />}
|
||||
onClick={handleGitHubClick}
|
||||
loading={githubLoading}
|
||||
disabled={githubButtonDisabled}
|
||||
>
|
||||
<span className='ml-3'>{githubButtonText}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.discord_oauth && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={
|
||||
<SiDiscord
|
||||
style={{
|
||||
color: '#5865F2',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={handleDiscordClick}
|
||||
loading={discordLoading}
|
||||
>
|
||||
<span className='ml-3'>{t('使用 Discord 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.oidc_enabled && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={<OIDCIcon style={{ color: '#1877F2' }} />}
|
||||
onClick={handleOIDCClick}
|
||||
loading={oidcLoading}
|
||||
>
|
||||
<span className='ml-3'>{t('使用 OIDC 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.linuxdo_oauth && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={
|
||||
<LinuxDoIcon
|
||||
style={{
|
||||
color: '#E95420',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={handleLinuxDOClick}
|
||||
loading={linuxdoLoading}
|
||||
>
|
||||
<span className='ml-3'>{t('使用 LinuxDO 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.custom_oauth_providers &&
|
||||
status.custom_oauth_providers.map((provider) => (
|
||||
<Button
|
||||
key={provider.slug}
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={getOAuthProviderIcon(provider.icon || '', 20)}
|
||||
onClick={() => handleCustomOAuthClick(provider)}
|
||||
loading={customOAuthLoading[provider.slug]}
|
||||
>
|
||||
<span className='ml-3'>
|
||||
{t('使用 {{name}} 继续', { name: provider.name })}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{status.telegram_oauth && (
|
||||
<div className='flex justify-center my-2'>
|
||||
<TelegramLoginButton
|
||||
dataOnauth={onTelegramLoginClicked}
|
||||
botName={status.telegram_bot_name}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.passkey_login && passkeySupported && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={<IconKey size='large' />}
|
||||
onClick={handlePasskeyLogin}
|
||||
loading={passkeyLoading}
|
||||
>
|
||||
<span className='ml-3'>{t('使用 Passkey 登录')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Divider margin='12px' align='center'>
|
||||
{t('或')}
|
||||
</Divider>
|
||||
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
className='w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors'
|
||||
icon={<IconMail size='large' />}
|
||||
onClick={handleEmailLoginClick}
|
||||
loading={emailLoginLoading}
|
||||
>
|
||||
<span className='ml-3'>{t('使用 邮箱或用户名 登录')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(hasUserAgreement || hasPrivacyPolicy) && (
|
||||
<div className='mt-6'>
|
||||
<Checkbox
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
>
|
||||
<Text size='small' className='text-gray-600'>
|
||||
{t('我已阅读并同意')}
|
||||
{hasUserAgreement && (
|
||||
<>
|
||||
<a
|
||||
href='/user-agreement'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
>
|
||||
{t('用户协议')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{hasUserAgreement && hasPrivacyPolicy && t('和')}
|
||||
{hasPrivacyPolicy && (
|
||||
<>
|
||||
<a
|
||||
href='/privacy-policy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
>
|
||||
{t('隐私政策')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!status.self_use_mode_enabled && (
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
<Text>
|
||||
{t('没有账户?')}{' '}
|
||||
<Link
|
||||
to='/register'
|
||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
||||
>
|
||||
{t('注册')}
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEmailLoginForm = () => {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
||||
<Title heading={3}>{systemName}</Title>
|
||||
</div>
|
||||
|
||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
||||
<div className='flex justify-center pt-6 pb-2'>
|
||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
||||
{t('登 录')}
|
||||
</Title>
|
||||
</div>
|
||||
<div className='px-2 py-8'>
|
||||
{status.passkey_login && passkeySupported && (
|
||||
<Button
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors mb-4'
|
||||
icon={<IconKey size='large' />}
|
||||
onClick={handlePasskeyLogin}
|
||||
loading={passkeyLoading}
|
||||
>
|
||||
<span className='ml-3'>{t('使用 Passkey 登录')}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Form className='space-y-3'>
|
||||
<Form.Input
|
||||
field='username'
|
||||
label={t('用户名或邮箱')}
|
||||
placeholder={t('请输入您的用户名或邮箱地址')}
|
||||
name='username'
|
||||
onChange={(value) => handleChange('username', value)}
|
||||
prefix={<IconMail />}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field='password'
|
||||
label={t('密码')}
|
||||
placeholder={t('请输入您的密码')}
|
||||
name='password'
|
||||
mode='password'
|
||||
onChange={(value) => handleChange('password', value)}
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
|
||||
{(hasUserAgreement || hasPrivacyPolicy) && (
|
||||
<div className='pt-4'>
|
||||
<Checkbox
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
>
|
||||
<Text size='small' className='text-gray-600'>
|
||||
{t('我已阅读并同意')}
|
||||
{hasUserAgreement && (
|
||||
<>
|
||||
<a
|
||||
href='/user-agreement'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
>
|
||||
{t('用户协议')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{hasUserAgreement && hasPrivacyPolicy && t('和')}
|
||||
{hasPrivacyPolicy && (
|
||||
<>
|
||||
<a
|
||||
href='/privacy-policy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
>
|
||||
{t('隐私政策')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='space-y-2 pt-2'>
|
||||
<Button
|
||||
theme='solid'
|
||||
className='w-full !rounded-full'
|
||||
type='primary'
|
||||
htmlType='submit'
|
||||
onClick={handleSubmit}
|
||||
loading={loginLoading}
|
||||
disabled={
|
||||
(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms
|
||||
}
|
||||
>
|
||||
{t('继续')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
className='w-full !rounded-full'
|
||||
onClick={handleResetPasswordClick}
|
||||
loading={resetPasswordLoading}
|
||||
>
|
||||
{t('忘记密码?')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
{hasOAuthLoginOptions && (
|
||||
<>
|
||||
<Divider margin='12px' align='center'>
|
||||
{t('或')}
|
||||
</Divider>
|
||||
|
||||
<div className='mt-4 text-center'>
|
||||
<Button
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
className='w-full !rounded-full'
|
||||
onClick={handleOtherLoginOptionsClick}
|
||||
loading={otherLoginOptionsLoading}
|
||||
>
|
||||
{t('其他登录选项')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!status.self_use_mode_enabled && (
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
<Text>
|
||||
{t('没有账户?')}{' '}
|
||||
<Link
|
||||
to='/register'
|
||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
||||
>
|
||||
{t('注册')}
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 微信登录模态框
|
||||
const renderWeChatLoginModal = () => {
|
||||
return (
|
||||
<Modal
|
||||
title={t('微信扫码登录')}
|
||||
visible={showWeChatLoginModal}
|
||||
maskClosable={true}
|
||||
onOk={onSubmitWeChatVerificationCode}
|
||||
onCancel={() => setShowWeChatLoginModal(false)}
|
||||
okText={t('登录')}
|
||||
centered={true}
|
||||
okButtonProps={{
|
||||
loading: wechatCodeSubmitLoading,
|
||||
}}
|
||||
>
|
||||
<div className='flex flex-col items-center'>
|
||||
<img src={status.wechat_qrcode} alt='微信二维码' className='mb-4' />
|
||||
</div>
|
||||
|
||||
<div className='text-center mb-4'>
|
||||
<p>
|
||||
{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form>
|
||||
<Form.Input
|
||||
field='wechat_verification_code'
|
||||
placeholder={t('验证码')}
|
||||
label={t('验证码')}
|
||||
value={inputs.wechat_verification_code}
|
||||
onChange={(value) =>
|
||||
handleChange('wechat_verification_code', value)
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// 2FA验证弹窗
|
||||
const render2FAModal = () => {
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
<div className='w-8 h-8 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mr-3'>
|
||||
<svg
|
||||
className='w-4 h-4 text-green-600 dark:text-green-400'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<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'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
两步验证
|
||||
</div>
|
||||
}
|
||||
visible={showTwoFA}
|
||||
onCancel={handleBackToLogin}
|
||||
footer={null}
|
||||
width={450}
|
||||
centered
|
||||
>
|
||||
<TwoFAVerification
|
||||
onSuccess={handle2FASuccess}
|
||||
onBack={handleBackToLogin}
|
||||
isModal={true}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
style={{ top: '-80px', right: '-80px', transform: 'none' }}
|
||||
/>
|
||||
<div
|
||||
className='blur-ball blur-ball-teal'
|
||||
style={{ top: '50%', left: '-120px' }}
|
||||
/>
|
||||
<div className='w-full max-w-sm mt-[60px]'>
|
||||
{showEmailLogin ||
|
||||
!hasOAuthLoginOptions
|
||||
? renderEmailLoginForm()
|
||||
: renderOAuthOptions()}
|
||||
{renderWeChatLoginModal()}
|
||||
{render2FAModal()}
|
||||
|
||||
{turnstileEnabled && (
|
||||
<div className='flex justify-center mt-6'>
|
||||
<Turnstile
|
||||
sitekey={turnstileSiteKey}
|
||||
onVerify={(token) => {
|
||||
setTurnstileToken(token);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
||||
107
web/src/components/auth/OAuth2Callback.jsx
Normal file
107
web/src/components/auth/OAuth2Callback.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useContext, useEffect, useRef } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
updateAPI,
|
||||
setUserData,
|
||||
} from '../../helpers';
|
||||
import { UserContext } from '../../context/User';
|
||||
import Loading from '../common/ui/Loading';
|
||||
|
||||
const OAuth2Callback = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [, userDispatch] = useContext(UserContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 防止 React 18 Strict Mode 下重复执行
|
||||
const hasExecuted = useRef(false);
|
||||
|
||||
// 最大重试次数
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
const sendCode = async (code, state, retry = 0) => {
|
||||
try {
|
||||
const { data: resData } = await API.get(
|
||||
`/api/oauth/${props.type}?code=${code}&state=${state}`,
|
||||
);
|
||||
|
||||
const { success, message, data } = resData;
|
||||
|
||||
if (!success) {
|
||||
// 业务错误不重试,直接显示错误
|
||||
showError(message || t('授权失败'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.action === 'bind') {
|
||||
showSuccess(t('绑定成功!'));
|
||||
navigate('/console/personal');
|
||||
} else {
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
localStorage.setItem('user', JSON.stringify(data));
|
||||
setUserData(data);
|
||||
updateAPI();
|
||||
showSuccess(t('登录成功!'));
|
||||
navigate('/console/token');
|
||||
}
|
||||
} catch (error) {
|
||||
// 网络错误等可重试
|
||||
if (retry < MAX_RETRIES) {
|
||||
// 递增的退避等待
|
||||
await new Promise((resolve) => setTimeout(resolve, (retry + 1) * 2000));
|
||||
return sendCode(code, state, retry + 1);
|
||||
}
|
||||
|
||||
// 重试次数耗尽,提示错误并返回设置页面
|
||||
showError(error.message || t('授权失败'));
|
||||
navigate('/console/personal');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 防止 React 18 Strict Mode 下重复执行
|
||||
if (hasExecuted.current) {
|
||||
return;
|
||||
}
|
||||
hasExecuted.current = true;
|
||||
|
||||
const code = searchParams.get('code');
|
||||
const state = searchParams.get('state');
|
||||
|
||||
// 参数缺失直接返回
|
||||
if (!code) {
|
||||
showError(t('未获取到授权码'));
|
||||
navigate('/console/personal');
|
||||
return;
|
||||
}
|
||||
|
||||
sendCode(code, state);
|
||||
}, []);
|
||||
|
||||
return <Loading />;
|
||||
};
|
||||
|
||||
export default OAuth2Callback;
|
||||
220
web/src/components/auth/PasswordResetConfirm.jsx
Normal file
220
web/src/components/auth/PasswordResetConfirm.jsx
Normal file
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
API,
|
||||
copy,
|
||||
showError,
|
||||
showNotice,
|
||||
getLogo,
|
||||
getSystemName,
|
||||
} from '../../helpers';
|
||||
import { useSearchParams, Link } from 'react-router-dom';
|
||||
import { Button, Card, Form, Typography, Banner } from '@douyinfe/semi-ui';
|
||||
import { IconMail, IconLock, IconCopy } from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const PasswordResetConfirm = () => {
|
||||
const { t } = useTranslation();
|
||||
const [inputs, setInputs] = useState({
|
||||
email: '',
|
||||
token: '',
|
||||
});
|
||||
const { email, token } = inputs;
|
||||
const isValidResetLink = email && token;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [disableButton, setDisableButton] = useState(false);
|
||||
const [countdown, setCountdown] = useState(30);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
|
||||
const logo = getLogo();
|
||||
const systemName = getSystemName();
|
||||
|
||||
useEffect(() => {
|
||||
let token = searchParams.get('token');
|
||||
let email = searchParams.get('email');
|
||||
setInputs({
|
||||
token: token || '',
|
||||
email: email || '',
|
||||
});
|
||||
if (formApi) {
|
||||
formApi.setValues({
|
||||
email: email || '',
|
||||
newPassword: newPassword || '',
|
||||
});
|
||||
}
|
||||
}, [searchParams, newPassword, formApi]);
|
||||
|
||||
useEffect(() => {
|
||||
let countdownInterval = null;
|
||||
if (disableButton && countdown > 0) {
|
||||
countdownInterval = setInterval(() => {
|
||||
setCountdown(countdown - 1);
|
||||
}, 1000);
|
||||
} else if (countdown === 0) {
|
||||
setDisableButton(false);
|
||||
setCountdown(30);
|
||||
}
|
||||
return () => clearInterval(countdownInterval);
|
||||
}, [disableButton, countdown]);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
if (!email || !token) {
|
||||
showError(t('无效的重置链接,请重新发起密码重置请求'));
|
||||
return;
|
||||
}
|
||||
setDisableButton(true);
|
||||
setLoading(true);
|
||||
const res = await API.post(`/api/user/reset`, {
|
||||
email,
|
||||
token,
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
let password = res.data.data;
|
||||
setNewPassword(password);
|
||||
await copy(password);
|
||||
showNotice(`${t('密码已重置并已复制到剪贴板:')} ${password}`);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
style={{ top: '-80px', right: '-80px', transform: 'none' }}
|
||||
/>
|
||||
<div
|
||||
className='blur-ball blur-ball-teal'
|
||||
style={{ top: '50%', left: '-120px' }}
|
||||
/>
|
||||
<div className='w-full max-w-sm mt-[60px]'>
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
||||
<Title heading={3} className='!text-gray-800'>
|
||||
{systemName}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
||||
<div className='flex justify-center pt-6 pb-2'>
|
||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
||||
{t('密码重置确认')}
|
||||
</Title>
|
||||
</div>
|
||||
<div className='px-2 py-8'>
|
||||
{!isValidResetLink && (
|
||||
<Banner
|
||||
type='danger'
|
||||
description={t('无效的重置链接,请重新发起密码重置请求')}
|
||||
className='mb-4 !rounded-lg'
|
||||
closeIcon={null}
|
||||
/>
|
||||
)}
|
||||
<Form
|
||||
getFormApi={(api) => setFormApi(api)}
|
||||
initValues={{
|
||||
email: email || '',
|
||||
newPassword: newPassword || '',
|
||||
}}
|
||||
className='space-y-4'
|
||||
>
|
||||
<Form.Input
|
||||
field='email'
|
||||
label={t('邮箱')}
|
||||
name='email'
|
||||
disabled={true}
|
||||
prefix={<IconMail />}
|
||||
placeholder={email ? '' : t('等待获取邮箱信息...')}
|
||||
/>
|
||||
|
||||
{newPassword && (
|
||||
<Form.Input
|
||||
field='newPassword'
|
||||
label={t('新密码')}
|
||||
name='newPassword'
|
||||
disabled={true}
|
||||
prefix={<IconLock />}
|
||||
suffix={
|
||||
<Button
|
||||
icon={<IconCopy />}
|
||||
type='tertiary'
|
||||
theme='borderless'
|
||||
onClick={async () => {
|
||||
await copy(newPassword);
|
||||
showNotice(
|
||||
`${t('密码已复制到剪贴板:')} ${newPassword}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t('复制')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='space-y-2 pt-2'>
|
||||
<Button
|
||||
theme='solid'
|
||||
className='w-full !rounded-full'
|
||||
type='primary'
|
||||
htmlType='submit'
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={
|
||||
disableButton || newPassword || !isValidResetLink
|
||||
}
|
||||
>
|
||||
{newPassword ? t('密码重置完成') : t('确认重置密码')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
<Text>
|
||||
<Link
|
||||
to='/login'
|
||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
||||
>
|
||||
{t('返回登录')}
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordResetConfirm;
|
||||
193
web/src/components/auth/PasswordResetForm.jsx
Normal file
193
web/src/components/auth/PasswordResetForm.jsx
Normal file
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
API,
|
||||
getLogo,
|
||||
showError,
|
||||
showInfo,
|
||||
showSuccess,
|
||||
getSystemName,
|
||||
} from '../../helpers';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import { Button, Card, Form, Typography } from '@douyinfe/semi-ui';
|
||||
import { IconMail } from '@douyinfe/semi-icons';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const PasswordResetForm = () => {
|
||||
const { t } = useTranslation();
|
||||
const [inputs, setInputs] = useState({
|
||||
email: '',
|
||||
});
|
||||
const { email } = inputs;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
||||
const [turnstileToken, setTurnstileToken] = useState('');
|
||||
const [disableButton, setDisableButton] = useState(false);
|
||||
const [countdown, setCountdown] = useState(30);
|
||||
|
||||
const logo = getLogo();
|
||||
const systemName = getSystemName();
|
||||
|
||||
useEffect(() => {
|
||||
let status = localStorage.getItem('status');
|
||||
if (status) {
|
||||
status = JSON.parse(status);
|
||||
if (status.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let countdownInterval = null;
|
||||
if (disableButton && countdown > 0) {
|
||||
countdownInterval = setInterval(() => {
|
||||
setCountdown(countdown - 1);
|
||||
}, 1000);
|
||||
} else if (countdown === 0) {
|
||||
setDisableButton(false);
|
||||
setCountdown(30);
|
||||
}
|
||||
return () => clearInterval(countdownInterval);
|
||||
}, [disableButton, countdown]);
|
||||
|
||||
function handleChange(value) {
|
||||
setInputs((inputs) => ({ ...inputs, email: value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
if (!email) {
|
||||
showError(t('请输入邮箱地址'));
|
||||
return;
|
||||
}
|
||||
if (turnstileEnabled && turnstileToken === '') {
|
||||
showInfo(t('请稍后几秒重试,Turnstile 正在检查用户环境!'));
|
||||
return;
|
||||
}
|
||||
setDisableButton(true);
|
||||
setLoading(true);
|
||||
const res = await API.get(
|
||||
`/api/reset_password?email=${email}&turnstile=${turnstileToken}`,
|
||||
);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('重置邮件发送成功,请检查邮箱!'));
|
||||
setInputs({ ...inputs, email: '' });
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
style={{ top: '-80px', right: '-80px', transform: 'none' }}
|
||||
/>
|
||||
<div
|
||||
className='blur-ball blur-ball-teal'
|
||||
style={{ top: '50%', left: '-120px' }}
|
||||
/>
|
||||
<div className='w-full max-w-sm mt-[60px]'>
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
||||
<Title heading={3} className='!text-gray-800'>
|
||||
{systemName}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
||||
<div className='flex justify-center pt-6 pb-2'>
|
||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
||||
{t('密码重置')}
|
||||
</Title>
|
||||
</div>
|
||||
<div className='px-2 py-8'>
|
||||
<Form className='space-y-3'>
|
||||
<Form.Input
|
||||
field='email'
|
||||
label={t('邮箱')}
|
||||
placeholder={t('请输入您的邮箱地址')}
|
||||
name='email'
|
||||
value={email}
|
||||
onChange={handleChange}
|
||||
prefix={<IconMail />}
|
||||
/>
|
||||
|
||||
<div className='space-y-2 pt-2'>
|
||||
<Button
|
||||
theme='solid'
|
||||
className='w-full !rounded-full'
|
||||
type='primary'
|
||||
htmlType='submit'
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={disableButton}
|
||||
>
|
||||
{disableButton
|
||||
? `${t('重试')} (${countdown})`
|
||||
: t('提交')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
<Text>
|
||||
{t('想起来了?')}{' '}
|
||||
<Link
|
||||
to='/login'
|
||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
||||
>
|
||||
{t('登录')}
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{turnstileEnabled && (
|
||||
<div className='flex justify-center mt-6'>
|
||||
<Turnstile
|
||||
sitekey={turnstileSiteKey}
|
||||
onVerify={(token) => {
|
||||
setTurnstileToken(token);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordResetForm;
|
||||
805
web/src/components/auth/RegisterForm.jsx
Normal file
805
web/src/components/auth/RegisterForm.jsx
Normal file
@@ -0,0 +1,805 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
API,
|
||||
getLogo,
|
||||
showError,
|
||||
showInfo,
|
||||
showSuccess,
|
||||
updateAPI,
|
||||
getSystemName,
|
||||
getOAuthProviderIcon,
|
||||
setUserData,
|
||||
onDiscordOAuthClicked,
|
||||
onCustomOAuthClicked,
|
||||
} from '../../helpers';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Form,
|
||||
Icon,
|
||||
Modal,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
import {
|
||||
IconGithubLogo,
|
||||
IconMail,
|
||||
IconUser,
|
||||
IconLock,
|
||||
IconKey,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import {
|
||||
onGitHubOAuthClicked,
|
||||
onLinuxDOOAuthClicked,
|
||||
onOIDCClicked,
|
||||
} from '../../helpers';
|
||||
import OIDCIcon from '../common/logo/OIDCIcon';
|
||||
import LinuxDoIcon from '../common/logo/LinuxDoIcon';
|
||||
import WeChatIcon from '../common/logo/WeChatIcon';
|
||||
import TelegramLoginButton from 'react-telegram-login/src';
|
||||
import { UserContext } from '../../context/User';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SiDiscord } from 'react-icons/si';
|
||||
|
||||
const RegisterForm = () => {
|
||||
let navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const githubButtonTextKeyByState = {
|
||||
idle: '使用 GitHub 继续',
|
||||
redirecting: '正在跳转 GitHub...',
|
||||
timeout: '请求超时,请刷新页面后重新发起 GitHub 登录',
|
||||
};
|
||||
const [inputs, setInputs] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
password2: '',
|
||||
email: '',
|
||||
verification_code: '',
|
||||
wechat_verification_code: '',
|
||||
});
|
||||
const { username, password, password2 } = inputs;
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
||||
const [turnstileToken, setTurnstileToken] = useState('');
|
||||
const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
|
||||
const [showEmailRegister, setShowEmailRegister] = useState(false);
|
||||
const [wechatLoading, setWechatLoading] = useState(false);
|
||||
const [githubLoading, setGithubLoading] = useState(false);
|
||||
const [discordLoading, setDiscordLoading] = useState(false);
|
||||
const [oidcLoading, setOidcLoading] = useState(false);
|
||||
const [linuxdoLoading, setLinuxdoLoading] = useState(false);
|
||||
const [emailRegisterLoading, setEmailRegisterLoading] = useState(false);
|
||||
const [registerLoading, setRegisterLoading] = useState(false);
|
||||
const [verificationCodeLoading, setVerificationCodeLoading] = useState(false);
|
||||
const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] =
|
||||
useState(false);
|
||||
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
||||
const [customOAuthLoading, setCustomOAuthLoading] = useState({});
|
||||
const [disableButton, setDisableButton] = useState(false);
|
||||
const [countdown, setCountdown] = useState(30);
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
const [hasUserAgreement, setHasUserAgreement] = useState(false);
|
||||
const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false);
|
||||
const [githubButtonState, setGithubButtonState] = useState('idle');
|
||||
const [githubButtonDisabled, setGithubButtonDisabled] = useState(false);
|
||||
const githubTimeoutRef = useRef(null);
|
||||
const githubButtonText = t(githubButtonTextKeyByState[githubButtonState]);
|
||||
|
||||
const logo = getLogo();
|
||||
const systemName = getSystemName();
|
||||
|
||||
let affCode = new URLSearchParams(window.location.search).get('aff');
|
||||
if (affCode) {
|
||||
localStorage.setItem('aff', affCode);
|
||||
}
|
||||
|
||||
const status = useMemo(() => {
|
||||
if (statusState?.status) return statusState.status;
|
||||
const savedStatus = localStorage.getItem('status');
|
||||
if (!savedStatus) return {};
|
||||
try {
|
||||
return JSON.parse(savedStatus) || {};
|
||||
} catch (err) {
|
||||
return {};
|
||||
}
|
||||
}, [statusState?.status]);
|
||||
const hasCustomOAuthProviders =
|
||||
(status.custom_oauth_providers || []).length > 0;
|
||||
const hasOAuthRegisterOptions = Boolean(
|
||||
status.github_oauth ||
|
||||
status.discord_oauth ||
|
||||
status.oidc_enabled ||
|
||||
status.wechat_login ||
|
||||
status.linuxdo_oauth ||
|
||||
status.telegram_oauth ||
|
||||
hasCustomOAuthProviders,
|
||||
);
|
||||
|
||||
const [showEmailVerification, setShowEmailVerification] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setShowEmailVerification(!!status?.email_verification);
|
||||
if (status?.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
|
||||
// 从 status 获取用户协议和隐私政策的启用状态
|
||||
setHasUserAgreement(status?.user_agreement_enabled || false);
|
||||
setHasPrivacyPolicy(status?.privacy_policy_enabled || false);
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
let countdownInterval = null;
|
||||
if (disableButton && countdown > 0) {
|
||||
countdownInterval = setInterval(() => {
|
||||
setCountdown(countdown - 1);
|
||||
}, 1000);
|
||||
} else if (countdown === 0) {
|
||||
setDisableButton(false);
|
||||
setCountdown(30);
|
||||
}
|
||||
return () => clearInterval(countdownInterval); // Clean up on unmount
|
||||
}, [disableButton, countdown]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (githubTimeoutRef.current) {
|
||||
clearTimeout(githubTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onWeChatLoginClicked = () => {
|
||||
setWechatLoading(true);
|
||||
setShowWeChatLoginModal(true);
|
||||
setWechatLoading(false);
|
||||
};
|
||||
|
||||
const onSubmitWeChatVerificationCode = async () => {
|
||||
if (turnstileEnabled && turnstileToken === '') {
|
||||
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
||||
return;
|
||||
}
|
||||
setWechatCodeSubmitLoading(true);
|
||||
try {
|
||||
const res = await API.get(
|
||||
`/api/oauth/wechat?code=${inputs.wechat_verification_code}`,
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
localStorage.setItem('user', JSON.stringify(data));
|
||||
setUserData(data);
|
||||
updateAPI();
|
||||
navigate('/');
|
||||
showSuccess('登录成功!');
|
||||
setShowWeChatLoginModal(false);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('登录失败,请重试');
|
||||
} finally {
|
||||
setWechatCodeSubmitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
function handleChange(name, value) {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
if (password.length < 8) {
|
||||
showInfo('密码长度不得小于 8 位!');
|
||||
return;
|
||||
}
|
||||
if (password !== password2) {
|
||||
showInfo('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
if (username && password) {
|
||||
if (turnstileEnabled && turnstileToken === '') {
|
||||
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
||||
return;
|
||||
}
|
||||
setRegisterLoading(true);
|
||||
try {
|
||||
if (!affCode) {
|
||||
affCode = localStorage.getItem('aff');
|
||||
}
|
||||
inputs.aff_code = affCode;
|
||||
const res = await API.post(
|
||||
`/api/user/register?turnstile=${turnstileToken}`,
|
||||
inputs,
|
||||
);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
navigate('/login');
|
||||
showSuccess('注册成功!');
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('注册失败,请重试');
|
||||
} finally {
|
||||
setRegisterLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sendVerificationCode = async () => {
|
||||
if (inputs.email === '') return;
|
||||
if (turnstileEnabled && turnstileToken === '') {
|
||||
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
||||
return;
|
||||
}
|
||||
setVerificationCodeLoading(true);
|
||||
try {
|
||||
const res = await API.get(
|
||||
`/api/verification?email=${encodeURIComponent(inputs.email)}&turnstile=${turnstileToken}`,
|
||||
);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('验证码发送成功,请检查你的邮箱!');
|
||||
setDisableButton(true); // 发送成功后禁用按钮,开始倒计时
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('发送验证码失败,请重试');
|
||||
} finally {
|
||||
setVerificationCodeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGitHubClick = () => {
|
||||
if (githubButtonDisabled) {
|
||||
return;
|
||||
}
|
||||
setGithubLoading(true);
|
||||
setGithubButtonDisabled(true);
|
||||
setGithubButtonState('redirecting');
|
||||
if (githubTimeoutRef.current) {
|
||||
clearTimeout(githubTimeoutRef.current);
|
||||
}
|
||||
githubTimeoutRef.current = setTimeout(() => {
|
||||
setGithubLoading(false);
|
||||
setGithubButtonState('timeout');
|
||||
setGithubButtonDisabled(true);
|
||||
}, 20000);
|
||||
try {
|
||||
onGitHubOAuthClicked(status.github_client_id, { shouldLogout: true });
|
||||
} finally {
|
||||
setTimeout(() => setGithubLoading(false), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscordClick = () => {
|
||||
setDiscordLoading(true);
|
||||
try {
|
||||
onDiscordOAuthClicked(status.discord_client_id, { shouldLogout: true });
|
||||
} finally {
|
||||
setTimeout(() => setDiscordLoading(false), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOIDCClick = () => {
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
onOIDCClicked(
|
||||
status.oidc_authorization_endpoint,
|
||||
status.oidc_client_id,
|
||||
false,
|
||||
{ shouldLogout: true },
|
||||
);
|
||||
} finally {
|
||||
setTimeout(() => setOidcLoading(false), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLinuxDOClick = () => {
|
||||
setLinuxdoLoading(true);
|
||||
try {
|
||||
onLinuxDOOAuthClicked(status.linuxdo_client_id, { shouldLogout: true });
|
||||
} finally {
|
||||
setTimeout(() => setLinuxdoLoading(false), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomOAuthClick = (provider) => {
|
||||
setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: true }));
|
||||
try {
|
||||
onCustomOAuthClicked(provider, { shouldLogout: true });
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: false }));
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailRegisterClick = () => {
|
||||
setEmailRegisterLoading(true);
|
||||
setShowEmailRegister(true);
|
||||
setEmailRegisterLoading(false);
|
||||
};
|
||||
|
||||
const handleOtherRegisterOptionsClick = () => {
|
||||
setOtherRegisterOptionsLoading(true);
|
||||
setShowEmailRegister(false);
|
||||
setOtherRegisterOptionsLoading(false);
|
||||
};
|
||||
|
||||
const onTelegramLoginClicked = async (response) => {
|
||||
const fields = [
|
||||
'id',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'username',
|
||||
'photo_url',
|
||||
'auth_date',
|
||||
'hash',
|
||||
'lang',
|
||||
];
|
||||
const params = {};
|
||||
fields.forEach((field) => {
|
||||
if (response[field]) {
|
||||
params[field] = response[field];
|
||||
}
|
||||
});
|
||||
try {
|
||||
const res = await API.get(`/api/oauth/telegram/login`, { params });
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
localStorage.setItem('user', JSON.stringify(data));
|
||||
showSuccess('登录成功!');
|
||||
setUserData(data);
|
||||
updateAPI();
|
||||
navigate('/');
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('登录失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
const renderOAuthOptions = () => {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
||||
<Title heading={3} className='!text-gray-800'>
|
||||
{systemName}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
||||
<div className='flex justify-center pt-6 pb-2'>
|
||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
||||
{t('注 册')}
|
||||
</Title>
|
||||
</div>
|
||||
<div className='px-2 py-8'>
|
||||
<div className='space-y-3'>
|
||||
{status.wechat_login && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={
|
||||
<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />
|
||||
}
|
||||
onClick={onWeChatLoginClicked}
|
||||
loading={wechatLoading}
|
||||
>
|
||||
<span className='ml-3'>{t('使用 微信 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.github_oauth && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={<IconGithubLogo size='large' />}
|
||||
onClick={handleGitHubClick}
|
||||
loading={githubLoading}
|
||||
disabled={githubButtonDisabled}
|
||||
>
|
||||
<span className='ml-3'>{githubButtonText}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.discord_oauth && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={
|
||||
<SiDiscord
|
||||
style={{
|
||||
color: '#5865F2',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={handleDiscordClick}
|
||||
loading={discordLoading}
|
||||
>
|
||||
<span className='ml-3'>{t('使用 Discord 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.oidc_enabled && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={<OIDCIcon style={{ color: '#1877F2' }} />}
|
||||
onClick={handleOIDCClick}
|
||||
loading={oidcLoading}
|
||||
>
|
||||
<span className='ml-3'>{t('使用 OIDC 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.linuxdo_oauth && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={
|
||||
<LinuxDoIcon
|
||||
style={{
|
||||
color: '#E95420',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={handleLinuxDOClick}
|
||||
loading={linuxdoLoading}
|
||||
>
|
||||
<span className='ml-3'>{t('使用 LinuxDO 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.custom_oauth_providers &&
|
||||
status.custom_oauth_providers.map((provider) => (
|
||||
<Button
|
||||
key={provider.slug}
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={getOAuthProviderIcon(provider.icon || '', 20)}
|
||||
onClick={() => handleCustomOAuthClick(provider)}
|
||||
loading={customOAuthLoading[provider.slug]}
|
||||
>
|
||||
<span className='ml-3'>
|
||||
{t('使用 {{name}} 继续', { name: provider.name })}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{status.telegram_oauth && (
|
||||
<div className='flex justify-center my-2'>
|
||||
<TelegramLoginButton
|
||||
dataOnauth={onTelegramLoginClicked}
|
||||
botName={status.telegram_bot_name}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider margin='12px' align='center'>
|
||||
{t('或')}
|
||||
</Divider>
|
||||
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
className='w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors'
|
||||
icon={<IconMail size='large' />}
|
||||
onClick={handleEmailRegisterClick}
|
||||
loading={emailRegisterLoading}
|
||||
>
|
||||
<span className='ml-3'>{t('使用 用户名 注册')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
<Text>
|
||||
{t('已有账户?')}{' '}
|
||||
<Link
|
||||
to='/login'
|
||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
||||
>
|
||||
{t('登录')}
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEmailRegisterForm = () => {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
||||
<Title heading={3} className='!text-gray-800'>
|
||||
{systemName}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
||||
<div className='flex justify-center pt-6 pb-2'>
|
||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
||||
{t('注 册')}
|
||||
</Title>
|
||||
</div>
|
||||
<div className='px-2 py-8'>
|
||||
<Form className='space-y-3'>
|
||||
<Form.Input
|
||||
field='username'
|
||||
label={t('用户名')}
|
||||
placeholder={t('请输入用户名')}
|
||||
name='username'
|
||||
onChange={(value) => handleChange('username', value)}
|
||||
prefix={<IconUser />}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field='password'
|
||||
label={t('密码')}
|
||||
placeholder={t('输入密码,最短 8 位,最长 20 位')}
|
||||
name='password'
|
||||
mode='password'
|
||||
onChange={(value) => handleChange('password', value)}
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field='password2'
|
||||
label={t('确认密码')}
|
||||
placeholder={t('确认密码')}
|
||||
name='password2'
|
||||
mode='password'
|
||||
onChange={(value) => handleChange('password2', value)}
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
|
||||
{showEmailVerification && (
|
||||
<>
|
||||
<Form.Input
|
||||
field='email'
|
||||
label={t('邮箱')}
|
||||
placeholder={t('输入邮箱地址')}
|
||||
name='email'
|
||||
type='email'
|
||||
onChange={(value) => handleChange('email', value)}
|
||||
prefix={<IconMail />}
|
||||
suffix={
|
||||
<Button
|
||||
onClick={sendVerificationCode}
|
||||
loading={verificationCodeLoading}
|
||||
disabled={disableButton || verificationCodeLoading}
|
||||
>
|
||||
{disableButton
|
||||
? `${t('重新发送')} (${countdown})`
|
||||
: t('获取验证码')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Form.Input
|
||||
field='verification_code'
|
||||
label={t('验证码')}
|
||||
placeholder={t('输入验证码')}
|
||||
name='verification_code'
|
||||
onChange={(value) =>
|
||||
handleChange('verification_code', value)
|
||||
}
|
||||
prefix={<IconKey />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(hasUserAgreement || hasPrivacyPolicy) && (
|
||||
<div className='pt-4'>
|
||||
<Checkbox
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
>
|
||||
<Text size='small' className='text-gray-600'>
|
||||
{t('我已阅读并同意')}
|
||||
{hasUserAgreement && (
|
||||
<>
|
||||
<a
|
||||
href='/user-agreement'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
>
|
||||
{t('用户协议')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{hasUserAgreement && hasPrivacyPolicy && t('和')}
|
||||
{hasPrivacyPolicy && (
|
||||
<>
|
||||
<a
|
||||
href='/privacy-policy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
>
|
||||
{t('隐私政策')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='space-y-2 pt-2'>
|
||||
<Button
|
||||
theme='solid'
|
||||
className='w-full !rounded-full'
|
||||
type='primary'
|
||||
htmlType='submit'
|
||||
onClick={handleSubmit}
|
||||
loading={registerLoading}
|
||||
disabled={
|
||||
(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms
|
||||
}
|
||||
>
|
||||
{t('注册')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
{hasOAuthRegisterOptions && (
|
||||
<>
|
||||
<Divider margin='12px' align='center'>
|
||||
{t('或')}
|
||||
</Divider>
|
||||
|
||||
<div className='mt-4 text-center'>
|
||||
<Button
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
className='w-full !rounded-full'
|
||||
onClick={handleOtherRegisterOptionsClick}
|
||||
loading={otherRegisterOptionsLoading}
|
||||
>
|
||||
{t('其他注册选项')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
<Text>
|
||||
{t('已有账户?')}{' '}
|
||||
<Link
|
||||
to='/login'
|
||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
||||
>
|
||||
{t('登录')}
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderWeChatLoginModal = () => {
|
||||
return (
|
||||
<Modal
|
||||
title={t('微信扫码登录')}
|
||||
visible={showWeChatLoginModal}
|
||||
maskClosable={true}
|
||||
onOk={onSubmitWeChatVerificationCode}
|
||||
onCancel={() => setShowWeChatLoginModal(false)}
|
||||
okText={t('登录')}
|
||||
centered={true}
|
||||
okButtonProps={{
|
||||
loading: wechatCodeSubmitLoading,
|
||||
}}
|
||||
>
|
||||
<div className='flex flex-col items-center'>
|
||||
<img src={status.wechat_qrcode} alt='微信二维码' className='mb-4' />
|
||||
</div>
|
||||
|
||||
<div className='text-center mb-4'>
|
||||
<p>
|
||||
{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form>
|
||||
<Form.Input
|
||||
field='wechat_verification_code'
|
||||
placeholder={t('验证码')}
|
||||
label={t('验证码')}
|
||||
value={inputs.wechat_verification_code}
|
||||
onChange={(value) =>
|
||||
handleChange('wechat_verification_code', value)
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
style={{ top: '-80px', right: '-80px', transform: 'none' }}
|
||||
/>
|
||||
<div
|
||||
className='blur-ball blur-ball-teal'
|
||||
style={{ top: '50%', left: '-120px' }}
|
||||
/>
|
||||
<div className='w-full max-w-sm mt-[60px]'>
|
||||
{showEmailRegister ||
|
||||
!hasOAuthRegisterOptions
|
||||
? renderEmailRegisterForm()
|
||||
: renderOAuthOptions()}
|
||||
{renderWeChatLoginModal()}
|
||||
|
||||
{turnstileEnabled && (
|
||||
<div className='flex justify-center mt-6'>
|
||||
<Turnstile
|
||||
sitekey={turnstileSiteKey}
|
||||
onVerify={(token) => {
|
||||
setTurnstileToken(token);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterForm;
|
||||
244
web/src/components/auth/TwoFAVerification.jsx
Normal file
244
web/src/components/auth/TwoFAVerification.jsx
Normal file
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { API, showError, showSuccess } from '../../helpers';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [useBackupCode, setUseBackupCode] = useState(false);
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!verificationCode) {
|
||||
showError('请输入验证码');
|
||||
return;
|
||||
}
|
||||
// Validate code format
|
||||
if (useBackupCode && verificationCode.length !== 8) {
|
||||
showError('备用码必须是8位');
|
||||
return;
|
||||
} else if (!useBackupCode && !/^\d{6}$/.test(verificationCode)) {
|
||||
showError('验证码必须是6位数字');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.post('/api/user/login/2fa', {
|
||||
code: verificationCode,
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
showSuccess('登录成功');
|
||||
// 保存用户信息到本地存储
|
||||
localStorage.setItem('user', JSON.stringify(res.data.data));
|
||||
if (onSuccess) {
|
||||
onSuccess(res.data.data);
|
||||
}
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('验证失败,请重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
if (isModal) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<Paragraph className='text-gray-600 dark:text-gray-300'>
|
||||
请输入认证器应用显示的验证码完成登录
|
||||
</Paragraph>
|
||||
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Input
|
||||
field='code'
|
||||
label={useBackupCode ? '备用码' : '验证码'}
|
||||
placeholder={useBackupCode ? '请输入8位备用码' : '请输入6位验证码'}
|
||||
value={verificationCode}
|
||||
onChange={setVerificationCode}
|
||||
onKeyPress={handleKeyPress}
|
||||
size='large'
|
||||
style={{ marginBottom: 16 }}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Button
|
||||
htmlType='submit'
|
||||
type='primary'
|
||||
loading={loading}
|
||||
block
|
||||
size='large'
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
验证并登录
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
onClick={() => {
|
||||
setUseBackupCode(!useBackupCode);
|
||||
setVerificationCode('');
|
||||
}}
|
||||
style={{ marginRight: 16, color: '#1890ff', padding: 0 }}
|
||||
>
|
||||
{useBackupCode ? '使用认证器验证码' : '使用备用码'}
|
||||
</Button>
|
||||
|
||||
{onBack && (
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
onClick={onBack}
|
||||
style={{ color: '#1890ff', padding: 0 }}
|
||||
>
|
||||
返回登录
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-3'>
|
||||
<Text size='small' type='secondary'>
|
||||
<strong>提示:</strong>
|
||||
<br />
|
||||
• 验证码每30秒更新一次
|
||||
<br />
|
||||
• 如果无法获取验证码,请使用备用码
|
||||
<br />• 每个备用码只能使用一次
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '60vh',
|
||||
}}
|
||||
>
|
||||
<Card style={{ width: 400, padding: 24 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
<Title heading={3}>两步验证</Title>
|
||||
<Paragraph type='secondary'>
|
||||
请输入认证器应用显示的验证码完成登录
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Input
|
||||
field='code'
|
||||
label={useBackupCode ? '备用码' : '验证码'}
|
||||
placeholder={useBackupCode ? '请输入8位备用码' : '请输入6位验证码'}
|
||||
value={verificationCode}
|
||||
onChange={setVerificationCode}
|
||||
onKeyPress={handleKeyPress}
|
||||
size='large'
|
||||
style={{ marginBottom: 16 }}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Button
|
||||
htmlType='submit'
|
||||
type='primary'
|
||||
loading={loading}
|
||||
block
|
||||
size='large'
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
验证并登录
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
onClick={() => {
|
||||
setUseBackupCode(!useBackupCode);
|
||||
setVerificationCode('');
|
||||
}}
|
||||
style={{ marginRight: 16, color: '#1890ff', padding: 0 }}
|
||||
>
|
||||
{useBackupCode ? '使用认证器验证码' : '使用备用码'}
|
||||
</Button>
|
||||
|
||||
{onBack && (
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
onClick={onBack}
|
||||
style={{ color: '#1890ff', padding: 0 }}
|
||||
>
|
||||
返回登录
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 24,
|
||||
padding: 16,
|
||||
background: '#f6f8fa',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<Text size='small' type='secondary'>
|
||||
<strong>提示:</strong>
|
||||
<br />
|
||||
• 验证码每30秒更新一次
|
||||
<br />
|
||||
• 如果无法获取验证码,请使用备用码
|
||||
<br />• 每个备用码只能使用一次
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TwoFAVerification;
|
||||
Reference in New Issue
Block a user