459 lines
16 KiB
TypeScript
459 lines
16 KiB
TypeScript
/** 账号页:添加账号(Cloudflare OAuth / API Token 同窗平铺),授权后自动接入并同步域名;搜索、详情、凭据管理 */
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useSearchParams } from "react-router-dom";
|
|
import {
|
|
Button,
|
|
Card,
|
|
Divider,
|
|
Drawer,
|
|
Form,
|
|
Input,
|
|
Modal,
|
|
Space,
|
|
Table,
|
|
Tag,
|
|
Timeline,
|
|
Typography,
|
|
App,
|
|
} from "antd";
|
|
import { ApiOutlined, DeleteOutlined, PlusOutlined, ReloadOutlined, SafetyOutlined, SearchOutlined } from "@ant-design/icons";
|
|
import {
|
|
batchDeleteAccounts,
|
|
createAccountByToken,
|
|
deleteAccount,
|
|
getAccount,
|
|
listAccounts,
|
|
oauthStart,
|
|
rotateAccount,
|
|
updateAccount,
|
|
type AccountDetail,
|
|
type AccountRow,
|
|
} from "../api.ts";
|
|
import { BatchActions, useRowSelection } from "../components/BatchActions.tsx";
|
|
|
|
/** # 成员变量: 账号状态 -> Tag 颜色 **/
|
|
const STATUS_COLOR: Record<string, string> = {
|
|
pending_oauth: "orange",
|
|
provisioning: "processing",
|
|
active: "success",
|
|
error: "error",
|
|
};
|
|
|
|
/** # 成员变量: 接入步骤中文标签(与后端 PROVISION_STEPS 对应) **/
|
|
const STEP_LABEL: Record<string, string> = {
|
|
oauth: "Cloudflare OAuth 授权",
|
|
discover: "Account / Zone 发现",
|
|
domains: "域名与 catch-all 邮箱同步",
|
|
worker: "Email Worker 部署与 Worker 凭据",
|
|
};
|
|
|
|
/** # 函数
|
|
* 账号页组件
|
|
*/
|
|
export function AccountsPage() {
|
|
const { message, modal } = App.useApp();
|
|
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 [deleting, setDeleting] = useState(false);
|
|
const { rowSelection, ids: selectedIds, count, clear } = useRowSelection();
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
setItems((await listAccounts()).items);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
// 授权被 Cloudflare 拒绝时回调会带 ?oauth_error=...,把原因直接显示出来
|
|
const oauthError = params.get("oauth_error");
|
|
if (oauthError) {
|
|
const detailText = params.get("oauth_error_description") ?? "";
|
|
modal.info({
|
|
title: `Cloudflare 授权未完成(${oauthError})`,
|
|
content: (
|
|
<div>
|
|
<p style={{ marginBottom: 8 }}>{detailText || "Cloudflare 拒绝了本次授权请求。"}</p>
|
|
<p className="muted" style={{ marginBottom: 0 }}>
|
|
常见原因:OAuth App 未 promote to public、Client URI 域名未校验、
|
|
或 App 中登记的 scope 与本应用请求的不一致(需点号分隔)。
|
|
到「设置 → Cloudflare 接入」点「检测授权配置」可看到具体原因。
|
|
</p>
|
|
</div>
|
|
),
|
|
});
|
|
return;
|
|
}
|
|
// 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],
|
|
);
|
|
|
|
const openDetail = async (id: string) => {
|
|
setDetail(await getAccount(id));
|
|
setDetailOpen(true);
|
|
};
|
|
|
|
/** 添加账号:OAuth 与 API Token 两种方式都收在同一个模态窗口里 */
|
|
const [addOpen, setAddOpen] = useState(false);
|
|
/** API Token 提交的 loading(要防重复点击,一次接入要走好几次 Cloudflare 调用) **/
|
|
const [adding, setAdding] = useState(false);
|
|
const [tokenForm] = Form.useForm();
|
|
|
|
/** API Token 授权:校验 + 建账号 + 自动接入,一条龙 */
|
|
const submitToken = async () => {
|
|
const values = await tokenForm.validateFields();
|
|
setAdding(true);
|
|
try {
|
|
const res = await createAccountByToken({
|
|
name: values.name,
|
|
token: values.token.trim(),
|
|
});
|
|
if (!res.ok) {
|
|
message.error(res.error ?? "接入失败");
|
|
return;
|
|
}
|
|
message.success(`已接入账号 ${values.name},正在同步域名`);
|
|
setAddOpen(false);
|
|
tokenForm.resetFields();
|
|
void load();
|
|
// 直接打开详情看接入步骤,和 OAuth 回调回来的体验一致
|
|
setDetail(await getAccount(res.account_id));
|
|
setDetailOpen(true);
|
|
} catch (err) {
|
|
message.error(err instanceof Error ? err.message : "接入失败");
|
|
} finally {
|
|
setAdding(false);
|
|
}
|
|
};
|
|
|
|
/** OAuth 授权:整页跳转到 Cloudflare 授权页(授权完成后浏览器重定向回 Console 回调端点) */
|
|
const startOAuth = async () => {
|
|
try {
|
|
const { url } = await oauthStart();
|
|
window.location.href = url;
|
|
} catch (err) {
|
|
message.error(err instanceof Error ? err.message : "发起授权失败");
|
|
}
|
|
};
|
|
|
|
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">接入后自动同步域名、部署 Email Worker 并签发 Worker 凭据(Email Worker 直连 Console)。</span>
|
|
</div>
|
|
<Space>
|
|
<BatchActions
|
|
count={count}
|
|
onClear={clear}
|
|
loading={deleting}
|
|
title={`删除选中的 ${count} 个账号?`}
|
|
description="账号的 Worker 凭据会被撤销(已部署的 Email Worker 随后无法再向 Console 投递);域名与邮箱数据保留。"
|
|
onDelete={async () => {
|
|
setDeleting(true);
|
|
try {
|
|
const res = await batchDeleteAccounts(selectedIds);
|
|
message.success(`已删除 ${res.deleted} 个账号`);
|
|
clear();
|
|
void load();
|
|
} catch (err) {
|
|
message.error(err instanceof Error ? err.message : "批量删除失败");
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
}}
|
|
/>
|
|
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setAddOpen(true)}>
|
|
添加账号
|
|
</Button>
|
|
</Space>
|
|
</div>
|
|
|
|
<Card styles={{ body: { padding: 0 } }}>
|
|
<Table
|
|
rowKey="id"
|
|
loading={loading}
|
|
dataSource={filtered}
|
|
rowSelection={rowSelection}
|
|
pagination={false}
|
|
onRow={(row) => ({
|
|
onClick: (e) => {
|
|
// 点击复选框不打开详情抽屉
|
|
if ((e.target as HTMLElement).closest(".ant-table-selection-cell")) return;
|
|
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: "接入时间",
|
|
dataIndex: "created_at",
|
|
width: 170,
|
|
render: (v: string) => new Date(v).toLocaleString(),
|
|
},
|
|
{
|
|
title: "操作",
|
|
width: 110,
|
|
render: (_: unknown, row: AccountRow) => (
|
|
<Space>
|
|
<Button
|
|
size="small"
|
|
danger
|
|
icon={<DeleteOutlined />}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
modal.confirm({
|
|
title: `删除账号 ${row.name}?`,
|
|
content:
|
|
"将撤销该账号的 Worker 凭据并标记为 revoked;同时删除 Cloudflare 上的 Email Worker 与各域名的 Email Routing 配置。",
|
|
onOk: async () => {
|
|
const r = (await deleteAccount(row.id)) as { warnings?: string[] };
|
|
if (r.warnings?.length) message.warning(`已删除,但 Cloudflare 清理未完全成功:${r.warnings[0]}`);
|
|
else message.success("已删除(Cloudflare 侧资源已一并清理)");
|
|
void load();
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
删除
|
|
</Button>
|
|
</Space>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
</Card>
|
|
|
|
<AddAccountModal
|
|
open={addOpen}
|
|
loading={adding}
|
|
form={tokenForm}
|
|
onCancel={() => {
|
|
if (adding) return;
|
|
setAddOpen(false);
|
|
tokenForm.resetFields();
|
|
}}
|
|
onOAuth={() => void startOAuth()}
|
|
onTokenSubmit={() => void submitToken()}
|
|
/>
|
|
|
|
<AccountDetailDrawer
|
|
detail={detail}
|
|
open={detailOpen}
|
|
onClose={() => setDetailOpen(false)}
|
|
onReload={async () => {
|
|
if (detail) setDetail(await getAccount(detail.account.id));
|
|
void load();
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** # 成员变量: 添加账号模态窗口的属性 **/
|
|
interface AddAccountModalProps {
|
|
open: boolean;
|
|
loading: boolean;
|
|
form: ReturnType<typeof Form.useForm>[0];
|
|
onCancel: () => void;
|
|
onOAuth: () => void;
|
|
onTokenSubmit: () => void;
|
|
}
|
|
|
|
/** # 函数
|
|
* 「添加 Cloudflare 账号」模态窗口:两种授权方式平铺展示、无需切换 ——
|
|
* 顶部「Cloudflare OAuth 授权」一键跳转授权,分隔线下方是 API Token 表单
|
|
* (账户名称 + Token,账户 ID 由 Token 在 Cloudflare 侧实际所属账户自动确定)。
|
|
*/
|
|
function AddAccountModal(props: AddAccountModalProps) {
|
|
const { open, loading, form, onCancel, onOAuth, onTokenSubmit } = props;
|
|
return (
|
|
<Modal
|
|
title="添加 Cloudflare 账号"
|
|
open={open}
|
|
onCancel={onCancel}
|
|
maskClosable={!loading}
|
|
closable={!loading}
|
|
width={520}
|
|
footer={null}
|
|
destroyOnHidden
|
|
>
|
|
<Button type="primary" icon={<SafetyOutlined />} onClick={onOAuth} block>
|
|
Cloudflare OAuth 授权
|
|
</Button>
|
|
<Divider style={{ margin: "16px 0" }} />
|
|
<Form form={form} layout="vertical" requiredMark="optional">
|
|
<Form.Item name="name" label="账户名称" rules={[{ required: true, message: "请输入账户名称" }]}>
|
|
<Input placeholder="我的主账号" />
|
|
</Form.Item>
|
|
<Form.Item name="token" label="API Token" rules={[{ required: true, message: "请输入 API Token" }]}>
|
|
<Input.Password placeholder="粘贴 Cloudflare API Token" autoComplete="new-password" />
|
|
</Form.Item>
|
|
<Button type="primary" icon={<ApiOutlined />} onClick={onTokenSubmit} loading={loading} block>
|
|
{loading ? "正在校验并接入…" : "API Token 授权"}
|
|
</Button>
|
|
</Form>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
/** # 函数
|
|
* 账号详情抽屉:接入步骤时间线 + 域名 + Email Worker 凭据管理
|
|
*/
|
|
function AccountDetailDrawer({
|
|
detail,
|
|
open,
|
|
onClose,
|
|
onReload,
|
|
}: {
|
|
detail: AccountDetail | null;
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onReload: () => void;
|
|
}) {
|
|
const { message, modal } = App.useApp();
|
|
const [name, setName] = useState("");
|
|
|
|
useEffect(() => {
|
|
if (detail) 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">{d.worker_id ? "已配置 Email Workers" : "未配置 Email Workers"}</span>
|
|
</div>
|
|
))
|
|
)}
|
|
</Card>
|
|
|
|
<Card size="small" title="Email Worker 与凭据">
|
|
<Space orientation="vertical" style={{ width: "100%" }} size="middle">
|
|
<Space.Compact style={{ width: "100%" }}>
|
|
<Input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="账号名称"
|
|
/>
|
|
<Button
|
|
onClick={async () => {
|
|
await updateAccount(detail.account.id, { name });
|
|
message.success("已保存");
|
|
onReload();
|
|
}}
|
|
>
|
|
保存
|
|
</Button>
|
|
</Space.Compact>
|
|
<Typography.Text type="secondary" className="mono">
|
|
Worker ID:{detail.account.worker_id ?? "-"}
|
|
{detail.credential ? ` · 凭据 ****${detail.credential.secret_last4}(有效)` : ""}
|
|
</Typography.Text>
|
|
<Typography.Text type="secondary" style={{ display: "block" }}>
|
|
Email Worker 以该凭据直连 Console(/internal/email/authorize 与 /ingest);轮换后已配置的域名会自动重新部署。
|
|
</Typography.Text>
|
|
<Space wrap>
|
|
<Button
|
|
onClick={async () => {
|
|
const res = await rotateAccount(detail.account.id);
|
|
modal.info({
|
|
title: "新的 Worker Secret(仅显示一次)",
|
|
content: (
|
|
<div>
|
|
<Typography.Paragraph copyable className="mono" style={{ marginBottom: 4 }}>
|
|
{res.secret}
|
|
</Typography.Paragraph>
|
|
<Typography.Text type="secondary">
|
|
已自动重新部署域名:{res.redeployed.length ? res.redeployed.join("、") : "无(尚无已配置域名)"}
|
|
</Typography.Text>
|
|
</div>
|
|
),
|
|
});
|
|
onReload();
|
|
}}
|
|
>
|
|
轮换 Worker 凭据
|
|
</Button>
|
|
</Space>
|
|
</Space>
|
|
</Card>
|
|
</Space>
|
|
</Drawer>
|
|
);
|
|
}
|