From 7ee27bb1ab125840208af84bce24ef6e292438eb Mon Sep 17 00:00:00 2001 From: Cedric Date: Sun, 1 Nov 2026 16:00:05 +0100 Subject: [PATCH] Settings > API: token changes wait for Save, curl example with the token Adding or revoking an API token is now pending like the other settings: Save applies it, Cancel drops it. After Save each new token is shown once with a copy button, and the curl example uses it, highlighted, with its own copy button. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.fr.md | 14 +++-- README.md | 12 ++-- web/app.js | 148 +++++++++++++++++++++++++++++++++++-------------- web/index.html | 15 ++--- web/style.css | 7 +++ 5 files changed, 133 insertions(+), 63 deletions(-) diff --git a/README.fr.md b/README.fr.md index 8e159e8..0fe2657 100644 --- a/README.fr.md +++ b/README.fr.md @@ -401,7 +401,7 @@ L'accès passe par un **jeton d'API**, créé dans *Paramètres > API* par un ad envoyé dans `Authorization: Bearer lst_…`. Un jeton est en lecture seule et n'ouvre que `/api/v1/` ; il fonctionne quel que soit le mode d'[authentification](#authentification) (`local`, `oidc` ou aucune). Il n'est affiché qu'une fois, à sa création : LogStream n'en garde -que l'empreinte SHA-256, dans `/data/api_tokens.json`. Le révoquer coupe l'accès immédiatement ; +que l'empreinte SHA-256, dans `/data/api_tokens.json`. Le révoquer (puis Valider) coupe l'accès immédiatement ; la liste indique quand et d'où chaque jeton a servi pour la dernière fois. Une session ouverte dans le navigateur peut aussi appeler la route. @@ -470,8 +470,8 @@ s'affichent tout de suite mais ne sont conservées qu'avec le bouton **Valider** des onglets : il prend la couleur d'accent tant que des modifications sont en attente. **Annuler** revient aux paramètres enregistrés, et fermer la fenêtre avec des modifications non validées demande confirmation avant de les abandonner. Localisation et Affichage sont mémorisés par -navigateur ; les tags de couleur et les sources de logs sont enregistrés sur le serveur. La purge, -les jetons d'API et la réinitialisation des colonnes agissent immédiatement. +navigateur ; les tags de couleur et les sources de logs sont enregistrés sur le serveur. La purge +et la réinitialisation des colonnes agissent immédiatement. - **Localisation et langue** - *Langue* : anglais ou français. @@ -525,9 +525,11 @@ les jetons d'API et la réinitialisation des colonnes agissent immédiatement. désactivée par défaut : mettez `ALLOW_PURGE=true` pour l'autoriser. Tout administrateur peut alors purger : activez l'[authentification](#authentification) si l'interface est accessible à d'autres. -- **API** : jetons de l'[API de lecture](#api-de-lecture-n8n-scripts) : en créer un avec un nom - (affiché une seule fois, à copier tout de suite), voir quand et d'où chacun a servi pour la - dernière fois, le révoquer. Réservé aux administrateurs. +- **API** : jetons de l'[API de lecture](#api-de-lecture-n8n-scripts) : en ajouter un avec un + nom, en révoquer un, voir quand et d'où chacun a servi pour la dernière fois. Ajouts et + révocations attendent **Valider** comme les autres réglages (**Annuler** les abandonne). Après + Valider, chaque nouveau jeton est affiché une seule fois avec un bouton Copier, et l'exemple + curl en dessous l'utilise, surligné, avec son propre bouton Copier. Réservé aux administrateurs. ## Authentification diff --git a/README.md b/README.md index 75f39ac..81b7c7c 100644 --- a/README.md +++ b/README.md @@ -364,7 +364,7 @@ Access goes through an **API token**, created in *Settings > API* by an administ as `Authorization: Bearer lst_…`. A token is read-only and only opens `/api/v1/`; it works whatever the [authentication](#authentication) mode (`local`, `oidc`, or none). It is shown once, when it is created: LogStream only keeps its SHA-256 hash, in `/data/api_tokens.json`. -Revoking it cuts off access at once; the list shows when and from where each token was last +Revoking it (then Save) cuts off access at once; the list shows when and from where each token was last used. A logged-in browser session can call the route too. | Parameter | Meaning | @@ -429,7 +429,7 @@ The gear icon opens the settings, organized in tabs. Changes show at once but ar with the **Save** button, under the tab labels: it takes the accent color while changes are waiting. **Cancel** goes back to the saved settings, and closing the window with unsaved changes asks before dropping them. Localization and Appearance are remembered per browser; color tags -and log sources are saved on the server. Purging, API tokens and resetting column widths act at once. +and log sources are saved on the server. Purging and resetting column widths act at once. - **Localization** - *Language*: English or French. @@ -477,9 +477,11 @@ and log sources are saved on the server. Purging, API tokens and resetting colum (already set in `docker-compose.yml`). The feature is off by default: set `ALLOW_PURGE=true` to allow it. Any admin can then purge: turn on [authentication](#authentication) if the UI is reachable by others. -- **API**: API tokens for the [reading API](#reading-api-n8n-scripts): create one with a name - (shown once, copy it then), see when and from where each was last used, revoke it. Reserved - to administrators. +- **API**: API tokens for the [reading API](#reading-api-n8n-scripts): add one with a name, + revoke one, see when and from where each was last used. Additions and revocations wait for + **Save** like the other settings (**Cancel** drops them). After Save, each new token is shown + once with a copy button, and the curl example below uses it, highlighted, with its own copy + button. Reserved to administrators. ## Authentication diff --git a/web/app.js b/web/app.js index 763da75..023e77f 100644 --- a/web/app.js +++ b/web/app.js @@ -176,21 +176,23 @@ const I18N = { err_export_pipes: 'Export does not support LogsQL pipes (| …): keep only the filter part', tabApi: 'API', tokensTitle: 'API tokens', - tokensHelp: 'Tokens give scripts and automation tools (n8n…) read-only access to /api/v1/logs, whatever the authentication mode. Only a hash is kept: a token is shown once, when it is created. Revoking it cuts off access at once.', + tokensHelp: 'Tokens give scripts and automation tools (n8n…) read-only access to /api/v1/logs, whatever the authentication mode. Additions and revocations apply with Save. Only a hash is kept: a new token is shown once, right after Save.', tokenCount: (n) => `${n} token${n > 1 ? 's' : ''}`, tokenNone: 'No API token yet.', tokenNamePh: 'Token name (n8n, backup script…)', - tokenCreate: 'Create a token', + tokenCreate: 'Add a token', tokenOnce: 'Copy this token now: it will not be shown again.', tokenCopy: 'Copy', tokenRevoke: 'Revoke', - tokenRevokeAsk: (n) => `Revoke the token "${n}"? Tools using it lose access at once.`, - tokenRevoked: 'Token revoked', + tokenUndo: 'Undo', + tokenCreatePending: 'created on Save', + tokenRevokePending: 'revoked on Save', + tokenExampleCopy: 'Copy the command', tokenCreated: (d) => `created ${d}`, tokenUsed: (p) => `last used ${p.d}${p.ip ? ' from ' + p.ip : ''}`, tokenNever: 'never used', tokensUse: 'Usage', - tokensUseHelp: 'Send the token in an Authorization header. Each answer has next_cursor: pass it as since on the next call to get the following logs, without gaps or duplicates (see the README for an n8n workflow).', + tokensUseHelp: 'Send the token in an Authorization header (highlighted below). Each answer has next_cursor: pass it as since on the next call to get the following logs, without gaps or duplicates (see the README for an n8n workflow).', err_token_name: 'Give the token a name of 1 to 80 characters', err_token_max: 'Too many API tokens', tabLocale: 'Localization', tabFilters: 'Patterns', tabInterface: 'Appearance', tabData: 'Database', @@ -427,21 +429,23 @@ const I18N = { err_export_pipes: 'L\'export ne gère pas les pipes LogsQL (| …) : gardez seulement la partie filtre', tabApi: 'API', tokensTitle: "Jetons d'API", - tokensHelp: "Les jetons donnent aux scripts et outils d'automatisation (n8n…) un accès en lecture seule à /api/v1/logs, quel que soit le mode d'authentification. Seule une empreinte est conservée : un jeton n'est affiché qu'une fois, à sa création. Le révoquer coupe l'accès immédiatement.", + tokensHelp: "Les jetons donnent aux scripts et outils d'automatisation (n8n…) un accès en lecture seule à /api/v1/logs, quel que soit le mode d'authentification. Ajouts et révocations s'appliquent avec Valider. Seule une empreinte est conservée : un nouveau jeton n'est affiché qu'une fois, juste après Valider.", tokenCount: (n) => `${n} jeton${n > 1 ? 's' : ''}`, tokenNone: "Aucun jeton d'API pour l'instant.", tokenNamePh: 'Nom du jeton (n8n, script de sauvegarde…)', - tokenCreate: 'Créer un jeton', + tokenCreate: 'Ajouter un jeton', tokenOnce: "Copiez ce jeton maintenant : il ne sera plus affiché.", tokenCopy: 'Copier', tokenRevoke: 'Révoquer', - tokenRevokeAsk: (n) => `Révoquer le jeton « ${n} » ? Les outils qui l'utilisent perdent l'accès immédiatement.`, - tokenRevoked: 'Jeton révoqué', + tokenUndo: 'Annuler', + tokenCreatePending: 'créé en validant', + tokenRevokePending: 'révoqué en validant', + tokenExampleCopy: 'Copier la commande', tokenCreated: (d) => `créé le ${d}`, tokenUsed: (p) => `dernière utilisation ${p.d}${p.ip ? ' depuis ' + p.ip : ''}`, tokenNever: 'jamais utilisé', tokensUse: 'Utilisation', - tokensUseHelp: "Envoyez le jeton dans un en-tête Authorization. Chaque réponse contient next_cursor : passez-le dans since à l'appel suivant pour obtenir les logs qui suivent, sans trou ni doublon (voir le README pour un workflow n8n).", + tokensUseHelp: "Envoyez le jeton dans un en-tête Authorization (surligné ci-dessous). Chaque réponse contient next_cursor : passez-le dans since à l'appel suivant pour obtenir les logs qui suivent, sans trou ni doublon (voir le README pour un workflow n8n).", err_token_name: 'Donnez au jeton un nom de 1 à 80 caractères', err_token_max: "Trop de jetons d'API", tabLocale: 'Localisation et langue', tabFilters: 'Motifs de recherche', tabInterface: 'Affichage', tabData: 'Base de données', @@ -2704,31 +2708,62 @@ $('#purgeBtn').addEventListener('click', async () => { /* ================= API tokens ================= */ -const tokens = { list: null, error: null }; +// Creations and revocations wait for Save like the other settings +// (settings.pending.tokens = { create: [names], revoke: [ids] }); a new token +// is shown once, after Save, with a curl example using it. +const tokens = { list: null, error: null, fresh: [] }; + +const pendingTokens = () => settings.pending.tokens || { create: [], revoke: [] }; +function pendTokens(p) { + if (p.create.length || p.revoke.length) settings.pending.tokens = p; + else delete settings.pending.tokens; + renderTokens(); + renderSaveState(); +} + +function curlExample(token) { + const url = `${location.origin}/api/v1/logs?query=severity:err&since=15m&limit=1000`; + return { + text: `curl -H "Authorization: Bearer ${token}" \\\n "${url}"`, + html: `curl -H "Authorization: Bearer ${esc(token)}" \\\n "${esc(url)}"`, + }; +} function renderTokens() { const el = $('#tokenList'); const day = (x) => fmtFull(x).replace(/[.,]\d{3}(?=\D*$)/, ''); + const p = pendingTokens(); $('#tokenCount').textContent = tokens.list ? t('tokenCount', tokens.list.length) : ''; - $('#tokenExample').textContent = `curl -H "Authorization: Bearer lst_…" \\\n "${location.origin}/api/v1/logs?query=severity:err&since=15m&limit=1000"`; + + // Tokens shown once after Save, and the curl example with the last one. + const fresh = tokens.fresh; + $('#tokenNew').hidden = !fresh.length; + $('#tokenNew').innerHTML = fresh.length ? `

${esc(t('tokenOnce'))}

` + fresh.map((f, i) => + `
${esc(f.name)}${esc(f.token)}` + + `
`).join('') : ''; + $('#tokenExample').innerHTML = curlExample(fresh.length ? fresh[fresh.length - 1].token : 'lst_…').html; + if (!tokens.list) { el.innerHTML = tokens.error ? `
${esc(tokens.error)}
` : ''; return; } - if (!tokens.list.length) { - el.innerHTML = `
${esc(t('tokenNone'))}
`; - return; - } - el.innerHTML = tokens.list.map((k) => { + let html = tokens.list.map((k) => { + const gone = p.revoke.includes(k.id); const used = k.last_used ? t('tokenUsed', { d: day(k.last_used), ip: k.last_ip }) : t('tokenNever'); - return `
${esc(k.name)} ${esc(k.hint)}…
` - + `` - + `
${esc(t('tokenCreated', day(k.created)))} · ${esc(used)}
`; + const info = gone ? t('tokenRevokePending') : `${t('tokenCreated', day(k.created))} · ${used}`; + return `
${esc(k.name)} ${esc(k.hint)}…
` + + (gone + ? `` + : ``) + + `
${esc(info)}
`; }).join(''); + html += p.create.map((name, i) => `
${esc(name)}
` + + `` + + `
${esc(t('tokenCreatePending'))}
`).join(''); + el.innerHTML = html || `
${esc(t('tokenNone'))}
`; } async function loadTokens() { - $('#tokenNew').hidden = true; try { tokens.list = await api('/api/tokens'); tokens.error = null; @@ -2739,35 +2774,59 @@ async function loadTokens() { renderTokens(); } -$('#tokenForm').addEventListener('submit', async (ev) => { +// Save: revocations first, then creations; the new tokens are shown once. +// What failed stays pending, so Save can try it again. +async function saveTokens() { + const p = pendingTokens(); + const errors = []; + const left = { create: [], revoke: [] }; + for (const id of p.revoke) { + try { + await api('/api/tokens/' + encodeURIComponent(id), { method: 'DELETE' }); + } catch (e) { + if (!/not found/.test(e.message)) { errors.push(e.message); left.revoke.push(id); } + } + } + const fresh = []; + for (const name of p.create) { + try { + const res = await api('/api/tokens', { method: 'POST', body: { name } }); + fresh.push({ name: res.name, token: res.token }); + } catch (e) { errors.push(e.message); left.create.push(name); } + } + tokens.fresh = fresh; + if (errors.length) settings.pending.tokens = left; + else delete settings.pending.tokens; + await loadTokens(); + if (errors.length) throw new Error(errors.join(' · ')); +} + +$('#tokenForm').addEventListener('submit', (ev) => { ev.preventDefault(); const name = $('#tokenName').value.trim(); if (!name) { $('#tokenName').focus(); return; } - try { - const res = await api('/api/tokens', { method: 'POST', body: { name } }); - $('#tokenName').value = ''; - await loadTokens(); - $('#tokenSecret').textContent = res.token; - $('#tokenNew').hidden = false; - } catch (e) { - toast(e.message); - } + const p = pendingTokens(); + pendTokens({ create: [...p.create, name], revoke: p.revoke }); + $('#tokenName').value = ''; }); -$('#tokenCopy').addEventListener('click', () => copy($('#tokenSecret').textContent)); +$('#tokenNew').addEventListener('click', (ev) => { + const b = ev.target.closest('[data-copy-token]'); + if (b) copy(tokens.fresh[b.dataset.copyToken].token); +}); -$('#tokenList').addEventListener('click', async (ev) => { - const b = ev.target.closest('[data-revoke]'); +$('#tokenExampleCopy').addEventListener('click', () => { + const fresh = tokens.fresh; + copy(curlExample(fresh.length ? fresh[fresh.length - 1].token : 'lst_…').text); +}); + +$('#tokenList').addEventListener('click', (ev) => { + const p = pendingTokens(); + const b = ev.target.closest('[data-revoke], [data-unrevoke], [data-uncreate]'); if (!b) return; - const k = tokens.list?.find((x) => x.id === b.dataset.revoke); - if (!k || !confirm(t('tokenRevokeAsk', k.name))) return; - try { - await api('/api/tokens/' + encodeURIComponent(k.id), { method: 'DELETE' }); - toast(t('tokenRevoked')); - } catch (e) { - toast(e.message); - } - loadTokens(); + if (b.dataset.revoke) pendTokens({ create: p.create, revoke: [...p.revoke, b.dataset.revoke] }); + else if (b.dataset.unrevoke) pendTokens({ create: p.create, revoke: p.revoke.filter((id) => id !== b.dataset.unrevoke) }); + else pendTokens({ create: p.create.filter((_, i) => i !== Number(b.dataset.uncreate)), revoke: p.revoke }); }); /* ================= Tag management ================= */ @@ -2934,6 +2993,7 @@ $('#settingsBtn').addEventListener('click', () => { loadDocker(); renderDbStats(); loadDbStats(); + tokens.fresh = []; loadTokens(); showSettingsTab(store.get('settingsTab', 'locale')); $('#settingsDlg').showModal(); @@ -2984,6 +3044,7 @@ const SOURCE_SAVE = { selflog: async () => { selfState = await api('/api/selflog', { method: 'PUT', body: { enabled: selfState.enabled, min_level: selfState.min_level || 'warning' } }); }, docker: async () => { dockerState = await api('/api/docker', { method: 'PUT', body: settings.pending.docker }); }, files: saveFiles, + tokens: saveTokens, }; async function saveSettings() { @@ -3062,6 +3123,7 @@ function cancelSettings() { settings.orig = {}; fileDrafts = []; reloadSources(); + renderTokens(); renderSaveState(); } diff --git a/web/index.html b/web/index.html index dace1f8..3427453 100644 --- a/web/index.html +++ b/web/index.html @@ -462,20 +462,17 @@
- +
- +

Usage

-

+              
+

+                
+              
diff --git a/web/style.css b/web/style.css index 90d484b..3934f67 100644 --- a/web/style.css +++ b/web/style.css @@ -928,6 +928,13 @@ body.login-page { .token-form .btn { height: 28px; font-size: 12px; } .token-new { margin-top: 12px; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--accent); background: var(--accent-soft); } .token-secret { display: flex; gap: 10px; align-items: center; margin-top: 8px; } +.token-label { font-size: 12.5px; font-weight: 600; white-space: nowrap; } +.token-row.revoked b, .token-row.revoked code { text-decoration: line-through; opacity: .6; } +.token-row.pending { border: 1px dashed var(--accent); } +.token-example-box { display: flex; gap: 10px; align-items: flex-start; margin-top: 8px; } +.token-example-box .token-example { flex: 1; min-width: 0; margin: 0; } +.token-example-box .btn { height: 28px; font-size: 12px; } +.token-example mark { background: var(--accent-soft); color: var(--accent); border-radius: 3px; padding: 0 2px; font-weight: 600; } .token-secret code { flex: 1; min-width: 0; font: 12px var(--mono); overflow-wrap: anywhere; user-select: all; } .token-secret .btn { height: 28px; font-size: 12px; } .token-example {