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 (

登录

欢迎回到 RainWeb

setUsername(e.target.value)} placeholder="输入用户名" autoComplete="username" autoFocus onKeyDown={(e) => { if (e.key === 'Enter') document.getElementById('loginPw').focus(); }} />
setPassword(e.target.value)} placeholder="输入密码" autoComplete="current-password" style={{ width: '100%', paddingRight: 44 }} onKeyDown={(e) => { if (e.key === 'Enter') handleLogin(); }} />
{error &&
{error}
} {capRequired && ( )} {rainidEnabled && ( )}
没有账户? 注册
← 返回主页
); }