From f1b82baebdf003577504dc5ff13c0c308ea84f02 Mon Sep 17 00:00:00 2001 From: derekc Date: Tue, 24 Mar 2026 21:09:38 -0700 Subject: [PATCH] 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 --- backend/app/routers/admin.py | 20 ++-- backend/app/routers/users.py | 9 +- backend/app/schemas/user.py | 1 + backend/requirements.txt | 2 + frontend/admin.html | 82 ++++++++++++++ frontend/css/style.css | 97 ++++++++++++++--- frontend/dashboard.html | 6 +- frontend/index.html | 6 +- frontend/js/admin.js | 164 ++++++++++++++++++++++++++++ frontend/js/api.js | 14 +++ frontend/js/auth.js | 206 +++++++++++++++++++++++++++++++---- frontend/log.html | 6 +- frontend/login.html | 6 +- frontend/profile.html | 13 +-- frontend/register.html | 6 +- 15 files changed, 570 insertions(+), 68 deletions(-) create mode 100644 frontend/admin.html create mode 100644 frontend/js/admin.js diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 8700291..c8b06af 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -35,8 +35,8 @@ async def create_user( display_name=body.display_name or body.email.split("@")[0], is_admin=False, ) - async with db.begin(): - db.add(user) + db.add(user) + await db.commit() await db.refresh(user) return user @@ -53,8 +53,8 @@ async def reset_password( if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") - async with db.begin(): - user.password_hash = hash_password(body.new_password) + user.password_hash = hash_password(body.new_password) + await db.commit() @router.post("/users/{user_id}/disable", status_code=status.HTTP_204_NO_CONTENT) @@ -71,8 +71,8 @@ async def disable_user( if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") - async with db.begin(): - user.is_disabled = True + user.is_disabled = True + await db.commit() @router.post("/users/{user_id}/enable", status_code=status.HTTP_204_NO_CONTENT) @@ -86,8 +86,8 @@ async def enable_user( if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") - async with db.begin(): - user.is_disabled = False + user.is_disabled = False + await db.commit() @router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -104,8 +104,8 @@ async def delete_user( if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") - async with db.begin(): - await db.delete(user) + await db.delete(user) + await db.commit() @router.post("/users/{user_id}/impersonate", response_model=Token) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 749e113..1d93325 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -25,9 +25,8 @@ async def update_me( if body.timezone is not None: current_user.timezone = body.timezone - async with db.begin(): - db.add(current_user) - + db.add(current_user) + await db.commit() await db.refresh(current_user) return current_user @@ -43,5 +42,5 @@ async def change_password( current_user.password_hash = hash_password(body.new_password) - async with db.begin(): - db.add(current_user) + db.add(current_user) + await db.commit() diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 06027ff..8825074 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -24,6 +24,7 @@ class UserResponse(BaseModel): email: str display_name: Optional[str] timezone: str + is_admin: bool created_at: datetime model_config = {"from_attributes": True} diff --git a/backend/requirements.txt b/backend/requirements.txt index 299b7f8..a5129fd 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,5 +5,7 @@ aiomysql==0.2.0 pydantic-settings==2.7.0 python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 python-multipart==0.0.20 pytz==2024.2 +email-validator==2.2.0 diff --git a/frontend/admin.html b/frontend/admin.html new file mode 100644 index 0000000..90f27f6 --- /dev/null +++ b/frontend/admin.html @@ -0,0 +1,82 @@ + + + + + + Admin β€” Bourbonacci + + + + + + + +
+

Admin β€” User Management

+ +
+ +
+

All Users

+ +
+ +
+ + + + + + + + + + + + + + +
Display NameEmailRoleStatusJoinedActions
Loading…
+
+ + + + + + +
+ + + + + + diff --git a/frontend/css/style.css b/frontend/css/style.css index 93a7919..6a25c54 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -51,32 +51,99 @@ nav { letter-spacing: .05em; } +.nav-left { + display: flex; + align-items: center; + gap: 1rem; +} + .nav-links { display: flex; - gap: 1.5rem; + gap: .5rem; align-items: center; } -.nav-links a { - color: var(--cream-dim); - text-decoration: none; - font-size: .95rem; - transition: color .2s; -} - -.nav-links a:hover, .nav-links a.active { - color: var(--amber-light); + border-color: var(--amber); + color: var(--amber); } .nav-user { - color: var(--amber); - cursor: pointer; - font-size: .95rem; - text-decoration: none; + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; } -.nav-user:hover { color: var(--amber-light); } +.nav-username { + color: var(--cream-dim); + font-size: 0.88rem; + white-space: nowrap; +} + +.nav-impersonating { + color: var(--amber-light); + font-size: 0.85rem; + white-space: nowrap; +} + +.btn-amber { + background: var(--amber); + color: #0d0800; + border: none; +} + +.btn-amber:hover { background: var(--amber-light); } + +/* ---- Modal ---- */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 500; + padding: 1rem; +} + +.modal-box { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.75rem; + width: 100%; + max-width: 440px; + box-shadow: var(--shadow); +} + +.modal-box h2 { + color: var(--amber-light); + margin-bottom: 1.25rem; + font-size: 1.2rem; +} + +/* ---- Settings modal ---- */ +.settings-section-title { + font-size: 0.8rem; + font-weight: bold; + color: var(--cream-dim); + text-transform: uppercase; + letter-spacing: .07em; + margin-bottom: 0.75rem; +} + +.settings-divider { + border: none; + border-top: 1px solid var(--border); + margin: 1.25rem 0; +} + +/* ---- Admin badges ---- */ +.badge-admin { background: rgba(200,134,10,.2); color: var(--amber-light); border: 1px solid var(--amber-dim); } +.badge-user { background: rgba(245,230,200,.1); color: var(--cream-dim); border: 1px solid var(--border); } +.badge-active { background: rgba(39,174,96,.15); color: #5dd490; border: 1px solid #1e6b3d; } +.badge-disabled { background: rgba(192,57,43,.2); color: #e07060; border: 1px solid var(--danger-dim); } /* ---- Layout ---- */ main { diff --git a/frontend/dashboard.html b/frontend/dashboard.html index bcfbd38..4dc43b7 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -10,8 +10,10 @@ diff --git a/frontend/index.html b/frontend/index.html index 9b1ff4c..e8ad213 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10,8 +10,10 @@ diff --git a/frontend/js/admin.js b/frontend/js/admin.js new file mode 100644 index 0000000..64561b8 --- /dev/null +++ b/frontend/js/admin.js @@ -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 = 'No users found.'; + return; + } + + tbody.innerHTML = users.map(u => { + const isSelf = u.id === currentAdminId; + const name = escHtml(u.display_name || u.email); + const roleLabel = u.is_admin + ? 'Admin' + : 'User'; + const statusLabel = u.is_disabled + ? 'Disabled' + : 'Active'; + const joined = new Date(u.created_at).toLocaleDateString(); + + const toggleBtn = u.is_disabled + ? `` + : ``; + + const impersonateBtn = !isSelf + ? `` + : ''; + + const deleteBtn = !isSelf + ? `` + : ''; + + return ` + ${name} + ${escHtml(u.email)} + ${roleLabel} + ${statusLabel} + ${joined} + + + ${toggleBtn} + ${impersonateBtn} + ${deleteBtn} + + `; + }).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 = '
Password must be at least 8 characters.
'; + return; + } + + try { + await API.admin.resetPassword(resetTargetId, { new_password: password }); + msgEl.innerHTML = '
Password reset successfully.
'; + setTimeout(hideResetModal, 1200); + } catch (err) { + msgEl.innerHTML = `
${escHtml(err.message)}
`; + } +} + +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 = `
${escHtml(text)}
`; + 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(); +}); diff --git a/frontend/js/api.js b/frontend/js/api.js index 724a5ec..42c6b3f 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -1,5 +1,9 @@ /* Central API client β€” all fetch calls go through here */ +function escHtml(str) { + return String(str ?? '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + 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', {}), + }, }; })(); diff --git a/frontend/js/auth.js b/frontend/js/auth.js index 80481d9..8721312 100644 --- a/frontend/js/auth.js +++ b/frontend/js/auth.js @@ -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 = ` - My Bottle - Log Entry + My Bottle + Log Entry `; - navUserEl.innerHTML = ` - ${user?.display_name || user?.email || 'Account'} - Logout - `; - document.getElementById('logout-btn')?.addEventListener('click', (e) => { - e.preventDefault(); - logout(); - }); + + if (isImpersonating) { + navUserEl.innerHTML = ` + Viewing as ${escHtml(user?.display_name || user?.email || 'User')} + + `; + } else { + navUserEl.innerHTML = ` + ${escHtml(user?.display_name || user?.email || 'Account')} + ${user?.is_admin ? 'Admin' : ''} + + + `; + _injectSettingsModal(user); + } } else { navLinksEl.innerHTML = ''; navUserEl.innerHTML = `Login`; } } - 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 ``; + }).join(''); + return `${opts}`; + }).join(''); + } + + function _injectSettingsModal(user) { + if (document.getElementById('settings-modal')) return; + const tzOptions = _buildTimezoneOptions(user?.timezone || 'UTC'); + document.body.insertAdjacentHTML('beforeend', ` + + `); + + 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 = `
${escHtml(text)}
`; + 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, + }; })(); diff --git a/frontend/log.html b/frontend/log.html index 6ee922a..9fae40a 100644 --- a/frontend/log.html +++ b/frontend/log.html @@ -10,8 +10,10 @@ diff --git a/frontend/login.html b/frontend/login.html index 8ce58c2..6a244fa 100644 --- a/frontend/login.html +++ b/frontend/login.html @@ -10,8 +10,10 @@ diff --git a/frontend/profile.html b/frontend/profile.html index aa04455..4ce4c84 100644 --- a/frontend/profile.html +++ b/frontend/profile.html @@ -10,8 +10,10 @@ @@ -59,13 +61,6 @@ - - -
-
Danger Zone
-

Sign out of your account on this device.

- -
diff --git a/frontend/register.html b/frontend/register.html index ea09681..f8c5a0a 100644 --- a/frontend/register.html +++ b/frontend/register.html @@ -10,8 +10,10 @@