feat: v2.3.0 设置内配置 Cloudflare OAuth 客户端 + 全列表批量删除
- 设置新增「Cloudflare 接入」tab:Client ID / Client Secret(Secret AES-GCM 加密入库,
只回显末 4 位),并展示可复制的授权回调地址;settings 优先、env 变量兜底,
保存后立即生效无需重新部署;DELETE /api/settings/cf-oauth 清除
- 四个资源列表(账号/域名/邮箱/收件箱)加复选框批量选择与批量删除
- 新增 POST /api/{accounts,domains,mailboxes,emails}/batch-delete:
账号软删+撤销凭据;域名/邮箱级联删除其下邮件;邮件连 R2 原文与附件一并清理
- 抽出 deleteEmailById / deleteEmailsByMailbox / revokeAccount 供单条与批量共用
- 点击复选框不再触发行点击(避免误开详情抽屉)
- 新增 tests/batch.test.ts(6 例)与 settings OAuth 凭据用例(2 例);ui-smoke 扩到 17 步
This commit is contained in:
+20
-14
@@ -18,7 +18,13 @@ import { Hono } from "hono";
|
||||
import type { Env, AppVars } from "../env.ts";
|
||||
import { requireSession } from "../middleware.ts";
|
||||
import { ids } from "../../../shared/src/index.ts";
|
||||
import { CF_OAUTH_AUTHORIZE_URL, CF_OAUTH_SCOPES, getCFApi, isOAuthConfigured } from "../services/cfapi.ts";
|
||||
import {
|
||||
CF_OAUTH_AUTHORIZE_URL,
|
||||
CF_OAUTH_SCOPES,
|
||||
getCFApi,
|
||||
getOAuthClient,
|
||||
oauthRedirectUri,
|
||||
} from "../services/cfapi.ts";
|
||||
import { provisionAccount, createAccount } from "../services/provision.ts";
|
||||
import { encryptConnectionToken } from "../db.ts";
|
||||
import { nowIso } from "../util.ts";
|
||||
@@ -42,18 +48,20 @@ export function oauthRoutes(): App {
|
||||
});
|
||||
|
||||
/** 授权模式与入口信息(前端 Connect Cloudflare Account 向导用) */
|
||||
app.get("/mode", (c) => {
|
||||
const configured = isOAuthConfigured(c.env);
|
||||
app.get("/mode", async (c) => {
|
||||
const client = await getOAuthClient(c.env);
|
||||
return c.json({
|
||||
mode: configured ? "cloudflare" : "unconfigured",
|
||||
authorize_url_base: configured ? CF_OAUTH_AUTHORIZE_URL : null,
|
||||
mode: client ? "cloudflare" : "unconfigured",
|
||||
authorize_url_base: client ? CF_OAUTH_AUTHORIZE_URL : null,
|
||||
source: client?.source ?? "none",
|
||||
});
|
||||
});
|
||||
|
||||
/** 开始授权:每次自动创建账号记录;创建 state 并返回跳转 URL */
|
||||
app.get("/cloudflare/start", async (c) => {
|
||||
if (!isOAuthConfigured(c.env)) {
|
||||
return c.json({ error: "cloudflare oauth not configured (CF_OAUTH_CLIENT_ID/CF_OAUTH_CLIENT_SECRET)" }, 503);
|
||||
const client = await getOAuthClient(c.env);
|
||||
if (!client) {
|
||||
return c.json({ error: "cloudflare oauth not configured (在设置 → Cloudflare 接入 中填写 Client ID / Secret)" }, 503);
|
||||
}
|
||||
const created = await createAccount(c.env, {
|
||||
name: `Cloudflare 账号 ${new Date().toISOString().slice(0, 10)}`,
|
||||
@@ -66,11 +74,10 @@ export function oauthRoutes(): App {
|
||||
.bind(state, created.accountId, nowIso(), new Date(Date.now() + STATE_TTL_SECONDS * 1000).toISOString())
|
||||
.run();
|
||||
|
||||
const base = c.env.PUBLIC_BASE_URL ?? new URL(c.req.url).origin;
|
||||
const redirectUri = c.env.CF_OAUTH_REDIRECT_URI ?? `${base}/api/oauth/cloudflare/callback`;
|
||||
const redirectUri = oauthRedirectUri(c.env, new URL(c.req.url).origin);
|
||||
const url =
|
||||
`${CF_OAUTH_AUTHORIZE_URL}?response_type=code` +
|
||||
`&client_id=${encodeURIComponent(c.env.CF_OAUTH_CLIENT_ID!)}` +
|
||||
`&client_id=${encodeURIComponent(client.clientId)}` +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||
`&scope=${encodeURIComponent(CF_OAUTH_SCOPES)}` +
|
||||
`&state=${encodeURIComponent(state)}`;
|
||||
@@ -79,7 +86,7 @@ export function oauthRoutes(): App {
|
||||
|
||||
/** 授权回调(浏览器重定向):校验 state -> 兑换 token -> 自动接入 -> 跳回控制台 */
|
||||
app.get("/cloudflare/callback", async (c) => {
|
||||
if (!isOAuthConfigured(c.env)) {
|
||||
if (!(await getOAuthClient(c.env))) {
|
||||
return c.text("Cloudflare OAuth not configured", 503);
|
||||
}
|
||||
const url = new URL(c.req.url);
|
||||
@@ -89,10 +96,9 @@ export function oauthRoutes(): App {
|
||||
if (!verdict) {
|
||||
return c.text("OAuth state invalid or expired (possible CSRF)", 400);
|
||||
}
|
||||
const cf = getCFApi(c.env);
|
||||
const cf = await getCFApi(c.env);
|
||||
try {
|
||||
const base = c.env.PUBLIC_BASE_URL ?? url.origin;
|
||||
const redirectUri = c.env.CF_OAUTH_REDIRECT_URI ?? `${base}/api/oauth/cloudflare/callback`;
|
||||
const redirectUri = oauthRedirectUri(c.env, url.origin);
|
||||
const token = await cf.exchangeToken(code, redirectUri);
|
||||
// 加密入库(不进前端/日志)
|
||||
await saveConnection(c.env, token.accessToken, token.expiresIn);
|
||||
|
||||
Reference in New Issue
Block a user