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.
This commit is contained in:
dsh
2026-08-16 03:28:31 -04:00
parent f9883150e0
commit 96b5fb6ed0
6 changed files with 208 additions and 151 deletions
+24 -9
View File
@@ -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'
```
## 修改密码(命令行)
-133
View File
@@ -404,137 +404,6 @@ export function renderLoginPage({ realm, next = '/', error = '', loginPath = DEF
</html>`
}
// ── 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 `<style>
#pna-widget { position: fixed; right: 18px; bottom: 18px; z-index: 2147483000; display: flex; flex-direction: column; gap: 8px; }
.pna-btn { display: flex; align-items: center; gap: 7px; border: 1px solid rgba(255,255,255,0.14); background: rgba(22,26,40,0.88); color: #e8eaf2; font: 600 12.5px/1 "Segoe UI","PingFang SC","Microsoft YaHei",system-ui,sans-serif; padding: 9px 13px; border-radius: 10px; cursor: pointer; box-shadow: 0 8px 22px rgba(0,0,0,0.35); backdrop-filter: blur(10px); transition: border-color .15s ease, transform .15s ease; }
.pna-btn:hover { border-color: rgba(109,141,255,0.65); transform: translateY(-1px); }
.pna-btn svg { width: 14px; height: 14px; }
#pna-modal { position: fixed; inset: 0; z-index: 2147483001; display: grid; place-items: center; background: rgba(8,10,20,0.55); backdrop-filter: blur(4px); padding: 20px; }
#pna-modal[hidden] { display: none; }
.pna-card { width: min(360px, 100%); background: rgba(22,26,40,0.94); border: 1px solid rgba(255,255,255,0.12); border-radius: 16px; padding: 24px 22px 18px; box-shadow: 0 24px 60px rgba(0,0,0,0.5); color: #e8eaf2; font: 14px/1.5 "Segoe UI","PingFang SC","Microsoft YaHei",system-ui,sans-serif; }
.pna-card h3 { margin: 0 0 14px; font-size: 16px; font-weight: 650; }
.pna-card label { display: block; font-size: 12px; color: #9aa1b5; margin: 10px 0 4px; }
.pna-card input { width: 100%; box-sizing: border-box; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.14); border-radius: 9px; color: #e8eaf2; padding: 9px 11px; font-size: 14px; outline: none; }
.pna-card input:focus { border-color: #6d8dff; box-shadow: 0 0 0 3px rgba(109,141,255,0.2); }
.pna-error { background: rgba(248,113,113,0.12); border: 1px solid rgba(248,113,113,0.4); color: #f87171; border-radius: 8px; padding: 7px 10px; font-size: 12.5px; margin-bottom: 4px; }
.pna-ok { background: rgba(74,222,128,0.12); border: 1px solid rgba(74,222,128,0.4); color: #4ade80; border-radius: 8px; padding: 7px 10px; font-size: 12.5px; margin-bottom: 4px; }
.pna-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
.pna-actions button { border: 1px solid rgba(255,255,255,0.14); background: transparent; color: #e8eaf2; font-size: 13px; padding: 8px 14px; border-radius: 9px; cursor: pointer; }
.pna-actions .pna-primary { background: linear-gradient(135deg, #6d8dff, #8b5cf6); border: none; color: #fff; font-weight: 600; }
.pna-actions .pna-primary:disabled { opacity: 0.6; cursor: wait; }
</style>
<div id="pna-widget">
<button id="pna-change" class="pna-btn" title="修改面板密码">
<svg viewBox="0 0 24 24" fill="none"><rect x="4" y="10" width="16" height="10" rx="2.5" stroke="currentColor" stroke-width="1.8"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="12" cy="15" r="1.6" fill="currentColor"/></svg>
修改密码
</button>
<button id="pna-logout" class="pna-btn" title="退出登录">
<svg viewBox="0 0 24 24" fill="none"><path d="M9 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M15 8l4 4-4 4M19 12H9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
退出登录
</button>
</div>
<div id="pna-modal" hidden>
<div class="pna-card">
<h3>修改面板密码</h3>
<div id="pna-msg" hidden></div>
<label for="pna-old">当前密码</label>
<input id="pna-old" type="password" autocomplete="current-password">
<label for="pna-new">新密码(至少 8 位)</label>
<input id="pna-new" type="password" autocomplete="new-password">
<label for="pna-confirm">确认新密码</label>
<input id="pna-confirm" type="password" autocomplete="new-password">
<div class="pna-actions">
<button id="pna-cancel" type="button">取消</button>
<button id="pna-submit" class="pna-primary" type="button">确认修改</button>
</div>
</div>
</div>
<script>
(function () {
var LOGOUT_URL = ${logoutUrl};
var CHANGE_URL = ${changeUrl};
var modal = document.getElementById('pna-modal');
var msg = document.getElementById('pna-msg');
var oldInput = document.getElementById('pna-old');
var newInput = document.getElementById('pna-new');
var confirmInput = document.getElementById('pna-confirm');
var submitBtn = document.getElementById('pna-submit');
function showMsg(text, ok) {
msg.hidden = false;
msg.textContent = text;
msg.className = ok ? 'pna-ok' : 'pna-error';
}
function openModal() {
modal.hidden = false;
msg.hidden = true;
oldInput.value = newInput.value = confirmInput.value = '';
oldInput.focus();
}
function closeModal() {
modal.hidden = true;
}
document.getElementById('pna-logout').addEventListener('click', function () {
window.location.href = LOGOUT_URL;
});
document.getElementById('pna-change').addEventListener('click', openModal);
document.getElementById('pna-cancel').addEventListener('click', closeModal);
modal.addEventListener('click', function (event) {
if (event.target === modal) closeModal();
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && !modal.hidden) closeModal();
});
submitBtn.addEventListener('click', function () {
var oldPassword = oldInput.value;
var newPassword = newInput.value;
if (!oldPassword || !newPassword) { showMsg('请填写当前密码和新密码', false); return; }
if (newPassword.length < 8) { showMsg('新密码至少 8 位', false); return; }
if (newPassword !== confirmInput.value) { showMsg('两次输入的新密码不一致', false); return; }
submitBtn.disabled = true;
fetch(CHANGE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ oldPassword: oldPassword, newPassword: newPassword }),
}).then(function (response) {
return response.json().then(function (data) {
if (response.ok) {
showMsg(data.message || '密码已更新', true);
oldInput.value = newInput.value = confirmInput.value = '';
setTimeout(closeModal, 1400);
} else {
showMsg(data.message || '修改失败', false);
}
}).catch(function () {
showMsg(response.ok ? '密码已更新' : '修改失败,请稍后再试', response.ok);
});
}).catch(function () {
showMsg('网络错误,请稍后再试', false);
}).finally(function () {
submitBtn.disabled = false;
});
});
})();
</script>`
}
/** Inject the widget before </body>; no-op when the marker is absent. */
export function injectWidget(html, widget) {
if (typeof html !== 'string') return html
const at = html.lastIndexOf('</body>')
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
+1 -9
View File
@@ -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('<html><head></head><body>hi</body></html>', widget)
assert.ok(injected.includes('id="pna-widget"'))
assert.ok(injected.indexOf('id="pna-widget"') < injected.indexOf('</body>'))
assert.equal(injectWidget('<html>nobody</html>', widget), '<html>nobody</html>')
// 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')
+154
View File
@@ -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 }),
))
},
}
},
})
+7
View File
@@ -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() {},
}
+22
View File
@@ -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
}
}
}