feat(desktop): DB-T6 登录后自动绑定 + 机器码/pending 可查 + 失败可见(§21 U-7)

登录成功/恢复会话后 main 自动发起免口令绑定(每进程一次),复用 U-1
通路,失败绝不阻塞登录;binding-status 扩展机器码(插件 GET status)
与 pending/失败码回查;/me 绑定区单源展示 + 重试,侧栏卡片 amber 角标。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-09-19 22:50:04 +08:00
parent 55868dc7b1
commit 10a46a00a9
14 changed files with 394 additions and 62 deletions
+6 -5
View File
@@ -8012,7 +8012,7 @@ registerUserAccountIpc({
fs.readFileSync(path.join(resolveHermesHome(), 'mercury-relay', 'state.json'), 'utf8'),
// 本机 gateway 插件管理面(§21 DB-T4b):经 dashboard 会话令牌走
// /api/plugins/mercury-relay/*;FastAPI {detail} 即稳定 code。
callPluginApi: async (apiPath: string, body: Record<string, unknown>) => {
callPluginApi: async (apiPath: string, body: Record<string, unknown>, method: 'GET' | 'POST' = 'POST') => {
let descriptor
try {
@@ -8022,10 +8022,11 @@ registerUserAccountIpc({
}
try {
return await fetchJson(`${descriptor.baseUrl}/api/plugins/mercury-relay/${apiPath}`, descriptor.token, {
method: 'POST',
body
})
return await fetchJson(
`${descriptor.baseUrl}/api/plugins/mercury-relay/${apiPath}`,
descriptor.token,
method === 'GET' ? { method: 'GET' } : { method: 'POST', body }
)
} catch (error: any) {
const status = typeof error?.statusCode === 'number' ? error.statusCode : 0
let code = status > 0 ? 'server' : 'plugin_unreachable'
+158 -13
View File
@@ -223,7 +223,7 @@ function register(deps: {
io: UserAccountStoreIo
fetcher: FetchLike
readMachineBindingState: () => string
callPluginApi?: (path: string, body: Record<string, unknown>) => Promise<any>
callPluginApi?: (path: string, body: Record<string, unknown>, method?: 'GET' | 'POST') => Promise<any>
}): void {
registerUserAccountIpc({
...deps,
@@ -449,12 +449,17 @@ test('update-profile:服务端 403 profile_self_service_disabled 结构化透
// ---------- DB-T4b:绑定打通 + U-6 解绑全清(§21) ----------------------------
test('binding-status:未绑定 / 已绑定只回 site+installationId / 损坏 fail-closed 标记', async () => {
test('binding-status:未绑定 / 已绑定只回展示字段 / 损坏 fail-closed 标记', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
assert.deepEqual(await handlers.get('hermes:account:binding-status')!(null), { bound: false })
assert.deepEqual(await handlers.get('hermes:account:binding-status')!(null), {
bound: false,
machineId: null,
pending: null,
bindError: null
})
})
test('binding-status:已绑定不含凭据字段;损坏报 stateInvalid', async () => {
@@ -463,12 +468,18 @@ test('binding-status:已绑定不含凭据字段;损坏报 stateInvalid', as
const { fetcher } = loginOkFetcher()
register({ ipcMain, io, fetcher, readMachineBindingState: () => BINDING_STATE })
const bound = await handlers.get('hermes:account:binding-status')!(null)
assert.deepEqual(bound, { bound: true, site: 'https://r', installationId: 'inst-1' })
assert.deepEqual(bound, { bound: true, site: 'https://r', installationId: 'inst-1', machineId: null })
assert.equal(JSON.stringify(bound).includes('SHOULD-NOT-BE-READ'), false)
const { handlers: h2, ipcMain: i2 } = fakeIpcMain()
register({ ipcMain: i2, io: fakeIo().io, fetcher, readMachineBindingState: () => '{broken' })
assert.deepEqual(await h2.get('hermes:account:binding-status')!(null), { bound: false, stateInvalid: true })
assert.deepEqual(await h2.get('hermes:account:binding-status')!(null), {
bound: false,
stateInvalid: true,
machineId: null,
pending: null,
bindError: null
})
})
test('bind-machine:插件收到 site+session_token(Bearer 等价物)+suggested_name;不落 email/password', async () => {
@@ -492,12 +503,16 @@ test('bind-machine:插件收到 site+session_token(Bearer 等价物)+suggested
const result = await handlers.get('hermes:account:bind-machine')!(null)
assert.deepEqual(result, { ok: true, state: 'bound' })
assert.equal(pluginCalls.length, 1)
assert.equal(pluginCalls[0].path, 'identity/login')
assert.equal(pluginCalls[0].body.site, 'https://r')
assert.equal(pluginCalls[0].body.session_token, 'AT-1')
assert.equal(pluginCalls[0].body.suggested_name, 'test-machine')
assert.equal('password' in pluginCalls[0].body, false)
// DB-T6:login 已自动绑定一次;手动按钮走的是同一条通路(fake 绑定态恒为未绑定,故再次发起)。
assert.equal(pluginCalls.length, 2)
for (const call of pluginCalls) {
assert.equal(call.path, 'identity/login')
assert.equal(call.body.site, 'https://r')
assert.equal(call.body.session_token, 'AT-1')
assert.equal(call.body.suggested_name, 'test-machine')
assert.equal('password' in call.body, false)
}
})
test('bind-machine:已绑定不出网(already_bound);未登录 not_logged_in', async () => {
@@ -669,8 +684,7 @@ test('unbind-machine:未绑定 not_bound,不出网', async () => {
assert.equal(calls.some(c => c.url.includes('/installations/')), false)
})
test('登出≠解绑:logout 绝不碰插件 API 与 installation 端点', async () => {
const { handlers, ipcMain } = fakeIpcMain()
test('登出≠解绑:logout 绝不碰插件 API 与 installation 端点', async () => { const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher, calls } = loginOkFetcher()
let pluginCalls = 0
@@ -692,3 +706,134 @@ test('登出≠解绑:logout 绝不碰插件 API 与 installation 端点', asy
assert.equal(pluginCalls, 0)
assert.equal(calls.some(c => c.url.includes('/installations/')), false)
})
// ---------- DB-T6:登录后自动绑定 + 机器码/pending 可查 + 失败可见 ------------
test('DB-T6:登录成功即自动绑定,无需点击;bound 后 binding-status 无待办', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
const pluginCalls: string[] = []
register({
ipcMain,
io,
fetcher,
readMachineBindingState: unbound,
callPluginApi: async path => {
pluginCalls.push(path)
return { state: 'bound', created: true }
}
})
const result = await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
assert.equal(result.ok, true)
assert.deepEqual(pluginCalls, ['identity/login'])
assert.deepEqual(await handlers.get('hermes:account:binding-status')!(null), {
bound: false,
machineId: null,
pending: null,
bindError: null
})
})
test('DB-T6:自动绑定 pending → 确认码随 binding-status 随时可查;logout 清空记录', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
register({
ipcMain,
io,
fetcher,
readMachineBindingState: unbound,
callPluginApi: async () => ({ state: 'binding_pending', code: 'MR-7K2P-Q9', expires_at: '2026-09-19T12:00:00Z' })
})
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
const status = await handlers.get('hermes:account:binding-status')!(null)
assert.deepEqual(status.pending, { code: 'MR-7K2P-Q9', expiresAt: '2026-09-19T12:00:00Z' })
assert.equal(status.bindError, null)
await handlers.get('hermes:account:logout')!(null)
const after = await handlers.get('hermes:account:binding-status')!(null)
assert.equal(after.pending, null)
assert.equal(after.bindError, null)
})
test('DB-T6:自动绑定失败绝不阻塞登录;失败码随 binding-status 透出', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
const { PluginApiError } = await import('./user-account-ipc')
register({
ipcMain,
io,
fetcher,
readMachineBindingState: unbound,
callPluginApi: async () => {
throw new PluginApiError('plugin_unreachable', 0)
}
})
const result = await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
assert.equal(result.ok, true)
assert.notEqual(loadUserAccount(io), null)
const status = await handlers.get('hermes:account:binding-status')!(null)
assert.equal(status.bindError, 'plugin_unreachable')
assert.equal(status.pending, null)
})
test('DB-T6:binding-status 带机器码(插件 GET status 的 relay_machine_id),插件不可达为 null', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
const seen: { path: string; method?: string }[] = []
register({
ipcMain,
io,
fetcher,
readMachineBindingState: () => BINDING_STATE,
callPluginApi: async (path, _body, method) => {
seen.push({ path, method })
return path === 'status' ? { relay_machine_id: 'MR-ABCD-1234' } : { state: 'bound' }
}
})
const status = await handlers.get('hermes:account:binding-status')!(null)
assert.equal(status.machineId, 'MR-ABCD-1234')
assert.deepEqual(seen, [{ path: 'status', method: 'GET' }])
})
test('DB-T6:恢复出的会话在首次 status 补一次自动绑定,之后不重复', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
persistUserAccount(
{
session: { site: 'https://r', accessToken: 'AT', refreshToken: 'RT', sessionId: 'SID' },
profile: { email: 'u@example.com', username: '', nickname: '', phone: '', planId: '', periodEnd: '', dataAsOf: 't' },
profileAsOf: 't'
},
io
)
const pluginCalls: string[] = []
register({
ipcMain,
io,
fetcher,
readMachineBindingState: unbound,
callPluginApi: async path => {
pluginCalls.push(path)
return { state: 'bound', created: true }
}
})
const first = await handlers.get('hermes:account:status')!(null)
assert.equal(first.loggedIn, true)
assert.deepEqual(pluginCalls, ['identity/login'])
await handlers.get('hermes:account:status')!(null)
assert.deepEqual(pluginCalls, ['identity/login'])
})
+97 -7
View File
@@ -14,6 +14,10 @@
* fail-closed:锁无法判定时不许登录。
*
* 登出≠解绑:logout 只清用户会话槽,机器绑定(H/R)完全不经过这里。
*
* DB-T6 自动绑定(2026-09-19 裁定):登录成功 / 恢复会话后若本机未绑定,
* main 自动发起一次免口令绑定,无需用户点击。结果(pending 确认码 / 失败
* 码)留在 main 内存,binding-status 随时回查;绑定失败绝不阻塞登录。
*/
import { loadMachineBinding, MachineBindingError } from './machine-binding'
@@ -65,9 +69,10 @@ export interface UserAccountIpcDeps {
/**
* 调本机 gateway 的插件管理 API(路径如 'identity/login')。
* 非 2xx 必须抛 PluginApiError(detail 即 code);gateway 不可达抛
* PluginApiError('plugin_unreachable', 0)。
* PluginApiError('plugin_unreachable', 0)。method 默认 POST;GET 时
* body 被忽略(如 'status' 查机器码)。
*/
callPluginApi: (path: string, body: Record<string, unknown>) => Promise<any>
callPluginApi: (path: string, body: Record<string, unknown>, method?: 'GET' | 'POST') => Promise<any>
/** 绑定时的 suggested_name(本机显示名)。 */
machineName: string
}
@@ -84,6 +89,13 @@ function failure(error: unknown): { ok: false; code: string } {
export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
const { ipcMain, io, fetcher, readMachineBindingState, callPluginApi, machineName } = deps
let cached: StoredUserAccount | null = null
/**
* 最近一次绑定尝试的结果(DB-T6 自动绑定):pending 确认码 / 失败码,
* 供 binding-status 随时回查。只在 main 内存里,随进程退出消失
* (pending 码本就 10 分钟 TTL,重启后重试即可)。
*/
let lastBind: { pending?: { code: string; expiresAt: string }; error?: string } | null = null
let autoBindAttempted = false
function current(): StoredUserAccount | null {
if (cached) {return cached}
@@ -188,6 +200,8 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
const profile = await relayAccountFetchMe(site, session.accessToken, fetcher)
store({ session, profile, profileAsOf: new Date().toISOString() })
// DB-T6:登录成功即自动绑定本机(未绑定时);失败不阻塞登录。
await autoBind()
return { ok: true, site, profile }
} catch (error) {
@@ -204,6 +218,9 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
const profile = await withSession(session => relayAccountFetchMe(session.site, session.accessToken, fetcher))
store({ ...current()!, profile, profileAsOf: new Date().toISOString() })
// DB-T6:恢复出的会话若尚未尝试过自动绑定(如进程重启后),补一次。
if (!autoBindAttempted) {await autoBind()}
return { loggedIn: true, site: account.session.site, offline: false, profile }
} catch (error) {
if (error instanceof RelayAccountError && error.code === 'session_expired') {
@@ -263,27 +280,36 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
})
ipcMain.handle('hermes:account:binding-status', async () => {
const machineId = await queryMachineId()
let binding
try {
binding = loadMachineBinding(readMachineBindingState)
} catch (error) {
if (error instanceof MachineBindingError) {return { bound: false, stateInvalid: true }}
if (error instanceof MachineBindingError) {
return { bound: false, stateInvalid: true, machineId, pending: null, bindError: lastBind?.error ?? null }
}
throw error
}
if (!binding) {return { bound: false }}
if (!binding) {
return { bound: false, machineId, pending: lastBind?.pending ?? null, bindError: lastBind?.error ?? null }
}
return { bound: true, site: binding.site, installationId: binding.installationId }
return { bound: true, site: binding.site, installationId: binding.installationId, machineId }
})
/**
* U-1/RL-A1 免口令绑定:用当前用户会话 access token 让本机插件完成
* U-1/RL-A1 免口令绑定主体:用当前用户会话 access token 让本机插件完成
* 绑定(口令永远不再经手)。插件 401(会话失效)并入 withSession 的
* 刷新重试链。binding_pending(双因素)原样透传给 UI。
*/
ipcMain.handle('hermes:account:bind-machine', async () => {
async function performBind(): Promise<
| { ok: true; state: 'bound' }
| { ok: true; state: 'binding_pending'; code: string; expiresAt: string }
| { ok: false; code: string }
> {
let existing
try {
@@ -328,6 +354,64 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
} catch (error) {
return failure(error)
}
}
function recordBind(result: Awaited<ReturnType<typeof performBind>>): void {
if (!result.ok && result.code === 'already_bound') {
lastBind = null
return
}
if (result.ok && result.state === 'bound') {
lastBind = null
return
}
if (result.ok) {
lastBind = { pending: { code: result.code, expiresAt: result.expiresAt } }
return
}
lastBind = { error: result.code }
}
/**
* DB-T6 自动绑定:登录成功 / 恢复会话后若本机未绑定则自动发起一次。
* 绝不阻塞或逆转登录——任何意外都只落成 lastBind.error 供界面展示。
*/
async function autoBind(): Promise<void> {
autoBindAttempted = true
try {
recordBind(await performBind())
} catch {
lastBind = { error: 'server' }
}
}
/** 机器码(relay allowlist 展示口径):插件不可达时回 null,不影响状态查询。 */
async function queryMachineId(): Promise<string | null> {
try {
const status = await callPluginApi('status', {}, 'GET')
return typeof status?.relay_machine_id === 'string' ? status.relay_machine_id : null
} catch {
return null
}
}
ipcMain.handle('hermes:account:bind-machine', async () => {
try {
const result = await performBind()
recordBind(result)
return result
} catch (error) {
return failure(error)
}
})
/**
@@ -368,6 +452,9 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
store(null)
}
lastBind = null
autoBindAttempted = false
return { ok: true }
})
@@ -379,6 +466,9 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
store(null)
}
lastBind = null
autoBindAttempted = false
return { ok: true }
})
@@ -13,15 +13,18 @@ import { ME_ROUTE } from '@/app/me'
import { navigateToWorkspacePage } from '@/app/routes'
import { AvatarChip } from '@/components/ui/avatar-chip'
import { useI18n } from '@/i18n'
import { $userAccount, userAccountDisplayName } from '@/store/user-account'
import { $machineBinding, $userAccount, userAccountDisplayName } from '@/store/user-account'
export function UserAccountCard() {
const { t } = useI18n()
const account = useStore($userAccount)
const binding = useStore($machineBinding)
const navigate = useNavigate()
if (account.status !== 'signed_in') {return null}
const name = userAccountDisplayName(account.profile) || account.profile?.email || ''
// DB-T6:自动绑定失败或 pending 待确认 → 名后 amber 点,点卡片进 /me 处理。
const bindIssue = binding != null && !binding.bound && (binding.bindError != null || binding.pending != null)
return (
<button
@@ -41,6 +44,13 @@ export function UserAccountCard() {
title={t.userAccount.me.cardOffline}
/>
)}
{bindIssue && (
<span
aria-label={t.userAccount.me.cardBindIssue}
className="size-1.5 shrink-0 rounded-full bg-amber-500"
title={t.userAccount.me.cardBindIssue}
/>
)}
</span>
{account.profile?.email && name !== account.profile.email && (
<span className="block truncate text-xs text-muted-foreground">{account.profile.email}</span>
+31 -19
View File
@@ -1,9 +1,12 @@
/**
* binding-section.tsx — `/me` 机器绑定区(§21 DB-T4c,U-1/U-6)。
* binding-section.tsx — `/me` 机器绑定区(§21 DB-T4c/DB-T6,U-1/U-6)。
*
* 未绑定:说明 + 绑定按钮(U-1 免口令,session_token 由 main 侧注入)。
* DB-T6 起绑定自动发生:登录成功 / 恢复会话后 main 侧已自动发起免口令
* 绑定,本区是结果的展示与补救面——机器码、pending 确认码、失败码都可
* 随时回查(binding-status),失败给重试入口;手动按钮与自动绑定走同一
* 条 performBind 通路。
* binding_pending:展示确认码 + 有效期,等另一台已绑定设备批准。
* 已绑定:site + installationId + 解绑入口;解绑走破坏性确认框——
* 已绑定:site + installationId + 机器码 + 解绑入口;解绑走破坏性确认框——
* U-6 全清(服务端撤销 + 插件 purge + 删所有会话),成功即落 signed_out,
* 登录门随即落回。state 损坏 fail-closed 提示。
*/
@@ -24,9 +27,7 @@ import {
type BindingCopy = Translations['userAccount']['me']['binding']
function bindingErrorText(copy: BindingCopy, failure: unknown): string {
const code = failure instanceof UserAccountFailure ? failure.code : 'server'
function bindingErrorText(copy: BindingCopy, code: string): string {
if (code === 'network') {return copy.errorNetwork}
if (code === 'plugin_unreachable') {return copy.errorPluginUnreachable}
@@ -38,7 +39,6 @@ export function BindingSection() {
const { t } = useI18n()
const copy = t.userAccount.me.binding
const [status, setStatus] = useState<HermesUserAccountBindingStatus | null>(null)
const [pending, setPending] = useState<{ code: string; expiresAt: string } | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [confirmingUnbind, setConfirmingUnbind] = useState(false)
@@ -66,16 +66,15 @@ export function BindingSection() {
setError(null)
try {
const result = await userAccountBindMachine()
await userAccountBindMachine()
} catch {
// 失败码已由 main 记录(lastBind),随下面的状态回查一并展示。
}
if (result.state === 'binding_pending') {
setPending({ code: result.code, expiresAt: result.expiresAt })
} else {
setPending(null)
setStatus(await userAccountBindingStatus())
}
} catch (failure) {
setError(bindingErrorText(copy, failure))
try {
setStatus(await userAccountBindingStatus())
} catch {
setError(copy.errorServer)
} finally {
setBusy(false)
}
@@ -84,10 +83,14 @@ export function BindingSection() {
async function unbind() {
// 成功即全清 + signed_out(store 内落定),本页随登录门卸载;失败抛错由确认框内联展示。
await userAccountUnbindMachine().catch((failure: unknown) => {
throw new Error(bindingErrorText(copy, failure))
throw new Error(bindingErrorText(copy, failure instanceof UserAccountFailure ? failure.code : 'server'))
})
}
const pending = status && !status.bound ? (status.pending ?? null) : null
const bindError = status && !status.bound ? (status.bindError ?? null) : null
const machineId = status?.machineId ?? null
return (
<section className="space-y-3 border-t border-border pt-4">
<h2 className="text-sm font-medium">{copy.title}</h2>
@@ -100,11 +103,18 @@ export function BindingSection() {
</p>
)}
{machineId && (
<div className="flex justify-between gap-4 text-sm">
<span className="text-muted-foreground">{copy.machineLabel}</span>
<span className="truncate font-mono text-xs">{machineId}</span>
</div>
)}
{status && !status.bound && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">{copy.unboundBody}</p>
{!pending && !bindError && <p className="text-sm text-muted-foreground">{copy.unboundBody}</p>}
<Button disabled={busy} onClick={() => void bind()}>
{busy ? copy.binding : copy.bind}
{busy ? copy.binding : pending || bindError ? copy.retry : copy.bind}
</Button>
</div>
)}
@@ -116,6 +126,8 @@ export function BindingSection() {
</div>
)}
{bindError && <p className="text-sm text-destructive">{bindingErrorText(copy, bindError)}</p>}
{status?.bound === true && (
<div className="space-y-2">
<dl className="space-y-2 text-sm">
+46 -11
View File
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { UserAccountCard } from '@/app/chat/sidebar/user-account-card'
import { MePage } from '@/app/me'
import { $userAccount } from '@/store/user-account'
import { $machineBinding, $userAccount } from '@/store/user-account'
const PROFILE = {
email: 'u@example.com',
@@ -44,6 +44,7 @@ function stubBridge(overrides: Record<string, unknown> = {}) {
beforeEach(() => {
$userAccount.set({ status: 'unknown' })
$machineBinding.set(null)
})
afterEach(() => {
@@ -128,30 +129,52 @@ describe('MePage', () => {
expect(screen.getByText('inst-1')).toBeTruthy()
})
it('binding_pending:展示确认码与有效期', async () => {
stubBridge({
bindMachine: vi.fn(async () => ({
ok: true,
state: 'binding_pending',
code: 'MR-7K2P-Q9',
expiresAt: '2026-09-19T12:00:00Z'
it('binding_pending:确认码与有效期随状态可查;重试走同一通路', async () => {
const api = stubBridge({
bindingStatus: vi.fn(async () => ({
bound: false,
machineId: null,
pending: { code: 'MR-7K2P-Q9', expiresAt: '2026-09-19T12:00:00Z' },
bindError: null
}))
})
signedIn()
render(<MePage />)
fireEvent.click(await screen.findByText('Bind this machine'))
await screen.findByText(/MR-7K2P-Q9/)
expect(screen.getByText(/2026-09-19T12:00:00Z/)).toBeTruthy()
fireEvent.click(screen.getByText('Retry binding'))
await waitFor(() => expect(api.bindMachine).toHaveBeenCalled())
})
it('绑定失败按码上文案(plugin_unreachable)', async () => {
stubBridge({ bindMachine: vi.fn(async () => ({ ok: false, code: 'plugin_unreachable' })) })
it('绑定失败按码上文案(plugin_unreachable),并可重试', async () => {
const api = stubBridge({
bindingStatus: vi
.fn()
.mockResolvedValueOnce({ bound: false, machineId: null, pending: null, bindError: null })
.mockResolvedValue({ bound: false, machineId: null, pending: null, bindError: 'plugin_unreachable' }),
bindMachine: vi.fn(async () => ({ ok: false, code: 'plugin_unreachable' }))
})
signedIn()
render(<MePage />)
fireEvent.click(await screen.findByText('Bind this machine'))
await screen.findByText('The local machine channel is unavailable. Restart Hermes and try again.')
expect(api.bindMachine).toHaveBeenCalled()
})
it('机器码随绑定状态展示(已绑定与未绑定同口径)', async () => {
stubBridge({
bindingStatus: vi.fn(async () => ({ bound: true, site: 'https://r', installationId: 'inst-1', machineId: 'MR-ABCD-1234' }))
})
signedIn()
render(<MePage />)
await screen.findByText('inst-1')
expect(screen.getByText('Machine ID')).toBeTruthy()
expect(screen.getByText('MR-ABCD-1234')).toBeTruthy()
})
it('已绑定:显示 site + installationId;解绑走破坏性确认框,成功落 signed_out', async () => {
@@ -204,4 +227,16 @@ describe('UserAccountCard', () => {
)
expect(screen.getByLabelText('Offline — showing cached data')).toBeTruthy()
})
it('绑定待处理(失败/pending):名旁标 amber 圆点', () => {
stubBridge()
signedIn()
$machineBinding.set({ bound: false, machineId: null, pending: null, bindError: 'plugin_unreachable' })
render(
<MemoryRouter>
<UserAccountCard />
</MemoryRouter>
)
expect(screen.getByLabelText('Machine binding needs attention — open to review')).toBeTruthy()
})
})
+3
View File
@@ -3327,6 +3327,7 @@ export const ar = defineLocale({
me: {
cardAria: 'فتح صفحة الحساب',
cardOffline: 'غير متصل — تُعرض بيانات مخزنة',
cardBindIssue: 'ربط الجهاز يحتاج إلى متابعة — انقر للعرض',
unavailable: 'بيانات الحساب غير متاحة.',
offlineNotice: asOf => `غير متصل — تُعرض البيانات كما في ${asOf}.`,
nicknameLabel: 'الاسم المستعار',
@@ -3351,8 +3352,10 @@ export const ar = defineLocale({
unboundBody: 'هذا الجهاز غير مرتبط بحسابك بعد. اربطه ليظهر في قائمة أجهزتك ويمكن الوصول إليه من أجهزتك الأخرى.',
bind: 'ربط هذا الجهاز',
binding: 'جارٍ الربط…',
retry: 'إعادة محاولة الربط',
siteLabel: 'الخادم',
installationLabel: 'التثبيت',
machineLabel: 'معرّف الجهاز',
unbind: 'إلغاء الربط…',
unbindTitle: 'إلغاء ربط هذا الجهاز؟',
unbindBody:
+3
View File
@@ -4568,6 +4568,7 @@ export const en: Translations = {
me: {
cardAria: 'Open your account page',
cardOffline: 'Offline — showing cached data',
cardBindIssue: 'Machine binding needs attention — open to review',
unavailable: 'Account details are unavailable.',
offlineNotice: asOf => `Offline — showing data as of ${asOf}.`,
nicknameLabel: 'Nickname',
@@ -4593,8 +4594,10 @@ export const en: Translations = {
'This machine is not bound to your account yet. Bind it so it appears in your machine list and can be reached from your other devices.',
bind: 'Bind this machine',
binding: 'Binding…',
retry: 'Retry binding',
siteLabel: 'Server',
installationLabel: 'Installation',
machineLabel: 'Machine ID',
unbind: 'Unbind…',
unbindTitle: 'Unbind this machine?',
unbindBody:
+3
View File
@@ -3741,6 +3741,7 @@ export const ja = defineLocale({
me: {
cardAria: 'アカウントページを開く',
cardOffline: 'オフライン — キャッシュデータを表示中',
cardBindIssue: 'マシンバインドに対応が必要です — クリックして確認',
unavailable: 'アカウント情報を利用できません。',
offlineNotice: asOf => `オフライン — ${asOf} 時点のデータを表示しています。`,
nicknameLabel: 'ニックネーム',
@@ -3766,8 +3767,10 @@ export const ja = defineLocale({
'このマシンはまだアカウントにバインドされていません。バインドするとマシン一覧に表示され、他のデバイスから到達できるようになります。',
bind: 'このマシンをバインド',
binding: 'バインド中…',
retry: 'バインドを再試行',
siteLabel: 'サーバー',
installationLabel: 'インストール',
machineLabel: 'マシン ID',
unbind: 'バインド解除…',
unbindTitle: 'このマシンのバインドを解除しますか?',
unbindBody:
+3
View File
@@ -3948,6 +3948,7 @@ export const ru = defineLocale({
me: {
cardAria: 'Открыть страницу аккаунта',
cardOffline: 'Офлайн — показаны кэшированные данные',
cardBindIssue: 'Привязка машины требует внимания — открыть',
unavailable: 'Данные аккаунта недоступны.',
offlineNotice: asOf => `Офлайн — показаны данные на ${asOf}.`,
nicknameLabel: 'Отображаемое имя',
@@ -3973,8 +3974,10 @@ export const ru = defineLocale({
'Эта машина ещё не привязана к вашему аккаунту. Привяжите её, чтобы она появилась в списке машин и была доступна с других ваших устройств.',
bind: 'Привязать эту машину',
binding: 'Привязка…',
retry: 'Повторить привязку',
siteLabel: 'Сервер',
installationLabel: 'Установка',
machineLabel: 'ID машины',
unbind: 'Отвязать…',
unbindTitle: 'Отвязать эту машину?',
unbindBody:
+3
View File
@@ -3890,6 +3890,7 @@ export interface Translations {
me: {
cardAria: string
cardOffline: string
cardBindIssue: string
unavailable: string
offlineNotice: (asOf: string) => string
nicknameLabel: string
@@ -3914,8 +3915,10 @@ export interface Translations {
unboundBody: string
bind: string
binding: string
retry: string
siteLabel: string
installationLabel: string
machineLabel: string
unbind: string
unbindTitle: string
unbindBody: string
+3
View File
@@ -3661,6 +3661,7 @@ export const zhHant = defineLocale({
me: {
cardAria: '開啟帳號頁',
cardOffline: '離線——顯示快取資料',
cardBindIssue: '機器綁定待處理——點擊查看',
unavailable: '帳號資料不可用。',
offlineNotice: asOf => `離線——顯示 ${asOf} 的快取資料。`,
nicknameLabel: '暱稱',
@@ -3685,8 +3686,10 @@ export const zhHant = defineLocale({
unboundBody: '本機尚未綁定到你的帳號。綁定後才會出現在你的機器清單中,其他裝置才能觸達本機。',
bind: '綁定本機',
binding: '綁定中…',
retry: '重試綁定',
siteLabel: '伺服器',
installationLabel: '安裝實例',
machineLabel: '機器碼',
unbind: '解綁…',
unbindTitle: '解綁本機?',
unbindBody: '解綁會在伺服器撤銷本機,並清空本機全部憑證——使用者工作階段與機器通道一併刪除,你將立即登出。',
+3
View File
@@ -4466,6 +4466,7 @@ export const zh = defineLocale({
me: {
cardAria: '打开账号页',
cardOffline: '离线——展示缓存数据',
cardBindIssue: '机器绑定待处理——点击查看',
unavailable: '账号资料不可用。',
offlineNotice: asOf => `离线——展示 ${asOf} 的缓存数据。`,
nicknameLabel: '昵称',
@@ -4490,8 +4491,10 @@ export const zh = defineLocale({
unboundBody: '本机尚未绑定到你的账号。绑定后才会出现在你的机器列表中,其他设备才能触达本机。',
bind: '绑定本机',
binding: '绑定中…',
retry: '重试绑定',
siteLabel: '服务器',
installationLabel: '安装实例',
machineLabel: '机器码',
unbind: '解绑…',
unbindTitle: '解绑本机?',
unbindBody: '解绑会在服务端撤销本机,并清空本机全部凭据——用户会话与机器通道一并删除,你将立即退出登录。',
+24 -6
View File
@@ -58,10 +58,19 @@ export type HermesUserAccountLoginResult =
| { ok: true; site: string; profile: HermesUserAccountProfile }
| { ok: false; code: string; binderEmail?: string | null }
/** 本机绑定状态(§21 DB-T4b):只含 site + installationId,凭据绝不出 main。 */
/** 本机绑定状态(§21 DB-T4b/DB-T6):只含展示字段,凭据绝不出 main。 */
export type HermesUserAccountBindingStatus =
| { bound: false; stateInvalid?: boolean }
| { bound: true; site: string; installationId: string }
| {
bound: false
stateInvalid?: boolean
/** 机器码(relay allowlist 展示口径);插件不可达为 null。 */
machineId?: string | null
/** DB-T6 自动/手动绑定的 pending 确认码(main 内存,TTL 内可查)。 */
pending?: { code: string; expiresAt: string } | null
/** 最近一次绑定失败码;null = 无失败记录。 */
bindError?: string | null
}
| { bound: true; site: string; installationId: string; machineId?: string | null }
export type HermesUserAccountBindResult =
| { ok: true; state: 'bound' }
@@ -111,6 +120,8 @@ export async function userAccountLogin(site: string, email: string, password: st
}
$userAccount.set({ status: 'signed_in', site: result.site, offline: false, profile: result.profile, profileAsOf: null })
// DB-T6:登录后 main 已自动绑定;把绑定态(机器码/pending/失败码)拉进 store 供卡片角标。
void userAccountBindingStatus().catch(() => {})
}
async function runAccountOp(op: () => Promise<HermesUserAccountOpResult>): Promise<void> {
@@ -139,6 +150,7 @@ export function userAccountResetConfirm(site: string, email: string, code: strin
export async function userAccountLogout(): Promise<void> {
await bridge().logout()
$userAccount.set({ status: 'signed_out' })
$machineBinding.set(null)
}
/** 改本人资料(username/nickname/phone)。成功回写 signed_in 快照;失败抛 UserAccountFailure。 */
@@ -198,9 +210,14 @@ export function userAccountDisplayName(profile: HermesUserAccountProfile | null)
return at > 0 ? profile.email.slice(0, at) : profile.email
}
/** 本机绑定状态(只读插件 state.json 的非敏感二字段)。 */
export function userAccountBindingStatus(): Promise<HermesUserAccountBindingStatus> {
return bridge().bindingStatus()
/** 本机绑定状态(只读插件 state.json 的非敏感字段 + main 记录的最近绑定结果)。 */
export const $machineBinding = atom<HermesUserAccountBindingStatus | null>(null)
export async function userAccountBindingStatus(): Promise<HermesUserAccountBindingStatus> {
const status = await bridge().bindingStatus()
$machineBinding.set(status)
return status
}
/** 绑定本机到当前账号(§21 U-1 免口令)。失败抛 UserAccountFailure;pending 返回确认码。 */
@@ -226,4 +243,5 @@ export async function userAccountUnbindMachine(): Promise<void> {
if (!result.ok) {throw new UserAccountFailure(result.code ?? 'server')}
$userAccount.set({ status: 'signed_out' })
$machineBinding.set(null)
}