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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user