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

33
backend/routers/auth.py Normal file
View File

@@ -0,0 +1,33 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from auth import create_access_token, get_current_user, hash_password, verify_password
from database import get_db
from models import User
from schemas import Token, UserCreate, UserLogin, UserOut
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserOut, status_code=201)
def register(data: UserCreate, db: Session = Depends(get_db)):
if db.query(User).filter(User.email == data.email).first():
raise HTTPException(status_code=400, detail="Email already registered")
user = User(email=data.email, hashed_password=hash_password(data.password))
db.add(user)
db.commit()
db.refresh(user)
return user
@router.post("/login", response_model=Token)
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")
return {"access_token": create_access_token(user.id), "token_type": "bearer"}
@router.get("/me", response_model=UserOut)
def me(current_user: User = Depends(get_current_user)):
return current_user

View File

@@ -1,36 +1,39 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session, joinedload
from typing import List
from auth import get_current_user
from database import get_db
from models import Batch, Variety
from schemas import BatchCreate, BatchUpdate, BatchOut
from models import Batch, User, Variety
from schemas import BatchCreate, BatchOut, BatchUpdate
router = APIRouter(prefix="/batches", tags=["batches"])
@router.get("/", response_model=List[BatchOut])
def list_batches(db: Session = Depends(get_db)):
def list_batches(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
return (
db.query(Batch)
.options(joinedload(Batch.variety))
.filter(Batch.user_id == current_user.id)
.order_by(Batch.sow_date.desc().nullslast(), Batch.created_at.desc())
.all()
)
@router.get("/{batch_id}", response_model=BatchOut)
def get_batch(batch_id: int, db: Session = Depends(get_db)):
b = db.query(Batch).options(joinedload(Batch.variety)).filter(Batch.id == batch_id).first()
def get_batch(batch_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
b = db.query(Batch).options(joinedload(Batch.variety)).filter(Batch.id == batch_id, Batch.user_id == current_user.id).first()
if not b:
raise HTTPException(status_code=404, detail="Batch not found")
return b
@router.post("/", response_model=BatchOut, status_code=201)
def create_batch(data: BatchCreate, db: Session = Depends(get_db)):
if not db.query(Variety).filter(Variety.id == data.variety_id).first():
def create_batch(data: BatchCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
if not db.query(Variety).filter(Variety.id == data.variety_id, Variety.user_id == current_user.id).first():
raise HTTPException(status_code=404, detail="Variety not found")
b = Batch(**data.model_dump())
b = Batch(**data.model_dump(), user_id=current_user.id)
db.add(b)
db.commit()
db.refresh(b)
@@ -38,8 +41,8 @@ def create_batch(data: BatchCreate, db: Session = Depends(get_db)):
@router.put("/{batch_id}", response_model=BatchOut)
def update_batch(batch_id: int, data: BatchUpdate, db: Session = Depends(get_db)):
b = db.query(Batch).filter(Batch.id == batch_id).first()
def update_batch(batch_id: int, data: BatchUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
b = db.query(Batch).filter(Batch.id == batch_id, Batch.user_id == current_user.id).first()
if not b:
raise HTTPException(status_code=404, detail="Batch not found")
for field, value in data.model_dump().items():
@@ -50,8 +53,8 @@ def update_batch(batch_id: int, data: BatchUpdate, db: Session = Depends(get_db)
@router.delete("/{batch_id}", status_code=204)
def delete_batch(batch_id: int, db: Session = Depends(get_db)):
b = db.query(Batch).filter(Batch.id == batch_id).first()
def delete_batch(batch_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
b = db.query(Batch).filter(Batch.id == batch_id, Batch.user_id == current_user.id).first()
if not b:
raise HTTPException(status_code=404, detail="Batch not found")
db.delete(b)

View File

@@ -2,9 +2,11 @@ from datetime import date, timedelta
from typing import List, Optional
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session, joinedload
from auth import get_current_user
from database import get_db
from models import Variety, Batch, Settings, BatchStatus
from schemas import DashboardOut, Task, TimelineEntry, BatchOut
from models import Batch, BatchStatus, Settings, User, Variety
from schemas import BatchOut, DashboardOut, Task, TimelineEntry
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@@ -45,15 +47,13 @@ def day_of_year(d: date) -> int:
@router.get("/", response_model=DashboardOut)
def get_dashboard(db: Session = Depends(get_db)):
settings = db.query(Settings).filter(Settings.id == 1).first()
def get_dashboard(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
settings = db.query(Settings).filter(Settings.user_id == current_user.id).first()
today = date.today()
last_frost = None
if settings and settings.last_frost_date:
last_frost = settings.last_frost_date.replace(year=today.year)
# If last frost has passed this year, use next year's date for planning
# but keep this year for historical display
planning_frost = last_frost
if last_frost < today - timedelta(days=60):
planning_frost = last_frost.replace(year=today.year + 1)
@@ -63,12 +63,11 @@ def get_dashboard(db: Session = Depends(get_db)):
all_tasks: List[Task] = []
timeline: List[TimelineEntry] = []
varieties = db.query(Variety).all()
varieties = db.query(Variety).filter(Variety.user_id == current_user.id).all()
for v in varieties:
full_name = f"{v.name} ({v.variety_name})" if v.variety_name else v.name
# --- Timeline entry ---
entry = TimelineEntry(
variety_id=v.id,
name=v.name,
@@ -89,9 +88,8 @@ def get_dashboard(db: Session = Depends(get_db)):
else:
td = planning_frost - timedelta(weeks=abs(v.weeks_to_garden))
entry.garden_day = day_of_year(td)
entry.end_day = day_of_year(td) + 80 # rough season length
entry.end_day = day_of_year(td) + 80
# --- Tasks from variety schedule (only within 30 days) ---
if v.weeks_to_start:
start_date = planning_frost - timedelta(weeks=v.weeks_to_start)
days = (start_date - today).days
@@ -133,11 +131,10 @@ def get_dashboard(db: Session = Depends(get_db)):
timeline.append(entry)
# --- Tasks from active batches ---
batches = (
db.query(Batch)
.options(joinedload(Batch.variety))
.filter(Batch.status.in_(ACTIVE_STATUSES))
.filter(Batch.user_id == current_user.id, Batch.status.in_(ACTIVE_STATUSES))
.all()
)
@@ -173,7 +170,6 @@ def get_dashboard(db: Session = Depends(get_db)):
today, color, batch_id=b.id,
))
# Deduplicate and filter to -7 to +30 day window
seen = set()
filtered = []
for t in all_tasks:
@@ -184,11 +180,9 @@ def get_dashboard(db: Session = Depends(get_db)):
filtered.sort(key=lambda t: t.days_away)
# Active batches for display
active_batch_objs = [b for b in batches if b.status != BatchStatus.garden]
# Stats
all_batches = db.query(Batch).all()
all_batches = db.query(Batch).filter(Batch.user_id == current_user.id).all()
stats = {
"total_varieties": len(varieties),
"total_batches": len(all_batches),

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]

View File

@@ -1,29 +1,32 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from auth import get_current_user
from database import get_db
from models import Settings
from schemas import SettingsUpdate, SettingsOut
from models import Settings, User
from schemas import SettingsOut, SettingsUpdate
router = APIRouter(prefix="/settings", tags=["settings"])
@router.get("/", response_model=SettingsOut)
def get_settings(db: Session = Depends(get_db)):
s = db.query(Settings).filter(Settings.id == 1).first()
def _get_or_create(db: Session, user_id: int) -> Settings:
s = db.query(Settings).filter(Settings.user_id == user_id).first()
if not s:
s = Settings(id=1)
s = Settings(user_id=user_id)
db.add(s)
db.commit()
db.refresh(s)
return s
@router.get("/", response_model=SettingsOut)
def get_settings(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
return _get_or_create(db, current_user.id)
@router.put("/", response_model=SettingsOut)
def update_settings(data: SettingsUpdate, db: Session = Depends(get_db)):
s = db.query(Settings).filter(Settings.id == 1).first()
if not s:
s = Settings(id=1)
db.add(s)
def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
s = _get_or_create(db, current_user.id)
for field, value in data.model_dump(exclude_unset=True).items():
setattr(s, field, value)
db.commit()

View File

@@ -1,29 +1,31 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from auth import get_current_user
from database import get_db
from models import Variety
from schemas import VarietyCreate, VarietyUpdate, VarietyOut
from models import User, Variety
from schemas import VarietyCreate, VarietyOut, VarietyUpdate
router = APIRouter(prefix="/varieties", tags=["varieties"])
@router.get("/", response_model=List[VarietyOut])
def list_varieties(db: Session = Depends(get_db)):
return db.query(Variety).order_by(Variety.category, Variety.name).all()
def list_varieties(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
return db.query(Variety).filter(Variety.user_id == current_user.id).order_by(Variety.category, Variety.name).all()
@router.get("/{variety_id}", response_model=VarietyOut)
def get_variety(variety_id: int, db: Session = Depends(get_db)):
v = db.query(Variety).filter(Variety.id == variety_id).first()
def get_variety(variety_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
v = db.query(Variety).filter(Variety.id == variety_id, Variety.user_id == current_user.id).first()
if not v:
raise HTTPException(status_code=404, detail="Variety not found")
return v
@router.post("/", response_model=VarietyOut, status_code=201)
def create_variety(data: VarietyCreate, db: Session = Depends(get_db)):
v = Variety(**data.model_dump())
def create_variety(data: VarietyCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
v = Variety(**data.model_dump(), user_id=current_user.id)
db.add(v)
db.commit()
db.refresh(v)
@@ -31,8 +33,8 @@ def create_variety(data: VarietyCreate, db: Session = Depends(get_db)):
@router.put("/{variety_id}", response_model=VarietyOut)
def update_variety(variety_id: int, data: VarietyUpdate, db: Session = Depends(get_db)):
v = db.query(Variety).filter(Variety.id == variety_id).first()
def update_variety(variety_id: int, data: VarietyUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
v = db.query(Variety).filter(Variety.id == variety_id, Variety.user_id == current_user.id).first()
if not v:
raise HTTPException(status_code=404, detail="Variety not found")
for field, value in data.model_dump().items():
@@ -43,8 +45,8 @@ def update_variety(variety_id: int, data: VarietyUpdate, db: Session = Depends(g
@router.delete("/{variety_id}", status_code=204)
def delete_variety(variety_id: int, db: Session = Depends(get_db)):
v = db.query(Variety).filter(Variety.id == variety_id).first()
def delete_variety(variety_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
v = db.query(Variety).filter(Variety.id == variety_id, Variety.user_id == current_user.id).first()
if not v:
raise HTTPException(status_code=404, detail="Variety not found")
db.delete(v)