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

View File

@@ -1,5 +1,9 @@
/* Central API client — all fetch calls go through here */
function escHtml(str) {
return String(str ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
const API = (() => {
const base = '/api';
@@ -55,5 +59,15 @@ const API = (() => {
public: {
stats: () => request('GET', '/public/stats'),
},
admin: {
listUsers: () => request('GET', '/admin/users'),
createUser: (body) => request('POST', '/admin/users', body),
resetPassword: (id, body) => request('POST', `/admin/users/${id}/reset-password`, body),
disable: (id) => request('POST', `/admin/users/${id}/disable`, {}),
enable: (id) => request('POST', `/admin/users/${id}/enable`, {}),
delete: (id) => request('DELETE', `/admin/users/${id}`),
impersonate: (id) => request('POST', `/admin/users/${id}/impersonate`, {}),
unimpersonate: () => request('POST', '/admin/unimpersonate', {}),
},
};
})();

View File

@@ -5,7 +5,6 @@ const Auth = (() => {
const USER_KEY = 'bb_user';
function getToken() { return localStorage.getItem(KEY); }
function saveToken(token) { localStorage.setItem(KEY, token); }
function getUser() {
@@ -15,15 +14,20 @@ const Auth = (() => {
function saveUser(user) { localStorage.setItem(USER_KEY, JSON.stringify(user)); }
function _decodePayload() {
const token = getToken();
if (!token) return null;
try { return JSON.parse(atob(token.split('.')[1])); } catch (_) { return null; }
}
function logout() {
localStorage.removeItem(KEY);
localStorage.removeItem(USER_KEY);
window.location.href = '/index.html';
window.location.href = '/login.html';
}
function isLoggedIn() { return !!getToken(); }
/* Redirect to login if not authenticated */
function requireAuth() {
if (!isLoggedIn()) {
window.location.href = '/login.html';
@@ -32,14 +36,10 @@ const Auth = (() => {
return true;
}
/* Redirect away from auth pages if already logged in */
function redirectIfLoggedIn() {
if (isLoggedIn()) {
window.location.href = '/dashboard.html';
}
if (isLoggedIn()) window.location.href = '/dashboard.html';
}
/* Render the nav user area; call after DOM ready */
async function renderNav(activePage) {
const navLinksEl = document.getElementById('nav-links');
const navUserEl = document.getElementById('nav-user');
@@ -50,23 +50,191 @@ const Auth = (() => {
if (!user) {
try { user = await API.users.me(); saveUser(user); } catch (_) {}
}
const payload = _decodePayload();
const isImpersonating = !!(payload && payload.admin_id);
navLinksEl.innerHTML = `
<a href="/dashboard.html" class="${activePage === 'dashboard' ? 'active' : ''}">My Bottle</a>
<a href="/log.html" class="${activePage === 'log' ? 'active' : ''}">Log Entry</a>
<a href="/dashboard.html" class="btn btn-ghost btn-sm${activePage === 'dashboard' ? ' active' : ''}">My Bottle</a>
<a href="/log.html" class="btn btn-ghost btn-sm${activePage === 'log' ? ' active' : ''}">Log Entry</a>
`;
navUserEl.innerHTML = `
<a href="/profile.html" class="nav-user">${user?.display_name || user?.email || 'Account'}</a>
<a href="#" class="btn btn-ghost btn-sm" id="logout-btn">Logout</a>
`;
document.getElementById('logout-btn')?.addEventListener('click', (e) => {
e.preventDefault();
logout();
});
if (isImpersonating) {
navUserEl.innerHTML = `
<span class="nav-impersonating">Viewing as <strong>${escHtml(user?.display_name || user?.email || 'User')}</strong></span>
<button onclick="Auth.returnToAdmin()" class="btn btn-sm btn-amber">&#8617; Return to Admin</button>
`;
} else {
navUserEl.innerHTML = `
<span class="nav-username">${escHtml(user?.display_name || user?.email || 'Account')}</span>
${user?.is_admin ? '<a href="/admin.html" class="btn btn-sm btn-ghost">Admin</a>' : ''}
<button onclick="Auth.showSettingsModal()" class="btn btn-sm btn-ghost" title="Settings">&#9881;</button>
<button onclick="Auth.logout()" class="btn btn-sm btn-ghost">Logout</button>
`;
_injectSettingsModal(user);
}
} else {
navLinksEl.innerHTML = '';
navUserEl.innerHTML = `<a href="/login.html" class="btn btn-primary btn-sm">Login</a>`;
}
}
return { getToken, saveToken, getUser, saveUser, logout, isLoggedIn, requireAuth, redirectIfLoggedIn, renderNav };
function _buildTimezoneOptions(selected) {
let allTz;
try {
allTz = Intl.supportedValuesOf('timeZone');
} catch (_) {
allTz = ['UTC', 'America/New_York', 'America/Chicago', 'America/Denver',
'America/Los_Angeles', 'America/Anchorage', 'Pacific/Honolulu',
'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Asia/Tokyo',
'Asia/Shanghai', 'Australia/Sydney'];
}
const groups = {};
for (const tz of allTz) {
const slash = tz.indexOf('/');
const group = slash === -1 ? 'Other' : tz.slice(0, slash);
(groups[group] = groups[group] || []).push(tz);
}
return Object.keys(groups).sort().map(group => {
const opts = groups[group].map(tz => {
const label = tz.slice(tz.indexOf('/') + 1).replace(/_/g, ' ').replace(/\//g, ' / ');
return `<option value="${tz}"${tz === selected ? ' selected' : ''}>${label}</option>`;
}).join('');
return `<optgroup label="${group}">${opts}</optgroup>`;
}).join('');
}
function _injectSettingsModal(user) {
if (document.getElementById('settings-modal')) return;
const tzOptions = _buildTimezoneOptions(user?.timezone || 'UTC');
document.body.insertAdjacentHTML('beforeend', `
<div id="settings-modal" class="modal-overlay" style="display:none">
<div class="modal-box">
<h2>Settings</h2>
<div id="settings-msg"></div>
<h3 class="settings-section-title">Profile</h3>
<div class="form-group" style="margin-bottom:1.25rem">
<label for="settings-display-name">Display Name</label>
<input type="text" id="settings-display-name" value="${escHtml(user?.display_name || '')}" maxlength="100" />
</div>
<hr class="settings-divider">
<h3 class="settings-section-title">Timezone</h3>
<div class="form-group" style="margin-bottom:0.5rem">
<label for="settings-tz">Your timezone</label>
<select id="settings-tz">${tzOptions}</select>
</div>
<div style="display:flex;gap:0.5rem;align-items:center;margin-bottom:1.25rem">
<button class="btn btn-sm btn-ghost" onclick="Auth.detectTimezone()">Detect automatically</button>
<button class="btn btn-primary btn-sm" onclick="Auth.saveProfile()">Save Profile &amp; Timezone</button>
</div>
<hr class="settings-divider">
<h3 class="settings-section-title">Change Password</h3>
<div class="form-group" style="margin-bottom:0.75rem">
<label>Current Password</label>
<input type="password" id="settings-pw-current" autocomplete="current-password" />
</div>
<div class="form-group" style="margin-bottom:0.75rem">
<label>New Password</label>
<input type="password" id="settings-pw-new" autocomplete="new-password" />
</div>
<div class="form-group" style="margin-bottom:1rem">
<label>Confirm New Password</label>
<input type="password" id="settings-pw-confirm" autocomplete="new-password" />
</div>
<div style="display:flex;gap:0.5rem;justify-content:flex-end">
<button class="btn btn-ghost" onclick="Auth.hideSettingsModal()">Close</button>
<button class="btn btn-primary" onclick="Auth.submitPasswordChange()">Update Password</button>
</div>
</div>
</div>
`);
document.addEventListener('click', (e) => {
if (e.target.id === 'settings-modal') hideSettingsModal();
});
}
function showSettingsModal() {
document.getElementById('settings-modal').style.display = 'flex';
const msg = document.getElementById('settings-msg');
if (msg) msg.innerHTML = '';
document.getElementById('settings-pw-current').value = '';
document.getElementById('settings-pw-new').value = '';
document.getElementById('settings-pw-confirm').value = '';
}
function hideSettingsModal() {
document.getElementById('settings-modal').style.display = 'none';
}
function detectTimezone() {
const detected = Intl.DateTimeFormat().resolvedOptions().timeZone;
const sel = document.getElementById('settings-tz');
if (sel) sel.value = detected;
}
async function saveProfile() {
const displayName = document.getElementById('settings-display-name').value.trim();
const timezone = document.getElementById('settings-tz').value;
try {
const user = await API.users.update({ display_name: displayName || null, timezone });
saveUser(user);
const usernameEl = document.querySelector('.nav-username');
if (usernameEl) usernameEl.textContent = user.display_name || user.email || 'Account';
_showSettingsMsg('Profile saved.', 'success');
} catch (err) {
_showSettingsMsg(err.message, 'error');
}
}
async function submitPasswordChange() {
const current = document.getElementById('settings-pw-current').value;
const newPw = document.getElementById('settings-pw-new').value;
const confirm = document.getElementById('settings-pw-confirm').value;
if (newPw !== confirm) { _showSettingsMsg('New passwords do not match.', 'error'); return; }
if (newPw.length < 8) { _showSettingsMsg('Password must be at least 8 characters.', 'error'); return; }
try {
await API.users.changePassword({ current_password: current, new_password: newPw });
_showSettingsMsg('Password updated!', 'success');
document.getElementById('settings-pw-current').value = '';
document.getElementById('settings-pw-new').value = '';
document.getElementById('settings-pw-confirm').value = '';
} catch (err) {
_showSettingsMsg(err.message, 'error');
}
}
function _showSettingsMsg(text, type) {
const el = document.getElementById('settings-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);
}
async function returnToAdmin() {
try {
const data = await API.post('/admin/unimpersonate', {});
saveToken(data.access_token);
const user = await API.users.me();
saveUser(user);
window.location.href = '/admin.html';
} catch (_) {
logout();
}
}
return {
getToken, saveToken, getUser, saveUser,
logout, isLoggedIn, requireAuth, redirectIfLoggedIn, renderNav,
showSettingsModal, hideSettingsModal, detectTimezone, saveProfile, submitPasswordChange,
returnToAdmin,
};
})();