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

@@ -1,3 +1,4 @@
MYSQL_ROOT_PASSWORD=sproutly_root_secret
MYSQL_USER=sproutly
MYSQL_PASSWORD=sproutly_secret
SECRET_KEY=your-secret-key-change-this

47
backend/auth.py Normal file
View File

@@ -0,0 +1,47 @@
import os
from datetime import datetime, timedelta
import bcrypt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from database import get_db
from models import User
SECRET_KEY = os.environ.get("SECRET_KEY", "changeme-please-set-a-real-secret-in-env")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_DAYS = 30
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode(), hashed.encode())
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def create_access_token(user_id: int) -> str:
expire = datetime.utcnow() + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS)
return jwt.encode({"sub": str(user_id), "exp": expire}, SECRET_KEY, algorithm=ALGORITHM)
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
exc = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id = int(payload.get("sub"))
except (JWTError, TypeError, ValueError):
raise exc
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise exc
return user

View File

@@ -9,6 +9,7 @@ from apscheduler.triggers.cron import CronTrigger
from database import SessionLocal
from models import Settings, NotificationLog
from routers import varieties, batches, dashboard, settings, notifications
from routers import auth as auth_router
from routers.notifications import build_daily_summary, send_ntfy
logging.basicConfig(level=logging.INFO)
@@ -20,13 +21,13 @@ scheduler = AsyncIOScheduler()
async def scheduled_daily_notification():
db = SessionLocal()
try:
s = db.query(Settings).filter(Settings.id == 1).first()
if not s or not s.ntfy_topic:
logger.info("Daily notification skipped: ntfy not configured")
return
summary = build_daily_summary(db)
ok, detail = await send_ntfy(s, "Sproutly Daily Summary", summary, db)
logger.info(f"Daily notification: {detail}")
all_settings = db.query(Settings).filter(Settings.ntfy_topic.isnot(None)).all()
for s in all_settings:
if not s.ntfy_topic:
continue
summary = build_daily_summary(db, s.user_id)
ok, detail = await send_ntfy(s, "Sproutly Daily Summary", summary, db)
logger.info(f"Daily notification for user {s.user_id}: {detail}")
except Exception as e:
logger.error(f"Daily notification error: {e}")
log = NotificationLog(message="scheduler error", status="failed", error=str(e))
@@ -37,8 +38,9 @@ async def scheduled_daily_notification():
def get_notification_schedule(db) -> tuple[int, int]:
"""Use the earliest configured notification time across all users."""
try:
s = db.query(Settings).filter(Settings.id == 1).first()
s = db.query(Settings).filter(Settings.ntfy_topic.isnot(None)).first()
if s and s.notification_time:
h, m = s.notification_time.split(":")
return int(h), int(m)
@@ -65,7 +67,7 @@ async def lifespan(app: FastAPI):
scheduler.shutdown()
app = FastAPI(title="Sproutly API", version="1.0.0", lifespan=lifespan)
app = FastAPI(title="Sproutly API", version="2.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
@@ -74,6 +76,7 @@ app.add_middleware(
allow_headers=["*"],
)
app.include_router(auth_router.router)
app.include_router(varieties.router)
app.include_router(batches.router)
app.include_router(dashboard.router)

View File

@@ -35,10 +35,25 @@ class BatchStatus(str, enum.Enum):
failed = "failed"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
email = Column(String(255), unique=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
created_at = Column(DateTime, server_default=func.now())
varieties = relationship("Variety", back_populates="user", cascade="all, delete-orphan")
batches = relationship("Batch", back_populates="user", cascade="all, delete-orphan")
settings = relationship("Settings", back_populates="user", uselist=False, cascade="all, delete-orphan")
notification_logs = relationship("NotificationLog", back_populates="user", cascade="all, delete-orphan")
class Variety(Base):
__tablename__ = "varieties"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
name = Column(String(100), nullable=False)
variety_name = Column(String(100))
category = Column(Enum(Category), default=Category.vegetable)
@@ -54,6 +69,7 @@ class Variety(Base):
notes = Column(Text)
created_at = Column(DateTime, server_default=func.now())
user = relationship("User", back_populates="varieties")
batches = relationship("Batch", back_populates="variety", cascade="all, delete-orphan")
@@ -61,6 +77,7 @@ class Batch(Base):
__tablename__ = "batches"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
variety_id = Column(Integer, ForeignKey("varieties.id"), nullable=False)
label = Column(String(100))
quantity = Column(Integer, default=1)
@@ -72,13 +89,15 @@ class Batch(Base):
notes = Column(Text)
created_at = Column(DateTime, server_default=func.now())
user = relationship("User", back_populates="batches")
variety = relationship("Variety", back_populates="batches")
class Settings(Base):
__tablename__ = "settings"
id = Column(Integer, primary_key=True, default=1)
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False)
last_frost_date = Column(Date)
first_frost_fall_date = Column(Date)
ntfy_topic = Column(String(200))
@@ -90,12 +109,17 @@ class Settings(Base):
ntfy_password = Column(String(200))
ntfy_api_key = Column(String(200))
user = relationship("User", back_populates="settings")
class NotificationLog(Base):
__tablename__ = "notification_log"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
sent_at = Column(DateTime, server_default=func.now())
message = Column(Text)
status = Column(String(20))
error = Column(Text)
user = relationship("User", back_populates="notification_logs")

View File

@@ -7,3 +7,6 @@ python-dotenv==1.0.1
httpx==0.27.0
apscheduler==3.10.4
pydantic==2.7.1
python-jose[cryptography]==3.3.0
bcrypt==4.1.3
email-validator==2.1.1

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)

