Files
MailUI4Agents/client/electron/src/stores/appearanceSync.ts
JianFeeeee 18b148e476 跨端: fix(客户端) 换身份必须清全部账号数据 + 鸿蒙管理台门禁改三态
两份审查报告(`docs/reviews/electron-gui-review.md` /
`harmony-client-review.md`)里两条**数据隔离**缺陷。

## ① 换身份不清数据 ⇒ 在新账号的界面下显示旧账号的邮件

`setActive` 之后 `api/config` 单例里的 API_BASE 与 bearer 就翻到了新账号,
于是此后每个请求都带**新账号**的凭证。而各 store 里还留着**旧**账号的:
  · mailStore.sent / currentMail
  · sessionStore.sessions / currentSession / currentSessionMails
  · contactStore.contacts / archivedContacts

⇒ 肉眼完全看不出来(不报错、不空屏),而**从旧视图发出的写操作**
(归档 / 转发 / 批准权限)改的是**新账号**。

新增 `src/lib/resetAccountData.ts` —— **一处实现,三个入口都调它**:

    ① 主动切账号(AccountSwitcher.pick)
    ② 登出(authStore.logout)
    ③ 任意接口 401(api/client.ts 的 unauthorized 回调 → markAnonymous)

只在 ① 里清是最容易漏的那种做法:② 和 ③ 各自还会重新泄露一次,
而它们都不在切换账号的代码路径上,grep 也找不到。
**身份变化有三条路径,清空也该有三条。**

★ 只碰**数据** store;`uiStore` 的 reset 仍由 App.tsx 负责
  (它还要复位窄屏分栏、写信态那些纯界面状态)。

判据:`test/stores/resetAccountData.test.ts`(4 格)。

## ② 鸿蒙管理台门禁**失败开放**(fail-open)

`AdminUsersPage.ets` 原来的条件是 `roleKnown && !this.isAdmin`,
于是 `roleKnown === false`(loadRole() 失败、**身份还没读到**)
落进 else 分支 ⇒ **把完整管理台整个渲染出来**。
一次网络抖动 = 管理入口对所有人可见。

讽刺的是该文件自己的头注释写的就是正确规则
(「不能把读不到当成是管理员」)—— 代码做的正是这条注释禁止的事。

⇒ 改三态:`!roleKnown` 显示「正在确认身份…」、`!isAdmin` 显示墙、
  否则管理台。

服务端 `middleware/user.go` 的 `AdminOnly` 仍在,所以**不是越权**;
但非管理员会看到完整用户列表、建号表单、改密入口 ——
属于客户端信息泄露 + 无意义的失败请求风暴。
2026-09-28 08:46:01 +08:00

