Add complete multi-user support with role-based access control: Backend: - Add users CRUD endpoints (GET/POST/PUT/DELETE /api/users) with admin-only guards - Add display_name column to users table with ALTER TABLE migration - Fix auth to use session-based user identity (not hardcoded 'admin') - Add POST /api/auth/logout to revoke server-side sessions - Add require_admin() and extract_session() helpers for clean RBAC - Guard all mutating endpoints (clients, providers, models, settings, backup) Frontend: - Add Users management page with create/edit/reset-password/delete modals - Add role gating: hide edit/delete buttons for viewers on clients, providers, models - Settings page hides auth tokens and admin actions for viewers - Logout now revokes server session before clearing localStorage - Sidebar shows real display_name and formatted role (Administrator/Viewer) - Fix sidebar header: single logo with onerror fallback, renamed to 'LLM Proxy' - Add badge and btn-action CSS classes for role pills and action buttons - Bump cache-bust to v=7
393 lines
16 KiB
JavaScript
393 lines
16 KiB
JavaScript
// Clients Page Module
|
|
|
|
class ClientsPage {
|
|
constructor() {
|
|
this.clients = [];
|
|
this.init();
|
|
}
|
|
|
|
async init() {
|
|
// Load data
|
|
await Promise.all([
|
|
this.loadClients(),
|
|
this.loadClientUsageChart()
|
|
]);
|
|
|
|
// Setup event listeners
|
|
this.setupEventListeners();
|
|
}
|
|
|
|
async loadClients() {
|
|
try {
|
|
const data = await window.api.get('/clients');
|
|
this.clients = data;
|
|
this.renderClientsTable();
|
|
} catch (error) {
|
|
console.error('Error loading clients:', error);
|
|
window.authManager.showToast('Failed to load clients', 'error');
|
|
}
|
|
}
|
|
|
|
renderClientsTable() {
|
|
const tableBody = document.querySelector('#clients-table tbody');
|
|
if (!tableBody) return;
|
|
|
|
if (this.clients.length === 0) {
|
|
tableBody.innerHTML = '<tr><td colspan="8" class="text-center">No clients configured</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tableBody.innerHTML = this.clients.map(client => {
|
|
const statusClass = client.status === 'active' ? 'success' : 'secondary';
|
|
const statusIcon = client.status === 'active' ? 'check-circle' : 'clock';
|
|
const created = luxon.DateTime.fromISO(client.created_at).toFormat('MMM dd, yyyy');
|
|
|
|
return `
|
|
<tr>
|
|
<td><span class="badge-client">${client.id}</span></td>
|
|
<td><strong>${client.name}</strong></td>
|
|
<td>
|
|
<code class="token-display">sk-••••${client.id.substring(client.id.length - 4)}</code>
|
|
</td>
|
|
<td>${created}</td>
|
|
<td>${client.last_used ? window.api.formatTimeAgo(client.last_used) : 'Never'}</td>
|
|
<td>${client.requests_count.toLocaleString()}</td>
|
|
<td>
|
|
<span class="status-badge ${statusClass}">
|
|
<i class="fas fa-${statusIcon}"></i>
|
|
${client.status}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
${window._userRole === 'admin' ? `
|
|
<div class="action-buttons">
|
|
<button class="btn-action" title="Edit" onclick="window.clientsPage.editClient('${client.id}')">
|
|
<i class="fas fa-edit"></i>
|
|
</button>
|
|
<button class="btn-action danger" title="Delete" onclick="window.clientsPage.deleteClient('${client.id}')">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
` : ''}
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
async loadClientUsageChart() {
|
|
try {
|
|
const cm = window.chartManager || await window.waitForChartManager();
|
|
if (!cm) { this.showEmptyChart('client-usage-chart', 'Chart system unavailable'); return; }
|
|
|
|
const data = await window.api.get('/usage/clients');
|
|
|
|
if (!data || data.length === 0) {
|
|
this.showEmptyChart('client-usage-chart', 'No client usage data yet');
|
|
return;
|
|
}
|
|
|
|
const chartData = {
|
|
labels: data.map(item => item.client_id),
|
|
datasets: [{
|
|
label: 'Requests',
|
|
data: data.map(item => item.requests),
|
|
color: '#6366f1'
|
|
}]
|
|
};
|
|
|
|
cm.createHorizontalBarChart('client-usage-chart', chartData);
|
|
|
|
} catch (error) {
|
|
console.error('Error loading client usage chart:', error);
|
|
this.showEmptyChart('client-usage-chart', 'Failed to load usage data');
|
|
}
|
|
}
|
|
|
|
showEmptyChart(canvasId, message) {
|
|
const canvas = document.getElementById(canvasId);
|
|
if (!canvas) return;
|
|
const container = canvas.closest('.chart-container');
|
|
if (container) {
|
|
canvas.style.display = 'none';
|
|
let msg = container.querySelector('.empty-chart-msg');
|
|
if (!msg) {
|
|
msg = document.createElement('div');
|
|
msg.className = 'empty-chart-msg';
|
|
msg.style.cssText = 'display:flex;align-items:center;justify-content:center;height:200px;color:var(--fg4);font-size:0.9rem;';
|
|
container.appendChild(msg);
|
|
}
|
|
msg.textContent = message;
|
|
}
|
|
}
|
|
|
|
setupEventListeners() {
|
|
const addBtn = document.getElementById('add-client');
|
|
if (addBtn) {
|
|
addBtn.onclick = () => this.showAddClientModal();
|
|
}
|
|
}
|
|
|
|
showAddClientModal() {
|
|
const modal = document.createElement('div');
|
|
modal.className = 'modal active';
|
|
modal.innerHTML = `
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3 class="modal-title">Create New API Client</h3>
|
|
<button class="modal-close" onclick="this.closest('.modal').remove()">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="form-control">
|
|
<label for="new-client-name">Display Name</label>
|
|
<input type="text" id="new-client-name" placeholder="e.g. My Coding Assistant" required>
|
|
</div>
|
|
<div class="form-control">
|
|
<label for="new-client-id">Custom ID (Optional)</label>
|
|
<input type="text" id="new-client-id" placeholder="e.g. personal-app">
|
|
<small>Leave empty to generate automatically</small>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-secondary" onclick="this.closest('.modal').remove()">Cancel</button>
|
|
<button class="btn btn-primary" id="confirm-create-client">Create Client</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(modal);
|
|
|
|
modal.querySelector('#confirm-create-client').onclick = async () => {
|
|
const name = modal.querySelector('#new-client-name').value;
|
|
const id = modal.querySelector('#new-client-id').value;
|
|
|
|
if (!name) {
|
|
window.authManager.showToast('Name is required', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
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;
|
|
|
|
try {
|
|
await window.api.delete(`/clients/${id}`);
|
|
window.authManager.showToast('Client deleted', 'success');
|
|
this.loadClients();
|
|
} catch (error) {
|
|
window.authManager.showToast(error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async editClient(id) {
|
|
try {
|
|
const client = await window.api.get(`/clients/${id}`);
|
|
this.showEditClientModal(client);
|
|
} catch (error) {
|
|
window.authManager.showToast(`Failed to load client: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
showEditClientModal(client) {
|
|
const modal = document.createElement('div');
|
|
modal.className = 'modal active';
|
|
modal.innerHTML = `
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3 class="modal-title">Edit Client: ${client.id}</h3>
|
|
<button class="modal-close" onclick="this.closest('.modal').remove()">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="form-control">
|
|
<label for="edit-client-name">Display Name</label>
|
|
<input type="text" id="edit-client-name" value="${client.name || ''}" placeholder="e.g. My Coding Assistant">
|
|
</div>
|
|
<div class="form-control">
|
|
<label for="edit-client-description">Description</label>
|
|
<textarea id="edit-client-description" rows="3" placeholder="Optional description">${client.description || ''}</textarea>
|
|
</div>
|
|
<div class="form-control">
|
|
<label for="edit-client-rate-limit">Rate Limit (requests/minute)</label>
|
|
<input type="number" id="edit-client-rate-limit" min="0" value="${client.rate_limit_per_minute || ''}" placeholder="Leave empty for unlimited">
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="toggle-label">
|
|
<input type="checkbox" id="edit-client-active" ${client.is_active ? 'checked' : ''}>
|
|
<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>
|
|
<button class="btn btn-primary" id="confirm-edit-client">Save Changes</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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();
|
|
const rateLimitVal = modal.querySelector('#edit-client-rate-limit').value;
|
|
const isActive = modal.querySelector('#edit-client-active').checked;
|
|
|
|
if (!name) {
|
|
window.authManager.showToast('Name is required', 'error');
|
|
return;
|
|
}
|
|
|
|
const payload = {
|
|
name,
|
|
description: description || null,
|
|
is_active: isActive,
|
|
rate_limit_per_minute: rateLimitVal ? parseInt(rateLimitVal, 10) : null,
|
|
};
|
|
|
|
try {
|
|
await window.api.put(`/clients/${client.id}`, payload);
|
|
window.authManager.showToast(`Client "${name}" updated`, 'success');
|
|
modal.remove();
|
|
this.loadClients();
|
|
} catch (error) {
|
|
window.authManager.showToast(error.message, 'error');
|
|
}
|
|
};
|
|
}
|
|
|
|
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 () => {
|
|
window.clientsPage = new ClientsPage();
|
|
};
|