From d47c49350bf3af160584e255d6d29f28cfc81e99 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Sun, 18 Oct 2026 16:31:17 +0000 Subject: [PATCH] agent-exec transport 832aaeb220c114fdce38450a7e3d2dc7/e80375762b4e --- .gitea/workflows/ci.yml | 68 ++++++++++++++++++++++++++++ src/api.ts | 6 +-- src/components/AccountUsersPanel.tsx | 42 +++++++++++------ src/components/LoginPage.tsx | 4 +- src/components/adminSections.tsx | 65 +++++++++++++++++++++----- src/recovery.test.ts | 40 +++++++++++++++- src/recovery.ts | 21 +++++++++ test/account-users-panel.test.ts | 10 ++++ test/loginpage.test.ts | 6 +-- 9 files changed, 228 insertions(+), 34 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 test/account-users-panel.test.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..8515323 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,68 @@ +name: ci +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test-and-build: + runs-on: ubuntu-latest + steps: + - name: Check out fixed Frontend and UI commits + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + UI_DEPLOY_KEY: ${{ secrets.UI_DEPLOY_KEY }} + UI_SHA: 7c35a78abc8ef7db6fb2d508b2971d59a90de964 + run: | + set -euo pipefail + printf 'context server=%s repository=%s sha=%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_SHA}" + [[ "${GITHUB_SERVER_URL}" =~ ^https?://[^/]+/?$ ]] + test "${GITHUB_REPOSITORY}" = "weichen/Agent-System-Frontend" + [[ "${GITHUB_SHA}" =~ ^[0-9a-f]{40}$ ]] + [[ "${UI_SHA}" =~ ^[0-9a-f]{40}$ ]] + test -n "${TOKEN}" + if [ -z "${UI_DEPLOY_KEY}" ]; then + echo "UI_DEPLOY_KEY is required for the private UI dependency" >&2 + exit 1 + fi + export GIT_TERMINAL_PROMPT=0 + mkdir -p "${RUNNER_TEMP}/iam-frontend-ci/frontend" "${RUNNER_TEMP}/iam-frontend-ci/ui" + cd "${RUNNER_TEMP}/iam-frontend-ci/frontend" + git init -q + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" fetch --depth=1 origin "${GITHUB_SHA}" + git checkout -q --detach FETCH_HEAD + test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + umask 077 + trap 'rm -f "${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key" "${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts"' EXIT + printf '%s' "${UI_DEPLOY_KEY}" > "${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key" + printf '%s\n' '[git-ssh.u1680832.nyat.app]:31411 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK55QSxDqMMZ1HIWHSPkQ7fyJkDKbdg98TeIv6SrR3qx' > "${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts" + export GIT_SSH_COMMAND="ssh -F /dev/null -i ${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts -p 31411" + cd "${RUNNER_TEMP}/iam-frontend-ci/ui" + git init -q + git remote add origin "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-UI.git" + git fetch --depth=1 origin "${UI_SHA}" + git checkout -q --detach FETCH_HEAD + test "$(git rev-parse HEAD)" = "${UI_SHA}" + git -C "${RUNNER_TEMP}/iam-frontend-ci/frontend" status --short + git -C "${RUNNER_TEMP}/iam-frontend-ci/ui" status --short + - name: Install Node 22 + env: + NODE_ARCHIVE_SHA256: df450af89261115ef9f9e3830c3eeb2cc9213b63c720b1af623cb5dcbe2e02de + run: | + set -euo pipefail + curl -fsSL -o "$RUNNER_TEMP/node.tar.xz" https://npmmirror.com/mirrors/node/v22.23.3/node-v22.23.3-linux-x64.tar.xz + printf '%s %s\n' "${NODE_ARCHIVE_SHA256}" "$RUNNER_TEMP/node.tar.xz" | sha256sum -c - + tar -C /usr/local --strip-components=1 -xJf "$RUNNER_TEMP/node.tar.xz" + - name: Install dependencies + run: | + set -euo pipefail + npm ci --prefix "${RUNNER_TEMP}/iam-frontend-ci/ui" + npm ci --prefix "${RUNNER_TEMP}/iam-frontend-ci/frontend" + - name: Test and build + run: | + set -euo pipefail + cd "${RUNNER_TEMP}/iam-frontend-ci/frontend" + npm test + npm run build diff --git a/src/api.ts b/src/api.ts index 90745e5..0c5d646 100644 --- a/src/api.ts +++ b/src/api.ts @@ -132,15 +132,15 @@ export const api = { // ---- 认证与会话(管理员发放制,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 }), diff --git a/src/components/AccountUsersPanel.tsx b/src/components/AccountUsersPanel.tsx index 4b5dc56..67fe108 100644 --- a/src/components/AccountUsersPanel.tsx +++ b/src/components/AccountUsersPanel.tsx @@ -12,6 +12,9 @@ const fields: { key: keyof AccountProfile; label: string }[] = [ { key: 'staffNo', label: '工号' }, { key: 'department', label: '部门' }, { key: 'notes', label: '备注' }, ] +export const credentialLabel = (account: Pick) => + account.initializationState === 'pending' ? '待可信 owner 初始化' : account.passwordSet ? '已设置密码' : '未设置密码' + function ProfileInputs({ profile, onChange, prefix }: { profile: AccountProfile; onChange: (value: AccountProfile) => void; prefix: string }) { return
{fields.map(field =>
@@ -56,15 +59,18 @@ export function AccountUsersPanel() { const sequence = useRef(0) const roleSequence = useRef(0) - const search = async (pageCursor = cursor) => { + const search = async (pageCursor = cursor, afterWrite = false) => { const request = ++sequence.current try { const page = await api.searchAdminUsers({ query: query.trim(), mode, status, fields: field === 'default' ? ['owner', 'name', 'userId'] : [field], cursor: pageCursor, limit: 20 }) if (request !== sequence.current) return setAccounts(page.accounts ?? []); setNextCursor(page.nextCursor ?? '') - } catch (error) { if (request === sequence.current) setErr(String(error)) } + } catch (error) { + if (request === sequence.current) setErr(afterWrite ? `操作已完成,但用户列表暂未刷新:${String(error)}` : String(error)) + } } + const resetPaging = () => { setCursor(''); setTrail([]) } useEffect(() => { const timer = window.setTimeout(() => { void search() }, 180) @@ -113,9 +119,10 @@ export function AccountUsersPanel() { return () => { window.clearTimeout(timer) } }, [selected?.userId, roleQuery, roleCursor, canViewRoles]) + // 写入成功会推进目录版本,旧页游标随之失效:从第一页重读,刷新失败不改写成功结果。 const refreshFirst = async () => { - setCursor(''); setTrail([]); setNextCursor('') - await search('') + resetPaging(); setNextCursor('') + await search('', true) } const create = async () => { @@ -124,8 +131,9 @@ export function AccountUsersPanel() { try { const account = await api.createManagedUser(newOwner.trim(), newProfile) setNewOwner(''); setNewProfile(emptyProfile()) - await refreshFirst(); await open(account) + await open(account) setNotice(`已创建 ${account.owner};账号资料可空,角色另行分配`) + await refreshFirst() } catch (error) { setErr(String(error).includes('409') ? '登录名已存在' : String(error)) } finally { setBusy(false) } } @@ -168,8 +176,10 @@ export function AccountUsersPanel() { setBusy(true); setErr(''); setNotice('') try { const updated = await api.adminSetUserEnabled(selected.userId, selected.stateVersion, !selected.enabled) - setSelected(updated); await refreshFirst(); setNotice(updated.enabled ? '账号已启用' : '账号已停用') - } catch (error) { setErr(String(error)) } finally { setBusy(false) } + setSelected(updated); setNotice(updated.enabled ? '账号已启用;旧会话和 Key 不会恢复' : '账号已停用,旧会话、Key 与进行中的恢复提案已撤销') + await refreshFirst() + } catch (error) { setErr(String(error).includes('409') ? '状态版本或最后管理员保护发生冲突,请重新打开用户' : String(error)) } + finally { setBusy(false) } } if (!allowed('system.users.view')) return

当前账号没有查看用户的系统权限

@@ -186,7 +196,8 @@ export function AccountUsersPanel() { {selected ?
{selected.owner}{selected.userId}
-

{selected.enabled ? '有效账号' : '停用账号'} · 当前角色 {canViewRoles ? (roleView?.role?.name ?? '未分配') : '无角色查看权限'}

+

{selected.enabled ? '有效账号' : '停用账号'} · {credentialLabel(selected)} · 资料版本 {selected.profileVersion} · 当前角色 {canViewRoles ? (roleView?.role?.name ?? '未分配') : '无角色查看权限'}

+ {selected.initializationState === 'pending' &&

交给可信 owner 的初始化定位:userId {selected.userId} · 状态版本 {selected.stateVersion} · 代数 {selected.initializationGeneration}。凭据只经保护文件交付。

} {allowed('system.users.edit') && @@ -213,17 +224,20 @@ export function AccountUsersPanel() { {!canViewGrants && allowed('system.permissions.manage') &&

需要同时具备系统权限查看权,才能安全编辑现有授权

}
: <>
- { setQuery(event.target.value); setCursor(''); setTrail([]) }} /> - - { setQuery(event.target.value); resetPaging() }} /> + + - +
- +
登录名姓名工号状态
{(accounts ?? []).map(account => - )}
登录名姓名工号系统身份状态首次凭据
{account.owner}{account.profile.name || '—'}{account.profile.staffNo || '—'}{account.enabled ? '有效' : '停用'}
+ {account.profile.staffNo || '—'}{account.isAdmin ? '系统管理员' : '普通账号'}{account.enabled ? '有效' : '停用'} + {credentialLabel(account)})} + {accounts !== null && accounts.length === 0 && 没有匹配用户} + {accounts === null && 加载中…}
} diff --git a/src/components/LoginPage.tsx b/src/components/LoginPage.tsx index 220fe40..0c4e875 100644 --- a/src/components/LoginPage.tsx +++ b/src/components/LoginPage.tsx @@ -166,9 +166,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*/, '') } diff --git a/src/components/adminSections.tsx b/src/components/adminSections.tsx index ad04896..9a090e5 100644 --- a/src/components/adminSections.tsx +++ b/src/components/adminSections.tsx @@ -1,7 +1,7 @@ // 系统管理分区面板(重设计方案 阶段②):普通用户界面不再暴露的系统能力 // 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致 //(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。 -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { api, type RecoveryProposal } from '../api' import { store, useStore } from '../store' import type { AgentProfile } from '../contracts' @@ -9,7 +9,10 @@ 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 { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from '../recovery' +import { + recoveryAttemptKey, recoveryProposalErrorMessage, recoveryProposalHint, recoveryProposalNotice, recoveryProposalRejected, + recoveryStatusLabel, type RecoveryAttempt, +} from '../recovery' // ---- 用户管理:页面建号可设置/生成初始密码,管理员重置生成临时密码; // 保留恢复提案与 Key 管理作为既有非登录页恢复流程。 ---- @@ -29,11 +32,20 @@ export function UsersPanel() { const [propFor, setPropFor] = useState(null) const [proposals, setProposals] = useState(null) + // 提案视图代次:切换/关闭目标推进代次,迟到响应不得覆盖新选中的账号。 + const proposalSeq = useRef(0) + // 结果未确定的提案请求:显式重试沿用同一 idempotencyKey,不重复创建。 + const recoveryAttempt = useRef(null) + const refresh = async () => { try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) } } const refreshProposals = async (userId: string) => { - try { setProposals(await api.listAdminRecoveryProposals(userId)) } catch (e) { setErr(String(e)) } + const seq = proposalSeq.current + try { + const statuses = await api.listAdminRecoveryProposals(userId) + if (seq === proposalSeq.current) setProposals(statuses) + } catch (e) { if (seq === proposalSeq.current) setErr(String(e)) } } useEffect(() => { refresh() }, []) @@ -60,32 +72,63 @@ export function UsersPanel() { } const openProposals = async (u: AdminUserRow) => { + proposalSeq.current++ setErr(''); setNotice(''); setPropFor(u); setProposals(null) await refreshProposals(u.userId) } + const closeProposals = () => { proposalSeq.current++; setPropFor(null); setProposals(null) } const propose = async () => { if (!propFor) return + const target = propFor + const seq = proposalSeq.current + const stillSelected = () => seq === proposalSeq.current setErr(''); setNotice(''); setBusy(true) + const idempotencyKey = recoveryAttemptKey(recoveryAttempt.current, target.userId, target.stateVersion, () => crypto.randomUUID()) + recoveryAttempt.current = { userId: target.userId, stateVersion: target.stateVersion, key: idempotencyKey } try { - await api.createAdminRecoveryProposal(propFor.userId, propFor.stateVersion, crypto.randomUUID()) - setNotice(`已为 ${propFor.owner} 建立恢复提案:请该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现`) - await refreshProposals(propFor.userId) + const created = await api.createAdminRecoveryProposal(target.userId, target.stateVersion, idempotencyKey) + recoveryAttempt.current = null + const statuses = await api.listAdminRecoveryProposals(target.userId).catch(() => null) + if (!stillSelected()) return + if (statuses) setProposals(statuses) + setNotice(recoveryProposalNotice(target.owner, (statuses?.find(p => p.id === created.id) ?? created).status)) } catch (e) { - if (String(e).includes('409')) { - await resetPassword(propFor) + const message = String(e) + if (message.includes('409')) { + recoveryAttempt.current = null + await resetPassword(target) return } - setErr(recoveryProposalErrorMessage(String(e))) + if (!stillSelected()) return + if (recoveryProposalRejected(message)) { + recoveryAttempt.current = null + setErr(recoveryProposalErrorMessage(message)) + return + } + // 响应丢失或服务端异常时提案可能已提交:先重读元数据,再允许沿用同一 key 显式重试。 + try { + const statuses = await api.listAdminRecoveryProposals(target.userId) + if (!stillSelected()) return + setProposals(statuses) + if (statuses.length > 0) { + setNotice(`提案响应未收到;状态已刷新。最近记录:${recoveryStatusLabel[statuses[0].status]}。请核对下方记录;再次提交会沿用同一请求标识。`) + } else { + setErr('提案结果未确定;状态已刷新。再次提交会沿用同一请求标识。') + } + } catch { + if (stillSelected()) setErr('提案结果未知且状态暂时无法读取;请稍后核对,不会自动重复创建。') + } } finally { setBusy(false) } } const revokeProposal = async (p: RecoveryProposal) => { if (!propFor) return - setErr(''); setBusy(true) + setErr(''); setNotice(''); setBusy(true) try { await api.adminRevokeRecoveryProposal(propFor.userId, p.id, p.version) await refreshProposals(propFor.userId) + setNotice('提案已撤销') } catch (e) { setErr(String(e)) } finally { setBusy(false) } } @@ -114,7 +157,7 @@ export function UsersPanel() {
{propFor.owner} 的恢复提案(属主确认制:管理员提案,本人确认后获一次性 Key) - +

前提:该用户当前在任一设备保持完整密码登录(会话证明)。已锁死且无会话的账号无法在线恢复,请走服务器本地处置。

{proposals !== null && proposals.length > 0 && ( diff --git a/src/recovery.test.ts b/src/recovery.test.ts index 4b9c7cd..4337fd4 100644 --- a/src/recovery.test.ts +++ b/src/recovery.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' -import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from './recovery' +import { + recoveryAttemptKey, recoveryProposalErrorMessage, recoveryProposalHint, recoveryProposalNotice, recoveryProposalRejected, recoveryStatusLabel, +} from './recovery' import { humanChangePasswordError, humanRedeemError, requiresCurrentPassword } from './components/LoginPage' // 账户链路文案与模式判定(2026-10-14 current_required 加固后前后端契约对齐的 @@ -43,6 +45,42 @@ describe('recoveryProposalHint', () => { }) }) +describe('recoveryProposalNotice', () => { + it('按服务端当前状态给出结果:待确认、已确认与终态(重复响应只回状态)', () => { + expect(recoveryProposalNotice('alice', 'pending')).toContain('本人确认') + expect(recoveryProposalNotice('alice', 'pending')).toContain('管理员不会收到设密能力') + expect(recoveryProposalNotice('alice', 'confirmed')).toContain('已由本人确认') + expect(recoveryProposalNotice('alice', 'confirmed')).toContain('管理员不会收到设密能力') + for (const status of ['completed', 'revoked', 'expired'] as const) { + const notice = recoveryProposalNotice('alice', status) + expect(notice).toContain(recoveryStatusLabel[status]) + expect(notice).toContain('核对下方记录') + expect(notice).not.toContain('请该用户') + } + }) +}) + +describe('recoveryAttemptKey', () => { + const fresh = () => 'new-key' + it('同一账号同一状态版本的重试沿用未确定结果的 key', () => { + expect(recoveryAttemptKey({ userId: 'usr_1', stateVersion: 3, key: 'old-key' }, 'usr_1', 3, fresh)).toBe('old-key') + }) + it('无未确定请求、换账号或状态版本变化时生成新 key', () => { + expect(recoveryAttemptKey(null, 'usr_1', 3, fresh)).toBe('new-key') + expect(recoveryAttemptKey({ userId: 'usr_2', stateVersion: 3, key: 'old-key' }, 'usr_1', 3, fresh)).toBe('new-key') + expect(recoveryAttemptKey({ userId: 'usr_1', stateVersion: 2, key: 'old-key' }, 'usr_1', 3, fresh)).toBe('new-key') + }) +}) + +describe('recoveryProposalRejected', () => { + it('4xx 为明确拒绝;网络失败与 5xx 结果未确定,需要先重读状态', () => { + expect(recoveryProposalRejected('Error: 403: admin required')).toBe(true) + expect(recoveryProposalRejected('Error: 404: not found')).toBe(true) + expect(recoveryProposalRejected('Error: 502: bad gateway')).toBe(false) + expect(recoveryProposalRejected('TypeError: Failed to fetch')).toBe(false) + }) +}) + describe('recoveryProposalErrorMessage', () => { it('OWNER_PROOF_UNAVAILABLE 与活动提案冲突分开表达', () => { expect(recoveryProposalErrorMessage('409: OWNER_PROOF_UNAVAILABLE')).toContain('会话证明') diff --git a/src/recovery.ts b/src/recovery.ts index 6b60749..497637f 100644 --- a/src/recovery.ts +++ b/src/recovery.ts @@ -18,6 +18,27 @@ export function recoveryProposalHint(status: RecoveryProposal['status']): string return '' } +/** 建立提案后的结果提示:以服务端返回或重读的当前状态为准。同一 idempotencyKey + * 的重复响应只回状态(可能已是终态),管理员任何时候都拿不到设密能力。 */ +export function recoveryProposalNotice(owner: string, status: RecoveryProposal['status']): string { + if (status === 'pending') return `已为 ${owner} 建立恢复提案:请该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现,管理员不会收到设密能力` + if (status === 'confirmed') return `${owner} 的恢复提案已由本人确认。只有目标本人可以取得设密能力;管理员不会收到设密能力` + return `${owner} 的恢复提案${recoveryStatusLabel[status]}。如需再次恢复,请核对下方记录` +} + +export interface RecoveryAttempt { userId: string; stateVersion: number; key: string } + +/** 提案请求标识:同一账号、同一状态版本的显式重试沿用上次未确定结果的 key, + * 让响应丢失后的重试落到同一提案而不是重复创建;账号或状态版本变化才换新 key。 */ +export function recoveryAttemptKey(previous: RecoveryAttempt | null, userId: string, stateVersion: number, fresh: () => string): string { + return previous?.userId === userId && previous.stateVersion === stateVersion ? previous.key : fresh() +} + +/** 4xx 是服务端明确拒绝(提案未创建);网络失败或 5xx 时提案可能已提交,结果未确定。 */ +export function recoveryProposalRejected(raw: string): boolean { + return /\b4\d\d:/.test(raw) +} + export function recoveryProposalErrorMessage(raw: string): string { if (raw.includes('OWNER_PROOF_UNAVAILABLE')) { return '无法发起:该账号没有可用的合格会话证明(需该用户当前在任一设备保持完整密码登录)。历史来源或已锁死账号请走服务器本地处置' diff --git a/test/account-users-panel.test.ts b/test/account-users-panel.test.ts new file mode 100644 index 0000000..4085e92 --- /dev/null +++ b/test/account-users-panel.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { credentialLabel } from '../src/components/AccountUsersPanel' + +describe('account directory credential state', () => { + it('shows pending trusted-owner initialization before password state', () => { + expect(credentialLabel({ initializationState: 'pending', passwordSet: false })).toBe('待可信 owner 初始化') + expect(credentialLabel({ initializationState: 'initialized', passwordSet: true })).toBe('已设置密码') + expect(credentialLabel({ initializationState: 'legacy', passwordSet: false })).toBe('未设置密码') + }) +}) diff --git a/test/loginpage.test.ts b/test/loginpage.test.ts index 1aa008c..afaa887 100644 --- a/test/loginpage.test.ts +++ b/test/loginpage.test.ts @@ -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('未知错误剥状态码前缀透传', () => {