214 lines
8.9 KiB
React
214 lines
8.9 KiB
React
import React, { useEffect, useState } from 'react';
|
||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||
import * as authApi from '../api/auth.js';
|
||
import * as settingsApi from '../api/settings.js';
|
||
import { required as captchaRequired } from '../api/captcha.js';
|
||
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
||
import { getToken, setToken, notifyAuthChange } from '../api/client.js';
|
||
|
||
/** RainID OIDC 回跳错误码 → 用户可读文案 */
|
||
const OIDC_ERROR_TEXT = {
|
||
access_denied: '已在 RainID 拒绝授权,可重新登录',
|
||
consent_required: '请重新完整走 RainID 登录',
|
||
login_required: '请重新完整走 RainID 登录',
|
||
invalid_grant: '登录状态已失效,请重新登录',
|
||
rate_limited: '请求过于频繁,请稍后再试',
|
||
invalid_scope: '配置错误,请联系管理员',
|
||
invalid_request: '配置错误,请联系管理员',
|
||
};
|
||
|
||
/**
|
||
* 登录页(迁移自 login.html):
|
||
* captcha.required('login') 判断 → 需要则显示"点击进行人机验证"按钮 → showCaptcha('login') 拿 proof;
|
||
* 成功后 setToken + 通知登录态变更 + 跳转来源页或首页。
|
||
* RainID 单点登录:rainid_enabled 时显示「使用 RainID 登录」按钮;回跳(?oidc_ticket / ?oidc_error)
|
||
* 在此页面收尾——一次性过渡码换 token / 错误码映射提示。ROPC 表单逻辑不变(后端已转发)。
|
||
*/
|
||
export default function Login() {
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
const [username, setUsername] = useState('');
|
||
const [password, setPassword] = useState('');
|
||
const [showPw, setShowPw] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [busy, setBusy] = useState(false);
|
||
const [capRequired, setCapRequired] = useState(false);
|
||
const [capDone, setCapDone] = useState(false);
|
||
const [capResult, setCapResult] = useState(null);
|
||
const [rainidEnabled, setRainidEnabled] = useState(false);
|
||
const [oidcBusy, setOidcBusy] = useState(false);
|
||
|
||
/** 清理地址栏的 oidc 过渡参数(替换为纯路径,避免残留/刷新重复兑换) */
|
||
const clearOidcParams = () => {
|
||
window.history.replaceState({}, '', window.location.pathname);
|
||
};
|
||
|
||
/** RainID 回跳收尾:一次性过渡码换 token → 复用本地登录成功逻辑 */
|
||
const handleOidcTicket = async (ticket) => {
|
||
setBusy(true);
|
||
try {
|
||
const data = await authApi.oidcExchange(ticket);
|
||
setToken(data.token);
|
||
notifyAuthChange();
|
||
clearOidcParams();
|
||
const from = location.state && location.state.from;
|
||
navigate(from || '/');
|
||
} catch (e) {
|
||
clearOidcParams();
|
||
setError(e.message || 'RainID 登录失败,请重试');
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
// 1) RainID 回跳优先处理(一次性码 30s 有效,须先兑换)
|
||
// 注意:OIDC 走整页跳转,此 effect 仅在页面挂载时执行一次;
|
||
// 兑换后 navigate 触发 location 变化时不再重跑(避免覆盖 from 目标)。
|
||
const params = new URLSearchParams(window.location.search);
|
||
const ticket = params.get('oidc_ticket');
|
||
const oidcError = params.get('oidc_error');
|
||
if (ticket) { handleOidcTicket(ticket); return; }
|
||
if (oidcError) {
|
||
setError(OIDC_ERROR_TEXT[oidcError] || 'RainID 登录失败,请重试');
|
||
clearOidcParams();
|
||
return;
|
||
}
|
||
// 2) 已登录直接回首页
|
||
if (getToken()) { navigate('/', { replace: true }); return; }
|
||
// 3) 公开设置:rainid_enabled → 显示 RainID 按钮
|
||
settingsApi.getPublicSettings()
|
||
.then((s) => { if (s.rainid_enabled === '1') setRainidEnabled(true); })
|
||
.catch(() => {});
|
||
// 4) 人机验证需求
|
||
captchaRequired('login')
|
||
.then((r) => { if (r && r.required) setCapRequired(true); })
|
||
.catch(() => {});
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
const doCaptcha = async () => {
|
||
const result = await showCaptcha('login');
|
||
if (result) { setCapResult(result); setCapDone(true); }
|
||
};
|
||
|
||
/** RainID 整页跳转(loading 防重复点击) */
|
||
const handleRainId = () => {
|
||
if (oidcBusy) return;
|
||
setOidcBusy(true);
|
||
authApi.oidcLoginRedirect();
|
||
};
|
||
|
||
const handleLogin = async () => {
|
||
if (!username.trim() || !password) { setError('请输入用户名和密码'); return; }
|
||
if (capRequired && !capDone) { setError('请先点击验证按钮完成验证'); return; }
|
||
setError('');
|
||
setBusy(true);
|
||
try {
|
||
const data = await authApi.login(username.trim(), password, capResult || undefined);
|
||
setToken(data.token);
|
||
notifyAuthChange();
|
||
const from = location.state && location.state.from;
|
||
navigate(from || '/');
|
||
} catch (e) {
|
||
// 401 文案透传(含 RainID 已开启时的 2FA 提示:"该账号已开启二次验证,请使用 RainID 登录")
|
||
setError(e.message || '登录失败');
|
||
setBusy(false);
|
||
// 验证码 proof 为一次性消费:无论密码错还是 proof 已被使用,登录失败后均作废,
|
||
// 重置「已验证」标记允许用户重新完成验证,否则按钮保持 disabled 死锁。
|
||
setCapDone(false);
|
||
setCapResult(null);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="login-page" style={{ minHeight: '70vh' }}>
|
||
<div className="card login-card">
|
||
<h1>登录</h1>
|
||
<p className="subtitle">欢迎回到 RainWeb</p>
|
||
|
||
<div className="form-group">
|
||
<label>用户名</label>
|
||
<input
|
||
type="text"
|
||
value={username}
|
||
onChange={(e) => setUsername(e.target.value)}
|
||
placeholder="输入用户名"
|
||
autoComplete="username"
|
||
autoFocus
|
||
onKeyDown={(e) => { if (e.key === 'Enter') document.getElementById('loginPw').focus(); }}
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>密码</label>
|
||
<div style={{ position: 'relative' }}>
|
||
<input
|
||
id="loginPw"
|
||
type={showPw ? 'text' : 'password'}
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
placeholder="输入密码"
|
||
autoComplete="current-password"
|
||
style={{ width: '100%', paddingRight: 44 }}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') handleLogin(); }}
|
||
/>
|
||
<button
|
||
type="button"
|
||
tabIndex="-1"
|
||
style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', color: 'var(--md-ref-on-surface-variant)', cursor: 'pointer', padding: 4, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||
onClick={() => setShowPw((v) => !v)}
|
||
>
|
||
<span className="material-icons" style={{ fontSize: 20 }}>{showPw ? 'visibility' : 'visibility_off'}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{error && <div style={{ color: 'var(--md-ref-error)', fontSize: 14, marginBottom: 12 }}>{error}</div>}
|
||
|
||
{capRequired && (
|
||
<button
|
||
className="btn w-full"
|
||
onClick={doCaptcha}
|
||
disabled={capDone}
|
||
style={{
|
||
display: 'inline-flex',
|
||
marginBottom: 8,
|
||
justifyContent: 'center',
|
||
height: 44,
|
||
...(capDone
|
||
? { background: '#e8f5e9', borderColor: '#4caf50', color: '#2e7d32' }
|
||
: { background: 'var(--md-ref-surface-container)', color: 'var(--md-ref-on-surface)', border: '1px solid var(--md-ref-outline-variant)' }),
|
||
}}
|
||
>
|
||
<span className="material-icons" style={{ fontSize: 20 }}>{capDone ? 'check_circle' : 'verified_user'}</span>
|
||
<span>{capDone ? '验证通过' : '点击进行人机验证'}</span>
|
||
</button>
|
||
)}
|
||
|
||
{rainidEnabled && (
|
||
<button
|
||
className="btn btn-outline w-full"
|
||
onClick={handleRainId}
|
||
disabled={busy || oidcBusy}
|
||
style={{ display: 'inline-flex', marginBottom: 8, justifyContent: 'center', height: 44, gap: 8, alignItems: 'center' }}
|
||
>
|
||
<span className="material-icons" style={{ fontSize: 20 }}>fingerprint</span>
|
||
<span>{oidcBusy ? '正在跳转 RainID…' : '使用 RainID 登录'}</span>
|
||
</button>
|
||
)}
|
||
|
||
<button className="btn btn-filled w-full" onClick={handleLogin} disabled={busy}>
|
||
{busy ? '登录中...' : '登录'}
|
||
</button>
|
||
|
||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||
<span className="text-muted">没有账户?</span>
|
||
<Link to="/register.html" className="btn-text btn" style={{ fontSize: 14 }}>注册</Link>
|
||
</div>
|
||
<div style={{ textAlign: 'center', marginTop: 8 }}>
|
||
<Link to="/" className="btn-text btn" style={{ fontSize: 14 }}>← 返回主页</Link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|