Overhaul nav, fix DB transaction bugs, add admin UI

- Replace nav user area with display name (non-clickable), gear settings
  modal, admin button (admins only), and logout button
- Settings modal handles display name, timezone, and password change
- Add admin.html + admin.js: user table with reset PW, disable/enable,
  login-as (impersonation), and delete; return-to-admin flow in nav
- Add is_admin to UserResponse so frontend can gate the Admin button
- Fix all db.begin() bugs in admin.py and users.py (transaction already
  active from get_current_user query; use commit() directly instead)
- Add email-validator and pin bcrypt==4.0.1 for passlib compatibility
- Add escHtml() to api.js and admin API namespace
- Group nav brand + links in nav-left for left-aligned layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 21:09:38 -07:00
parent 48a15c54f6
commit f1b82baebd
15 changed files with 570 additions and 68 deletions

164
frontend/js/admin.js Normal file
View File

@@ -0,0 +1,164 @@
// admin.js — admin user management page
let resetTargetId = null;
let deleteTargetId = null;
let currentAdminId = null;
document.addEventListener('DOMContentLoaded', async () => {
if (!Auth.requireAuth()) return;
await Auth.renderNav();
const user = Auth.getUser();
if (!user || !user.is_admin) {
window.location.href = '/dashboard.html';
return;
}
currentAdminId = user.id;
await loadUsers();
});
async function loadUsers() {
try {
const users = await API.admin.listUsers();
renderUsers(users);
} catch (err) {
showMsg(err.message, 'error');
}
}
function renderUsers(users) {
const tbody = document.getElementById('users-body');
if (!users.length) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="6">No users found.</td></tr>';
return;
}
tbody.innerHTML = users.map(u => {
const isSelf = u.id === currentAdminId;
const name = escHtml(u.display_name || u.email);
const roleLabel = u.is_admin
? '<span class="badge badge-admin">Admin</span>'
: '<span class="badge badge-user">User</span>';
const statusLabel = u.is_disabled
? '<span class="badge badge-disabled">Disabled</span>'
: '<span class="badge badge-active">Active</span>';
const joined = new Date(u.created_at).toLocaleDateString();
const toggleBtn = u.is_disabled
? `<button class="btn btn-sm btn-primary" onclick="toggleUser(${u.id}, false)">Enable</button>`
: `<button class="btn btn-sm btn-ghost" onclick="toggleUser(${u.id}, true)"${isSelf ? ' disabled title="Cannot disable yourself"' : ''}>Disable</button>`;
const impersonateBtn = !isSelf
? `<button class="btn btn-sm btn-ghost" onclick="impersonateUser(${u.id})">Login As</button>`
: '';
const deleteBtn = !isSelf
? `<button class="btn btn-sm btn-danger" onclick="showDeleteModal(${u.id}, '${name}')">Delete</button>`
: '';
return `<tr>
<td><strong>${name}</strong></td>
<td>${escHtml(u.email)}</td>
<td>${roleLabel}</td>
<td>${statusLabel}</td>
<td>${joined}</td>
<td style="display:flex;gap:0.35rem;flex-wrap:wrap">
<button class="btn btn-sm btn-ghost" onclick="showResetModal(${u.id}, '${name}')">Reset PW</button>
${toggleBtn}
${impersonateBtn}
${deleteBtn}
</td>
</tr>`;
}).join('');
}
function showResetModal(id, name) {
resetTargetId = id;
document.getElementById('reset-username').textContent = name;
document.getElementById('reset-password').value = '';
document.getElementById('reset-msg').innerHTML = '';
document.getElementById('reset-modal').style.display = 'flex';
document.getElementById('reset-password').focus();
}
function hideResetModal() {
document.getElementById('reset-modal').style.display = 'none';
resetTargetId = null;
}
async function submitReset() {
const password = document.getElementById('reset-password').value;
const msgEl = document.getElementById('reset-msg');
if (password.length < 8) {
msgEl.innerHTML = '<div class="alert alert-error">Password must be at least 8 characters.</div>';
return;
}
try {
await API.admin.resetPassword(resetTargetId, { new_password: password });
msgEl.innerHTML = '<div class="alert alert-success">Password reset successfully.</div>';
setTimeout(hideResetModal, 1200);
} catch (err) {
msgEl.innerHTML = `<div class="alert alert-error">${escHtml(err.message)}</div>`;
}
}
async function toggleUser(id, disable) {
try {
if (disable) await API.admin.disable(id);
else await API.admin.enable(id);
await loadUsers();
} catch (err) {
showMsg(err.message, 'error');
}
}
async function impersonateUser(id) {
try {
const data = await API.admin.impersonate(id);
Auth.saveToken(data.access_token);
const user = await API.users.me();
Auth.saveUser(user);
window.location.href = '/dashboard.html';
} catch (err) {
showMsg(err.message, 'error');
}
}
function showDeleteModal(id, name) {
deleteTargetId = id;
document.getElementById('delete-username').textContent = name;
document.getElementById('delete-modal').style.display = 'flex';
}
function hideDeleteModal() {
document.getElementById('delete-modal').style.display = 'none';
deleteTargetId = null;
}
async function submitDelete() {
try {
await API.admin.delete(deleteTargetId);
hideDeleteModal();
showMsg('User deleted.', 'success');
await loadUsers();
} catch (err) {
hideDeleteModal();
showMsg(err.message, 'error');
}
}
function showMsg(text, type) {
const el = document.getElementById('msg');
if (!el) return;
const cls = type === 'success' ? 'alert-success' : 'alert-error';
el.innerHTML = `<div class="alert ${cls}" style="margin-bottom:1rem">${escHtml(text)}</div>`;
if (type === 'success') setTimeout(() => { el.innerHTML = ''; }, 3000);
}
document.addEventListener('click', (e) => {
if (e.target.id === 'reset-modal') hideResetModal();
if (e.target.id === 'delete-modal') hideDeleteModal();
});