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
+41
View File
@@ -1547,6 +1547,7 @@
<li><a href="#cfg-themes" class="help-nav-link" data-i18n="settings.themes"></a></li>
<li><a href="#cfg-profile" class="help-nav-link" data-i18n="settings.profile"></a></li>
<li><a href="#cfg-security" class="help-nav-link" data-i18n="settings.security"></a></li>
<li><a href="#cfg-tokens" class="help-nav-link" data-i18n="config.nav_tokens">🔑 Clés API & MCP</a></li>
<li><a href="#cfg-push" class="help-nav-link" data-i18n="config.section_push">Notifications push</a></li>
<li><a href="#cfg-plugins" class="help-nav-link" data-i18n="config.section_plugins">🧩 Plugins</a></li>
<li><a href="#cfg-about" class="help-nav-link" data-i18n="settings.about"></a></li>
@@ -2335,6 +2336,46 @@
</div>
</section>
<!-- Clés API / MCP (#107) -->
<section id="cfg-tokens" class="config-section help-section">
<h2 data-i18n="config.section_tokens">🔑 Clés API &amp; MCP</h2>
<p class="config-description" data-i18n="config.tokens_desc">
Jetons longue durée pour l'API REST et le serveur MCP.
La même clé fonctionne pour les deux (en-tête Authorization: Bearer).
</p>
<div id="tokens-list"></div>
<div class="config-add-row" id="tokens-create-row">
<input
type="text"
id="token-name-input"
data-i18n-placeholder="config.token_name_placeholder"
placeholder="Nom (ex: Claude Desktop)"
class="config-input"
style="width: 180px"
/>
<select id="token-expiry-select" class="config-input" style="width: 140px">
<option value="1d" data-i18n="config.token_expiry_1d">1 jour</option>
<option value="30d" selected data-i18n="config.token_expiry_30d">1 mois</option>
<option value="180d" data-i18n="config.token_expiry_180d">6 mois</option>
<option value="365d" data-i18n="config.token_expiry_365d">1 an</option>
<option value="never" data-i18n="config.token_expiry_never">Sans fin</option>
</select>
<button class="config-btn-add" id="token-create-btn" data-i18n="config.token_create">Créer une clé</button>
</div>
<div id="token-secret-area" class="token-secret-area hidden">
<p class="config-description" data-i18n="config.token_secret_warning">Copiez cette clé maintenant — elle ne sera plus jamais affichée.</p>
<textarea id="token-secret-value" class="config-input token-secret-text" rows="3" readonly></textarea>
<div class="config-add-row">
<button class="config-btn-add" id="token-copy-btn" data-i18n="config.token_copy">Copier</button>
<button class="config-btn-add" id="token-dismiss-btn" data-i18n="config.token_done">Terminé</button>
</div>
</div>
<div class="qh-tip" style="margin-top:10px">
<strong data-i18n="config.token_usage">Utilisation</strong>
<span data-i18n="config.token_usage_detail">: en-tête `Authorization: Bearer ` sur l'API, et config MCP (`claude_desktop_config.json`) : `{"mcpServers":{"obsigate":{"url":"<base>/mcp","headers":{"Authorization":"Bearer "}}}}`.</span>
</div>
</section>
<!-- Notifications push -->
<section
class="config-section help-section"
+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");
+28
View File
@@ -572,6 +572,34 @@
"config.title_boost": "Title boost",
"config.title_boost_hint": "Relevance multiplier for title matches",
"config.title_boost_label": "Title boost",
"config.nav_tokens": "🔑 API & MCP keys",
"config.section_tokens": "🔑 API & MCP keys",
"config.tokens_desc": "Long-lived tokens for the REST API and the MCP server — the same key works for both (Authorization: Bearer header).",
"config.tokens_empty": "No API keys created.",
"config.token_name_placeholder": "Name (e.g. Claude Desktop)",
"config.token_name_required": "A name is required",
"config.token_expiry_1d": "1 day",
"config.token_expiry_30d": "1 month",
"config.token_expiry_180d": "6 months",
"config.token_expiry_365d": "1 year",
"config.token_expiry_never": "Never",
"config.token_create": "Create key",
"config.token_secret_warning": "Copy this key now — it will never be shown again.",
"config.token_copy": "Copy",
"config.token_copied": "Key copied to clipboard",
"config.token_done": "Done",
"config.token_usage": "Usage",
"config.token_usage_detail": ": \"Authorization: Bearer <key>\" header on the API; for MCP, declare it in the headers of the /mcp URL.",
"config.token_created": "Created",
"config.token_expires": "Expires",
"config.token_last_used": "Last used",
"config.token_never_used": "Never used",
"config.token_status_active": "Active",
"config.token_status_expired": "Expired",
"config.token_revoke": "Revoke",
"config.token_revoke_confirm": "Revoke key",
"config.token_revoked_toast": "Key revoked (immediate effect on API + MCP)",
"config.token_created_toast": "Key created",
"config.url_required": "URL required",
"config.watcher_debounce_label": "Debounce (s)",
"config.watcher_enabled_label": "Enable watcher",
+28
View File
@@ -572,6 +572,34 @@
"config.title_boost": "Boost titre",
"config.title_boost_hint": "Multiplicateur de pertinence pour les correspondances dans le titre",
"config.title_boost_label": "Boost titre",
"config.nav_tokens": "🔑 Clés API & MCP",
"config.section_tokens": "🔑 Clés API & MCP",
"config.tokens_desc": "Jetons longue durée pour l'API REST et le serveur MCP — la même clé fonctionne pour les deux (en-tête Authorization: Bearer).",
"config.tokens_empty": "Aucune clé API créée.",
"config.token_name_placeholder": "Nom (ex: Claude Desktop)",
"config.token_name_required": "Un nom est requis",
"config.token_expiry_1d": "1 jour",
"config.token_expiry_30d": "1 mois",
"config.token_expiry_180d": "6 mois",
"config.token_expiry_365d": "1 an",
"config.token_expiry_never": "Sans fin",
"config.token_create": "Créer une clé",
"config.token_secret_warning": "Copiez cette clé maintenant — elle ne sera plus jamais affichée.",
"config.token_copy": "Copier",
"config.token_copied": "Clé copiée dans le presse-papiers",
"config.token_done": "Terminé",
"config.token_usage": "Utilisation",
"config.token_usage_detail": ": en-tête « Authorization: Bearer <clé> » sur l'API ; pour MCP, déclarez-la dans les headers de l'URL /mcp.",
"config.token_created": "Créée le",
"config.token_expires": "Expire",
"config.token_last_used": "Dernière utilisation",
"config.token_never_used": "Jamais utilisée",
"config.token_status_active": "Active",
"config.token_status_expired": "Expirée",
"config.token_revoke": "Révoquer",
"config.token_revoke_confirm": "Révoquer la clé",
"config.token_revoked_toast": "Clé révoquée (effet immédiat API + MCP)",
"config.token_created_toast": "Clé créée",
"config.url_required": "URL requise",
"config.watcher_debounce_label": "Debounce (s)",
"config.watcher_enabled_label": "Activer la surveillance",
+21
View File
@@ -7607,6 +7607,27 @@ body.popup-mode .content-area {
.config-add-row { display: flex; gap: 8px; margin-top: 8px; }
.config-btn-add { padding: 6px 14px; background: var(--accent); color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 0.8rem; }
/* ── API/MCP tokens UI (#107) ── */
.token-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: var(--bg-card, var(--bg-secondary));
border-radius: 6px;
margin-bottom: 6px;
font-size: 0.8rem;
}
.token-name { font-weight: 500; min-width: 90px; }
.token-badge { font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; white-space: nowrap; }
.token-badge-active { background: color-mix(in srgb, var(--success, #2e7d32) 18%, transparent); color: var(--success, #2e7d32); }
.token-badge-expired { background: color-mix(in srgb, var(--text-error, #c62828) 15%, transparent); color: var(--text-error, #c62828); }
.token-meta { color: var(--text-muted); font-size: 0.7rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.token-delete { background: none; border: none; color: var(--text-error); cursor: pointer; font-size: 1rem; padding: 2px 6px; }
.token-secret-area { margin-top: 10px; padding: 10px; border: 1px dashed var(--accent); border-radius: 8px; }
.token-secret-area.hidden { display: none; }
.token-secret-text { width: 100%; font-family: monospace; font-size: 0.7rem; word-break: break-all; resize: none; }
/* ── Shares UI ── */
.share-item {
display: flex;
+1 -1
View File
@@ -11,7 +11,7 @@
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
* a separate store. Bumping SW_VERSION invalidates it on every release.
*/
const SW_VERSION = 'v23';
const SW_VERSION = 'v24';
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
const API_CACHE = `obsigate-api-${SW_VERSION}`;