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

View File

@@ -35,10 +35,25 @@ class BatchStatus(str, enum.Enum):
failed = "failed" 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): class Variety(Base):
__tablename__ = "varieties" __tablename__ = "varieties"
id = Column(Integer, primary_key=True, index=True) 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) name = Column(String(100), nullable=False)
variety_name = Column(String(100)) variety_name = Column(String(100))
category = Column(Enum(Category), default=Category.vegetable) category = Column(Enum(Category), default=Category.vegetable)
@@ -54,6 +69,7 @@ class Variety(Base):
notes = Column(Text) notes = Column(Text)
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
user = relationship("User", back_populates="varieties")
batches = relationship("Batch", back_populates="variety", cascade="all, delete-orphan") batches = relationship("Batch", back_populates="variety", cascade="all, delete-orphan")
@@ -61,6 +77,7 @@ class Batch(Base):
__tablename__ = "batches" __tablename__ = "batches"
id = Column(Integer, primary_key=True, index=True) 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) variety_id = Column(Integer, ForeignKey("varieties.id"), nullable=False)
label = Column(String(100)) label = Column(String(100))
quantity = Column(Integer, default=1) quantity = Column(Integer, default=1)
@@ -72,13 +89,15 @@ class Batch(Base):
notes = Column(Text) notes = Column(Text)
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
user = relationship("User", back_populates="batches")
variety = relationship("Variety", back_populates="batches") variety = relationship("Variety", back_populates="batches")
class Settings(Base): class Settings(Base):
__tablename__ = "settings" __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) last_frost_date = Column(Date)
first_frost_fall_date = Column(Date) first_frost_fall_date = Column(Date)
ntfy_topic = Column(String(200)) ntfy_topic = Column(String(200))
@@ -90,12 +109,17 @@ class Settings(Base):
ntfy_password = Column(String(200)) ntfy_password = Column(String(200))
ntfy_api_key = Column(String(200)) ntfy_api_key = Column(String(200))
user = relationship("User", back_populates="settings")
class NotificationLog(Base): class NotificationLog(Base):
__tablename__ = "notification_log" __tablename__ = "notification_log"
id = Column(Integer, primary_key=True, index=True) 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()) sent_at = Column(DateTime, server_default=func.now())
message = Column(Text) message = Column(Text)
status = Column(String(20)) status = Column(String(20))
error = Column(Text) 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 httpx==0.27.0
apscheduler==3.10.4 apscheduler==3.10.4
pydantic==2.7.1 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 fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from typing import List from typing import List
from auth import get_current_user
from database import get_db from database import get_db
from models import Batch, Variety from models import Batch, User, Variety
from schemas import BatchCreate, BatchUpdate, BatchOut from schemas import BatchCreate, BatchOut, BatchUpdate
router = APIRouter(prefix="/batches", tags=["batches"]) router = APIRouter(prefix="/batches", tags=["batches"])
@router.get("/", response_model=List[BatchOut]) @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 ( return (
db.query(Batch) db.query(Batch)
.options(joinedload(Batch.variety)) .options(joinedload(Batch.variety))
.filter(Batch.user_id == current_user.id)
.order_by(Batch.sow_date.desc().nullslast(), Batch.created_at.desc()) .order_by(Batch.sow_date.desc().nullslast(), Batch.created_at.desc())
.all() .all()
) )
@router.get("/{batch_id}", response_model=BatchOut) @router.get("/{batch_id}", response_model=BatchOut)
def get_batch(batch_id: int, db: Session = Depends(get_db)): 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).first() b = db.query(Batch).options(joinedload(Batch.variety)).filter(Batch.id == batch_id, Batch.user_id == current_user.id).first()
if not b: if not b:
raise HTTPException(status_code=404, detail="Batch not found") raise HTTPException(status_code=404, detail="Batch not found")
return b return b
@router.post("/", response_model=BatchOut, status_code=201) @router.post("/", response_model=BatchOut, status_code=201)
def create_batch(data: BatchCreate, db: Session = Depends(get_db)): 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).first(): 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") 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.add(b)
db.commit() db.commit()
db.refresh(b) db.refresh(b)
@@ -38,8 +41,8 @@ def create_batch(data: BatchCreate, db: Session = Depends(get_db)):
@router.put("/{batch_id}", response_model=BatchOut) @router.put("/{batch_id}", response_model=BatchOut)
def update_batch(batch_id: int, data: BatchUpdate, db: Session = Depends(get_db)): 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).first() b = db.query(Batch).filter(Batch.id == batch_id, Batch.user_id == current_user.id).first()
if not b: if not b:
raise HTTPException(status_code=404, detail="Batch not found") raise HTTPException(status_code=404, detail="Batch not found")
for field, value in data.model_dump().items(): 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) @router.delete("/{batch_id}", status_code=204)
def delete_batch(batch_id: int, db: Session = Depends(get_db)): 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).first() b = db.query(Batch).filter(Batch.id == batch_id, Batch.user_id == current_user.id).first()
if not b: if not b:
raise HTTPException(status_code=404, detail="Batch not found") raise HTTPException(status_code=404, detail="Batch not found")
db.delete(b) db.delete(b)

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,35 @@
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime from datetime import date, datetime
from typing import Optional, List from typing import Optional, List
from pydantic import BaseModel from pydantic import BaseModel, EmailStr
from models import Category, SunRequirement, WaterNeeds, BatchStatus 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 --- # --- Variety ---
class VarietyBase(BaseModel): class VarietyBase(BaseModel):

