feat(auth): add DB-based token authentication for dashboard-created clients
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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user