Files
chorus/portal/web/static/api-keys.js
T

290 lines
14 KiB
JavaScript

(() => {
"use strict";
let keys = [];
let selectedKey = null;
let lastTrigger = null;
let toastTimer = null;
const $ = selector => document.querySelector(selector);
const csrfToken = () => $("meta[name='csrf-token']")?.content || "";
const show = element => element?.classList.remove("hidden");
const hide = element => element?.classList.add("hidden");
const renderIcons = root => window.renderChorusIcons?.(root || document);
function toast(message) {
$("#toast-text").textContent = message;
show($("#toast"));
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => hide($("#toast")), 4000);
}
function openModal(id, focusSelector) {
lastTrigger = document.activeElement;
const modal = document.getElementById(id);
show(modal);
window.setTimeout(() => modal?.querySelector(focusSelector)?.focus(), 0);
}
function closeModal(id) {
hide(document.getElementById(id));
lastTrigger?.focus?.();
lastTrigger = null;
}
function formatDate(value) {
if (!value) return "-";
return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value));
}
function statusLabel(status) {
return { active: "有效", expired: "已过期", revoked: "已撤销" }[status] || "未知";
}
function stateMarkup(type, retryAfter) {
const states = {
empty: ["key-round", "还没有 API Key", "创建后可用于程序调用;完整 Key 只显示一次。"],
error: ["alert-circle", "API Key 加载失败", "已有数据不会被修改。请检查网络后重试。"],
forbidden: ["shield-check", "当前账户无法管理 API Key", "账户已停用或当前会话不再具备访问条件。"],
limited: ["clock-3", "请求过于频繁", `请在 ${retryAfter || 1} 秒后重试。已有 Key 不会受到影响。`]
};
const [icon, title, message] = states[type];
const action = type === "empty" ? '<button class="button button-primary" type="button" data-action="create"><i class="icon" data-lucide="plus"></i>创建 API Key</button>' : (type === "error" || type === "limited" ? '<button class="button button-secondary" type="button" data-action="retry"><i class="icon" data-lucide="refresh-cw"></i>重试</button>' : "");
return `<div class="api-key-state"><span class="state-icon ${type === "error" || type === "forbidden" ? "danger" : ""}"><i class="icon" data-lucide="${icon}"></i></span><h3>${title}</h3><p>${message}</p>${action}</div>`;
}
function tableCell(label, value, className) {
const cell = document.createElement("td");
cell.dataset.label = label;
if (className) cell.className = className;
cell.textContent = value;
return cell;
}
function renderList() {
const content = $("#api-key-content");
content.setAttribute("aria-busy", "false");
$("#api-key-count").textContent = `共 ${keys.length} 个`;
if (!keys.length) {
content.innerHTML = stateMarkup("empty");
renderIcons(content);
return;
}
const wrap = document.createElement("div");
wrap.className = "api-key-table-wrap";
const table = document.createElement("table");
table.className = "api-key-table";
table.innerHTML = "<thead><tr><th>名称</th><th>Key 前缀</th><th>状态</th><th>最近使用</th><th>到期时间</th><th>创建时间</th><th>操作</th></tr></thead>";
const tbody = document.createElement("tbody");
keys.forEach(key => {
const row = document.createElement("tr");
row.append(tableCell("名称", key.name, "api-key-name"));
row.append(tableCell("Key 前缀", key.key_prefix, "api-key-prefix"));
const status = document.createElement("td");
status.dataset.label = "状态";
const badge = document.createElement("span");
badge.className = `api-key-badge ${key.status}`;
badge.textContent = statusLabel(key.status);
status.append(badge);
row.append(status);
row.append(tableCell("最近使用", key.last_used_at ? formatDate(key.last_used_at) : "从未使用"));
row.append(tableCell("到期时间", key.expires_at ? formatDate(key.expires_at) : "永不过期"));
row.append(tableCell("创建时间", formatDate(key.created_at)));
const actions = document.createElement("td");
actions.dataset.label = "操作";
const group = document.createElement("div");
group.className = "api-key-actions";
const rename = document.createElement("button");
rename.className = "api-key-action";
rename.type = "button";
rename.dataset.action = "rename";
rename.dataset.keyId = String(key.id);
rename.innerHTML = '<i class="icon" data-lucide="pencil"></i><span>改名</span>';
group.append(rename);
if (key.status === "active") {
const revoke = document.createElement("button");
revoke.className = "api-key-action danger";
revoke.type = "button";
revoke.dataset.action = "revoke";
revoke.dataset.keyId = String(key.id);
revoke.innerHTML = '<i class="icon" data-lucide="ban"></i><span>撤销</span>';
group.append(revoke);
}
actions.append(group);
row.append(actions);
tbody.append(row);
});
table.append(tbody);
wrap.append(table);
content.replaceChildren(wrap);
renderIcons(content);
}
function renderRequestState(type, retryAfter) {
const content = $("#api-key-content");
content.setAttribute("aria-busy", "false");
$("#api-key-count").textContent = "";
content.innerHTML = stateMarkup(type, retryAfter);
renderIcons(content);
}
function handleAccessResponse(response, modalID) {
if (response.status === 401) {
if (modalID) closeModal(modalID);
openModal("auth-modal", "a");
return true;
}
if (response.status === 403 || response.status === 429) {
if (modalID) closeModal(modalID);
renderRequestState(response.status === 403 ? "forbidden" : "limited", response.headers.get("Retry-After"));
return true;
}
return false;
}
function apiErrorMessage(payload, fallback) {
return {
invalid_api_key_name: "名称不能为空且不能超过 80 个字符。",
invalid_api_key_expiry: "请选择有效的到期时间。",
not_found: "API Key 不存在或已无法访问。"
}[payload.error?.code] || fallback;
}
async function loadKeys() {
const content = $("#api-key-content");
content.setAttribute("aria-busy", "true");
content.innerHTML = '<div class="api-key-loading" aria-label="正在加载 API Key"><span></span><span></span><span></span></div>';
try {
const response = await fetch("/api/api-keys", { headers: { Accept: "application/json" }, cache: "no-store" });
if (response.status === 401) { openModal("auth-modal", "a"); return; }
if (response.status === 403) { renderRequestState("forbidden"); return; }
if (response.status === 429) { renderRequestState("limited", response.headers.get("Retry-After")); return; }
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error?.code || "load_failed");
keys = Array.isArray(payload.items) ? payload.items : [];
renderList();
} catch (_) {
renderRequestState("error");
}
}
async function createKey(event) {
event.preventDefault();
const name = $("#api-key-name");
const error = $("#api-key-name-error");
hide(error);
if (!name.value.trim()) { error.textContent = "请输入 API Key 名称。"; show(error); name.focus(); return; }
const button = $("#submit-create-key");
button.disabled = true;
button.querySelector("span").textContent = "正在创建";
const expiry = $("#api-key-expiry").value;
try {
const response = await fetch("/api/api-keys", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken() }, body: JSON.stringify({ name: name.value, expires_in_days: expiry ? Number(expiry) : null }), cache: "no-store" });
if (handleAccessResponse(response, "create-key-modal")) return;
const payload = await response.json().catch(() => ({}));
if (!response.ok) { error.textContent = apiErrorMessage(payload, "创建失败,请稍后重试。"); show(error); return; }
keys.unshift({ id: payload.id, name: payload.name, key_prefix: payload.key_prefix, status: payload.status, expires_at: payload.expires_at, last_used_at: payload.last_used_at, revoked_at: payload.revoked_at, created_at: payload.created_at, updated_at: payload.updated_at });
closeModal("create-key-modal");
renderList();
$("#secret-key-value").textContent = payload.token;
hide($("#copy-secret-error"));
openModal("secret-key-modal", "#copy-secret-key");
name.value = "";
} catch (_) {
error.textContent = "创建请求失败,请检查网络后重试。";
show(error);
} finally {
button.disabled = false;
button.querySelector("span").textContent = "创建";
}
}
async function renameKey(event) {
event.preventDefault();
const input = $("#rename-key-name");
const error = $("#rename-key-error");
hide(error);
if (!selectedKey || !input.value.trim()) { error.textContent = "请输入 API Key 名称。"; show(error); input.focus(); return; }
const button = $("#submit-rename-key");
button.disabled = true;
button.querySelector("span").textContent = "正在保存";
try {
const response = await fetch(`/api/api-keys/${encodeURIComponent(selectedKey.id)}`, { method: "PATCH", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken() }, body: JSON.stringify({ name: input.value }), cache: "no-store" });
if (handleAccessResponse(response, "rename-key-modal")) return;
const payload = await response.json().catch(() => ({}));
if (!response.ok) { error.textContent = apiErrorMessage(payload, "保存失败,请稍后重试。"); show(error); return; }
const index = keys.findIndex(key => key.id === payload.api_key.id);
if (index >= 0) keys[index] = payload.api_key;
closeModal("rename-key-modal");
renderList();
toast("名称已更新");
} catch (_) {
error.textContent = "保存请求失败,请检查网络后重试。";
show(error);
} finally {
button.disabled = false;
button.querySelector("span").textContent = "保存";
}
}
async function revokeKey() {
if (!selectedKey) return;
const error = $("#revoke-key-error");
const button = $("#confirm-revoke-key");
hide(error);
button.disabled = true;
button.querySelector("span").textContent = "正在撤销";
try {
const response = await fetch(`/api/api-keys/${encodeURIComponent(selectedKey.id)}`, { method: "DELETE", headers: { "X-CSRF-Token": csrfToken() }, cache: "no-store" });
if (handleAccessResponse(response, "revoke-key-modal")) return;
const payload = await response.json().catch(() => ({}));
if (!response.ok) { error.textContent = apiErrorMessage(payload, "撤销失败,请稍后重试。"); show(error); return; }
const index = keys.findIndex(key => key.id === payload.api_key.id);
if (index >= 0) keys[index] = payload.api_key;
closeModal("revoke-key-modal");
renderList();
toast("API Key 已撤销");
} catch (_) {
error.textContent = "撤销请求失败,请检查网络后重试。";
show(error);
} finally {
button.disabled = false;
button.querySelector("span").textContent = "确认撤销";
}
}
function initialize() {
$("#create-key-button").addEventListener("click", () => openModal("create-key-modal", "#api-key-name"));
$("#create-key-form").addEventListener("submit", createKey);
$("#rename-key-form").addEventListener("submit", renameKey);
$("#confirm-revoke-key").addEventListener("click", revokeKey);
$("#confirm-secret-key").addEventListener("click", () => { $("#secret-key-value").textContent = ""; closeModal("secret-key-modal"); toast("API Key 已创建"); });
$("#copy-secret-key").addEventListener("click", async () => {
try { await navigator.clipboard.writeText($("#secret-key-value").textContent); hide($("#copy-secret-error")); toast("API Key 已复制"); }
catch (_) { show($("#copy-secret-error")); }
});
document.addEventListener("click", event => {
const close = event.target.closest("[data-close-modal]");
if (close) { closeModal(close.dataset.closeModal); return; }
const action = event.target.closest("[data-action]");
if (!action) return;
if (action.dataset.action === "create") { openModal("create-key-modal", "#api-key-name"); return; }
if (action.dataset.action === "retry") { loadKeys(); return; }
selectedKey = keys.find(key => String(key.id) === action.dataset.keyId);
if (!selectedKey) return;
if (action.dataset.action === "rename") { $("#rename-key-name").value = selectedKey.name; hide($("#rename-key-error")); openModal("rename-key-modal", "#rename-key-name"); }
if (action.dataset.action === "revoke") { $("#revoke-key-summary").textContent = `${selectedKey.name} · ${selectedKey.key_prefix}`; hide($("#revoke-key-error")); openModal("revoke-key-modal", "#confirm-revoke-key"); }
});
document.addEventListener("keydown", event => {
if (event.key !== "Escape" || !$("#secret-key-modal").classList.contains("hidden")) return;
const open = document.querySelector(".modal-backdrop:not(.hidden)");
if (open && open.id !== "auth-modal") closeModal(open.id);
});
$("#logout-button").addEventListener("click", async () => { await fetch("/api/session/logout", { method: "POST", headers: { "X-CSRF-Token": csrfToken() } }).catch(() => {}); window.location.assign("/login"); });
renderIcons();
loadKeys();
}
document.addEventListener("DOMContentLoaded", initialize);
})();