diff --git a/apps/desktop/electron/machine-binding.test.ts b/apps/desktop/electron/machine-binding.test.ts new file mode 100644 index 0000000000..5392bf9c04 --- /dev/null +++ b/apps/desktop/electron/machine-binding.test.ts @@ -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) +}) diff --git a/apps/desktop/electron/machine-binding.ts b/apps/desktop/electron/machine-binding.ts new file mode 100644 index 0000000000..abc1e973f3 --- /dev/null +++ b/apps/desktop/electron/machine-binding.ts @@ -0,0 +1,66 @@ +/** + * machine-binding.ts — 本机机器绑定(mercury-relay 插件 state.json)的只读视图。 + * + * U-5 机器-用户锁的事实来源:插件绑定后把 ``relay_binding`` 写进 + * ``/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) +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 28f4e5b59b..13d9deff4d 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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() + 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), () => diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 1224b69a0b..a1f0f04034 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -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), diff --git a/apps/desktop/electron/relay-account.ts b/apps/desktop/electron/relay-account.ts index 743e7fdcc1..84d2311e02 100644 --- a/apps/desktop/electron/relay-account.ts +++ b/apps/desktop/electron/relay-account.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/apps/desktop/electron/user-account-ipc.test.ts b/apps/desktop/electron/user-account-ipc.test.ts index 48eeb46603..8722ec52e2 100644 --- a/apps/desktop/electron/user-account-ipc.test.ts +++ b/apps/desktop/electron/user-account-ipc.test.ts @@ -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' + }) +}) diff --git a/apps/desktop/electron/user-account-ipc.ts b/apps/desktop/electron/user-account-ipc.ts index 74aa0877f4..9dbba3e25f 100644 --- a/apps/desktop/electron/user-account-ipc.ts +++ b/apps/desktop/electron/user-account-ipc.ts @@ -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) => 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 | 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) + } + }) } diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index da8e2f1e00..edc08b3bed 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -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 }) { {!isAuxiliaryWindow() && } {!isAuxiliaryWindow() && } + {/* §21 强制登录门:signed_out 时盖全屏;只盖 UI,不断机器通道(U-1)。 */} + {!isAuxiliaryWindow() && } {!isAuxiliaryWindow() && ( - openSessionForOwner(sessionId, navigate, owner)} /> + openSessionForOwner(sessionId, navigate, owner)} requestGateway={requestGateway} /> openSession(sessionId, navigate)} /> = {}) { + const calls = { login: [] as any[], registerStart: [] as any[], resetRequest: [] as any[], resetConfirm: [] as any[] } + + const api = { + login: vi.fn(async (payload: any): Promise => { + 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() + 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() + 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() + 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() + 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() + 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() + 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() + await waitFor(() => expect($userAccount.get().status).toBe('signed_out')) + expect(screen.getByText('Sign in to Hermes')).toBeTruthy() + }) +}) + +void userAccountRefreshStatus diff --git a/apps/desktop/src/components/user-account-gate.tsx b/apps/desktop/src/components/user-account-gate.tsx new file mode 100644 index 0000000000..eadd81fa35 --- /dev/null +++ b/apps/desktop/src/components/user-account-gate.tsx @@ -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('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 ( +
+
+ {account.status === 'unknown' ? ( +
+ + {copy.checking} +
+ ) : page === 'login' ? ( + + ) : page === 'register' ? ( + + ) : ( + + )} +
+
+ ) +} + +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(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 ( +
+

{lock.invalid ? copy.errorServer : copy.lockedTitle}

+

+ {lock.invalid ? copy.bindingInvalidBody : lock.binderEmail ? copy.lockedBody(lock.binderEmail) : copy.lockedBodyUnknown} +

+ +
+ ) + } + + return ( +
+
+

{copy.title}

+

{copy.subtitle}

+
+ {sessionExpired &&

{copy.sessionExpiredNotice}

} + + + + {error &&

{error}

} + +
+ + +
+
+ ) +} + +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(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 ( +
+

{copy.registerSentTitle}

+

{copy.registerSentBody}

+ + +
+ ) + } + + return ( +
+
+

{copy.registerTitle}

+

{copy.registerSubtitle}

+
+ + + + + {error &&

{error}

} + + +
+ ) +} + +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(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 ( +
+

{copy.resetDoneBody}

+ +
+ ) + } + + if (!sent) { + return ( +
+
+

{copy.resetTitle}

+

{copy.resetSubtitle}

+
+ + + {error &&

{error}

} + + +
+ ) + } + + return ( +
+

{copy.resetSentBody}

+ + + {error &&

{error}

} + + +
+ ) +} diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index d9e3e0dba9..e2ce7106a8 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -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 status: () => Promise me: () => Promise<{ profile: HermesUserAccountProfile }> logout: () => Promise<{ ok: boolean }> + registerStart: (payload: { site: string; email: string; password: string }) => Promise + registerResend: (payload: { site: string; email: string }) => Promise + resetRequest: (payload: { site: string; email: string }) => Promise + resetConfirm: (payload: { site: string; email: string; code: string; password: string }) => Promise } // Registry-scoped backend resolution: dial (connectionId, profile). An // empty/local connectionId delegates to the legacy getConnection path. diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index d3af6e3fd3..325ec35493 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -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 قبل تسجيل الدخول.' } }) + diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 0273bb313a..d93ba09ec1 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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.' } } + diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index fc22f2c76b..c59c80a34f 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -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 エージェントのインストールを修復してください。' } }) + diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts index 41d3ac80e2..048d808440 100644 --- a/apps/desktop/src/i18n/ru.ts +++ b/apps/desktop/src/i18n/ru.ts @@ -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 перед входом.' } }) + diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 17250cd76d..77675a545a 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 + } } diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 14c2de425a..1d94e7f2e9 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -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 安裝再登入。' } }) + diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 6812b62498..d8be3c1fb9 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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 安装再登录。' } }) + diff --git a/apps/desktop/src/store/user-account.ts b/apps/desktop/src/store/user-account.ts index 523903d569..603356b826 100644 --- a/apps/desktop/src/store/user-account.ts +++ b/apps/desktop/src/store/user-account.ts @@ -43,6 +43,38 @@ export type UserAccountState = export const $userAccount = atom({ 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 { 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): Promise { + 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 { + return runAccountOp(() => bridge().registerStart({ site, email, password })) +} + +export function userAccountRegisterResend(site: string, email: string): Promise { + return runAccountOp(() => bridge().registerResend({ site, email })) +} + +export function userAccountResetRequest(site: string, email: string): Promise { + return runAccountOp(() => bridge().resetRequest({ site, email })) +} + +export function userAccountResetConfirm(site: string, email: string, code: string, password: string): Promise { + return runAccountOp(() => bridge().resetConfirm({ site, email, code, password })) +} + export async function userAccountLogout(): Promise { await bridge().logout() $userAccount.set({ status: 'signed_out' })