feat(desktop): DB-T2 强制登录门 + 注册/找回 + U-5 机器-用户锁(§21)

- 登录门:非 signed_in 全屏盖 app,登录/注册/找回三页,只盖 UI
  不断机器通道(U-1);status 通道异常落登录页不砖(U-3)
- U-5:登录成功当场校验本机 installation ∈ 会话用户列表,不符即
  销毁会话并提示绑定者邮箱;绑定状态损坏 fail-closed
- relay 客户端补注册/找回四通道 + installations/binding-owner 查询;
  IPC 预期失败全部结构化 {ok:false, code} 返回
- i18n userAccount 48 键 × 6 语言;electron +11 / ui +7 测试全绿

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-09-19 13:12:49 +08:00
parent a48a81a5f0
commit 357ac45281
19 changed files with 1478 additions and 25 deletions
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { loadMachineBinding, MachineBindingError, parseMachineBinding } from './machine-binding'
const VALID = JSON.stringify({
relay_binding: {
site: 'https://relay.example.com/',
agent_id: 'inst-1',
route: 'r',
host_key_fingerprint: 'AAAA-AA',
key_epoch: 1,
host_refresh_token: 'SECRET',
protocol_version: 1,
bound_at: '2026-09-19T00:00:00Z',
last_connected_at: null
},
host_identity: { private_key: 'ALSO-SECRET' }
})
test('合法绑定只取 site + installationId,site 规范化', () => {
assert.deepEqual(parseMachineBinding(VALID), { site: 'https://relay.example.com', installationId: 'inst-1' })
})
test('无 relay_binding 键 = 未绑定', () => {
assert.equal(parseMachineBinding('{}'), null)
assert.equal(parseMachineBinding('{"relay_binding":null}'), null)
})
test('损坏/缺字段/坏站点 fail-closed 抛 binding_state_invalid', () => {
assert.throws(() => parseMachineBinding('{broken'), MachineBindingError)
assert.throws(() => parseMachineBinding('{"relay_binding":{"site":"https://r"}}'), MachineBindingError)
assert.throws(() => parseMachineBinding('{"relay_binding":{"site":"http://evil.example.com","agent_id":"x"}}'), MachineBindingError)
})
test('loadMachineBinding:ENOENT = 未绑定;损坏抛出', () => {
assert.equal(
loadMachineBinding(() => {
throw new Error('ENOENT')
}),
null
)
assert.equal(loadMachineBinding(() => VALID)?.installationId, 'inst-1')
assert.throws(() => loadMachineBinding(() => '{broken'), MachineBindingError)
})
+66
View File
@@ -0,0 +1,66 @@
/**
* machine-binding.ts — 本机机器绑定(mercury-relay 插件 state.json)的只读视图。
*
* U-5 机器-用户锁的事实来源:插件绑定后把 ``relay_binding`` 写进
* ``<HERMES_HOME|~/.hermes>/mercury-relay/state.json``。desktop 只取
* ``site`` 与 ``agent_id``(= relay installation id)两个字段做所有权
* 校验;``host_refresh_token`` 等凭据字段读完即弃,绝不落日志。
*
* 无 electron import:状态文件原文由 main.ts 注入。文件缺失 = 未绑定
* (返回 null);文件存在但损坏 = fail-closed 抛 MachineBindingError
* (锁态无法判定时不能默许放行)。
*/
import { canonicalizeRelaySite } from './relay-account'
export interface MachineBinding {
site: string
installationId: string
}
export class MachineBindingError extends Error {
readonly code = 'binding_state_invalid'
constructor() {
super('binding_state_invalid')
}
}
export function parseMachineBinding(stateText: string): MachineBinding | null {
let parsed: any
try {
parsed = JSON.parse(stateText)
} catch {
throw new MachineBindingError()
}
const record = parsed?.relay_binding
if (record === null || record === undefined) {return null}
try {
const site = canonicalizeRelaySite(record.site)
const installationId = typeof record.agent_id === 'string' ? record.agent_id : ''
if (!installationId) {throw new MachineBindingError()}
return { site, installationId }
} catch (error) {
if (error instanceof MachineBindingError) {throw error}
throw new MachineBindingError()
}
}
/** 读状态文件;ENOENT 视为未绑定。 */
export function loadMachineBinding(readStateText: () => string): MachineBinding | null {
let text: string
try {
text = readStateText()
} catch {
return null
}
return parseMachineBinding(text)
}
+11 -5
View File
@@ -192,6 +192,7 @@ import {
stopFind
} from './find-in-page'
import { createFirstRunSetupGate } from './first-run-setup-gate'
import { registerFreeModel2ApiIpc } from './freemodel2api-ipc'
import { registerFsIpc } from './fs-ipc'
import {
filenameFromContentDisposition,
@@ -273,8 +274,6 @@ import {
} from './native-oauth'
import { runNativeLogin } from './native-oauth-login'
import { loadNativeTokenSet, type NativeTokenStoreIo, persistNativeTokenSet } from './native-token-store'
import { registerFreeModel2ApiIpc } from './freemodel2api-ipc'
import { registerUserAccountIpc } from './user-account-ipc'
import { registerNativeNotifications } from './notification-ipc'
import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
import { LEGACY_OAUTH_PARTITION, resolveOauthPartition } from './oauth-partition'
@@ -416,6 +415,7 @@ import {
windowsUpdatePrerequisiteError,
wrapHandoffForDetachedConsole
} from './updater-process'
import { registerUserAccountIpc } from './user-account-ipc'
import {
formatBlockerMessage,
formatProbeFailedMessage,
@@ -7992,8 +7992,9 @@ function _userAccountStorePath() {
return path.join(app.getPath('userData'), 'user-account-session.json')
}
// user-account-ipc 的 electron 耦合半边(§21 DB-T1):与 freemodel2api 同一
// 模式——safeStorage 加密 + userData 0600 文件;token 永远不出 main 进程。
// user-account-ipc 的 electron 耦合半边(§21 DB-T1/DB-T2):与 freemodel2api
// 同一模式——safeStorage 加密 + userData 0600 文件;token 永远不出 main
// 进程。U-5 锁的事实来源是插件 state.json,只读、读完即弃。
registerUserAccountIpc({
ipcMain,
io: {
@@ -8006,7 +8007,9 @@ registerUserAccountIpc({
},
rememberLog
},
fetcher: fetch as any
fetcher: fetch as any,
readMachineBindingState: () =>
fs.readFileSync(path.join(resolveHermesHome(), 'mercury-relay', 'state.json'), 'utf8')
})
function _loadNativeTokens(baseUrl: string): NativeTokenSet | null {
@@ -10150,9 +10153,11 @@ async function buildRemoteConnection(
}
const sshConnections = new Map<string, any>()
const sshIsolatedKeepalives = createSshIsolatedKeepaliveRegistry({
log: chunk => sshRememberLog(chunk)
})
const desktopInstallationId = loadOrCreateInstallationId(DESKTOP_INSTALLATION_PATH)
// Managed SSH update lifecycle (#93042): while an update owns a registered
@@ -16717,6 +16722,7 @@ async function dispatchRegistryApiRequest(
// OUT of the claim: an interactive open coalescing onto an in-flight
// passive read would otherwise inherit its "no warm backend" rejection.
const spawnPriority = spawnPriorityFrom(request?.priority)
const connection: any = request?.passive
? await ensureRegistryBackend(registryConnectionId, routeProfile, '', { passive: true })
: await backendDialClaims.run(backendScopeKey(registryConnectionId, routeProfile), () =>
+5 -1
View File
@@ -39,7 +39,11 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
login: payload => ipcRenderer.invoke('hermes:account:login', payload),
status: () => ipcRenderer.invoke('hermes:account:status'),
me: () => ipcRenderer.invoke('hermes:account:me'),
logout: () => ipcRenderer.invoke('hermes:account:logout')
logout: () => ipcRenderer.invoke('hermes:account:logout'),
registerStart: payload => ipcRenderer.invoke('hermes:account:register-start', payload),
registerResend: payload => ipcRenderer.invoke('hermes:account:register-resend', payload),
resetRequest: payload => ipcRenderer.invoke('hermes:account:reset-request', payload),
resetConfirm: payload => ipcRenderer.invoke('hermes:account:reset-confirm', payload)
},
// Registry-scoped backend resolution: { connectionId, profile } → descriptor.
getConnectionFor: payload => ipcRenderer.invoke('hermes:connection:for', payload),
+79
View File
@@ -182,3 +182,82 @@ export async function relayAccountFetchMe(
return parseRelayMe(payload)
}
/** 注册第一步:投递验证邮件。202 与「邮箱已被注册」同形(防枚举)。 */
export async function relayAccountRegisterStart(
site: string,
email: string,
password: string,
fetcher: FetchLike
): Promise<void> {
await request(fetcher, `${site}/api/v2/registration/start`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password })
})
}
export async function relayAccountRegisterResend(site: string, email: string, fetcher: FetchLike): Promise<void> {
await request(fetcher, `${site}/api/v2/registration/resend`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email })
})
}
/** 找回第一步:投递重置码邮件。202 同样防枚举。 */
export async function relayAccountResetRequest(site: string, email: string, fetcher: FetchLike): Promise<void> {
await request(fetcher, `${site}/api/v2/auth/password-reset/request`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email })
})
}
export async function relayAccountResetConfirm(
site: string,
email: string,
code: string,
password: string,
fetcher: FetchLike
): Promise<void> {
await request(fetcher, `${site}/api/v2/auth/password-reset/confirm`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, code, password })
})
}
/** U-5 所有权校验:列出本会话用户的 installation id 集合。 */
export async function relayAccountListInstallationIds(
site: string,
accessToken: string,
fetcher: FetchLike
): Promise<string[]> {
const payload = await request(fetcher, `${site}/api/v2/installations`, {
headers: { authorization: `Bearer ${accessToken}` }
})
const items = Array.isArray(payload?.items) ? payload.items : []
return items.map((item: any) => (typeof item?.id === 'string' ? item.id : '')).filter((id: string) => id !== '')
}
/** U-5 锁态提示:查一台已绑定机器的绑定者邮箱;404 时返回 null。 */
export async function relayAccountBindingOwner(
site: string,
accessToken: string,
installationId: string,
fetcher: FetchLike
): Promise<string | null> {
try {
const payload = await request(fetcher, `${site}/api/v2/agent/binding/owner?installation_id=${encodeURIComponent(installationId)}`, {
headers: { authorization: `Bearer ${accessToken}` }
})
return typeof payload?.email === 'string' ? payload.email : null
} catch (error) {
if (error instanceof RelayAccountError && error.status === 404) {return null}
throw error
}
}
+136 -6
View File
@@ -87,7 +87,7 @@ test('login 持久化会话并返回 profile;token 不出 IPC 返回值', asyn
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
registerUserAccountIpc({ ipcMain, io, fetcher })
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: unbound })
const result = await handlers.get('hermes:account:login')!(null, {
site: 'https://relay.example.com/',
@@ -108,7 +108,7 @@ test('status 未登录返回 loggedIn:false', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
registerUserAccountIpc({ ipcMain, io, fetcher })
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: unbound })
assert.deepEqual(await handlers.get('hermes:account:status')!(null), { loggedIn: false })
})
@@ -134,7 +134,7 @@ test('me 401 → 自动 refresh 一次重试成功,轮换后的令牌落盘',
return { status: 200, body: {} }
})
registerUserAccountIpc({ ipcMain, io, fetcher })
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: unbound })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
const me = await handlers.get('hermes:account:me')!(null)
assert.equal(me.profile.email, 'u@example.com')
@@ -164,7 +164,7 @@ test('refresh 也被拒 = 硬会话终点:本地清零并上报 sessionExpired
return { status: 200, body: {} }
})
registerUserAccountIpc({ ipcMain, io, fetcher })
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: unbound })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
const status = await handlers.get('hermes:account:status')!(null)
assert.deepEqual(status, { loggedIn: false, sessionExpired: true })
@@ -186,7 +186,7 @@ test('relay 不可达但本地会话在:status 标离线并回缓存 profile +
return { ok: true, status: 200, json: async () => ME, text: async () => '' }
}
registerUserAccountIpc({ ipcMain, io, fetcher })
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: unbound })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
online = false
const status = await handlers.get('hermes:account:status')!(null)
@@ -210,10 +210,140 @@ test('logout 尽力通知服务端,本地清零;服务端 401 不阻塞', as
return { status: 200, body: {} }
})
registerUserAccountIpc({ ipcMain, io, fetcher })
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: unbound })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
const result = await handlers.get('hermes:account:logout')!(null)
assert.deepEqual(result, { ok: true })
assert.equal(loadUserAccount(io), null)
assert.deepEqual(await handlers.get('hermes:account:status')!(null), { loggedIn: false })
})
function unbound(): string {
throw new Error('ENOENT')
}
const BINDING_STATE = JSON.stringify({
relay_binding: {
site: 'https://r',
agent_id: 'inst-1',
route: 'route-1',
host_key_fingerprint: 'AAAA-AA',
key_epoch: 1,
host_refresh_token: 'SHOULD-NOT-BE-READ',
protocol_version: 1,
bound_at: '2026-09-19T00:00:00Z'
}
})
function u5Fetcher(ownerEmail: string | null, ownedIds: string[]) {
return makeFetcher(url => {
if (url.endsWith('/auth/login')) {return { status: 200, body: TOKENS }}
if (url.endsWith('/me')) {return { status: 200, body: ME }}
if (url.endsWith('/installations')) {return { status: 200, body: { items: ownedIds.map(id => ({ id })) } }}
if (url.includes('/agent/binding/owner')) {
return ownerEmail ? { status: 200, body: { email: ownerEmail } } : { status: 404, text: 'not_visible' }
}
if (url.endsWith('/auth/logout')) {return { status: 200, body: { status: 'succeeded' } }}
return { status: 200, body: {} }
})
}
test('U-5:本机绑定属于登录者本人 → 放行', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = u5Fetcher('u@example.com', ['inst-1'])
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: () => BINDING_STATE })
const result = await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u@example.com', password: 'pw' })
assert.equal(result.ok, true)
assert.equal(loadUserAccount(io)?.session.refreshToken, 'RT-1')
})
test('U-5:他账号登录 → 当场销毁会话,返回锁码 + 绑定者邮箱', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher, calls } = u5Fetcher('owner@example.com', ['inst-other'])
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: () => BINDING_STATE })
const result = await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'intruder@example.com', password: 'pw' })
assert.deepEqual(result, { ok: false, code: 'machine_bound_to_other', binderEmail: 'owner@example.com' })
// 会话当场销毁:通知了服务端 logout,本地无一物落盘。
assert.equal(calls.filter(c => c.url.endsWith('/auth/logout')).length, 1)
assert.equal(loadUserAccount(io), null)
assert.deepEqual(await handlers.get('hermes:account:status')!(null), { loggedIn: false })
})
test('U-5:绑定在别的站点 → 同样拒绝', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = u5Fetcher(null, [])
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: () => BINDING_STATE })
const result = await handlers.get('hermes:account:login')!(null, { site: 'https://other', email: 'u@example.com', password: 'pw' })
assert.equal(result.ok, false)
assert.equal(result.code, 'machine_bound_to_other')
assert.equal(loadUserAccount(io), null)
})
test('U-5:绑定状态损坏 fail-closed,新会话一并销毁', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher, calls } = u5Fetcher(null, [])
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: () => '{broken json' })
const result = await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u@example.com', password: 'pw' })
assert.deepEqual(result, { ok: false, code: 'binding_state_invalid' })
assert.equal(calls.filter(c => c.url.endsWith('/auth/logout')).length, 1)
assert.equal(loadUserAccount(io), null)
})
test('login 预期失败走结构化返回:invalid_credentials / login_rate_limited / network', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = makeFetcher(() => ({ status: 401, text: 'invalid_credentials' }))
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: unbound })
assert.deepEqual(await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' }), {
ok: false,
code: 'invalid_credentials'
})
assert.equal(loadUserAccount(io), null)
})
test('register/reset 四通道透传路径与参数', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher, calls } = makeFetcher(() => ({ status: 202, body: { status: 'verification_sent' } }))
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: unbound })
assert.deepEqual(await handlers.get('hermes:account:register-start')!(null, { site: 'https://r', email: 'u@e.c', password: 'pw' }), { ok: true })
assert.deepEqual(await handlers.get('hermes:account:register-resend')!(null, { site: 'https://r', email: 'u@e.c' }), { ok: true })
assert.deepEqual(await handlers.get('hermes:account:reset-request')!(null, { site: 'https://r', email: 'u@e.c' }), { ok: true })
assert.deepEqual(
await handlers.get('hermes:account:reset-confirm')!(null, { site: 'https://r', email: 'u@e.c', code: '123456', password: 'new' }),
{ ok: true }
)
const paths = calls.map(c => c.url.replace('https://r', ''))
assert.deepEqual(paths, [
'/api/v2/registration/start',
'/api/v2/registration/resend',
'/api/v2/auth/password-reset/request',
'/api/v2/auth/password-reset/confirm'
])
assert.deepEqual(JSON.parse(calls[3].init.body), { email: 'u@e.c', code: '123456', password: 'new' })
})
test('register-start 失败码透传(weak_password / registration_rate_limited)', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = makeFetcher(() => ({ status: 400, text: 'weak_password' }))
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: unbound })
assert.deepEqual(await handlers.get('hermes:account:register-start')!(null, { site: 'https://r', email: 'u@e.c', password: 'x' }), {
ok: false,
code: 'weak_password'
})
})
+133 -8
View File
@@ -1,5 +1,5 @@
/**
* user-account-ipc.ts — 用户账号会话的 IPC 边界(§21 DB-T1,U-1/U-3)。
* user-account-ipc.ts — 用户账号会话的 IPC 边界(§21 DB-T1/DB-T2,U-1/U-3/U-5)。
*
* token 只活在 main 进程(内存 + safeStorage 落盘),renderer 永远拿不到
* access/refresh token——login 之后 renderer 只能拿到 profile 与状态。
@@ -8,17 +8,29 @@
* refresh 也被拒(session_inactive / refresh_reuse_or_invalid)= 唯一的
* 硬会话终点(U-3)→ 本地清零并上报 sessionExpired,由 renderer 落登录页。
*
* U-5 机器-用户锁:登录成功当场校验本机 installation ∈ 该用户的列表;
* 不符立即销毁会话并返回 machine_bound_to_other(附绑定者邮箱,便于
* 认领/联系解绑——2026-09-19 用户裁定容许显示)。绑定状态文件损坏
* fail-closed:锁无法判定时不许登录。
*
* 登出≠解绑:logout 只清用户会话槽,机器绑定(H/R)完全不经过这里。
*/
import { loadMachineBinding, MachineBindingError } from './machine-binding'
import {
canonicalizeRelaySite,
type FetchLike,
relayAccountBindingOwner,
RelayAccountError,
relayAccountFetchMe,
relayAccountListInstallationIds,
relayAccountLogin,
relayAccountLogout,
relayAccountRefresh,
relayAccountRegisterResend,
relayAccountRegisterStart,
relayAccountResetConfirm,
relayAccountResetRequest,
type RelayAccountSession
} from './relay-account'
import {
@@ -34,10 +46,21 @@ export interface UserAccountIpcDeps {
ipcMain: { handle: (channel: string, fn: (event: any, payload?: any) => Promise<any>) => void }
io: UserAccountStoreIo
fetcher: FetchLike
/** 插件 state.json 原文;ENOENT 抛错视为未绑定。 */
readMachineBindingState: () => string
}
/** 预期内的失败走结构化返回(renderer 按 code 上文案);意外仍抛。 */
function failure(error: unknown): { ok: false; code: string } {
if (error instanceof RelayAccountError) {
return { ok: false, code: error.code }
}
throw error
}
export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
const { ipcMain, io, fetcher } = deps
const { ipcMain, io, fetcher, readMachineBindingState } = deps
let cached: StoredUserAccount | null = null
function current(): StoredUserAccount | null {
@@ -82,20 +105,72 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
return fn(refreshed)
}
/**
* U-5:登录成功后当场校验。返回 null 放行;否则结构化失败(会话已销毁)。
* 绑定状态损坏 = 锁无法判定,fail-closed 拒绝登录(同样销毁新会话)。
*/
async function enforceMachineLock(session: RelayAccountSession): Promise<Record<string, unknown> | null> {
let binding
try {
binding = loadMachineBinding(readMachineBindingState)
} catch (error) {
if (error instanceof MachineBindingError) {
await relayAccountLogout(session, fetcher)
return { ok: false, code: 'binding_state_invalid' }
}
throw error
}
if (!binding) {return null}
if (binding.site === session.site) {
const owned = await relayAccountListInstallationIds(session.site, session.accessToken, fetcher)
if (owned.includes(binding.installationId)) {return null}
}
// 他账号/他站:当场销毁会话(U-5),再尽力取绑定者邮箱供锁态提示。
const binderEmail = await relayAccountBindingOwner(session.site, session.accessToken, binding.installationId, fetcher).catch(
() => null
)
await relayAccountLogout(session, fetcher)
return { ok: false, code: 'machine_bound_to_other', binderEmail }
}
ipcMain.handle('hermes:account:login', async (_event, payload) => {
const site = canonicalizeRelaySite(String(payload?.site ?? ''))
let site: string
try {
site = canonicalizeRelaySite(String(payload?.site ?? ''))
} catch (error) {
return failure(error)
}
const email = String(payload?.email ?? '').trim()
const password = String(payload?.password ?? '')
if (!email || !password) {
throw new RelayAccountError('invalid_login_request', 0)
return { ok: false, code: 'invalid_login_request' }
}
const session = await relayAccountLogin(site, email, password, USER_ACCOUNT_CLIENT_ID, fetcher)
const profile = await relayAccountFetchMe(site, session.accessToken, fetcher)
store({ session, profile, profileAsOf: new Date().toISOString() })
try {
const session = await relayAccountLogin(site, email, password, USER_ACCOUNT_CLIENT_ID, fetcher)
const locked = await enforceMachineLock(session)
return { ok: true, site, profile }
if (locked) {return locked}
const profile = await relayAccountFetchMe(site, session.accessToken, fetcher)
store({ session, profile, profileAsOf: new Date().toISOString() })
return { ok: true, site, profile }
} catch (error) {
return failure(error)
}
})
ipcMain.handle('hermes:account:status', async () => {
@@ -145,4 +220,54 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
return { ok: true }
})
ipcMain.handle('hermes:account:register-start', async (_event, payload) => {
try {
const site = canonicalizeRelaySite(String(payload?.site ?? ''))
await relayAccountRegisterStart(site, String(payload?.email ?? '').trim(), String(payload?.password ?? ''), fetcher)
return { ok: true }
} catch (error) {
return failure(error)
}
})
ipcMain.handle('hermes:account:register-resend', async (_event, payload) => {
try {
const site = canonicalizeRelaySite(String(payload?.site ?? ''))
await relayAccountRegisterResend(site, String(payload?.email ?? '').trim(), fetcher)
return { ok: true }
} catch (error) {
return failure(error)
}
})
ipcMain.handle('hermes:account:reset-request', async (_event, payload) => {
try {
const site = canonicalizeRelaySite(String(payload?.site ?? ''))
await relayAccountResetRequest(site, String(payload?.email ?? '').trim(), fetcher)
return { ok: true }
} catch (error) {
return failure(error)
}
})
ipcMain.handle('hermes:account:reset-confirm', async (_event, payload) => {
try {
const site = canonicalizeRelaySite(String(payload?.site ?? ''))
await relayAccountResetConfirm(
site,
String(payload?.email ?? '').trim(),
String(payload?.code ?? '').trim(),
String(payload?.password ?? ''),
fetcher
)
return { ok: true }
} catch (error) {
return failure(error)
}
})
}
+5 -2
View File
@@ -14,6 +14,7 @@ import { type CSSProperties, lazy, type ReactNode, Suspense, useCallback, useEff
import { useLocation, useNavigate } from 'react-router'
import { graftRefreshedTailOntoBackfill } from '@/app/chat/transcript-backfill'
import { ProjectRecallHost } from '@/app/project-recall/host'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { BootFailureOverlay } from '@/components/boot-failure-overlay'
import { ConfirmHost } from '@/components/confirm-host'
@@ -36,6 +37,7 @@ import { FloatingPet } from '@/components/pet/floating-pet'
import { RemoteDisplayBanner } from '@/components/remote-display-banner'
import { SendDiagnosticsHost } from '@/components/send-diagnostics-dialog'
import { TipHost } from '@/components/tips'
import { UserAccountGate } from '@/components/user-account-gate'
import { emitGatewayEvent } from '@/contrib/events'
import { getLatestSessionMessages } from '@/hermes'
import { translateNow } from '@/i18n'
@@ -93,7 +95,6 @@ import { $titlebarAppActionsSide, titlebarAppActionsClusterCounts } from '@/stor
import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos'
import { armWakeWord, stopClientCapture } from '@/store/wake-word'
import { isAuxiliaryWindow, isBrowserWindow, isHudWindow } from '@/store/windows'
import { ProjectRecallHost } from '@/app/project-recall/host'
import { useSkinCommand } from '@/themes/use-skin-command'
import { closeWorkspaceTab } from '../chat/close-tab'
@@ -1315,6 +1316,8 @@ export function ContribWiring({ children }: { children: ReactNode }) {
<RemoteDisplayBanner />
{!isAuxiliaryWindow() && <DesktopInstallOverlay />}
{!isAuxiliaryWindow() && <IntroRevealGate enabled={gatewayState === 'open'} />}
{/* §21 强制登录门:signed_out 时盖全屏;只盖 UI,不断机器通道(U-1)。 */}
{!isAuxiliaryWindow() && <UserAccountGate />}
{!isAuxiliaryWindow() && (
<OnboardingChatGate
enabled={gatewayState === 'open'}
@@ -1345,7 +1348,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
profile={activeGatewayProfile}
requestGateway={requestGateway}
/>
<ProjectRecallHost requestGateway={requestGateway} onOpenSession={(sessionId, owner) => openSessionForOwner(sessionId, navigate, owner)} />
<ProjectRecallHost onOpenSession={(sessionId, owner) => openSessionForOwner(sessionId, navigate, owner)} requestGateway={requestGateway} />
<SessionPickerOverlay onResume={sessionId => openSession(sessionId, navigate)} />
<ModelVisibilityOverlay
gateway={gateway || undefined}
@@ -0,0 +1,179 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $userAccount, userAccountRefreshStatus } from '@/store/user-account'
import { UserAccountGate } from './user-account-gate'
const PROFILE = {
email: 'u@example.com',
username: '',
nickname: '小赫',
phone: '',
planId: 'free',
periodEnd: '',
dataAsOf: 't0'
}
function stubBridge(overrides: Record<string, unknown> = {}) {
const calls = { login: [] as any[], registerStart: [] as any[], resetRequest: [] as any[], resetConfirm: [] as any[] }
const api = {
login: vi.fn(async (payload: any): Promise<any> => {
calls.login.push(payload)
return { ok: true, site: payload.site, profile: PROFILE }
}),
status: vi.fn(async () => ({ loggedIn: false })),
me: vi.fn(async () => ({ profile: PROFILE })),
logout: vi.fn(async () => ({ ok: true })),
registerStart: vi.fn(async (payload: any) => {
calls.registerStart.push(payload)
return { ok: true }
}),
registerResend: vi.fn(async () => ({ ok: true })),
resetRequest: vi.fn(async (payload: any) => {
calls.resetRequest.push(payload)
return { ok: true }
}),
resetConfirm: vi.fn(async (payload: any) => {
calls.resetConfirm.push(payload)
return { ok: true }
}),
...overrides
}
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { userAccount: api } })
return { api, calls }
}
beforeEach(() => {
window.localStorage.clear()
$userAccount.set({ status: 'unknown' })
})
afterEach(() => {
cleanup()
Reflect.deleteProperty(window, 'hermesDesktop')
})
async function settleStatus() {
await waitFor(() => {
expect($userAccount.get().status).not.toBe('unknown')
})
}
describe('UserAccountGate', () => {
it('signed_in 时不渲染;signed_out 时盖出登录页', async () => {
stubBridge()
render(<UserAccountGate />)
await settleStatus()
expect(screen.getByText('Sign in to Hermes')).toBeTruthy()
$userAccount.set({ status: 'signed_in', site: 'https://r', offline: false, profile: PROFILE, profileAsOf: null })
await waitFor(() => expect(screen.queryByText('Sign in to Hermes')).toBeNull())
})
it('登录成功走 bridge 并放行;失败按码上文案', async () => {
const { api, calls } = stubBridge()
render(<UserAccountGate />)
await settleStatus()
api.login.mockResolvedValueOnce({ ok: false, code: 'invalid_credentials' })
fireEvent.change(screen.getByPlaceholderText('https://relay.example.com'), { target: { value: 'https://r' } })
fireEvent.change(screen.getByPlaceholderText('you@example.com'), { target: { value: 'u@example.com' } })
fireEvent.change(screen.getByPlaceholderText('Your password'), { target: { value: 'pw' } })
fireEvent.click(screen.getByText('Sign in'))
await screen.findByText('Wrong email or password.')
expect($userAccount.get().status).toBe('signed_out')
fireEvent.click(screen.getByText('Sign in'))
await waitFor(() => expect($userAccount.get().status).toBe('signed_in'))
expect(calls.login[0]).toEqual({ site: 'https://r', email: 'u@example.com', password: 'pw' })
})
it('U-5 锁态:显示绑定者邮箱;损坏另出修复文案', async () => {
const { api } = stubBridge()
api.login.mockResolvedValue({ ok: false, code: 'machine_bound_to_other', binderEmail: 'owner@example.com' })
render(<UserAccountGate />)
await settleStatus()
fireEvent.change(screen.getByPlaceholderText('https://relay.example.com'), { target: { value: 'https://r' } })
fireEvent.change(screen.getByPlaceholderText('you@example.com'), { target: { value: 'x@example.com' } })
fireEvent.change(screen.getByPlaceholderText('Your password'), { target: { value: 'pw' } })
fireEvent.click(screen.getByText('Sign in'))
await screen.findByText('This machine is bound to another account')
expect(screen.getByText(/owner@example\.com/)).toBeTruthy()
fireEvent.click(screen.getByText('Back to sign in'))
api.login.mockResolvedValue({ ok: false, code: 'binding_state_invalid' })
fireEvent.click(screen.getByText('Sign in'))
await screen.findByText(/binding data is damaged/)
})
it('sessionExpired 提示出现在登录页', async () => {
stubBridge()
render(<UserAccountGate />)
await settleStatus()
$userAccount.set({ status: 'signed_out', sessionExpired: true })
await screen.findByText('Your session expired. Please sign in again.')
})
it('注册:密码不一致本地拦下;成功出「查收邮件」且可重发', async () => {
const { calls } = stubBridge()
render(<UserAccountGate />)
await settleStatus()
fireEvent.click(screen.getByText('Create account'))
fireEvent.change(screen.getByPlaceholderText('https://relay.example.com'), { target: { value: 'https://r' } })
fireEvent.change(screen.getByPlaceholderText('you@example.com'), { target: { value: 'n@example.com' } })
const [pw, confirm] = screen.getAllByDisplayValue('')
fireEvent.change(pw, { target: { value: 'abc' } })
fireEvent.change(confirm, { target: { value: 'xyz' } })
fireEvent.click(screen.getByText('Sign up'))
await screen.findByText('Passwords do not match.')
expect(calls.registerStart).toHaveLength(0)
fireEvent.change(confirm, { target: { value: 'abc' } })
fireEvent.click(screen.getByText('Sign up'))
await screen.findByText('Check your inbox')
expect(calls.registerStart[0]).toEqual({ site: 'https://r', email: 'n@example.com', password: 'abc' })
fireEvent.click(screen.getByText('Resend email'))
await screen.findByText('Sent again.')
})
it('找回:两步走完回登录', async () => {
const { calls } = stubBridge()
render(<UserAccountGate />)
await settleStatus()
fireEvent.click(screen.getByText('Forgot password?'))
fireEvent.change(screen.getByPlaceholderText('https://relay.example.com'), { target: { value: 'https://r' } })
fireEvent.change(screen.getByPlaceholderText('you@example.com'), { target: { value: 'u@example.com' } })
fireEvent.click(screen.getByText('Send reset code'))
await screen.findByText(/reset code is on its way/)
const codeInput = document.querySelectorAll('input')[0] as HTMLInputElement
fireEvent.change(codeInput, { target: { value: '123456' } })
fireEvent.change(document.querySelector('input[type=password]')!, { target: { value: 'newpw' } })
fireEvent.click(screen.getByText('Set new password'))
await screen.findByText('Password updated. Sign in with the new password.')
expect(calls.resetConfirm[0]).toEqual({ site: 'https://r', email: 'u@example.com', code: '123456', password: 'newpw' })
fireEvent.click(screen.getByText('Back to sign in'))
await screen.findByText('Sign in to Hermes')
})
it('refreshStatus 通道异常时落登录页而不是卡住', async () => {
const { api } = stubBridge()
api.status.mockRejectedValue(new Error('bridge gone'))
render(<UserAccountGate />)
await waitFor(() => expect($userAccount.get().status).toBe('signed_out'))
expect(screen.getByText('Sign in to Hermes')).toBeTruthy()
})
})
void userAccountRefreshStatus
@@ -0,0 +1,395 @@
/**
* user-account-gate.tsx — 强制登录门(§21 DB-T2,U-3/U-5)。
*
* $userAccount 为 signed_out 时全屏盖住整个 app:登录/注册/找回三页。
* U-3:无会话 + relay 不可达 = 停留在登录页带 network 文案(等待,不砖);
* 有会话 + 不可达在 DB-T1 status 层已标离线放行,门根本不出现。
* U-5:machine_bound_to_other → 锁态页,显示绑定者邮箱(用户裁定容许)。
*
* 门只盖 UI:机器通道(agent 宿主连接)由 hermes_cli 独立供能,与门的
* 开关无任何关系(U-1)。
*/
import { useStore } from '@nanostores/react'
import { type FormEvent, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Loader } from '@/components/ui/loader'
import { useI18n } from '@/i18n'
import type { Translations } from '@/i18n/types'
import { readKey, writeKey } from '@/lib/storage'
import {
$userAccount,
UserAccountFailure,
userAccountLogin,
userAccountRefreshStatus,
userAccountRegisterResend,
userAccountRegisterStart,
userAccountResetConfirm,
userAccountResetRequest
} from '@/store/user-account'
const SITE_KEY = 'hermes-user-account-site'
type Page = 'login' | 'register' | 'reset'
function errorText(t: Translations['userAccount'], failure: UserAccountFailure): string {
switch (failure.code) {
case 'invalid_credentials':
return t.errorInvalidCredentials
case 'account_locked':
case 'login_rate_limited':
case 'registration_rate_limited':
return t.errorRateLimited
case 'network':
return t.errorNetwork
case 'weak_password':
return t.errorWeakPassword
case 'reset_code_invalid_or_expired':
return t.errorResetCode
case 'invalid_site':
return t.errorInvalidSite
default:
return t.errorServer
}
}
export function UserAccountGate() {
const { t } = useI18n()
const copy = t.userAccount
const account = useStore($userAccount)
const [page, setPage] = useState<Page>('login')
useEffect(() => {
if (account.status === 'unknown') {
void userAccountRefreshStatus().catch(() => {
// status 通道本身的意外(桥不可用等)按未登录落门,不砖。
$userAccount.set({ status: 'signed_out' })
})
}
}, [account.status])
if (account.status === 'signed_in') {return null}
return (
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-background">
<div className="w-[min(92vw,380px)] rounded-2xl border border-border bg-card p-8 shadow-xl">
{account.status === 'unknown' ? (
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<Loader className="size-4" />
{copy.checking}
</div>
) : page === 'login' ? (
<LoginPage onNavigate={setPage} sessionExpired={account.sessionExpired === true} />
) : page === 'register' ? (
<RegisterPage onNavigate={setPage} />
) : (
<ResetPage onNavigate={setPage} />
)}
</div>
</div>
)
}
function useSite(): [string, (value: string) => void] {
const [site, setSite] = useState(() => readKey(SITE_KEY) ?? '')
return [
site,
(value: string) => {
setSite(value)
writeKey(SITE_KEY, value || null)
}
]
}
function LoginPage({ sessionExpired, onNavigate }: { sessionExpired: boolean; onNavigate: (page: Page) => void }) {
const { t } = useI18n()
const copy = t.userAccount
const [site, setSite] = useSite()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [lock, setLock] = useState<{ binderEmail: string | null; invalid: boolean } | null>(null)
async function submit(event: FormEvent) {
event.preventDefault()
setBusy(true)
setError(null)
setLock(null)
try {
await userAccountLogin(site, email, password)
} catch (failure) {
if (failure instanceof UserAccountFailure && failure.code === 'machine_bound_to_other') {
setLock({ binderEmail: failure.binderEmail, invalid: false })
} else if (failure instanceof UserAccountFailure && failure.code === 'binding_state_invalid') {
setLock({ binderEmail: null, invalid: true })
} else {
setError(errorText(copy, failure instanceof UserAccountFailure ? failure : new UserAccountFailure('server')))
}
} finally {
setBusy(false)
}
}
if (lock) {
return (
<div className="space-y-4">
<h1 className="text-lg font-semibold">{lock.invalid ? copy.errorServer : copy.lockedTitle}</h1>
<p className="text-sm text-muted-foreground">
{lock.invalid ? copy.bindingInvalidBody : lock.binderEmail ? copy.lockedBody(lock.binderEmail) : copy.lockedBodyUnknown}
</p>
<Button className="w-full" onClick={() => setLock(null)} variant="outline">
{copy.backToSignIn}
</Button>
</div>
)
}
return (
<form className="space-y-4" onSubmit={submit}>
<div className="space-y-1">
<h1 className="text-lg font-semibold">{copy.title}</h1>
<p className="text-sm text-muted-foreground">{copy.subtitle}</p>
</div>
{sessionExpired && <p className="text-sm text-amber-600">{copy.sessionExpiredNotice}</p>}
<label className="block space-y-1 text-sm">
<span>{copy.siteLabel}</span>
<Input onChange={event => setSite(event.target.value)} placeholder={copy.sitePlaceholder} required value={site} />
</label>
<label className="block space-y-1 text-sm">
<span>{copy.emailLabel}</span>
<Input onChange={event => setEmail(event.target.value)} placeholder={copy.emailPlaceholder} required type="email" value={email} />
</label>
<label className="block space-y-1 text-sm">
<span>{copy.passwordLabel}</span>
<Input
onChange={event => setPassword(event.target.value)}
placeholder={copy.passwordPlaceholder}
required
type="password"
value={password}
/>
</label>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button className="w-full" disabled={busy} type="submit">
{busy ? copy.signingIn : copy.signIn}
</Button>
<div className="flex justify-between text-sm">
<button className="text-primary hover:underline" onClick={() => onNavigate('reset')} type="button">
{copy.forgotPassword}
</button>
<button className="text-primary hover:underline" onClick={() => onNavigate('register')} type="button">
{copy.register}
</button>
</div>
</form>
)
}
function RegisterPage({ onNavigate }: { onNavigate: (page: Page) => void }) {
const { t } = useI18n()
const copy = t.userAccount
const [site, setSite] = useSite()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [busy, setBusy] = useState(false)
const [resent, setResent] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState<string | null>(null)
async function submit(event: FormEvent) {
event.preventDefault()
if (password !== confirm) {
setError(copy.passwordMismatch)
return
}
setBusy(true)
setError(null)
try {
await userAccountRegisterStart(site, email, password)
setSent(true)
} catch (failure) {
setError(errorText(copy, failure instanceof UserAccountFailure ? failure : new UserAccountFailure('server')))
} finally {
setBusy(false)
}
}
async function resend() {
setBusy(true)
try {
await userAccountRegisterResend(site, email)
setResent(true)
} catch {
// 重发失败不改变「邮件已发」的主文案(防枚举口径:本就不保证对方存在)。
} finally {
setBusy(false)
}
}
if (sent) {
return (
<div className="space-y-4">
<h1 className="text-lg font-semibold">{copy.registerSentTitle}</h1>
<p className="text-sm text-muted-foreground">{copy.registerSentBody}</p>
<Button className="w-full" disabled={busy} onClick={resend} variant="outline">
{busy ? copy.resending : resent ? copy.resent : copy.resend}
</Button>
<Button className="w-full" onClick={() => onNavigate('login')}>
{copy.backToSignIn}
</Button>
</div>
)
}
return (
<form className="space-y-4" onSubmit={submit}>
<div className="space-y-1">
<h1 className="text-lg font-semibold">{copy.registerTitle}</h1>
<p className="text-sm text-muted-foreground">{copy.registerSubtitle}</p>
</div>
<label className="block space-y-1 text-sm">
<span>{copy.siteLabel}</span>
<Input onChange={event => setSite(event.target.value)} placeholder={copy.sitePlaceholder} required value={site} />
</label>
<label className="block space-y-1 text-sm">
<span>{copy.emailLabel}</span>
<Input onChange={event => setEmail(event.target.value)} placeholder={copy.emailPlaceholder} required type="email" value={email} />
</label>
<label className="block space-y-1 text-sm">
<span>{copy.passwordLabel}</span>
<Input onChange={event => setPassword(event.target.value)} required type="password" value={password} />
</label>
<label className="block space-y-1 text-sm">
<span>{copy.confirmPasswordLabel}</span>
<Input onChange={event => setConfirm(event.target.value)} required type="password" value={confirm} />
</label>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button className="w-full" disabled={busy} type="submit">
{busy ? copy.registering : copy.registerSubmit}
</Button>
<button className="text-sm text-primary hover:underline" onClick={() => onNavigate('login')} type="button">
{copy.backToSignIn}
</button>
</form>
)
}
function ResetPage({ onNavigate }: { onNavigate: (page: Page) => void }) {
const { t } = useI18n()
const copy = t.userAccount
const [site, setSite] = useSite()
const [email, setEmail] = useState('')
const [code, setCode] = useState('')
const [password, setPassword] = useState('')
const [sent, setSent] = useState(false)
const [done, setDone] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function sendCode(event: FormEvent) {
event.preventDefault()
setBusy(true)
setError(null)
try {
await userAccountResetRequest(site, email)
setSent(true)
} catch (failure) {
setError(errorText(copy, failure instanceof UserAccountFailure ? failure : new UserAccountFailure('server')))
} finally {
setBusy(false)
}
}
async function confirmReset(event: FormEvent) {
event.preventDefault()
setBusy(true)
setError(null)
try {
await userAccountResetConfirm(site, email, code, password)
setDone(true)
} catch (failure) {
setError(errorText(copy, failure instanceof UserAccountFailure ? failure : new UserAccountFailure('server')))
} finally {
setBusy(false)
}
}
if (done) {
return (
<div className="space-y-4">
<p className="text-sm">{copy.resetDoneBody}</p>
<Button className="w-full" onClick={() => onNavigate('login')}>
{copy.backToSignIn}
</Button>
</div>
)
}
if (!sent) {
return (
<form className="space-y-4" onSubmit={sendCode}>
<div className="space-y-1">
<h1 className="text-lg font-semibold">{copy.resetTitle}</h1>
<p className="text-sm text-muted-foreground">{copy.resetSubtitle}</p>
</div>
<label className="block space-y-1 text-sm">
<span>{copy.siteLabel}</span>
<Input onChange={event => setSite(event.target.value)} placeholder={copy.sitePlaceholder} required value={site} />
</label>
<label className="block space-y-1 text-sm">
<span>{copy.emailLabel}</span>
<Input onChange={event => setEmail(event.target.value)} placeholder={copy.emailPlaceholder} required type="email" value={email} />
</label>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button className="w-full" disabled={busy} type="submit">
{busy ? copy.resetSending : copy.resetSend}
</Button>
<button className="text-sm text-primary hover:underline" onClick={() => onNavigate('login')} type="button">
{copy.backToSignIn}
</button>
</form>
)
}
return (
<form className="space-y-4" onSubmit={confirmReset}>
<p className="text-sm text-muted-foreground">{copy.resetSentBody}</p>
<label className="block space-y-1 text-sm">
<span>{copy.codeLabel}</span>
<Input onChange={event => setCode(event.target.value)} required value={code} />
</label>
<label className="block space-y-1 text-sm">
<span>{copy.newPasswordLabel}</span>
<Input onChange={event => setPassword(event.target.value)} required type="password" value={password} />
</label>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button className="w-full" disabled={busy} type="submit">
{busy ? copy.resetConfirming : copy.resetConfirm}
</Button>
<button className="text-sm text-primary hover:underline" onClick={() => onNavigate('login')} type="button">
{copy.backToSignIn}
</button>
</form>
)
}
+6 -2
View File
@@ -5,7 +5,6 @@ import type { HermesNotification } from '../electron/notification-types'
import type { PoolLimits } from '../electron/pool-limits'
import type { WakeIndicatorState } from './lib/wake-indicator'
import type { HermesUserAccountProfile, HermesUserAccountStatus } from './store/user-account'
import type {
PetOverlayBounds,
PetOverlayControl,
@@ -13,6 +12,7 @@ import type {
PetOverlayStatePayload
} from './store/pet-overlay'
import type { QuickEntryStatePush, QuickEntryStatus, QuickEntrySubmitPayload } from './store/quick-entry'
import type { HermesUserAccountProfile, HermesUserAccountStatus } from './store/user-account'
export {}
@@ -41,10 +41,14 @@ declare global {
// process, which auto-refreshes on 401 and wipes the local session on
// refresh expiry (sessionExpired).
userAccount: {
login: (payload: { site: string; email: string; password: string }) => Promise<{ ok: boolean; site: string; profile: HermesUserAccountProfile }>
login: (payload: { site: string; email: string; password: string }) => Promise<HermesUserAccountLoginResult>
status: () => Promise<HermesUserAccountStatus>
me: () => Promise<{ profile: HermesUserAccountProfile }>
logout: () => Promise<{ ok: boolean }>
registerStart: (payload: { site: string; email: string; password: string }) => Promise<HermesUserAccountOpResult>
registerResend: (payload: { site: string; email: string }) => Promise<HermesUserAccountOpResult>
resetRequest: (payload: { site: string; email: string }) => Promise<HermesUserAccountOpResult>
resetConfirm: (payload: { site: string; email: string; code: string; password: string }) => Promise<HermesUserAccountOpResult>
}
// Registry-scoped backend resolution: dial (connectionId, profile). An
// empty/local connectionId delegates to the legacy getConnection path.
+51
View File
@@ -3274,5 +3274,56 @@ export const ar = defineLocale({
description: 'تنقل التطبيق',
toggle: open => `${open ? 'إظهار' : 'إخفاء'} الشريط الجانبي`
}
},
userAccount: {
checking: 'جارٍ التحقق من الجلسة…',
title: 'تسجيل الدخول إلى Hermes',
subtitle: 'حساب واحد لسطح المكتب والتطبيق ووحدة التحكم.',
siteLabel: 'الخادم',
sitePlaceholder: 'https://relay.example.com',
emailLabel: 'البريد الإلكتروني',
emailPlaceholder: 'you@example.com',
passwordLabel: 'كلمة المرور',
passwordPlaceholder: 'كلمة المرور الخاصة بك',
signIn: 'تسجيل الدخول',
signingIn: 'جارٍ تسجيل الدخول…',
forgotPassword: 'نسيت كلمة المرور؟',
register: 'إنشاء حساب',
registerTitle: 'أنشئ حسابك',
registerSubtitle: 'نفس بيانات الاعتماد تعمل على سطح المكتب والتطبيق ووحدة التحكم.',
confirmPasswordLabel: 'تأكيد كلمة المرور',
passwordMismatch: 'كلمتا المرور غير متطابقتين.',
registerSubmit: 'التسجيل',
registering: 'جارٍ التسجيل…',
registerSentTitle: 'تحقق من بريدك الوارد',
registerSentBody: 'أرسلنا رسالة تحقق. افتح الرابط لإتمام تأكيد حسابك (تُفتح في وحدة التحكم)، ثم عد وسجّل الدخول.',
resend: 'إعادة إرسال الرسالة',
resending: 'جارٍ الإعادة…',
resent: 'تمت الإعادة.',
resetTitle: 'إعادة تعيين كلمة المرور',
resetSubtitle: 'سنرسل رمز إعادة التعيين إلى بريدك.',
resetSend: 'إرسال الرمز',
resetSending: 'جارٍ الإرسال…',
resetSentBody: 'إذا كان البريد مسجلاً، فالرمز في الطريق. أدخله أدناه مع كلمة مرور جديدة.',
codeLabel: 'رمز إعادة التعيين',
newPasswordLabel: 'كلمة المرور الجديدة',
resetConfirm: 'تعيين كلمة مرور جديدة',
resetConfirming: 'جارٍ التعيين…',
resetDoneBody: 'تم تحديث كلمة المرور. سجّل الدخول بكلمة المرور الجديدة.',
backToSignIn: 'العودة إلى تسجيل الدخول',
sessionExpiredNotice: 'انتهت صلاحية الجلسة. سجّل الدخول مرة أخرى.',
errorInvalidCredentials: 'البريد الإلكتروني أو كلمة المرور غير صحيحة.',
errorRateLimited: 'محاولات كثيرة جداً. انتظر ثم أعد المحاولة.',
errorNetwork: 'تعذّر الوصول إلى الخادم. تحقق من الشبكة وأعد المحاولة.',
errorServer: 'خطأ في الخادم. حاول لاحقاً.',
errorWeakPassword: 'كلمة المرور لا تستوفي المتطلبات.',
errorResetCode: 'رمز إعادة التعيين غير صالح أو منتهٍ.',
errorInvalidSite: 'عنوان الخادم غير صالح (يلزم https).',
lockedTitle: 'هذا الجهاز مرتبط بحساب آخر',
lockedBody: email => `هذا الجهاز مرتبط بـ ${email}. ألغِ الارتباط هناك أولاً ثم سجّل الدخول بحساب مختلف.`,
lockedBodyUnknown: 'هذا الجهاز مرتبط بحساب آخر. ألغِ الارتباط أولاً.',
bindingInvalidBody: 'بيانات ارتباط الجهاز تالفة. أصلح تثبيت Hermes agent قبل تسجيل الدخول.'
}
})
+51
View File
@@ -4515,5 +4515,56 @@ export const en: Translations = {
description: 'Displays the mobile sidebar.',
toggle: open => `${open ? 'Show' : 'Hide'} sidebar`
}
},
userAccount: {
checking: 'Verifying your session…',
title: 'Sign in to Hermes',
subtitle: 'One account for desktop, app, and console.',
siteLabel: 'Server',
sitePlaceholder: 'https://relay.example.com',
emailLabel: 'Email',
emailPlaceholder: 'you@example.com',
passwordLabel: 'Password',
passwordPlaceholder: 'Your password',
signIn: 'Sign in',
signingIn: 'Signing in…',
forgotPassword: 'Forgot password?',
register: 'Create account',
registerTitle: 'Create your account',
registerSubtitle: 'Same credentials work on desktop, app, and console.',
confirmPasswordLabel: 'Confirm password',
passwordMismatch: 'Passwords do not match.',
registerSubmit: 'Sign up',
registering: 'Signing up…',
registerSentTitle: 'Check your inbox',
registerSentBody: 'We sent a verification email. Open the link to finish confirming your account (it opens in the console), then come back and sign in.',
resend: 'Resend email',
resending: 'Resending…',
resent: 'Sent again.',
resetTitle: 'Reset password',
resetSubtitle: 'We will email you a reset code.',
resetSend: 'Send reset code',
resetSending: 'Sending…',
resetSentBody: 'If the email is registered, a reset code is on its way. Enter it below with a new password.',
codeLabel: 'Reset code',
newPasswordLabel: 'New password',
resetConfirm: 'Set new password',
resetConfirming: 'Setting…',
resetDoneBody: 'Password updated. Sign in with the new password.',
backToSignIn: 'Back to sign in',
sessionExpiredNotice: 'Your session expired. Please sign in again.',
errorInvalidCredentials: 'Wrong email or password.',
errorRateLimited: 'Too many attempts. Please wait and try again.',
errorNetwork: 'Cannot reach the server. Check the network and try again.',
errorServer: 'Server error. Please try again later.',
errorWeakPassword: 'The password does not meet the requirements.',
errorResetCode: 'The reset code is invalid or has expired.',
errorInvalidSite: 'The server address is invalid (https required).',
lockedTitle: 'This machine is bound to another account',
lockedBody: email => `This machine is bound to ${email}. Unbind it there before signing in with a different account.`,
lockedBodyUnknown: 'This machine is bound to another account. Unbind it before signing in.',
bindingInvalidBody: 'The machine binding data is damaged. Repair the Hermes agent installation before signing in.'
}
}
+51
View File
@@ -3688,5 +3688,56 @@ export const ja = defineLocale({
description: 'モバイルサイドバーを表示します。',
toggle: open => `サイドバーを${open ? '表示' : '非表示'}`
}
},
userAccount: {
checking: 'セッションを確認しています…',
title: 'Hermes にサインイン',
subtitle: 'デスクトップ・アプリ・コンソール共通のアカウントです。',
siteLabel: 'サーバー',
sitePlaceholder: 'https://relay.example.com',
emailLabel: 'メールアドレス',
emailPlaceholder: 'you@example.com',
passwordLabel: 'パスワード',
passwordPlaceholder: 'パスワード',
signIn: 'サインイン',
signingIn: 'サインイン中…',
forgotPassword: 'パスワードをお忘れですか?',
register: 'アカウント作成',
registerTitle: 'アカウントを作成',
registerSubtitle: '同じ認証情報がデスクトップ・アプリ・コンソールで使えます。',
confirmPasswordLabel: 'パスワード(確認)',
passwordMismatch: 'パスワードが一致しません。',
registerSubmit: '登録',
registering: '登録中…',
registerSentTitle: '受信ボックスを確認してください',
registerSentBody: '確認メールを送信しました。メール内のリンクを開いてアカウント確認を完了し(コンソールで開きます)、戻ってサインインしてください。',
resend: 'メールを再送信',
resending: '再送信中…',
resent: '再送信しました。',
resetTitle: 'パスワードのリセット',
resetSubtitle: 'リセットコードをメールでお送りします。',
resetSend: 'リセットコードを送信',
resetSending: '送信中…',
resetSentBody: '登録済みのメールアドレスであれば、リセットコードが届きます。コードと新しいパスワードを入力してください。',
codeLabel: 'リセットコード',
newPasswordLabel: '新しいパスワード',
resetConfirm: '新しいパスワードを設定',
resetConfirming: '設定中…',
resetDoneBody: 'パスワードを更新しました。新しいパスワードでサインインしてください。',
backToSignIn: 'サインインに戻る',
sessionExpiredNotice: 'セッションの有効期限が切れました。もう一度サインインしてください。',
errorInvalidCredentials: 'メールアドレスまたはパスワードが正しくありません。',
errorRateLimited: '試行回数が多すぎます。しばらく待ってから再試行してください。',
errorNetwork: 'サーバーに接続できません。ネットワークを確認して再試行してください。',
errorServer: 'サーバーエラーです。後でもう一度お試しください。',
errorWeakPassword: 'パスワードが要件を満たしていません。',
errorResetCode: 'リセットコードが無効または期限切れです。',
errorInvalidSite: 'サーバーアドレスが無効です(https が必要です)。',
lockedTitle: 'このマシンは別のアカウントにバインドされています',
lockedBody: email => `このマシンは ${email} にバインドされています。別のアカウントでサインインする前に、そちらでバインド解除してください。`,
lockedBodyUnknown: 'このマシンは別のアカウントにバインドされています。先にバインド解除してください。',
bindingInvalidBody: 'マシンのバインドデータが壊れています。サインイン前に Hermes エージェントのインストールを修復してください。'
}
})
+51
View File
@@ -3895,5 +3895,56 @@ export const ru = defineLocale({
description: 'Показывает мобильную боковую панель.',
toggle: open => `${open ? 'Показать' : 'Скрыть'} боковую панель`
}
},
userAccount: {
checking: 'Проверяем сеанс…',
title: 'Войти в Hermes',
subtitle: 'Одна учётная запись для десктопа, приложения и консоли.',
siteLabel: 'Сервер',
sitePlaceholder: 'https://relay.example.com',
emailLabel: 'Эл. почта',
emailPlaceholder: 'you@example.com',
passwordLabel: 'Пароль',
passwordPlaceholder: 'Ваш пароль',
signIn: 'Войти',
signingIn: 'Вход…',
forgotPassword: 'Забыли пароль?',
register: 'Создать аккаунт',
registerTitle: 'Создайте аккаунт',
registerSubtitle: 'Те же данные работают на десктопе, в приложении и консоли.',
confirmPasswordLabel: 'Подтвердите пароль',
passwordMismatch: 'Пароли не совпадают.',
registerSubmit: 'Зарегистрироваться',
registering: 'Регистрация…',
registerSentTitle: 'Проверьте почту',
registerSentBody: 'Мы отправили письмо с подтверждением. Откройте ссылку, чтобы завершить подтверждение (откроется консоль), затем вернитесь и войдите.',
resend: 'Отправить письмо ещё раз',
resending: 'Отправка…',
resent: 'Отправлено повторно.',
resetTitle: 'Сброс пароля',
resetSubtitle: 'Мы отправим код сброса на вашу почту.',
resetSend: 'Отправить код',
resetSending: 'Отправка…',
resetSentBody: 'Если почта зарегистрирована, код сброса уже в пути. Введите его ниже вместе с новым паролем.',
codeLabel: 'Код сброса',
newPasswordLabel: 'Новый пароль',
resetConfirm: 'Задать новый пароль',
resetConfirming: 'Сохранение…',
resetDoneBody: 'Пароль обновлён. Войдите с новым паролем.',
backToSignIn: 'Назад ко входу',
sessionExpiredNotice: 'Сеанс истёк. Войдите снова.',
errorInvalidCredentials: 'Неверная почта или пароль.',
errorRateLimited: 'Слишком много попыток. Подождите и повторите.',
errorNetwork: 'Не удаётся связаться с сервером. Проверьте сеть и повторите.',
errorServer: 'Ошибка сервера. Повторите позже.',
errorWeakPassword: 'Пароль не соответствует требованиям.',
errorResetCode: 'Код сброса недействителен или истёк.',
errorInvalidSite: 'Недопустимый адрес сервера (требуется https).',
lockedTitle: 'Эта машина привязана к другому аккаунту',
lockedBody: email => `Эта машина привязана к ${email}. Сначала отвяжите её там, затем входите с другим аккаунтом.`,
lockedBodyUnknown: 'Эта машина привязана к другому аккаунту. Сначала отвяжите её.',
bindingInvalidBody: 'Данные привязки машины повреждены. Восстановите установку Hermes agent перед входом.'
}
})
+50
View File
@@ -3838,4 +3838,54 @@ export interface Translations {
toggle: (open: boolean) => string
}
}
userAccount: {
checking: string
title: string
subtitle: string
siteLabel: string
sitePlaceholder: string
emailLabel: string
emailPlaceholder: string
passwordLabel: string
passwordPlaceholder: string
signIn: string
signingIn: string
forgotPassword: string
register: string
registerTitle: string
registerSubtitle: string
confirmPasswordLabel: string
passwordMismatch: string
registerSubmit: string
registering: string
registerSentTitle: string
registerSentBody: string
resend: string
resending: string
resent: string
resetTitle: string
resetSubtitle: string
resetSend: string
resetSending: string
resetSentBody: string
codeLabel: string
newPasswordLabel: string
resetConfirm: string
resetConfirming: string
resetDoneBody: string
backToSignIn: string
sessionExpiredNotice: string
errorInvalidCredentials: string
errorRateLimited: string
errorNetwork: string
errorServer: string
errorWeakPassword: string
errorResetCode: string
errorInvalidSite: string
lockedTitle: string
lockedBody: (email: string) => string
lockedBodyUnknown: string
bindingInvalidBody: string
}
}
+51
View File
@@ -3608,5 +3608,56 @@ export const zhHant = defineLocale({
description: '顯示行動裝置側邊欄。',
toggle: open => `${open ? '顯示' : '隱藏'}側邊欄`
}
},
userAccount: {
checking: '正在驗證工作階段…',
title: '登入 Hermes',
subtitle: '桌面端、App 與控制台,同一個帳號。',
siteLabel: '伺服器',
sitePlaceholder: 'https://relay.example.com',
emailLabel: '電子郵件',
emailPlaceholder: 'you@example.com',
passwordLabel: '密碼',
passwordPlaceholder: '你的密碼',
signIn: '登入',
signingIn: '正在登入…',
forgotPassword: '忘記密碼?',
register: '註冊帳號',
registerTitle: '建立帳號',
registerSubtitle: '同一套憑據通用於桌面端、App 與控制台。',
confirmPasswordLabel: '確認密碼',
passwordMismatch: '兩次輸入的密碼不一致。',
registerSubmit: '註冊',
registering: '正在註冊…',
registerSentTitle: '請查收郵件',
registerSentBody: '驗證郵件已發送。開啟郵件中的連結完成確認(連結在控制台開啟),然後回來登入。',
resend: '重發郵件',
resending: '正在重發…',
resent: '已重發。',
resetTitle: '找回密碼',
resetSubtitle: '我們會把重設碼寄到你的信箱。',
resetSend: '發送重設碼',
resetSending: '正在發送…',
resetSentBody: '如果該信箱已註冊,重設碼正在路上。請在下方輸入重設碼和新密碼。',
codeLabel: '重設碼',
newPasswordLabel: '新密碼',
resetConfirm: '設定新密碼',
resetConfirming: '正在設定…',
resetDoneBody: '密碼已更新,請用新密碼登入。',
backToSignIn: '返回登入',
sessionExpiredNotice: '工作階段已過期,請重新登入。',
errorInvalidCredentials: '信箱或密碼錯誤。',
errorRateLimited: '嘗試次數過多,請稍後再試。',
errorNetwork: '無法連接伺服器,請檢查網路後重試。',
errorServer: '伺服器出錯,請稍後再試。',
errorWeakPassword: '密碼不符合要求。',
errorResetCode: '重設碼無效或已過期。',
errorInvalidSite: '伺服器位址無效(需要 https)。',
lockedTitle: '此機器已綁定其他帳號',
lockedBody: email => `此機器已綁定 ${email}。請先在原帳號解綁,再用其他帳號登入。`,
lockedBodyUnknown: '此機器已綁定其他帳號,請先解綁再登入。',
bindingInvalidBody: '機器綁定資料已損毀,請先修復 Hermes agent 安裝再登入。'
}
})
+51
View File
@@ -4413,5 +4413,56 @@ export const zh = defineLocale({
description: '显示移动端侧边栏。',
toggle: open => `${open ? '显示' : '隐藏'}侧边栏`
}
},
userAccount: {
checking: '正在验证会话…',
title: '登录 Hermes',
subtitle: '桌面端、App 与控制台,同一个账号。',
siteLabel: '服务器',
sitePlaceholder: 'https://relay.example.com',
emailLabel: '邮箱',
emailPlaceholder: 'you@example.com',
passwordLabel: '密码',
passwordPlaceholder: '你的密码',
signIn: '登录',
signingIn: '正在登录…',
forgotPassword: '忘记密码?',
register: '注册账号',
registerTitle: '创建账号',
registerSubtitle: '同一套凭据通用于桌面端、App 与控制台。',
confirmPasswordLabel: '确认密码',
passwordMismatch: '两次输入的密码不一致。',
registerSubmit: '注册',
registering: '正在注册…',
registerSentTitle: '请查收邮件',
registerSentBody: '验证邮件已发送。打开邮件中的链接完成确认(链接在控制台打开),然后回来登录。',
resend: '重发邮件',
resending: '正在重发…',
resent: '已重发。',
resetTitle: '找回密码',
resetSubtitle: '我们会把重置码发到你的邮箱。',
resetSend: '发送重置码',
resetSending: '正在发送…',
resetSentBody: '如果该邮箱已注册,重置码正在路上。请在下方输入重置码和新密码。',
codeLabel: '重置码',
newPasswordLabel: '新密码',
resetConfirm: '设置新密码',
resetConfirming: '正在设置…',
resetDoneBody: '密码已更新,请用新密码登录。',
backToSignIn: '返回登录',
sessionExpiredNotice: '会话已过期,请重新登录。',
errorInvalidCredentials: '邮箱或密码错误。',
errorRateLimited: '尝试次数过多,请稍后再试。',
errorNetwork: '无法连接服务器,请检查网络后重试。',
errorServer: '服务器出错,请稍后再试。',
errorWeakPassword: '密码不符合要求。',
errorResetCode: '重置码无效或已过期。',
errorInvalidSite: '服务器地址无效(需要 https)。',
lockedTitle: '该机器已绑定其他账号',
lockedBody: email => `该机器已绑定 ${email}。请先在原账号解绑,再用其他账号登录。`,
lockedBodyUnknown: '该机器已绑定其他账号,请先解绑再登录。',
bindingInvalidBody: '机器绑定数据已损坏,请先修复 Hermes agent 安装再登录。'
}
})
+61 -1
View File
@@ -43,6 +43,38 @@ export type UserAccountState =
export const $userAccount = atom<UserAccountState>({ status: 'unknown' })
export type HermesUserAccountOpResult = { ok: true } | { ok: false; code: string }
export type HermesUserAccountLoginResult =
| { ok: true; site: string; profile: HermesUserAccountProfile }
| { ok: false; code: string; binderEmail?: string | null }
/** 登录/注册/找回的可预期失败码(renderer 按码上文案,绝不回显服务端原文)。 */
export type UserAccountFailureCode =
| 'account_locked'
| 'binding_state_invalid'
| 'invalid_login_request'
| 'invalid_credentials'
| 'invalid_site'
| 'login_rate_limited'
| 'machine_bound_to_other'
| 'network'
| 'registration_rate_limited'
| 'reset_code_invalid_or_expired'
| 'server'
| 'weak_password'
export class UserAccountFailure extends Error {
readonly code: string
readonly binderEmail: string | null
constructor(code: string, binderEmail: string | null = null) {
super(code)
this.code = code
this.binderEmail = binderEmail
}
}
function bridge() {
const api = window.hermesDesktop?.userAccount
@@ -51,12 +83,40 @@ function bridge() {
return api
}
/** 登录。失败抛带 .code 的 Error(invalid_credentials / login_rate_limited / network / server)。 */
/** 登录。失败抛 UserAccountFailure(.code 见 UserAccountFailureCode;U-5 锁带 binderEmail)。 */
export async function userAccountLogin(site: string, email: string, password: string): Promise<void> {
const result = await bridge().login({ site, email, password })
if (!result.ok) {
throw new UserAccountFailure(result.code, result.binderEmail ?? null)
}
$userAccount.set({ status: 'signed_in', site: result.site, offline: false, profile: result.profile, profileAsOf: null })
}
async function runAccountOp(op: () => Promise<HermesUserAccountOpResult>): Promise<void> {
const result = await op()
if (!result.ok) {throw new UserAccountFailure(result.code)}
}
/** 注册第一步:投递验证邮件(确认链接落在控制台完成,§21 DB-T2)。 */
export function userAccountRegisterStart(site: string, email: string, password: string): Promise<void> {
return runAccountOp(() => bridge().registerStart({ site, email, password }))
}
export function userAccountRegisterResend(site: string, email: string): Promise<void> {
return runAccountOp(() => bridge().registerResend({ site, email }))
}
export function userAccountResetRequest(site: string, email: string): Promise<void> {
return runAccountOp(() => bridge().resetRequest({ site, email }))
}
export function userAccountResetConfirm(site: string, email: string, code: string, password: string): Promise<void> {
return runAccountOp(() => bridge().resetConfirm({ site, email, code, password }))
}
export async function userAccountLogout(): Promise<void> {
await bridge().logout()
$userAccount.set({ status: 'signed_out' })