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
Commit
2428c83591b10079ab34fff3cabc8ae636e4d1ea3a35320e6f2f9a91044fe10c
Parent
3d0d46ec6554b9c…
1 file changed
+94
| --- internal/api/ui/index.html | ||
| +++ internal/api/ui/index.html | ||
| @@ -580,10 +580,40 @@ | ||
| 580 | 580 | <button type="submit" class="primary sm" style="margin-bottom:1px">add admin</button> |
| 581 | 581 | </form> |
| 582 | 582 | <div id="add-admin-result" style="margin-top:10px"></div> |
| 583 | 583 | </div> |
| 584 | 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> | |
| 585 | 615 | |
| 586 | 616 | <!-- tls --> |
| 587 | 617 | <div class="card" id="card-tls"> |
| 588 | 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> |
| 589 | 619 | <div class="card-body"> |
| @@ -2542,10 +2572,73 @@ | ||
| 2542 | 2572 | try { |
| 2543 | 2573 | await api('PUT', `/v1/admins/${encodeURIComponent(username)}/password`, { password: pw }); |
| 2544 | 2574 | alert('Password updated.'); |
| 2545 | 2575 | } catch(e) { alert('Failed: ' + e.message); } |
| 2546 | 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 | +} | |
| 2547 | 2640 | |
| 2548 | 2641 | // --- AI / LLM tab --- |
| 2549 | 2642 | async function loadAI() { |
| 2550 | 2643 | await Promise.all([loadAIBackends(), loadAIKnown()]); |
| 2551 | 2644 | } |
| @@ -2915,10 +3008,11 @@ | ||
| 2915 | 3008 | renderBehaviors(s.policies.behaviors || []); |
| 2916 | 3009 | renderAgentPolicy(s.policies.agent_policy || {}); |
| 2917 | 3010 | renderBridgePolicy(s.policies.bridge || {}); |
| 2918 | 3011 | renderLoggingPolicy(s.policies.logging || {}); |
| 2919 | 3012 | loadAdmins(); |
| 3013 | + loadAPIKeys(); | |
| 2920 | 3014 | loadConfigCards(); |
| 2921 | 3015 | } catch(e) { |
| 2922 | 3016 | document.getElementById('tls-badge').textContent = 'error'; |
| 2923 | 3017 | } |
| 2924 | 3018 | } |
| 2925 | 3019 |
| --- 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 |