diff --git a/.env.example b/.env.example index fc3c994..fd1a72d 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ MYSQL_ROOT_PASSWORD=sproutly_root_secret MYSQL_USER=sproutly MYSQL_PASSWORD=sproutly_secret +SECRET_KEY=your-secret-key-change-this diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..06c1685 --- /dev/null +++ b/backend/auth.py @@ -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 diff --git a/backend/main.py b/backend/main.py index 3d9c9d2..4eef104 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/models.py b/backend/models.py index 487445a..06508b0 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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") diff --git a/backend/requirements.txt b/backend/requirements.txt index c98b2f8..8cdebc2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/routers/auth.py b/backend/routers/auth.py new file mode 100644 index 0000000..2291968 --- /dev/null +++ b/backend/routers/auth.py @@ -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 diff --git a/backend/routers/batches.py b/backend/routers/batches.py index 74e146e..5577a5c 100644 --- a/backend/routers/batches.py +++ b/backend/routers/batches.py @@ -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) diff --git a/backend/routers/dashboard.py b/backend/routers/dashboard.py index 9b743fb..cd8829e 100644 --- a/backend/routers/dashboard.py +++ b/backend/routers/dashboard.py @@ -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), diff --git a/backend/routers/notifications.py b/backend/routers/notifications.py index f4d6051..4ecbedd 100644 --- a/backend/routers/notifications.py +++ b/backend/routers/notifications.py @@ -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] diff --git a/backend/routers/settings.py b/backend/routers/settings.py index 6f51e5c..e391257 100644 --- a/backend/routers/settings.py +++ b/backend/routers/settings.py @@ -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() diff --git a/backend/routers/varieties.py b/backend/routers/varieties.py index 950f1f6..837398c 100644 --- a/backend/routers/varieties.py +++ b/backend/routers/varieties.py @@ -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) diff --git a/backend/schemas.py b/backend/schemas.py index cde2a6c..61d96b6 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -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): diff --git a/docker-compose.yml b/docker-compose.yml index 5a578f0..5df2482 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/mysql/init.sql b/mysql/init.sql index 29a80ee..20315f4 100644 --- a/mysql/init.sql +++ b/mysql/init.sql @@ -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.'); diff --git a/nginx/html/css/style.css b/nginx/html/css/style.css index 49b7b48..8c70bf1 100644 --- a/nginx/html/css/style.css +++ b/nginx/html/css/style.css @@ -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); } diff --git a/nginx/html/index.html b/nginx/html/index.html index c2533c1..5ef50ac 100644 --- a/nginx/html/index.html +++ b/nginx/html/index.html @@ -8,6 +8,53 @@ + + +
+
+
+ 🌿 + Sproutly +
+
+ + +
+ +
+
+ + +
+
+ + +
+ + +
+ + +
+
+ + + + diff --git a/nginx/html/js/app.js b/nginx/html/js/app.js index fea7d89..29bf7a5 100644 --- a/nginx/html/js/app.js +++ b/nginx/html/js/app.js @@ -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);