Files
GopherGate/static/js/pages/clients.js
hobokenchicken e07377adc0
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
feat: add multi-user RBAC with admin/viewer roles and user management
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
2026-03-02 15:58:33 -05:00

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();
};