186 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { create } from 'zustand';
import { activeAuth } from '../api/config';
import {
payloadFromLocal,
pullAppearance,
pushAppearance,
uploadAppearanceImage,
type AppearanceAuth
} from '../lib/appearance';
import { useAccountStore, isAggregate } from './accountStore';
import { useBackgroundStore } from './backgroundStore';
import { useThemeStore } from './themeStore';
/**
* 外观同步:服务端是权威,本地是缓存。
*
* 三条不变量(都有判据):
* 1. **登录后以服务端为准**(换设备/换浏览器能拿回来);
* 2. **本地改动会推上去**(换设备再看还是这个外观);
* 3. **拉下来的东西不会再推回去**(否则就是自己触发自己的回环 —— 一次拉取
* 会连带 PUT 一次,服务端 updated_at 被无意义地刷新,还会掩盖真实的本地改动)。
*
* 服务端不可达时状态是 `local-only`,界面上**必须看得见** —— 静默失败会让用户
* 以为"已经同步了",然后在另一台机器上发现没有,正是这次的缺陷。
*/
export type SyncStatus = 'idle' | 'synced' | 'pending' | 'local-only';
interface AppearanceSyncState {
status: SyncStatus;
/** 最近一次成功同步的时刻(ms)。0 = 从没成功过。 */
lastSyncedAt: number;
/** 正在把服务端的值灌进 store —— 此时不推回服务端。 */
applyingRemote: boolean;
pull: () => Promise<void>;
push: () => Promise<void>;
}
/**
* 上一次成功上传的壁纸(data URL),**按账号分桶**。
*
* ★ 为什么必须带账号维度:这是本模块唯一没做账号隔离的状态,而它恰好是最不能
* 漏的那一个。A 账号设壁纸 X(上传成功 ⇒ 记下 "A"→X)→ 切到 B 账号,而 B 的
* 服务端记录是 `saved:false` ⇒ `pull()` 走"把本地这份推上去"的分支 ⇒ 但
* `push()` 的跳过判据被 **A** 留下的标记满足 ⇒ **B 的壁纸永远传不上去**,
* 而状态仍被置为 `'synced'`。现象是"B 的壁纸在其它所有设备上都缺,
* 界面上却显示已同步"。
*
* 本来这个文件把 localStorage 键按账号隔离了(见 `lib/appearance.ts`),
* 漏的只有这个内存变量。用 `Map` 而不是单串,是因为它的生命周期就等于
* 一次运行期,而账号数很小,不需要额外持久化。
*/
const lastUploadedImage = new Map<string, string>();
/** 当前身份的稳定键。聚合视图下用固定值(聚合本来就不该单独上传)。 */
function currentAccountKey(): string {
const s = useAccountStore.getState();
return isAggregate(s) ? '__aggregate__' : s.activeId;
}
let pushTimer: ReturnType<typeof setTimeout> | null = null;
function currentAuth(): AppearanceAuth | null {
const a = activeAuth();
if (!a?.base) return null;
// 令牌可以为空:**浏览器密码登录走 cookie 会话**(桌面端粘贴用户密钥才设 Bearer)。
// 以前这里要求 token 非空,于是 WebUI 里这套同步从来不跑 —— 用户看到的就是
// "服务端存了外观、界面却毫无变化"。
return { base: a.base, token: a.token ?? '' };
}
export const useAppearanceSync = create<AppearanceSyncState>((set) => ({
status: 'idle',
lastSyncedAt: 0,
applyingRemote: false,
pull: async () => {
const auth = currentAuth();
if (!auth) {
// 没登录(或还没选账号):本地就是全部,且要让用户知道。
set({ status: 'local-only' });
return;
}
const remote = await pullAppearance(auth);
if (!remote) {
set({ status: 'local-only' });
return;
}
if (!remote.saved) {
// 服务端还没这份记录(第一次用这套同步,或换了新账号):
// **不能拿默认值覆盖本地** —— 反过来把本地这份推上去,作为账号的初始外观。
// 漏了这一条,每个老用户升级后第一次登录都会发现主题/壁纸被重置。
set({ status: 'pending' });
await useAppearanceSync.getState().push();
return;
}
set({ applyingRemote: true });
try {
useThemeStore.getState().setPref(remote.snapshot.theme);
const bg = useBackgroundStore.getState();
// 背景档与参数以服务端为准;图片只在服务端有图时才覆盖本地
// (服务端没图而本地有 = 本地还没推上去,别把用户的图擦掉)。
if (remote.snapshot.bgKind === 'preset') bg.setPreset(remote.snapshot.bgPresetId);
else if (remote.snapshot.bgKind === 'none') bg.setKind('none');
else if (remote.snapshot.bgKind === 'image' && remote.imageDataUrl) bg.setImage(remote.imageDataUrl);
if (remote.imageDataUrl) lastUploadedImage.set(currentAccountKey(), remote.imageDataUrl);
bg.setDim(remote.snapshot.bgDim);
bg.setBlur(remote.snapshot.bgBlur);
} finally {
set({ applyingRemote: false });
}
set({ status: 'synced', lastSyncedAt: Date.now() });
},
push: async () => {
const auth = currentAuth();
if (!auth) {
set({ status: 'local-only' });
return;
}
const theme = useThemeStore.getState().pref;
const bg = useBackgroundStore.getState();
const accountKey = currentAccountKey();
const payload = payloadFromLocal({
theme,
kind: bg.kind,
presetId: bg.presetId,
imageDataUrl: bg.imageDataUrl,
dim: bg.dim,
blur: bg.blur
});
let ok = await pushAppearance(auth, payload);
// 壁纸本体单独传:几 MB 的图不该每次都跟着 PUT 走,只在换图时传一次。
if (ok && bg.kind === 'image' && bg.imageDataUrl && bg.imageDataUrl !== lastUploadedImage.get(accountKey)) {
const uploaded = await uploadAppearanceImage(auth, bg.imageDataUrl);
if (uploaded) lastUploadedImage.set(accountKey, bg.imageDataUrl);
else ok = false;
}
set(ok ? { status: 'synced', lastSyncedAt: Date.now() } : { status: 'pending' });
}
}));
/** 本地一改就推(去抖),但**拉取期间不推**(见文件头第 3 条)。 */
function schedulePush(): void {
if (useAppearanceSync.getState().applyingRemote) return;
if (pushTimer) clearTimeout(pushTimer);
pushTimer = setTimeout(() => {
pushTimer = null;
void useAppearanceSync.getState().push();
}, 600);
}
/**
* 接线:订阅两个 store 的改动,并在账号切换时重新拉取。
*
* 账号切换必须重新拉 —— 外观是**账号级**的,而 localStorage 里的缓存键是全局的
* (这正是"放本地"时最直接的缺陷:同一台机器换账号背景不跟着走)。
*
* @returns 取消订阅(React 的 effect 清理用)
*/
export function initAppearanceSync(): () => void {
const unsubBg = useBackgroundStore.subscribe(schedulePush);
const unsubTheme = useThemeStore.subscribe(schedulePush);
const unsubAccount = useAccountStore.subscribe((state, prev) => {
if (state.activeId !== prev.activeId) {
/*
* 顺序:**先按新账号重读本地缓存,再拉服务端**。
*
* 为什么不能省这一步:服务端"没有记录"(`saved === false`)时 `pull()` 会
* 走"以本地为准推上去"——那时"本地"必须已经是**这个账号**的值。
* 全局键时代这里是上一个账号的值,于是新账号凭空继承了别人的外观,
* 而且被写进了服务端(pi 2026-09-14 复核时点出的那条缺陷)。
*/
useBackgroundStore.getState().reloadForAccount();
void useAppearanceSync.getState().pull();
}
});
return () => {
unsubBg();
unsubTheme();
unsubAccount();
if (pushTimer) clearTimeout(pushTimer);
};
}