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

@ -12,6 +12,7 @@
import { useEffect, useRef, useState } from 'react';
import { AGGREGATE_ID, isUsableAccount } from '../lib/accounts';
import { resetAccountData } from '../lib/resetAccountData';
import { useAccountStore } from '../stores/accountStore';
import { useMailStore } from '../stores/mailStore';
import { useUIStore } from '../stores/uiStore';
@ -55,9 +56,20 @@ export default function AccountSwitcher() {
const pick = async (id: string) => {
setOpen(false);
/*
* ★ 切账号**必须先清全部账号数据**,再重取。
*
* `setActive` 会 `syncAuth()` 翻 `api/config` 单例里的 API_BASE 与 bearer,
* 从这一刻起 `api.getSent()` / `api.getMail()` / `api.archiveContact()` 全带
* **新**账号的凭证。而 mailStore 的 sent/currentMail、sessionStore 的
* sessions/currentSession(+Mails)、contactStore 的 contacts 全是**旧**账号的 ——
* 留着就会"在新账号的界面下显示旧账号的邮件",而从这些陈旧视图点归档/转发
* 改的是**新**账号。不报错、不空屏,肉眼完全看不出来,所以只能靠这里堵。
*
* 登出与 401 走的是同一套清空(`App.tsx` 与 `api/client.ts` 的 unauthorized 回调)。
*/
resetAccountData();
await setActive(id);
// 切账号后**必须重取**:收件箱是按账号返回的,不重取就会看到上一个账号的信。
// 这条在聚合⇄单账号之间同样成立(聚合要并发问多个账号)。
await fetchInbox('all');
};

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import * as api from '../api/client';
import type { AdminScopes, User } from '../types';
import { CheckIcon, LockIcon, UsersIcon, ChevronRightIcon, KeyIcon, BotIcon, CpuIcon } from './icons';
@ -89,9 +89,22 @@ export default function AdminUsersPage() {
}
};
/*
* 提示条自动消失。句柄存进 ref 并在卸载时清掉:原来直接 `setTimeout` 不管,
* 组件卸载后那个回调仍会跑(状态写在死组件上是纯粹的脏行为),而且连点几次
* 会留下多个待触发的定时器,先触发的会把后触发的提示提前吃掉。
*/
const noticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (noticeTimer.current) clearTimeout(noticeTimer.current);
};
}, []);
const flash = (msg: string) => {
setNotice(msg);
setTimeout(() => setNotice(null), 2500);
if (noticeTimer.current) clearTimeout(noticeTimer.current);
noticeTimer.current = setTimeout(() => setNotice(null), 2500);
};
return (

View File

@ -115,10 +115,17 @@ export default function BackgroundPicker() {
presetId === p.id ? 'border-blue-500 ring-1 ring-blue-500' : 'border-gray-300'
}`}
>
{/* 用与全屏背景同一份 CSS 变量渲染缩略图 —— 预览必然等于结果 */}
{/*
* 用与全屏背景同一份 CSS 变量渲染缩略图 —— 预览必然等于结果。
*
* ★ 这里**不能**写 `style={{ backgroundImage: 'var(--bg-image)' }}`:
* 内联样式优先于类,而当 `kind==='image'` 时 `:root` 上被设了
* `--bg-image: url("用户那张图")`(`backgroundStore.ts:262`)⇒
* **六个预设格子全都显示同一张照片**,而用户正在这一步里挑预设。
* 预设类自己就定义了 `--bg-image`(`index.css:783+`),让它生效即可。
*/}
<span
className={`bg-preset-${p.id} absolute inset-0 block`}
style={{ backgroundImage: 'var(--bg-image)', backgroundSize: 'cover' }}
aria-hidden="true"
/>
<span className="absolute bottom-0 inset-x-0 text-3xs py-0.5 bg-black/45 text-white">

View File

@ -93,15 +93,28 @@ export default function CalendarView() {
};
}, [anchor]);
/*
* 请求代次:翻月/翻日时若有两次请求同时在飞,**只认最后一次**。
*
* ★ 为什么需要它(`ThreadView.tsx:62-84` 早就做对了,这里是漏掉的同一处):
* 快速点「下一页」「上一页」或连续滑动 ⇒ 多个请求同时在飞,而它们**不保证
* 按发起顺序返回**。无守卫时后落地的覆盖先落地的 ⇒ 标题写着「11 月」而
* 网格里是 10 月的事件,且不报错、不空屏。
*/
const loadGen = useRef(0);
async function load() {
const myGen = ++loadGen.current;
setErr('');
try {
const r = await api.listCalendarEvents({ from: range.from, to: range.to });
if (loadGen.current !== myGen) return; // 已被更新的请求取代
setEvents(r.events ?? []);
} catch (e: any) {
if (loadGen.current !== myGen) return;
setErr(e?.message || '加载失败');
} finally {
setLoading(false);
if (loadGen.current === myGen) setLoading(false);
}
}
@ -263,8 +276,20 @@ export default function CalendarView() {
const a = document.createElement('a');
a.href = url;
a.download = `agentmail-${dayKey(anchor)}.ics`;
/*
* ★ 撤销必须**推迟到下一个任务**,不能紧跟 `click()` 同步撤销。
*
* `click()` 只是"启动"下载,blob 实际是被**异步**读取的。Chromium 会容忍
* 同步撤销,而 Firefox / WebKit 不会 —— 表现是**静默失败或 0 字节 .ics**,
* 而且只在部分内核上出现,很容易漏测。
*
* 顺带把 `a` 挂到 document 上再摘掉:部分内核只在元素处于文档中时才认
* `download` 属性(这条与 `setTimeout` 是同一个原因的两种修法,缺一不可)。
*/
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 0);
} catch (e: any) {
setErr(e?.message || '导出失败');
}

View File

@ -54,6 +54,18 @@ export default function ComposePage() {
const [error, setError] = useState<string | null>(null);
const [okMsg, setOkMsg] = useState<string | null>(null);
/*
* "发完 900ms 自动退出写信"的定时器。句柄存起来并在卸载时清掉 ——
* 原来直接 `setTimeout` 不管,组件已卸载/换页之后那个回调仍会跑,
* `setOkMsg` 写在死组件上;而"清空"按钮在 sending 期间并未禁用 ⇒ 可达。
*/
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (closeTimer.current) clearTimeout(closeTimer.current);
};
}, []);
useEffect(() => {
const el = rootRef.current;
const from = takeComposeOrigin();
@ -157,7 +169,8 @@ export default function ComposePage() {
res.budget_max ? `已发送 · ${where} · 预算 ${res.budget_max} 个来回` : `已发送 · ${where}`
);
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
setTimeout(() => {
if (closeTimer.current) clearTimeout(closeTimer.current);
closeTimer.current = setTimeout(() => {
setOkMsg(null);
cancelCompose();
}, 900);

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import * as api from '../api/client';
import { KeyIcon, CopyIcon, TrashIcon, PlusIcon, CheckIcon } from './icons';
@ -31,12 +31,21 @@ function keyState(k: { key_type: api.KeyType; expires_at: string | null; used_at
*/
function NewKeyBanner({ token, onDismiss }: { token: string; onDismiss: () => void }) {
const [copied, setCopied] = useState(false);
// "已复制"的复位定时器:句柄存起来并在卸载时清掉(连点复制会留下多个定时器,
// 先触发的把后触发的状态提前复位)。
const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (copiedTimer.current) clearTimeout(copiedTimer.current);
};
}, []);
const copy = async () => {
try {
await navigator.clipboard.writeText(token);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
if (copiedTimer.current) clearTimeout(copiedTimer.current);
copiedTimer.current = setTimeout(() => setCopied(false), 1500);
} catch {
/* 无剪贴板权限时用户可手动选中 */
}

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}

View File

@ -0,0 +1,37 @@
/**
* 换身份时清空全部账号相关数据 —— **一处实现,三个入口都调它**。
*
* # 为什么需要这个文件
*
* `setActive` 之后 `api/config` 单例里的 `API_BASE` 与 bearer 就翻到了新账号,
* 于是**此后每一个请求都带新账号的凭证**。而各 store 里还留着**旧**账号的数据:
*
* · `mailStore.sent` / `currentMail`
* · `sessionStore.sessions` / `currentSession` / `currentSessionMails`
* · `contactStore.contacts` / `archivedContacts`
*
* 结果就是"在新账号的界面下显示旧账号的邮件",而且**从旧视图发出的写操作
* (归档 / 转发 / 批准权限)改的是新账号**。这类问题不报错、不空屏、
* 肉眼完全看不出来 —— 只能靠一处收口来堵。
*
* # 为什么是三个入口而不是一个
*
* ① 主动切账号(`AccountSwitcher.pick`)
* ② 登出(`authStore.logout`)
* ③ 任意接口 401(`api/client.ts` 的 unauthorized 回调)
*
* 只在 ① 里清是最容易漏的那种做法:② 和 ③ 各自还会重新泄露一次,而它们都不在
* 切换账号的代码路径上,grep 也找不到。**身份变化有三条路径,清空也该有三条。**
*
* ★ 这里只碰**数据** store;`uiStore` 的 `reset` 由 `App.tsx` 负责
* (它还要复位窄屏分栏、写信态那些纯界面状态)。
*/
import { useContactStore } from '../stores/contactStore';
import { useMailStore } from '../stores/mailStore';
import { useSessionStore } from '../stores/sessionStore';
export function resetAccountData(): void {
useMailStore.getState().resetAll();
useSessionStore.getState().resetAll();
useContactStore.getState().resetAll();
}

View File

@ -8,7 +8,7 @@ import {
uploadAppearanceImage,
type AppearanceAuth
} from '../lib/appearance';
import { useAccountStore } from './accountStore';
import { useAccountStore, isAggregate } from './accountStore';
import { useBackgroundStore } from './backgroundStore';
import { useThemeStore } from './themeStore';
@ -36,8 +36,27 @@ interface AppearanceSyncState {
push: () => Promise<void>;
}
/** 上一次成功上传的壁纸(data URL)。相同就不重复上传几 MB。 */
let lastUploadedImage = '';
/**
* 上一次成功上传的壁纸(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 {
@ -84,7 +103,7 @@ export const useAppearanceSync = create<AppearanceSyncState>((set) => ({
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 = remote.imageDataUrl;
if (remote.imageDataUrl) lastUploadedImage.set(currentAccountKey(), remote.imageDataUrl);
bg.setDim(remote.snapshot.bgDim);
bg.setBlur(remote.snapshot.bgBlur);
} finally {
@ -101,6 +120,7 @@ export const useAppearanceSync = create<AppearanceSyncState>((set) => ({
}
const theme = useThemeStore.getState().pref;
const bg = useBackgroundStore.getState();
const accountKey = currentAccountKey();
const payload = payloadFromLocal({
theme,
kind: bg.kind,
@ -112,9 +132,9 @@ export const useAppearanceSync = create<AppearanceSyncState>((set) => ({
let ok = await pushAppearance(auth, payload);
// 壁纸本体单独传:几 MB 的图不该每次都跟着 PUT 走,只在换图时传一次。
if (ok && bg.kind === 'image' && bg.imageDataUrl && bg.imageDataUrl !== lastUploadedImage) {
if (ok && bg.kind === 'image' && bg.imageDataUrl && bg.imageDataUrl !== lastUploadedImage.get(accountKey)) {
const uploaded = await uploadAppearanceImage(auth, bg.imageDataUrl);
if (uploaded) lastUploadedImage = bg.imageDataUrl;
if (uploaded) lastUploadedImage.set(accountKey, bg.imageDataUrl);
else ok = false;
}
set(ok ? { status: 'synced', lastSyncedAt: Date.now() } : { status: 'pending' });

View File

@ -2,6 +2,7 @@ import { create } from 'zustand';
import type { User } from '../types';
import * as api from '../api/client';
import { ApiError } from '../api/client';
import { resetAccountData } from '../lib/resetAccountData';
type Phase = 'checking' | 'anonymous' | 'authenticated';
@ -77,6 +78,12 @@ export const useAuthStore = create<AuthState>(set => ({
} catch {
/* 即使请求失败也在前端登出 */
}
/*
* ★ 登出也必须清账号数据:不清的话,下一个登录的人会先看到上一个人的
* 邮件、会话与联系人(`App.tsx` 的 `resetUI()` 只复位界面状态,不碰数据)。
* 走的是与切账号同一个 `resetAccountData` —— 身份变化的每条路径都要清。
*/
resetAccountData();
set({ phase: 'anonymous', user: null, error: null });
},
@ -102,7 +109,12 @@ export const useAuthStore = create<AuthState>(set => ({
clearError: () => set({ error: null, retryAfter: null }),
markAnonymous: () => set({ phase: 'anonymous', user: null })
markAnonymous: () => {
// 401 掉登录也是一次"身份没了",同样要清数据 —— 否则令牌失效后再登录
// 另一个账号,上一个账号的邮件/会话还会留在界面上。
resetAccountData();
set({ phase: 'anonymous', user: null });
}
}));
// 注册全局 401 处理:任何接口返回 401 即回到登录页

View File

@ -41,6 +41,13 @@ interface ContactState {
archive: (contact: Contact) => Promise<void>;
/** SSE session_archived 到达时本地即时移除 */
removeSessionLocally: (sessionId: string) => void;
/**
* 换身份(切账号 / 登出 / 401)时清空联系人数据。
*
* ★ 与 `mailStore.resetAll` 同一件事的另一半:`contacts` / `archivedContacts`
* 是**旧**账号的会话清单,而 `archive` 走的是新账号的凭证。
*/
resetAll: () => void;
}
export const useContactStore = create<ContactState>((set, get) => ({
@ -109,5 +116,14 @@ export const useContactStore = create<ContactState>((set, get) => ({
removeSessionLocally: sessionId =>
set(state => ({
contacts: state.contacts.filter(c => c.session_id !== sessionId)
}))
})),
resetAll: () =>
set({
contacts: [],
archivedContacts: [],
pendingArchive: null,
loading: false,
error: null
})
}));

View File

@ -33,6 +33,8 @@ interface MailState {
markRead: (id: string) => Promise<void>;
/** 某会话归档后,把它的邮件从列表与选中态里剔除 */
dropSession: (sessionId: string) => void;
/** 换身份(切账号 / 登出 / 401)时清空全部账号相关数据 */
resetAll: () => void;
}
export const useMailStore = create<MailState>((set, get) => ({
@ -124,6 +126,19 @@ export const useMailStore = create<MailState>((set, get) => ({
},
clearCurrentMail: () => set({ currentMail: null }),
/**
* 换身份(切账号 / 登出 / 401)时清空全部账号相关数据。
*
* ★ `setActive` 会翻 `api/config` 单例里的 API_BASE 与 bearer,此后 `getSent()` /
* `getMail()` / `archiveContact()` 全带**新**账号的凭证 —— 而界面上还留着**旧**账号
* 的数据,于是"从陈旧视图点归档/转发"改的是新账号。这是跨账号数据泄露,
* 而且不报错、不空屏,肉眼完全看不出来。
*
* ★ 为什么放在 store 上而不是由调用方各自清:同一件事有三个入口
* (切换 / 登出 / 401),漏一个就重新泄露一次。一处实现、三个入口都调它。
*/
resetAll: () =>
set({ inbox: [], sent: [], currentMail: null, accountErrors: [], error: null, loading: false }),
markRead: async id => {
try {

View File

@ -33,6 +33,14 @@ interface SessionState {
refreshBudget: () => Promise<void>;
/** 归档后若正查看该会话则退出 */
dropSessionIfCurrent: (sessionId: string) => void;
/**
* 换身份(切账号 / 登出 / 401)时清空全部会话数据。
*
* ★ 与 `mailStore.resetAll` 同一件事的另一半:`currentSession` + `currentSessionMails`
* 带着**旧**账号的会话全文留在内存里,而此后所有请求都带**新**账号的凭证
* (`getSessionDetail` / `archiveContact`)。不清就是跨账号泄露。
*/
resetAll: () => void;
}
export const useSessionStore = create<SessionState>((set, get) => ({
@ -164,5 +172,16 @@ export const useSessionStore = create<SessionState>((set, get) => ({
set(state => ({
sessions: state.sessions.filter(s => s.session_id !== sessionId)
}));
}
},
resetAll: () =>
set({
sessions: [],
currentSession: null,
currentSessionMails: [],
renameProposal: null,
budget: null,
loading: false,
error: null
})
}));