Add super admin panel and update README

- Admin account bootstrapped from ADMIN_EMAIL/ADMIN_PASSWORD env vars on startup
- Admin panel: list users, view content, reset passwords, disable/delete accounts
- is_admin and is_disabled columns on users table
- Disabled accounts blocked at login
- README updated with admin setup instructions and panel docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 00:24:27 -07:00
parent 0cdb2c2c2d
commit bd2bd43395
13 changed files with 404 additions and 14 deletions

View File

@@ -681,3 +681,27 @@ a:hover { text-decoration: underline; }
transition: background 0.15s, color 0.15s;
}
.btn-logout:hover { background: var(--border); color: var(--text); }
/* ===== Admin ===== */
.admin-table-wrap { overflow-x: auto; }
.admin-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
.admin-table th { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 2px solid var(--border); color: var(--text-light); font-weight: 600; white-space: nowrap; }
.admin-table td { padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
.admin-table tr:last-child td { border-bottom: none; }
.admin-email { font-weight: 500; }
.admin-date, .admin-num { color: var(--text-light); white-space: nowrap; }
.admin-actions { display: flex; gap: 0.35rem; flex-wrap: wrap; }
.badge-admin { background: var(--green-dark); color: #fff; font-size: 0.7rem; padding: 0.1rem 0.4rem; border-radius: 99px; vertical-align: middle; }
.status-pill { font-size: 0.78rem; padding: 0.2rem 0.55rem; border-radius: 99px; font-weight: 500; }
.status-pill.active { background: #d1fae5; color: #065f46; }
.status-pill.disabled { background: #fee2e2; color: #991b1b; }
.btn-xs { padding: 0.2rem 0.55rem; font-size: 0.78rem; }
.btn-warn { background: #fef3c7; color: #92400e; border: 1px solid #fde68a; }
.btn-warn:hover { background: #fde68a; }
.btn-danger { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
.btn-danger:hover { background: #fca5a5; }
.variety-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 0.35rem; vertical-align: middle; }
.admin-view-tabs { display: flex; gap: 0; margin-bottom: 1rem; border-bottom: 2px solid var(--border); }
.admin-view-tab { background: none; border: none; padding: 0.5rem 1rem; font-size: 0.9rem; cursor: pointer; color: var(--text-light); border-bottom: 2px solid transparent; margin-bottom: -2px; }
.admin-view-tab.active { color: var(--green-dark); border-bottom-color: var(--green-dark); font-weight: 500; }
.admin-view-panel { max-height: 60vh; overflow-y: auto; }

View File

@@ -73,6 +73,9 @@
<a href="#settings" class="nav-link" data-page="settings">
<span class="nav-icon">&#9881;</span> Settings
</a>
<a href="#admin" class="nav-link admin-only hidden" data-page="admin">
<span class="nav-icon">&#128272;</span> Admin
</a>
</nav>
<div class="sidebar-footer">
<span id="sidebar-user" class="sidebar-user"></span>
@@ -277,6 +280,19 @@
<span id="settings-status" class="settings-status"></span>
</div>
</section>
<!-- ADMIN -->
<section id="page-admin" class="page">
<div class="page-header">
<div>
<h1 class="page-title">Admin</h1>
<p class="page-subtitle">Manage user accounts</p>
</div>
</div>
<div id="admin-users-container">
<div class="empty-state">Loading users...</div>
</div>
</section>
</main>
<!-- MODALS -->

View File

@@ -32,8 +32,9 @@ const Auth = (() => {
const data = await res.json();
localStorage.setItem('sproutly_token', data.access_token);
localStorage.setItem('sproutly_user', email);
showApp();
initApp();
const user = await apiFetch('/auth/me');
showApp(user);
initApp(user);
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
@@ -71,8 +72,9 @@ const Auth = (() => {
const data = await loginRes.json();
localStorage.setItem('sproutly_token', data.access_token);
localStorage.setItem('sproutly_user', email);
showApp();
initApp();
const user = await apiFetch('/auth/me');
showApp(user);
initApp(user);
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
@@ -90,10 +92,10 @@ const Auth = (() => {
return { showTab, submit, submitRegister, logout };
})();
function showApp() {
function showApp(user) {
document.getElementById('auth-screen').classList.add('hidden');
document.getElementById('app-shell').classList.remove('hidden');
const email = localStorage.getItem('sproutly_user') || '';
const email = (user?.email) || localStorage.getItem('sproutly_user') || '';
document.getElementById('sidebar-user').textContent = email;
}
@@ -197,6 +199,7 @@ function navigate(page) {
if (page === 'varieties') loadVarieties();
if (page === 'garden') loadGarden();
if (page === 'settings') loadSettings();
if (page === 'admin') loadAdmin();
}
// ===== Dashboard =====
@@ -890,14 +893,183 @@ async function deleteBatch(id) {
}
}
// ===== Admin =====
async function loadAdmin() {
const container = document.getElementById('admin-users-container');
try {
const users = await api.get('/admin/users');
if (!users.length) {
container.innerHTML = '<div class="empty-state">No users found.</div>';
return;
}
container.innerHTML = `
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Email</th>
<th>Joined</th>
<th>Varieties</th>
<th>Batches</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${users.map(u => `
<tr id="admin-row-${u.id}">
<td><span class="admin-email">${esc(u.email)}</span>${u.is_admin ? ' <span class="badge-admin">admin</span>' : ''}</td>
<td class="admin-date">${fmt(u.created_at)}</td>
<td class="admin-num">${u.variety_count}</td>
<td class="admin-num">${u.batch_count}</td>
<td><span class="status-pill ${u.is_disabled ? 'disabled' : 'active'}">${u.is_disabled ? 'Disabled' : 'Active'}</span></td>
<td class="admin-actions">
<button class="btn btn-xs btn-secondary" onclick="App.adminViewUser(${u.id}, '${esc(u.email)}')">View</button>
<button class="btn btn-xs btn-secondary" onclick="App.adminResetPassword(${u.id}, '${esc(u.email)}')">Reset PW</button>
${!u.is_admin ? `<button class="btn btn-xs ${u.is_disabled ? 'btn-secondary' : 'btn-warn'}" onclick="App.adminToggleDisable(${u.id}, ${u.is_disabled})">${u.is_disabled ? 'Enable' : 'Disable'}</button>` : ''}
${!u.is_admin ? `<button class="btn btn-xs btn-danger" onclick="App.adminDeleteUser(${u.id}, '${esc(u.email)}')">Delete</button>` : ''}
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
} catch (e) {
container.innerHTML = `<div class="empty-state">Error: ${esc(e.message)}</div>`;
}
}
async function adminViewUser(id, email) {
document.getElementById('modal-title').textContent = `User: ${email}`;
document.getElementById('modal-body').innerHTML = '<div class="empty-state">Loading...</div>';
document.getElementById('modal-overlay').classList.remove('hidden');
try {
const [varieties, batches] = await Promise.all([
api.get(`/admin/users/${id}/varieties`),
api.get(`/admin/users/${id}/batches`),
]);
document.getElementById('modal-body').innerHTML = `
<div class="admin-view-tabs">
<button class="admin-view-tab active" onclick="App.adminSwitchTab(this, 'av-varieties')">Varieties (${varieties.length})</button>
<button class="admin-view-tab" onclick="App.adminSwitchTab(this, 'av-batches')">Batches (${batches.length})</button>
</div>
<div id="av-varieties" class="admin-view-panel">
${varieties.length ? `
<table class="admin-table">
<thead><tr><th>Name</th><th>Category</th><th>Start wks</th><th>Garden wks</th></tr></thead>
<tbody>${varieties.map(v => `
<tr>
<td><span class="variety-dot" style="background:${v.color}"></span>${esc(v.name)}${v.variety_name ? ` <em>${esc(v.variety_name)}</em>` : ''}</td>
<td>${v.category}</td>
<td>${v.weeks_to_start ?? '—'}</td>
<td>${v.weeks_to_garden ?? '—'}</td>
</tr>`).join('')}
</tbody>
</table>` : '<div class="empty-state">No varieties.</div>'}
</div>
<div id="av-batches" class="admin-view-panel hidden">
${batches.length ? `
<table class="admin-table">
<thead><tr><th>Plant</th><th>Label</th><th>Status</th><th>Sown</th></tr></thead>
<tbody>${batches.map(b => `
<tr>
<td>${esc(b.variety?.name || '—')}</td>
<td>${esc(b.label || '—')}</td>
<td>${statusLabel(b.status)}</td>
<td>${fmt(b.sow_date)}</td>
</tr>`).join('')}
</tbody>
</table>` : '<div class="empty-state">No batches.</div>'}
</div>
`;
} catch (e) {
document.getElementById('modal-body').innerHTML = `<div class="empty-state">Error: ${esc(e.message)}</div>`;
}
}
function adminSwitchTab(btn, panelId) {
document.querySelectorAll('.admin-view-tab').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.admin-view-panel').forEach(p => p.classList.add('hidden'));
btn.classList.add('active');
document.getElementById(panelId).classList.remove('hidden');
}
function adminResetPassword(id, email) {
document.getElementById('modal-title').textContent = `Reset Password: ${email}`;
document.getElementById('modal-body').innerHTML = `
<div class="form-group">
<label class="form-label">New Password</label>
<input type="password" id="admin-new-pw" class="form-input" placeholder="At least 8 characters"
onkeydown="if(event.key==='Enter') App.adminSubmitReset(${id})" />
</div>
<div id="admin-pw-error" class="auth-msg error hidden"></div>
<div class="btn-row" style="margin-top:1rem">
<button class="btn btn-primary" onclick="App.adminSubmitReset(${id})">Reset Password</button>
<button class="btn btn-secondary" onclick="App.closeModal()">Cancel</button>
</div>
`;
document.getElementById('modal-overlay').classList.remove('hidden');
setTimeout(() => document.getElementById('admin-new-pw')?.focus(), 50);
}
async function adminSubmitReset(id) {
const pw = document.getElementById('admin-new-pw').value;
const errEl = document.getElementById('admin-pw-error');
if (pw.length < 8) {
errEl.textContent = 'Password must be at least 8 characters';
errEl.classList.remove('hidden');
return;
}
try {
await api.post(`/admin/users/${id}/reset-password`, { new_password: pw });
closeModal();
toast('Password reset successfully');
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
}
}
async function adminToggleDisable(id, currentlyDisabled) {
try {
const result = await api.post(`/admin/users/${id}/disable`, {});
toast(result.is_disabled ? 'Account disabled' : 'Account enabled');
loadAdmin();
} catch (e) {
toast('Error: ' + e.message, true);
}
}
async function adminDeleteUser(id, email) {
if (!confirm(`Permanently delete account "${email}" and all their data?\n\nThis cannot be undone.`)) return;
try {
await api.delete(`/admin/users/${id}`);
toast('User deleted');
loadAdmin();
} catch (e) {
toast('Error: ' + e.message, true);
}
}
// ===== Init =====
function initApp() {
function initApp(user) {
document.getElementById('sidebar-date').textContent =
new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
// Show admin nav if user is admin
document.querySelectorAll('.admin-only').forEach(el => {
el.classList.toggle('hidden', !user?.is_admin);
});
const validPages = ['dashboard', 'varieties', 'garden', 'settings'];
if (user?.is_admin) validPages.push('admin');
function handleNav() {
const page = (location.hash.replace('#','') || 'dashboard');
navigate(['dashboard','varieties','garden','settings'].includes(page) ? page : 'dashboard');
navigate(validPages.includes(page) ? page : 'dashboard');
}
window.removeEventListener('hashchange', handleNav);
window.addEventListener('hashchange', handleNav);
@@ -908,9 +1080,9 @@ async function init() {
const token = localStorage.getItem('sproutly_token');
if (!token) return; // auth screen is visible by default
try {
await apiFetch('/auth/me');
showApp();
initApp();
const user = await apiFetch('/auth/me');
showApp(user);
initApp(user);
} catch (e) {
// token invalid — auth screen stays visible
}
@@ -922,6 +1094,7 @@ window.App = {
showAddBatchModal, showEditBatchModal, submitAddBatch, submitEditBatch, deleteBatch,
filterVarieties, filterBatches,
saveSettings, toggleNtfyAuth, sendTestNotification, sendDailySummary,
adminViewUser, adminResetPassword, adminSubmitReset, adminToggleDisable, adminDeleteUser, adminSwitchTab,
closeModal: (e) => closeModal(e),
};