v2.4.0 修 Email Routing 假成功 + 加 API Token 接入与邮箱/域名页交互改进
问题 6(收不到邮件、Dashboard 邮件路由里看不到域名)的根因:
上一版把「开启 Email Routing」改成了 PUT /zones/{id}/email/routing,
但实测该接口(以及 PATCH)是**纯回显** —— 返回 success:true 但 enabled 纹丝不动,
域名在 Cloudflare 侧仍是 unconfigured、连 MX 记录都没有。Console 却照它的返回值
把域名标成「已配置 Email Workers」,于是「显示配好了却收不到邮件」。
实测只有 POST /zones/{id}/email/routing/enable 真的开启(线上对 adx9.com 与
galaxyemail.com 逐一验证)。
后端
- cfapi: 改用 /enable,并在开启后回读 GET 校验 enabled===true,不满足即判失败;
新增 CFApiError(带 Cloudflare 错误码)与 getEmailRoutingSettings
- cfapi: 错误码 2008(Non-Cloudflare MX records exist)翻译成中文并点名冲突的 MX 记录
- provision: Email Worker 统一命名 wmailhub-email(原先按账号 id 拼名),
部署走 PUT,同名即覆盖更新
- db: 新增 decryptAccountToken —— 按账号取 token,不再取「最近一条连接」,
并跳过已过期的 OAuth token;oauth_connections.account_id 真正写入
- resources: 新增 POST /api/accounts/token(API Token 方式添加账号):
先校验 token 与账户 ID,确认可用才建账号并自动接入
前端
- 账号:「添加账号」改为模态窗口,OAuth / API Token 两个 tab,各自带说明
- 域名: 删除「Email Worker」列;「设置 Email Workers / 重新配置」先弹确认框
(说明会改 MX 与路由),执行期间按钮 loading 且互相禁用;error 状态改成可读的 Alert + 重试
- 邮箱: 删除「状态」列(已由「启用」开关承担);地址列加复制按钮(带弹跳+变绿动画);
「添加邮箱」窗口的域名只列已装 Email Workers 的、支持搜索、默认选中最近使用的域名;
地址框可一键从 namedb 取随机用户名;勾选 catch-all 后地址框禁用
- 收件箱: 域名筛选只显示已装 Email Workers 的域名
测试
- cf-mock 复刻「PUT/PATCH 回 success 但不改 enabled」与 2008,锁住本次修复
- oauth.test 新增 4 例:2008 冲突、回读校验、API Token 接入、Token/账户 ID 校验失败
- ui-smoke 覆盖新增的添加账号/添加邮箱/确认框等交互(28/28 通过)
文档: docs/02 补「三种开启接口只有 /enable 有效」的实测表、2008 处理、
两种接入方式与按账号取 token 的说明
This commit is contained in:
@@ -2,20 +2,26 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Radio,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Timeline,
|
||||
Typography,
|
||||
App,
|
||||
} from "antd";
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { ApiOutlined, DeleteOutlined, PlusOutlined, ReloadOutlined, SafetyOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
batchDeleteAccounts,
|
||||
createAccountByToken,
|
||||
deleteAccount,
|
||||
getAccount,
|
||||
listAccounts,
|
||||
@@ -111,7 +117,48 @@ export function AccountsPage() {
|
||||
[items, q],
|
||||
);
|
||||
|
||||
/** 发起 OAuth:整页跳转到 Cloudflare 授权页(授权完成后浏览器重定向回 Console 回调端点) */
|
||||
const openDetail = async (id: string) => {
|
||||
setDetail(await getAccount(id));
|
||||
setDetailOpen(true);
|
||||
};
|
||||
|
||||
/** 添加账号:OAuth 与 API Token 两种方式都收在同一个模态窗口里 */
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
/** 当前选中的授权方式 **/
|
||||
const [addMode, setAddMode] = useState<"oauth" | "token">("oauth");
|
||||
/** 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,
|
||||
cloudflare_account_id: values.cloudflare_account_id?.trim() || undefined,
|
||||
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();
|
||||
@@ -121,11 +168,6 @@ export function AccountsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const openDetail = async (id: string) => {
|
||||
setDetail(await getAccount(id));
|
||||
setDetailOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-toolbar">
|
||||
@@ -138,8 +180,8 @@ export function AccountsPage() {
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<span className="muted">
|
||||
点击「添加账号」完成 Cloudflare OAuth;Console 自动同步域名、部署 Email Worker
|
||||
并签发 Worker 凭据(Email Worker 直连 Console)。
|
||||
「添加账号」支持 Cloudflare OAuth 授权与 API Token 两种方式;接入后自动同步域名、
|
||||
部署 Email Worker 并签发 Worker 凭据(Email Worker 直连 Console)。
|
||||
</span>
|
||||
</div>
|
||||
<Space>
|
||||
@@ -164,7 +206,7 @@ export function AccountsPage() {
|
||||
}}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => void startOAuth()}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setAddOpen(true)}>
|
||||
添加账号
|
||||
</Button>
|
||||
</Space>
|
||||
@@ -237,6 +279,21 @@ export function AccountsPage() {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<AddAccountModal
|
||||
open={addOpen}
|
||||
mode={addMode}
|
||||
loading={adding}
|
||||
form={tokenForm}
|
||||
onModeChange={setAddMode}
|
||||
onCancel={() => {
|
||||
if (adding) return;
|
||||
setAddOpen(false);
|
||||
tokenForm.resetFields();
|
||||
}}
|
||||
onOAuth={() => void startOAuth()}
|
||||
onTokenSubmit={() => void submitToken()}
|
||||
/>
|
||||
|
||||
<AccountDetailDrawer
|
||||
detail={detail}
|
||||
open={detailOpen}
|
||||
@@ -250,6 +307,126 @@ export function AccountsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** # 成员变量: 添加账号模态窗口的属性 **/
|
||||
interface AddAccountModalProps {
|
||||
open: boolean;
|
||||
mode: "oauth" | "token";
|
||||
loading: boolean;
|
||||
form: ReturnType<typeof Form.useForm>[0];
|
||||
onModeChange: (mode: "oauth" | "token") => void;
|
||||
onCancel: () => void;
|
||||
onOAuth: () => void;
|
||||
onTokenSubmit: () => void;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 「添加账号」模态窗口:Cloudflare OAuth 与 API Token 两种授权方式,各自带说明。
|
||||
* 两种方式最终都走同一套自动接入流程(发现域名 -> 部署 Email Worker -> 签发 Worker 凭据),
|
||||
* 区别只在于 token 从哪来:OAuth 走用户点授权,API Token 由用户自己粘贴。
|
||||
*/
|
||||
function AddAccountModal(props: AddAccountModalProps) {
|
||||
const { open, mode, loading, form, onModeChange, onCancel, onOAuth, onTokenSubmit } = props;
|
||||
return (
|
||||
<Modal
|
||||
title="添加 Cloudflare 账号"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
maskClosable={!loading}
|
||||
closable={!loading}
|
||||
width={620}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Tabs
|
||||
activeKey={mode}
|
||||
onChange={(k) => onModeChange(k as "oauth" | "token")}
|
||||
items={[
|
||||
{
|
||||
key: "oauth",
|
||||
label: (
|
||||
<span>
|
||||
<SafetyOutlined /> Cloudflare OAuth 授权
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<Space orientation="vertical" size="middle" style={{ width: "100%" }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="推荐:不需要在本机保存任何密钥"
|
||||
description={
|
||||
<span>
|
||||
点击下方按钮跳转到 Cloudflare 官方授权页,用你的 Cloudflare 账号登录并同意授权,
|
||||
完成后自动跳回本控制台。Console 会用拿到的授权去发现该账号下的域名、
|
||||
部署 Email Worker 并签发 Worker 凭据,全程无需在本系统里保存 Cloudflare 密钥。
|
||||
<br />
|
||||
前提:「设置 → Cloudflare 接入」里已填好 Client ID / Secret,且 OAuth App
|
||||
已勾选本应用需要的权限(scope 用点号分隔)。
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Button type="primary" icon={<SafetyOutlined />} onClick={onOAuth} block>
|
||||
前往 Cloudflare 授权
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "token",
|
||||
label: (
|
||||
<span>
|
||||
<ApiOutlined /> API Token 授权
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<Form form={form} layout="vertical" requiredMark="optional">
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="适合不方便走 OAuth 跳转的场景"
|
||||
description={
|
||||
<span>
|
||||
直接粘贴一个 Cloudflare API Token。Token 会在提交时先被 Cloudflare 校验一遍
|
||||
(并与填写的账户 ID 比对),确认可用后才建账号并自动接入,填错会当场报错。
|
||||
Token 加密入库、不回显、不写日志。
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="账户名称"
|
||||
rules={[{ required: true, message: "请输入账户名称" }]}
|
||||
>
|
||||
<Input placeholder="我的主账号" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="cloudflare_account_id"
|
||||
label="账户 ID"
|
||||
extra="Cloudflare 账户 ID(账户主页右侧的 Account ID)。可留空,留空则以 Token 实际所属账户为准;填错会被校验拦下。"
|
||||
>
|
||||
<Input placeholder="bf8cd983e6dfd171c1881b00b512ed5a" className="mono" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="token"
|
||||
label="API Token"
|
||||
rules={[{ required: true, message: "请输入 API Token" }]}
|
||||
extra="到 Cloudflare → My Profile → API Tokens 生成。所需权限与 OAuth 方式一致:Zone Read、Workers Scripts Edit、Email Routing Rules Edit、Zone Settings Write。"
|
||||
>
|
||||
<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 凭据管理
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user