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

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