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:
@@ -35,8 +35,8 @@ async def create_user(
|
|||||||
display_name=body.display_name or body.email.split("@")[0],
|
display_name=body.display_name or body.email.split("@")[0],
|
||||||
is_admin=False,
|
is_admin=False,
|
||||||
)
|
)
|
||||||
async with db.begin():
|
db.add(user)
|
||||||
db.add(user)
|
await db.commit()
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@@ -53,8 +53,8 @@ async def reset_password(
|
|||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
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)
|
@router.post("/users/{user_id}/disable", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -71,8 +71,8 @@ async def disable_user(
|
|||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
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)
|
@router.post("/users/{user_id}/enable", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -86,8 +86,8 @@ async def enable_user(
|
|||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
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)
|
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -104,8 +104,8 @@ async def delete_user(
|
|||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
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)
|
@router.post("/users/{user_id}/impersonate", response_model=Token)
|
||||||
|
|||||||
@@ -25,9 +25,8 @@ async def update_me(
|
|||||||
if body.timezone is not None:
|
if body.timezone is not None:
|
||||||
current_user.timezone = body.timezone
|
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)
|
await db.refresh(current_user)
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
@@ -43,5 +42,5 @@ async def change_password(
|
|||||||
|
|
||||||
current_user.password_hash = hash_password(body.new_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()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class UserResponse(BaseModel):
|
|||||||
email: str
|
email: str
|
||||||
display_name: Optional[str]
|
display_name: Optional[str]
|
||||||
timezone: str
|
timezone: str
|
||||||
|
is_admin: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|||||||
@@ -5,5 +5,7 @@ aiomysql==0.2.0
|
|||||||
pydantic-settings==2.7.0
|
pydantic-settings==2.7.0
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
|
bcrypt==4.0.1
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
pytz==2024.2
|
pytz==2024.2
|
||||||
|
email-validator==2.2.0
|
||||||
|
|||||||
82
frontend/admin.html
Normal file
82
frontend/admin.html
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Admin — Bourbonacci</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🥃</text></svg>" />
|
||||||
|
<link rel="stylesheet" href="/css/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<div class="nav-left">
|
||||||
|
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
|
||||||
|
<div class="nav-links" id="nav-links"></div>
|
||||||
|
</div>
|
||||||
|
<div id="nav-user"></div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h1 class="page-title">Admin — User Management</h1>
|
||||||
|
|
||||||
|
<div id="msg"></div>
|
||||||
|
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>All Users</h2>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="loadUsers()">Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Display Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Joined</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="users-body">
|
||||||
|
<tr class="empty-row"><td colspan="6">Loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reset password modal -->
|
||||||
|
<div id="reset-modal" class="modal-overlay" style="display:none">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h2>Reset Password</h2>
|
||||||
|
<p style="margin-bottom:1rem;color:var(--cream-dim)">Setting new password for: <strong id="reset-username"></strong></p>
|
||||||
|
<div id="reset-msg"></div>
|
||||||
|
<div class="form-group" style="margin-bottom:1rem">
|
||||||
|
<label>New Password</label>
|
||||||
|
<input type="password" id="reset-password" placeholder="Min 8 characters" />
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:0.5rem;justify-content:flex-end">
|
||||||
|
<button class="btn btn-ghost" onclick="hideResetModal()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick="submitReset()">Set Password</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete confirmation modal -->
|
||||||
|
<div id="delete-modal" class="modal-overlay" style="display:none">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h2>Delete User</h2>
|
||||||
|
<p style="margin-bottom:1.5rem;color:var(--cream-dim)">Delete <strong id="delete-username"></strong>? This will permanently remove their account and all associated data.</p>
|
||||||
|
<div style="display:flex;gap:0.5rem;justify-content:flex-end">
|
||||||
|
<button class="btn btn-ghost" onclick="hideDeleteModal()">Cancel</button>
|
||||||
|
<button class="btn btn-danger" onclick="submitDelete()">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/auth.js"></script>
|
||||||
|
<script src="/js/admin.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -51,32 +51,99 @@ nav {
|
|||||||
letter-spacing: .05em;
|
letter-spacing: .05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-links {
|
.nav-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.5rem;
|
gap: .5rem;
|
||||||
align-items: center;
|
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 {
|
.nav-links a.active {
|
||||||
color: var(--amber-light);
|
border-color: var(--amber);
|
||||||
|
color: var(--amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-user {
|
.nav-user {
|
||||||
color: var(--amber);
|
display: flex;
|
||||||
cursor: pointer;
|
align-items: center;
|
||||||
font-size: .95rem;
|
gap: 0.5rem;
|
||||||
text-decoration: none;
|
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 ---- */
|
/* ---- Layout ---- */
|
||||||
main {
|
main {
|
||||||
|
|||||||
@@ -10,8 +10,10 @@
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
|
<div class="nav-left">
|
||||||
<div class="nav-links" id="nav-links"></div>
|
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
|
||||||
|
<div class="nav-links" id="nav-links"></div>
|
||||||
|
</div>
|
||||||
<div id="nav-user"></div>
|
<div id="nav-user"></div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,10 @@
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
|
<div class="nav-left">
|
||||||
<div class="nav-links" id="nav-links"></div>
|
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
|
||||||
|
<div class="nav-links" id="nav-links"></div>
|
||||||
|
</div>
|
||||||
<div id="nav-user"></div>
|
<div id="nav-user"></div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
164
frontend/js/admin.js
Normal file
164
frontend/js/admin.js
Normal 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();
|
||||||
|
});
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
/* Central API client — all fetch calls go through here */
|
/* Central API client — all fetch calls go through here */
|
||||||
|
|
||||||
|
function escHtml(str) {
|
||||||
|
return String(str ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
const API = (() => {
|
const API = (() => {
|
||||||
const base = '/api';
|
const base = '/api';
|
||||||
|
|
||||||
@@ -55,5 +59,15 @@ const API = (() => {
|
|||||||
public: {
|
public: {
|
||||||
stats: () => request('GET', '/public/stats'),
|
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', {}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ const Auth = (() => {
|
|||||||
const USER_KEY = 'bb_user';
|
const USER_KEY = 'bb_user';
|
||||||
|
|
||||||
function getToken() { return localStorage.getItem(KEY); }
|
function getToken() { return localStorage.getItem(KEY); }
|
||||||
|
|
||||||
function saveToken(token) { localStorage.setItem(KEY, token); }
|
function saveToken(token) { localStorage.setItem(KEY, token); }
|
||||||
|
|
||||||
function getUser() {
|
function getUser() {
|
||||||
@@ -15,15 +14,20 @@ const Auth = (() => {
|
|||||||
|
|
||||||
function saveUser(user) { localStorage.setItem(USER_KEY, JSON.stringify(user)); }
|
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() {
|
function logout() {
|
||||||
localStorage.removeItem(KEY);
|
localStorage.removeItem(KEY);
|
||||||
localStorage.removeItem(USER_KEY);
|
localStorage.removeItem(USER_KEY);
|
||||||
window.location.href = '/index.html';
|
window.location.href = '/login.html';
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLoggedIn() { return !!getToken(); }
|
function isLoggedIn() { return !!getToken(); }
|
||||||
|
|
||||||
/* Redirect to login if not authenticated */
|
|
||||||
function requireAuth() {
|
function requireAuth() {
|
||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) {
|
||||||
window.location.href = '/login.html';
|
window.location.href = '/login.html';
|
||||||
@@ -32,14 +36,10 @@ const Auth = (() => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Redirect away from auth pages if already logged in */
|
|
||||||
function redirectIfLoggedIn() {
|
function redirectIfLoggedIn() {
|
||||||
if (isLoggedIn()) {
|
if (isLoggedIn()) window.location.href = '/dashboard.html';
|
||||||
window.location.href = '/dashboard.html';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Render the nav user area; call after DOM ready */
|
|
||||||
async function renderNav(activePage) {
|
async function renderNav(activePage) {
|
||||||
const navLinksEl = document.getElementById('nav-links');
|
const navLinksEl = document.getElementById('nav-links');
|
||||||
const navUserEl = document.getElementById('nav-user');
|
const navUserEl = document.getElementById('nav-user');
|
||||||
@@ -50,23 +50,191 @@ const Auth = (() => {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
try { user = await API.users.me(); saveUser(user); } catch (_) {}
|
try { user = await API.users.me(); saveUser(user); } catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const payload = _decodePayload();
|
||||||
|
const isImpersonating = !!(payload && payload.admin_id);
|
||||||
|
|
||||||
navLinksEl.innerHTML = `
|
navLinksEl.innerHTML = `
|
||||||
<a href="/dashboard.html" class="${activePage === 'dashboard' ? 'active' : ''}">My Bottle</a>
|
<a href="/dashboard.html" class="btn btn-ghost btn-sm${activePage === 'dashboard' ? ' active' : ''}">My Bottle</a>
|
||||||
<a href="/log.html" class="${activePage === 'log' ? 'active' : ''}">Log Entry</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>
|
if (isImpersonating) {
|
||||||
<a href="#" class="btn btn-ghost btn-sm" id="logout-btn">Logout</a>
|
navUserEl.innerHTML = `
|
||||||
`;
|
<span class="nav-impersonating">Viewing as <strong>${escHtml(user?.display_name || user?.email || 'User')}</strong></span>
|
||||||
document.getElementById('logout-btn')?.addEventListener('click', (e) => {
|
<button onclick="Auth.returnToAdmin()" class="btn btn-sm btn-amber">↩ Return to Admin</button>
|
||||||
e.preventDefault();
|
`;
|
||||||
logout();
|
} 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">⚙</button>
|
||||||
|
<button onclick="Auth.logout()" class="btn btn-sm btn-ghost">Logout</button>
|
||||||
|
`;
|
||||||
|
_injectSettingsModal(user);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
navLinksEl.innerHTML = '';
|
navLinksEl.innerHTML = '';
|
||||||
navUserEl.innerHTML = `<a href="/login.html" class="btn btn-primary btn-sm">Login</a>`;
|
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 & 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,
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -10,8 +10,10 @@
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
|
<div class="nav-left">
|
||||||
<div class="nav-links" id="nav-links"></div>
|
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
|
||||||
|
<div class="nav-links" id="nav-links"></div>
|
||||||
|
</div>
|
||||||
<div id="nav-user"></div>
|
<div id="nav-user"></div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,10 @@
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
|
<div class="nav-left">
|
||||||
<div class="nav-links" id="nav-links"></div>
|
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
|
||||||
|
<div class="nav-links" id="nav-links"></div>
|
||||||
|
</div>
|
||||||
<div id="nav-user"></div>
|
<div id="nav-user"></div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,10 @@
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
|
<div class="nav-left">
|
||||||
<div class="nav-links" id="nav-links"></div>
|
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
|
||||||
|
<div class="nav-links" id="nav-links"></div>
|
||||||
|
</div>
|
||||||
<div id="nav-user"></div>
|
<div id="nav-user"></div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -59,13 +61,6 @@
|
|||||||
<button type="submit" class="btn btn-primary" id="btn-pw">Update Password</button>
|
<button type="submit" class="btn btn-primary" id="btn-pw">Update Password</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Danger -->
|
|
||||||
<div class="card" style="border-color:var(--danger-dim)">
|
|
||||||
<div class="card-title" style="color:#e07060">Danger Zone</div>
|
|
||||||
<p style="color:var(--cream-dim);font-size:.9rem;margin-bottom:1rem">Sign out of your account on this device.</p>
|
|
||||||
<button class="btn btn-danger" onclick="Auth.logout()">Logout</button>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/js/api.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
|
|||||||
@@ -10,8 +10,10 @@
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
|
<div class="nav-left">
|
||||||
<div class="nav-links" id="nav-links"></div>
|
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
|
||||||
|
<div class="nav-links" id="nav-links"></div>
|
||||||
|
</div>
|
||||||
<div id="nav-user"></div>
|
<div id="nav-user"></div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user