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
+
+
+
+
+
+
+
+
+
+ | Display Name |
+ Email |
+ Role |
+ Status |
+ Joined |
+ Actions |
+
+
+
+ | Loading⦠|
+
+
+
+
+
+
+
+
Reset Password
+
Setting new password for:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Delete User
+
Delete ? This will permanently remove their account and all associated data.
+
+
+
+
+
+
+
+
+
+
+
+
+
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 ``;
+ }).join('');
+ }
+
+ function _injectSettingsModal(user) {
+ if (document.getElementById('settings-modal')) return;
+ const tzOptions = _buildTimezoneOptions(user?.timezone || 'UTC');
+ document.body.insertAdjacentHTML('beforeend', `
+
+
+
Settings
+
+
+
Profile
+
+
+
+
+
+
+
+
Timezone
+
+
+
+
+
+
+
+
+
+
+
+
Change Password
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `);
+
+ 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 @@