Compare commits

..
Author SHA1 Message Date
weichen 125261410c Merge remote-tracking branch 'origin/main' into fix/account-chain-ui 2026-10-16 08:03:01 +00:00
weichen e895a4f6a8 Close the account chain UI against the current backend contract
Why: the forced password gate still submitted an empty current password
after the backend began requiring it (current_required), locking every
account seeded with an initial password out of the workbench, and the
admin user panel had no client for the owner-confirmed recovery
proposal contract that replaced one-time key issuing (HTTP 410).
Evidence: npm test 300 passed; tsc -b && vite build ok; knip issues
54 <= baseline 55 (SELF).
Impact: mode-aware ChangePasswordGate with current-password input,
admin recovery proposal panel, owner-side account settings section,
recovery proposal client methods; no backend changes.
Refs: backend 8dfc223, b9c0a34; frontend #94
2026-10-16 07:49:25 +00:00
weichen 46e99fedbb Merge pull request 'Align admin user creation with the pending-account contract' (#94) from fix/admin-user-create-contract into main 2026-10-16 07:48:49 +00:00
weichen bcb81719ce Drop the login-key issue client and fix hint punctuation
Why: independent review flagged the issue client still targeting the 410
endpoint and a hint sentence ending with a period.
Evidence: npm run build + npm test re-run on the fix (SELF).
Impact: api client surface only.
Refs: review brief /tmp/review-brief-admin-user-contract.md
2026-10-16 07:15:43 +00:00
weichen d8e610aebe Align admin user creation with the pending-account contract
Why: backend 8dfc223 replaced POST /api/admin/users with strict
{owner, profile} decoding and returned 410 for login-key issuing, so the
UI create form failed with 400 unknown field name. Creation now sends
{owner, profile:{name}} only; password input, admin checkbox and the
login-key issue entry are removed; notice points to the local CLI
initialize-account flow. passwords.ts lost its only consumer.
Evidence: npm run build + npm test (34 files/293 passed) + knip identical
to baseline on the task worktree (SELF).
Impact: UsersPanel and api client only; no style/token changes.
Refs: backend 8dfc223, b9c0a34
2026-10-16 06:41:27 +00:00
weichen 3bbb918795 Merge pull request 'Gate project override settings to owners and admins' (#93) from fix/duty-settings-gating into main 2026-10-16 05:10:17 +00:00
weichen 10635387e0 Gate project override settings to owners and admins
Why: S9 acceptance finding 3 — the project settings modal exposed the
host override settings (including the model/key group, masked) to plain
project members; the duties data plane was already permission-enforced,
but the host settings surface had no gate.

Evidence: vitest 34 files / 293 tests pass with the change; tsc reports
no errors in the modified file. Ruling per the acceptance plan P1-B:
the modal entry stays (granted duty managers need the RDM tabs), while
the host override settings group renders only for the project owner or
a system admin; RDM tabs keep their own fail-closed gating.

Impact: ProjectSettingsModal project tab renders an explanatory empty
state for non-privileged viewers; members/duties/matrix tabs unchanged.
Refs: S9 acceptance report P1-B
2026-10-16 05:09:58 +00:00
weichen a81e94a4f2 Merge pull request 'Show the layered Python environment in admin'
Reviewed against the layered environment plan. Required CI on the current head passed where this repository runs CI.
2026-10-15 14:24:46 +00:00
weichen 781cde6667 Merge remote-tracking branch 'origin/main' into feature/layered-python-env 2026-10-15 14:01:27 +00:00
weichenandCursor e33d17e1a6 Show the layered Python environment in the admin zone.
Why: Approving an extra package, rebuilding the base layer, reclaiming unused layers, and uploading a wheel need an admin surface that uses the existing admin controls.
Evidence: SELF tsc of the new panel reported no errors in frontend sources. UI package type errors come from missing node_modules in this worktree, not from this panel.
Impact: Administrators get a Python 环境 section. Other admin sections are unchanged.
Refs: .task/feature-layered-python-env.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 13:52:55 +00:00
weichen 5c0e9f2711 Merge pull request 'Show document duties in three shared views' (#90) from feature/rdm-duty-matrix into main 2026-10-15 09:15:09 +00:00
11 changed files with 489 additions and 103 deletions
+59 -5
View File
@@ -15,6 +15,37 @@ const BASE = ''
* 自身命令面 fail-closed。 */
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固):
* passwordChangeMode 由服务端从会话派生;grant_setup 免当前密码,
* current_required 必须校验当前密码。 */
export type PasswordChangeMode = 'grant_setup' | 'current_required'
/** 恢复提案(属主确认制):管理员只能提案,确认须用提案前已存在的合格完整密码会话。 */
export interface RecoveryProposal {
id: string; userId: string; owner: string
status: 'pending' | 'confirmed' | 'completed' | 'revoked' | 'expired'
version: number; generation: number; createdBy: string
createdAt: string; expiresAt: string
}
/** 账号资料与档案视图(backend accountProfileJSON 契约)。 */
export interface AccountProfile { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
export interface AccountRecord {
userId: string; owner: string; profile: AccountProfile; profileVersion: number
enabled?: boolean; stateVersion?: number; isAdmin?: boolean; passwordSet?: boolean
initializationState?: string; initializationGeneration?: number
}
export interface ExecEnvView {
configured: boolean
root?: string
python?: string
base?: string
layers?: string[]
requests?: { id: string; conversationId: string; owner: string; spec: string; reason: string; status: string; layerId: string; detail: string; approver: string }[]
bindings?: { conversationId: string; baseId: string; extensions: string[] | null; isolated: string }[]
}
async function checked(res: Response): Promise<Response> {
if (!res.ok) {
const text = await res.text()
@@ -48,7 +79,7 @@ export const api = {
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean }>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean }>(r)),
// ---- 认证与会话(管理员发放制,R2)----
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
@@ -73,12 +104,35 @@ export const api = {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; reloginRequired: boolean }>
}),
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }[]>(r)),
createAdminUser: (owner: string, name: string, password: string, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, name, password, isAdmin }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)),
// ---- 管理员:一次性登录 Key(忘记密码恢复主入口;旧"直接重置密码"接口退出常规 UI)----
adminIssueLoginKey: (owner: string, ttlMinutes?: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ purpose: 'recover', ...(ttlMinutes ? { ttlMinutes } : {}) }) }).then(r => json<{ id: string; owner: string; purpose: string; status: string; expiresAt: string; key: string }>(r)),
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)),
// 建号只创建待初始化账号(无密码、无权限位);初始凭证由服务器本地 CLI 发放激活 Key。
createAdminUser: (owner: string, name: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name } }) }).then(r => json<{ owner: string; initializationState: string }>(r)),
// ---- 管理员:一次性登录 Key(HTTP 签发端点已被恢复提案契约替换为 410,客户端方法随之移除;
// list/revoke 仍有效,供恢复提案视图复用;兑换入口不变)----
adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []),
adminRevokeLoginKey: (owner: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(r => json<{ ok: boolean }>(r)),
// ---- 管理员:恢复提案(属主确认制,替换已 410 的直接签发 Key 入口)----
createAdminRecoveryProposal: (userId: string, expectedStateVersion: number, idempotencyKey: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, idempotencyKey }) }).then(r => json<RecoveryProposal>(r)),
listAdminRecoveryProposals: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []),
adminRevokeRecoveryProposal: (userId: string, id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json<RecoveryProposal>(r)),
// ---- 本人:资料与恢复提案确认(须完整密码会话)----
meProfile: () => auth.fetch(`${BASE}/api/me/profile`).then(r => json<AccountRecord>(r)),
patchMeProfile: (expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/me/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<AccountRecord>(r)),
listMyRecoveryProposals: () => auth.fetch(`${BASE}/api/me/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []),
getMyRecoveryProposal: (id: string) => auth.fetch(`${BASE}/api/me/recovery-proposals/${encodeURIComponent(id)}`).then(r => json<RecoveryProposal>(r)),
confirmMyRecoveryProposal: (id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json<{ proposal: RecoveryProposal; grant?: string }>(r)),
rotateMyRecoveryGrant: (id: string, expectedVersion: number, expectedGeneration: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/rotate-grant`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion, expectedGeneration }) }).then(r => json<{ proposal: RecoveryProposal; grant?: string }>(r)),
adminExecEnv: () => auth.fetch(`${BASE}/api/admin/exec-env`).then(r => json<ExecEnvView>(r)),
adminExecEnvDecision: (id: string, allow: boolean, isolate: boolean) => auth.fetch(`${BASE}/api/admin/exec-env/requests/${encodeURIComponent(id)}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ allow, isolate }) }).then(r => json<{ status: string; detail?: string; layerId?: string }>(r)),
adminExecEnvRebase: () => auth.fetch(`${BASE}/api/admin/exec-env/rebase`, { method: 'POST' }).then(r => json<{ base: string }>(r)),
adminExecEnvGC: () => auth.fetch(`${BASE}/api/admin/exec-env/gc`, { method: 'POST' }).then(r => json<{ removed: string[] | null }>(r)),
adminExecEnvWheel: (file: File, conversationId: string, reason: string) => {
const body = new FormData()
body.set('wheel', file)
body.set('conversationId', conversationId)
body.set('reason', reason)
return auth.fetch(`${BASE}/api/admin/exec-env/wheels`, { method: 'POST', body }).then(r => json<{ file: string; outcome: { status: string; requestId?: string; detail?: string } }>(r))
},
createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json<Conversation>(r)),
deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }),
setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)),
+2
View File
@@ -8,6 +8,7 @@ import { useState } from 'react'
import { store, useStore } from '../store'
import { EmptyState, Icon, type IconName } from '@ui'
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections'
import { ExecEnvPanel } from './ExecEnvPanel'
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
import { RdmRolesPanel } from './adminRdmSections'
import type { AdminZoneSection } from '../api'
@@ -34,6 +35,7 @@ const SECTIONS: AdminSectionDef[] = [
// P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。
{ key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', render: () => <SystemSettingsPanel /> },
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
{ key: 'python-env', label: 'Python 环境', icon: 'package', desc: '基础层、扩展申请、回收和 wheel 上传(仅管理员)', render: () => <ExecEnvPanel /> },
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
]
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react'
import { api, type ExecEnvView } from '../api'
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup } from '@ui'
export function ExecEnvPanel() {
const [view, setView] = useState<ExecEnvView | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [conv, setConv] = useState('')
const [reason, setReason] = useState('')
const [wheel, setWheel] = useState<File | null>(null)
const refresh = async () => {
try { setView(await api.adminExecEnv()) } catch (e) { setErr(String(e)) }
}
useEffect(() => { refresh() }, [])
const run = async (work: () => Promise<string>) => {
setErr(''); setNotice(''); setBusy(true)
try { setNotice(await work()); await refresh() } catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
if (!view) return <EmptyState icon="package">加载 Python 环境…</EmptyState>
if (!view.configured) return <EmptyState icon="package">这台服务没有配置分层 Python 环境根</EmptyState>
return (
<div>
<SettingsGroup title="当前环境">
<SettingRow label="环境根" desc={view.root || '未设置'}><span /></SettingRow>
<SettingRow label="解释器" desc={view.python || '尚未安装托管解释器'}><span /></SettingRow>
<SettingRow label="基础层" desc={view.base || '尚未建立'}><span /></SettingRow>
<SettingRow label="层目录" desc={(view.layers ?? []).join('、') || '无'}>
<Btn variant="ghost" icon="undo" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvRebase()
return `基础层已切换到 ${out.base}。已有会话仍用原来的绑定。`
})}>重建基础层</Btn>
</SettingRow>
</SettingsGroup>
<SettingsGroup title="申请">
{(view.requests ?? []).length === 0 && <EmptyState icon="inbox">还没有扩展包申请</EmptyState>}
{(view.requests ?? []).map(req => (
<SettingRow key={req.id} label={req.spec || req.id} desc={`${req.status} · ${req.conversationId} · ${req.detail}`}>
{req.status === 'pending_approval' && (
<span className="ad-row">
<Btn variant="primary" icon="check" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvDecision(req.id, true, false)
return out.detail || out.status
})}>批准</Btn>
<Btn variant="ghost" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvDecision(req.id, true, true)
return out.detail || '已用隔离层'
})}>隔离层</Btn>
<Btn variant="ghost" icon="x" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvDecision(req.id, false, false)
return out.detail || '已拒绝'
})}>拒绝</Btn>
</span>
)}
</SettingRow>
))}
</SettingsGroup>
<SettingsGroup title="会话绑定">
{(view.bindings ?? []).map(b => (
<SettingRow key={b.conversationId} label={b.conversationId} desc={b.isolated ? `隔离 ${b.isolated}` : (b.extensions ?? []).join('、') || '只用基础层'}>
<span />
</SettingRow>
))}
<SettingRow label="回收" desc="删掉 7 天内没有会话引用、也不是当前基础层的目录">
<Btn variant="ghost" icon="trash" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvGC()
const n = out.removed?.length ?? 0
return n === 0 ? '没有可回收的层' : `已回收 ${n} 层`
})}>回收未引用层</Btn>
</SettingRow>
</SettingsGroup>
<SettingsGroup title="上传 wheel">
<SettingRow label="会话" desc="这个 wheel 只挂到指定会话,并进入审批">
<input className="lg-input" value={conv} onChange={e => setConv(e.target.value)} placeholder="会话 ID" />
</SettingRow>
<SettingRow label="理由" desc="写给审批记录">
<input className="lg-input" value={reason} onChange={e => setReason(e.target.value)} />
</SettingRow>
<SettingRow label="文件" desc="只接受预编译 wheel,不执行里面的脚本">
<input type="file" accept=".whl" onChange={e => setWheel(e.target.files?.[0] ?? null)} />
</SettingRow>
<div className="ad-row">
<Btn variant="primary" icon="download" disabled={busy || !wheel || !conv.trim()} onClick={() => run(async () => {
const out = await api.adminExecEnvWheel(wheel as File, conv.trim(), reason.trim())
return `${out.file}:${out.outcome.status}`
})}>提交审批</Btn>
</div>
</SettingsGroup>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
}
+42 -11
View File
@@ -1,8 +1,10 @@
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
// LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(忘记密码恢复,只输 Key);
// ChangePasswordGate —— 受限会话强制设密(恢复/首登同形:只输新密码,完成前不放行)。
import { useState } from 'react'
import { api } from '../api'
// LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(恢复/激活,只输 Key);
// 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 }) {
@@ -96,7 +98,7 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
<input className="lg-input lg-mono" value={key} onChange={e => setKey(e.target.value)} autoFocus placeholder="alk_…" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !key}>{busy ? '兑换中…' : '兑换并设置新密码'}</button>
<p className="lg-hint">忘记密码时由管理员签发;单次有效、默认 30 分钟内兑换。程序化客户端请继续使用 API 密钥(Authorization 头)。</p>
<p className="lg-hint">忘记密码时由管理员发起恢复提案,并由你在已登录设备的「设置 → 账号」中本人确认后获得;新账号使用管理员交付的激活 Key。单次有效、限时兑换。程序化客户端请继续使用 API 密钥(Authorization 头)。</p>
</>
)}
</form>
@@ -105,23 +107,46 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
)
}
/** 强制设密门:恢复 Key 兑换或初始密码首登建立的受限会话,必须完成设密。
* 免当前密码(R5):身份与授权均由服务端受限会话确认,页面不出现当前密码输入框;
* 完成前不能进入工作台,完成后的唯一出口是回密码登录页。 */
/** 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('', next)
await api.changePassword(requiresCurrentPassword(mode) ? current : '', next)
setDone(true)
} catch (e) {
setErr(humanChangePasswordError(String(e)))
@@ -143,13 +168,19 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
<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>
<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 || !next || !confirm}>{busy ? '提交中…' : '设置新密码'}</button>
<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>
)}
+142
View File
@@ -0,0 +1,142 @@
// 我的账号与恢复(设置 → 账号):可选资料编辑 + 恢复提案的属主确认视图。
// 管理员只能建立提案;确认必须使用提案之前已存在、来源可信的完整密码会话
//(backend eligibleRecoveryProof 契约)。确认产出的一次性 grant 只在本次响应
// 出现,可就地兑换进受限设密会话(完成前不放行工作台)。
import { useEffect, useRef, useState } from 'react'
import { api, type AccountProfile, type AccountRecord, type RecoveryProposal } from '../api'
import { useStore } from '../store'
import { Collapsible, Icon, SettingsGroup } from '@ui'
import { recoveryStatusLabel } from '../recovery'
const fields: { key: keyof AccountProfile; label: string }[] = [
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
{ key: 'staffNo', label: '工号' }, { key: 'department', label: '部门' }, { key: 'notes', label: '备注' },
]
export function MyAccountSettings() {
const me = useStore(s => s.me)
const [account, setAccount] = useState<AccountRecord | null>(null)
const [draft, setDraft] = useState<AccountProfile | null>(null)
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
const [grant, setGrant] = useState<{ proposalId: string; value: string } | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const sequence = useRef(0)
useEffect(() => {
if (!me?.auth) return
const seq = ++sequence.current
Promise.all([api.meProfile(), api.listMyRecoveryProposals()]).then(([profile, statuses]) => {
if (seq !== sequence.current) return
setAccount(profile); setDraft(profile.profile); setProposals(statuses)
}).catch(error => { if (seq === sequence.current) setErr(String(error)) })
return () => { sequence.current++ }
}, [me?.owner, me?.auth])
if (!me?.auth) return null
const save = async () => {
if (!account || !draft) return
const patch: Partial<AccountProfile> = {}
for (const field of fields) if (draft[field.key] !== account.profile[field.key]) patch[field.key] = draft[field.key]
if (Object.keys(patch).length === 0) { setNotice('资料没有变化'); return }
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.patchMeProfile(account.profileVersion, patch)
setAccount(updated); setDraft(updated.profile); setNotice('个人资料已保存;资料不会成为恢复身份的证明')
} catch (error) { setErr(String(error).includes('409') ? '资料版本已变化,请关闭设置后重新打开' : String(error)) }
finally { setBusy(false) }
}
const refreshProposal = async (id: string) => {
const updated = await api.getMyRecoveryProposal(id)
setProposals(current => current?.map(item => item.id === id ? updated : item) ?? [updated])
return updated
}
const confirm = async (proposal: RecoveryProposal) => {
setBusy(true); setErr(''); setNotice(''); setGrant(null)
try {
const result = await api.confirmMyRecoveryProposal(proposal.id, proposal.version)
setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal])
if (!result.grant) {
setErr('提案已确认,但一次性明文不可读回。若仍保有原本人会话,可显式重新生成设密能力。')
return
}
setGrant({ proposalId: proposal.id, value: result.grant })
setNotice('本人确认成功。设密能力只在此响应可用,请立即兑换并设置新密码。')
} catch (error) {
const current = await refreshProposal(proposal.id).catch(() => null)
setErr(current?.status === 'confirmed'
? '确认结果待核验:提案已确认,但明文不能读回。若仍保有原本人会话,可显式重新生成设密能力。'
: `确认未完成或结果未知:${String(error)}。请核对当前提案状态。`)
} finally { setBusy(false) }
}
const rotate = async (proposal: RecoveryProposal) => {
setBusy(true); setErr(''); setNotice(''); setGrant(null)
try {
const result = await api.rotateMyRecoveryGrant(proposal.id, proposal.version, proposal.generation)
setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal])
if (!result.grant) {
setErr('重新生成已提交,但一次性明文不可读回。请核对当前提案状态,再由本人显式操作。')
return
}
setGrant({ proposalId: proposal.id, value: result.grant })
setNotice('已重新生成设密能力;旧能力与派生受限会话已失效。')
} catch (error) {
await refreshProposal(proposal.id).catch(() => null)
setErr(`重新生成结果待核验:${String(error)}。请核对提案状态,再由本人显式操作。`)
} finally { setBusy(false) }
}
const redeem = async () => {
if (!grant) return
setBusy(true); setErr(''); setNotice('')
try {
await api.redeemLoginKey(grant.value)
location.reload() // 新 HttpOnly 受限会话由应用启动门送到设密页
} catch (error) {
const session = await api.me().catch(() => null)
if (session?.sessionScope === 'set_password_only' && session.passwordChangeMode === 'grant_setup') {
location.reload()
return
}
await refreshProposal(grant.proposalId).catch(() => null)
setGrant(null)
setErr(`兑换结果待核验:${String(error)}。不会自动重复兑换;若仍保有原本人会话,请显式重新生成能力。`)
} finally { setBusy(false) }
}
return <SettingsGroup title="我的账号与恢复">
{account && draft ? <>
<p className="ad-hint">登录名 {account.owner} · userId {account.userId}。以下资料均可留空,也不会作为本人恢复证明。</p>
<Collapsible id={`my-profile-${account.userId}`} trigger="编辑可选资料">
<div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
<label className="lg-label" htmlFor={`my-profile-${field.key}`}>{field.label}(可空)</label>
<input id={`my-profile-${field.key}`} className="lg-input" value={draft[field.key]}
onChange={event => setDraft({ ...draft, [field.key]: event.target.value })} />
</div>)}
<button className="lg-submit ad-create-btn" disabled={busy} onClick={save}>保存我的资料</button></div>
</Collapsible>
</> : <p className="ad-hint">使用完整密码会话后可查看和修改个人资料。</p>}
<p className="ad-hint">忘记密码的恢复:由管理员发起提案,本人在此用当前登录会话确认后获得一次性 Key 兑换设密;历史未知来源账号无法在线证明。</p>
{(proposals ?? []).map(proposal => <div className="ad-create" key={proposal.id}>
<div className="ad-row"><span className="ad-mono">{proposal.id}</span><b>{recoveryStatusLabel[proposal.status]}</b>
<span className="ad-dim">到期 {new Date(proposal.expiresAt).toLocaleString()}</span></div>
<div className="ad-row">
{proposal.status === 'pending' && <button className="ad-mini" disabled={busy} onClick={() => confirm(proposal)}>由本人确认</button>}
{proposal.status === 'confirmed' && <button className="ad-mini" disabled={busy} onClick={() => rotate(proposal)}>显式重新生成设密能力</button>}
</div>
</div>)}
{proposals !== null && proposals.length === 0 && <p className="ad-hint">当前没有恢复提案。</p>}
{grant && <div className="ad-reset">
<p className="ad-hint">设密能力仅保存在当前页面内存,离开后不可读回;不自动复制到剪贴板。</p>
<button className="lg-submit ad-create-btn" disabled={busy} onClick={redeem}>立即兑换并设置新密码</button>
</div>}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
</SettingsGroup>
}
+7
View File
@@ -19,6 +19,11 @@ export function ProjectSettingsModal() {
const rdmDocReviewAvailable = useStore(s => s.rdmDocReviewAvailable)
const rdmPermMatrixAvailable = useStore(s => s.rdmPermMatrixAvailable)
const owner = useStore(s => s.me?.owner)
const isAdmin = useStore(s => s.me?.isAdmin ?? false)
// P1-B 裁定:宿主覆盖设置(含模型与密钥)仅项目负责人/系统管理员可查看与修改;
// RDM 页签(成员/文档职责/项目权限)不在此列——数据面由 RDM 权限自行收敛,
// 授权的职责管理者仍可进入。
const projectPrivileged = !!project && (project.owner === owner || isAdmin)
const [tab, setTab] = useState('project')
const [schema, setSchema] = useState<SettingDesc[]>([])
const [draft, setDraft] = useState<Record<string, any>>({})
@@ -85,6 +90,8 @@ export function ProjectSettingsModal() {
</>}
{tab !== 'members' && tab !== 'duties' && tab !== 'matrix' && !loaded ? (
<EmptyState icon="settings">{error || '加载中…'}</EmptyState>
) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' && !projectPrivileged ? (
<EmptyState icon="settings">项目覆盖设置(含模型与密钥)仅项目负责人或系统管理员可查看与修改。</EmptyState>
) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' ? (
<>
<div className="empty-note" style={{ marginBottom: 10 }}>
+6 -3
View File
@@ -9,8 +9,9 @@ import type { ThemeChoice } from '../theme'
import type { SettingDesc } from '../contracts'
import { SettingsShell, SettingRow, SettingsGroup, Btn } from '@ui'
import { BorrowPanels } from './BorrowPanels'
import { MyAccountSettings } from './MyAccountSettings'
type Section = 'settings'
type Section = 'settings' | 'account'
// 分组显示名(项目设置面板共用)。
export const groupNames: Record<string, string> = {
@@ -19,7 +20,7 @@ export const groupNames: Record<string, string> = {
}
export function SettingsModal() {
const [section] = useState<Section>('settings')
const [section, setSection] = useState<Section>('settings')
const [schema, setSchema] = useState<SettingDesc[]>([])
const [, setUser] = useState<Record<string, any>>({})
const [draft, setDraft] = useState<Record<string, any>>({})
@@ -54,10 +55,11 @@ export function SettingsModal() {
return (
<SettingsShell
title="设置" active={section} onSelect={() => undefined}
title="设置" active={section} onSelect={key => setSection(key as Section)}
onClose={() => store.openSettings(false)}
sections={[
{ key: 'settings', label: '常规', icon: 'settings' },
{ key: 'account', label: '账号', icon: 'user' },
]}
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : 'Agent-System · 设置中心'}
actions={section === 'settings' && schema.length > 0 ? (
@@ -108,6 +110,7 @@ export function SettingsModal() {
)
})}
{section === 'settings' && <BorrowPanels />}
{section === 'account' && <MyAccountSettings />}
</SettingsShell>
)
}
+50 -73
View File
@@ -2,26 +2,20 @@
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
import { useEffect, useState } from 'react'
import { api } from '../api'
import { api, type RecoveryProposal } from '../api'
import { store, useStore } from '../store'
import type { AgentProfile } from '../contracts'
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
import { SettingInput, flatten, unflatten } from './SettingsModal'
import type { SettingDesc } from '../contracts'
import { docBridge } from './Workbench'
import { randomPassword } from '../passwords'
import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from '../recovery'
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制 + 2026-10-13 恢复 Key)----
interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
interface LoginKeyRow { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }
/** Key 状态徽标:复用既有 chip 体系,不新增颜色。 */
function keyStatusChip(status: string) {
if (status === 'unused') return <span className="ad-chip ad-chip-warn">待使用</span>
if (status === 'consumed') return <span className="ad-chip">已使用</span>
if (status === 'expired') return <span className="ad-chip">已过期</span>
return <span className="ad-chip">已撤销</span>
}
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制):建号仅创建待初始化
// 账号(HTTP 契约不含密码与管理员位),初始凭证由服务器本地 CLI 发放激活 Key;
// 恢复走属主确认制提案:管理员提案 → 用户在「设置 → 账号」本人确认 → 一次性 Key
// 只在属主会话内出现(直接签发 Key 的 HTTP 入口已被 410 替换)。----
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
export function UsersPanel() {
const me = useStore(s => s.me)
@@ -30,60 +24,51 @@ export function UsersPanel() {
const [notice, setNotice] = useState('')
const [owner, setOwner] = useState('')
const [name, setName] = useState('')
const [pw, setPw] = useState('')
const [asAdmin, setAsAdmin] = useState(false)
const [busy, setBusy] = useState(false)
// 登录 Key 面板:keyFor 打开指定用户的签发/状态视图;issued 明文仅签发响应持有一次。
const [keyFor, setKeyFor] = useState<string | null>(null)
const [issued, setIssued] = useState<{ key: string; expiresAt: string } | null>(null)
const [keys, setKeys] = useState<LoginKeyRow[] | null>(null)
// 恢复提案面板:propFor 打开指定用户的提案视图(创建 / 状态 / 撤销)。
const [propFor, setPropFor] = useState<AdminUserRow | null>(null)
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
const refresh = async () => {
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
}
const refreshKeys = async (owner: string) => {
try { setKeys(await api.adminListLoginKeys(owner)) } catch (e) { setErr(String(e)) }
const refreshProposals = async (userId: string) => {
try { setProposals(await api.listAdminRecoveryProposals(userId)) } catch (e) { setErr(String(e)) }
}
useEffect(() => { refresh() }, [])
const openKeyPanel = async (owner: string) => {
setErr(''); setNotice(''); setIssued(null); setKeyFor(owner); setKeys(null)
await refreshKeys(owner)
}
const create = async () => {
setErr(''); setNotice('')
if (pw.length < 8) { setErr('初始密码至少 8 位'); return }
setBusy(true)
try {
await api.createAdminUser(owner.trim(), name.trim(), pw, asAdmin)
setNotice(`已创建 ${owner.trim()}:首次登录须改密`)
setOwner(''); setName(''); setPw('')
const out = await api.createAdminUser(owner.trim(), name.trim())
setNotice(`已创建 ${out.owner}(待初始化):请在服务器执行 user initialize-account 发放激活 Key`)
setOwner(''); setName('')
await refresh()
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
}
const issue = async () => {
if (!keyFor) return
const openProposals = async (u: AdminUserRow) => {
setErr(''); setNotice(''); setPropFor(u); setProposals(null)
await refreshProposals(u.userId)
}
const propose = async () => {
if (!propFor) return
setErr(''); setNotice(''); setBusy(true)
try {
const out = await api.adminIssueLoginKey(keyFor)
setIssued({ key: out.key, expiresAt: out.expiresAt })
await refreshKeys(keyFor)
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
await api.createAdminRecoveryProposal(propFor.userId, propFor.stateVersion, crypto.randomUUID())
setNotice(`已为 ${propFor.owner} 建立恢复提案:请该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现`)
await refreshProposals(propFor.userId)
} catch (e) { setErr(recoveryProposalErrorMessage(String(e))) } finally { setBusy(false) }
}
const copyKey = async () => {
if (!issued) return
try { await navigator.clipboard.writeText(issued.key); setNotice('已复制到剪贴板,请安全交付给该用户') } catch { setErr('复制失败,请手动选择复制') }
}
const revoke = async (id: string) => {
if (!keyFor) return
const revokeProposal = async (p: RecoveryProposal) => {
if (!propFor) return
setErr(''); setBusy(true)
try {
await api.adminRevokeLoginKey(keyFor, id)
await refreshKeys(keyFor)
await api.adminRevokeRecoveryProposal(propFor.userId, p.id, p.version)
await refreshProposals(propFor.userId)
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
@@ -94,48 +79,40 @@ export function UsersPanel() {
<div className="ad-row">
<input className="lg-input" placeholder="用户名(登录名)" value={owner} onChange={e => setOwner(e.target.value)} />
<input className="lg-input" placeholder="显示名(可空)" value={name} onChange={e => setName(e.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim()} onClick={create}>创建账号</button>
</div>
<div className="ad-row">
<input className="lg-input lg-mono" placeholder="初始密码(≥8 位)" value={pw} onChange={e => setPw(e.target.value)} />
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setPw(randomPassword())}>随机</button>
<label className="ad-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
</div>
<p className="ad-hint">创建后该用户首次登录必须修改此初始密码;忘记密码时为其生成一次性登录 Key。</p>
<p className="ad-hint">账号创建后处于待初始化状态:在服务器执行 user initialize-account 发放一次性激活 Key,用户经兑换入口设置密码后登录</p>
</div>
)}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
{keyFor ? (
{propFor ? (
<div className="ad-reset">
<div className="ad-row">
<span><b>{keyFor}</b> 的一次性登录 Key(忘记密码恢复)</span>
<button className="lg-submit ad-create-btn" disabled={busy} onClick={issue}>{issued ? '重新签发(旧 Key 与受限会话立即失效)' : '生成 Key'}</button>
<button type="button" className="ad-mini" onClick={() => { setKeyFor(null); setIssued(null); setKeys(null) }}>关闭</button>
<span><b>{propFor.owner}</b> 的恢复提案(属主确认制:管理员提案,本人确认后获一次性 Key)</span>
<button className="lg-submit ad-create-btn" disabled={busy} onClick={propose}>发起恢复提案</button>
<button type="button" className="ad-mini" onClick={() => { setPropFor(null); setProposals(null) }}>关闭</button>
</div>
{issued && (
<div className="ad-row">
<input className="lg-input lg-mono" readOnly value={issued.key} aria-label="一次性登录 Key(仅显示一次)" onFocus={e => e.currentTarget.select()} />
<button type="button" className="ad-mini" onClick={copyKey}>复制</button>
</div>
)}
{issued && <p className="ad-hint">明文仅此一次显示,关闭后不可再取;单次有效,{new Date(issued.expiresAt).toLocaleString()} 过期。请经安全渠道交付本人。</p>}
{keys !== null && keys.length > 0 && (
<p className="ad-hint">前提:该用户当前在任一设备保持完整密码登录(会话证明)。已锁死且无会话的账号无法在线恢复,请走服务器本地处置。</p>
{proposals !== null && proposals.length > 0 && (
<table className="ad-table">
<thead><tr><th>签发时间</th><th>用途</th><th>状态</th><th>过期时间</th><th></th></tr></thead>
<thead><tr><th>提案</th><th>状态</th><th>到期时间</th><th>发起人</th><th></th></tr></thead>
<tbody>
{keys.map(k => (
<tr key={k.id}>
<td className="ad-dim">{new Date(k.createdAt).toLocaleString()}</td>
<td>{k.purpose === 'recover' ? '恢复' : '激活'}</td>
<td>{keyStatusChip(k.status)}</td>
<td className="ad-dim">{new Date(k.expiresAt).toLocaleString()}</td>
<td>{k.status === 'unused' && <button className="ad-mini" disabled={busy} onClick={() => revoke(k.id)}>撤销</button>}</td>
{proposals.map(p => (
<tr key={p.id}>
<td className="ad-mono">{p.id}</td>
<td>{recoveryStatusLabel[p.status]}{recoveryProposalHint(p.status) && <span className="ad-dim"> · {recoveryProposalHint(p.status)}</span>}</td>
<td className="ad-dim">{new Date(p.expiresAt).toLocaleString()}</td>
<td className="ad-dim">{p.createdBy}</td>
<td>{(p.status === 'pending' || p.status === 'confirmed') && (
<button className="ad-mini" disabled={busy} onClick={() => revokeProposal(p)}>撤销</button>
)}</td>
</tr>
))}
</tbody>
</table>
)}
{proposals !== null && proposals.length === 0 && <p className="ad-hint">该用户当前没有恢复提案。</p>}
</div>
) : (
<table className="ad-table">
@@ -149,7 +126,7 @@ export function UsersPanel() {
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && (
<button className="ad-mini" onClick={() => openKeyPanel(u.owner)}>生成登录 Key</button>
<button className="ad-mini" onClick={() => openProposals(u)}>恢复提案</button>
)}</td>
</tr>
))}
-11
View File
@@ -1,11 +0,0 @@
// 浏览器端随机初始密码(管理员未显式给定密码时):
// crypto.getRandomValues 拒绝易混淆字符(0/O/1/l/I),保证可抄写。
const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789'
export function randomPassword(length = 12): string {
const buf = new Uint32Array(length)
crypto.getRandomValues(buf)
let out = ''
for (let i = 0; i < length; i++) out += ALPHABET[buf[i] % ALPHABET.length]
return out
}
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from './recovery'
import { humanChangePasswordError, humanRedeemError, requiresCurrentPassword } from './components/LoginPage'
// 账户链路文案与模式判定(2026-10-14 current_required 加固后前后端契约对齐的
// 防漂移锁):改密门必须区分 grant_setup / current_required,恢复提案 409 的
// 两种语义必须分开表达。
describe('requiresCurrentPassword', () => {
it('current_required 必须要当前密码,grant_setup 免当前密码', () => {
expect(requiresCurrentPassword('current_required')).toBe(true)
expect(requiresCurrentPassword('grant_setup')).toBe(false)
})
})
describe('humanChangePasswordError', () => {
it('后端 current password incorrect 映射为当前密码不正确', () => {
expect(humanChangePasswordError('401: current password incorrect')).toBe('当前密码不正确')
})
})
describe('humanRedeemError', () => {
it('401/409/429 语义分离', () => {
expect(humanRedeemError('409: login key already used')).toContain('已被使用')
expect(humanRedeemError('401: invalid or expired login key')).toContain('无效或已过期')
expect(humanRedeemError('429: too many failed attempts')).toContain('稍后再试')
})
})
describe('recoveryStatusLabel', () => {
it('覆盖全部五种提案状态', () => {
for (const status of ['pending', 'confirmed', 'completed', 'revoked', 'expired'] as const) {
expect(recoveryStatusLabel[status]).toBeTruthy()
}
})
})
describe('recoveryProposalHint', () => {
it('pending/confirmed 给出下一步,终态不给', () => {
expect(recoveryProposalHint('pending')).toContain('本人确认')
expect(recoveryProposalHint('confirmed')).toContain('新密码')
expect(recoveryProposalHint('completed')).toBe('')
})
})
describe('recoveryProposalErrorMessage', () => {
it('OWNER_PROOF_UNAVAILABLE 与活动提案冲突分开表达', () => {
expect(recoveryProposalErrorMessage('409: OWNER_PROOF_UNAVAILABLE')).toContain('会话证明')
expect(recoveryProposalErrorMessage('409: store: conflict')).toContain('已有进行中的恢复提案')
})
it('未识别错误剥状态码原样透出', () => {
expect(recoveryProposalErrorMessage('500: boom')).toBe('boom')
})
})
+29
View File
@@ -0,0 +1,29 @@
// 恢复提案展示与错误文案(纯函数):管理端 UsersPanel 与本人 MyAccountSettings
// 共用,vitest 锁定语义防漂移。后端 409 两种含义按响应体区分:
// OWNER_PROOF_UNAVAILABLE=无合格会话证明;其余 conflict=已有活动提案。
import type { RecoveryProposal } from './api'
export const recoveryStatusLabel: Record<RecoveryProposal['status'], string> = {
pending: '待本人确认',
confirmed: '已确认,待兑换设密',
completed: '已完成',
revoked: '已撤销',
expired: '已过期',
}
/** 管理端视角的下一步指引:提案成立后动作在属主侧(设置 → 账号)。 */
export function recoveryProposalHint(status: RecoveryProposal['status']): string {
if (status === 'pending') return '等待该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现'
if (status === 'confirmed') return '该用户已确认;请提醒其尽快在受限会话中完成新密码设置,到期未用需重新生成'
return ''
}
export function recoveryProposalErrorMessage(raw: string): string {
if (raw.includes('OWNER_PROOF_UNAVAILABLE')) {
return '无法发起:该账号没有可用的合格会话证明(需该用户当前在任一设备保持完整密码登录)。历史来源或已锁死账号请走服务器本地处置'
}
if (raw.includes('409')) return '该用户已有进行中的恢复提案;请先撤销旧提案或等它完成/过期'
if (raw.includes('403')) return '需要管理员身份'
if (raw.includes('404')) return '用户不存在或已被移除'
return raw.replace(/^\d{3}:\s*/, '')
}