View File

@@ -32,6 +32,7 @@ services:
DB_NAME: sproutly DB_NAME: sproutly
DB_USER: ${MYSQL_USER} DB_USER: ${MYSQL_USER}
DB_PASSWORD: ${MYSQL_PASSWORD} DB_PASSWORD: ${MYSQL_PASSWORD}
SECRET_KEY: ${SECRET_KEY}
depends_on: depends_on:
mysql: mysql:
condition: service_healthy 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 ( CREATE TABLE IF NOT EXISTS varieties (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
variety_name VARCHAR(100), variety_name VARCHAR(100),
category ENUM('vegetable', 'herb', 'flower', 'fruit') DEFAULT 'vegetable', 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', water_needs ENUM('low', 'medium', 'high') DEFAULT 'medium',
color VARCHAR(7) DEFAULT '#52b788', color VARCHAR(7) DEFAULT '#52b788',
notes TEXT, 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 ( CREATE TABLE IF NOT EXISTS batches (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
variety_id INT NOT NULL, variety_id INT NOT NULL,
label VARCHAR(100), label VARCHAR(100),
quantity INT DEFAULT 1, 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', status ENUM('planned','germinating','seedling','potted_up','hardening','garden','harvested','failed') DEFAULT 'planned',
notes TEXT, notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 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 FOREIGN KEY (variety_id) REFERENCES varieties(id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS settings ( 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, last_frost_date DATE,
first_frost_fall_date DATE, first_frost_fall_date DATE,
ntfy_topic VARCHAR(200), ntfy_topic VARCHAR(200),
@@ -44,31 +56,16 @@ CREATE TABLE IF NOT EXISTS settings (
location_name VARCHAR(100), location_name VARCHAR(100),
ntfy_username VARCHAR(200), ntfy_username VARCHAR(200),
ntfy_password 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 ( CREATE TABLE IF NOT EXISTS notification_log (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT,
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
message TEXT, message TEXT,
status VARCHAR(20), 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 { 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 { 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-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" /> <link rel="stylesheet" href="/css/style.css" />
</head> </head>
<body> <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"> <aside class="sidebar">
<div class="sidebar-brand"> <div class="sidebar-brand">
<span class="brand-icon">&#127807;</span> <span class="brand-icon">&#127807;</span>
@@ -28,7 +75,9 @@
</a> </a>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<span id="sidebar-user" class="sidebar-user"></span>
<span id="sidebar-date"></span> <span id="sidebar-date"></span>
<button class="btn-logout" onclick="Auth.logout()">Log out</button>
</div> </div>
</aside> </aside>
@@ -243,6 +292,8 @@
<div id="toast" class="toast hidden"></div> <div id="toast" class="toast hidden"></div>
</div><!-- /app-shell -->
<script src="/js/app.js"></script> <script src="/js/app.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,13 +1,118 @@
/* Sproutly Frontend — Vanilla JS SPA */ /* Sproutly Frontend — Vanilla JS SPA */
const API = '/api'; 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 ===== // ===== API Helpers =====
async function apiFetch(path, opts = {}) { async function apiFetch(path, opts = {}) {
const token = localStorage.getItem('sproutly_token');
const res = await fetch(API + path, { 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, ...opts,
body: opts.body ? JSON.stringify(opts.body) : undefined, 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) { if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText })); const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || res.statusText); throw new Error(err.detail || res.statusText);
@@ -786,20 +891,31 @@ async function deleteBatch(id) {
} }
// ===== Init ===== // ===== Init =====
function init() { function initApp() {
// Sidebar date
document.getElementById('sidebar-date').textContent = document.getElementById('sidebar-date').textContent =
new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
// Navigation via hash
function handleNav() { function handleNav() {
const page = (location.hash.replace('#','') || 'dashboard'); const page = (location.hash.replace('#','') || 'dashboard');
navigate(['dashboard','varieties','garden','settings'].includes(page) ? page : 'dashboard'); navigate(['dashboard','varieties','garden','settings'].includes(page) ? page : 'dashboard');
} }
window.removeEventListener('hashchange', handleNav);
window.addEventListener('hashchange', handleNav); window.addEventListener('hashchange', handleNav);
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 ===== // ===== Public API =====
window.App = { window.App = {
showAddVarietyModal, showEditVarietyModal, submitAddVariety, submitEditVariety, deleteVariety, showAddVarietyModal, showEditVarietyModal, submitAddVariety, submitEditVariety, deleteVariety,
@@ -809,4 +925,11 @@ window.App = {
closeModal: (e) => closeModal(e), closeModal: (e) => closeModal(e),
}; };
window.Auth = {
showTab: (t) => Auth.showTab(t),
submit: () => Auth.submit(),
submitRegister: () => Auth.submitRegister(),
logout: () => Auth.logout(),
};
document.addEventListener('DOMContentLoaded', init); document.addEventListener('DOMContentLoaded', init);