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

@@ -2,3 +2,6 @@ MYSQL_ROOT_PASSWORD=sproutly_root_secret
MYSQL_USER=sproutly
MYSQL_PASSWORD=sproutly_secret
SECRET_KEY=your-secret-key-change-this
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=change-this-password

View File

@@ -9,6 +9,7 @@ Sproutly takes the guesswork out of seed starting. Enter your plant varieties on
## Features
- **Multi-user** — each user has their own account with fully isolated data
- **Admin panel** — manage all user accounts: view content, reset passwords, disable, or delete
- **Dashboard** — at-a-glance view of overdue, today's, and upcoming tasks with a full year planting timeline
- **Seed Library** — manage plant varieties with frost-relative timing, germination days, sun/water requirements
- **Garden Tracker** — log growing batches and track status from `planned``germinating``seedling``potted up``hardening off``garden``harvested`
@@ -60,6 +61,8 @@ MYSQL_ROOT_PASSWORD=sproutly_root_secret
MYSQL_USER=sproutly
MYSQL_PASSWORD=sproutly_secret
SECRET_KEY=your-secret-key-change-this
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=change-this-password
```
`SECRET_KEY` is used to sign JWT tokens. Generate a secure value with:
@@ -68,6 +71,8 @@ SECRET_KEY=your-secret-key-change-this
python3 -c "import secrets; print(secrets.token_hex(32))"
```
`ADMIN_EMAIL` and `ADMIN_PASSWORD` define the super admin account. This account is created (or updated) automatically every time the backend starts — changing these values in `.env` and restarting is all that's needed to update the credentials.
## Project Structure
```
@@ -84,6 +89,7 @@ sproutly/
│ ├── database.py
│ └── routers/
│ ├── auth.py # /auth/register, /auth/login, /auth/me
│ ├── admin.py # /admin/users — admin-only user management
│ ├── varieties.py
│ ├── batches.py
│ ├── dashboard.py
@@ -114,6 +120,24 @@ Key endpoints:
- `POST /api/notifications/test` — send test ntfy notification
- `POST /api/notifications/daily` — trigger daily summary
- `GET /api/notifications/log` — recent notification history
- `GET /api/admin/users` — list all users with stats (admin only)
- `GET /api/admin/users/{id}/varieties` — view a user's seed library (admin only)
- `GET /api/admin/users/{id}/batches` — view a user's batches (admin only)
- `POST /api/admin/users/{id}/reset-password` — reset a user's password (admin only)
- `POST /api/admin/users/{id}/disable` — toggle account disabled state (admin only)
- `DELETE /api/admin/users/{id}` — delete a user and all their data (admin only)
## Admin Panel
Log in with the `ADMIN_EMAIL` / `ADMIN_PASSWORD` credentials from your `.env`. Once logged in, an **Admin** link appears in the sidebar. From there you can:
- View all registered users with their variety and batch counts
- Browse any user's seed library and growing batches
- Reset a user's password
- Disable or re-enable an account
- Permanently delete an account and all associated data
The admin account itself cannot be disabled or deleted through the panel.
## Ntfy Authentication

View File

@@ -45,3 +45,9 @@ def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(
if not user:
raise exc
return user
def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return current_user

View File

@@ -1,5 +1,6 @@
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -7,10 +8,11 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from database import SessionLocal
from models import Settings, NotificationLog
from models import Settings, NotificationLog, User
from routers import varieties, batches, dashboard, settings, notifications
from routers import auth as auth_router
from routers import auth as auth_router, admin as admin_router
from routers.notifications import build_daily_summary, send_ntfy
from auth import hash_password
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("sproutly")
@@ -49,8 +51,37 @@ def get_notification_schedule(db) -> tuple[int, int]:
return 7, 0
def bootstrap_admin(retries: int = 5, delay: float = 2.0):
import time
admin_email = os.environ.get("ADMIN_EMAIL")
admin_password = os.environ.get("ADMIN_PASSWORD")
if not admin_email or not admin_password:
return
for attempt in range(retries):
db = SessionLocal()
try:
admin = db.query(User).filter(User.email == admin_email).first()
if admin:
admin.hashed_password = hash_password(admin_password)
admin.is_admin = True
admin.is_disabled = False
else:
admin = User(email=admin_email, hashed_password=hash_password(admin_password), is_admin=True)
db.add(admin)
db.commit()
logger.info(f"Admin user ready: {admin_email}")
return
except Exception as e:
logger.warning(f"Admin bootstrap attempt {attempt + 1} failed: {e}")
time.sleep(delay)
finally:
db.close()
logger.error("Admin bootstrap failed after all retries")
@asynccontextmanager
async def lifespan(app: FastAPI):
bootstrap_admin()
db = SessionLocal()
hour, minute = get_notification_schedule(db)
db.close()
@@ -77,6 +108,7 @@ app.add_middleware(
)
app.include_router(auth_router.router)
app.include_router(admin_router.router)
app.include_router(varieties.router)
app.include_router(batches.router)
app.include_router(dashboard.router)

View File

@@ -41,6 +41,8 @@ class User(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
email = Column(String(255), unique=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
is_admin = Column(Boolean, default=False, nullable=False)
is_disabled = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, server_default=func.now())
varieties = relationship("Variety", back_populates="user", cascade="all, delete-orphan")

88
backend/routers/admin.py Normal file
View File

@@ -0,0 +1,88 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from auth import get_admin_user, hash_password
from database import get_db
from models import Batch, User, Variety
from schemas import AdminResetPassword, AdminUserOut, BatchOut, VarietyOut
router = APIRouter(prefix="/admin", tags=["admin"])
def _user_stats(db: Session, user_id: int) -> dict:
return {
"variety_count": db.query(Variety).filter(Variety.user_id == user_id).count(),
"batch_count": db.query(Batch).filter(Batch.user_id == user_id).count(),
}
@router.get("/users", response_model=List[AdminUserOut])
def list_users(db: Session = Depends(get_db), _: User = Depends(get_admin_user)):
users = db.query(User).order_by(User.created_at).all()
return [
AdminUserOut(
id=u.id, email=u.email, is_admin=u.is_admin, is_disabled=u.is_disabled,
created_at=u.created_at, **_user_stats(db, u.id)
)
for u in users
]
@router.get("/users/{user_id}/varieties", response_model=List[VarietyOut])
def user_varieties(user_id: int, db: Session = Depends(get_db), _: User = Depends(get_admin_user)):
_require_user(db, user_id)
return db.query(Variety).filter(Variety.user_id == user_id).order_by(Variety.category, Variety.name).all()
@router.get("/users/{user_id}/batches", response_model=List[BatchOut])
def user_batches(user_id: int, db: Session = Depends(get_db), _: User = Depends(get_admin_user)):
from sqlalchemy.orm import joinedload
_require_user(db, user_id)
return (
db.query(Batch)
.options(joinedload(Batch.variety))
.filter(Batch.user_id == user_id)
.order_by(Batch.created_at.desc())
.all()
)
@router.post("/users/{user_id}/reset-password")
def reset_password(user_id: int, data: AdminResetPassword, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
user = _require_user(db, user_id)
if not data.new_password or len(data.new_password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
user.hashed_password = hash_password(data.new_password)
db.commit()
return {"status": "ok"}
@router.post("/users/{user_id}/disable")
def toggle_disable(user_id: int, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
user = _require_user(db, user_id)
if user.id == admin.id:
raise HTTPException(status_code=400, detail="Cannot disable your own account")
if user.is_admin:
raise HTTPException(status_code=400, detail="Cannot disable another admin account")
user.is_disabled = not user.is_disabled
db.commit()
return {"is_disabled": user.is_disabled}
@router.delete("/users/{user_id}", status_code=204)
def delete_user(user_id: int, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
user = _require_user(db, user_id)
if user.id == admin.id:
raise HTTPException(status_code=400, detail="Cannot delete your own account")
if user.is_admin:
raise HTTPException(status_code=400, detail="Cannot delete another admin account")
db.delete(user)
db.commit()
def _require_user(db: Session, user_id: int) -> User:
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user

View File

@@ -46,6 +46,8 @@ def login(data: UserLogin, db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == data.email).first()
if not user or not verify_password(data.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid email or password")
if user.is_disabled:
raise HTTPException(status_code=403, detail="Account has been disabled")
return {"access_token": create_access_token(user.id), "token_type": "bearer"}

View File

@@ -13,18 +13,34 @@ class UserCreate(BaseModel):
class UserLogin(BaseModel):
email: EmailStr
email: str
password: str
class UserOut(BaseModel):
id: int
email: str
is_admin: bool = False
is_disabled: bool = False
created_at: Optional[datetime] = None
model_config = {"from_attributes": True}
class AdminUserOut(BaseModel):
id: int
email: str
is_admin: bool
is_disabled: bool
created_at: Optional[datetime]
variety_count: int
batch_count: int
class AdminResetPassword(BaseModel):
new_password: str
class Token(BaseModel):
access_token: str
token_type: str

View File

@@ -33,6 +33,8 @@ services:
DB_USER: ${MYSQL_USER}
DB_PASSWORD: ${MYSQL_PASSWORD}
SECRET_KEY: ${SECRET_KEY}
ADMIN_EMAIL: ${ADMIN_EMAIL}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
depends_on:
mysql:
condition: service_healthy

View File

@@ -4,6 +4,8 @@ CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
hashed_password VARCHAR(255) NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
is_disabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

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),
};