Files
rainblogweb/frontend/src/pages/Login.jsx
T

214 lines
8.9 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}