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
-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