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) <noreply@anthropic.com>
This commit is contained in:
cedricandClaude Opus 5.5 committed 2026-11-01 16:00:05 +01:00
1 parent e77fcb326d
commit 7ee27bb1ab
5 files changed
+133 -63

No files matched your search

+8 -6
View File
@@ -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
+7 -5
View File
@@ -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
+105 -43
View File
@@ -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 <mark>${esc(token)}</mark>" \\\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 ? `<p class="small">${esc(t('tokenOnce'))}</p>` + fresh.map((f, i) =>
`<div class="token-secret"><span class="token-label">${esc(f.name)}</span><code>${esc(f.token)}</code>`
+ `<button class="btn" type="button" data-copy-token="${i}">${esc(t('tokenCopy'))}</button></div>`).join('') : '';
$('#tokenExample').innerHTML = curlExample(fresh.length ? fresh[fresh.length - 1].token : 'lst_…').html;
if (!tokens.list) {
el.innerHTML = tokens.error ? `<div class="token-empty bad">${esc(tokens.error)}</div>` : '';
return;
}
if (!tokens.list.length) {
el.innerHTML = `<div class="token-empty">${esc(t('tokenNone'))}</div>`;
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 `<div class="token-row"><div><b>${esc(k.name)}</b> <code>${esc(k.hint)}…</code></div>`
+ `<button class="btn danger" type="button" data-revoke="${esc(k.id)}">${esc(t('tokenRevoke'))}</button>`
+ `<div class="muted">${esc(t('tokenCreated', day(k.created)))} · ${esc(used)}</div></div>`;
const info = gone ? t('tokenRevokePending') : `${t('tokenCreated', day(k.created))} · ${used}`;
return `<div class="token-row${gone ? ' revoked' : ''}"><div><b>${esc(k.name)}</b> <code>${esc(k.hint)}…</code></div>`
+ (gone
? `<button class="btn" type="button" data-unrevoke="${esc(k.id)}">${esc(t('tokenUndo'))}</button>`
: `<button class="btn danger" type="button" data-revoke="${esc(k.id)}">${esc(t('tokenRevoke'))}</button>`)
+ `<div class="muted">${esc(info)}</div></div>`;
}).join('');
html += p.create.map((name, i) => `<div class="token-row pending"><div><b>${esc(name)}</b></div>`
+ `<button class="btn" type="button" data-uncreate="${i}">${esc(t('tokenUndo'))}</button>`
+ `<div class="muted">${esc(t('tokenCreatePending'))}</div></div>`).join('');
el.innerHTML = html || `<div class="token-empty">${esc(t('tokenNone'))}</div>`;
}
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();
}
+6 -9
View File
@@ -462,20 +462,17 @@
<div id="tokenList" class="token-list"></div>
<form id="tokenForm" class="token-form">
<input id="tokenName" type="text" class="dk-filter" maxlength="80" autocomplete="off" spellcheck="false" data-i18n-ph="tokenNamePh" data-i18n-aria="tokenNamePh">
<button id="tokenCreate" class="btn" type="submit" data-i18n="tokenCreate">Create a token</button>
<button id="tokenCreate" class="btn" type="submit" data-i18n="tokenCreate">Add a token</button>
</form>
<div id="tokenNew" class="token-new" hidden>
<p class="small" data-i18n="tokenOnce"></p>
<div class="token-secret">
<code id="tokenSecret"></code>
<button id="tokenCopy" class="btn" type="button" data-i18n="tokenCopy">Copy</button>
</div>
</div>
<div id="tokenNew" class="token-new" hidden></div>
</section>
<section class="set-section">
<h3 data-i18n="tokensUse">Usage</h3>
<p class="muted small" data-i18n="tokensUseHelp"></p>
<pre id="tokenExample" class="token-example"></pre>
<div class="token-example-box">
<pre id="tokenExample" class="token-example"></pre>
<button id="tokenExampleCopy" class="btn" type="button" data-i18n="tokenExampleCopy">Copy the command</button>
</div>
</section>
</div>
</div>
+7
View File
@@ -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 {