Files
Meal-Planner/docs/superpowers/plans/2026-05-09-mvp-login.md
T

11 KiB

MVP Login + Family Seed Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task.

Goal: Make the UI actually usable — login page, auth gating, family profile seed, and vote-link base URL fix.

Architecture: Three changes: (1) React Login page + 401 interceptor in the frontend so unauthenticated users are redirected; (2) Python seed script to insert the first family_profile + family_member rows (no API endpoint exists to create them); (3) APP_BASE_URL added to .env.test so vote links in emails point to the right host.

Tech Stack: React 18 + TypeScript + Vite + Tailwind, axios, FastAPI, SQLAlchemy 2.0, Postgres 15.


File map

File Action
frontend/src/pages/Login.tsx Create — password form, calls auth.login(), redirects to /
frontend/src/App.tsx Modify — add /login route, logout button in nav
frontend/src/api/index.ts Modify — add 401 response interceptor → redirect to /login
scripts/seed_family.py Create — inserts family_profile + family_member rows via SQLAlchemy

Task 1: Login page + 401 interceptor + nav logout

Worktree: /home/peter/Projects/MealPlanner/.worktrees/feature-mvp-login

Files:

  • Create: frontend/src/pages/Login.tsx
  • Modify: frontend/src/App.tsx
  • Modify: frontend/src/api/index.ts

Step 1: Create frontend/src/pages/Login.tsx

import { useState } from 'react'
import { mealPlannerApi } from '../api'

export default function Login() {
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')
  const [loading, setLoading] = useState(false)

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setError('')
    setLoading(true)
    try {
      await mealPlannerApi.auth.login(password)
      window.location.href = '/'
    } catch {
      setError('Incorrect password. Try again.')
    } finally {
      setLoading(false)
    }
  }

  return (
    <div className="min-h-screen bg-gray-50 flex items-center justify-center">
      <div className="bg-white rounded-lg shadow p-8 w-full max-w-sm">
        <h1 className="text-2xl font-bold text-gray-900 mb-2 text-center">MealPlanner</h1>
        <p className="text-sm text-gray-500 text-center mb-6">Family login</p>
        <form onSubmit={handleSubmit} className="space-y-4">
          <div>
            <label className="block text-sm font-medium text-gray-700 mb-1">
              Password
            </label>
            <input
              type="password"
              value={password}
              onChange={e => setPassword(e.target.value)}
              className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
              autoFocus
              required
            />
          </div>
          {error && <p className="text-red-600 text-sm">{error}</p>}
          <button
            type="submit"
            disabled={loading}
            className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 font-medium"
          >
            {loading ? 'Signing in…' : 'Sign in'}
          </button>
        </form>
      </div>
    </div>
  )
}

Step 2: Modify frontend/src/App.tsx

Add import Login from './pages/Login' to the imports block.

Add a logout button to the nav (right side of the flex):

<div className="flex items-center">
  <button
    onClick={async () => {
      await mealPlannerApi.auth.logout().catch(() => {})
      window.location.href = '/login'
    }}
    className="text-sm text-gray-500 hover:text-gray-900"
  >
    Sign out
  </button>
</div>

Add the Login route inside <Routes>:

<Route path="/login" element={<Login />} />

The full updated App.tsx:

import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import Dashboard from './pages/Dashboard'
import MealDetail from './pages/MealDetail'
import Pantry from './pages/Pantry'
import ShoppingList from './pages/ShoppingList'
import Login from './pages/Login'
import { mealPlannerApi } from './api'

const queryClient = new QueryClient()

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <BrowserRouter>
        <div className="min-h-screen bg-gray-50">
          <nav className="bg-white shadow-sm">
            <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
              <div className="flex justify-between h-16">
                <div className="flex space-x-8">
                  <Link to="/" className="inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-900">
                    MealPlanner
                  </Link>
                  <Link to="/pantry" className="inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-500 hover:text-gray-900">
                    Pantry
                  </Link>
                  <Link to="/shopping-list" className="inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-500 hover:text-gray-900">
                    Shopping List
                  </Link>
                </div>
                <div className="flex items-center">
                  <button
                    onClick={async () => {
                      await mealPlannerApi.auth.logout().catch(() => {})
                      window.location.href = '/login'
                    }}
                    className="text-sm text-gray-500 hover:text-gray-900"
                  >
                    Sign out
                  </button>
                </div>
              </div>
            </div>
          </nav>
          <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
            <Routes>
              <Route path="/" element={<Dashboard />} />
              <Route path="/meals/:id" element={<MealDetail />} />
              <Route path="/pantry" element={<Pantry />} />
              <Route path="/shopping-list" element={<ShoppingList />} />
              <Route path="/login" element={<Login />} />
            </Routes>
          </main>
        </div>
      </BrowserRouter>
    </QueryClientProvider>
  )
}

