Add multi-user auth, admin panel, and timezone support; rename to Yolkbook

- Rename app from Eggtracker to Yolkbook throughout
- Add JWT-based authentication (python-jose, passlib/bcrypt)
- Add users table; all data tables gain user_id FK for full data isolation
- Super admin credentials sourced from ADMIN_USERNAME/ADMIN_PASSWORD env vars,
  synced on every startup; orphaned rows auto-assigned to admin post-migration
- Login page with self-registration; JWT stored in localStorage (30-day expiry)
- Admin panel (/admin): list users, reset passwords, disable/enable, delete,
  and impersonate (Login As) with Return to Admin banner
- Settings modal (gear icon in nav): timezone selector and change password
- Timezone stored per-user; stats date windows computed in user's timezone;
  date input setToday() respects user timezone via Intl API
- migrate_v2.sql for existing single-user installs
- Auto-migration adds timezone column to users on startup
- Updated README with full setup, auth, admin, and migration docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 23:19:29 -07:00
parent 7d50af0054
commit aa12648228
31 changed files with 1572 additions and 140 deletions

157
nginx/html/js/admin.js Normal file
View File

@@ -0,0 +1,157 @@
// admin.js — admin user management page
let resetTargetId = null;
let deleteTargetId = null;
let currentAdminId = null;
document.addEventListener('DOMContentLoaded', async () => {
// Verify admin access
const user = Auth.getUser();
if (!user || !user.is_admin) {
window.location.href = '/';
return;
}
currentAdminId = parseInt(user.sub);
await loadUsers();
});
async function loadUsers() {
try {
const users = await API.get('/api/admin/users');
renderUsers(users);
} catch (err) {
showMessage(document.getElementById('msg'), err.message, 'error');
}
}
function renderUsers(users) {
const tbody = document.getElementById('users-body');
if (!users.length) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="5">No users found.</td></tr>';
return;
}
tbody.innerHTML = users.map(u => {
const isSelf = u.id === currentAdminId;
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 created = 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}, '${u.username}')">Login As</button>`
: '';
const deleteBtn = !isSelf
? `<button class="btn btn-sm btn-danger" onclick="showDeleteModal(${u.id}, '${u.username}')">Delete</button>`
: '';
return `<tr>
<td><strong>${u.username}</strong></td>
<td>${roleLabel}</td>
<td>${statusLabel}</td>
<td>${created}</td>
<td class="actions" style="display:flex;gap:0.35rem;flex-wrap:wrap">
<button class="btn btn-sm btn-ghost" onclick="showResetModal(${u.id}, '${u.username}')">Reset PW</button>
${toggleBtn}
${impersonateBtn}
${deleteBtn}
</td>
</tr>`;
}).join('');
}
function showResetModal(id, username) {
resetTargetId = id;
document.getElementById('reset-username').textContent = username;
document.getElementById('reset-password').value = '';
document.getElementById('reset-msg').className = 'message';
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 < 6) {
msgEl.textContent = 'Password must be at least 6 characters';
msgEl.className = 'message error visible';
return;
}
try {
await API.post(`/api/admin/users/${resetTargetId}/reset-password`, { new_password: password });
msgEl.textContent = 'Password reset successfully.';
msgEl.className = 'message success visible';
setTimeout(hideResetModal, 1200);
} catch (err) {
msgEl.textContent = err.message;
msgEl.className = 'message error visible';
}
}
async function toggleUser(id, disable) {
const action = disable ? 'disable' : 'enable';
try {
await API.post(`/api/admin/users/${id}/${action}`, {});
await loadUsers();
} catch (err) {
showMessage(document.getElementById('msg'), err.message, 'error');
}
}
async function impersonateUser(id, username) {
try {
const data = await API.post(`/api/admin/users/${id}/impersonate`, {});
// Save admin token so user can return
sessionStorage.setItem('admin_token', Auth.getToken());
Auth.setToken(data.access_token);
window.location.href = '/';
} catch (err) {
showMessage(document.getElementById('msg'), err.message, 'error');
}
}
function showDeleteModal(id, username) {
deleteTargetId = id;
document.getElementById('delete-username').textContent = username;
document.getElementById('delete-modal').style.display = 'flex';
}
function hideDeleteModal() {
document.getElementById('delete-modal').style.display = 'none';
deleteTargetId = null;
}
async function submitDelete() {
try {
await API.del(`/api/admin/users/${deleteTargetId}`);
hideDeleteModal();
showMessage(document.getElementById('msg'), 'User deleted.');
await loadUsers();
} catch (err) {
hideDeleteModal();
showMessage(document.getElementById('msg'), err.message, 'error');
}
}
// Close modals on overlay click
document.addEventListener('click', (e) => {
if (e.target.id === 'reset-modal') hideResetModal();
if (e.target.id === 'delete-modal') hideDeleteModal();
});

