diff --git a/README.md b/README.md index 34caf30..3aeab6a 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ export CF_OAUTH_REDIRECT_URI=http://127.0.0.1:18787/api/oauth/cloudflare/callbac - **用户登录**:用户名密码(PBKDF2),默认 admin/admin,HMAC 签名会话 cookie;密码在「设置」中修改。 - **账号管理**:「添加账号」直达 Cloudflare OAuth → 自动创建账号并同步该账号下全部域名、部署 Email Worker、签发 Worker 凭据;支持凭据轮换(轮换后已配置域名自动重新部署)。 +- **Cloudflare 接入配置**:设置 →「Cloudflare 接入」填写 OAuth Client ID / Secret(Secret AES-GCM 加密入库,只回显末 4 位),保存后立即生效、无需重新部署;未配置时「添加账号」返回 503。 +- **批量操作**:账号 / 域名 / 邮箱 / 收件箱四个列表均支持复选框选择与批量删除。账号为软删(撤销 Worker 凭据);域名与邮箱级联删除其下邮件;邮件连同 R2 原文与附件一并清理。 - **域名**:由 OAuth 自动同步(无手工添加);点击域名「设置 Email Workers」= 确保账号级 Worker 部署 + 开启 Email Routing + catch-all 规则;支持按账号筛选。 - **两阶段接收**:authorize(SMTP 决策:accept/reject/temporary_error,拒绝码 DOMAIN_NOT_FOUND / DOMAIN_DISABLED / MAILBOX_NOT_FOUND / MAILBOX_DISABLED / RECEIVING_DISABLED)+ ingest(raw MIME 入库);动态邮箱创建/删除/禁用下一封邮件立即生效。 - **邮件管线**:MIME 解析(postal-mime)、附件、catch-all/精确匹配、大小限额、malformed 降级入库。 @@ -123,7 +125,11 @@ WmailHub/ 3. 依次执行 `bunx wrangler d1 execute wmailhub --remote --file=./migrations/0001_init.sql` … `0004_single_key_markdown.sql`(0001~0004 按序号全部执行,0004 依赖 0001 的 api_credentials) 4. `cd console && bunx wrangler deploy`(前端先 `bun run build`);`PUBLIC_BASE_URL` 即 Email Worker 注入的 CONSOLE_URL 5. `bunx wrangler secret put SESSION_SECRET --config console/wrangler.jsonc`(≥32 位随机串) -6. Cloudflare Dashboard 创建 OAuth App(client id/secret),配置 `CF_OAUTH_CLIENT_ID/SECRET` vars 与回调 `https://wmailhub.adx9.com/api/oauth/cloudflare/callback`。**这是接入 Cloudflare 账号的硬前提**:未配置时 `/api/oauth/cloudflare/start` 与 `/cloudflare/callback` 直接返回 503,系统不会降级到任何模拟通道 +6. Cloudflare Dashboard 创建 OAuth App(client id/secret),回调填 `https://wmailhub.adx9.com/api/oauth/cloudflare/callback`(页面里会显示这个地址,直接复制)。Client ID / Secret 有两个配置位置,任一即可: + - **推荐**:部署后登录 Console → 设置 →「Cloudflare 接入」填写,Secret 加密入库,改完立即生效,以后换 client 也不用重新部署; + - 或部署时用 wrangler 配成 vars/secret(`CF_OAUTH_CLIENT_ID` / `CF_OAUTH_CLIENT_SECRET`),作为后台未配置时的兜底。 + + 两者都没有时,`/api/oauth/cloudflare/start` 与 `/cloudflare/callback` 直接返回 503,系统不会降级到任何模拟通道 > 提示:`wrangler.jsonc` 里的 `database_id` 必须先填真实值,否则 `d1 execute` / `deploy` 都会报 `Invalid uuid`。 > 部署后自检:`curl https://wmailhub.adx9.com/api/health` → `{"ok":true,"name":"WmailHub","version":""}`。 diff --git a/console/frontend/src/api.ts b/console/frontend/src/api.ts index 0d54ca6..b26401a 100644 --- a/console/frontend/src/api.ts +++ b/console/frontend/src/api.ts @@ -266,21 +266,63 @@ export const getEmail = (id: string) => api(`/api/emails/${id}`); export const deleteEmail = (id: string) => api(`/api/emails/${id}`, "DELETE"); +/* ---- 批量删除(列表复选框) ---- */ + +/** # 成员变量: 批量删除结果 **/ +export interface BatchDeleteResult { + ok: boolean; + /** 成功删除的主记录数 **/ + deleted: number; + /** 级联删除的邮箱数(域名批量删除时有值) **/ + mailboxes?: number; + /** 级联删除的邮件数(域名/邮箱批量删除时有值) **/ + emails?: number; + /** 删除失败的 id 及原因(不存在/已删除) **/ + failed: { id: string; reason: string }[]; +} + +export const batchDeleteAccounts = (ids: string[]) => + api("/api/accounts/batch-delete", "POST", { ids }); + +export const batchDeleteDomains = (ids: string[]) => + api("/api/domains/batch-delete", "POST", { ids }); + +export const batchDeleteMailboxes = (ids: string[]) => + api("/api/mailboxes/batch-delete", "POST", { ids }); + +export const batchDeleteEmails = (ids: string[]) => + api("/api/emails/batch-delete", "POST", { ids }); + /* ---- 设置 ---- */ export interface SettingsInfo { username: string; webhook: { url: string; headers: Record; has_key: boolean; key_last4: string }; api_key: { has_key: boolean; key_last4: string; created_at: string | null }; + /** Cloudflare OAuth 客户端凭据元信息(不含明文 secret) */ + cf_oauth: { + client_id: string; + has_secret: boolean; + secret_last4: string; + /** settings = 后台设置;env = 部署变量兜底;none = 未配置 */ + source: "settings" | "env" | "none"; + }; + /** 需要在 Cloudflare Dashboard 登记的授权回调地址 */ + redirect_uri: string; } export const getSettings = () => api("/api/settings"); -/** 保存设置;webhook.key:undefined 保留原值、空串清除、非空设置新 key */ +/** 保存设置 + * webhook.key / cf_oauth.secret 语义一致:undefined 保留原值、空串清除、非空设置新值 */ export const putSettings = (body: { - webhook?: { url: string; headers?: Record; key?: string }; + webhook?: { url: string; headers: Record; key?: string }; + cf_oauth?: { client_id: string; secret?: string }; }) => api("/api/settings", "PUT", body); +/** 清除后台保存的 Cloudflare OAuth 客户端凭据(env 兜底不受影响) */ +export const deleteCfOAuth = () => api("/api/settings/cf-oauth", "DELETE"); + export const changePassword = (old_password: string, new_password: string) => api("/api/settings/password", "PUT", { old_password, new_password }); diff --git a/console/frontend/src/components/BatchActions.tsx b/console/frontend/src/components/BatchActions.tsx new file mode 100644 index 0000000..c141f60 --- /dev/null +++ b/console/frontend/src/components/BatchActions.tsx @@ -0,0 +1,69 @@ +/** + * 列表批量操作(v2.3.0): + * 各资源列表共用的复选框选中状态 hook + 工具条(已选计数 / 清除选择 / 批量删除)。 + */ +import { useState, type Key } from "react"; +import { Button, Popconfirm, Space, Typography } from "antd"; +import { DeleteOutlined } from "@ant-design/icons"; + +/** # 成员变量: 行选择器配置(直接展开到 antd Table 的 rowSelection) **/ +export type RowSelection = { + type: "checkbox"; + selectedRowKeys: Key[]; + onChange: (keys: Key[]) => void; +}; + +/** # 函数 + * 复选框选中状态 hook:返回 Table 的 rowSelection 配置、选中的字符串 id 列表与清空方法 + */ +export function useRowSelection(): { + rowSelection: RowSelection; + /** 选中的行 id(字符串形式,可直接提交给批量接口) **/ + ids: string[]; + count: number; + clear: () => void; +} { + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + return { + rowSelection: { type: "checkbox", selectedRowKeys, onChange: setSelectedRowKeys }, + ids: selectedRowKeys.map((k) => String(k)), + count: selectedRowKeys.length, + clear: () => setSelectedRowKeys([]), + }; +} + +/** # 成员变量: 批量操作工具条属性 **/ +interface BatchActionsProps { + /** 已选数量;0 时不渲染 **/ + count: number; + /** 清除选择 */ + onClear: () => void; + /** 确认删除后执行 */ + onDelete: () => void | Promise; + /** 删除确认标题(含数量) */ + title: string; + /** 删除影响范围说明(域名/邮箱级联删除时必填) */ + description?: string; + /** 删除进行中 */ + loading?: boolean; +} + +/** # 函数 + * 批量操作工具条:未选中时渲染 null,选中后出现在列表工具栏右侧 + */ +export function BatchActions({ count, onClear, onDelete, title, description, loading }: BatchActionsProps) { + if (count === 0) return null; + return ( + + 已选 {count} 项 + + void onDelete()}> + + + + ); +} diff --git a/console/frontend/src/components/SettingsModal.tsx b/console/frontend/src/components/SettingsModal.tsx index 2c24b88..7e7732e 100644 --- a/console/frontend/src/components/SettingsModal.tsx +++ b/console/frontend/src/components/SettingsModal.tsx @@ -1,7 +1,9 @@ /** - * 设置模态窗口(v2.1.0): - * - 两个 tab:全局 Webhook(含 API/MCP Key 卡片)、账号密码 + * 设置模态窗口(v2.3.0): + * - 三个 tab:全局 Webhook(含 API/MCP Key 卡片)、Cloudflare 接入、账号密码 * - 单一 API/MCP Key(API 与 MCP 共用),凭据存 settings 表,明文仅生成时返回一次 + * - Cloudflare OAuth 客户端(Client ID / Secret)也在设置里维护,Secret 加密存储只回 last4, + * 保存后立即生效,无需重新部署;未配置时账号页「添加账号」不可用 * - 内容高度不超过屏幕(超出滚动) */ import { useCallback, useEffect, useState } from "react"; @@ -20,12 +22,21 @@ import { Typography, } from "antd"; import { + CloudServerOutlined, CopyOutlined, DeleteOutlined, KeyOutlined, PlusOutlined, } from "@ant-design/icons"; -import { changePassword, createApiKey, deleteApiKey, getSettings, putSettings, type SettingsInfo } from "../api.ts"; +import { + changePassword, + createApiKey, + deleteApiKey, + deleteCfOAuth, + getSettings, + putSettings, + type SettingsInfo, +} from "../api.ts"; /** # 函数 * 设置模态窗口组件 @@ -61,6 +72,7 @@ export function SettingsModal({ open, onClose }: { open: boolean; onClose: () => : null }, + { key: "cloudflare", label: Cloudflare 接入, children: info ? : null }, { key: "password", label: "账号密码", children: }, ]} /> @@ -251,6 +263,125 @@ function ApiKeyCard({ ); } +/** # 成员变量: Cloudflare OAuth 客户端表单值 **/ +interface CfOAuthFormValues { + client_id: string; + secret?: string; +} + +/** # 函数 + * Cloudflare 接入 tab:维护 OAuth 客户端凭据 + 展示授权回调地址与申请权限 + */ +function CloudflareTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void }) { + const { message } = App.useApp(); + const [form] = Form.useForm(); + const [saving, setSaving] = useState(false); + const meta = info.cf_oauth; + + useEffect(() => { + form.setFieldsValue({ client_id: meta.client_id }); + }, [meta.client_id, form]); + + /** 保存 OAuth 客户端凭据 */ + const save = async () => { + const values = await form.validateFields(); + setSaving(true); + try { + await putSettings({ cf_oauth: { client_id: values.client_id.trim(), secret: values.secret } }); + message.success("Cloudflare OAuth 客户端已保存"); + form.setFieldValue("secret", undefined); + onSaved(); + } catch (err) { + message.error(err instanceof Error ? err.message : "保存失败"); + } finally { + setSaving(false); + } + }; + + /** 清除后台保存的凭据 */ + const clear = async () => { + setSaving(true); + try { + await deleteCfOAuth(); + message.success("已清除后台保存的凭据"); + form.resetFields(); + onSaved(); + } catch (err) { + message.error(err instanceof Error ? err.message : "清除失败"); + } finally { + setSaving(false); + } + }; + + return ( + + + 在 Cloudflare Dashboard 的 OAuth Applications{" "} + 新建应用,把下面的回调地址填入 Redirect URL,并勾选 Account:Read / Zone:Read / + Email Routing:Edit / Workers Scripts:Edit 四项权限。 + + } + /> + +
+ + + {info.redirect_uri} + + + + + + + + + + + {meta.source === "settings" && ( + void clear()} + > + + + )} + +
+
+ ); +} + /** # 函数 * 账号密码 tab:修改登录密码 */ diff --git a/console/frontend/src/pages/Accounts.tsx b/console/frontend/src/pages/Accounts.tsx index f6cfebb..03b20d0 100644 --- a/console/frontend/src/pages/Accounts.tsx +++ b/console/frontend/src/pages/Accounts.tsx @@ -15,6 +15,7 @@ import { } from "antd"; import { DeleteOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons"; import { + batchDeleteAccounts, deleteAccount, getAccount, listAccounts, @@ -24,6 +25,7 @@ import { type AccountDetail, type AccountRow, } from "../api.ts"; +import { BatchActions, useRowSelection } from "../components/BatchActions.tsx"; /** # 成员变量: 账号状态 -> Tag 颜色 **/ const STATUS_COLOR: Record = { @@ -52,6 +54,8 @@ export function AccountsPage() { const [q, setQ] = useState(""); const [detail, setDetail] = useState(null); const [detailOpen, setDetailOpen] = useState(false); + const [deleting, setDeleting] = useState(false); + const { rowSelection, ids: selectedIds, count, clear } = useRowSelection(); const load = useCallback(async () => { setLoading(true); @@ -120,6 +124,26 @@ export function AccountsPage() { + { + 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); + } + }} + />