ScuttleBot

feat: web UI API key management card (#125) Settings tab now has an API keys card: - Create keys with name, scope checkboxes, optional expiry - Token shown once on creation with copy-friendly display - List all keys with name, scopes, status, last-used - Revoke button per key with confirmation

lmata 2026-04-05 14:18 trunk
Commit 2428c83591b10079ab34fff3cabc8ae636e4d1ea3a35320e6f2f9a91044fe10c
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -580,10 +580,40 @@
580580
<button type="submit" class="primary sm" style="margin-bottom:1px">add admin</button>
581581
</form>
582582
<div id="add-admin-result" style="margin-top:10px"></div>
583583
</div>
584584
</div>
585
+
586
+ <!-- api keys -->
587
+ <div class="card" id="card-apikeys">
588
+ <div class="card-header" onclick="toggleCard('card-apikeys',event)"><h2>API keys</h2><span class="card-desc">per-consumer tokens with scoped permissions</span><span class="collapse-icon">▾</span></div>
589
+ <div id="apikeys-list-container"></div>
590
+ <div class="card-body" style="border-top:1px solid #21262d">
591
+ <p style="font-size:12px;color:#8b949e;margin-bottom:12px">Create an API key with a name and scopes. The token is shown only once.</p>
592
+ <form id="add-apikey-form" onsubmit="createAPIKey(event)" style="display:flex;flex-direction:column;gap:10px">
593
+ <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
594
+ <div style="flex:1;min-width:160px"><label>name</label><input type="text" id="new-apikey-name" placeholder="e.g. kohakku-controller" autocomplete="off"></div>
595
+ <div style="flex:1;min-width:160px"><label>expires in</label><input type="text" id="new-apikey-expires" placeholder="e.g. 720h (empty=never)" autocomplete="off"></div>
596
+ </div>
597
+ <div>
598
+ <label style="margin-bottom:6px;display:block">scopes</label>
599
+ <div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px">
600
+ <label><input type="checkbox" value="admin" class="apikey-scope"> admin</label>
601
+ <label><input type="checkbox" value="agents" class="apikey-scope"> agents</label>
602
+ <label><input type="checkbox" value="channels" class="apikey-scope"> channels</label>
603
+ <label><input type="checkbox" value="chat" class="apikey-scope"> chat</label>
604
+ <label><input type="checkbox" value="topology" class="apikey-scope"> topology</label>
605
+ <label><input type="checkbox" value="bots" class="apikey-scope"> bots</label>
606
+ <label><input type="checkbox" value="config" class="apikey-scope"> config</label>
607
+ <label><input type="checkbox" value="read" class="apikey-scope"> read</label>
608
+ </div>
609
+ </div>
610
+ <button type="submit" class="primary sm" style="align-self:flex-start">create key</button>
611
+ </form>
612
+ <div id="add-apikey-result" style="margin-top:10px"></div>
613
+ </div>
614
+ </div>
585615
586616
<!-- tls -->
587617
<div class="card" id="card-tls">
588618
<div class="card-header" onclick="toggleCard('card-tls',event)"><h2>TLS / SSL</h2><span class="card-desc">certificate status</span><span class="collapse-icon">▾</span><div class="spacer"></div><span id="tls-badge" class="badge">loading…</span></div>
589619
<div class="card-body">
@@ -2542,10 +2572,73 @@
25422572
try {
25432573
await api('PUT', `/v1/admins/${encodeURIComponent(username)}/password`, { password: pw });
25442574
alert('Password updated.');
25452575
} catch(e) { alert('Failed: ' + e.message); }
25462576
}
2577
+
2578
+// --- API keys ---
2579
+async function loadAPIKeys() {
2580
+ try {
2581
+ const keys = await api('GET', '/v1/api-keys');
2582
+ renderAPIKeys(keys || []);
2583
+ } catch(e) {
2584
+ document.getElementById('apikeys-list-container').innerHTML = '';
2585
+ }
2586
+}
2587
+
2588
+function renderAPIKeys(keys) {
2589
+ const el = document.getElementById('apikeys-list-container');
2590
+ if (!keys.length) { el.innerHTML = ''; return; }
2591
+ const rows = keys.map(k => {
2592
+ const status = k.active ? '<span style="color:#3fb950">active</span>' : '<span style="color:#f85149">revoked</span>';
2593
+ const scopes = (k.scopes || []).map(s => `<code style="font-size:11px;background:#21262d;padding:1px 5px;border-radius:3px">${esc(s)}</code>`).join(' ');
2594
+ const lastUsed = k.last_used ? fmtTime(k.last_used) : '—';
2595
+ const revokeBtn = k.active ? `<button class="sm danger" onclick="revokeAPIKey('${esc(k.id)}')">revoke</button>` : '';
2596
+ return `<tr>
2597
+ <td><strong>${esc(k.name)}</strong><br><span style="color:#8b949e;font-size:11px">${esc(k.id)}</span></td>
2598
+ <td>${scopes}</td>
2599
+ <td style="font-size:12px">${status}</td>
2600
+ <td style="color:#8b949e;font-size:12px">${lastUsed}</td>
2601
+ <td><div class="actions">${revokeBtn}</div></td>
2602
+ </tr>`;
2603
+ }).join('');
2604
+ el.innerHTML = `<table><thead><tr><th>name</th><th>scopes</th><th>status</th><th>last used</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
2605
+}
2606
+
2607
+async function createAPIKey(e) {
2608
+ e.preventDefault();
2609
+ const name = document.getElementById('new-apikey-name').value.trim();
2610
+ const expires = document.getElementById('new-apikey-expires').value.trim();
2611
+ const scopes = [...document.querySelectorAll('.apikey-scope:checked')].map(cb => cb.value);
2612
+ const resultEl = document.getElementById('add-apikey-result');
2613
+ if (!name) { resultEl.innerHTML = '<span style="color:#f85149">name is required</span>'; return; }
2614
+ if (!scopes.length) { resultEl.innerHTML = '<span style="color:#f85149">select at least one scope</span>'; return; }
2615
+ try {
2616
+ const body = { name, scopes };
2617
+ if (expires) body.expires_in = expires;
2618
+ const result = await api('POST', '/v1/api-keys', body);
2619
+ resultEl.innerHTML = `<div style="background:#0d1117;border:1px solid #3fb95044;border-radius:6px;padding:12px;margin-top:8px">
2620
+ <div style="color:#3fb950;font-weight:600;margin-bottom:6px">Key created: ${esc(result.name)}</div>
2621
+ <div style="margin-bottom:4px;font-size:12px;color:#8b949e">Copy this token now — it will not be shown again:</div>
2622
+ <code style="display:block;padding:8px;background:#161b22;border-radius:4px;word-break:break-all;user-select:all">${esc(result.token)}</code>
2623
+ </div>`;
2624
+ document.getElementById('new-apikey-name').value = '';
2625
+ document.getElementById('new-apikey-expires').value = '';
2626
+ document.querySelectorAll('.apikey-scope:checked').forEach(cb => cb.checked = false);
2627
+ loadAPIKeys();
2628
+ } catch(e) {
2629
+ resultEl.innerHTML = `<span style="color:#f85149">${esc(e.message)}</span>`;
2630
+ }
2631
+}
2632
+
2633
+async function revokeAPIKey(id) {
2634
+ if (!confirm('Revoke this API key? This cannot be undone.')) return;
2635
+ try {
2636
+ await api('DELETE', `/v1/api-keys/${encodeURIComponent(id)}`);
2637
+ loadAPIKeys();
2638
+ } catch(e) { alert('Failed: ' + e.message); }
2639
+}
25472640
25482641
// --- AI / LLM tab ---
25492642
async function loadAI() {
25502643
await Promise.all([loadAIBackends(), loadAIKnown()]);
25512644
}
@@ -2915,10 +3008,11 @@
29153008
renderBehaviors(s.policies.behaviors || []);
29163009
renderAgentPolicy(s.policies.agent_policy || {});
29173010
renderBridgePolicy(s.policies.bridge || {});
29183011
renderLoggingPolicy(s.policies.logging || {});
29193012
loadAdmins();
3013
+ loadAPIKeys();
29203014
loadConfigCards();
29213015
} catch(e) {
29223016
document.getElementById('tls-badge').textContent = 'error';
29233017
}
29243018
}
29253019
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -580,10 +580,40 @@
580 <button type="submit" class="primary sm" style="margin-bottom:1px">add admin</button>
581 </form>
582 <div id="add-admin-result" style="margin-top:10px"></div>
583 </div>
584 </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
585
586 <!-- tls -->
587 <div class="card" id="card-tls">
588 <div class="card-header" onclick="toggleCard('card-tls',event)"><h2>TLS / SSL</h2><span class="card-desc">certificate status</span><span class="collapse-icon">▾</span><div class="spacer"></div><span id="tls-badge" class="badge">loading…</span></div>
589 <div class="card-body">
@@ -2542,10 +2572,73 @@
2542 try {
2543 await api('PUT', `/v1/admins/${encodeURIComponent(username)}/password`, { password: pw });
2544 alert('Password updated.');
2545 } catch(e) { alert('Failed: ' + e.message); }
2546 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2547
2548 // --- AI / LLM tab ---
2549 async function loadAI() {
2550 await Promise.all([loadAIBackends(), loadAIKnown()]);
2551 }
@@ -2915,10 +3008,11 @@
2915 renderBehaviors(s.policies.behaviors || []);
2916 renderAgentPolicy(s.policies.agent_policy || {});
2917 renderBridgePolicy(s.policies.bridge || {});
2918 renderLoggingPolicy(s.policies.logging || {});
2919 loadAdmins();
 
2920 loadConfigCards();
2921 } catch(e) {
2922 document.getElementById('tls-badge').textContent = 'error';
2923 }
2924 }
2925
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -580,10 +580,40 @@
580 <button type="submit" class="primary sm" style="margin-bottom:1px">add admin</button>
581 </form>
582 <div id="add-admin-result" style="margin-top:10px"></div>
583 </div>
584 </div>
585
586 <!-- api keys -->
587 <div class="card" id="card-apikeys">
588 <div class="card-header" onclick="toggleCard('card-apikeys',event)"><h2>API keys</h2><span class="card-desc">per-consumer tokens with scoped permissions</span><span class="collapse-icon">▾</span></div>
589 <div id="apikeys-list-container"></div>
590 <div class="card-body" style="border-top:1px solid #21262d">
591 <p style="font-size:12px;color:#8b949e;margin-bottom:12px">Create an API key with a name and scopes. The token is shown only once.</p>
592 <form id="add-apikey-form" onsubmit="createAPIKey(event)" style="display:flex;flex-direction:column;gap:10px">
593 <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
594 <div style="flex:1;min-width:160px"><label>name</label><input type="text" id="new-apikey-name" placeholder="e.g. kohakku-controller" autocomplete="off"></div>
595 <div style="flex:1;min-width:160px"><label>expires in</label><input type="text" id="new-apikey-expires" placeholder="e.g. 720h (empty=never)" autocomplete="off"></div>
596 </div>
597 <div>
598 <label style="margin-bottom:6px;display:block">scopes</label>
599 <div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px">
600 <label><input type="checkbox" value="admin" class="apikey-scope"> admin</label>
601 <label><input type="checkbox" value="agents" class="apikey-scope"> agents</label>
602 <label><input type="checkbox" value="channels" class="apikey-scope"> channels</label>
603 <label><input type="checkbox" value="chat" class="apikey-scope"> chat</label>
604 <label><input type="checkbox" value="topology" class="apikey-scope"> topology</label>
605 <label><input type="checkbox" value="bots" class="apikey-scope"> bots</label>
606 <label><input type="checkbox" value="config" class="apikey-scope"> config</label>
607 <label><input type="checkbox" value="read" class="apikey-scope"> read</label>
608 </div>
609 </div>
610 <button type="submit" class="primary sm" style="align-self:flex-start">create key</button>
611 </form>
612 <div id="add-apikey-result" style="margin-top:10px"></div>
613 </div>
614 </div>
615
616 <!-- tls -->
617 <div class="card" id="card-tls">
618 <div class="card-header" onclick="toggleCard('card-tls',event)"><h2>TLS / SSL</h2><span class="card-desc">certificate status</span><span class="collapse-icon">▾</span><div class="spacer"></div><span id="tls-badge" class="badge">loading…</span></div>
619 <div class="card-body">
@@ -2542,10 +2572,73 @@
2572 try {
2573 await api('PUT', `/v1/admins/${encodeURIComponent(username)}/password`, { password: pw });
2574 alert('Password updated.');
2575 } catch(e) { alert('Failed: ' + e.message); }
2576 }
2577
2578 // --- API keys ---
2579 async function loadAPIKeys() {
2580 try {
2581 const keys = await api('GET', '/v1/api-keys');
2582 renderAPIKeys(keys || []);
2583 } catch(e) {
2584 document.getElementById('apikeys-list-container').innerHTML = '';
2585 }
2586 }
2587
2588 function renderAPIKeys(keys) {
2589 const el = document.getElementById('apikeys-list-container');
2590 if (!keys.length) { el.innerHTML = ''; return; }
2591 const rows = keys.map(k => {
2592 const status = k.active ? '<span style="color:#3fb950">active</span>' : '<span style="color:#f85149">revoked</span>';
2593 const scopes = (k.scopes || []).map(s => `<code style="font-size:11px;background:#21262d;padding:1px 5px;border-radius:3px">${esc(s)}</code>`).join(' ');
2594 const lastUsed = k.last_used ? fmtTime(k.last_used) : '—';
2595 const revokeBtn = k.active ? `<button class="sm danger" onclick="revokeAPIKey('${esc(k.id)}')">revoke</button>` : '';
2596 return `<tr>
2597 <td><strong>${esc(k.name)}</strong><br><span style="color:#8b949e;font-size:11px">${esc(k.id)}</span></td>
2598 <td>${scopes}</td>
2599 <td style="font-size:12px">${status}</td>
2600 <td style="color:#8b949e;font-size:12px">${lastUsed}</td>
2601 <td><div class="actions">${revokeBtn}</div></td>
2602 </tr>`;
2603 }).join('');
2604 el.innerHTML = `<table><thead><tr><th>name</th><th>scopes</th><th>status</th><th>last used</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
2605 }
2606
2607 async function createAPIKey(e) {
2608 e.preventDefault();
2609 const name = document.getElementById('new-apikey-name').value.trim();
2610 const expires = document.getElementById('new-apikey-expires').value.trim();
2611 const scopes = [...document.querySelectorAll('.apikey-scope:checked')].map(cb => cb.value);
2612 const resultEl = document.getElementById('add-apikey-result');
2613 if (!name) { resultEl.innerHTML = '<span style="color:#f85149">name is required</span>'; return; }
2614 if (!scopes.length) { resultEl.innerHTML = '<span style="color:#f85149">select at least one scope</span>'; return; }
2615 try {
2616 const body = { name, scopes };
2617 if (expires) body.expires_in = expires;
2618 const result = await api('POST', '/v1/api-keys', body);
2619 resultEl.innerHTML = `<div style="background:#0d1117;border:1px solid #3fb95044;border-radius:6px;padding:12px;margin-top:8px">
2620 <div style="color:#3fb950;font-weight:600;margin-bottom:6px">Key created: ${esc(result.name)}</div>
2621 <div style="margin-bottom:4px;font-size:12px;color:#8b949e">Copy this token now — it will not be shown again:</div>
2622 <code style="display:block;padding:8px;background:#161b22;border-radius:4px;word-break:break-all;user-select:all">${esc(result.token)}</code>
2623 </div>`;
2624 document.getElementById('new-apikey-name').value = '';
2625 document.getElementById('new-apikey-expires').value = '';
2626 document.querySelectorAll('.apikey-scope:checked').forEach(cb => cb.checked = false);
2627 loadAPIKeys();
2628 } catch(e) {
2629 resultEl.innerHTML = `<span style="color:#f85149">${esc(e.message)}</span>`;
2630 }
2631 }
2632
2633 async function revokeAPIKey(id) {
2634 if (!confirm('Revoke this API key? This cannot be undone.')) return;
2635 try {
2636 await api('DELETE', `/v1/api-keys/${encodeURIComponent(id)}`);
2637 loadAPIKeys();
2638 } catch(e) { alert('Failed: ' + e.message); }
2639 }
2640
2641 // --- AI / LLM tab ---
2642 async function loadAI() {
2643 await Promise.all([loadAIBackends(), loadAIKnown()]);
2644 }
@@ -2915,10 +3008,11 @@
3008 renderBehaviors(s.policies.behaviors || []);
3009 renderAgentPolicy(s.policies.agent_policy || {});
3010 renderBridgePolicy(s.policies.bridge || {});
3011 renderLoggingPolicy(s.policies.logging || {});
3012 loadAdmins();
3013 loadAPIKeys();
3014 loadConfigCards();
3015 } catch(e) {
3016 document.getElementById('tls-badge').textContent = 'error';
3017 }
3018 }
3019

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button