189 lines
9.9 KiB
TypeScript
189 lines
9.9 KiB
TypeScript
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
|
|
// LoginPage —— 用户名+密码唯一入口;
|
|
// ChangePasswordGate —— 受限会话强制设密:模式由服务端 passwordChangeMode 派生,
|
|
// grant_setup 免当前密码(激活/恢复 Key 兑换),current_required 必须校验当前密码
|
|
//(初始密码首登;2026-10-14 后端加固,空 current 会被 401 拒绝)。
|
|
import { useEffect, useState } from 'react'
|
|
import { api, type PasswordChangeMode } from '../api'
|
|
import { Icon, BrandMark } from '@ui'
|
|
|
|
function Err({ msg }: { msg: string }) {
|
|
if (!msg) return null
|
|
return <div className="lg-err" role="alert"><Icon name="alert" size={14} />{msg}</div>
|
|
}
|
|
|
|
/** 宽屏右侧品牌面板(叙事 + 特性清单);窄屏隐藏。 */
|
|
function BrandPanel() {
|
|
return (
|
|
<aside className="lg-panel">
|
|
<h2>把研发记忆,<br />变成组织的第二大脑</h2>
|
|
<p>文档、决策与知识的沉淀、检索与生成——面向团队的自托管智能体工作台。</p>
|
|
<ul>
|
|
<li><Icon name="message" size={15} />项目化多任务工作区,收件箱驱动待办</li>
|
|
<li><Icon name="shield" size={15} />管理员发放账号,密码策略与密钥分级</li>
|
|
<li><Icon name="search" size={15} />知识库混合检索(全文 + 向量)</li>
|
|
<li><Icon name="package" size={15} />文档生成流水线与工件管理</li>
|
|
</ul>
|
|
<div className="lg-foot"><span>自托管 · 多租户</span><span>数据不出域</span></div>
|
|
</aside>
|
|
)
|
|
}
|
|
|
|
/** 登录页:认证开启(PG 档/–-auth on)时的唯一入口。
|
|
* 长期 API 密钥(ask_)不再作为浏览器日常登录入口——程序化客户端直接带
|
|
* Authorization 头;浏览器保存长期密钥属于持久凭据暴露面。 */
|
|
export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; onMustChange: (owner: string) => void }) {
|
|
const [owner, setOwner] = useState('')
|
|
const [password, setPassword] = useState('')
|
|
const [err, setErr] = useState('')
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
const submitPassword = async () => {
|
|
setErr(''); setBusy(true)
|
|
try {
|
|
const me = await api.login(owner.trim(), password)
|
|
// 受限会话(must-change 首登)或正常会话都由后端 sessionScope 声明;
|
|
// 受限会话先进设密门,完成前不放行。
|
|
if (me.sessionScope === 'set_password_only' || me.mustChangePassword) {
|
|
onMustChange(me.owner)
|
|
return
|
|
}
|
|
onAuthed()
|
|
} catch (e) {
|
|
setErr(humanLoginError(String(e)))
|
|
} finally { setBusy(false) }
|
|
}
|
|
|
|
return (
|
|
<div className="lg-wrap">
|
|
<BrandPanel />
|
|
<div className="lg-formside">
|
|
<form className="lg-card" onSubmit={e => { e.preventDefault(); submitPassword() }}>
|
|
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
|
<h2 className="lg-title">登录</h2>
|
|
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员重置临时密码</p>
|
|
<label className="lg-label">用户名</label>
|
|
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
|
|
<label className="lg-label">密码</label>
|
|
<input className="lg-input" type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" />
|
|
<Err msg={err} />
|
|
<button className="lg-submit" type="submit" disabled={busy || !owner.trim() || !password}>{busy ? '验证中…' : '登录'}</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** current_required 模式必须由用户输入当前密码(后端 CompleteForcedPasswordChecked
|
|
* 校验);grant_setup(激活/恢复 Key 兑换的受限会话)免当前密码。 */
|
|
export function requiresCurrentPassword(mode: PasswordChangeMode | null | undefined): boolean {
|
|
return mode === 'current_required'
|
|
}
|
|
|
|
/** 强制设密门:受限会话必须完成设密。模式不由前端猜测——进门前先经 /api/me
|
|
* 读取服务端派生的 passwordChangeMode;读取失败或会话不再受限时拒绝放行。 */
|
|
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
|
|
const [mode, setMode] = useState<PasswordChangeMode | null>(null)
|
|
const [current, setCurrent] = useState('')
|
|
const [next, setNext] = useState('')
|
|
const [confirm, setConfirm] = useState('')
|
|
const [err, setErr] = useState('')
|
|
const [busy, setBusy] = useState(false)
|
|
const [done, setDone] = useState(false)
|
|
|
|
useEffect(() => {
|
|
let active = true
|
|
api.me().then(me => {
|
|
if (!active) return
|
|
if (me.owner !== owner || me.sessionScope !== 'set_password_only' ||
|
|
(me.passwordChangeMode !== 'grant_setup' && me.passwordChangeMode !== 'current_required')) {
|
|
setErr('当前设密凭据不再有效,请重新登录;恢复场景请先核对「设置 → 账号」中的恢复提案状态。')
|
|
return
|
|
}
|
|
setMode(me.passwordChangeMode)
|
|
}).catch(() => { if (active) setErr('无法确认当前设密方式,请重新登录。') })
|
|
return () => { active = false }
|
|
}, [owner])
|
|
|
|
const submit = async () => {
|
|
setErr('')
|
|
if (!mode) { setErr('设密方式尚未由服务端确认'); return }
|
|
if (requiresCurrentPassword(mode) && !current) { setErr('请填写当前密码'); return }
|
|
if (next !== confirm) { setErr('两次输入的新密码不一致'); return }
|
|
if (next.length < 8) { setErr('新密码至少 8 位'); return }
|
|
setBusy(true)
|
|
try {
|
|
await api.changePassword(requiresCurrentPassword(mode) ? current : '', next)
|
|
setDone(true)
|
|
} catch (e) {
|
|
setErr(humanChangePasswordError(String(e)))
|
|
} finally { setBusy(false) }
|
|
}
|
|
|
|
return (
|
|
<div className="lg-wrap">
|
|
<BrandPanel />
|
|
<div className="lg-formside">
|
|
{done ? (
|
|
<form className="lg-card" onSubmit={e => { e.preventDefault(); onDone() }}>
|
|
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
|
<h2 className="lg-title">密码已更新</h2>
|
|
<p className="lg-sub">请使用新密码重新登录账号 <b>{owner}</b></p>
|
|
<button className="lg-submit" type="submit">返回登录</button>
|
|
</form>
|
|
) : (
|
|
<form className="lg-card" onSubmit={e => { e.preventDefault(); submit() }}>
|
|
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
|
<h2 className="lg-title">设置新密码</h2>
|
|
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续</p>
|
|
{mode === 'current_required' && <>
|
|
<label className="lg-label" htmlFor="forced-current">当前密码</label>
|
|
<input id="forced-current" className="lg-input" type="password" value={current}
|
|
onChange={e => setCurrent(e.target.value)} autoComplete="current-password" />
|
|
</>}
|
|
{mode === 'grant_setup' && <p className="lg-hint">身份经可信激活/恢复能力确认,此次无需输入当前密码。</p>}
|
|
<label className="lg-label">新密码(≥8 位)</label>
|
|
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoFocus autoComplete="new-password" />
|
|
<label className="lg-label">确认新密码</label>
|
|
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} autoComplete="new-password" />
|
|
<Err msg={err} />
|
|
<button className="lg-submit" type="submit" disabled={busy || !mode || (requiresCurrentPassword(mode) && !current) || !next || !confirm}>{busy ? '提交中…' : '设置新密码'}</button>
|
|
{onCancel && <button type="button" className="lg-cancel" onClick={onCancel}>退出并返回登录</button>}
|
|
</form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
|
|
export function humanLoginError(raw: string): string {
|
|
if (raw.includes('401')) return '用户名或密码不正确'
|
|
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
|
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
|
|
return raw.replace(/^\d{3}:\s*/, '')
|
|
}
|
|
|
|
/** 一次性登录 Key 兑换文案:401/409/429 语义不同,逐条映射防漂移。 */
|
|
export function humanRedeemError(raw: string): string {
|
|
if (raw.includes('409')) return '该 Key 已被使用过,请联系管理员重新签发'
|
|
if (raw.includes('429')) return '尝试次数过多,请稍后再试'
|
|
if (raw.includes('401')) return 'Key 无效或已过期,请联系管理员重新签发'
|
|
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
|
|
return raw.replace(/^\d{3}:\s*/, '')
|
|
}
|
|
|
|
/** 改密门错误文案:改密的 401 有多种真实原因(会话过期/并发失效/退避),
|
|
* 不能沿用登录的「用户名或密码不正确」一刀切。
|
|
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
|
|
export function humanChangePasswordError(raw: string): string {
|
|
if (raw.includes('current password incorrect')) return '当前密码不正确'
|
|
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
|
|
if (raw.includes('session expired')) return '会话已过期,请重新登录'
|
|
if (raw.includes('credentials changed concurrently')) return '登录状态已变化(密码在其他地方被更新),请重新登录'
|
|
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
|
|
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
|
if (raw.includes('401')) return '认证失败,请重新登录'
|
|
return raw.replace(/^\d{3}:\s*/, '')
|
|
}
|