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:
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Vendored
+2
@@ -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.
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user