跨端: 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:
@ -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');
|
||||
};
|
||||
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 || '导出失败');
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
/* 无剪贴板权限时用户可手动选中 */
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
37
client/electron/src/lib/resetAccountData.ts
Normal file
37
client/electron/src/lib/resetAccountData.ts
Normal 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();
|
||||
}
|
||||
@ -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' });
|
||||
|
||||
@ -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 即回到登录页
|
||||
|
||||
@ -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
|
||||
})
|
||||
}));
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
})
|
||||
}));
|
||||
|
||||
@ -181,7 +181,9 @@ test('A|系统拥有的维度,鸿蒙侧的唯一来源是系统资源(不
|
||||
*/
|
||||
const sysOwned = {
|
||||
pageBg: 'color', surface: 'color', surfaceMuted: 'color', border: 'color',
|
||||
textPrimary: 'color', textMuted: 'color', textSubtle: 'color',
|
||||
// textSubtle 已移除:系统三级色浅色下 2.85:1,且本机二级色同值
|
||||
// (见 SELF_OWNED_COLORS 里 textSubtleLight/Dark 那段),小字走 textSubtleFor()。
|
||||
textPrimary: 'color', textMuted: 'color',
|
||||
overlay: 'color', navFg: 'color',
|
||||
radiusCard: 'float', radiusControl: 'float'
|
||||
};
|
||||
@ -297,6 +299,28 @@ const SELF_OWNED_COLORS = [
|
||||
'chipNeutralBgDark', 'chipNeutralFgDark', 'chipSpentFgDark', 'chipWarnFgDark',
|
||||
// 权限档位与预算档位的胶囊配色(档位是产品语义,系统不认识"plan/workspace/full")
|
||||
'chipNeutralBg', 'chipNeutralFg', 'chipSpentBg', 'chipSpentFg', 'chipWarnBg', 'chipWarnFg',
|
||||
/*
|
||||
* ★★ 2026-09-26 补登记:小字那一档(`textSubtleLight` / `textSubtleDark`)。
|
||||
*
|
||||
* 为什么必须是**自己写**的色,而不是 `$r('sys.color.ohos_id_color_text_*')`:
|
||||
*
|
||||
* 2026-09-19 这条设备判据已经抓到过「浅色三级色 = rgb(153,153,153) 压白底
|
||||
* = 2.85:1」,当时的修法是让 `textSubtleFor()` 两个主题都返回 `textMuted`
|
||||
* (`ohos_id_color_text_secondary`),理由是"二级色语义对得上"。
|
||||
*
|
||||
* ★★ 但 2026-09-26 复扫证明**那个前提不成立**:本机 HarmonyOS 6.1.1 上
|
||||
* `ohos_id_color_text_secondary` 实测**也是 rgb(153,153,153)**,与三级色同值
|
||||
* ⇒ 换过去之后**一点没变**,判据继续红。
|
||||
*
|
||||
* 系统二级/三级色只保证「比一级淡」这层**层次关系**,不保证 WCAG 下限;
|
||||
* 同一个坑在本文件已经犯过三次(`surface` 当前景色、accent 深色没调亮、
|
||||
* 这一次)。而 WebUI 那边 gray-400/gray-500 是**两套主题各自适配过**的值
|
||||
* (浅 4.83:1 / 深 5.51:1),不是同一个色翻个主题。
|
||||
*
|
||||
* ⇒ 这一档的"跨端身份"就是 WebUI 的 gray-400,所以按 gray-400 逐字对齐,
|
||||
* 并给出两个主题各自实测 ≥3:1 的值。
|
||||
*/
|
||||
'textSubtleLight', 'textSubtleDark',
|
||||
/*
|
||||
* ★★ 2026-09-19 补登记(对应用户「底栏数字为什么显示在图标下面?」与
|
||||
* 用户「你写的app和webui大面积不符」那两轮改动)。
|
||||
|
||||
@ -522,8 +522,18 @@ test('★ 设备:真实壁纸在设备上的尺寸/体积与压缩决策的输
|
||||
}
|
||||
|
||||
/* 素材在设备上吗?不在就显式跳过(不假装通过) */
|
||||
const listed = (D.shellOn(hdc, 'ls -la /data/local/tmp/real-wallpaper.jpg').stdout || '').trim();
|
||||
if (!listed.includes('real-wallpaper.jpg')) {
|
||||
const probe = D.shellOn(hdc, 'ls -la /data/local/tmp/real-wallpaper.jpg');
|
||||
const listed = (probe.stdout || '').trim();
|
||||
/*
|
||||
* ★ 判"在不在"必须看**退出码**,不能只看输出里有没有那个文件名。
|
||||
*
|
||||
* `hdc shell` 会把 stderr 并进 stdout,于是文件不存在时 stdout 是
|
||||
* `ls: /data/local/tmp/real-wallpaper.jpg: No such file or directory` ——
|
||||
* **里面仍然含有文件名**,于是原来的 `listed.includes('real-wallpaper.jpg')`
|
||||
* 判成"在",本该跳过的判据继续往下跑,撞在 `/bin/file` 上**报错**。
|
||||
* 设备上没有素材是正常状态(没传过壁纸的设备),不该让整条判据变红。
|
||||
*/
|
||||
if (probe.status !== 0 || !/^-.*\breal-wallpaper\.jpg$/m.test(listed)) {
|
||||
return t.skip('设备上没有真实素材 /data/local/tmp/real-wallpaper.jpg —— 本次不跑');
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
129
client/electron/test/stores/resetAccountData.test.ts
Normal file
129
client/electron/test/stores/resetAccountData.test.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { resetAccountData } from '../../src/lib/resetAccountData';
|
||||
import { useContactStore } from '../../src/stores/contactStore';
|
||||
import { useMailStore } from '../../src/stores/mailStore';
|
||||
import { useSessionStore } from '../../src/stores/sessionStore';
|
||||
|
||||
/**
|
||||
* ★ 切身份必须清空全部账号数据(2026-09-26 加的行为锁)。
|
||||
*
|
||||
* ── 锁的是哪个 bug ──
|
||||
* `setActive` 会把 `api/config` 单例里的 API_BASE 与 bearer 翻到新账号,
|
||||
* 而各 store 还留着**旧**账号的数据 ⇒ "新账号的界面下显示旧账号的邮件",
|
||||
* 且**从旧视图发出的写操作(归档/转发/批准权限)改的是新账号**。
|
||||
* 不报错、不空屏、没有任何可见征兆 —— 只能靠判据钉住。
|
||||
*
|
||||
* ── 为什么锁 `resetAccountData()` 而不是锁某个组件 ──
|
||||
* 身份变化有**三条**路径(切账号 / 登出 / 401),它们都必须清;
|
||||
* 把"清空"收在一个函数里,这三条路径共用它 ⇒ 这里只需要锁**这一个**函数
|
||||
* 把三份 store 都清干净(清漏一份,那份就是新的泄露面)。
|
||||
*/
|
||||
|
||||
const mail = (id: string) => ({ mail_id: id }) as never;
|
||||
const session = (id: string) => ({ session_id: id }) as never;
|
||||
|
||||
/** 往三个 store 里各塞一份"上个账号的脏数据"。 */
|
||||
function seedStaleData() {
|
||||
useMailStore.setState({
|
||||
inbox: [mail('a1')],
|
||||
sent: [mail('a2')],
|
||||
currentMail: mail('a3'),
|
||||
error: '旧错误',
|
||||
accountErrors: ['x']
|
||||
});
|
||||
useSessionStore.setState({
|
||||
sessions: [session('s1')] as never,
|
||||
currentSession: { session_id: 's1' } as never,
|
||||
currentSessionMails: [mail('a4')],
|
||||
renameProposal: { old: 'x', new: 'y' } as never,
|
||||
budget: { used: 1 } as never,
|
||||
error: '旧错误'
|
||||
});
|
||||
useContactStore.setState({
|
||||
contacts: [{ session_id: 'c1', name: 'A' }] as never,
|
||||
archivedContacts: [{ session_id: 'c2', name: 'A2' }] as never,
|
||||
pendingArchive: 'c3',
|
||||
error: '旧错误'
|
||||
});
|
||||
}
|
||||
|
||||
describe('resetAccountData —— 换身份时清空全部账号数据', () => {
|
||||
beforeEach(() => {
|
||||
// 各 store 的初值本身就是"空",所以直接回到初值即可复位。
|
||||
useMailStore.setState({
|
||||
inbox: [],
|
||||
sent: [],
|
||||
currentMail: null,
|
||||
error: null,
|
||||
accountErrors: [],
|
||||
loading: false
|
||||
});
|
||||
useSessionStore.setState({
|
||||
sessions: [],
|
||||
currentSession: null,
|
||||
currentSessionMails: [],
|
||||
renameProposal: null,
|
||||
budget: null,
|
||||
error: null,
|
||||
loading: false
|
||||
});
|
||||
useContactStore.setState({
|
||||
contacts: [],
|
||||
archivedContacts: [],
|
||||
pendingArchive: null,
|
||||
error: null,
|
||||
loading: false
|
||||
});
|
||||
});
|
||||
|
||||
it('★ 三个 store 全部清空(漏掉任何一份都是新的泄露面)', () => {
|
||||
seedStaleData();
|
||||
resetAccountData();
|
||||
|
||||
expect(useMailStore.getState().inbox).toEqual([]);
|
||||
expect(useMailStore.getState().sent).toEqual([]);
|
||||
expect(useMailStore.getState().currentMail).toBeNull();
|
||||
|
||||
expect(useSessionStore.getState().sessions).toEqual([]);
|
||||
expect(useSessionStore.getState().currentSession).toBeNull();
|
||||
expect(useSessionStore.getState().currentSessionMails).toEqual([]);
|
||||
expect(useSessionStore.getState().renameProposal).toBeNull();
|
||||
expect(useSessionStore.getState().budget).toBeNull();
|
||||
|
||||
expect(useContactStore.getState().contacts).toEqual([]);
|
||||
expect(useContactStore.getState().archivedContacts).toEqual([]);
|
||||
expect(useContactStore.getState().pendingArchive).toBeNull();
|
||||
});
|
||||
|
||||
it('错误态也要清:旧账号的报错不该挂在别人的界面上', () => {
|
||||
seedStaleData();
|
||||
resetAccountData();
|
||||
expect(useMailStore.getState().error).toBeNull();
|
||||
expect(useSessionStore.getState().error).toBeNull();
|
||||
expect(useContactStore.getState().error).toBeNull();
|
||||
});
|
||||
|
||||
it('幂等:连续切账号时重复清空不报错', () => {
|
||||
seedStaleData();
|
||||
resetAccountData();
|
||||
expect(() => {
|
||||
resetAccountData();
|
||||
resetAccountData();
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('清空**不碰**纯界面状态(那是 uiStore 的职责,不在这里)', () => {
|
||||
/*
|
||||
* 这条是**边界**:复位界面状态(窄屏分栏、写信态)由 `App.tsx` 的 `resetUI()`
|
||||
* 负责。`resetAccountData` 若顺手去清那些,会让"切账号"变成"退出登录",
|
||||
* 把两件不同的事混在一起 —— 所以这里显式锁住"只清数据"。
|
||||
*/
|
||||
const ui = useSessionStore.getState();
|
||||
expect(typeof ui.resetAll).toBe('function');
|
||||
// 三个 store 都提供 resetAll(而不是各自散落的 clearXxx)——
|
||||
// 统一入口才不会在第四条身份路径上被漏掉。
|
||||
expect(typeof useMailStore.getState().resetAll).toBe('function');
|
||||
expect(typeof useContactStore.getState().resetAll).toBe('function');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user