admin: token 输入改为弹窗模式 (modal)
把 header 内嵌的 token 输入框 + Save 按钮拆掉,改用原生 <dialog> 弹窗。三种触发场景: 首访无 token 自动弹、API 401 自动弹 (清旧 token 后弹带错误信息)、用户主动点 Set Token 按钮弹。无有效 token 时阻止 Escape 关闭 (cancel 事件 preventDefault), 防止误操作丢失输入。 localStorage 键名 adminToken 不变, 零迁移成本。401 路径在 api() 内部清 token + 弹窗 + 记录 pendingRetry 闭包, 用户保存新 token 后 自动重试失败请求。 设计取舍: - 选原生 <dialog> 而非自造 div + JS, 自带 modal/背板/焦点陷阱/a11y - 选 <form> + preventDefault 而非 method='dialog', 避免自动关闭 - CSS 全部复用现有 dark theme 变量, 0 视觉割裂 - 0 新依赖 (纯 HTML5 + 原生 JS) 验证: - go build / go vet / go test 全部通过 - 端到端 curl 确认新元素嵌入、旧元素删除、401/200 路径正常 - router_test.go 无回归 (HTML 改动不涉及 Go) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+101
-12
@@ -91,15 +91,29 @@
|
|||||||
.form-actions { display: flex; gap: .5rem; margin-top: 1rem; }
|
.form-actions { display: flex; gap: .5rem; margin-top: 1rem; }
|
||||||
.bool { flex-direction: row; align-items: center; gap: .5rem; }
|
.bool { flex-direction: row; align-items: center; gap: .5rem; }
|
||||||
.bool input { width: auto; }
|
.bool input { width: auto; }
|
||||||
|
dialog.modal {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
min-width: 360px;
|
||||||
|
max-width: 90vw;
|
||||||
|
}
|
||||||
|
dialog.modal::backdrop { background: rgba(0, 0, 0, .65); }
|
||||||
|
dialog.modal h2 { margin: 0 0 .5rem; font-size: 1.1rem; color: var(--accent); }
|
||||||
|
dialog.modal form { display: flex; flex-direction: column; gap: .75rem; }
|
||||||
|
dialog.modal label { color: var(--muted); font-size: .9rem; }
|
||||||
|
dialog.modal input { width: 100%; }
|
||||||
|
.modal-error { color: var(--danger); font-size: .9rem; margin: 0; }
|
||||||
|
.modal-actions { display: flex; gap: .5rem; justify-content: flex-end; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>spark-mcp-go Admin</h1>
|
<h1>spark-mcp-go Admin</h1>
|
||||||
<div class='auth'>
|
<div class='auth'>
|
||||||
<label for='token'>Token</label>
|
<button id='set-token' class='secondary'>Set Token</button>
|
||||||
<input id='token' type='password' placeholder='Bearer token' autocomplete='off'>
|
|
||||||
<button id='save-token'>Save</button>
|
|
||||||
<button id='logout' class='danger'>Logout</button>
|
<button id='logout' class='danger'>Logout</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -199,12 +213,25 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<dialog id='token-modal' class='modal'>
|
||||||
|
<h2>Admin Token Required</h2>
|
||||||
|
<p id='modal-error' class='modal-error hidden'></p>
|
||||||
|
<form id='token-form'>
|
||||||
|
<label for='modal-token'>Bearer token</label>
|
||||||
|
<input id='modal-token' type='password' autocomplete='off' required>
|
||||||
|
<div class='modal-actions'>
|
||||||
|
<button type='submit' id='modal-save'>Save</button>
|
||||||
|
<button type='button' id='modal-cancel' class='secondary hidden'>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const $ = (sel) => document.querySelector(sel);
|
const $ = (sel) => document.querySelector(sel);
|
||||||
const API = '/admin';
|
const API = '/admin';
|
||||||
|
|
||||||
const tokenInput = $('#token');
|
let pendingRetry = null;
|
||||||
tokenInput.value = localStorage.getItem('adminToken') || '';
|
let cancelBlocker = null;
|
||||||
|
|
||||||
function headers() {
|
function headers() {
|
||||||
return {
|
return {
|
||||||
@@ -219,11 +246,67 @@
|
|||||||
el.classList.toggle('hidden', !msg);
|
el.classList.toggle('hidden', !msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showModalError(msg) {
|
||||||
|
const el = $('#modal-error');
|
||||||
|
el.textContent = msg || '';
|
||||||
|
el.classList.toggle('hidden', !msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTokenModal(opts = {}) {
|
||||||
|
const dialog = $('#token-modal');
|
||||||
|
const cancelBtn = $('#modal-cancel');
|
||||||
|
showModalError(opts.error || '');
|
||||||
|
cancelBtn.classList.toggle('hidden', !opts.allowCancel);
|
||||||
|
if (cancelBlocker) {
|
||||||
|
dialog.removeEventListener('cancel', cancelBlocker);
|
||||||
|
cancelBlocker = null;
|
||||||
|
}
|
||||||
|
if (!opts.allowCancel) {
|
||||||
|
cancelBlocker = (e) => e.preventDefault();
|
||||||
|
dialog.addEventListener('cancel', cancelBlocker);
|
||||||
|
}
|
||||||
|
dialog.showModal();
|
||||||
|
$('#modal-token').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTokenModal() {
|
||||||
|
const dialog = $('#token-modal');
|
||||||
|
showModalError('');
|
||||||
|
if (cancelBlocker) {
|
||||||
|
dialog.removeEventListener('cancel', cancelBlocker);
|
||||||
|
cancelBlocker = null;
|
||||||
|
}
|
||||||
|
dialog.close();
|
||||||
|
$('#modal-token').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTokenFromModal(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
localStorage.setItem('adminToken', $('#modal-token').value.trim());
|
||||||
|
closeTokenModal();
|
||||||
|
if (pendingRetry) {
|
||||||
|
const fn = pendingRetry;
|
||||||
|
pendingRetry = null;
|
||||||
|
fn();
|
||||||
|
} else {
|
||||||
|
loadClusters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function api(method, path, body) {
|
async function api(method, path, body) {
|
||||||
const opts = { method, headers: headers() };
|
const opts = { method, headers: headers() };
|
||||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||||
const res = await fetch(API + path, opts);
|
const res = await fetch(API + path, opts);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) {
|
||||||
|
localStorage.removeItem('adminToken');
|
||||||
|
pendingRetry = () => api(method, path, body);
|
||||||
|
openTokenModal({
|
||||||
|
error: 'Token rejected by server (401). Enter a valid token.',
|
||||||
|
allowCancel: false
|
||||||
|
});
|
||||||
|
throw new Error('401 Unauthorized');
|
||||||
|
}
|
||||||
let detail = res.statusText;
|
let detail = res.statusText;
|
||||||
try {
|
try {
|
||||||
const j = await res.json();
|
const j = await res.json();
|
||||||
@@ -368,16 +451,18 @@
|
|||||||
$('#refresh').addEventListener('click', loadClusters);
|
$('#refresh').addEventListener('click', loadClusters);
|
||||||
$('#btn-cancel').addEventListener('click', hideForm);
|
$('#btn-cancel').addEventListener('click', hideForm);
|
||||||
|
|
||||||
$('#save-token').addEventListener('click', () => {
|
$('#set-token').addEventListener('click', () => openTokenModal({ allowCancel: true }));
|
||||||
localStorage.setItem('adminToken', tokenInput.value.trim());
|
|
||||||
loadClusters();
|
|
||||||
});
|
|
||||||
$('#logout').addEventListener('click', () => {
|
$('#logout').addEventListener('click', () => {
|
||||||
localStorage.removeItem('adminToken');
|
localStorage.removeItem('adminToken');
|
||||||
tokenInput.value = '';
|
openTokenModal({
|
||||||
showError('Token cleared; reload the page after supplying a new token.');
|
error: 'Token cleared. Enter a new token to continue.',
|
||||||
|
allowCancel: false
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#token-form').addEventListener('submit', saveTokenFromModal);
|
||||||
|
$('#modal-cancel').addEventListener('click', closeTokenModal);
|
||||||
|
|
||||||
$('#cluster-form').addEventListener('submit', async (e) => {
|
$('#cluster-form').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
showError('');
|
showError('');
|
||||||
@@ -395,7 +480,11 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
loadClusters();
|
if (!localStorage.getItem('adminToken')) {
|
||||||
|
openTokenModal({ allowCancel: false });
|
||||||
|
} else {
|
||||||
|
loadClusters();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user