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:
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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), () =>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+6
-2
@@ -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.
|
||||
|
||||
@@ -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 قبل تسجيل الدخول.'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 エージェントのインストールを修復してください。'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 перед входом.'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 安裝再登入。'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 安装再登录。'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
Reference in New Issue
Block a user