feat(auth): add DB-based token authentication for dashboard-created clients
Some checks failed
CI / Check (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Formatting (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Release Build (push) Has been cancelled

Add client_tokens table with auto-generated sk-{hex} tokens so clients
created in the dashboard get working API keys. Auth flow: DB token lookup
first, then env token fallback, then permissive mode. Includes token
management CRUD endpoints and copy-once reveal modal in the frontend.
This commit is contained in:
2026-03-02 15:14:12 -05:00
parent 4e53b05126
commit 54c45cbfca
8 changed files with 373 additions and 24 deletions

View File

@@ -167,16 +167,61 @@ class ClientsPage {
}
try {
await window.api.post('/clients', { name, client_id: id || null });
window.authManager.showToast(`Client "${name}" created`, 'success');
const result = await window.api.post('/clients', { name, client_id: id || null });
modal.remove();
this.loadClients();
// Show the generated token (copy-once dialog)
if (result.token) {
this.showTokenRevealModal(name, result.token);
} else {
window.authManager.showToast(`Client "${name}" created`, 'success');
}
} catch (error) {
window.authManager.showToast(error.message, 'error');
}
};
}
showTokenRevealModal(clientName, token) {
const modal = document.createElement('div');
modal.className = 'modal active';
modal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Client Created: ${clientName}</h3>
</div>
<div class="modal-body">
<p style="margin-bottom: 0.75rem; color: var(--yellow);">
<i class="fas fa-exclamation-triangle"></i>
Copy this token now. It will not be shown again.
</p>
<div class="form-control">
<label>API Token</label>
<div style="display: flex; gap: 0.5rem;">
<input type="text" id="revealed-token" value="${token}" readonly
style="font-family: monospace; font-size: 0.85rem;">
<button class="btn btn-secondary" id="copy-token-btn" title="Copy">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" onclick="this.closest('.modal').remove()">Done</button>
</div>
</div>
`;
document.body.appendChild(modal);
modal.querySelector('#copy-token-btn').onclick = () => {
navigator.clipboard.writeText(token).then(() => {
window.authManager.showToast('Token copied to clipboard', 'success');
});
};
}
async deleteClient(id) {
if (!confirm(`Are you sure you want to delete client ${id}? This cannot be undone.`)) return;
@@ -228,6 +273,18 @@ class ClientsPage {
<span>Active</span>
</label>
</div>
<hr style="border-color: var(--bg3); margin: 1rem 0;">
<div class="form-control">
<label style="display: flex; justify-content: space-between; align-items: center;">
<span>API Tokens</span>
<button class="btn btn-secondary" id="generate-token-btn" style="padding: 0.25rem 0.5rem; font-size: 0.8rem;">
<i class="fas fa-plus"></i> Generate
</button>
</label>
<div id="tokens-list" style="margin-top: 0.5rem;">
<div style="color: var(--fg4); font-size: 0.85rem;">Loading tokens...</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="this.closest('.modal').remove()">Cancel</button>
@@ -238,6 +295,21 @@ class ClientsPage {
document.body.appendChild(modal);
// Load tokens
this.loadTokensList(client.id, modal);
modal.querySelector('#generate-token-btn').onclick = async () => {
try {
const result = await window.api.post(`/clients/${client.id}/tokens`, { name: null });
if (result.token) {
this.showTokenRevealModal(`${client.name} - New Token`, result.token);
}
this.loadTokensList(client.id, modal);
} catch (error) {
window.authManager.showToast(error.message, 'error');
}
};
modal.querySelector('#confirm-edit-client').onclick = async () => {
const name = modal.querySelector('#edit-client-name').value.trim();
const description = modal.querySelector('#edit-client-description').value.trim();
@@ -266,6 +338,51 @@ class ClientsPage {
}
};
}
async loadTokensList(clientId, modal) {
const container = modal.querySelector('#tokens-list');
if (!container) return;
try {
const tokens = await window.api.get(`/clients/${clientId}/tokens`);
if (!tokens || tokens.length === 0) {
container.innerHTML = '<div style="color: var(--fg4); font-size: 0.85rem;">No tokens. Generate one to enable API access.</div>';
return;
}
container.innerHTML = tokens.map(t => {
const lastUsed = t.last_used_at
? luxon.DateTime.fromISO(t.last_used_at).toRelative()
: 'Never';
return `
<div style="display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid var(--bg3);">
<code style="flex: 1; font-size: 0.8rem; color: var(--fg2);">${t.token_masked}</code>
<span style="font-size: 0.75rem; color: var(--fg4);" title="Last used">${lastUsed}</span>
<button class="btn-action danger" title="Revoke" style="padding: 0.2rem 0.4rem;"
onclick="window.clientsPage.revokeToken('${clientId}', ${t.id}, this)">
<i class="fas fa-trash" style="font-size: 0.75rem;"></i>
</button>
</div>
`;
}).join('');
} catch (error) {
container.innerHTML = '<div style="color: var(--red); font-size: 0.85rem;">Failed to load tokens</div>';
}
}
async revokeToken(clientId, tokenId, btn) {
if (!confirm('Revoke this token? Any services using it will lose access.')) return;
try {
await window.api.delete(`/clients/${clientId}/tokens/${tokenId}`);
window.authManager.showToast('Token revoked', 'success');
// Reload tokens list in the open modal
const modal = btn.closest('.modal');
if (modal) this.loadTokensList(clientId, modal);
} catch (error) {
window.authManager.showToast(error.message, 'error');
}
}
}
window.initClients = async () => {