按 docs/MULTI-ACCOUNT-PLAN.md 实现客户端多账号的前半段(SSE 多连接与 写信账号切换留作下一轮)。 - `src/lib/accounts.ts`:纯逻辑(地址规范化、身份判重、默认账号、聚合合并), 16 条测试钉住每条判据(含反向对照)。 - 持久化在主进程:`userData/accounts.json`,**原子写**(临时文件 + rename)+ 0600。不落 localStorage:那份存储渲染层任何脚本都可读,且 file:// 与 http:// 是两套。无 IPC 时(浏览器)退到 localStorage 并在界面**如实写明**。 - 取信:单账号走原路径(逐字节不变);聚合时**每账号各一次请求、各带自己的 令牌**(`fetchWithAuth`,不碰认证单例,避免并发串号)。 - ★ 只合并**同一网关**的账号:跨网关的邮件混进列表后点开会去问当前账号的 服务器(404,或 mail_id 撞上就打开了别人的信)。如实排除 + 列表上方说明。 - ★ 部分失败可见:某账号取不到时给出账号名与原因 —— 静默丢掉它会让聚合列表 少一整份邮件而界面看起来完全正常。 - `API_BASE` 改为 `let`(切换账号要换网关),api 层不得缓存它 (`client.ts` 的 `const BASE` 快照已改成每次读)。 - UI:列表头下拉(≥2 个可用账号才出现「全部邮箱」)+ 账号徽标 + 账号页 「多账号」一段(添加前调 /auth/me 验证,401 当场拒绝,不写进列表)。 - 测试:vitest 230 通过(原 222 + 新 8)、`test/lib/accounts.test.mjs` 16 通过、 typecheck 通过。新增 `test/manual/multi-account-verify.mjs`(真起打包产物 + 两个真实账号,判据落在网络层:聚合必须每账号各一次请求且各带自己的令牌)。
190 lines
7.5 KiB
TypeScript
190 lines
7.5 KiB
TypeScript
/**
|
||
* 多账号管理(账号页里的一段):列出已添加账号、添加新账号、删除。
|
||
*
|
||
* # 添加时**真的去验证**连通性
|
||
*
|
||
* 粘贴一个错的密钥不会报错——它只会表现为"这个账号的收件箱一直是空的"。
|
||
* 所以添加流程必须调一次 `GET /auth/me`:
|
||
* 成功 → 显示用户名(用户能确认"这是我要的那个账号")
|
||
* 401 → 明确说"密钥无效",不写进账号列表
|
||
* 网络错 → 说清是哪一种(连不上 / 超时),因为这两种的修法不同
|
||
*
|
||
* 用「显式认证」发这个请求(`fetchWithAuth`),不碰当前账号的认证单例 ——
|
||
* 否则验证一个坏账号的过程会把当前账号的令牌换掉。
|
||
*/
|
||
|
||
import { useState } from 'react';
|
||
import { fetchWithAuth } from '../api/config';
|
||
import { deriveDisplayName, normalizeGateway } from '../lib/accounts';
|
||
import { activeAccount, useAccountStore } from '../stores/accountStore';
|
||
|
||
export default function AccountList() {
|
||
const accounts = useAccountStore(s => s.accounts);
|
||
const activeId = useAccountStore(s => s.activeId);
|
||
const ephemeral = useAccountStore(s => s.ephemeralStorage);
|
||
const storageFile = useAccountStore(s => s.storageFile);
|
||
const addAccount = useAccountStore(s => s.add);
|
||
const removeAccount = useAccountStore(s => s.remove);
|
||
const setActive = useAccountStore(s => s.setActive);
|
||
const current = useAccountStore(activeAccount);
|
||
|
||
const [open, setOpen] = useState(false);
|
||
const [gateway, setGateway] = useState('');
|
||
const [token, setToken] = useState('');
|
||
const [name, setName] = useState('');
|
||
const [busy, setBusy] = useState(false);
|
||
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
|
||
|
||
const submit = async () => {
|
||
setMsg(null);
|
||
const g = normalizeGateway(gateway);
|
||
const t = token.trim();
|
||
if (!g) return setMsg({ kind: 'err', text: '请填写 Gateway 地址' });
|
||
if (!t) return setMsg({ kind: 'err', text: '请填写用户密钥' });
|
||
|
||
setBusy(true);
|
||
try {
|
||
// 先验证,再入库 —— 顺序反过来就会出现"列表里有个永远空的账号"
|
||
const res = await fetchWithAuth({ base: `${g}/api/v1`, token: t }, '/auth/me');
|
||
const body = await res.json().catch(() => ({}));
|
||
if (res.status === 401 || res.status === 403) {
|
||
setBusy(false);
|
||
return setMsg({ kind: 'err', text: '密钥无效(服务端返回 401)——请确认用的是用户密钥' });
|
||
}
|
||
if (!res.ok) {
|
||
setBusy(false);
|
||
return setMsg({ kind: 'err', text: `服务端返回 ${res.status}${body?.error ? `:${body.error}` : ''}` });
|
||
}
|
||
const username = body?.user?.username || body?.username || '';
|
||
const r = await addAccount({
|
||
gateway: g,
|
||
token: t,
|
||
username,
|
||
displayName: name.trim() || deriveDisplayName(g, username)
|
||
});
|
||
setBusy(false);
|
||
if (!r.ok) return setMsg({ kind: 'err', text: r.error || '添加失败' });
|
||
setMsg({ kind: 'ok', text: `已添加${username ? `(${username})` : ''}` });
|
||
setGateway('');
|
||
setToken('');
|
||
setName('');
|
||
setOpen(false);
|
||
} catch (e) {
|
||
setBusy(false);
|
||
// 连不上 / 超时 / CORS 都要能分辨:这里至少把原始错误原样说出来
|
||
setMsg({ kind: 'err', text: `连不上:${(e as Error)?.message || e}` });
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div data-testid="account-list">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<h3 className="text-xs font-medium text-gray-500">多账号</h3>
|
||
<button
|
||
type="button"
|
||
data-testid="account-add-toggle"
|
||
onClick={() => setOpen(o => !o)}
|
||
className="ml-auto text-xs text-blue-600 hover:underline"
|
||
>
|
||
{open ? '取消' : '+ 添加账号'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* 存储位置如实说:网页端退到 localStorage,那不是安全存储 */}
|
||
<p className="text-[11px] text-gray-400 mb-3">
|
||
{ephemeral
|
||
? '账号保存在浏览器 localStorage(当前不在桌面端,这不是加密存储)'
|
||
: `账号保存在本机文件:${storageFile || '(未知路径)'}(权限 600)`}
|
||
</p>
|
||
|
||
<ul className="space-y-1.5">
|
||
{accounts.map(a => {
|
||
const usable = Boolean(a.token && a.gateway);
|
||
return (
|
||
<li
|
||
key={a.id}
|
||
data-testid={`account-row-${a.id}`}
|
||
className="flex items-center gap-2 px-3 py-2 rounded-md border border-gray-200"
|
||
>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block text-sm text-gray-800 truncate">{a.displayName}</span>
|
||
<span className="block text-[11px] text-gray-400 truncate">
|
||
{a.gateway}
|
||
{a.username ? ` · ${a.username}` : ''}
|
||
</span>
|
||
</span>
|
||
{!usable && <span className="text-[11px] text-amber-600">缺密钥</span>}
|
||
{a.id === current?.id ? (
|
||
<span className="text-[11px] text-blue-600">当前</span>
|
||
) : a.id !== activeId ? (
|
||
<button
|
||
type="button"
|
||
data-testid={`account-use-${a.id}`}
|
||
onClick={() => setActive(a.id)}
|
||
className="text-[11px] text-gray-600 hover:underline"
|
||
>
|
||
切换
|
||
</button>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
data-testid={`account-del-${a.id}`}
|
||
onClick={() => removeAccount(a.id)}
|
||
className="text-[11px] text-red-600 hover:underline"
|
||
>
|
||
删除
|
||
</button>
|
||
</li>
|
||
);
|
||
})}
|
||
{accounts.length === 0 && <li className="text-xs text-gray-400">还没有添加任何账号</li>}
|
||
</ul>
|
||
|
||
{open && (
|
||
<div className="mt-3 space-y-2 p-3 rounded-md bg-gray-50 border border-gray-200">
|
||
<input
|
||
data-testid="account-gateway"
|
||
value={gateway}
|
||
onChange={e => setGateway(e.target.value)}
|
||
placeholder="Gateway 地址,如 http://192.168.2.60:8180"
|
||
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-gray-300"
|
||
/>
|
||
<input
|
||
data-testid="account-token"
|
||
value={token}
|
||
onChange={e => setToken(e.target.value)}
|
||
placeholder="用户密钥(user key,64 位十六进制)"
|
||
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-gray-300 font-mono"
|
||
/>
|
||
<input
|
||
data-testid="account-name"
|
||
value={name}
|
||
onChange={e => setName(e.target.value)}
|
||
placeholder="显示名(可留空,默认用 用户名@主机)"
|
||
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-gray-300"
|
||
/>
|
||
<button
|
||
type="button"
|
||
data-testid="account-add-submit"
|
||
disabled={busy}
|
||
onClick={submit}
|
||
className="tap px-3 py-1.5 text-sm rounded-md bg-blue-600 text-white disabled:opacity-50"
|
||
>
|
||
{busy ? '验证中…' : '验证并添加'}
|
||
</button>
|
||
<p className="text-[11px] text-gray-500">添加前会调用一次 /auth/me 验证密钥,通过才写入。</p>
|
||
</div>
|
||
)}
|
||
|
||
{msg && (
|
||
<p
|
||
data-testid="account-msg"
|
||
className={`mt-2 text-xs ${msg.kind === 'ok' ? 'text-green-600' : 'text-red-600'}`}
|
||
>
|
||
{msg.text}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|