View File

@@ -1,10 +1,35 @@
from __future__ import annotations
from datetime import date, datetime
from typing import Optional, List
from pydantic import BaseModel
from pydantic import BaseModel, EmailStr
from models import Category, SunRequirement, WaterNeeds, BatchStatus
# --- Auth ---
class UserCreate(BaseModel):
email: EmailStr
password: str
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserOut(BaseModel):
id: int
email: str
created_at: Optional[datetime] = None
model_config = {"from_attributes": True}
class Token(BaseModel):
access_token: str
token_type: str
# --- Variety ---
class VarietyBase(BaseModel):

View File

@@ -32,6 +32,7 @@ services:
DB_NAME: sproutly
DB_USER: ${MYSQL_USER}
DB_PASSWORD: ${MYSQL_PASSWORD}
SECRET_KEY: ${SECRET_KEY}
depends_on:
mysql:
condition: service_healthy

View File

@@ -1,7 +1,15 @@
-- Sproutly Database Schema
-- Sproutly Database Schema (multi-user)
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
hashed_password VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS varieties (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
name VARCHAR(100) NOT NULL,
variety_name VARCHAR(100),
category ENUM('vegetable', 'herb', 'flower', 'fruit') DEFAULT 'vegetable',
@@ -15,11 +23,13 @@ CREATE TABLE IF NOT EXISTS varieties (
water_needs ENUM('low', 'medium', 'high') DEFAULT 'medium',
color VARCHAR(7) DEFAULT '#52b788',
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS batches (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
variety_id INT NOT NULL,
label VARCHAR(100),
quantity INT DEFAULT 1,
@@ -30,11 +40,13 @@ CREATE TABLE IF NOT EXISTS batches (
status ENUM('planned','germinating','seedling','potted_up','hardening','garden','harvested','failed') DEFAULT 'planned',
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (variety_id) REFERENCES varieties(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS settings (
id INT PRIMARY KEY DEFAULT 1,
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL UNIQUE,
last_frost_date DATE,
first_frost_fall_date DATE,
ntfy_topic VARCHAR(200),
@@ -44,31 +56,16 @@ CREATE TABLE IF NOT EXISTS settings (
location_name VARCHAR(100),
ntfy_username VARCHAR(200),
ntfy_password VARCHAR(200),
ntfy_api_key VARCHAR(200)
ntfy_api_key VARCHAR(200),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS notification_log (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT,
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
message TEXT,
status VARCHAR(20),
error TEXT
error TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);
-- Insert default settings row
INSERT INTO settings (id) VALUES (1);
-- Sample plant varieties
INSERT INTO varieties (name, variety_name, category, weeks_to_start, weeks_to_greenhouse, weeks_to_garden, days_to_germinate, frost_tolerant, sun_requirement, water_needs, color, notes) VALUES
('Tomato', 'Roma', 'vegetable', 8, 2, 2, 7, FALSE, 'full_sun', 'medium', '#e76f51', 'Start indoors 6-8 weeks before last frost. Needs warm soil to transplant.'),
('Tomato', 'Cherry', 'vegetable', 8, 2, 2, 7, FALSE, 'full_sun', 'medium', '#f4a261', 'Great in containers. Very prolific producer.'),
('Pepper', 'Bell', 'vegetable', 10, 2, 2, 10, FALSE, 'full_sun', 'medium', '#e9c46a', 'Slow to germinate, start early. Needs heat.'),
('Pepper', 'Hot Banana', 'vegetable', 10, 2, 2, 12, FALSE, 'full_sun', 'low', '#f4a261', 'Very slow to germinate. Keep soil warm (80F+).'),
('Broccoli', 'Calabrese', 'vegetable', 6, 2, -2, 5, TRUE, 'full_sun', 'medium', '#2d6a4f', 'Can tolerate light frost. Start indoors for spring or direct sow in summer for fall crop.'),
('Lettuce', 'Butterhead', 'vegetable', 4, 1, -4, 3, TRUE, 'part_shade', 'medium', '#74c69d', 'Cold tolerant. Can direct sow early in spring. Bolts in heat.'),
('Cucumber', 'Straight Eight', 'vegetable', 3, 0, 2, 5, FALSE, 'full_sun', 'high', '#52b788', 'Direct sow after last frost or start indoors 2-3 weeks before. Hates root disturbance.'),
('Basil', 'Sweet', 'herb', 6, 1, 2, 7, FALSE, 'full_sun', 'medium', '#40916c', 'Very frost sensitive. Start indoors, transplant after all danger of frost.'),
('Marigold', 'French', 'flower', 6, 1, 0, 5, FALSE, 'full_sun', 'low', '#f4a261', 'Great companion plant for tomatoes. Deters pests.'),
('Zinnia', 'Cut & Come Again', 'flower', 4, 0, 1, 5, FALSE, 'full_sun', 'low', '#e76f51', 'Can direct sow after last frost. Easy and prolific.'),
('Kale', 'Lacinato', 'vegetable', 6, 2, -4, 5, TRUE, 'full_sun', 'medium', '#1b4332', 'Very cold hardy. Start early for spring or late summer for fall/winter harvest.'),
('Squash', 'Zucchini', 'vegetable', 3, 0, 2, 5, FALSE, 'full_sun', 'high', '#95d5b2', 'Direct sow or start indoors 2-3 weeks before last frost. Fast growing.');

View File

@@ -634,3 +634,50 @@ a:hover { text-decoration: underline; }
.auth-toggle { display: flex; gap: 1.25rem; flex-wrap: wrap; }
.auth-toggle-option { display: flex; align-items: center; gap: 0.35rem; cursor: pointer; font-size: 0.9rem; }
.auth-toggle-option input[type="radio"] { accent-color: var(--green-mid); }
/* ===== Auth Screen ===== */
.auth-overlay {
position: fixed; inset: 0; z-index: 1000;
background: linear-gradient(135deg, #d8f3dc 0%, #b7e4c7 50%, #95d5b2 100%);
display: flex; align-items: center; justify-content: center;
padding: 1rem;
}
.auth-card {
background: var(--bg-card); border-radius: 1rem;
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
padding: 2.5rem 2rem; width: 100%; max-width: 400px;
}
.auth-brand {
display: flex; align-items: center; gap: 0.5rem;
justify-content: center; margin-bottom: 2rem;
font-size: 1.6rem; font-weight: 700; color: var(--green-dark);
}
.auth-brand .brand-icon { font-size: 2rem; }
.auth-tabs {
display: flex; margin-bottom: 1.5rem;
border-bottom: 2px solid var(--border);
}
.auth-tab {
flex: 1; padding: 0.6rem; background: none; border: none;
font-size: 0.9rem; font-weight: 500; color: var(--text-light);
cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -2px;
transition: color 0.2s, border-color 0.2s;
}
.auth-tab.active { color: var(--green-dark); border-bottom-color: var(--green-dark); }
.auth-msg { padding: 0.6rem 0.75rem; border-radius: 0.4rem; font-size: 0.85rem; margin-bottom: 1rem; }
.auth-msg.error { background: #fee2e2; color: #b91c1c; }
.btn-full { width: 100%; justify-content: center; margin-top: 0.5rem; }
/* Sidebar user + logout */
.sidebar-user {
display: block; font-size: 0.75rem; color: var(--text-light);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
max-width: 100%; margin-bottom: 0.25rem;
}
.btn-logout {
width: 100%; padding: 0.4rem; margin-top: 0.5rem;
background: none; border: 1px solid var(--border); border-radius: 0.4rem;
color: var(--text-light); font-size: 0.8rem; cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.btn-logout:hover { background: var(--border); color: var(--text); }

View File

@@ -8,6 +8,53 @@
<link rel="stylesheet" href="/css/style.css" />
</head>
<body>
<!-- AUTH SCREEN -->
<div id="auth-screen" class="auth-overlay">
<div class="auth-card">
<div class="auth-brand">
<span class="brand-icon">&#127807;</span>
<span class="brand-name">Sproutly</span>
</div>
<div class="auth-tabs">
<button id="tab-login" class="auth-tab active" onclick="Auth.showTab('login')">Log In</button>
<button id="tab-register" class="auth-tab" onclick="Auth.showTab('register')">Create Account</button>
</div>
<div id="auth-login-panel">
<div class="form-group">
<label class="form-label">Email</label>
<input type="email" id="auth-email" class="form-input" placeholder="you@example.com"
onkeydown="if(event.key==='Enter') Auth.submit()" />
</div>
<div class="form-group">
<label class="form-label">Password</label>
<input type="password" id="auth-password" class="form-input" placeholder="Password"
onkeydown="if(event.key==='Enter') Auth.submit()" />
</div>
<div id="auth-error" class="auth-msg error hidden"></div>
<button class="btn btn-primary btn-full" onclick="Auth.submit()">Log In</button>
</div>
<div id="auth-register-panel" class="hidden">
<div class="form-group">
<label class="form-label">Email</label>
<input type="email" id="reg-email" class="form-input" placeholder="you@example.com"
onkeydown="if(event.key==='Enter') Auth.submitRegister()" />
</div>
<div class="form-group">
<label class="form-label">Password</label>
<input type="password" id="reg-password" class="form-input" placeholder="At least 8 characters"
onkeydown="if(event.key==='Enter') Auth.submitRegister()" />
</div>
<div id="reg-error" class="auth-msg error hidden"></div>
<button class="btn btn-primary btn-full" onclick="Auth.submitRegister()">Create Account</button>
</div>
</div>
</div>
<!-- APP SHELL -->
<div id="app-shell" class="hidden">
<aside class="sidebar">
<div class="sidebar-brand">
<span class="brand-icon">&#127807;</span>
@@ -28,7 +75,9 @@
</a>
</nav>
<div class="sidebar-footer">
<span id="sidebar-user" class="sidebar-user"></span>
<span id="sidebar-date"></span>
<button class="btn-logout" onclick="Auth.logout()">Log out</button>
</div>
</aside>
@@ -243,6 +292,8 @@
<div id="toast" class="toast hidden"></div>
</div><!-- /app-shell -->
<script src="/js/app.js"></script>
</body>
</html>

View File

@@ -1,13 +1,118 @@
/* Sproutly Frontend — Vanilla JS SPA */
const API = '/api';
// ===== Auth =====
const Auth = (() => {
function showTab(tab) {
document.getElementById('auth-login-panel').classList.toggle('hidden', tab !== 'login');
document.getElementById('auth-register-panel').classList.toggle('hidden', tab !== 'register');
document.getElementById('tab-login').classList.toggle('active', tab === 'login');
document.getElementById('tab-register').classList.toggle('active', tab === 'register');
document.getElementById('auth-error').classList.add('hidden');
document.getElementById('reg-error').classList.add('hidden');
}
async function submit() {
const email = document.getElementById('auth-email').value.trim();
const password = document.getElementById('auth-password').value;
const errEl = document.getElementById('auth-error');
errEl.classList.add('hidden');
try {
const res = await fetch(API + '/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Login failed' }));
errEl.textContent = err.detail || 'Login failed';
errEl.classList.remove('hidden');
return;
}
const data = await res.json();
localStorage.setItem('sproutly_token', data.access_token);
localStorage.setItem('sproutly_user', email);
showApp();
initApp();
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
}
}
async function submitRegister() {
const email = document.getElementById('reg-email').value.trim();
const password = document.getElementById('reg-password').value;
const errEl = document.getElementById('reg-error');
errEl.classList.add('hidden');
if (password.length < 8) {
errEl.textContent = 'Password must be at least 8 characters';
errEl.classList.remove('hidden');
return;
}
try {
const res = await fetch(API + '/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Registration failed' }));
errEl.textContent = err.detail || 'Registration failed';
errEl.classList.remove('hidden');
return;
}
// Auto-login after register
const loginRes = await fetch(API + '/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await loginRes.json();
localStorage.setItem('sproutly_token', data.access_token);
localStorage.setItem('sproutly_user', email);
showApp();
initApp();
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
}
}
function logout() {
localStorage.removeItem('sproutly_token');
localStorage.removeItem('sproutly_user');
document.getElementById('app-shell').classList.add('hidden');
document.getElementById('auth-screen').classList.remove('hidden');
showTab('login');
}
return { showTab, submit, submitRegister, logout };
})();
function showApp() {
document.getElementById('auth-screen').classList.add('hidden');
document.getElementById('app-shell').classList.remove('hidden');
const email = localStorage.getItem('sproutly_user') || '';
document.getElementById('sidebar-user').textContent = email;
}
// ===== API Helpers =====
async function apiFetch(path, opts = {}) {
const token = localStorage.getItem('sproutly_token');
const res = await fetch(API + path, {
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...(opts.headers || {}),
},
...opts,
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
if (res.status === 401) {
Auth.logout();
throw new Error('Session expired — please log in again');
}
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || res.statusText);
@@ -786,20 +891,31 @@ async function deleteBatch(id) {
}
// ===== Init =====
function init() {
// Sidebar date
function initApp() {
document.getElementById('sidebar-date').textContent =
new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
// Navigation via hash
function handleNav() {
const page = (location.hash.replace('#','') || 'dashboard');
navigate(['dashboard','varieties','garden','settings'].includes(page) ? page : 'dashboard');
}
window.removeEventListener('hashchange', handleNav);
window.addEventListener('hashchange', handleNav);
handleNav();
}
async function init() {
const token = localStorage.getItem('sproutly_token');
if (!token) return; // auth screen is visible by default
try {
await apiFetch('/auth/me');
showApp();
initApp();
} catch (e) {
// token invalid — auth screen stays visible
}
}
// ===== Public API =====
window.App = {
showAddVarietyModal, showEditVarietyModal, submitAddVariety, submitEditVariety, deleteVariety,
@@ -809,4 +925,11 @@ window.App = {
closeModal: (e) => closeModal(e),
};
window.Auth = {
showTab: (t) => Auth.showTab(t),
submit: () => Auth.submit(),
submitRegister: () => Auth.submitRegister(),
logout: () => Auth.logout(),
};
document.addEventListener('DOMContentLoaded', init);