feat(admin): add session-based portal login
This commit is contained in:
@@ -412,7 +412,7 @@
|
||||
<article class="panel">
|
||||
<h2>发起导入</h2>
|
||||
<p class="panel-desc">
|
||||
用 admin token 直接调用当前控制面的 batch-import API。
|
||||
优先使用管理员登录会话调用当前控制面的 batch-import API;必要时也可以回退到 Bearer token。
|
||||
`entries` 每行一个供应商帐号:`base_url|api_key|model_a,model_b`。
|
||||
</p>
|
||||
|
||||
@@ -426,8 +426,9 @@
|
||||
</div>
|
||||
|
||||
<div class="field-grid two">
|
||||
<label>Admin Token
|
||||
<label>Admin Token(可选)
|
||||
<input id="admin-token" type="password" placeholder="secret-token">
|
||||
<span class="hint">优先使用下方管理员登录;这里只保留给脚本联调或紧急兜底。</span>
|
||||
</label>
|
||||
<label>Mode
|
||||
<select id="mode">
|
||||
@@ -437,6 +438,21 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field-grid two">
|
||||
<label>管理员用户名
|
||||
<input id="admin-username" type="text" placeholder="admin">
|
||||
</label>
|
||||
<label>管理员密码
|
||||
<input id="admin-password" type="password" placeholder="请输入管理员密码">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<button id="admin-login-btn" type="button">管理员登录</button>
|
||||
<button id="admin-logout-btn" type="button" class="ghost">退出登录</button>
|
||||
<span id="admin-session-status" class="statusbar">尚未检查管理员会话。</span>
|
||||
</div>
|
||||
|
||||
<div class="field-grid two">
|
||||
<label>Access Mode
|
||||
<select id="access-mode">
|
||||
@@ -568,6 +584,11 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
|
||||
const apiBaseInput = document.getElementById("api-base");
|
||||
const hostIDInput = document.getElementById("host-id");
|
||||
const adminTokenInput = document.getElementById("admin-token");
|
||||
const adminUsernameInput = document.getElementById("admin-username");
|
||||
const adminPasswordInput = document.getElementById("admin-password");
|
||||
const adminLoginButton = document.getElementById("admin-login-btn");
|
||||
const adminLogoutButton = document.getElementById("admin-logout-btn");
|
||||
const adminSessionStatus = document.getElementById("admin-session-status");
|
||||
const modeInput = document.getElementById("mode");
|
||||
const accessModeInput = document.getElementById("access-mode");
|
||||
const confirmTimeoutInput = document.getElementById("confirm-timeout");
|
||||
@@ -615,6 +636,7 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
|
||||
apiBase: apiBaseInput.value.trim(),
|
||||
hostID: hostIDInput.value.trim(),
|
||||
adminToken: adminTokenInput.value,
|
||||
adminUsername: adminUsernameInput.value.trim(),
|
||||
mode: modeInput.value,
|
||||
accessMode: accessModeInput.value,
|
||||
confirmTimeoutSec: confirmTimeoutInput.value,
|
||||
@@ -641,6 +663,7 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
|
||||
apiBaseInput.value = payload.apiBase || defaultApiBase();
|
||||
hostIDInput.value = payload.hostID || "";
|
||||
adminTokenInput.value = payload.adminToken || "";
|
||||
adminUsernameInput.value = payload.adminUsername || "";
|
||||
modeInput.value = payload.mode || "strict";
|
||||
accessModeInput.value = payload.accessMode || "self_service";
|
||||
confirmTimeoutInput.value = payload.confirmTimeoutSec || "10";
|
||||
@@ -673,14 +696,14 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
const token = adminTokenInput.value.trim();
|
||||
if (!token) {
|
||||
throw new Error("admin token 不能为空");
|
||||
}
|
||||
return {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
};
|
||||
const token = adminTokenInput.value.trim();
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function parseEntries() {
|
||||
@@ -734,7 +757,16 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
|
||||
}
|
||||
|
||||
async function requestJSON(path, options = {}) {
|
||||
const response = await fetch(`${normalizeApiBase()}${path}`, options);
|
||||
const { skipAuth = false, headers = {}, ...rest } = options;
|
||||
const finalHeaders = { ...headers };
|
||||
if (!skipAuth) {
|
||||
Object.assign(finalHeaders, authHeaders(), finalHeaders);
|
||||
}
|
||||
const response = await fetch(`${normalizeApiBase()}${path}`, {
|
||||
...rest,
|
||||
credentials: "include",
|
||||
headers: finalHeaders,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = {};
|
||||
try {
|
||||
@@ -749,6 +781,58 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function refreshAdminSession() {
|
||||
try {
|
||||
const payload = await requestJSON("/api/admin/session", { skipAuth: true });
|
||||
if (payload.username && !adminUsernameInput.value.trim()) {
|
||||
adminUsernameInput.value = payload.username;
|
||||
}
|
||||
if (payload.authenticated) {
|
||||
setStatus(`管理员已登录:${payload.username}`, "success");
|
||||
adminSessionStatus.textContent = `已登录:${payload.username}`;
|
||||
} else if (payload.login_enabled) {
|
||||
adminSessionStatus.textContent = "未登录,可直接使用管理员用户名密码建立会话。";
|
||||
} else {
|
||||
adminSessionStatus.textContent = "当前实例未启用管理员密码登录,只能使用 Bearer token。";
|
||||
}
|
||||
return payload;
|
||||
} catch (error) {
|
||||
adminSessionStatus.textContent = `管理员会话检查失败:${error.message}`;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loginAdminSession() {
|
||||
const username = adminUsernameInput.value.trim();
|
||||
const password = adminPasswordInput.value;
|
||||
if (!username || !password) {
|
||||
throw new Error("管理员用户名和密码不能为空");
|
||||
}
|
||||
const payload = await requestJSON("/api/admin/session/login", {
|
||||
method: "POST",
|
||||
skipAuth: true,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
adminPasswordInput.value = "";
|
||||
saveConfig();
|
||||
adminSessionStatus.textContent = `已登录:${payload.username}`;
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function logoutAdminSession() {
|
||||
const response = await fetch(`${normalizeApiBase()}/api/admin/session/logout`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `HTTP ${response.status}`);
|
||||
}
|
||||
adminPasswordInput.value = "";
|
||||
adminSessionStatus.textContent = "管理员会话已退出。";
|
||||
}
|
||||
|
||||
function syncHeaderMetrics() {
|
||||
metricApiRoot.textContent = normalizeApiBase();
|
||||
metricRunID.textContent = state.currentRunID || "-";
|
||||
@@ -926,11 +1010,28 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
|
||||
document.getElementById("load-sample-btn").addEventListener("click", loadSampleEntries);
|
||||
document.getElementById("clear-items-btn").addEventListener("click", clearResults);
|
||||
document.getElementById("apply-filter-btn").addEventListener("click", refreshRun);
|
||||
adminLoginButton.addEventListener("click", async () => {
|
||||
try {
|
||||
await loginAdminSession();
|
||||
setStatus("管理员会话已建立。", "success");
|
||||
} catch (error) {
|
||||
setStatus(error.message, "danger");
|
||||
}
|
||||
});
|
||||
adminLogoutButton.addEventListener("click", async () => {
|
||||
try {
|
||||
await logoutAdminSession();
|
||||
await refreshAdminSession();
|
||||
} catch (error) {
|
||||
setStatus(error.message, "danger");
|
||||
}
|
||||
});
|
||||
accessModeInput.addEventListener("change", updateAccessModeFields);
|
||||
|
||||
restoreConfig();
|
||||
updateAccessModeFields();
|
||||
syncHeaderMetrics();
|
||||
refreshAdminSession().catch(() => {});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -375,7 +375,7 @@
|
||||
</p>
|
||||
<ul class="hero-points">
|
||||
<li>默认 API Base:<code>/portal-admin-api</code></li>
|
||||
<li>支持同域 Bearer admin token</li>
|
||||
<li>支持管理员登录会话,也保留 Bearer admin token 兜底</li>
|
||||
<li>支持 provider 草稿发布到 pack 仓库</li>
|
||||
</ul>
|
||||
</article>
|
||||
@@ -408,11 +408,27 @@
|
||||
<label>API Base
|
||||
<input id="api-base" type="text" placeholder="https://sub.tksea.top/portal-admin-api">
|
||||
</label>
|
||||
<label>Admin Token
|
||||
<label>Admin Token(可选)
|
||||
<input id="admin-token" type="password" placeholder="crm-admin-token">
|
||||
<span class="hint">优先使用下方管理员登录;这里只保留给脚本联调或紧急兜底。</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field-grid two">
|
||||
<label>管理员用户名
|
||||
<input id="admin-username" type="text" placeholder="admin">
|
||||
</label>
|
||||
<label>管理员密码
|
||||
<input id="admin-password" type="password" placeholder="请输入管理员密码">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="admin-login-btn" type="button">管理员登录</button>
|
||||
<button id="admin-logout-btn" type="button" class="ghost">退出登录</button>
|
||||
<span id="admin-session-status" class="status">尚未检查管理员会话。</span>
|
||||
</div>
|
||||
|
||||
<div class="field-grid two">
|
||||
<label>Pack
|
||||
<select id="pack-id"></select>
|
||||
@@ -592,6 +608,11 @@
|
||||
|
||||
const apiBaseInput = document.getElementById("api-base");
|
||||
const adminTokenInput = document.getElementById("admin-token");
|
||||
const adminUsernameInput = document.getElementById("admin-username");
|
||||
const adminPasswordInput = document.getElementById("admin-password");
|
||||
const adminLoginButton = document.getElementById("admin-login-btn");
|
||||
const adminLogoutButton = document.getElementById("admin-logout-btn");
|
||||
const adminSessionStatus = document.getElementById("admin-session-status");
|
||||
const packIDInput = document.getElementById("pack-id");
|
||||
const hostIDInput = document.getElementById("host-id");
|
||||
const packPathInput = document.getElementById("pack-path");
|
||||
@@ -641,18 +662,27 @@
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
const token = adminTokenInput.value.trim();
|
||||
if (!token) {
|
||||
throw new Error("admin token 不能为空");
|
||||
}
|
||||
return {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
};
|
||||
const token = adminTokenInput.value.trim();
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function requestJSON(path, options = {}) {
|
||||
return fetch(`${normalizeApiBase()}${path}`, options)
|
||||
const { skipAuth = false, headers = {}, ...rest } = options;
|
||||
const finalHeaders = { ...headers };
|
||||
if (!skipAuth) {
|
||||
Object.assign(finalHeaders, authHeaders(), finalHeaders);
|
||||
}
|
||||
return fetch(`${normalizeApiBase()}${path}`, {
|
||||
...rest,
|
||||
credentials: "include",
|
||||
headers: finalHeaders,
|
||||
})
|
||||
.then(async (response) => {
|
||||
const text = await response.text();
|
||||
let payload = {};
|
||||
@@ -679,6 +709,7 @@
|
||||
localStorage.setItem(storageKey, JSON.stringify({
|
||||
apiBase: apiBaseInput.value.trim(),
|
||||
adminToken: adminTokenInput.value,
|
||||
adminUsername: adminUsernameInput.value.trim(),
|
||||
packID: packIDInput.value,
|
||||
hostID: hostIDInput.value,
|
||||
packPath: packPathInput.value.trim(),
|
||||
@@ -714,6 +745,7 @@
|
||||
const payload = JSON.parse(raw);
|
||||
apiBaseInput.value = payload.apiBase || defaultApiBase();
|
||||
adminTokenInput.value = payload.adminToken || "";
|
||||
adminUsernameInput.value = payload.adminUsername || "";
|
||||
packPathInput.value = payload.packPath || "/app/packs/openai-cn-pack";
|
||||
providerIDInput.value = payload.providerID || "";
|
||||
modeInput.value = payload.mode || "strict";
|
||||
@@ -739,6 +771,57 @@
|
||||
subscriptionFields.hidden = accessModeInput.value !== "subscription";
|
||||
}
|
||||
|
||||
async function refreshAdminSession() {
|
||||
try {
|
||||
const payload = await requestJSON("/api/admin/session", { skipAuth: true });
|
||||
if (payload.username && !adminUsernameInput.value.trim()) {
|
||||
adminUsernameInput.value = payload.username;
|
||||
}
|
||||
if (payload.authenticated) {
|
||||
setStatus(adminSessionStatus, `已登录:${payload.username}`, "success");
|
||||
} else if (payload.login_enabled) {
|
||||
setStatus(adminSessionStatus, "未登录。可直接使用管理员用户名密码建立会话。", "note");
|
||||
} else {
|
||||
setStatus(adminSessionStatus, "当前实例未启用管理员密码登录,只能使用 Bearer token。", "warning");
|
||||
}
|
||||
return payload;
|
||||
} catch (error) {
|
||||
setStatus(adminSessionStatus, `管理员会话检查失败:${error.message}`, "danger");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loginAdminSession() {
|
||||
const username = adminUsernameInput.value.trim();
|
||||
const password = adminPasswordInput.value;
|
||||
if (!username || !password) {
|
||||
throw new Error("管理员用户名和密码不能为空");
|
||||
}
|
||||
const payload = await requestJSON("/api/admin/session/login", {
|
||||
method: "POST",
|
||||
skipAuth: true,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
adminPasswordInput.value = "";
|
||||
saveConfig();
|
||||
setStatus(adminSessionStatus, `已登录:${payload.username}`, "success");
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function logoutAdminSession() {
|
||||
const response = await fetch(`${normalizeApiBase()}/api/admin/session/logout`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `HTTP ${response.status}`);
|
||||
}
|
||||
adminPasswordInput.value = "";
|
||||
setStatus(adminSessionStatus, "管理员会话已退出。", "note");
|
||||
}
|
||||
|
||||
function renderSelectOptions(select, values, currentValue, emptyLabel) {
|
||||
select.innerHTML = "";
|
||||
if (!values.length) {
|
||||
@@ -1203,6 +1286,22 @@
|
||||
document.getElementById("delete-draft-btn").addEventListener("click", deleteDraftOnServer);
|
||||
document.getElementById("copy-draft-btn").addEventListener("click", copyDraft);
|
||||
document.getElementById("refresh-drafts-btn").addEventListener("click", loadServerDrafts);
|
||||
adminLoginButton.addEventListener("click", async () => {
|
||||
try {
|
||||
await loginAdminSession();
|
||||
await loadCatalog();
|
||||
} catch (error) {
|
||||
setStatus(adminSessionStatus, error.message, "danger");
|
||||
}
|
||||
});
|
||||
adminLogoutButton.addEventListener("click", async () => {
|
||||
try {
|
||||
await logoutAdminSession();
|
||||
await refreshAdminSession();
|
||||
} catch (error) {
|
||||
setStatus(adminSessionStatus, error.message, "danger");
|
||||
}
|
||||
});
|
||||
accessModeInput.addEventListener("change", updateAccessModeFields);
|
||||
packIDInput.addEventListener("change", loadCatalog);
|
||||
providerIDInput.addEventListener("input", syncMetrics);
|
||||
@@ -1211,6 +1310,7 @@
|
||||
restoreConfig();
|
||||
updateAccessModeFields();
|
||||
syncMetrics();
|
||||
refreshAdminSession().catch(() => {});
|
||||
renderServerDrafts();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user