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

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