feat(desktop): 登录记住用户名密码 + 用户名不可改(2026-09-20 用户裁定)

记住凭据:登录成功自动把 {site, email, password} 存进 safeStorage 独立槽
(复刻 freemodel2api remembered 模式),登录页启动预填;登出不清,
U-6 解绑全清时一并抹掉。renderer 只有读口(rememberedLoad)。

用户名不可改:/me 资料页 username 从可编辑表单降为只读展示行,
可改字段只剩 nickname/phone。

测试:electron 侧 login 自动存/登出保留/换号覆盖/解绑抹除;renderer 侧
预填、读取失败兜底、username 只读。聚焦 59 passed;全量 10408 passed,
仅 4 个既有失败(clean HEAD 同现,与本次无关)。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-09-20 17:39:12 +08:00
parent 5832cbbe9c
commit 3272102fe9
10 changed files with 235 additions and 20 deletions
+2 -1
View File
@@ -47,7 +47,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
resetConfirm: payload => ipcRenderer.invoke('hermes:account:reset-confirm', payload),
bindingStatus: () => ipcRenderer.invoke('hermes:account:binding-status'),
bindMachine: () => ipcRenderer.invoke('hermes:account:bind-machine'),
unbindMachine: () => ipcRenderer.invoke('hermes:account:unbind-machine')
unbindMachine: () => ipcRenderer.invoke('hermes:account:unbind-machine'),
rememberedLoad: () => ipcRenderer.invoke('hermes:account:remembered:load')
},
// Registry-scoped backend resolution: { connectionId, profile } → descriptor.
getConnectionFor: payload => ipcRenderer.invoke('hermes:connection:for', payload),
+66 -1
View File
@@ -4,7 +4,7 @@ import { test } from 'vitest'
import type { FetchLike } from './relay-account'
import { registerUserAccountIpc } from './user-account-ipc'
import { loadUserAccount, persistUserAccount, type UserAccountStoreIo } from './user-account-store'
import { loadRememberedLogin, loadUserAccount, persistUserAccount, type UserAccountStoreIo } from './user-account-store'
function fakeIo() {
let fileText: string | null = null
@@ -837,3 +837,68 @@ test('DB-T6:恢复出的会话在首次 status 补一次自动绑定,之后
await handlers.get('hermes:account:status')!(null)
assert.deepEqual(pluginCalls, ['identity/login'])
})
// ---------- 记住凭据:登录自动存,登出不清,U-6 全清抹掉 -----------------------
test('login 成功自动记住凭据:加密落盘、remembered:load 可取;磁盘无明文密码', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io, fileText } = fakeIo()
const { fetcher } = loginOkFetcher()
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
assert.deepEqual(await handlers.get('hermes:account:remembered:load')!(null), null)
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u@example.com', password: 'pw-secret' })
assert.equal(fileText()!.includes('pw-secret'), false)
assert.deepEqual(loadRememberedLogin(io), { site: 'https://r', email: 'u@example.com', password: 'pw-secret' })
assert.deepEqual(await handlers.get('hermes:account:remembered:load')!(null), {
site: 'https://r',
email: 'u@example.com',
password: 'pw-secret'
})
})
test('登出不清记住凭据;换账号登录覆盖旧记录', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'a@example.com', password: 'pw-a' })
await handlers.get('hermes:account:logout')!(null)
assert.deepEqual(loadRememberedLogin(io), { site: 'https://r', email: 'a@example.com', password: 'pw-a' })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'b@example.com', password: 'pw-b' })
assert.deepEqual(loadRememberedLogin(io), { site: 'https://r', email: 'b@example.com', password: 'pw-b' })
})
test('U-6 解绑全清:记住凭据一并抹掉', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = 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: [{ id: 'inst-1' }] } }}
if (url.includes('/installations/')) {return { status: 202, body: { status: 'requested' } }}
return { status: 200, body: {} }
})
register({
ipcMain,
io,
fetcher,
readMachineBindingState: () => BINDING_STATE,
callPluginApi: async () => ({ state: 'purged' })
})
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
assert.notEqual(loadRememberedLogin(io), null)
assert.deepEqual(await handlers.get('hermes:account:unbind-machine')!(null), { ok: true })
assert.equal(loadRememberedLogin(io), null)
assert.deepEqual(await handlers.get('hermes:account:remembered:load')!(null), null)
})
+15
View File
@@ -40,7 +40,9 @@ import {
relayAccountUpdateProfile
} from './relay-account'
import {
loadRememberedLogin,
loadUserAccount,
persistRememberedLogin,
persistUserAccount,
type StoredUserAccount,
type UserAccountStoreIo
@@ -200,6 +202,8 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
const profile = await relayAccountFetchMe(site, session.accessToken, fetcher)
store({ session, profile, profileAsOf: new Date().toISOString() })
// 记住凭据:登录成功即刷新 remembered 槽(登出不清,下次启动预填)。
persistRememberedLogin({ site, email, password }, io)
// DB-T6:登录成功即自动绑定本机(未绑定时);失败不阻塞登录。
await autoBind()
@@ -446,6 +450,9 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
store(null)
}
// U-6 全清含记住的凭据——机器回裸态后登录页不再预填本机旧账号。
persistRememberedLogin(null, io)
lastBind = null
autoBindAttempted = false
@@ -515,4 +522,12 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
return failure(error)
}
})
/**
* 登录表单预填:返回最近一次成功登录记住的 {site, email, password}。
* 写入只发生在 login 成功(自动)与 unbind 全清(抹除)——renderer 无写口。
*/
ipcMain.handle('hermes:account:remembered:load', async () => {
return loadRememberedLogin(io)
})
}
@@ -29,6 +29,7 @@ export interface UserAccountStoreIo {
}
const SLOT = 'user-account'
const REMEMBERED_SLOT = 'user-account-remembered'
function readStore(io: UserAccountStoreIo): Record<string, any> {
try {
@@ -130,3 +131,70 @@ export function loadUserAccount(io: UserAccountStoreIo): StoredUserAccount | nul
return null
}
}
export interface RememberedUserAccountLogin {
site: string
email: string
password: string
}
/**
* 登录表单「记住凭据」槽:与会话槽分开存,登出不清(下次启动预填),
* U-6 解绑全清时由调用方一并抹掉。同样整体 safeStorage 加密落盘。
*/
export function persistRememberedLogin(creds: RememberedUserAccountLogin | null, io: UserAccountStoreIo): void {
const store = readStore(io)
if (creds) {
const secret = io.encrypt(JSON.stringify(creds))
if (!secret) {
throw new Error('Secure storage returned no encrypted payload; refusing to overwrite remembered login.')
}
store[REMEMBERED_SLOT] = secret
} else {
delete store[REMEMBERED_SLOT]
}
try {
io.writeStoreText(JSON.stringify(store))
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
io.rememberLog?.(`[user-account] failed to persist remembered login: ${detail}`)
}
}
export function loadRememberedLogin(io: UserAccountStoreIo): RememberedUserAccountLogin | null {
const secret = readStore(io)[REMEMBERED_SLOT]
if (!secret) {return null}
try {
const plaintext = io.decrypt(secret)
if (!plaintext) {return null}
const parsed = JSON.parse(plaintext)
if (
!parsed ||
typeof parsed !== 'object' ||
typeof parsed.site !== 'string' ||
typeof parsed.email !== 'string' ||
typeof parsed.password !== 'string' ||
!parsed.site ||
!parsed.email ||
!parsed.password
) {
return null
}
return { site: parsed.site, email: parsed.email, password: parsed.password }
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
io.rememberLog?.(`[user-account] failed to load remembered login: ${detail}`)
return null
}
}
+9 -17
View File
@@ -3,9 +3,9 @@
*
* 资料卡:头像字母章 + U-4 展示名 + 邮箱(登录标识,永不可改)+ 套餐/
* 周期/数据时间;离线(U-3)标 amber 横幅并注明缓存数据时间。
* 可改字段只有服务端 PROFILE_FIELDS 三件套(username/nickname/phone),
* 只提交改动项;保存失败按 code 上文案。登出入口也在此——登出≠解绑
* (U-1),只清用户会话,门随即落回。
* 可改字段只有 nickname/phone 两项(username 随账号创建后不可改,
* 2026-09-20 用户裁定),只提交改动项;保存失败按 code 上文案。
* 登出入口也在此——登出≠解绑(U-1),只清用户会话,门随即落回。
*
* 路由走 ROUTES_AREA 贡献(workspace 页),模块导入时注册,wiring 引入。
*/
@@ -53,7 +53,6 @@ export function MePage() {
const { t } = useI18n()
const copy = t.userAccount.me
const account = useStore($userAccount)
const [username, setUsername] = useState<string | null>(null)
const [nickname, setNickname] = useState<string | null>(null)
const [phone, setPhone] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
@@ -69,20 +68,16 @@ export function MePage() {
// null = 未碰过,跟随快照;保存成功后快照刷新,草稿位随之归零。
const draft = {
username: username ?? profile.username,
nickname: nickname ?? profile.nickname,
phone: phone ?? profile.phone
}
const dirty =
draft.username !== profile.username || draft.nickname !== profile.nickname || draft.phone !== profile.phone
const dirty = draft.nickname !== profile.nickname || draft.phone !== profile.phone
function edit(field: 'username' | 'nickname' | 'phone', value: string) {
function edit(field: 'nickname' | 'phone', value: string) {
setSaved(false)
setError(null)
if (field === 'username') {setUsername(value)}
if (field === 'nickname') {setNickname(value)}
if (field === 'phone') {setPhone(value)}
@@ -93,8 +88,6 @@ export function MePage() {
const patch: HermesUserAccountProfilePatch = {}
if (draft.username !== profile!.username) {patch.username = draft.username}
if (draft.nickname !== profile!.nickname) {patch.nickname = draft.nickname}
if (draft.phone !== profile!.phone) {patch.phone = draft.phone}
@@ -105,7 +98,6 @@ export function MePage() {
try {
await userAccountUpdateProfile(patch)
setUsername(null)
setNickname(null)
setPhone(null)
setSaved(true)
@@ -147,10 +139,6 @@ export function MePage() {
<span>{copy.nicknameLabel}</span>
<Input maxLength={64} onChange={event => edit('nickname', event.target.value)} value={draft.nickname} />
</label>
<label className="block space-y-1 text-sm">
<span>{copy.usernameLabel}</span>
<Input maxLength={64} onChange={event => edit('username', event.target.value)} value={draft.username} />
</label>
<label className="block space-y-1 text-sm">
<span>{copy.phoneLabel}</span>
<Input maxLength={32} onChange={event => edit('phone', event.target.value)} value={draft.phone} />
@@ -169,6 +157,10 @@ export function MePage() {
<dt className="text-muted-foreground">{copy.emailLabel}</dt>
<dd className="truncate">{profile.email}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{copy.usernameLabel}</dt>
<dd className="truncate">{profile.username || '—'}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{copy.planLabel}</dt>
<dd>{profile.planId || '—'}</dd>
+11 -1
View File
@@ -90,11 +90,21 @@ describe('MePage', () => {
render(<MePage />)
expect((screen.getByText('Save') as HTMLButtonElement).disabled).toBe(true)
fireEvent.change(screen.getByDisplayValue('u-hermes'), { target: { value: 'u-new' } })
fireEvent.change(screen.getByDisplayValue('13800000000'), { target: { value: '13900000000' } })
fireEvent.click(screen.getByText('Save'))
await screen.findByText('Profile editing is disabled on this server.')
})
it('用户名不可改:只读展示,不进表单也无 patch', () => {
const api = stubBridge()
signedIn()
render(<MePage />)
// username 以只读行展示,而非可编辑输入框。
expect(screen.queryByDisplayValue('u-hermes')).toBeNull()
expect(screen.getByText('u-hermes')).toBeTruthy()
expect(api.updateProfile).not.toHaveBeenCalled()
})
it('登出:走 bridge 并落 signed_out(登出≠解绑,门自落)', async () => {
const api = stubBridge()
signedIn()
@@ -43,6 +43,7 @@ function stubBridge(overrides: Record<string, unknown> = {}) {
return { ok: true }
}),
rememberedLoad: vi.fn(async () => null),
...overrides
}
@@ -174,6 +175,33 @@ describe('UserAccountGate', () => {
await waitFor(() => expect($userAccount.get().status).toBe('signed_out'))
expect(screen.getByText('Sign in to Hermes')).toBeTruthy()
})
it('记住凭据:登录页预填 remembered 的站点/邮箱/密码', async () => {
stubBridge({
rememberedLoad: vi.fn(async () => ({ site: 'https://r2', email: 'saved@example.com', password: 'savedpw' }))
})
render(<UserAccountGate />)
await settleStatus()
await waitFor(() => {
expect((screen.getByPlaceholderText('you@example.com') as HTMLInputElement).value).toBe('saved@example.com')
})
expect((screen.getByPlaceholderText('https://relay.example.com') as HTMLInputElement).value).toBe('https://r2')
expect((screen.getByPlaceholderText('Your password') as HTMLInputElement).value).toBe('savedpw')
})
it('记住凭据读取失败不影响手动登录', async () => {
const { api } = stubBridge({ rememberedLoad: vi.fn(async () => Promise.reject(new Error('keychain locked'))) })
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: 'u@example.com' } })
fireEvent.change(screen.getByPlaceholderText('Your password'), { target: { value: 'pw' } })
fireEvent.click(screen.getByText('Sign in'))
await waitFor(() => expect($userAccount.get().status).toBe('signed_in'))
expect(api.login).toHaveBeenCalled()
})
})
void userAccountRefreshStatus
@@ -26,6 +26,7 @@ import {
userAccountRefreshStatus,
userAccountRegisterResend,
userAccountRegisterStart,
userAccountRememberedLoad,
userAccountResetConfirm,
userAccountResetRequest
} from '@/store/user-account'
@@ -122,6 +123,28 @@ function LoginPage({ sessionExpired, onNavigate }: { sessionExpired: boolean; on
const [error, setError] = useState<string | null>(null)
const [lock, setLock] = useState<{ binderEmail: string | null; invalid: boolean } | null>(null)
// 记住凭据:最近一次成功登录的 {site, email, password} 预填进表单(登出不清)。
useEffect(() => {
let cancelled = false
userAccountRememberedLoad()
.then(remembered => {
if (cancelled || !remembered) {return}
if (!site) {setSite(remembered.site)}
setEmail(current => current || remembered.email)
setPassword(current => current || remembered.password)
})
.catch(() => {
// 预填失败(桥不可用/钥匙串锁住)按无记住凭据处理,不影响手动登录。
})
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
async function submit(event: FormEvent) {
event.preventDefault()
setBusy(true)
+2
View File
@@ -62,6 +62,8 @@ declare global {
bindingStatus: () => Promise<HermesUserAccountBindingStatus>
bindMachine: () => Promise<HermesUserAccountBindResult>
unbindMachine: () => Promise<{ ok: boolean; code?: string }>
// 登录表单预填:最近一次成功登录记住的凭据(登出不清,U-6 解绑才抹)。
rememberedLoad: () => Promise<{ site: string; email: string; password: string } | null>
}
// Registry-scoped backend resolution: dial (connectionId, profile). An
// empty/local connectionId delegates to the legacy getConnection path.
+11
View File
@@ -111,6 +111,17 @@ function bridge() {
return api
}
/** 登录表单预填:最近一次成功登录记住的凭据(登出不清;U-6 解绑全清时已抹)。 */
export interface RememberedUserAccountLogin {
site: string
email: string
password: string
}
export function userAccountRememberedLoad(): Promise<RememberedUserAccountLogin | null> {
return bridge().rememberedLoad()
}
/** 登录。失败抛 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 })