Files
GopherGate/static/js/pages/users.js
hobokenchicken 9b8483e797 feat(security): implement AES-256-GCM encryption for API keys and HMAC-signed session tokens
This commit introduces:
- AES-256-GCM encryption for LLM provider API keys in the database.
- HMAC-SHA256 signed session tokens with activity-based refresh logic.
- Standardized frontend XSS protection using a global escapeHtml utility.
- Hardened security headers and request body size limits.
- Improved database integrity with foreign key enforcement and atomic transactions.
- Integration tests for the full encrypted key storage and proxy usage lifecycle.
2026-03-06 14:17:56 -05:00

286 lines
13 KiB
JavaScript

// 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 active';
overlay.innerHTML = `
<div class="modal-content" style="max-width: 480px;">
<div class="modal-header">
<h3 class="modal-title">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-control">
<label for="new-username">Username</label>
<input type="text" id="new-username" placeholder="e.g. jsmith" required>
</div>
<div class="form-control">
<label for="new-display-name">Display Name (optional)</label>
<input type="text" id="new-display-name" placeholder="e.g. John Smith">
</div>
<div class="form-control">
<label for="new-password">Password</label>
<input type="password" id="new-password" placeholder="Minimum 4 characters" required>
</div>
<div class="form-control">
<label for="new-role">Role</label>
<select id="new-role">
<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 active';
overlay.innerHTML = `
<div class="modal-content" style="max-width: 480px;">
<div class="modal-header">
<h3 class="modal-title">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-control">
<label for="edit-display-name">Display Name</label>
<input type="text" id="edit-display-name" value="${escapeHtml(user.display_name || '')}">
</div>
<div class="form-control">
<label for="edit-role">Role</label>
<select id="edit-role">
<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 active';
overlay.innerHTML = `
<div class="modal-content" style="max-width: 420px;">
<div class="modal-header">
<h3 class="modal-title">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-control">
<label for="reset-password">New Password</label>
<input type="password" id="reset-password" placeholder="Minimum 4 characters">
</div>
<div class="form-control">
<label class="checkbox-label" style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
<input type="checkbox" id="reset-must-change" checked style="width:auto;">
<span>Require password change on next login</span>
</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) {
return window.api.escapeHtml(str);
}
})();