Add multi-user authentication with JWT

- Users table with email/bcrypt-hashed password; register and login via /auth/ endpoints
- JWT tokens (30-day expiry) stored in localStorage; all API routes require Bearer auth
- All data (varieties, batches, settings, notification logs) scoped to the authenticated user
- Login/register screen overlays the app; sidebar shows user email and logout button
- Scheduler sends daily ntfy summaries for every configured user
- DB schema rewritten for multi-user; SECRET_KEY added to env

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 00:08:28 -07:00
parent 1bed02ebb5
commit 4db9988406
17 changed files with 470 additions and 115 deletions

View File

@@ -3,8 +3,10 @@ from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
import httpx
from auth import get_current_user
from database import get_db
from models import Settings, Variety, Batch, NotificationLog, BatchStatus
from models import Batch, BatchStatus, NotificationLog, Settings, User, Variety
router = APIRouter(prefix="/notifications", tags=["notifications"])
@@ -14,8 +16,8 @@ ACTIVE_STATUSES = [
]
def build_daily_summary(db: Session) -> str:
settings = db.query(Settings).filter(Settings.id == 1).first()
def build_daily_summary(db: Session, user_id: int) -> str:
settings = db.query(Settings).filter(Settings.user_id == user_id).first()
today = date.today()
lines = [f"Sproutly Daily Summary — {today.strftime('%A, %B %d')}"]
lines.append("")
@@ -32,7 +34,7 @@ def build_daily_summary(db: Session) -> str:
lines.append(f"Last frost in {days_to_frost} days ({last_frost.strftime('%B %d')})!")
lines.append("")
varieties = db.query(Variety).all()
varieties = db.query(Variety).filter(Variety.user_id == user_id).all()
for v in varieties:
full_name = f"{v.name} ({v.variety_name})" if v.variety_name else v.name
@@ -59,7 +61,7 @@ def build_daily_summary(db: Session) -> str:
batches = (
db.query(Batch)
.filter(Batch.status.in_(ACTIVE_STATUSES))
.filter(Batch.user_id == user_id, Batch.status.in_(ACTIVE_STATUSES))
.all()
)
for b in batches:
@@ -112,44 +114,34 @@ async def send_ntfy(settings: Settings, title: str, message: str, db: Session, p
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
url,
content=message.encode("utf-8"),
headers=headers,
)
resp = await client.post(url, content=message.encode("utf-8"), headers=headers)
resp.raise_for_status()
log = NotificationLog(message=message, status="sent")
log = NotificationLog(message=message, status="sent", user_id=settings.user_id)
db.add(log)
db.commit()
return True, "sent"
except Exception as e:
log = NotificationLog(message=message, status="failed", error=str(e))
log = NotificationLog(message=message, status="failed", error=str(e), user_id=settings.user_id)
db.add(log)
db.commit()
return False, str(e)
@router.post("/test")
async def send_test_notification(db: Session = Depends(get_db)):
settings = db.query(Settings).filter(Settings.id == 1).first()
ok, detail = await send_ntfy(
settings,
"Sproutly Test",
"Your Sproutly notifications are working!",
db,
priority="default",
)
async def send_test_notification(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
settings = db.query(Settings).filter(Settings.user_id == current_user.id).first()
ok, detail = await send_ntfy(settings, "Sproutly Test", "Your Sproutly notifications are working!", db)
if not ok:
raise HTTPException(status_code=400, detail=detail)
return {"status": "sent"}
@router.post("/daily")
async def send_daily_summary(db: Session = Depends(get_db)):
settings = db.query(Settings).filter(Settings.id == 1).first()
summary = build_daily_summary(db)
async def send_daily_summary(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
settings = db.query(Settings).filter(Settings.user_id == current_user.id).first()
summary = build_daily_summary(db, current_user.id)
ok, detail = await send_ntfy(settings, "Sproutly Daily Summary", summary, db)
if not ok:
raise HTTPException(status_code=400, detail=detail)
@@ -157,6 +149,12 @@ async def send_daily_summary(db: Session = Depends(get_db)):
@router.get("/log")
def get_notification_log(db: Session = Depends(get_db)):
logs = db.query(NotificationLog).order_by(NotificationLog.sent_at.desc()).limit(50).all()
def get_notification_log(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
logs = (
db.query(NotificationLog)
.filter(NotificationLog.user_id == current_user.id)
.order_by(NotificationLog.sent_at.desc())
.limit(50)
.all()
)
return [{"id": l.id, "sent_at": l.sent_at, "status": l.status, "message": l.message, "error": l.error} for l in logs]