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:
@@ -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'
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '解綁會在伺服器撤銷本機,並清空本機全部憑證——使用者工作階段與機器通道一併刪除,你將立即登出。',
|
||||
|
||||
@@ -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: '解绑会在服务端撤销本机,并清空本机全部凭据——用户会话与机器通道一并删除,你将立即退出登录。',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user