Close account setup and recovery flows in the workbench
Why: The old user panel accepted initial passwords, exposed administrator-issued recovery keys, and used owner-only candidate selections. Evidence: SELF receipts /tmp/iam-u3-frontend-normal-combo-build.json, /tmp/iam-u3-frontend-normal-combo-test.json, /tmp/iam-u3-browser-final.json, and /tmp/iam-u3-rdm-ui-browser-replay.json. Impact: Adds optional profiles, server-backed account search, effective-state controls, metadata-only proposals, owner confirmation and recovery, current-required password mode, and stable-ID RDM pickers. Verified with the Dev-pinned UI component SHA. Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
This commit is contained in:
+32
-10
@@ -15,6 +15,13 @@ const BASE = ''
|
||||
* 自身命令面 fail-closed。 */
|
||||
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
|
||||
|
||||
export type PasswordChangeMode = 'grant_setup' | 'current_required'
|
||||
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 AdminUserRow { userId: string; owner: string; name: string; enabled: boolean; stateVersion: number; profileVersion: number; initializationState: string; initializationGeneration: number; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
|
||||
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 }
|
||||
export interface AccountSearchPage { accounts: AccountRecord[]; nextCursor?: string; directoryVersion: string }
|
||||
|
||||
async function checked(res: Response): Promise<Response> {
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
@@ -48,19 +55,19 @@ 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; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean; passwordChangeMode?: PasswordChangeMode }>(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()}`)
|
||||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }
|
||||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
|
||||
auth.beginSession()
|
||||
return me
|
||||
}),
|
||||
// 一次性登录 Key 兑换(恢复/激活):请求只带 Key,身份由服务端绑定记录决定;
|
||||
// 成功建立 set_password_only 受限会话(HttpOnly cookie),明文 Key 不落任何存储。
|
||||
redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/login-key/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => {
|
||||
redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/recovery-grants/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }
|
||||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
|
||||
auth.beginSession()
|
||||
return me
|
||||
}),
|
||||
@@ -73,12 +80,27 @@ 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)),
|
||||
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)),
|
||||
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<AdminUserRow[]>(r)),
|
||||
searchAdminUsers: (request: { query: string; mode: 'exact' | 'fuzzy'; status: 'all' | 'enabled' | 'disabled'; fields: string[]; cursor?: string; limit?: number }) => {
|
||||
const params = new URLSearchParams({ query: request.query, mode: request.mode, status: request.status,
|
||||
fields: request.fields.join(','), limit: String(request.limit ?? 20) })
|
||||
if (request.cursor) params.set('cursor', request.cursor)
|
||||
return auth.fetch(`${BASE}/api/admin/users/search?${params}`).then(r => json<AccountSearchPage>(r))
|
||||
},
|
||||
createAdminUser: (owner: string, profile: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile }) }).then(r => json<AccountRecord>(r)),
|
||||
adminGetUser: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}`).then(r => json<AccountRecord>(r)),
|
||||
adminPatchProfile: (userId: string, expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<AccountRecord>(r)),
|
||||
adminSetUserEnabled: (userId: string, expectedStateVersion: number, enabled: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/state`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, enabled }) }).then(r => json<AccountRecord>(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)),
|
||||
createRecoveryProposal: (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 ?? []),
|
||||
adminGetRecoveryProposal: (userId: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals/${encodeURIComponent(id)}`).then(r => json<RecoveryProposal>(r)),
|
||||
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)),
|
||||
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)),
|
||||
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)),
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, type AccountProfile, type AccountRecord, type RecoveryProposal } from '../api'
|
||||
import { useStore } from '../store'
|
||||
import { Collapsible, Icon } from '@ui'
|
||||
|
||||
const emptyProfile = (): AccountProfile => ({ name: '', phone: '', email: '', staffNo: '', department: '', notes: '' })
|
||||
const profileFields: { key: keyof AccountProfile; label: string }[] = [
|
||||
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
|
||||
{ key: 'staffNo', label: '工号' }, { key: 'department', label: '部门' }, { key: 'notes', label: '备注' },
|
||||
]
|
||||
|
||||
function ProfileInputs({ value, onChange, prefix }: { value: AccountProfile; onChange: (next: AccountProfile) => void; prefix: string }) {
|
||||
return <div className="ad-create">
|
||||
{profileFields.map(field => <div className="ad-row" key={field.key}>
|
||||
<label className="lg-label" htmlFor={`${prefix}-${field.key}`}>{field.label}(可空)</label>
|
||||
<input id={`${prefix}-${field.key}`} className="lg-input" value={value[field.key]}
|
||||
onChange={event => onChange({ ...value, [field.key]: event.target.value })} />
|
||||
</div>)}
|
||||
</div>
|
||||
}
|
||||
|
||||
function recoveryStatus(status: RecoveryProposal['status']): string {
|
||||
return ({ pending: '等待本人确认', confirmed: '本人已确认', completed: '已完成', revoked: '已撤销', expired: '已过期' })[status]
|
||||
}
|
||||
|
||||
export function UsersPanel() {
|
||||
const me = useStore(s => s.me)
|
||||
const [users, setUsers] = useState<AccountRecord[] | null>(null)
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
const [detail, setDetail] = useState<AccountRecord | null>(null)
|
||||
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
|
||||
const [createOwner, setCreateOwner] = useState('')
|
||||
const [createProfile, setCreateProfile] = useState<AccountProfile>(emptyProfile)
|
||||
const [draft, setDraft] = useState<AccountProfile>(emptyProfile)
|
||||
const [search, setSearch] = useState('')
|
||||
const [searchMode, setSearchMode] = useState<'exact' | 'fuzzy'>('fuzzy')
|
||||
const [searchField, setSearchField] = useState('default')
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
|
||||
const [cursor, setCursor] = useState('')
|
||||
const [cursorTrail, setCursorTrail] = useState<string[]>([])
|
||||
const [nextCursor, setNextCursor] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const requestSeq = useRef(0)
|
||||
const searchSeq = useRef(0)
|
||||
const searchRequest = () => ({ query: search.trim(), mode: searchMode, status: statusFilter,
|
||||
fields: searchField === 'default' ? ['owner', 'name', 'userId'] : [searchField], cursor, limit: 20 })
|
||||
|
||||
const refresh = async () => {
|
||||
const seq = ++searchSeq.current
|
||||
const page = await api.searchAdminUsers(searchRequest())
|
||||
if (seq === searchSeq.current) { setUsers(page.accounts); setNextCursor(page.nextCursor ?? '') }
|
||||
return page
|
||||
}
|
||||
useEffect(() => {
|
||||
const seq = ++searchSeq.current
|
||||
const timer = window.setTimeout(() => {
|
||||
api.searchAdminUsers(searchRequest()).then(page => {
|
||||
if (seq === searchSeq.current) { setUsers(page.accounts); setNextCursor(page.nextCursor ?? '') }
|
||||
}).catch(error => { if (seq === searchSeq.current) setErr(String(error)) })
|
||||
}, 180)
|
||||
return () => { window.clearTimeout(timer); searchSeq.current++; requestSeq.current++ }
|
||||
}, [search, searchMode, searchField, statusFilter, cursor])
|
||||
|
||||
const openAccount = async (userId: string) => {
|
||||
const seq = ++requestSeq.current
|
||||
setSelectedId(userId); setDetail(null); setProposals(null); setErr(''); setNotice('')
|
||||
try {
|
||||
const [account, statuses] = await Promise.all([api.adminGetUser(userId), api.listAdminRecoveryProposals(userId)])
|
||||
if (seq !== requestSeq.current) return
|
||||
setDetail(account); setDraft(account.profile); setProposals(statuses)
|
||||
} catch (error) { if (seq === requestSeq.current) setErr(String(error)) }
|
||||
}
|
||||
|
||||
const create = async () => {
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try {
|
||||
const account = await api.createAdminUser(createOwner.trim(), createProfile)
|
||||
setCreateOwner(''); setCreateProfile(emptyProfile())
|
||||
setNotice(`已创建 ${account.owner}。等待可信 owner 按 userId 初始化首次凭据。`)
|
||||
await refresh()
|
||||
await openAccount(account.userId)
|
||||
setNotice(`已创建 ${account.owner}。等待可信 owner 按 userId 初始化首次凭据。`)
|
||||
} catch (error) { setErr(String(error).includes('409') ? '用户名已存在' : String(error)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const saveProfile = async () => {
|
||||
if (!detail) return
|
||||
const patch: Partial<AccountProfile> = {}
|
||||
for (const field of profileFields) if (draft[field.key] !== detail.profile[field.key]) patch[field.key] = draft[field.key]
|
||||
if (Object.keys(patch).length === 0) { setNotice('资料没有变化'); return }
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try {
|
||||
const updated = await api.adminPatchProfile(detail.userId, detail.profileVersion, patch)
|
||||
setDetail(updated); setDraft(updated.profile); await refresh(); setNotice('资料已保存')
|
||||
} catch (error) { setErr(String(error).includes('409') ? '资料已被其他操作修改,请重新打开账号' : String(error)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const setEnabled = async () => {
|
||||
if (!detail) return
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try {
|
||||
const updated = await api.adminSetUserEnabled(detail.userId, detail.stateVersion, !detail.enabled)
|
||||
setDetail(updated); await refresh(); setNotice(updated.enabled ? '账号已启用;旧会话和 Key 不会恢复' : '账号已停用,旧会话和 Key 已撤销')
|
||||
} catch (error) { setErr(String(error).includes('409') ? '状态版本或最后管理员保护发生冲突,请重新打开账号' : String(error)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const propose = async () => {
|
||||
if (!detail) return
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try {
|
||||
const idempotencyKey = globalThis.crypto?.randomUUID?.() ?? `proposal-${Date.now()}`
|
||||
await api.createRecoveryProposal(detail.userId, detail.stateVersion, idempotencyKey)
|
||||
setProposals(await api.listAdminRecoveryProposals(detail.userId))
|
||||
setNotice('恢复提案已建立。只有目标本人提案前的合格密码会话可以确认;管理员不会收到设密能力。')
|
||||
} catch (error) {
|
||||
const message = String(error)
|
||||
setErr(message.includes('OWNER_PROOF_UNAVAILABLE') ? '目标没有合格的旧本人会话,当前无法在线恢复;异常恢复需另行获批的可信 owner 流程。' : message)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const revoke = async (proposal: RecoveryProposal) => {
|
||||
if (!detail) return
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try {
|
||||
await api.adminRevokeRecoveryProposal(detail.userId, proposal.id, proposal.version)
|
||||
setProposals(await api.listAdminRecoveryProposals(detail.userId))
|
||||
setNotice('提案已撤销')
|
||||
} catch (error) { setErr(String(error)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (!me?.isAdmin) return null
|
||||
return <div>
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" aria-label="新账号登录名" placeholder="登录名" value={createOwner} onChange={event => setCreateOwner(event.target.value)} />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !createOwner.trim()} onClick={create}>创建普通账号</button>
|
||||
</div>
|
||||
<Collapsible id="admin-create-optional-profile" trigger="可选资料:姓名、电话、邮箱、工号、部门、备注">
|
||||
<ProfileInputs value={createProfile} onChange={setCreateProfile} prefix="create-profile" />
|
||||
</Collapsible>
|
||||
<p className="ad-hint">网页建号不设置密码或管理员身份。新账号由可信 owner 按稳定 userId 完成首次初始化。</p>
|
||||
</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>}
|
||||
{selectedId ? <div className="settings-group">
|
||||
<h4 className="settings-group-title">账号详情</h4>
|
||||
<div className="ad-row">
|
||||
<button className="ad-mini" onClick={() => { requestSeq.current++; setSelectedId(''); setDetail(null); setProposals(null) }}>返回列表</button>
|
||||
<b>{detail?.owner ?? '加载账号…'}</b>
|
||||
<span className="ad-dim ad-mono">{selectedId}</span>
|
||||
</div>
|
||||
{detail && <>
|
||||
<p className="ad-hint">状态:{detail.enabled ? '有效' : '停用'} · {detail.initializationState === 'pending' ? '待可信 owner 初始化' : detail.passwordSet ? '已设置密码' : '未设置密码'} · 资料版本 {detail.profileVersion}</p>
|
||||
{detail.initializationState === 'pending' && <p className="ad-hint">交给可信 owner 的初始化定位:userId <span className="ad-mono">{detail.userId}</span> · 状态版本 {detail.stateVersion} · 代数 {detail.initializationGeneration}。凭据只经保护文件交付。</p>}
|
||||
<Collapsible id={`admin-profile-${detail.userId}`} trigger="编辑可选资料">
|
||||
<ProfileInputs value={draft} onChange={setDraft} prefix="admin-profile" />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={saveProfile}>保存资料</button>
|
||||
</Collapsible>
|
||||
<div className="ad-row">
|
||||
<button className="ad-mini" disabled={busy} onClick={setEnabled}>{detail.enabled ? '停用账号' : '启用账号'}</button>
|
||||
<button className="ad-mini" disabled={busy || !detail.enabled || !detail.passwordSet} onClick={propose}>建立本人恢复提案</button>
|
||||
</div>
|
||||
<p className="ad-hint">停用会撤销现有会话与 Key。恢复提案只返回状态;目标本人必须使用提案前已有的可信完整密码会话确认。</p>
|
||||
<h4 className="settings-group-title">恢复提案</h4>
|
||||
{(proposals ?? []).map(proposal => <div className="ad-row" key={proposal.id}>
|
||||
<span className="ad-mono">{proposal.id}</span>
|
||||
<span>{recoveryStatus(proposal.status)}</span>
|
||||
<span className="ad-dim">到期 {new Date(proposal.expiresAt).toLocaleString()}</span>
|
||||
{(proposal.status === 'pending' || proposal.status === 'confirmed') && <button className="ad-mini" disabled={busy} onClick={() => revoke(proposal)}>撤销</button>}
|
||||
</div>)}
|
||||
</>}
|
||||
</div> : <>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" aria-label="搜索账号" placeholder="搜索账号资料" value={search} onChange={event => { setSearch(event.target.value); setCursor(''); setCursorTrail([]) }} />
|
||||
<select className="lg-input" aria-label="匹配方式" value={searchMode} onChange={event => { setSearchMode(event.target.value as 'exact' | 'fuzzy'); setCursor(''); setCursorTrail([]) }}>
|
||||
<option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option>
|
||||
</select>
|
||||
<select className="lg-input" aria-label="搜索字段" value={searchField} onChange={event => { setSearchField(event.target.value); setCursor(''); setCursorTrail([]) }}>
|
||||
<option value="default">账号/姓名/ID</option>
|
||||
<option value="owner">登录名</option><option value="name">姓名</option><option value="userId">userId</option>
|
||||
<option value="phone">电话</option><option value="email">邮箱</option><option value="staffNo">工号</option>
|
||||
<option value="department">部门</option><option value="notes">备注</option>
|
||||
</select>
|
||||
<select className="lg-input" aria-label="账号状态筛选" value={statusFilter} onChange={event => { setStatusFilter(event.target.value as typeof statusFilter); setCursor(''); setCursorTrail([]) }}>
|
||||
<option value="all">全部状态</option><option value="enabled">有效</option><option value="disabled">停用</option>
|
||||
</select>
|
||||
</div>
|
||||
<table className="ad-table"><thead><tr><th>登录名</th><th>姓名</th><th>系统身份</th><th>状态</th><th>首次凭据</th><th></th></tr></thead>
|
||||
<tbody>{(users ?? []).map(user => <tr key={user.userId}>
|
||||
<td className="ad-mono">{user.owner}</td><td>{user.profile.name || '—'}</td>
|
||||
<td>{user.isAdmin ? '系统管理员' : '普通账号'}</td><td>{user.enabled ? '有效' : '停用'}</td>
|
||||
<td>{user.initializationState === 'pending' ? '待可信 owner 初始化' : user.passwordSet ? '已设置' : '未设置'}</td>
|
||||
<td><button className="ad-mini" onClick={() => openAccount(user.userId)}>查看与管理</button></td>
|
||||
</tr>)}
|
||||
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">没有匹配账号</td></tr>}
|
||||
{users === null && <tr><td colSpan={6} className="ad-dim">加载中…</td></tr>}</tbody>
|
||||
</table>
|
||||
<div className="ad-row">
|
||||
<button className="ad-mini" disabled={cursorTrail.length === 0} onClick={() => { setCursor(cursorTrail[cursorTrail.length - 1]); setCursorTrail(cursorTrail.slice(0, -1)) }}>上一页</button>
|
||||
<button className="ad-mini" disabled={!nextCursor} onClick={() => { setCursorTrail([...cursorTrail, cursor]); setCursor(nextCursor) }}>下一页</button>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
}
|
||||
@@ -27,7 +27,7 @@ function ProfilesPanelWrapper() {
|
||||
}
|
||||
|
||||
const SECTIONS: AdminSectionDef[] = [
|
||||
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => <UsersPanel /> },
|
||||
{ key: 'users', label: '用户管理', icon: 'users', desc: '无凭据建号、可选资料、账号有效态与本人恢复提案', render: () => <UsersPanel /> },
|
||||
// U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。
|
||||
{ key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => <RdmRolesPanel /> },
|
||||
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
|
||||
// LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(忘记密码恢复,只输 Key);
|
||||
// ChangePasswordGate —— 受限会话强制设密(恢复/首登同形:只输新密码,完成前不放行)。
|
||||
import { useState } from 'react'
|
||||
import { api } from '../api'
|
||||
// 登录与设密:普通密码登录、可信 owner 激活/本人确认恢复 grant。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, type PasswordChangeMode } from '../api'
|
||||
import { Icon, BrandMark } from '@ui'
|
||||
|
||||
function Err({ msg }: { msg: string }) {
|
||||
@@ -18,7 +16,7 @@ function BrandPanel() {
|
||||
<p>文档、决策与知识的沉淀、检索与生成——面向团队的自托管智能体工作台。</p>
|
||||
<ul>
|
||||
<li><Icon name="message" size={15} />项目化多任务工作区,收件箱驱动待办</li>
|
||||
<li><Icon name="shield" 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>
|
||||
@@ -76,27 +74,27 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
<form className="lg-card" onSubmit={e => { e.preventDefault(); tab === 'password' ? submitPassword() : submitKey() }}>
|
||||
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
||||
<h2 className="lg-title">登录</h2>
|
||||
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员签发一次性登录 Key</p>
|
||||
<p className="lg-sub">账号由组织创建;首次凭据由可信 owner 初始化。忘记密码可查看本人恢复提案。</p>
|
||||
<div className="lg-tabs" role="tablist">
|
||||
<button type="button" role="tab" aria-selected={tab === 'password'} className={`lg-tab ${tab === 'password' ? 'on' : ''}`} onClick={() => setTab('password')}>账号密码</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'loginkey'} className={`lg-tab ${tab === 'loginkey' ? 'on' : ''}`} onClick={() => setTab('loginkey')}>一次性登录 Key</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'loginkey'} className={`lg-tab ${tab === 'loginkey' ? 'on' : ''}`} onClick={() => setTab('loginkey')}>激活/恢复能力</button>
|
||||
</div>
|
||||
{tab === 'password' ? (
|
||||
<>
|
||||
<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="初始密码或常用密码" />
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="lg-label">一次性登录 Key</label>
|
||||
<label className="lg-label">一次性设密能力</label>
|
||||
<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">新账号激活由可信 owner 交付;恢复能力由本人使用提案前的合格会话确认后获得。管理员不能代领。程序化客户端可使用独立 API 密钥。</p>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
@@ -105,23 +103,39 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
)
|
||||
}
|
||||
|
||||
/** 强制设密门:恢复 Key 兑换或初始密码首登建立的受限会话,必须完成设密。
|
||||
* 免当前密码(R5):身份与授权均由服务端受限会话确认,页面不出现当前密码输入框;
|
||||
* 完成前不能进入工作台,完成后的唯一出口是回密码登录页。 */
|
||||
/** 设密门由服务端 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 (mode === 'current_required' && !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(mode === 'current_required' ? current : '', next)
|
||||
setDone(true)
|
||||
} catch (e) {
|
||||
setErr(humanChangePasswordError(String(e)))
|
||||
@@ -143,13 +157,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={event => setCurrent(event.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 || (mode === 'current_required' && !current) || !next || !confirm}>{busy ? '提交中…' : '设置新密码'}</button>
|
||||
{onCancel && <button type="button" className="lg-cancel" onClick={onCancel}>退出并返回登录</button>}
|
||||
</form>
|
||||
)}
|
||||
@@ -168,9 +188,9 @@ export function humanLoginError(raw: string): string {
|
||||
|
||||
/** 一次性登录 Key 兑换文案:401/409/429 语义不同,逐条映射防漂移。 */
|
||||
export function humanRedeemError(raw: string): string {
|
||||
if (raw.includes('409')) return '该 Key 已被使用过,请联系管理员重新签发'
|
||||
if (raw.includes('409')) return '该能力已被使用过;请由本人核对提案状态,或联系可信 owner 续办初始化'
|
||||
if (raw.includes('429')) return '尝试次数过多,请稍后再试'
|
||||
if (raw.includes('401')) return 'Key 无效或已过期,请联系管理员重新签发'
|
||||
if (raw.includes('401')) return '能力无效或已过期;请核对本人提案状态或联系可信 owner 续办初始化'
|
||||
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
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'
|
||||
|
||||
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: '备注' },
|
||||
]
|
||||
|
||||
const statusLabel: Record<RecoveryProposal['status'], string> = {
|
||||
pending: '等待本人确认', confirmed: '已确认,等待设密', completed: '已完成', revoked: '已撤销', expired: '已过期',
|
||||
}
|
||||
|
||||
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])
|
||||
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])
|
||||
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">管理员只能建立恢复提案。本人确认必须使用提案之前已存在、来源可信的完整密码会话;历史未知来源账号无法在线证明。</p>
|
||||
{(proposals ?? []).map(proposal => <div className="ad-create" key={proposal.id}>
|
||||
<div className="ad-row"><span className="ad-mono">{proposal.id}</span><b>{statusLabel[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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -163,29 +163,28 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [picked, setPicked] = useState<string[]>([])
|
||||
const [role, setRole] = useState<'member' | 'owner'>('member')
|
||||
const [searchMode, setSearchMode] = useState<'exact' | 'fuzzy'>('fuzzy')
|
||||
const candidatesById = useRef(new Map<string, { userId: string; owner: string }>())
|
||||
// 成员表经 ref 参与取数(已加入标记),保持 fetchPage 身份稳定(Picker 依赖)。
|
||||
const rowsRef = useRef<MemberRow[] | null>(null)
|
||||
rowsRef.current = rows
|
||||
|
||||
/** 人员选择器取数适配(P7-A):rdm.account.list 目录(member.manage 生效者)。
|
||||
* query 透传后端(基座升级 AccountSearcher 后全量生效);旧基座忽略 query 时
|
||||
* 对首页做本地包含匹配降级(翻页为浏览模式,不过滤)。 */
|
||||
/** 服务端在 member.manage 当前范围内匹配、排序和分页;选择稳定 userId。 */
|
||||
const fetchPage = useCallback(async (query: string, cursor: string, limit: number) => {
|
||||
const out = await cmd<{ accounts?: { owner: string; name?: string; valid: boolean }[]; next?: string }>(
|
||||
'rdm.account.list', { projectId, cursor, limit, query })
|
||||
const out = await cmd<{ accounts?: { userId: string; owner: string; name?: string; valid: boolean }[]; next?: string; directoryVersion?: string }>(
|
||||
'rdm.account.list', { projectId, cursor, limit, query, mode: searchMode, searchFields: ['owner', 'name'] })
|
||||
const joined = new Set((rowsRef.current ?? []).map(m => m.owner))
|
||||
let items = (out.accounts ?? []).filter(a => a.valid).map(a => ({
|
||||
id: a.owner,
|
||||
const items = (out.accounts ?? []).filter(a => a.valid && a.userId).map(a => {
|
||||
candidatesById.current.set(a.userId, { userId: a.userId, owner: a.owner })
|
||||
return {
|
||||
id: a.userId,
|
||||
title: a.name || a.owner,
|
||||
subtitle: a.owner,
|
||||
subtitle: `${a.owner} · ${a.userId}`,
|
||||
badge: joined.has(a.owner) ? '已加入' : undefined,
|
||||
}))
|
||||
if (query !== '' && cursor === '') {
|
||||
const q = query.toLowerCase()
|
||||
items = items.filter(it => it.id.toLowerCase().includes(q) || it.title.toLowerCase().includes(q))
|
||||
}
|
||||
}
|
||||
})
|
||||
return { items, nextCursor: out.next || undefined }
|
||||
}, [project])
|
||||
}, [project, searchMode])
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
@@ -193,7 +192,7 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
setRows(out.members ?? [])
|
||||
} catch (e) { setErr(String(e)) }
|
||||
}
|
||||
useEffect(() => { setPicked([]); refresh() }, [project])
|
||||
useEffect(() => { setPicked([]); candidatesById.current.clear(); refresh() }, [project])
|
||||
|
||||
const run = async (label: string, fn: () => Promise<unknown>) => {
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
@@ -202,6 +201,7 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
}
|
||||
|
||||
const pickedId = picked[0] ?? ''
|
||||
const pickedAccount = candidatesById.current.get(pickedId)
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -209,19 +209,24 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<Picker
|
||||
key={searchMode}
|
||||
aria-label="选择人员"
|
||||
selected={picked}
|
||||
onSelect={setPicked}
|
||||
fetchPage={fetchPage}
|
||||
placeholder="输入账号或姓名,或浏览候选…"
|
||||
placeholder={searchMode === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'}
|
||||
/>
|
||||
<select className="lg-input" aria-label="账号匹配方式" value={searchMode} onChange={event => { setSearchMode(event.target.value as 'exact' | 'fuzzy'); setPicked([]); candidatesById.current.clear() }}>
|
||||
<option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option>
|
||||
</select>
|
||||
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
|
||||
<option value="member">项目成员</option>
|
||||
<option value="owner">项目负责人</option>
|
||||
</select>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !!writeError || !pickedId}
|
||||
onClick={() => run(`已添加 ${pickedId}`, async () => {
|
||||
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedId, role))
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !!writeError || !pickedAccount}
|
||||
onClick={() => run(`已添加 ${pickedAccount?.owner ?? ''}`, async () => {
|
||||
if (!pickedAccount) throw new Error('请重新搜索并选择稳定账号')
|
||||
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedAccount.owner, role, pickedAccount.userId))
|
||||
setPicked([])
|
||||
})}>添加成员</button>
|
||||
</div>
|
||||
@@ -286,27 +291,12 @@ interface DutyGetOut {
|
||||
}
|
||||
|
||||
/** 职责候选 = 当前项目成员(方案 §4:候选只能来自项目成员)。
|
||||
* member.list 为小全量列表:本地 query 过滤 + 游标分页。 */
|
||||
function useMemberPickerPage(members: { owner: string; role: string }[] | null) {
|
||||
return useCallback(async (query: string, cursor: string, limit: number) => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const all = (members ?? [])
|
||||
.filter(m => q === '' || m.owner.toLowerCase().includes(q))
|
||||
.map(m => ({ id: m.owner, title: m.owner, subtitle: m.role === 'owner' ? '项目负责人' : '项目成员' }))
|
||||
const start = cursor === '' ? 0 : all.findIndex(x => x.id === cursor) + 1
|
||||
const page = all.slice(start, start + limit)
|
||||
return {
|
||||
items: page,
|
||||
nextCursor: start + limit < all.length ? page[page.length - 1]?.id : undefined,
|
||||
}
|
||||
}, [members])
|
||||
}
|
||||
* 账号候选改由 Backend 的受控文档目录提供者按当前职责权限与成员范围查询。 */
|
||||
|
||||
export function RdmDutiesSection({ project }: { project: Project }) {
|
||||
const projectId = project.id
|
||||
const writeError = projectWorkError(project)
|
||||
const [docs, setDocs] = useState<DutyCatalogDoc[] | null>(null)
|
||||
const [members, setMembers] = useState<{ owner: string; role: string }[] | null>(null)
|
||||
const [docId, setDocId] = useState('')
|
||||
const [detail, setDetail] = useState<DutyGetOut | null | undefined>(undefined)
|
||||
const [author, setAuthor] = useState<string[]>([])
|
||||
@@ -317,12 +307,31 @@ export function RdmDutiesSection({ project }: { project: Project }) {
|
||||
const [batchAuthor, setBatchAuthor] = useState<string[]>([])
|
||||
const [batchReviewers, setBatchReviewers] = useState<string[]>([])
|
||||
const [batchApprover, setBatchApprover] = useState<string[]>([])
|
||||
const [dutySearchMode, setDutySearchMode] = useState<'exact' | 'fuzzy'>('fuzzy')
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const cmd = <T,>(id: string, params: Record<string, unknown>) => api.rdmProjectCommand<T>(project, id, params)
|
||||
const memberPage = useMemberPickerPage(members)
|
||||
const candidateOwners = useRef(new Map<string, string>())
|
||||
const ownerOf = (userId: string) => candidateOwners.current.get(userId) ?? ''
|
||||
const ownersOf = (ids: string[]) => ids.map(ownerOf)
|
||||
const selectedIDs = (ids: string[]) => Object.fromEntries(ids.map(id => [ownerOf(id), id]).filter(([owner]) => owner))
|
||||
const fetchDutyPage = useCallback(async (locator: string, query: string, cursor: string, limit: number) => {
|
||||
if (!locator || !query.trim()) return { items: [], nextCursor: undefined }
|
||||
const out = await api.rdmProjectCommand<{ accounts?: { userId: string; owner: string; name?: string; valid: boolean }[]; next?: string }>(
|
||||
project, 'rdm.account.list', { projectId, docId: locator, query, cursor, limit,
|
||||
mode: dutySearchMode, searchFields: ['owner', 'name'] })
|
||||
const items = (out.accounts ?? []).filter(account => account.valid && account.userId).map(account => {
|
||||
candidateOwners.current.set(account.userId, account.owner)
|
||||
return { id: account.userId, title: account.name || account.owner, subtitle: `${account.owner} · ${account.userId}` }
|
||||
})
|
||||
return { items, nextCursor: out.next || undefined }
|
||||
}, [project, projectId, dutySearchMode])
|
||||
const dutyPage = useCallback((query: string, cursor: string, limit: number) =>
|
||||
fetchDutyPage(docId, query, cursor, limit), [fetchDutyPage, docId])
|
||||
const batchDutyPage = useCallback((query: string, cursor: string, limit: number) =>
|
||||
fetchDutyPage([...batchSel][0] ?? '', query, cursor, limit), [fetchDutyPage, batchSel])
|
||||
const requests = useRef({ project, epoch: 0, selected: '', selection: 0, catalog: 0, status: 0, action: 0 })
|
||||
requests.current.project = project
|
||||
const currentProject = (epoch: number) => requests.current.project === project && requests.current.epoch === epoch
|
||||
@@ -352,6 +361,15 @@ export function RdmDutiesSection({ project }: { project: Project }) {
|
||||
setSub(undefined); setErr(`整文状态读取失败:${e}`)
|
||||
}
|
||||
}
|
||||
const resolveCurrentOwner = async (document: string, owner: string): Promise<string> => {
|
||||
if (!owner) return ''
|
||||
const out = await cmd<{ accounts?: { userId: string; owner: string; valid: boolean }[] }>('rdm.account.list',
|
||||
{ projectId, docId: document, query: owner, mode: 'exact', searchFields: ['owner'], limit: 2 })
|
||||
const match = (out.accounts ?? []).find(account => account.owner === owner && account.valid && account.userId)
|
||||
if (!match) return ''
|
||||
candidateOwners.current.set(match.userId, owner)
|
||||
return match.userId
|
||||
}
|
||||
/** 选中文档:读当前职责(duty.get)并回填选择器(方案:当前职责与冻结名单分列)。 */
|
||||
const selectDoc = async (id: string, keepNotice = false) => {
|
||||
const epoch = requests.current.epoch
|
||||
@@ -361,15 +379,26 @@ export function RdmDutiesSection({ project }: { project: Project }) {
|
||||
setErr(''); if (!keepNotice) setNotice('')
|
||||
setDocId(id); setDetail(undefined); setSub(undefined); setBatchSel(new Set())
|
||||
setAuthor([]); setReviewers([]); setApprover([])
|
||||
setBatchAuthor([]); setBatchReviewers([]); setBatchApprover([]); candidateOwners.current.clear()
|
||||
if (!id) return
|
||||
try {
|
||||
const out = await cmd<DutyGetOut>('rdm.duty.get', { projectId, docId: id })
|
||||
if (!currentDocument(epoch, selection, id)) return
|
||||
if (out.docId !== id) throw new Error('文档职责与当前选择不一致,请重新选择文档')
|
||||
setDetail(out)
|
||||
setAuthor(out.assigned && out.roles.author ? [out.roles.author] : [])
|
||||
setReviewers(out.assigned ? out.roles.reviewers ?? [] : [])
|
||||
setApprover(out.assigned && out.roles.approver ? [out.roles.approver] : [])
|
||||
if (out.assigned) {
|
||||
try {
|
||||
const names = [out.roles.author, ...(out.roles.reviewers ?? []), out.roles.approver]
|
||||
const ids = await Promise.all(names.map(owner => resolveCurrentOwner(id, owner)))
|
||||
if (!currentDocument(epoch, selection, id)) return
|
||||
setAuthor(ids[0] ? [ids[0]] : [])
|
||||
setReviewers(ids.slice(1, -1).filter(Boolean))
|
||||
setApprover(ids[ids.length - 1] ? [ids[ids.length - 1]] : [])
|
||||
if (ids.some(identity => !identity)) setErr('现有职责包含失效或未授权账号;请重新搜索并选择有效成员')
|
||||
} catch (error) {
|
||||
if (currentDocument(epoch, selection, id)) setErr(`账号目录查验失败,当前职责不可编辑:${String(error)}`)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (!currentDocument(epoch, selection, id)) return
|
||||
setDetail(null); setErr(String(e))
|
||||
@@ -381,13 +410,10 @@ export function RdmDutiesSection({ project }: { project: Project }) {
|
||||
requests.current.selected = ''; requests.current.selection++; requests.current.status++
|
||||
requests.current.action++
|
||||
setDocId(''); setDetail(undefined); setSub(undefined); setBatchSel(new Set())
|
||||
setDocs(null); setMembers(null); setAuthor([]); setReviewers([]); setApprover([])
|
||||
setDocs(null); setAuthor([]); setReviewers([]); setApprover([]); candidateOwners.current.clear()
|
||||
setBatchAuthor([]); setBatchReviewers([]); setBatchApprover([])
|
||||
setErr(''); setNotice(''); setBusy(false)
|
||||
void refreshCatalog(epoch)
|
||||
cmd<{ members: { owner: string; role: string }[] }>('rdm.project.member.list', { projectId })
|
||||
.then(out => { if (currentProject(epoch)) setMembers(out.members ?? []) })
|
||||
.catch(() => { if (currentProject(epoch)) setMembers([]) })
|
||||
return () => {
|
||||
requests.current.epoch++; requests.current.selection++; requests.current.status++
|
||||
requests.current.catalog++; requests.current.action++; requests.current.selected = ''
|
||||
@@ -412,24 +438,30 @@ export function RdmDutiesSection({ project }: { project: Project }) {
|
||||
} finally { if (currentAction()) setBusy(false) }
|
||||
}
|
||||
|
||||
const authorOwner = ownerOf(author[0] ?? '')
|
||||
const reviewerOwners = ownersOf(reviewers)
|
||||
const approverOwner = ownerOf(approver[0] ?? '')
|
||||
const dirty = detail != null && (
|
||||
(author[0] ?? '') !== (detail.assigned ? detail.roles.author : '')
|
||||
|| JSON.stringify(reviewers) !== JSON.stringify(detail.assigned ? detail.roles.reviewers ?? [] : [])
|
||||
|| (approver[0] ?? '') !== (detail.assigned ? detail.roles.approver : ''))
|
||||
authorOwner !== (detail.assigned ? detail.roles.author : '')
|
||||
|| JSON.stringify(reviewerOwners) !== JSON.stringify(detail.assigned ? detail.roles.reviewers ?? [] : [])
|
||||
|| approverOwner !== (detail.assigned ? detail.roles.approver : ''))
|
||||
|
||||
const saveDisabled = busy || !!writeError || !docId || detail == null || !author[0] || !approver[0] || reviewers.length === 0
|
||||
const saveDisabled = busy || !!writeError || !docId || detail == null || !authorOwner || !approverOwner ||
|
||||
reviewers.length === 0 || reviewerOwners.some(owner => !owner)
|
||||
|
||||
const save = () => {
|
||||
if (saveDisabled || !dirty || requests.current.selected !== docId || detail?.docId !== docId) return
|
||||
if (detail.active && !window.confirm(
|
||||
`文档「${detail.title}」在途第 ${detail.active.round} 轮审签将因职责变更失效,需重新提交。确认变更?`)) return
|
||||
return run('已保存当前职责', () =>
|
||||
cmd('rdm.review.assign.doc', assignDocDutiesPayload(projectId, docId, author[0], reviewers, approver[0])))
|
||||
cmd('rdm.review.assign.doc', { ...assignDocDutiesPayload(projectId, docId, authorOwner, reviewerOwners, approverOwner),
|
||||
accountIds: selectedIDs([...author, ...reviewers, ...approver]) }))
|
||||
}
|
||||
|
||||
const batchApply = () => run(`已批量分配 ${batchSel.size} 份文档`, () =>
|
||||
cmd('rdm.review.assign.doc.batch', { projectId, docs: [...batchSel].map(d => ({
|
||||
docId: d, author: batchAuthor[0], reviewers: batchReviewers, approver: batchApprover[0] })) }))
|
||||
docId: d, author: ownerOf(batchAuthor[0] ?? ''), reviewers: ownersOf(batchReviewers),
|
||||
approver: ownerOf(batchApprover[0] ?? ''), accountIds: selectedIDs([...batchAuthor, ...batchReviewers, ...batchApprover]) })) }))
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -440,7 +472,7 @@ export function RdmDutiesSection({ project }: { project: Project }) {
|
||||
{(docs ?? []).map(d => (
|
||||
<tr key={d.docId} className={d.docId === docId ? 'ad-row-on' : ''}>
|
||||
<td><input type="checkbox" checked={batchSel.has(d.docId)}
|
||||
onChange={e => { const n = new Set(batchSel); e.target.checked ? n.add(d.docId) : n.delete(d.docId); setBatchSel(n) }} /></td>
|
||||
onChange={e => { const n = new Set(batchSel); e.target.checked ? n.add(d.docId) : n.delete(d.docId); setBatchSel(n); setBatchAuthor([]); setBatchReviewers([]); setBatchApprover([]) }} /></td>
|
||||
<td><button className="ad-mini" onClick={() => selectDoc(d.docId)}>{d.title}</button>
|
||||
<span className="ad-dim ad-mono"> · {d.docId}</span></td>
|
||||
<td className="ad-dim">{d.typeKey}{d.stage ? ` / ${d.stage}` : ''}</td>
|
||||
@@ -459,13 +491,19 @@ export function RdmDutiesSection({ project }: { project: Project }) {
|
||||
{detail && (
|
||||
<div className="settings-group" style={{ marginTop: 14 }}>
|
||||
<h4>{detail.title} · 当前职责{detail.active ? <span className="ad-chip ad-chip-warn">在途第 {detail.active.round} 轮(变更将失效需重提交)</span> : null}</h4>
|
||||
<div className="ad-row"><label className="lg-label" htmlFor="duty-search-mode">候选账号匹配</label>
|
||||
<select id="duty-search-mode" className="lg-input" value={dutySearchMode} onChange={event => setDutySearchMode(event.target.value as 'exact' | 'fuzzy')}>
|
||||
<option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option>
|
||||
</select><span className="ad-hint">仅当前有效项目成员;输入后选择 userId</span></div>
|
||||
<div className="setting-row"><label>编制人(单选)</label>
|
||||
<div style={{ maxWidth: 320 }}><Picker aria-label="编制人" selected={author} onSelect={setAuthor} fetchPage={memberPage} /></div></div>
|
||||
<div style={{ maxWidth: 320 }}><Picker aria-label="编制人" selected={author} onSelect={setAuthor} fetchPage={dutyPage} placeholder="输入账号或姓名搜索" emptyText="输入后显示有效成员" /></div>
|
||||
<span className="ad-dim">{authorOwner || '未选择'}</span></div>
|
||||
<div className="setting-row"><label>审核人(多选 ≥1)</label>
|
||||
<div style={{ maxWidth: 320 }}><Picker aria-label="审核人" multiple selected={reviewers} onSelect={setReviewers} fetchPage={memberPage} /></div>
|
||||
{reviewers.length > 0 && <span className="ad-dim">已选:{reviewers.join('、')}</span>}</div>
|
||||
<div style={{ maxWidth: 320 }}><Picker aria-label="审核人" multiple selected={reviewers} onSelect={setReviewers} fetchPage={dutyPage} placeholder="输入账号或姓名搜索" emptyText="输入后显示有效成员" /></div>
|
||||
{reviewers.length > 0 && <span className="ad-dim">已选:{reviewerOwners.join('、')}</span>}</div>
|
||||
<div className="setting-row"><label>批准人(单选)</label>
|
||||
<div style={{ maxWidth: 320 }}><Picker aria-label="批准人" selected={approver} onSelect={setApprover} fetchPage={memberPage} /></div></div>
|
||||
<div style={{ maxWidth: 320 }}><Picker aria-label="批准人" selected={approver} onSelect={setApprover} fetchPage={dutyPage} placeholder="输入账号或姓名搜索" emptyText="输入后显示有效成员" /></div>
|
||||
<span className="ad-dim">{approverOwner || '未选择'}</span></div>
|
||||
<div className="ad-row">
|
||||
<Btn variant="primary" icon="check" disabled={saveDisabled || !dirty} onClick={save}>保存当前职责</Btn>
|
||||
{detail.active && <span className="ad-hint">保存将使在途第 {detail.active.round} 轮失效(提交人 {detail.active.createdBy})</span>}
|
||||
@@ -507,13 +545,16 @@ export function RdmDutiesSection({ project }: { project: Project }) {
|
||||
<div className="settings-group" style={{ marginTop: 14 }}>
|
||||
<h4>批量分配(已选 {batchSel.size} 份;任一校验失败整批拒绝并列原因)</h4>
|
||||
<div className="setting-row"><label>统一编制人</label>
|
||||
<div style={{ maxWidth: 320 }}><Picker aria-label="批量编制人" selected={batchAuthor} onSelect={setBatchAuthor} fetchPage={memberPage} /></div></div>
|
||||
<div style={{ maxWidth: 320 }}><Picker aria-label="批量编制人" selected={batchAuthor} onSelect={setBatchAuthor} fetchPage={batchDutyPage} placeholder="输入账号或姓名搜索" emptyText="输入后显示有效成员" /></div>
|
||||
<span className="ad-dim">{ownerOf(batchAuthor[0] ?? '') || '未选择'}</span></div>
|
||||
<div className="setting-row"><label>统一审核人</label>
|
||||
<div style={{ maxWidth: 320 }}><Picker aria-label="批量审核人" multiple selected={batchReviewers} onSelect={setBatchReviewers} fetchPage={memberPage} /></div></div>
|
||||
<div style={{ maxWidth: 320 }}><Picker aria-label="批量审核人" multiple selected={batchReviewers} onSelect={setBatchReviewers} fetchPage={batchDutyPage} placeholder="输入账号或姓名搜索" emptyText="输入后显示有效成员" /></div>
|
||||
<span className="ad-dim">{ownersOf(batchReviewers).join('、') || '未选择'}</span></div>
|
||||
<div className="setting-row"><label>统一批准人</label>
|
||||
<div style={{ maxWidth: 320 }}><Picker aria-label="批量批准人" selected={batchApprover} onSelect={setBatchApprover} fetchPage={memberPage} /></div></div>
|
||||
<div style={{ maxWidth: 320 }}><Picker aria-label="批量批准人" selected={batchApprover} onSelect={setBatchApprover} fetchPage={batchDutyPage} placeholder="输入账号或姓名搜索" emptyText="输入后显示有效成员" /></div>
|
||||
<span className="ad-dim">{ownerOf(batchApprover[0] ?? '') || '未选择'}</span></div>
|
||||
<div className="ad-row">
|
||||
<Btn variant="primary" icon="check" disabled={busy || !!writeError || !batchAuthor[0] || !batchApprover[0] || batchReviewers.length === 0}
|
||||
<Btn variant="primary" icon="check" disabled={busy || !!writeError || !ownerOf(batchAuthor[0] ?? '') || !ownerOf(batchApprover[0] ?? '') || batchReviewers.length === 0 || ownersOf(batchReviewers).some(owner => !owner)}
|
||||
onClick={batchApply}>批量分配 {batchSel.size} 份文档</Btn>
|
||||
<button className="ad-mini" onClick={() => setBatchSel(new Set())}>清空选择</button>
|
||||
</div>
|
||||
|
||||
@@ -9,157 +9,9 @@ import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocB
|
||||
import { SettingInput, flatten, unflatten } from './SettingsModal'
|
||||
import type { SettingDesc } from '../contracts'
|
||||
import { docBridge } from './Workbench'
|
||||
import { randomPassword } from '../passwords'
|
||||
|
||||
// ---- 用户管理(原 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>
|
||||
}
|
||||
|
||||
export function UsersPanel() {
|
||||
const me = useStore(s => s.me)
|
||||
const [users, setUsers] = useState<AdminUserRow[] | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
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)
|
||||
|
||||
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)) }
|
||||
}
|
||||
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('')
|
||||
await refresh()
|
||||
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const issue = async () => {
|
||||
if (!keyFor) 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) }
|
||||
}
|
||||
|
||||
const copyKey = async () => {
|
||||
if (!issued) return
|
||||
try { await navigator.clipboard.writeText(issued.key); setNotice('已复制到剪贴板,请安全交付给该用户') } catch { setErr('复制失败,请手动选择复制') }
|
||||
}
|
||||
|
||||
const revoke = async (id: string) => {
|
||||
if (!keyFor) return
|
||||
setErr(''); setBusy(true)
|
||||
try {
|
||||
await api.adminRevokeLoginKey(keyFor, id)
|
||||
await refreshKeys(keyFor)
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{me?.isAdmin && (
|
||||
<div className="ad-create">
|
||||
<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)} />
|
||||
</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>
|
||||
</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 ? (
|
||||
<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>
|
||||
</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 && (
|
||||
<table className="ad-table">
|
||||
<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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(users ?? []).map(u => (
|
||||
<tr key={u.owner}>
|
||||
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
|
||||
<td>{u.name || '—'}</td>
|
||||
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
|
||||
<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>
|
||||
)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// ---- 用户管理:账号资料与恢复提案(U3)----
|
||||
export { UsersPanel } from './AccountUsersPanel'
|
||||
|
||||
// ---- 智能体画像(原 SettingsModal ProfilesSection:数据化导入导出 + 编辑)----
|
||||
export function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -102,8 +102,8 @@ export const roleActionPayload = (roleId: string, action: string, def: boolean,
|
||||
export const roleAssignPayload = (owner: string, roleId: string) => ({ owner, roleId })
|
||||
export const roleDisablePayload = (roleId: string, disabled: boolean) => ({ roleId, disabled })
|
||||
|
||||
export const memberAddPayload = (projectId: string, member: string, role: 'owner' | 'member' = 'member') =>
|
||||
({ projectId, member, role })
|
||||
export const memberAddPayload = (projectId: string, member: string, role: 'owner' | 'member' = 'member', userId?: string) =>
|
||||
({ projectId, member, role, ...(userId ? { userId } : {}) })
|
||||
export const memberRemovePayload = (projectId: string, member: string) => ({ projectId, member })
|
||||
export const memberSetRolePayload = (projectId: string, member: string, role: 'owner' | 'member') => ({ projectId, member, role })
|
||||
|
||||
|
||||
@@ -31,10 +31,10 @@ describe('humanChangePasswordError(改密门)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanRedeemError(一次性登录 Key 兑换)', () => {
|
||||
describe('humanRedeemError(可信激活与本人恢复能力兑换)', () => {
|
||||
it('409 已使用 / 401 无效过期 / 429 退避各有文案', () => {
|
||||
expect(humanRedeemError('409: login key already used')).toBe('该 Key 已被使用过,请联系管理员重新签发')
|
||||
expect(humanRedeemError('401: invalid or expired login key')).toBe('Key 无效或已过期,请联系管理员重新签发')
|
||||
expect(humanRedeemError('409: login key already used')).toBe('该能力已被使用过;请由本人核对提案状态,或联系可信 owner 续办初始化')
|
||||
expect(humanRedeemError('401: invalid or expired login key')).toBe('能力无效或已过期;请核对本人提案状态或联系可信 owner 续办初始化')
|
||||
expect(humanRedeemError('429: too many failed attempts; retry later')).toBe('尝试次数过多,请稍后再试')
|
||||
})
|
||||
it('未知错误剥状态码前缀透传', () => {
|
||||
|
||||
Reference in New Issue
Block a user