From 96b5fb6ed04199339a4c02b73187cc4f649b49d0 Mon Sep 17 00:00:00 2001 From: dsh Date: Sun, 16 Aug 2026 03:28:31 -0400 Subject: [PATCH] refactor: move logout/change-password UI from floating widget to settings The injected floating buttons overlapped the mobile composer send button. Replace the tapIndex-injected widget with a proper client plugin (panel-auth-ui, ui/ package): registers an account & security section in the panel settings via the settings.section slot, with change-password form and logout action. Host plugin keeps only the /panel-auth/change- password and /panel-auth/logout endpoints. - ui/: ModuleLoader-format client bundle (dsh.client declaration, exports ./client and ./package.json), host no-op stub, bare-package symlink required in the profile node_modules (documented in README). - Host: renderAuthWidget/injectWidget/tapIndex injection removed. - Tests: widget tests removed; change-password flows unchanged. --- README.md | 33 ++++++++--- index.js | 133 ----------------------------------------- test.mjs | 10 +--- ui/client.js | 154 ++++++++++++++++++++++++++++++++++++++++++++++++ ui/index.js | 7 +++ ui/package.json | 22 +++++++ 6 files changed, 208 insertions(+), 151 deletions(-) create mode 100644 ui/client.js create mode 100644 ui/index.js create mode 100644 ui/package.json diff --git a/README.md b/README.md index 10adfde..6e96975 100644 --- a/README.md +++ b/README.md @@ -88,23 +88,38 @@ dsh.example.com { - panel-auth 的来源校验在 Host 为回环时自动跳过(代理场景);对外域名下的 真实跨站提交仍会被拒绝(`非法请求来源`)。 -## 面板内小组件(登出 / 修改密码) +## 设置页「账号与安全」(登出 / 修改密码) -插件通过 `webServer.tapIndex` 在面板页面的右下角注入两个悬浮按钮: +配套客户端插件 `panel-auth-ui`(本仓库 `ui/` 目录)在面板**设置**里注册 +"账号与安全"区块,包含: -- **退出登录**:跳转 `/panel-auth/logout`,清除 Cookie 并回到登录页。 -- **修改密码**:弹窗输入当前密码 + 新密码(≥8 位、不得与旧密码相同), +- **修改密码**:输入当前密码 + 新密码(≥8 位、不得与旧密码相同), POST 到 `/panel-auth/change-password`: 1. 校验会话(Cookie/Basic)与旧密码(错误计入防爆破); 2. 生成新 scrypt 哈希,**写回 `patchFilePath`**(cordis.patch.yml,永续); 3. 通过 loader 热更新运行配置(`live-updated`,无需重启面板); 4. 审计记录 `password-change` 事件(含结果明细)。 +- **退出登录**:跳转 `/panel-auth/logout`,清除 Cookie 并回到登录页。 - 改密不影响已登录会话(签名密钥不变,Cookie 继续有效)。 -- 新增配置项: - ```yaml - changePasswordPath: '/panel-auth/change-password' - patchFilePath: '/root/.dsh/profiles/web/cordis.patch.yml' - ``` + +部署要求(客户端包按裸包名解析): + +```bash +# 1. 把 ui/ 目录复制为 profile 内的独立包(host 存根 + client bundle) +cp -r ui /root/.dsh/profiles/web/panel-auth-ui +# 2. 建立裸包名解析符号链接(profiles 级 node_modules 为 pnpm 布局) +ln -sfn ../web/panel-auth-ui /root/.dsh/profiles/node_modules/panel-auth-ui +# 3. cordis.patch.yml 增加行(dsh.client 由 package.json 声明驱动) +# - id: panel-auth-ui +# name: 'panel-auth-ui' +``` + +相关配置项: + +```yaml +changePasswordPath: '/panel-auth/change-password' +patchFilePath: '/root/.dsh/profiles/web/cordis.patch.yml' +``` ## 修改密码(命令行) diff --git a/index.js b/index.js index 45e5719..39005b2 100644 --- a/index.js +++ b/index.js @@ -404,137 +404,6 @@ export function renderLoginPage({ realm, next = '/', error = '', loginPath = DEF ` } -// ── in-panel auth widget (logout / change password) ───────────────────────── - -/** - * Self-contained widget injected into the panel's index.html via - * webServer.tapIndex: two floating buttons (logout, change password) and a - * change-password modal. Vanilla JS only, all styles scoped to `pna-*`. - */ -export function renderAuthWidget({ logoutPath, changePasswordPath }) { - const logoutUrl = JSON.stringify(logoutPath) - const changeUrl = JSON.stringify(changePasswordPath) - return ` -
- - -
- -` -} - -/** Inject the widget before ; no-op when the marker is absent. */ -export function injectWidget(html, widget) { - if (typeof html !== 'string') return html - const at = html.lastIndexOf('') - if (at === -1) return html - return html.slice(0, at) + widget + html.slice(at) -} - // ── request helpers ────────────────────────────────────────────────────────── function jsonResponse(res, status, payload) { @@ -961,8 +830,6 @@ export default function panelAuth(ctx, config) { }) const failedLoginDelayMs = positiveInt(cfg.failedLoginDelayMs, 300) console.log('[panel-auth] audit log: ' + auditPath) - const widget = renderAuthWidget({ logoutPath, changePasswordPath }) - ctx.effect(() => ctx.webServer.tapIndex((html) => injectWidget(html, widget)), 'panel-auth: auth widget') let disposer let timer let stopped = false diff --git a/test.mjs b/test.mjs index da3428d..fdb9eac 100644 --- a/test.mjs +++ b/test.mjs @@ -6,7 +6,7 @@ import { strict as assert } from 'node:assert' import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { createGuard, installGuard, createAuditWriter, createLockout, renderLoginPage, renderAuthWidget, injectWidget } from './index.js' +import { createGuard, installGuard, createAuditWriter, createLockout, renderLoginPage } from './index.js' import { hashPassword, verifyPassword } from './crypto.js' const tmp = mkdtempSync(join(tmpdir(), 'panel-auth-test-')) @@ -274,14 +274,6 @@ assert.ok(auditLines.some((e) => e.event === 'password-change'), 'audit missing assert.ok(auditLines.some((e) => e.event === 'login-fail' && e.reason === 'password-change-old-mismatch'), 'audit missing old-mismatch entry') assert.equal(auditLines.every((e) => typeof e.ip === 'string' && typeof e.ts === 'string'), true) -// 11b. widget rendering and index injection -const widget = renderAuthWidget({ logoutPath: '/panel-auth/logout', changePasswordPath: '/panel-auth/change-password' }) -assert.ok(widget.includes('id="pna-widget"') && widget.includes('id="pna-logout"') && widget.includes('id="pna-change"') && widget.includes('pna-modal')) -const injected = injectWidget('hi', widget) -assert.ok(injected.includes('id="pna-widget"')) -assert.ok(injected.indexOf('id="pna-widget"') < injected.indexOf('')) -assert.equal(injectWidget('nobody', widget), 'nobody') - // 12. XSS: hostile `next` value is escaped in the page const hostile = renderLoginPage({ realm: 'T', next: '/" onmouseover="alert(1)', error: '' }) assert.ok(!hostile.includes('onmouseover="alert(1)'), 'next not escaped') diff --git a/ui/client.js b/ui/client.js new file mode 100644 index 0000000..a218278 --- /dev/null +++ b/ui/client.js @@ -0,0 +1,154 @@ +// panel-auth-ui client bundle (ModuleLoader format, served by the client +// module system). Registers an "账号与安全" section in the panel settings +// with change-password and logout actions. Depends on the panel-auth host +// plugin's /panel-auth/change-password and /panel-auth/logout endpoints. +window.__ModuleLoader__.load({ + id: "panel-auth-ui", + factory: (require) => { + const React = require("react") + + const inputStyle = { + width: "100%", + boxSizing: "border-box", + padding: "9px 11px", + fontSize: 14, + borderRadius: 10, + border: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.14))", + background: "var(--dsw-alias-bg-module-platform, rgba(255,255,255,0.05))", + color: "var(--dsw-alias-label-primary, #e8eaf2)", + outline: "none", + } + const cardStyle = { + border: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.12))", + borderRadius: 16, + padding: "18px 16px", + } + const titleStyle = { + margin: "0 0 6px", + fontSize: 15, + fontWeight: 600, + color: "var(--dsw-alias-label-primary, #e8eaf2)", + } + const hintStyle = { + margin: "0 0 14px", + fontSize: 12.5, + color: "var(--dsw-alias-label-secondary, #9aa1b5)", + } + const buttonStyle = (tone) => ({ + padding: "9px 16px", + fontSize: 14, + fontWeight: 600, + borderRadius: 10, + cursor: "pointer", + border: tone === "danger" ? "1px solid rgba(248,113,113,0.5)" : "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.16))", + background: tone === "danger" ? "rgba(248,113,113,0.12)" : "var(--dsw-alias-bg-module-platform, rgba(255,255,255,0.06))", + color: tone === "danger" ? "#f87171" : "var(--dsw-alias-label-primary, #e8eaf2)", + }) + const messageStyle = (ok) => ({ + margin: "0 0 12px", + padding: "8px 11px", + fontSize: 12.5, + borderRadius: 8, + border: ok ? "1px solid rgba(74,222,128,0.4)" : "1px solid rgba(248,113,113,0.4)", + background: ok ? "rgba(74,222,128,0.12)" : "rgba(248,113,113,0.12)", + color: ok ? "#4ade80" : "#f87171", + }) + + function Field(props) { + return React.createElement( + "label", + { style: { display: "block", marginBottom: 12 } }, + React.createElement("div", { style: { fontSize: 12, marginBottom: 6, color: "var(--dsw-alias-label-secondary, #9aa1b5)" } }, props.label), + React.createElement("input", { + type: "password", + autoComplete: props.autoComplete || "off", + value: props.value, + onChange: (event) => props.onChange(event.target.value), + style: inputStyle, + }), + ) + } + + function PanelAuthSection() { + const [oldPw, setOldPw] = React.useState("") + const [newPw, setNewPw] = React.useState("") + const [confirm, setConfirm] = React.useState("") + const [busy, setBusy] = React.useState(false) + const [message, setMessage] = React.useState(null) + + const submit = () => { + if (busy) return + if (!oldPw || !newPw) { + setMessage({ ok: false, text: "请填写当前密码和新密码" }) + return + } + if (newPw.length < 8) { + setMessage({ ok: false, text: "新密码至少 8 位" }) + return + } + if (newPw !== confirm) { + setMessage({ ok: false, text: "两次输入的新密码不一致" }) + return + } + setBusy(true) + setMessage(null) + fetch("/panel-auth/change-password", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ oldPassword: oldPw, newPassword: newPw }), + }) + .then((response) => response.json().then((data) => ({ ok: response.ok, data }))) + .then(({ ok, data }) => { + if (ok) { + setOldPw("") + setNewPw("") + setConfirm("") + setMessage({ ok: true, text: (data && data.message) || "密码已更新" }) + } else { + setMessage({ ok: false, text: (data && data.message) || "修改失败" }) + } + }) + .catch(() => setMessage({ ok: false, text: "网络错误,请稍后再试" })) + .finally(() => setBusy(false)) + } + + const logout = () => { + window.location.href = "/panel-auth/logout" + } + + return React.createElement( + "div", + { style: { display: "flex", flexDirection: "column", gap: 18 } }, + React.createElement( + "section", + { style: cardStyle }, + React.createElement("h3", { style: titleStyle }, "修改密码"), + React.createElement("p", { style: hintStyle }, "需要输入当前密码验证身份,修改后无需重新登录。"), + React.createElement(Field, { label: "当前密码", autoComplete: "current-password", value: oldPw, onChange: setOldPw }), + React.createElement(Field, { label: "新密码(至少 8 位)", autoComplete: "new-password", value: newPw, onChange: setNewPw }), + React.createElement(Field, { label: "确认新密码", autoComplete: "new-password", value: confirm, onChange: setConfirm }), + message === null ? null : React.createElement("div", { style: messageStyle(message.ok) }, message.text), + React.createElement("button", { onClick: submit, disabled: busy, style: { ...buttonStyle("primary"), opacity: busy ? 0.6 : 1 } }, busy ? "提交中…" : "确认修改"), + ), + React.createElement( + "section", + { style: cardStyle }, + React.createElement("h3", { style: titleStyle }, "退出登录"), + React.createElement("p", { style: hintStyle }, "清除本浏览器的登录 Cookie 并返回登录页。"), + React.createElement("button", { onClick: logout, style: buttonStyle("danger") }, "退出登录"), + ), + ) + } + + return { + apply(ctx) { + const slots = ctx.get("slots") + if (slots === undefined) return + slots.inject("settings.section", () => slots.register( + { name: "settings.section", id: "panel-auth", order: 30, label: () => "账号与安全" }, + (props) => React.createElement(PanelAuthSection, { close: props.close }), + )) + }, + } + }, +}) diff --git a/ui/index.js b/ui/index.js new file mode 100644 index 0000000..c341c34 --- /dev/null +++ b/ui/index.js @@ -0,0 +1,7 @@ +// panel-auth-ui — host-side no-op stub. +// The loader mounts every row host-side; the real work lives in client.js, +// served by the client module system under the dsh.client declaration. +export default { + name: 'panel-auth-ui', + apply() {}, +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..c689800 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,22 @@ +{ + "name": "panel-auth-ui", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "index.js", + "exports": { + ".": "./index.js", + "./client": "./client.js", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-settings" + ], + "platform": "web", + "immediately": false + } + } +}