Files
wmailhub/console/frontend/src/pages/Accounts.tsx
T

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>
);
}