跨端: 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

@ -382,13 +382,23 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
const [comment, setComment] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
/*
* 同步的"正在提交"闸门。
*
* ★ 为什么 `busy` 这个 state 不够:它在 React 提交后才为真,而两次快速点击
* 可以在同一次提交之前都读到 `busy === false` ⇒ **发出两个 forwardMail**
* (真转发,不是本地乐观更新)。ref 的写入是同步的,能覆盖那个窗口
* —— 与 `ThreadView.loadMore` 用 `busy.current` 处理同一类问题的做法一致。
*/
const inFlight = useRef(false);
const fetchInbox = useMailStore(s => s.fetchInbox);
const fetchSent = useMailStore(s => s.fetchSent);
const fetchSessions = useSessionStore(s => s.fetchSessions);
const fetchContacts = useContactStore(s => s.fetchContacts);
const submit = async () => {
if (!to.trim() || busy) return;
if (!to.trim() || inFlight.current) return;
inFlight.current = true;
setBusy(true);
setError(null);
try {
@ -397,11 +407,18 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
cc: cc.trim(),
comment: comment.trim()
});
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
/*
* 刷新失败**不该拦住关窗**:转发此刻已经成功了,而 `Promise.all` 会在
* 四个刷新里任一失败时 reject ⇒ `onClose()` 被跳过 ⇒ 人看到报错、
* 于是**再转一次**(真的多发一封)。所以这里 allSettled:关窗照做,
* 失败的部分留给下一次 SSE/手动刷新补上。
*/
await Promise.allSettled([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
inFlight.current = false;
setBusy(false);
}
};
@ -718,6 +735,9 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
const [picked, setPicked] = useState<string[]>([]);
// 服务端判定这条决策越过了等待窗口时回给我们的说明。
const [staleWarning, setStaleWarning] = useState('');
// 提交失败要**说出来**:这是审批动作,失败时如果什么都不显示,人看到的是
// "点了没反应",于是反复点,而 Agent 那边一直阻塞在同一个请求上。
const [submitError, setSubmitError] = useState('');
const fetchInbox = useMailStore(s => s.fetchInbox);
const selectSession = useSessionStore(s => s.selectSession);
@ -743,7 +763,22 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
</p>
) : null;
/*
* 提交失败的提示条。
*
* 与 `staleBanner` 并列放在两种形态之外算一次:审批与提问的失败是同一件事
* (请求没打过去,`decided` 没变,Agent 还在等),不该各写一份。
*/
const submitErrorBanner = submitError ? (
<p className="mt-2 text-2xs leading-relaxed text-red-700 bg-red-50 border border-red-200 rounded-md px-2.5 py-1.5">
提交失败:{submitError}
<br />
Agent 仍在等待这个决定,可以再试一次。
</p>
) : null;
const submit = async (decision: string, noteText: string) => {
setSubmitError('');
setBusy(true);
try {
const res = await api.decidePermission(mail.mail_id, decision, noteText || undefined);
@ -754,7 +789,13 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
await fetchInbox('all');
if (mail.session_id) selectSession(mail.session_id);
} catch (err) {
console.error(err);
/*
* ★ 原来这里只有 `console.error(err)`:失败后 `decided` 仍是 '',界面回到
* 可点状态,**用户看不到任何提示**,只会以为"点了没反应"而反复点,
* 而 Agent 那边一直阻塞。同文件其它提交路径(转发/回复/预算)
* 都正确地把错误显示出来了,只有这一处是例外。
*/
setSubmitError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
@ -795,6 +836,7 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
return (
<div className="mt-3 pt-3 border-t border-orange-200">
{staleBanner}
{submitErrorBanner}
<Composer
value={note}
onChange={setNote}
@ -839,6 +881,7 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
return (
<div className="mt-3 pt-3 border-t border-orange-200">
{staleBanner}
{submitErrorBanner}
<Composer
value={note}
onChange={setNote}