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
This commit is contained in:
@@ -87,7 +87,20 @@ class AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
logout() {
|
||||
async logout() {
|
||||
// Revoke server-side session first
|
||||
try {
|
||||
if (this.token) {
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${this.token}` }
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Best-effort — still clear local state even if server call fails
|
||||
console.warn('Server logout failed:', e);
|
||||
}
|
||||
|
||||
// Clear localStorage
|
||||
localStorage.removeItem('dashboard_token');
|
||||
localStorage.removeItem('dashboard_user');
|
||||
@@ -104,14 +117,6 @@ class AuthManager {
|
||||
this.showToast('Successfully logged out', 'info');
|
||||
}
|
||||
|
||||
generateToken() {
|
||||
// Generate a simple token for demo purposes
|
||||
// In production, this would come from the server
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2);
|
||||
return btoa(`${timestamp}:${random}`).replace(/=/g, '');
|
||||
}
|
||||
|
||||
showLogin() {
|
||||
const loginScreen = document.getElementById('login-screen');
|
||||
const dashboard = document.getElementById('dashboard');
|
||||
@@ -154,13 +159,14 @@ class AuthManager {
|
||||
updateUserInfo() {
|
||||
const userNameElement = document.querySelector('.user-name');
|
||||
const userRoleElement = document.querySelector('.user-role');
|
||||
|
||||
|
||||
if (userNameElement && this.user) {
|
||||
userNameElement.textContent = this.user.name;
|
||||
userNameElement.textContent = this.user.name || this.user.username || 'User';
|
||||
}
|
||||
|
||||
|
||||
if (userRoleElement && this.user) {
|
||||
userRoleElement.textContent = this.user.role;
|
||||
const roleLabels = { admin: 'Administrator', viewer: 'Viewer' };
|
||||
userRoleElement.textContent = roleLabels[this.user.role] || this.user.role || 'User';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ class Dashboard {
|
||||
this.setupSidebar();
|
||||
this.setupRefresh();
|
||||
this.updateTime();
|
||||
this.applyRoleGating();
|
||||
|
||||
// Load initial page from hash or default to overview
|
||||
const initialPage = window.location.hash.substring(1) || 'overview';
|
||||
@@ -23,6 +24,20 @@ class Dashboard {
|
||||
setInterval(() => this.updateTime(), 1000);
|
||||
}
|
||||
|
||||
/** Hide admin-only menu items and mutation controls for non-admin users */
|
||||
applyRoleGating() {
|
||||
const user = window.authManager && window.authManager.user;
|
||||
const isAdmin = user && user.role === 'admin';
|
||||
|
||||
// Toggle visibility of admin-only sidebar items
|
||||
document.querySelectorAll('.menu-item.admin-only').forEach(item => {
|
||||
item.style.display = isAdmin ? '' : 'none';
|
||||
});
|
||||
|
||||
// Store role for use by page scripts
|
||||
window._userRole = isAdmin ? 'admin' : 'viewer';
|
||||
}
|
||||
|
||||
setupNavigation() {
|
||||
const menuItems = document.querySelectorAll('.menu-item');
|
||||
menuItems.forEach(item => {
|
||||
@@ -103,7 +118,8 @@ class Dashboard {
|
||||
'monitoring': 'Monitoring',
|
||||
'settings': 'Settings',
|
||||
'logs': 'Logs',
|
||||
'models': 'Models'
|
||||
'models': 'Models',
|
||||
'users': 'User Management'
|
||||
};
|
||||
if (titleElement) titleElement.textContent = titles[page] || 'Dashboard';
|
||||
|
||||
@@ -145,6 +161,7 @@ class Dashboard {
|
||||
case 'settings': return '<div class="loading-placeholder">Loading settings...</div>';
|
||||
case 'analytics': return this.getAnalyticsTemplate();
|
||||
case 'costs': return this.getCostsTemplate();
|
||||
case 'users': return this.getUsersTemplate();
|
||||
default: return '<div class="empty-state"><h3>Page not found</h3></div>';
|
||||
}
|
||||
}
|
||||
@@ -219,6 +236,7 @@ class Dashboard {
|
||||
}
|
||||
|
||||
getClientsTemplate() {
|
||||
const isAdmin = window._userRole === 'admin';
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
@@ -226,9 +244,9 @@ class Dashboard {
|
||||
<h3 class="card-title">API Clients</h3>
|
||||
<p class="card-subtitle">Manage tokens and access</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="add-client">
|
||||
${isAdmin ? `<button class="btn btn-primary" id="add-client">
|
||||
<i class="fas fa-plus"></i> Create Client
|
||||
</button>
|
||||
</button>` : ''}
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="table" id="clients-table">
|
||||
@@ -474,6 +492,30 @@ class Dashboard {
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
getUsersTemplate() {
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h3 class="card-title">Dashboard Users</h3>
|
||||
<p class="card-subtitle">Manage accounts and roles</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="add-user">
|
||||
<i class="fas fa-user-plus"></i> Create User
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="table" id="users-table">
|
||||
<thead>
|
||||
<tr><th>Username</th><th>Display Name</th><th>Role</th><th>Created</th><th>Status</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
@@ -59,6 +59,7 @@ class ClientsPage {
|
||||
</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>
|
||||
@@ -67,6 +68,7 @@ class ClientsPage {
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
@@ -55,11 +55,13 @@ class ModelsPage {
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
${window._userRole === 'admin' ? `
|
||||
<div class="action-buttons">
|
||||
<button class="btn-action" title="Edit Access/Pricing" onclick="window.modelsPage.configureModel('${model.id}')">
|
||||
<i class="fas fa-cog"></i>
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
@@ -84,9 +84,11 @@ class ProvidersPage {
|
||||
<button class="btn btn-secondary btn-sm" onclick="window.providersPage.testProvider('${provider.id}')">
|
||||
<i class="fas fa-vial"></i> Test
|
||||
</button>
|
||||
${window._userRole === 'admin' ? `
|
||||
<button class="btn btn-primary btn-sm" onclick="window.providersPage.configureProvider('${provider.id}')">
|
||||
<i class="fas fa-cog"></i> Config
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -38,6 +38,7 @@ class SettingsPage {
|
||||
<label>Application Version</label>
|
||||
<input type="text" value="${this.settings.server.version}" disabled>
|
||||
</div>
|
||||
${window._userRole === 'admin' ? `
|
||||
<div class="form-control">
|
||||
<label>Authentication Tokens</label>
|
||||
<div class="tokens-list" style="display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem;">
|
||||
@@ -52,6 +53,7 @@ class SettingsPage {
|
||||
</div>
|
||||
<small>Auth tokens are configured via environment variables or <code>config.toml</code>.</small>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,12 +97,16 @@ class SettingsPage {
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
${window._userRole === 'admin' ? `
|
||||
<button class="btn btn-secondary" onclick="window.settingsPage.refreshRegistry()">
|
||||
<i class="fas fa-sync"></i> Force Registry Refresh
|
||||
</button>
|
||||
<button class="btn btn-danger" onclick="window.settingsPage.triggerBackup()">
|
||||
<i class="fas fa-file-archive"></i> Export Database Backup
|
||||
</button>
|
||||
` : `
|
||||
<span style="color: var(--fg4); font-size: 0.85rem;">Admin access required for registry refresh and backups.</span>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
290
static/js/pages/users.js
Normal file
290
static/js/pages/users.js
Normal file
@@ -0,0 +1,290 @@
|
||||
// User Management Page
|
||||
|
||||
(function () {
|
||||
let usersData = [];
|
||||
|
||||
window.initUsers = async function () {
|
||||
await loadUsers();
|
||||
setupEventListeners();
|
||||
};
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
usersData = await window.api.get('/users');
|
||||
renderUsersTable();
|
||||
} catch (err) {
|
||||
console.error('Failed to load users:', err);
|
||||
const tbody = document.querySelector('#users-table tbody');
|
||||
if (tbody) {
|
||||
tbody.innerHTML = `<tr><td colspan="6" class="text-center" style="padding:2rem;color:var(--red);">Failed to load users: ${err.message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsersTable() {
|
||||
const tbody = document.querySelector('#users-table tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
if (!usersData || usersData.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="text-center" style="padding:2rem;color:var(--fg4);">No users found</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = usersData.map(user => {
|
||||
const roleBadge = user.role === 'admin'
|
||||
? '<span class="badge badge-success">Admin</span>'
|
||||
: '<span class="badge badge-info">Viewer</span>';
|
||||
|
||||
const statusBadge = user.must_change_password
|
||||
? '<span class="badge badge-warning">Must Change Password</span>'
|
||||
: '<span class="badge badge-success">Active</span>';
|
||||
|
||||
const created = user.created_at
|
||||
? luxon.DateTime.fromISO(user.created_at).toRelative()
|
||||
: 'Unknown';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(user.username)}</strong></td>
|
||||
<td>${escapeHtml(user.display_name || user.username)}</td>
|
||||
<td>${roleBadge}</td>
|
||||
<td>${created}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-sm btn-secondary" onclick="window._editUser(${user.id})" title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="window._resetUserPassword(${user.id}, '${escapeHtml(user.username)}')" title="Reset Password">
|
||||
<i class="fas fa-key"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="window._deleteUser(${user.id}, '${escapeHtml(user.username)}')" title="Delete">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
const addBtn = document.getElementById('add-user');
|
||||
if (addBtn) {
|
||||
addBtn.onclick = () => showCreateModal();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create User Modal ──────────────────────────────────────────
|
||||
|
||||
function showCreateModal() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.id = 'user-modal-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal" style="max-width: 480px;">
|
||||
<div class="modal-header">
|
||||
<h3>Create New User</h3>
|
||||
<button class="modal-close" id="user-modal-close"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" id="new-username" class="form-control" placeholder="e.g. jsmith" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Display Name (optional)</label>
|
||||
<input type="text" id="new-display-name" class="form-control" placeholder="e.g. John Smith">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password</label>
|
||||
<input type="password" id="new-password" class="form-control" placeholder="Minimum 4 characters" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Role</label>
|
||||
<select id="new-role" class="form-control">
|
||||
<option value="viewer">Viewer (read-only)</option>
|
||||
<option value="admin">Admin (full access)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="user-modal-cancel">Cancel</button>
|
||||
<button class="btn btn-primary" id="user-modal-save">
|
||||
<i class="fas fa-user-plus"></i> Create User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
document.getElementById('user-modal-close').onclick = () => overlay.remove();
|
||||
document.getElementById('user-modal-cancel').onclick = () => overlay.remove();
|
||||
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
|
||||
|
||||
document.getElementById('user-modal-save').onclick = async () => {
|
||||
const username = document.getElementById('new-username').value.trim();
|
||||
const display_name = document.getElementById('new-display-name').value.trim() || null;
|
||||
const password = document.getElementById('new-password').value;
|
||||
const role = document.getElementById('new-role').value;
|
||||
|
||||
if (!username) {
|
||||
window.authManager.showToast('Username is required', 'error');
|
||||
return;
|
||||
}
|
||||
if (password.length < 4) {
|
||||
window.authManager.showToast('Password must be at least 4 characters', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.api.post('/users', { username, password, display_name, role });
|
||||
overlay.remove();
|
||||
window.authManager.showToast(`User '${username}' created`, 'success');
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
window.authManager.showToast(err.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('new-username').focus();
|
||||
}
|
||||
|
||||
// ── Edit User Modal ────────────────────────────────────────────
|
||||
|
||||
window._editUser = function (id) {
|
||||
const user = usersData.find(u => u.id === id);
|
||||
if (!user) return;
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.id = 'user-edit-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal" style="max-width: 480px;">
|
||||
<div class="modal-header">
|
||||
<h3>Edit User: ${escapeHtml(user.username)}</h3>
|
||||
<button class="modal-close" id="edit-modal-close"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Display Name</label>
|
||||
<input type="text" id="edit-display-name" class="form-control" value="${escapeHtml(user.display_name || '')}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Role</label>
|
||||
<select id="edit-role" class="form-control">
|
||||
<option value="viewer" ${user.role === 'viewer' ? 'selected' : ''}>Viewer (read-only)</option>
|
||||
<option value="admin" ${user.role === 'admin' ? 'selected' : ''}>Admin (full access)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="edit-modal-cancel">Cancel</button>
|
||||
<button class="btn btn-primary" id="edit-modal-save">
|
||||
<i class="fas fa-save"></i> Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
document.getElementById('edit-modal-close').onclick = () => overlay.remove();
|
||||
document.getElementById('edit-modal-cancel').onclick = () => overlay.remove();
|
||||
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
|
||||
|
||||
document.getElementById('edit-modal-save').onclick = async () => {
|
||||
const display_name = document.getElementById('edit-display-name').value.trim() || null;
|
||||
const role = document.getElementById('edit-role').value;
|
||||
|
||||
try {
|
||||
await window.api.put(`/users/${id}`, { display_name, role });
|
||||
overlay.remove();
|
||||
window.authManager.showToast('User updated', 'success');
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
window.authManager.showToast(err.message, 'error');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// ── Reset Password Modal ───────────────────────────────────────
|
||||
|
||||
window._resetUserPassword = function (id, username) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.id = 'pw-reset-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal" style="max-width: 420px;">
|
||||
<div class="modal-header">
|
||||
<h3>Reset Password: ${escapeHtml(username)}</h3>
|
||||
<button class="modal-close" id="pw-modal-close"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>New Password</label>
|
||||
<input type="password" id="reset-password" class="form-control" placeholder="Minimum 4 characters">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
|
||||
<input type="checkbox" id="reset-must-change" checked>
|
||||
Require password change on next login
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="pw-modal-cancel">Cancel</button>
|
||||
<button class="btn btn-primary" id="pw-modal-save">
|
||||
<i class="fas fa-key"></i> Reset Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
document.getElementById('pw-modal-close').onclick = () => overlay.remove();
|
||||
document.getElementById('pw-modal-cancel').onclick = () => overlay.remove();
|
||||
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
|
||||
|
||||
document.getElementById('pw-modal-save').onclick = async () => {
|
||||
const password = document.getElementById('reset-password').value;
|
||||
const must_change_password = document.getElementById('reset-must-change').checked;
|
||||
|
||||
if (password.length < 4) {
|
||||
window.authManager.showToast('Password must be at least 4 characters', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.api.put(`/users/${id}`, { password, must_change_password });
|
||||
overlay.remove();
|
||||
window.authManager.showToast(`Password reset for '${username}'`, 'success');
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
window.authManager.showToast(err.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('reset-password').focus();
|
||||
};
|
||||
|
||||
// ── Delete User ────────────────────────────────────────────────
|
||||
|
||||
window._deleteUser = function (id, username) {
|
||||
if (!confirm(`Delete user '${username}'? This action cannot be undone.`)) return;
|
||||
|
||||
window.api.delete(`/users/${id}`).then(() => {
|
||||
window.authManager.showToast(`User '${username}' deleted`, 'success');
|
||||
loadUsers();
|
||||
}).catch(err => {
|
||||
window.authManager.showToast(err.message, 'error');
|
||||
});
|
||||
};
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user