Files
MailUI4Agents/client/harmony/entry/src/main/ets/common/ComposeIntent.ets
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

184 lines
7.1 KiB
Plaintext
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.

/**
* 「打开写信」的**跨组件一次性意图**。
*
* 用户 2026-09-21:「接下来做一下 2in1 上的快捷键,比如快捷键打开发信页面」。
*
* ## 为什么需要这个中间层
*
* 快捷键必须绑在**根**(`MainPage` 的根 `Stack`)—— 官方 `keyboardShortcut`
* 文档说「即使组件未获焦或是在所在页面未展示,只要已经挂载到**获焦窗口**的
* 组件树上就会响应」,而"窗口级生效"只有绑在窗口组件树的根上才成立。
*
* 但 `openCompose()` 住在 `CommPage` 上(写信要 `CommPage` 自己的 `navPathStack`,
* 宽屏 Split 时才能在右栏开 —— 见 `MainPage.openComposeWith()` 的注释)。
* 于是 `MainPage` 的快捷键够不着那个方法。
*
* ## 做法:照抄本仓已经验证过的"两半"形状
*
* `api/PushService.ets` 处理"点通知要跳转"时踩过同一个坑,解法写在它的注释里:
* · **第一半**:一个静态格子(`pending`)—— 冷启/尚未挂载时,请求先存着;
* · **第二半**:一个回调(`listener`)—— 已经挂载时,请求当场送达。
*
* 缺任何一半都会漏一种情形:
* · 只有格子 ⇒ 用户此刻就在通信页、页面早挂载完了,`aboutToAppear` 不重跑
* ⇒ **按了快捷键什么都没发生**;
* · 只有回调 ⇒ 用户此刻在日历页,`CommPage` 因 `if (currentIndex === 0)`
* 尚未挂载 ⇒ 没有任何监听者 ⇒ **同样什么都没发生**。
*
* ## 谁负责切到通信页
*
* `request()` **不管** UI 导航(那是 `MainPage` 的事:它得先把 `currentIndex`
* 拨到 0,`CommPage` 才会挂载)。这里只管"把意图存住/交出去"。
* 分开的好处:`MainPage` 里那两行是**唯一**需要读当前 tab 的地方,
* 而本类保持无 UI 依赖 —— 可被条件挂载的两侧都安全调用。
*/
export class ComposeIntent {
/**
* 还没人接手的一次请求。
*
* 用静态格子而不是 `AppStorage`:这与 `PushService.pendingRoute` 同一理由 ——
* 发起方(根组件)和接手方(条件挂载的 `CommPage`)之间只隔一次挂载,
* 不需要一个全局可观察的状态;而用了 `@StorageLink` 反而要处理
* "读到之后怎么清"(不清会重放,清了会触发另一轮刷新)。
*/
static pending: boolean = false;
/** 已经在监听的那一处(同一时刻只可能有一处:`CommPage` 是唯一的写信持有者) */
private static listener: (() => void) | undefined = undefined;
/** `CommPage` 挂载时登记 */
static setListener(fn: () => void): void {
ComposeIntent.listener = fn;
}
/** `CommPage` 卸载时摘掉(否则会唤醒一个已销毁的组件) */
static clearListener(): void {
ComposeIntent.listener = undefined;
}
/**
* 提"我要写信"。
*
* **有人监听就当场交出去;没人监听就存着等挂载后取走** —— 两半都留着,
* 因为两种时刻各走一条(用户此刻在通信页 / 在别的页)。
*/
static request(): void {
const fn: (() => void) | undefined = ComposeIntent.listener;
if (fn !== undefined) {
fn();
return;
}
ComposeIntent.pending = true;
}
/** 取走待处理的请求(取完即清,避免下次挂载时重放) */
static consume(): boolean {
if (!ComposeIntent.pending) {
return false;
}
ComposeIntent.pending = false;
return true;
}
}
/**
* 「打开回复」的跨组件一次性意图 —— 与 `ComposeIntent` **完全同形**。
*
* ★★ 2026-09-24 新增。用户:「邮件详情页回车打开回复」。
*
* ── 为何需要它(同样是因为"够不着")──
* 回车是在**主页根**上收到的(详情页自己的 `onKeyEvent` 因组件不获焦而从不触发,
* 实测硬证 —— 详见 `MainPage` 根上那段注释),
* 而"开回复"那个动作(`openReplyWithMorph`)住在 `MailDetailView` 里。
* 根组件拿不到它的实例 ⇒ 与 `openCompose()` 当初撞到的是**同一个问题**,
* 所以用**同一个解法**:一个格子 + 一个回调,两半都留着。
*
* ── 为什么不直接在根上 setState 传下去 ──
* `MailDetailView` 是被 `NavDestination` 挂进去的、参数只传 `mail_id/account_id`
* 那几个 —— 为这个再加一个 `@Prop showReply` 要改三层(壳→页→回调),
* 而且"根改一个值"与"详情正在看哪一封"会分叉(换一封时该清的没清)。
* 意图格子天然是"一次性动作"语义,正好。
*
* ★ 与 `ComposeIntent` 分开两个类而不是加个 mode 参数:
* 两类意图的**接收方完全不同**(一个在 `CommPage`、一个在 `MailDetailView`),
* 合成一个会让"谁该消费它"变得含糊 —— 而含糊正是漏接收的来源。
*/
export class ReplyIntent {
static pending: boolean = false;
private static listener: (() => void) | undefined = undefined;
/** `MailDetailView` 挂载时登记 */
static setListener(fn: () => void): void {
ReplyIntent.listener = fn;
}
/** 卸载时摘掉(否则会唤醒一个已销毁的组件) */
static clearListener(): void {
ReplyIntent.listener = undefined;
}
/** 提"我要回复":有人听就当场给,没人听就存着等挂载后取走 */
static request(): void {
const fn: (() => void) | undefined = ReplyIntent.listener;
if (fn !== undefined) {
fn();
return;
}
ReplyIntent.pending = true;
}
/** 取走待处理的请求(取完即清,避免下次挂载时重放) */
static consume(): boolean {
if (!ReplyIntent.pending) {
return false;
}
ReplyIntent.pending = false;
return true;
}
}
/**
* 「弹掉通信页导航栈的最上一层」的意图(Esc 用)—— 与另两个同形。
*
* ★★ 2026-09-24 新增(用户:「esc返回上一级」)。
*
* Esc 在**主页根**上收到(详情/写信自己的 onKeyEvent 因组件不获焦而不触发),
* 而 `navPathStack` 只有 `CommPage` 持有 ⇒ 同样的"够不着"问题、同样的解法。
*/
export class PopIntent {
static pending: boolean = false;
private static listener: (() => void) | undefined = undefined;
/*
* ★ 2026-09-26 曾把回调从 `() => void` 改成 `() => boolean`(为了让
* `UIAbility.onBackPressed` 知道"到底退没退")—— **已随那次一起撤回**,
* 原因见 `EntryAbility` 里 `onBackPress` 那段注释(模拟器上按一次系统
* 返回键直接把 UIAbility 销毁了,`harmony-admin` 判据因此挂住)。
* ⇒ 形状回到 `() => void`。等系统返回键那条**真机验过**之后再说。
*/
static setListener(fn: () => void): void {
PopIntent.listener = fn;
}
static clearListener(): void {
PopIntent.listener = undefined;
}
static request(): void {
const fn: (() => void) | undefined = PopIntent.listener;
if (fn !== undefined) {
fn();
return;
}
PopIntent.pending = true;
}
static consume(): boolean {
if (!PopIntent.pending) {
return false;
}
PopIntent.pending = false;
return true;
}
}