feat: #107 configuration - gestion des clés API & MCP (création/révocation, expiration 1j/1mois/6mois/1an/sans fin, une clé pour API REST + serveur MCP, dernière utilisation, store sans secret persisté; fix révocation longue durée) + script token MCP
CI / lint (push) Successful in 1m58s
CI / security (push) Successful in 1m32s
CI / test (push) Successful in 4m0s
CI / build (push) Successful in 1m15s
CI / e2e (push) Successful in 12m9s

This commit is contained in:
2026-09-22 14:48:51 -04:00
parent 6f0a6f7fd8
commit eff74cabe0
22 changed files with 885 additions and 33 deletions
+104
View File
@@ -773,6 +773,7 @@ function initConfigModal() {
loadAbout();
await loadHiddenFilesSettings();
loadWebhooksUI();
loadTokensUI();
loadSharesUI();
loadToolKeys();
safeCreateIcons();
@@ -1346,6 +1347,109 @@ document.addEventListener("click", function(e) {
}
});
// ── API / MCP tokens UI (#107) ──
let _tokensBound = false;
async function loadTokensUI() {
const list = document.getElementById("tokens-list");
if (!list) return;
try {
const data = await api("/api/auth/tokens");
renderTokensUI(data.tokens || []);
bindTokenEvents();
} catch (err) {
list.innerHTML = '<div class="config-description">' + escapeHtml(t("config.error_prefix") + ": " + (err.message || "")) + "</div>";
}
}
function _formatTokenDate(unixSec) {
if (!unixSec) return "";
return new Date(unixSec * 1000).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
}
function _tokenExpiryLabel(tok) {
if (!tok.expires_at) return t("config.token_expiry_never");
const map = { "1d": "config.token_expiry_1d", "30d": "config.token_expiry_30d", "180d": "config.token_expiry_180d", "365d": "config.token_expiry_365d" };
return map[tok.expiry_key] ? t(map[tok.expiry_key]) : _formatTokenDate(tok.expires_at);
}
function renderTokensUI(tokens) {
const list = document.getElementById("tokens-list");
if (!list) return;
if (!tokens.length) {
list.innerHTML = '<div class="config-description">' + escapeHtml(t("config.tokens_empty")) + "</div>";
return;
}
list.innerHTML = tokens.map(tok => {
const expired = tok.expired || (tok.expires_at && tok.expires_at * 1000 < Date.now());
const status = expired
? '<span class="token-badge token-badge-expired">' + escapeHtml(t("config.token_status_expired")) + "</span>"
: '<span class="token-badge token-badge-active">' + escapeHtml(t("config.token_status_active")) + "</span>";
const meta = [
t("config.token_created") + " " + _formatTokenDate(tok.created_at),
t("config.token_expires") + " " + _tokenExpiryLabel(tok),
tok.last_used_at ? t("config.token_last_used") + " " + _formatTokenDate(tok.last_used_at) : t("config.token_never_used")
].join(" · ");
return '<div class="token-item" data-jti="' + escapeHtml(tok.jti) + '">' +
'<span class="token-name">' + escapeHtml(tok.name) + "</span>" +
status +
'<span class="token-meta">' + escapeHtml(meta) + "</span>" +
'<button class="token-delete" data-jti="' + escapeHtml(tok.jti) + '" data-name="' + escapeHtml(tok.name) + '" title="' + escapeHtml(t("config.token_revoke")) + '">✕</button>' +
"</div>";
}).join("");
list.querySelectorAll(".token-delete").forEach(btn => btn.addEventListener("click", async () => {
const name = btn.dataset.name;
if (!confirm(t("config.token_revoke_confirm") + " \"" + name + "\" ?")) return;
try {
await api("/api/auth/tokens/" + btn.dataset.jti, { method: "DELETE" });
showToast(t("config.token_revoked_toast"), "success");
loadTokensUI();
} catch (err) {
showToast(err.message || t("config.error_unknown"), "error");
}
}));
}
function bindTokenEvents() {
if (_tokensBound) return;
_tokensBound = true;
const createBtn = document.getElementById("token-create-btn");
if (!createBtn) return;
createBtn.addEventListener("click", async () => {
const name = document.getElementById("token-name-input").value.trim();
const expiry = document.getElementById("token-expiry-select").value;
if (!name) { showToast(t("config.token_name_required"), "error"); return; }
createBtn.disabled = true;
try {
const res = await api("/api/auth/tokens", { method: "POST", body: JSON.stringify({ name, expiry }) });
const area = document.getElementById("token-secret-area");
const ta = document.getElementById("token-secret-value");
ta.value = res.token;
area.classList.remove("hidden");
ta.select();
document.getElementById("token-name-input").value = "";
showToast(t("config.token_created_toast"), "success");
loadTokensUI();
} catch (err) {
showToast(err.message || t("config.error_unknown"), "error");
} finally {
createBtn.disabled = false;
}
});
const copyBtn = document.getElementById("token-copy-btn");
if (copyBtn) copyBtn.addEventListener("click", async () => {
const ta = document.getElementById("token-secret-value");
try { await navigator.clipboard.writeText(ta.value); }
catch { ta.select(); document.execCommand("copy"); }
showToast(t("config.token_copied"), "success");
});
const dismissBtn = document.getElementById("token-dismiss-btn");
if (dismissBtn) dismissBtn.addEventListener("click", () => {
document.getElementById("token-secret-area").classList.add("hidden");
document.getElementById("token-secret-value").value = "";
});
}
// ── Shares UI ──
async function loadSharesUI() {
const list = document.getElementById("shares-list");