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:
157
nginx/html/js/admin.js
Normal file
157
nginx/html/js/admin.js
Normal 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();
|
||||
});
|
||||
Reference in New Issue
Block a user