From 3272102fe9f7f2ae652bf78dfce18d81267e3fa5 Mon Sep 17 00:00:00 2001 From: m4 Date: Sun, 20 Sep 2026 17:39:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20=E7=99=BB=E5=BD=95=E8=AE=B0?= =?UTF-8?q?=E4=BD=8F=E7=94=A8=E6=88=B7=E5=90=8D=E5=AF=86=E7=A0=81=20+=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=90=8D=E4=B8=8D=E5=8F=AF=E6=94=B9=EF=BC=88?= =?UTF-8?q?2026-09-20=20=E7=94=A8=E6=88=B7=E8=A3=81=E5=AE=9A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记住凭据:登录成功自动把 {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 --- apps/desktop/electron/preload.ts | 3 +- .../desktop/electron/user-account-ipc.test.ts | 67 +++++++++++++++++- apps/desktop/electron/user-account-ipc.ts | 15 ++++ apps/desktop/electron/user-account-store.ts | 68 +++++++++++++++++++ apps/desktop/src/app/me/index.tsx | 26 +++---- apps/desktop/src/app/me/me-page.test.tsx | 12 +++- .../src/components/user-account-gate.test.tsx | 28 ++++++++ .../src/components/user-account-gate.tsx | 23 +++++++ apps/desktop/src/global.d.ts | 2 + apps/desktop/src/store/user-account.ts | 11 +++ 10 files changed, 235 insertions(+), 20 deletions(-) diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index b2d598b231..e1e4c0f75d 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -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), diff --git a/apps/desktop/electron/user-account-ipc.test.ts b/apps/desktop/electron/user-account-ipc.test.ts index e12a81aefd..10659c5b11 100644 --- a/apps/desktop/electron/user-account-ipc.test.ts +++ b/apps/desktop/electron/user-account-ipc.test.ts @@ -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) +}) diff --git a/apps/desktop/electron/user-account-ipc.ts b/apps/desktop/electron/user-account-ipc.ts index ea68eb9521..145b462701 100644 --- a/apps/desktop/electron/user-account-ipc.ts +++ b/apps/desktop/electron/user-account-ipc.ts @@ -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) + }) } diff --git a/apps/desktop/electron/user-account-store.ts b/apps/desktop/electron/user-account-store.ts index 12ebb4d5e7..9faa8fadc2 100644 --- a/apps/desktop/electron/user-account-store.ts +++ b/apps/desktop/electron/user-account-store.ts @@ -29,6 +29,7 @@ export interface UserAccountStoreIo { } const SLOT = 'user-account' +const REMEMBERED_SLOT = 'user-account-remembered' function readStore(io: UserAccountStoreIo): Record { 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 + } +} diff --git a/apps/desktop/src/app/me/index.tsx b/apps/desktop/src/app/me/index.tsx index 32074879a7..22443ae765 100644 --- a/apps/desktop/src/app/me/index.tsx +++ b/apps/desktop/src/app/me/index.tsx @@ -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(null) const [nickname, setNickname] = useState(null) const [phone, setPhone] = useState(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() { {copy.nicknameLabel} edit('nickname', event.target.value)} value={draft.nickname} /> -