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

@@ -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)