fix: hide admin authorization in setup mode
This commit is contained in:
parent
8a24ec7e9e
commit
9064fc5f1d
|
|
@ -49,7 +49,7 @@
|
|||
|
||||
AI 摘要默认关闭。Telegram 用户可发送 `/ai on`(1小时)、`临时一次`、`1小时`、`今天`、`7天` 或 `直到我撤销` 明确授权,再发送 `/summary <email_id>`;授权到期不会自动续期,`/ai off` 立即撤销。未授权时仅返回邮件元数据,LLM 失败则回退为本地截断摘要。
|
||||
|
||||
> 缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时服务进入 configuration-setup 模式,仍提供 SPA、`/api/status` 和探针;状态页会提示缺少的环境变量,不回显任何密钥,也不会伪造 Redis 持久化成功。
|
||||
> 缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时服务进入 configuration-setup 模式,仍提供 SPA、`/api/status` 和探针;SPA 隐藏管理会话授权区,只显示缺少的环境变量与密钥不保存、不回显说明。配置恢复后 `/api/status` ready=true 才显示授权入口。
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
+ 相同 App Password(各后端实例的 `JMAP` 可不同,§10.2)。
|
||||
|
||||
### 路由 safelist(ARCH-LB-WORKER / C-LB-SINGLE-REG-URL)
|
||||
- 透传公开 `GET /api/status` 启动状态,以及管理 SPA API:`POST /api/admin/session[/revoke]`、`GET|PUT /api/config`、`PUT /api/business-config`。启动状态只包含缺少的环境变量名称;管理 API 的鉴权仍由后端执行。
|
||||
- 只透传 `POST /webhook/tg`、`POST /push/jmap`、`POST /reconcile`、`GET /ready`。
|
||||
- 其它路径 → **404**(不透传,避免 Worker 沦为后端任意路径的跳板)。
|
||||
- method 不符 → **405**。
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export const SAFE_ROUTES = Object.freeze([
|
|||
"/",
|
||||
"/assets/config.js",
|
||||
"/assets/styles.css",
|
||||
"/api/status",
|
||||
"/api/config",
|
||||
"/api/business-config",
|
||||
"/api/admin/session",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
*
|
||||
* 路由模型:
|
||||
* - GET /、/assets/config.js、/assets/styles.css → 内嵌 SPA 静态资源透传
|
||||
* - GET /api/status → 后端配置引导状态;只返回缺失变量名称
|
||||
* - GET|PUT /api/config、PUT /api/business-config、POST /api/admin/session[/revoke]
|
||||
* → 透传至后端;后端校验 bootstrap 凭据或短期 admin session
|
||||
* - GET /healthz → LB 聚合健康(MOD-HEALTH-AGG)
|
||||
|
|
@ -40,6 +41,7 @@ const ROUTE_METHODS = Object.freeze({
|
|||
"/": ["GET"],
|
||||
"/assets/config.js": ["GET"],
|
||||
"/assets/styles.css": ["GET"],
|
||||
"/api/status": ["GET"],
|
||||
"/api/config": ["GET", "PUT"],
|
||||
"/api/business-config": ["PUT"],
|
||||
"/api/admin/session": ["POST"],
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ test("BACKEND_ORIGINS_JSON parses, dedups, validates all entries", () => {
|
|||
|
||||
test("route safelist excludes /healthz and unknown paths (C-LB-SINGLE-REG-URL)", () => {
|
||||
for (const r of [
|
||||
"/", "/assets/config.js", "/assets/styles.css", "/ready", "/webhook/tg", "/push/jmap", "/reconcile",
|
||||
"/", "/assets/config.js", "/assets/styles.css", "/api/status", "/ready", "/webhook/tg", "/push/jmap", "/reconcile",
|
||||
"/api/config", "/api/business-config", "/api/admin/session", "/api/admin/session/revoke",
|
||||
]) {
|
||||
assert.ok(PROXIED_ROUTES.includes(r), `proxied: ${r}`);
|
||||
|
|
|
|||
|
|
@ -80,6 +80,30 @@ test("Worker: business config PUT is proxied with the session bearer and empty 2
|
|||
}
|
||||
});
|
||||
|
||||
test("Worker: public setup status is proxied without an authorization header", async () => {
|
||||
const restore = stubFetch(async (url, init) => {
|
||||
assert.match(url, /^https:\/\/(a|b)\.example\/api\/status$/);
|
||||
assert.equal(init.method, "GET");
|
||||
assert.equal(new Headers(init.headers).has("authorization"), false);
|
||||
return new Response(JSON.stringify({
|
||||
ready: false,
|
||||
mode: "configuration-setup",
|
||||
missing: ["REDIS_URL"],
|
||||
}), { status: 200, headers: { "content-type": "application/json" } });
|
||||
});
|
||||
try {
|
||||
const res = await handleFetch(new Request("https://lb.example/api/status"), makeEnv());
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(await res.json(), {
|
||||
ready: false,
|
||||
mode: "configuration-setup",
|
||||
missing: ["REDIS_URL"],
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("Worker: misconfigured origins => 503 fail-closed (no env leak)", async () => {
|
||||
const res = await handleFetch(
|
||||
new Request("https://lb.example/reconcile", { method: "POST", body: "{}" }),
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ TLS 必须由 Cloudflare 或受信任反向代理终结;代理到容器的链
|
|||
# 平台公网 URL: https://bot.example.com → 反代 → 容器 127.0.0.1:8080
|
||||
GET / 业务配置与运行参数管理 SPA
|
||||
GET /assets/config.js SPA 脚本;GET /assets/styles.css SPA 样式
|
||||
GET /api/status 公开启动状态;只返回 ready/mode/missing 环境变量名
|
||||
POST /api/admin/session Bearer Redis ACL 密码;成功返回 900 秒 admin session
|
||||
POST /api/admin/session/revoke Bearer admin session;成功返回 204
|
||||
GET|PUT /api/config Bearer admin session 或 WORKER_TOKEN;Redis 错误返回 503
|
||||
|
|
@ -338,7 +339,7 @@ Stalwart PushSub ────┼─▶ https://lb.<you>.workers.dev ← 唯
|
|||
|
||||
### 10.4 路由与故障转移
|
||||
|
||||
- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 `GET /`、SPA 静态资源、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`POST /webhook/tg`、`POST /push/jmap`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。
|
||||
- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 `GET /`、SPA 静态资源、`GET /api/status`、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`POST /webhook/tg`、`POST /push/jmap`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。
|
||||
- **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, available, total, backends:[{origin,up,status}]}`;≥1 后端 up → 200,全 down → 503。`/ready` 仍**透传**给后端,由 `GATE-READY-DEPS` 承担真实依赖探测。
|
||||
- **故障转移(`proxyWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。
|
||||
- **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。
|
||||
|
|
|
|||
|
|
@ -439,6 +439,7 @@ stalwart-bot/
|
|||
|
||||
### 7.3.1 配置管理 API
|
||||
- `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。
|
||||
- `GET /api/status` 公开返回 `{ "ready": boolean, "mode": "configured" | "configuration-setup", "missing": string[] }`,只列缺少的环境变量名称。缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时,SPA 只显示配置引导状态与缺失变量;服务状态确认 ready=true 后才显示管理会话授权区。
|
||||
- `POST /api/admin/session` 接受 `Authorization: Bearer <REDIS_URL ACL password>`,返回 `{ "session": "<opaque>", "expires_in": 900 }`;admin session 仅存 Redis 中的摘要并在 900 秒后过期。`POST /api/admin/session/revoke` 撤销当前 session,成功返回 `204`。
|
||||
- 管理页面仅在 JavaScript 内存中保存 session。请求设置 `credentials: omit`、`cache: no-store`,不使用 Cookie、localStorage 或 sessionStorage。管理 API 接受有效 admin session;兼容路径也接受 `WORKER_TOKEN`。Worker 原样透传鉴权头(`SAF-LB-PASSTHRU`)。
|
||||
- `GET /api/config` 与 `PUT /api/config` 只读取和写入非敏感运行参数:
|
||||
|
|
|
|||
|
|
@ -24,7 +24,13 @@
|
|||
|
||||
const bootstrapInput = document.querySelector("#bootstrap-input");
|
||||
const authForm = document.querySelector("#auth-form");
|
||||
const intro = document.querySelector("#intro");
|
||||
const authCard = document.querySelector("#auth-card");
|
||||
const setupCard = document.querySelector("#setup-card");
|
||||
const setupMessage = document.querySelector("#setup-message");
|
||||
const setupMissing = document.querySelector("#setup-missing");
|
||||
const statusRetryButton = document.querySelector("#status-retry-button");
|
||||
const privacyNote = document.querySelector("#privacy-note");
|
||||
const configPanel = document.querySelector("#config-panel");
|
||||
const businessForm = document.querySelector("#business-form");
|
||||
const businessFields = document.querySelector("#business-fields");
|
||||
|
|
@ -50,6 +56,7 @@
|
|||
let expiryTimer = 0;
|
||||
let busyAction = "";
|
||||
let runtimeLoaded = false;
|
||||
let setupMode = true;
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(status, message) {
|
||||
|
|
@ -59,6 +66,7 @@
|
|||
}
|
||||
|
||||
function showNotice(kind, message) {
|
||||
notice.hidden = false;
|
||||
notice.className = `notice notice-${kind}`;
|
||||
notice.textContent = message;
|
||||
notice.setAttribute("role", kind === "error" ? "alert" : "status");
|
||||
|
|
@ -122,27 +130,71 @@
|
|||
}
|
||||
|
||||
async function showSetupStatus() {
|
||||
showSetupView("正在检查服务启动配置…");
|
||||
statusRetryButton.disabled = true;
|
||||
statusRetryButton.textContent = "正在检查…";
|
||||
try {
|
||||
const status = await request("/api/status", "GET", undefined, "");
|
||||
if (status && status.ready === false) {
|
||||
const missing = Array.isArray(status.missing) ? status.missing.join("、") : "启动配置";
|
||||
showNotice("error", `服务处于配置引导模式。请在部署环境设置:${missing};密钥不会在页面保存或回显。`);
|
||||
if (status && status.ready === true) {
|
||||
setupMode = false;
|
||||
setupCard.hidden = true;
|
||||
notice.hidden = false;
|
||||
intro.hidden = false;
|
||||
authCard.hidden = false;
|
||||
configPanel.hidden = true;
|
||||
privacyNote.hidden = false;
|
||||
showNotice("info", "使用 Redis 管理凭据创建短期管理会话。");
|
||||
return;
|
||||
}
|
||||
const isSetup = status && status.ready === false && status.mode === "configuration-setup";
|
||||
if (isSetup) {
|
||||
const knownMissing = Array.isArray(status.missing)
|
||||
? status.missing.filter((name) => name === "REDIS_URL" || name === "CONFIG_ENCRYPTION_KEY")
|
||||
: [];
|
||||
const message = knownMissing.length
|
||||
? "服务处于配置引导模式。请在部署环境补齐以下启动变量:"
|
||||
: "服务处于配置引导模式,但状态接口未返回缺少的启动变量名称。";
|
||||
showSetupView(message, knownMissing);
|
||||
return;
|
||||
}
|
||||
showSetupView("无法确认服务是否已完成启动配置。管理授权入口保持隐藏,请重新检查服务状态。");
|
||||
} catch {
|
||||
// The regular connection flow reports unavailable services without exposing details.
|
||||
showSetupView("暂时无法读取服务启动状态。为保护管理凭据,授权入口保持隐藏;请确认服务可用后重新检查。");
|
||||
} finally {
|
||||
statusRetryButton.disabled = false;
|
||||
statusRetryButton.textContent = "重新检查";
|
||||
}
|
||||
}
|
||||
|
||||
function showSetupView(message, missing = []) {
|
||||
setupMode = true;
|
||||
setupMessage.textContent = message;
|
||||
setupMissing.textContent = missing.join("、");
|
||||
setupMissing.hidden = missing.length === 0;
|
||||
setupCard.hidden = false;
|
||||
intro.hidden = true;
|
||||
notice.hidden = true;
|
||||
authCard.hidden = true;
|
||||
configPanel.hidden = true;
|
||||
privacyNote.hidden = true;
|
||||
}
|
||||
|
||||
function showConfigView() {
|
||||
setupCard.hidden = true;
|
||||
intro.hidden = false;
|
||||
privacyNote.hidden = false;
|
||||
authCard.hidden = true;
|
||||
configPanel.hidden = false;
|
||||
}
|
||||
|
||||
function showAuthView() {
|
||||
setupCard.hidden = true;
|
||||
intro.hidden = false;
|
||||
privacyNote.hidden = false;
|
||||
configPanel.hidden = true;
|
||||
authCard.hidden = false;
|
||||
authCard.hidden = setupMode;
|
||||
bootstrapInput.value = "";
|
||||
bootstrapInput.focus();
|
||||
if (!setupMode) bootstrapInput.focus();
|
||||
}
|
||||
|
||||
function clearSecretFields() {
|
||||
|
|
@ -490,6 +542,8 @@
|
|||
showNotice("info", "已退出;管理会话和表单中的密钥已从页面内存清除。");
|
||||
});
|
||||
|
||||
statusRetryButton.addEventListener("click", showSetupStatus);
|
||||
|
||||
defaultsButton.addEventListener("click", () => {
|
||||
if (!runtimeLoaded || busyAction) return;
|
||||
fillRuntimeFields(RUNTIME_DEFAULTS);
|
||||
|
|
@ -574,7 +628,7 @@
|
|||
clearForms();
|
||||
clearSecretFields();
|
||||
configPanel.hidden = true;
|
||||
authCard.hidden = false;
|
||||
authCard.hidden = setupMode;
|
||||
bootstrapInput.value = "";
|
||||
setBusy("");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
|
||||
const html = readFileSync(new URL("./index.html", import.meta.url), "utf8");
|
||||
const source = readFileSync(new URL("./config.js", import.meta.url), "utf8");
|
||||
|
||||
class FakeElement {
|
||||
constructor(hidden = false) {
|
||||
this.hidden = hidden;
|
||||
this.disabled = false;
|
||||
this.required = false;
|
||||
this.checked = false;
|
||||
this.value = "";
|
||||
this.textContent = "";
|
||||
this.listeners = new Map();
|
||||
this.attributes = new Map();
|
||||
this.classList = {
|
||||
toggle() {},
|
||||
add() {},
|
||||
remove() {},
|
||||
};
|
||||
this.children = new Map();
|
||||
}
|
||||
|
||||
addEventListener(type, callback) {
|
||||
this.listeners.set(type, callback);
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, value);
|
||||
}
|
||||
|
||||
querySelector(selector) {
|
||||
if (!this.children.has(selector)) this.children.set(selector, new FakeElement());
|
||||
return this.children.get(selector);
|
||||
}
|
||||
|
||||
querySelectorAll() { return []; }
|
||||
focus() {}
|
||||
reset() {}
|
||||
setCustomValidity() {}
|
||||
reportValidity() { return true; }
|
||||
}
|
||||
|
||||
async function startPage(statusResponse) {
|
||||
const elements = new Map();
|
||||
for (const match of html.matchAll(/<[^>]*\bid="([^"]+)"[^>]*>/gs)) {
|
||||
const [, id] = match;
|
||||
const hidden = /\shidden(?:\s|=|>)/.test(match[0]);
|
||||
elements.set(id, new FakeElement(hidden));
|
||||
}
|
||||
const document = {
|
||||
querySelector(selector) {
|
||||
const match = selector.match(/^#(.+)$/);
|
||||
if (!match || !elements.has(match[1])) throw new Error(`Missing selector: ${selector}`);
|
||||
return elements.get(match[1]);
|
||||
},
|
||||
getElementById(id) {
|
||||
if (!elements.has(id)) throw new Error(`Missing element: ${id}`);
|
||||
return elements.get(id);
|
||||
},
|
||||
querySelectorAll() { return []; },
|
||||
};
|
||||
const window = {
|
||||
addEventListener() {},
|
||||
setTimeout() { return 1; },
|
||||
clearTimeout() {},
|
||||
};
|
||||
const fetchCalls = [];
|
||||
const fetch = async (path, options) => {
|
||||
fetchCalls.push({ path, options });
|
||||
if (path !== "/api/status") throw new Error(`Unexpected request: ${path}`);
|
||||
if (statusResponse instanceof Error) throw statusResponse;
|
||||
const httpStatus = statusResponse.httpStatus ?? 200;
|
||||
return {
|
||||
ok: httpStatus >= 200 && httpStatus < 300,
|
||||
status: httpStatus,
|
||||
text: async () => JSON.stringify(statusResponse.body),
|
||||
};
|
||||
};
|
||||
|
||||
vm.runInNewContext(source, { document, window, fetch, Headers });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await Promise.resolve();
|
||||
return { elements, fetchCalls };
|
||||
}
|
||||
|
||||
test("configuration-setup hides all authorization UI and lists only missing variable names", async () => {
|
||||
const { elements, fetchCalls } = await startPage({
|
||||
body: {
|
||||
ready: false,
|
||||
mode: "configuration-setup",
|
||||
missing: ["REDIS_URL", "CONFIG_ENCRYPTION_KEY", "secret-value-must-not-display"],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(elements.get("setup-card").hidden, false);
|
||||
assert.equal(elements.get("intro").hidden, true);
|
||||
assert.equal(elements.get("auth-card").hidden, true);
|
||||
assert.equal(elements.get("config-panel").hidden, true);
|
||||
assert.equal(elements.get("notice").hidden, true);
|
||||
assert.equal(elements.get("privacy-note").hidden, true);
|
||||
assert.equal(elements.get("setup-missing").textContent, "REDIS_URL、CONFIG_ENCRYPTION_KEY");
|
||||
assert.equal(elements.get("setup-message").textContent.includes("配置引导模式"), true);
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.equal(fetchCalls[0].path, "/api/status");
|
||||
assert.equal(fetchCalls[0].options.credentials, "omit");
|
||||
assert.equal(fetchCalls[0].options.cache, "no-store");
|
||||
assert.equal(fetchCalls[0].options.headers.has("authorization"), false);
|
||||
});
|
||||
|
||||
test("ready status preserves the existing admin-session entry and configuration page", async () => {
|
||||
const { elements } = await startPage({
|
||||
body: { ready: true, mode: "configured", missing: [] },
|
||||
});
|
||||
|
||||
assert.equal(elements.get("setup-card").hidden, true);
|
||||
assert.equal(elements.get("intro").hidden, false);
|
||||
assert.equal(elements.get("auth-card").hidden, false);
|
||||
assert.equal(elements.get("config-panel").hidden, true);
|
||||
assert.equal(elements.get("privacy-note").hidden, false);
|
||||
assert.equal(elements.get("notice").hidden, false);
|
||||
});
|
||||
|
||||
test("status errors keep the authorization form hidden until readiness is confirmed", async () => {
|
||||
const { elements } = await startPage({
|
||||
httpStatus: 503,
|
||||
body: {},
|
||||
});
|
||||
|
||||
assert.equal(elements.get("setup-card").hidden, false);
|
||||
assert.equal(elements.get("auth-card").hidden, true);
|
||||
assert.equal(elements.get("config-panel").hidden, true);
|
||||
assert.match(elements.get("setup-message").textContent, /授权入口保持隐藏/);
|
||||
});
|
||||
|
|
@ -22,17 +22,30 @@
|
|||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<section class="intro" aria-labelledby="page-title">
|
||||
<section id="intro" class="intro" aria-labelledby="page-title" hidden>
|
||||
<p class="eyebrow">服务管理 <span>·</span> CONFIGURATION</p>
|
||||
<h1 id="page-title">管理你的服务连接</h1>
|
||||
<p class="intro-copy">配置 JMAP、Telegram、LLM、访问白名单和运行参数。业务配置保存在外部 Redis,成功保存后会立即热加载。</p>
|
||||
</section>
|
||||
|
||||
<div id="notice" class="notice notice-info" role="status" aria-live="polite">
|
||||
<div id="notice" class="notice notice-info" role="status" aria-live="polite" hidden>
|
||||
使用 Redis 管理凭据创建短期管理会话。
|
||||
</div>
|
||||
|
||||
<section id="auth-card" class="panel auth-panel" aria-labelledby="auth-title">
|
||||
<section id="setup-card" class="panel auth-panel setup-panel" aria-labelledby="setup-title" aria-live="polite">
|
||||
<div class="panel-heading">
|
||||
<div class="icon-tile icon-violet" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 3 4 7v5c0 4.4 3.1 7.6 8 9 4.9-1.4 8-4.6 8-9V7l-8-4Z"/><path d="M12 8v5M12 16h.01"/></svg>
|
||||
</div>
|
||||
<div><p class="eyebrow">配置引导</p><h2 id="setup-title">服务启动配置</h2></div>
|
||||
</div>
|
||||
<p id="setup-message" class="panel-copy">正在检查服务启动配置…</p>
|
||||
<p id="setup-missing" class="setup-missing" hidden></p>
|
||||
<p class="setup-privacy">启动所需密钥请在部署环境中配置。密钥不会在页面保存或回显。</p>
|
||||
<button id="status-retry-button" class="button button-quiet" type="button">重新检查</button>
|
||||
</section>
|
||||
|
||||
<section id="auth-card" class="panel auth-panel" aria-labelledby="auth-title" hidden>
|
||||
<div class="panel-heading">
|
||||
<div class="icon-tile icon-violet" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24"><path d="M7.5 10V7.5a4.5 4.5 0 0 1 9 0V10M6 10h12v10H6z"/><path d="M12 14v2"/></svg>
|
||||
|
|
@ -140,7 +153,7 @@
|
|||
</section>
|
||||
</section>
|
||||
|
||||
<aside class="privacy-note"><span class="privacy-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3 5 6v5c0 4.5 2.8 8 7 10 4.2-2 7-5.5 7-10V6l-7-3Z"/><path d="m9 12 2 2 4-4"/></svg></span><p><strong>密钥不会回显或持久化到浏览器。</strong>业务配置只提交至同源 API,并由后端加密写入 Redis;本页面不读取邮箱内容、不使用本地文件或数据库。</p></aside>
|
||||
<aside id="privacy-note" class="privacy-note" hidden><span class="privacy-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3 5 6v5c0 4.5 2.8 8 7 10 4.2-2 7-5.5 7-10V6l-7-3Z"/><path d="m9 12 2 2 4-4"/></svg></span><p><strong>密钥不会回显或持久化到浏览器。</strong>业务配置只提交至同源 API,并由后端加密写入 Redis;本页面不读取邮箱内容、不使用本地文件或数据库。</p></aside>
|
||||
</main>
|
||||
|
||||
<footer class="footer"><span>STALWART BOT</span><span>配置由你的服务端管理</span></footer>
|
||||
|
|
|
|||
|
|
@ -60,6 +60,10 @@ h1 { margin-bottom: 12px; font-size: clamp(29px, 5vw, 40px); line-height: 1.16;
|
|||
h2 { margin-bottom: 0; font-size: 20px; line-height: 1.3; letter-spacing: -.025em; }
|
||||
h3 { margin-bottom: 4px; color: #35364e; font-size: 15px; }
|
||||
.panel-copy { max-width: 760px; margin-bottom: 22px; color: var(--muted); font-size: 13px; line-height: 1.7; }
|
||||
.setup-panel { max-width: 720px; }
|
||||
.setup-panel .panel-copy { margin-bottom: 14px; }
|
||||
.setup-missing { margin: 0 0 17px; padding: 12px 14px; border: 1px solid #f2e9c8; border-radius: 8px; background: #fffbef; color: #796132; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.setup-privacy { margin: 0 0 17px; color: #858697; font-size: 12px; line-height: 1.65; }
|
||||
code { padding: 2px 5px; border-radius: 4px; background: #f1f0fa; color: #534a9c; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
||||
.auth-form > label, .field > label, .setting-card > label { display: block; margin-bottom: 7px; color: #47485f; font-size: 12px; font-weight: 700; }
|
||||
.token-row { display: flex; gap: 10px; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue