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
This commit is contained in:
@@ -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 & 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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user