View File

@@ -2,10 +2,21 @@
const API = {
async _fetch(url, options = {}) {
const token = localStorage.getItem('token');
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
headers,
...options,
});
if (res.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
return;
}
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `Request failed (${res.status})`);
@@ -27,13 +38,13 @@ function showMessage(el, text, type = 'success') {
setTimeout(() => { el.className = 'message'; }, 4000);
}
// Set an input[type=date] to today's date (using local time, not UTC)
// Set an input[type=date] to today's date in the user's configured timezone
function setToday(inputEl) {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
inputEl.value = `${y}-${m}-${d}`;
const tz = (typeof Auth !== 'undefined' && Auth.getUser()?.timezone)
|| Intl.DateTimeFormat().resolvedOptions().timeZone
|| 'UTC';
// en-CA locale produces YYYY-MM-DD which is what date inputs expect
inputEl.value = new Date().toLocaleDateString('en-CA', { timeZone: tz });
}
// Format YYYY-MM-DD → MM/DD/YYYY for display

232
nginx/html/js/auth.js Normal file
View File

@@ -0,0 +1,232 @@
// auth.js — authentication utilities used by every authenticated page
const Auth = {
getToken() {
return localStorage.getItem('token');
},
setToken(token) {
localStorage.setItem('token', token);
},
removeToken() {
localStorage.removeItem('token');
},
getUser() {
const token = this.getToken();
if (!token) return null;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp < Date.now() / 1000) {
this.removeToken();
return null;
}
return payload;
} catch (_) {
return null;
}
},
requireAuth() {
const user = this.getUser();
if (!user) {
window.location.href = '/login';
return null;
}
return user;
},
logout() {
this.removeToken();
sessionStorage.removeItem('admin_token');
window.location.href = '/login';
},
};
function returnToAdmin() {
const adminToken = sessionStorage.getItem('admin_token');
Auth.setToken(adminToken);
sessionStorage.removeItem('admin_token');
window.location.href = '/admin';
}
// ── Timezone helpers ──────────────────────────────────────────────────────────
function getUserTimezone() {
return Auth.getUser()?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
}
function buildTimezoneOptions(selected) {
let allTz;
try {
allTz = Intl.supportedValuesOf('timeZone');
} catch (_) {
// Fallback for older browsers
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 options = 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}">${options}</optgroup>`;
}).join('');
}
// ── Nav + settings modal ──────────────────────────────────────────────────────
function initNav() {
const user = Auth.requireAuth();
if (!user) return;
const nav = document.querySelector('.nav');
if (!nav) return;
const adminToken = sessionStorage.getItem('admin_token');
const navUser = document.createElement('div');
navUser.className = 'nav-user';
if (adminToken) {
navUser.innerHTML = `
<span class="nav-impersonating">Viewing as <strong>${user.username}</strong></span>
<button onclick="returnToAdmin()" class="btn btn-sm btn-amber">&#8617; Return to Admin</button>
`;
} else {
navUser.innerHTML = `
<span class="nav-username">${user.username}</span>
${user.is_admin ? '<a href="/admin" class="btn btn-sm btn-ghost nav-admin-btn">Admin</a>' : ''}
<button onclick="showSettingsModal()" class="btn btn-sm btn-ghost" title="Settings">&#9881;</button>
<button onclick="Auth.logout()" class="btn btn-sm btn-ghost">Logout</button>
`;
}
nav.appendChild(navUser);
if (!adminToken) {
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" class="message"></div>
<h3 class="settings-section-title">Timezone</h3>
<div class="form-group" style="margin-bottom:0.5rem">
<label for="tz-select">Your timezone</label>
<select id="tz-select">${tzOptions}</select>
</div>
<div style="display:flex;gap:0.5rem;align-items:center;margin-bottom:1.5rem">
<button class="btn btn-sm btn-ghost" onclick="detectTimezone()">Detect automatically</button>
<button class="btn btn-primary btn-sm" onclick="submitTimezone()">Save 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="pw-current" autocomplete="current-password">
</div>
<div class="form-group" style="margin-bottom:0.75rem">
<label>New Password</label>
<input type="password" id="pw-new" autocomplete="new-password" minlength="6">
</div>
<div class="form-group" style="margin-bottom:1rem">
<label>Confirm New Password</label>
<input type="password" id="pw-confirm" autocomplete="new-password">
</div>
<div style="display:flex;gap:0.5rem;justify-content:flex-end">
<button class="btn btn-ghost" onclick="hideSettingsModal()">Close</button>
<button class="btn btn-primary" onclick="submitPasswordChange()">Update Password</button>
</div>
</div>
</div>
`);
}
}
function showSettingsModal() {
document.getElementById('settings-modal').style.display = 'flex';
document.getElementById('settings-msg').className = 'message';
document.getElementById('pw-current').value = '';
document.getElementById('pw-new').value = '';
document.getElementById('pw-confirm').value = '';
}
function hideSettingsModal() {
document.getElementById('settings-modal').style.display = 'none';
}
function detectTimezone() {
const detected = Intl.DateTimeFormat().resolvedOptions().timeZone;
const sel = document.getElementById('tz-select');
if (sel) sel.value = detected;
}
async function submitTimezone() {
const tz = document.getElementById('tz-select').value;
const msgEl = document.getElementById('settings-msg');
try {
const data = await API.put('/api/auth/timezone', { timezone: tz });
Auth.setToken(data.access_token);
msgEl.textContent = `Timezone saved: ${tz.replace(/_/g, ' ')}`;
msgEl.className = 'message success visible';
setTimeout(() => { msgEl.className = 'message'; }, 3000);
} catch (err) {
msgEl.textContent = err.message;
msgEl.className = 'message error visible';
}
}
async function submitPasswordChange() {
const current = document.getElementById('pw-current').value;
const newPw = document.getElementById('pw-new').value;
const confirm = document.getElementById('pw-confirm').value;
const msgEl = document.getElementById('settings-msg');
if (newPw !== confirm) {
msgEl.textContent = 'New passwords do not match';
msgEl.className = 'message error visible';
return;
}
if (newPw.length < 6) {
msgEl.textContent = 'Password must be at least 6 characters';
msgEl.className = 'message error visible';
return;
}
try {
await API.post('/api/auth/change-password', {
current_password: current,
new_password: newPw,
});
msgEl.textContent = 'Password updated!';
msgEl.className = 'message success visible';
document.getElementById('pw-current').value = '';
document.getElementById('pw-new').value = '';
document.getElementById('pw-confirm').value = '';
setTimeout(() => { msgEl.className = 'message'; }, 3000);
} catch (err) {
msgEl.textContent = err.message;
msgEl.className = 'message error visible';
}
}
document.addEventListener('click', (e) => {
const modal = document.getElementById('settings-modal');
if (modal && e.target === modal) hideSettingsModal();
});
document.addEventListener('DOMContentLoaded', initNav);

View File

@@ -187,7 +187,7 @@ async function exportCSV() {
a.href = url;
const now = new Date();
const fileDate = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
a.download = `egg-tracker-${fileDate}.csv`;
a.download = `yolkbook-${fileDate}.csv`;
a.click();
URL.revokeObjectURL(url);