Files
GopherGate/static/js/auth.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

266 lines
8.3 KiB
JavaScript

// Authentication Module for LLM Proxy Dashboard
class AuthManager {
constructor() {
this.isAuthenticated = false;
this.token = null;
this.user = null;
this.init();
}
init() {
// Check for existing session
const savedToken = localStorage.getItem('dashboard_token');
const savedUser = localStorage.getItem('dashboard_user');
if (savedToken && savedUser) {
this.token = savedToken;
this.user = JSON.parse(savedUser);
this.isAuthenticated = true;
this.showDashboard();
} else {
this.showLogin();
}
// Setup login form
this.setupLoginForm();
this.setupLogout();
}
setupLoginForm() {
const loginForm = document.getElementById('login-form');
if (!loginForm) return;
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
await this.login(username, password);
});
}
setupLogout() {
const logoutBtn = document.getElementById('logout-btn');
if (!logoutBtn) return;
logoutBtn.addEventListener('click', () => {
this.logout();
});
}
async login(username, password) {
const errorElement = document.getElementById('login-error');
const loginBtn = document.querySelector('.login-btn');
try {
loginBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Authenticating...';
loginBtn.disabled = true;
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const result = await response.json();
if (result.success) {
this.token = result.data.token;
this.user = result.data.user;
localStorage.setItem('dashboard_token', this.token);
localStorage.setItem('dashboard_user', JSON.stringify(this.user));
this.isAuthenticated = true;
this.showDashboard();
this.showToast('Successfully logged in!', 'success');
} else {
throw new Error(result.error || 'Invalid credentials');
}
} catch (error) {
errorElement.style.display = 'flex';
errorElement.querySelector('span').textContent = error.message;
loginBtn.innerHTML = '<i class="fas fa-sign-in-alt"></i> Sign In';
loginBtn.disabled = false;
}
}
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');
// Reset state
this.isAuthenticated = false;
this.token = null;
this.user = null;
// Show login screen
this.showLogin();
// Show logout message
this.showToast('Successfully logged out', 'info');
}
showLogin() {
const loginScreen = document.getElementById('login-screen');
const dashboard = document.getElementById('dashboard');
if (loginScreen) loginScreen.style.display = 'flex';
if (dashboard) dashboard.style.display = 'none';
// Clear form
const loginForm = document.getElementById('login-form');
if (loginForm) loginForm.reset();
// Hide error
const errorElement = document.getElementById('login-error');
if (errorElement) errorElement.style.display = 'none';
// Reset button
const loginBtn = document.querySelector('.login-btn');
if (loginBtn) {
loginBtn.innerHTML = '<i class="fas fa-sign-in-alt"></i> Sign In';
loginBtn.disabled = false;
}
}
showDashboard() {
const loginScreen = document.getElementById('login-screen');
const dashboard = document.getElementById('dashboard');
if (loginScreen) loginScreen.style.display = 'none';
if (dashboard) dashboard.style.display = 'flex';
// Update user info in sidebar
this.updateUserInfo();
// Initialize dashboard components
if (typeof window.initDashboard === 'function') {
window.initDashboard();
}
}
updateUserInfo() {
const userNameElement = document.querySelector('.user-name');
const userRoleElement = document.querySelector('.user-role');
if (userNameElement && this.user) {
userNameElement.textContent = this.user.name || this.user.username || 'User';
}
if (userRoleElement && this.user) {
const roleLabels = { admin: 'Administrator', viewer: 'Viewer' };
userRoleElement.textContent = roleLabels[this.user.role] || this.user.role || 'User';
}
}
getAuthHeaders() {
if (!this.token) return {};
return {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json'
};
}
async fetchWithAuth(url, options = {}) {
const headers = this.getAuthHeaders();
const response = await fetch(url, {
...options,
headers: {
...headers,
...options.headers
}
});
if (response.status === 401) {
// Token expired or invalid
this.logout();
throw new Error('Authentication required');
}
return response;
}
showToast(message, type = 'info') {
// Create toast container if it doesn't exist
let container = document.querySelector('.toast-container');
if (!container) {
container = document.createElement('div');
container.className = 'toast-container';
document.body.appendChild(container);
}
// Create toast
const toast = document.createElement('div');
toast.className = `toast ${type}`;
// Set icon based on type
let icon = 'info-circle';
switch (type) {
case 'success':
icon = 'check-circle';
break;
case 'error':
icon = 'exclamation-circle';
break;
case 'warning':
icon = 'exclamation-triangle';
break;
}
toast.innerHTML = `
<i class="fas fa-${icon} toast-icon"></i>
<div class="toast-content">
<div class="toast-title">${type.charAt(0).toUpperCase() + type.slice(1)}</div>
<div class="toast-message">${message}</div>
</div>
<button class="toast-close">
<i class="fas fa-times"></i>
</button>
`;
// Add close functionality
const closeBtn = toast.querySelector('.toast-close');
closeBtn.addEventListener('click', () => {
toast.remove();
});
// Add to container
container.appendChild(toast);
// Auto-remove after 5 seconds
setTimeout(() => {
if (toast.parentNode) {
toast.remove();
}
}, 5000);
}
}
// Initialize auth manager when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
window.authManager = new AuthManager();
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = AuthManager;
}