跨端: 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` 仍在,所以**不是越权**;
但非管理员会看到完整用户列表、建号表单、改密入口 ——
属于客户端信息泄露 + 无意义的失败请求风暴。
This commit is contained in:
2026-09-28 08:27:01 +08:00
parent 044a664cc3
commit 18b148e476
28 changed files with 690 additions and 64 deletions

View File

@ -276,22 +276,34 @@ export function foregroundBundle(hdc) {
/**
* 拉一份当前 UI 树(`uitest dumpLayout` → 设备上生成 JSON → `cat` 回来 → 解析)。
*
* dumpLayout 的输出形如 `DumpLayout saved to:/data/local/tmp/layout_<ts>.json`,
* 路径是确定的(不是"猜最新的文件"——那样会和别的会话的 dump 抢)。拿到路径
* 后 cat 回来 JSON.parse。根是 `{ attributes, children }`,children 递归同形。
* ★★ 2026-09-26:**必须显式给输出路径**。
*
* 原来跑的是裸 `uitest dumpLayout`(不带 `-p`),期望它自己在 stdout 打一行
* `DumpLayout saved to:…`。在 HarmonyPhone(HarmonyOS 6.1.1)上实测:
* 裸调用 → `DumpLayout failed:Wait for subscribe uitest.broadcast.command.reply timeout`
* (非 0 退出、stdout 空 ⇒ 这条判据直接**报错**而不是跳过)
* 加 `-p` → `DumpLayout saved to:/data/local/tmp/d.json`(正常)
* ⇒ 裸调用依赖的是**自动选名**那条路径,它要等 broadcast 回复;
* 给了确定路径就不用等。
*
* 这不是"设备不在"那种环境缺失,而是**判据自己跑不成** —— 所以必须修,
* 否则日历手势那类判据在真机上会一直红。
*
* 路径自己定 ⇒ 不再从 stdout 反解,也不存在"和别的会话的 dump 抢文件"。
*
* 抛异常(不返回 null):调用方该知道"dump 跑不成"和"dump 出来是空的"是两回事
* —— 前者是环境问题,后者才是断言该管的。判据的 broken 分类靠这个区分。
*/
export function dumpLayout(hdc) {
if (!hdc) throw new Error('hdc 没找到(findHdc() 返回 null)');
const r = sh(hdc, ['shell', 'uitest', 'dumpLayout'], 30000);
// 每次换一个名字:同一路径反复写会让并发跑的两个判据互相读到对方的中间态。
const path = `/data/local/tmp/dsh_dump_${process.pid}_${Date.now()}.json`;
const r = sh(hdc, ['shell', 'uitest', 'dumpLayout', '-p', path], 45000);
if (r.status !== 0) throw new Error(`dumpLayout 失败:${(r.stderr || r.stdout || '').trim()}`);
const m = (r.stdout || '').match(/saved to:(\S+)/);
if (!m) throw new Error(`dumpLayout 没返回文件路径:${(r.stdout || '').trim()}`);
const path = m[1].trim();
const cat = sh(hdc, ['shell', 'cat', path], 20000);
if (cat.status !== 0) throw new Error(`cat ${path} 失败:${(cat.stderr || '').trim()}`);
// 设备上的临时文件不留(几十次判据跑下来 /data/local/tmp 会攒一堆)。
sh(hdc, ['shell', 'rm', '-f', path], 10000);
return JSON.parse(cat.stdout);
}