export default App

Step 3: Add 401 interceptor to frontend/src/api/index.ts

After the api axios instance is created (after the const api = axios.create(...) block), add:

api.interceptors.response.use(
  response => response,
  error => {
    if (error.response?.status === 401 && window.location.pathname !== '/login') {
      window.location.href = '/login'
    }
    return Promise.reject(error)
  }
)

Step 4: Build and verify

cd /home/peter/Projects/MealPlanner/.worktrees/feature-mvp-login
npm --prefix frontend ci
npm --prefix frontend run build
# Expected: no TypeScript errors, build succeeds

Step 5: Commit

cd /home/peter/Projects/MealPlanner/.worktrees/feature-mvp-login
git add frontend/src/pages/Login.tsx \
        frontend/src/App.tsx \
        frontend/src/api/index.ts
git commit -m "feat: login page, 401 interceptor, nav sign-out"

Task 2: Family profile seed script

File: Create scripts/seed_family.py

This script inserts a family_profile row and family_member rows directly via SQLAlchemy. Run once after the containers are up.

#!/usr/bin/env python3
"""
One-time seed: creates the Woolery family profile + members.

Run inside the backend container:
    docker compose --env-file .env.test exec backend python /app/seed_family.py

Or copy and run:
    docker cp scripts/seed_family.py mealplanner-backend-1:/app/seed_family.py
    docker compose --env-file .env.test exec backend python /app/seed_family.py
"""
import os
import sys

# ── Edit these before running ────────────────────────────────────────────────
FAMILY_CALORIE_TARGET = 500   # per serving, per meal
MEMBERS = [
    {"name": "Peter",  "email": "peter@research.bike", "role": "admin",  "likes_mushrooms": True},
    {"name": "Wife",   "email": "",                    "role": "member", "likes_mushrooms": False},
    {"name": "Kid 1",  "email": "",                    "role": "member", "likes_mushrooms": False},
    {"name": "Kid 2",  "email": "",                    "role": "member", "likes_mushrooms": False},
]
# ─────────────────────────────────────────────────────────────────────────────

sys.path.insert(0, "/app")
os.environ.setdefault("DATABASE_URL", os.environ["DATABASE_URL"])

from app.database import SessionLocal
from app.models import FamilyProfile, FamilyMember, FamilyMemberRole

db = SessionLocal()
try:
    existing = db.query(FamilyProfile).first()
    if existing:
        print(f"Family profile already exists (id={existing.id}). Nothing to do.")
        sys.exit(0)

    profile = FamilyProfile(calorie_target=FAMILY_CALORIE_TARGET)
    db.add(profile)
    db.flush()

    for m in MEMBERS:
        if not m["email"]:
            print(f"  Skipping {m['name']} — no email set")
            continue
        member = FamilyMember(
            family_profile_id=profile.id,
            name=m["name"],
            email=m["email"],
            role=FamilyMemberRole[m["role"].upper()],
            likes_mushrooms=m["likes_mushrooms"],
        )
        db.add(member)
        print(f"  Added member: {m['name']} <{m['email']}>")

    db.commit()
    print(f"Done. Family profile id={profile.id}")
finally:
    db.close()

Step: Commit

cd /home/peter/Projects/MealPlanner/.worktrees/feature-mvp-login
git add scripts/seed_family.py
git commit -m "feat: one-time family profile seed script"

Post-merge steps (run by operator after merge)

1. Add APP_BASE_URL to .env.test

Edit .env.test on the host and add:

APP_BASE_URL=http://100.108.224.12:8081

This makes vote links in Friday emails point to the right host.

2. Rebuild frontend container and restart

docker compose --env-file .env.test build frontend
docker compose --env-file .env.test up -d

3. Run the seed script

# Edit scripts/seed_family.py to fill in real names/emails first, then:
docker cp scripts/seed_family.py mealplanner-backend-1:/app/seed_family.py
docker compose --env-file .env.test exec backend python /app/seed_family.py

4. Verify login works

Open http://100.108.224.12:8081 — should redirect to /login. Enter SESSION_PASSWORD. Should land on Dashboard showing "No meal plan generated yet."