Public Access
docs: MVP login implementation plan
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
# 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`
|
||||
|
||||
```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):
|
||||
|
||||
```tsx
|
||||
<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>`:
|
||||
|
||||
```tsx
|
||||
<Route path="/login" element={<Login />} />
|
||||
```
|
||||
|
||||
The full updated `App.tsx`:
|
||||
|
||||
```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:
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
```python
|
||||
#!/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
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.test build frontend
|
||||
docker compose --env-file .env.test up -d
|
||||
```
|
||||
|
||||
### 3. Run the seed script
|
||||
|
||||
```bash
|
||||
# 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."
|
||||
Reference in New Issue
Block a user