feat: v1.1.0 账号化重构 + 三级 Webhook + 设置中心 + Vite 工具链

- 账号化:节点更名账号,/nodes -> /accounts;添加账号直达 Cloudflare OAuth,自动创建账号并同步账号下全部域名(catch-all 邮箱随同步创建)
- 域名:移除手工添加;点击域名『设置 Email Workers』= 部署账号级 Email Worker + Email Routing + catch-all;支持按账号筛选
- Webhook 三级模型:邮箱 -> 域名 -> 全局,收信取第一个命中者;支持自定义请求头与 key(默认 Authorization: Bearer <key>,兼做 X-WmailHub-Signature 签名);独立 webhooks 实体与 /webhooks、/keys 页面删除,Key 管理收进设置模态窗口
- 设置中心:全局 Webhook / 默认 Node Ingress 地址 / 修改密码 / API & MCP Keys(生成、撤销、删除)
- UI:Header 标题副标题紧凑化(修复溢出),LOGO 加版本号 tag(v1.1.0 取自 package.json),各列表页搜索框
- 工具链:前端构建 Bun.build -> Vite(vite 8 + @vitejs/plugin-react)
- 后端:/api/settings、POST /api/domains/:id/setup、0002 迁移(投递行固化三级目标配置);MCP 去 list_webhooks(5 工具);迁移执行器加 _migrations 记录
- 测试:44/44(新增 settings 套件、webhook 三级优先级/SSRF/签名);UI 冒烟 11/11
This commit is contained in:
bigcode
2026-09-30 11:37:38 +08:00
parent d4c1334143
commit 421cfb2f04
47 changed files with 2316 additions and 1431 deletions
+338
View File
@@ -0,0 +1,338 @@
/** 账号页(原节点):添加账号直达 Cloudflare OAuth,授权后自动同步域名;搜索、详情、凭据管理 */
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import {
Button,
Card,
Drawer,
Input,
Space,
Table,
Tag,
Timeline,
Typography,
App,
} from "antd";
import { PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import {
deleteAccount,
getAccount,
getAccountIngressSecret,
listAccounts,
oauthMode,
oauthStart,
rotateAccount,
updateAccount,
type AccountDetail,
type AccountRow,
} from "../api.ts";
/** # 成员变量: 账号状态 -> Tag 颜色 **/
const STATUS_COLOR: Record<string, string> = {
pending_oauth: "orange",
provisioning: "processing",
online: "success",
offline: "default",
error: "error",
};
/** # 成员变量: 安装步骤中文标签(与后端 PROVISION_STEPS 对应) **/
const STEP_LABEL: Record<string, string> = {
oauth: "Cloudflare OAuth 授权",
identity: "Node identity 与 credential",
discover: "Account / Zone 发现",
domains: "域名与 catch-all 邮箱同步",
};
/** # 函数
* 账号页组件
*/
export function AccountsPage() {
const { message, modal } = App.useApp();
const navigate = useNavigate();
const [params] = useSearchParams();
const [items, setItems] = useState<AccountRow[]>([]);
const [loading, setLoading] = useState(false);
const [q, setQ] = useState("");
const [detail, setDetail] = useState<AccountDetail | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
setItems((await listAccounts()).items);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
// OAuth 回调携带 ?provision=<id> 时自动打开详情
const provisionId = params.get("provision");
if (provisionId) {
void (async () => {
try {
setDetail(await getAccount(provisionId));
setDetailOpen(true);
} catch {
// ignore
}
})();
}
}, [load, params]);
const filtered = useMemo(
() =>
items.filter((row) =>
`${row.name} ${row.status} ${row.cloudflare_account_id ?? ""}`
.toLowerCase()
.includes(q.trim().toLowerCase()),
),
[items, q],
);
/** 发起 OAuth:real 模式跳 Cloudflare;sim 模式跳本地模拟授权页。
* accountId 缺省 = 服务端自动创建账号。 */
const startOAuth = async (accountId?: string) => {
try {
const mode = (await oauthMode()).mode;
const { url } = await oauthStart(accountId);
if (mode === "cloudflare") window.location.href = url;
else navigate(url); // /oauth/sim/authorize?state=...
} catch (err) {
message.error(err instanceof Error ? err.message : "发起授权失败");
}
};
const openDetail = async (id: string) => {
setDetail(await getAccount(id));
setDetailOpen(true);
};
return (
<div>
<div className="page-toolbar">
<div className="filters">
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="搜索名称 / 状态 / Cloudflare 账户"
style={{ width: 280 }}
onChange={(e) => setQ(e.target.value)}
/>
<span className="muted">
点击「添加账号」完成 Cloudflare OAuth,自动同步账号下全部域名;在域名列表逐域名开启 Email Workers。
</span>
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => void startOAuth()}>
添加账号
</Button>
</Space>
</div>
<Card styles={{ body: { padding: 0 } }}>
<Table
rowKey="id"
loading={loading}
dataSource={filtered}
pagination={false}
onRow={(row) => ({ onClick: () => void openDetail(row.id), style: { cursor: "pointer" } })}
columns={[
{ title: "名称", dataIndex: "name" },
{
title: "状态",
dataIndex: "status",
width: 130,
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
},
{ title: "Cloudflare 账户", dataIndex: "cloudflare_account_id", ellipsis: true, render: (v: string | null) => v ?? "-" },
{ title: "Ingress", dataIndex: "ingress_url", ellipsis: true, render: (v: string) => <span className="mono">{v}</span> },
{ title: "版本", dataIndex: "version", width: 90, render: (v: string | null) => v ?? "-" },
{
title: "最近心跳",
dataIndex: "last_seen_at",
width: 170,
render: (v: string | null) => (v ? new Date(v).toLocaleString() : "-"),
},
{
title: "操作",
width: 150,
render: (_: unknown, row: AccountRow) => (
<Space>
<Button
size="small"
disabled={row.status !== "pending_oauth"}
onClick={(e) => {
e.stopPropagation();
void startOAuth(row.id);
}}
>
授权
</Button>
<Button
size="small"
danger
onClick={(e) => {
e.stopPropagation();
modal.confirm({
title: `删除账号 ${row.name}?`,
content: "将撤销账号凭据并标记为 revoked;Node 内部协议将立即失效。",
onOk: async () => {
await deleteAccount(row.id);
message.success("已删除");
void load();
},
});
}}
>
删除
</Button>
</Space>
),
},
]}
/>
</Card>
<AccountDetailDrawer
detail={detail}
open={detailOpen}
onClose={() => setDetailOpen(false)}
onReload={async () => {
if (detail) setDetail(await getAccount(detail.account.id));
void load();
}}
/>
</div>
);
}
/** # 函数
* 账号详情抽屉:安装步骤时间线 + 域名 + ingress/凭据管理
*/
function AccountDetailDrawer({
detail,
open,
onClose,
onReload,
}: {
detail: AccountDetail | null;
open: boolean;
onClose: () => void;
onReload: () => void;
}) {
const { message, modal } = App.useApp();
const [ingress, setIngress] = useState("");
const [name, setName] = useState("");
useEffect(() => {
if (detail) {
setIngress(detail.account.ingress_url);
setName(detail.account.name);
}
}, [detail]);
if (!detail) return null;
const steps = detail.steps;
return (
<Drawer title={`账号:${detail.account.name}`} size="large" open={open} onClose={onClose}>
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
<div>
<Typography.Title level={5}>安装进度</Typography.Title>
<Timeline
items={Object.entries(STEP_LABEL).map(([key, label]) => {
const st = steps[key]?.status ?? "pending";
return {
color: st === "done" ? "green" : st === "error" ? "red" : st === "running" ? "blue" : "gray",
children: (
<span>
{label}{" "}
{st === "error" && steps[key]?.error ? (
<Typography.Text type="danger">{steps[key].error}</Typography.Text>
) : (
<Tag style={{ marginLeft: 6 }}>{st}</Tag>
)}
</span>
),
};
})}
/>
</div>
<Card size="small" title="已同步域名">
{detail.domains.length === 0 ? (
<span className="muted">暂无;OAuth 授权完成后自动同步</span>
) : (
detail.domains.map((d) => (
<div key={d.id}>
<Tag color="blue">{d.name}</Tag>
<span className="muted mono">
{d.worker_id ?? "未配置 Email Workers"}
</span>
</div>
))
)}
</Card>
<Card size="small" title="Ingress 与凭据">
<Space orientation="vertical" style={{ width: "100%" }} size="middle">
<Space.Compact style={{ width: "100%" }}>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
style={{ width: 180 }}
placeholder="账号名称"
/>
<Input
value={ingress}
onChange={(e) => setIngress(e.target.value)}
placeholder="Node Ingress 地址"
/>
<Button
onClick={async () => {
await updateAccount(detail.account.id, { name, ingress_url: ingress });
message.success("已保存(新邮件将从新 Ingress 转发)");
onReload();
}}
>
保存
</Button>
</Space.Compact>
<Space wrap>
<Button
onClick={async () => {
const res = await rotateAccount(detail.account.id);
modal.info({
title: "新的 Enrollment Token(仅显示一次)",
content: <Typography.Paragraph copyable className="mono">{res.enrollment_token}</Typography.Paragraph>,
});
onReload();
}}
>
轮换凭据(生成新 Enrollment Token)
</Button>
<Button
onClick={async () => {
const res = await getAccountIngressSecret(detail.account.id);
modal.info({
title: "Ingress Secret(Email Worker -> Node 共享密钥)",
content: <Typography.Paragraph copyable className="mono">{res.ingress_secret}</Typography.Paragraph>,
});
}}
>
查看 Ingress Secret
</Button>
</Space>
<Typography.Text type="secondary" className="mono">
enrollment 有效期至 {detail.account.enrollment_expires_at ?? "-"}
</Typography.Text>
</Space>
</Card>
</Space>
</Drawer>
);
}