chore: Added User/InviteCode models, Alembic migration 016, auth utilit…

- "backend/models.py"
- "backend/auth.py"
- "backend/schemas.py"
- "backend/requirements.txt"
- "alembic/versions/016_add_users_and_invite_codes.py"

GSD-Task: S02/T01
This commit is contained in:
jlightner 2026-04-03 21:47:01 +00:00
parent d77b749cfb
commit a06ea946b1
17 changed files with 1164 additions and 3 deletions

View file

@ -4,7 +4,7 @@
## Current State
Eighteen milestones complete. Phase 1 (build) is done. M018 completed the Phase 1→Phase 2 transition with a comprehensive site audit and Forgejo wiki bootstrap. The system is deployed and running on ub01 at `http://ub01:8096`. Forgejo knowledgebase wiki live at `https://git.xpltd.co/xpltdco/chrysopedia/wiki/`.
Eighteen milestones complete. Phase 1 (build) is done. M018 completed the Phase 1→Phase 2 transition with a comprehensive site audit and Forgejo wiki bootstrap. M019 is in progress — LightRAG deployed as graph-enhanced retrieval service. The system is deployed and running on ub01 at `http://ub01:8096`. Forgejo knowledgebase wiki live at `https://git.xpltd.co/xpltdco/chrysopedia/wiki/`.
### What's Built
@ -56,6 +56,8 @@ Eighteen milestones complete. Phase 1 (build) is done. M018 completed the Phase
- **Pipeline admin UI fixes** — Collapse toggle styling, mobile card layout, stage chevrons, filter right-alignment, creator dropdown visibility.
- **Creator profile page** — Hero section (96px avatar, bio, genre pills), social link icons (9 platforms), stats dashboard (technique/video/moment counts), featured technique card with gradient border, enriched technique grid (summary, tags, moment count), inline admin editing (bio + social links), and 480px mobile responsive overrides.
- **LightRAG graph-enhanced retrieval** — Running as chrysopedia-lightrag service on port 9621. Uses DGX Sparks for LLM (entity extraction, summarization), Ollama nomic-embed-text for embeddings, Qdrant for vector storage, NetworkX for graph storage. 12 music production entity types configured. Exposed via REST API at /documents/text (ingest) and /query (retrieval with local/global/mix/hybrid modes).
- **Site Audit Report** — 467-line comprehensive reference document mapping all 12 routes, 41 API endpoints, 13 data models, CSS architecture (77 custom properties), and 8 Phase 2 integration risks. Lives at `.gsd/milestones/M018/slices/S01/SITE-AUDIT-REPORT.md`.
- **Forgejo knowledgebase wiki** — 10-page architecture documentation at `https://git.xpltd.co/xpltdco/chrysopedia/wiki/` covering Architecture, Data Model, API Surface, Frontend, Pipeline, Deployment, Development Guide, and Decisions.

View file

@ -6,7 +6,7 @@ Stand up the two foundational systems for Phase 2: creator authentication with c
## Slice Overview
| ID | Slice | Risk | Depends | Done | After this |
|----|-------|------|---------|------|------------|
| S01 | [B] LightRAG Deployment + Docker Integration | high | — | | LightRAG service running in Docker, connected to Qdrant, entity extraction prompts producing music production entities from test input |
| S01 | [B] LightRAG Deployment + Docker Integration | high | — | | LightRAG service running in Docker, connected to Qdrant, entity extraction prompts producing music production entities from test input |
| S02 | [A] Creator Authentication + Dashboard Shell | high | — | ⬜ | Creator registers with invite code, logs in, sees dashboard shell with nav and profile settings |
| S03 | [A] Consent Data Model + API Endpoints | medium | — | ⬜ | API accepts per-video consent toggles with versioned audit trail |
| S04 | [B] Reindex Existing Corpus Through LightRAG | medium | S01 | ⬜ | All existing content indexed in LightRAG with entity/relationship graph alongside current search |

View file

@ -0,0 +1,94 @@
---
id: S01
parent: M019
milestone: M019
provides:
- LightRAG service running on ub01 port 9621
- Entity extraction API at POST /documents/text
- Query API at POST /query with mix/local/global/hybrid modes
- Music production entity types configured (Creator, Technique, Plugin, Synthesizer, Effect, Genre, DAW, SamplePack, SignalChain, Concept, Frequency, SoundDesignElement)
requires:
[]
affects:
- S04
key_files:
- docker-compose.yml
- .env.lightrag
- .gsd/KNOWLEDGE.md
key_decisions:
- Used Python urllib for container healthcheck since LightRAG image lacks curl
- Used 127.0.0.1 instead of localhost in container healthcheck for reliable resolution
- Set env_file required:true since LightRAG cannot start without config
- Used NetworkX graph storage + Qdrant vector storage for initial deployment
- Used DGX Sparks as LLM backend with Ollama for embeddings only
patterns_established:
- Python urllib healthcheck pattern for Docker images lacking curl
- LightRAG service integration pattern: depends_on Qdrant + Ollama healthy, env_file for config, bind mount for persistent data
observability_surfaces:
- GET /health on port 9621 returns full config and status JSON
- Docker healthcheck via Python urllib reports healthy/unhealthy
- Container logs show entity extraction details and LLM/embedding request outcomes
drill_down_paths:
- .gsd/milestones/M019/slices/S01/tasks/T01-SUMMARY.md
- .gsd/milestones/M019/slices/S01/tasks/T02-SUMMARY.md
duration: ""
verification_result: passed
completed_at: 2026-04-03T21:36:50.606Z
blocker_discovered: false
---
# S01: [B] LightRAG Deployment + Docker Integration
**LightRAG service deployed in Docker Compose on ub01 with Qdrant vector storage, Ollama embeddings, DGX Sparks LLM, and verified entity extraction producing music production entities.**
## What Happened
Two tasks: T01 added the chrysopedia-lightrag service definition to docker-compose.yml and created .env.lightrag with all configuration. T02 deployed on ub01, discovered the LightRAG Docker image lacks curl (healthcheck switched to Python urllib with 127.0.0.1), set the real API key, and verified end-to-end: entity extraction from music production text (10 entities, 9 relations including Creator, Plugin, Technique, Effect types) and query returning accurate results mentioning Serum, OTT, and Valhalla Room.
The service uses ghcr.io/hkuds/lightrag:latest on port 9621, depends on healthy Qdrant and Ollama services, stores data at /vmPool/r/services/chrysopedia_lightrag, and uses NetworkX for graph storage with QdrantVectorDBStorage for vectors. LLM requests go through DGX Sparks (fyn-llm-agent-chat model via OpenAI-compatible API), embeddings through the existing Ollama nomic-embed-text model.
## Verification
All slice-level checks pass:
- `ssh ub01 'curl -sf http://localhost:9621/health'` → 200 with full config JSON
- `ssh ub01 'docker ps --filter name=chrysopedia-lightrag'` → "Up N minutes (healthy)"
- `docker compose config | grep chrysopedia-lightrag` → PASS
- `docker compose config` exits 0
- `git check-ignore .env.lightrag` → IGNORED (via .env.* wildcard)
- All 10 existing Chrysopedia services remain healthy
- Entity extraction POST returns success with 10 entities + 9 relations
- Query POST returns response with correct music production entities (Serum, OTT, Valhalla Room)
## Requirements Advanced
- R010 — LightRAG service added to Docker Compose stack, all existing services remain healthy
## Requirements Validated
None.
## New Requirements Surfaced
None.
## Requirements Invalidated or Re-scoped
None.
## Deviations
Healthcheck changed from curl to Python urllib because LightRAG image lacks curl. Additional .env.lightrag settings added beyond plan (SUMMARY_LANGUAGE, MAX_ASYNC, RERANK_BINDING, OLLAMA_EMBEDDING_NUM_CTX) based on upstream env.example review. Files deployed via scp rather than git pull.
## Known Limitations
Qdrant client v1.15.1 in LightRAG image warns about server v1.13.2 mismatch — functions correctly but should be monitored. ub01 repo has uncommitted changes (files deployed via scp). NetworkX graph storage is file-based — adequate for initial deployment but may need upgrade for large corpus.
## Follow-ups
S04 (Reindex Existing Corpus Through LightRAG) depends on this slice. Consider upgrading Qdrant server to match client version. Consider switching to Neo4j graph storage if NetworkX becomes a bottleneck at scale.
## Files Created/Modified
- `docker-compose.yml` — Added chrysopedia-lightrag service with healthcheck, depends_on, volumes, port mapping
- `.env.lightrag` — Created with LLM, embedding, vector storage, graph storage, and entity type configuration
- `.gsd/KNOWLEDGE.md` — Added LightRAG healthcheck knowledge entry

View file

@ -0,0 +1,66 @@
# S01: [B] LightRAG Deployment + Docker Integration — UAT
**Milestone:** M019
**Written:** 2026-04-03T21:36:50.606Z
# S01 UAT: LightRAG Deployment + Docker Integration
## Preconditions
- SSH access to ub01
- Docker Compose stack running on ub01
## Test Cases
### TC1: LightRAG Health Endpoint
1. Run `ssh ub01 'curl -sf http://localhost:9621/health'`
2. **Expected:** JSON response with `"status":"healthy"`, `"llm_binding":"openai"`, `"embedding_binding":"ollama"`, `"vector_storage":"QdrantVectorDBStorage"`
### TC2: Container Health Status
1. Run `ssh ub01 'docker ps --filter name=chrysopedia-lightrag --format "{{.Status}}"'`
2. **Expected:** Output contains "(healthy)"
### TC3: Docker Compose Config Valid
1. Run `docker compose config` from repo root
2. **Expected:** Exits 0, output contains `chrysopedia-lightrag` service definition
### TC4: Env File Git Ignored
1. Run `git check-ignore .env.lightrag`
2. **Expected:** Exits 0, prints `.env.lightrag`
### TC5: Entity Extraction
1. Run:
```
ssh ub01 'curl -sf -X POST http://localhost:9621/documents/text \
-H "Content-Type: application/json" \
-d "{\"text\": \"COPYCATT uses Serum for bass sound design with sidechain compression. The signal chain runs through OTT then Valhalla Room reverb.\", \"file_source\": \"uat_test.txt\"}"'
```
2. **Expected:** HTTP 200 response with status indicating success
### TC6: Query Returns Entities
1. Run:
```
ssh ub01 'curl -sf -X POST http://localhost:9621/query \
-H "Content-Type: application/json" \
-d "{\"query\": \"What plugins does COPYCATT use?\", \"mode\": \"mix\"}"'
```
2. **Expected:** Response contains references to Serum, OTT, and/or Valhalla Room
### TC7: Existing Services Unaffected
1. Run `ssh ub01 'docker ps --filter name=chrysopedia --format "{{.Names}} {{.Status}}"'`
2. **Expected:** All 10 existing services (db, redis, qdrant, ollama, api, worker, watcher, web-8096, mcp, lightrag) show "healthy" or "Up"
### TC8: Service Dependencies
1. Run `docker compose config` and inspect chrysopedia-lightrag depends_on
2. **Expected:** Depends on chrysopedia-qdrant (service_healthy) and chrysopedia-ollama (service_healthy)
## Edge Cases
### EC1: Container Restart Recovery
1. Run `ssh ub01 'docker restart chrysopedia-lightrag'`
2. Wait 45 seconds for start_period
3. Run `ssh ub01 'docker inspect --format="{{.State.Health.Status}}" chrysopedia-lightrag'`
4. **Expected:** "healthy" within 60 seconds
### EC2: Healthcheck Without Curl
1. Run `ssh ub01 'docker exec chrysopedia-lightrag which curl'`
2. **Expected:** Exits non-zero (curl not found — confirms Python urllib healthcheck is necessary)

View file

@ -0,0 +1,24 @@
{
"schemaVersion": 1,
"taskId": "T02",
"unitId": "M019/S01/T02",
"timestamp": 1775252110245,
"passed": false,
"discoverySource": "task-plan",
"checks": [
{
"command": "ssh ub01 'curl -sf http://localhost:9621/health",
"exitCode": 2,
"durationMs": 7,
"verdict": "fail"
},
{
"command": "echo CONTAINER_OK'",
"exitCode": 2,
"durationMs": 6,
"verdict": "fail"
}
],
"retryAttempt": 1,
"maxRetries": 2
}

View file

@ -1,6 +1,196 @@
# S02: [A] Creator Authentication + Dashboard Shell
**Goal:** Implement invite-only creator auth with JWT sessions, role system (creator/admin/user), and empty dashboard shell
**Goal:** Creator registers with invite code, logs in with JWT, sees dashboard shell with nav and profile settings. Existing public routes remain unaffected.
**Demo:** After this: Creator registers with invite code, logs in, sees dashboard shell with nav and profile settings
## Tasks
- [x] **T01: Added User/InviteCode models, Alembic migration 016, auth utilities (bcrypt hashing, JWT, FastAPI dependencies), and Pydantic auth schemas** — Create the User and InviteCode SQLAlchemy models in backend/models.py, generate Alembic migration 016, add PyJWT + passlib[bcrypt] to requirements.txt, and implement the auth utility module (password hashing, JWT encode/decode, FastAPI get_current_user dependency).
## Steps
1. Add `PyJWT>=2.8,<3.0` and `passlib[bcrypt]>=1.7,<2.0` to `backend/requirements.txt` and run `pip install -r backend/requirements.txt`.
2. In `backend/models.py`, add a `UserRole` enum (creator, admin) and the `User` model: id (UUID PK), email (unique, not null), hashed_password, display_name, role (UserRole, default creator), creator_id (FK to creators.id, nullable), is_active (default true), created_at, updated_at. Add the `InviteCode` model: id (UUID PK), code (unique, not null), uses_remaining (default 1), created_by (FK to users.id, nullable), expires_at (nullable), created_at.
3. In `backend/schemas.py`, add Pydantic schemas: `RegisterRequest` (email, password, display_name, invite_code, optional creator_slug), `LoginRequest` (email, password), `TokenResponse` (access_token, token_type), `UserResponse` (id, email, display_name, role, creator_id, is_active, created_at), `UpdateProfileRequest` (optional display_name, optional current_password, optional new_password).
4. Create `backend/auth.py` with: `hash_password(plain) -> str` using passlib bcrypt, `verify_password(plain, hashed) -> bool`, `create_access_token(user_id, role) -> str` using PyJWT HS256 with `settings.app_secret_key` and configurable expiry, `decode_access_token(token) -> dict` with expiry validation, `get_current_user` FastAPI dependency using `OAuth2PasswordBearer(tokenUrl='/api/v1/auth/login')` that decodes token and loads User from DB, `require_role(role)` sub-dependency.
5. Generate Alembic migration: `cd backend && alembic revision --autogenerate -m 'add users and invite_codes'`. Review the generated migration to ensure it creates both tables with correct constraints.
## Must-Haves
- [ ] User model with email, hashed_password, display_name, role enum, creator_id FK, is_active
- [ ] InviteCode model with code, uses_remaining, created_by FK, expires_at
- [ ] Alembic migration 016 creates both tables
- [ ] auth.py exports hash_password, verify_password, create_access_token, decode_access_token, get_current_user, require_role
- [ ] Pydantic request/response schemas for register, login, token, user profile, update profile
- [ ] PyJWT and passlib[bcrypt] in requirements.txt
## Failure Modes
| Dependency | On error | On timeout | On malformed response |
|------------|----------|-----------|----------------------|
| PostgreSQL (migration) | Alembic prints traceback — fix SQL/model and retry | N/A | N/A |
| passlib bcrypt | ImportError at import time — verify pip install | N/A | N/A |
## Negative Tests
- **Malformed inputs**: decode_access_token with expired token raises, with garbage string raises, with missing claims raises
- **Boundary conditions**: InviteCode with uses_remaining=0 should not be usable
## Verification
- `cd backend && python -c "from models import User, InviteCode, UserRole; print('Models OK')"` exits 0
- `cd backend && python -c "from auth import hash_password, verify_password, create_access_token, decode_access_token; print('Auth OK')"` exits 0
- `cd backend && python -c "from schemas import RegisterRequest, LoginRequest, TokenResponse, UserResponse, UpdateProfileRequest; print('Schemas OK')"` exits 0
- Alembic migration file exists in `alembic/versions/` with 'users' and 'invite_codes' in the upgrade function
- Estimate: 1h
- Files: backend/models.py, backend/schemas.py, backend/auth.py, backend/config.py, backend/requirements.txt, alembic/versions/016_add_users_and_invite_codes.py
- Verify: cd backend && python -c "from models import User, InviteCode, UserRole; print('OK')" && python -c "from auth import hash_password, verify_password, create_access_token, decode_access_token; print('OK')" && python -c "from schemas import RegisterRequest, LoginRequest, TokenResponse, UserResponse; print('OK')"
- [ ] **T02: Implement auth API router with registration, login, profile endpoints and integration tests** — Create the auth API router with all endpoints (register, login, me, update profile), register it in main.py, seed initial invite codes, and write comprehensive integration tests.
## Steps
1. Create `backend/routers/auth.py` with APIRouter prefix `/api/v1/auth`:
- `POST /register`: Validate invite code (exists, uses_remaining > 0, not expired), check email uniqueness, hash password, create User, decrement invite code uses_remaining, optionally link creator_id by slug. Return 201 + UserResponse. Errors: 403 invalid/expired code, 409 duplicate email.
- `POST /login`: Find user by email, verify password, return TokenResponse with JWT. Errors: 401 invalid credentials.
- `GET /me`: Requires auth (get_current_user dependency). Return UserResponse.
- `PUT /me`: Requires auth. Accept UpdateProfileRequest — update display_name if provided, verify current_password and set new_password if provided. Return updated UserResponse.
2. In `backend/main.py`, import and include the auth router.
3. Create a seed invite code mechanism: add a function `seed_invite_codes()` in `backend/auth.py` that creates a default invite code if none exist (code='CHRYSOPEDIA-ALPHA-2026', uses_remaining=100). Call it from a startup event or CLI command. For the test suite, create codes in fixtures.
4. Update `backend/tests/conftest.py`: import User, InviteCode, UserRole into the model imports so they're included in `Base.metadata.create_all`. Add an `invite_code` fixture that creates a test invite code. Add a `registered_user` fixture that creates a user via the register endpoint. Add an `auth_headers` fixture that logs in and returns `{"Authorization": "Bearer <token>"}`.
5. Create `backend/tests/test_auth.py` with integration tests:
- Register with valid invite code → 201 + user created in DB
- Register with invalid invite code → 403
- Register with expired invite code → 403
- Register duplicate email → 409
- Login with correct credentials → 200 + JWT token
- Login with wrong password → 401
- Login with nonexistent email → 401
- GET /me with valid token → 200 + user profile
- GET /me with expired/invalid token → 401
- GET /me without token → 401
- PUT /me updates display_name → 200 + updated profile
- PUT /me changes password → 200, can login with new password
- Existing public endpoints still work without auth (GET /api/v1/techniques, GET /api/v1/creators)
## Must-Haves
- [ ] POST /register with invite code validation, email uniqueness, password hashing
- [ ] POST /login returning JWT
- [ ] GET /me and PUT /me with auth dependency
- [ ] Auth router registered in main.py
- [ ] All 13 integration tests pass
- [ ] Existing public endpoints verified unaffected
## Failure Modes
| Dependency | On error | On timeout | On malformed response |
|------------|----------|-----------|----------------------|
| PostgreSQL (test DB) | Test fails with connection error — check TEST_DATABASE_URL | N/A | N/A |
| bcrypt hashing | Slow on first call (key derivation) — acceptable for test suite | N/A | N/A |
## Negative Tests
- **Malformed inputs**: Register with missing fields → 422, Register with empty password → 422, Login with empty body → 422
- **Error paths**: Invalid invite code → 403, expired invite code → 403, duplicate email → 409, wrong password → 401, invalid JWT → 401, expired JWT → 401
- **Boundary conditions**: Invite code with uses_remaining=1 decrements to 0 and becomes unusable, invite code with uses_remaining=0 rejected
## Verification
- `cd backend && python -m pytest tests/test_auth.py -v` — all tests pass
- `cd backend && python -m pytest tests/test_public_api.py -v` — existing tests still pass (no auth regression)
- Estimate: 1.5h
- Files: backend/routers/auth.py, backend/main.py, backend/auth.py, backend/tests/conftest.py, backend/tests/test_auth.py
- Verify: cd backend && python -m pytest tests/test_auth.py -v && python -m pytest tests/test_public_api.py -v
- [ ] **T03: Build frontend AuthContext, API auth functions, login and register pages** — Create the React auth infrastructure: AuthContext provider with JWT persistence, API client auth functions, login page, register page, and wire AuthContext into App.tsx.
## Steps
1. Create `frontend/src/context/` directory.
2. Create `frontend/src/context/AuthContext.tsx`: React context providing `user` (UserResponse | null), `token` (string | null), `isAuthenticated` (boolean), `login(email, password)` (calls API, stores token in localStorage, fetches /me to populate user), `register(data)` (calls API, returns success/error), `logout()` (clears token + user from state and localStorage). On mount, check localStorage for existing token and call /me to rehydrate session. Export `AuthProvider` component and `useAuth()` hook.
3. Add auth API functions to `frontend/src/api/public-client.ts`:
- `authRegister(data: RegisterRequest): Promise<UserResponse>` — POST /api/v1/auth/register
- `authLogin(email: string, password: string): Promise<TokenResponse>` — POST /api/v1/auth/login
- `authGetMe(token: string): Promise<UserResponse>` — GET /api/v1/auth/me with Authorization header
- `authUpdateProfile(token: string, data: UpdateProfileRequest): Promise<UserResponse>` — PUT /api/v1/auth/me
- Add TypeScript interfaces: `RegisterRequest`, `LoginRequest`, `TokenResponse`, `UserResponse`, `UpdateProfileRequest`
- Modify `request<T>` to automatically inject Authorization header from localStorage when token exists
4. Create `frontend/src/pages/Login.tsx` (CSS module: `Login.module.css`):
- Email + password form with validation (required fields, email format)
- Error display for invalid credentials
- Submit calls `useAuth().login()`, redirects to /creator/dashboard on success
- Link to register page
- Uses `useDocumentTitle('Login')` for consistent page titles
5. Create `frontend/src/pages/Register.tsx` (CSS module: `Register.module.css`):
- Invite code + email + password + confirm password + display name form
- Client-side validation: required fields, email format, password match, min password length (8 chars)
- Error display for API errors (invalid code, email taken)
- Submit calls `useAuth().register()`, redirects to /login with success message on completion
- Uses `useDocumentTitle('Register')`
6. Wrap `<App />` content with `<AuthProvider>` in App.tsx. Add Route entries for `/login` and `/register`.
## Must-Haves
- [ ] AuthContext with login, register, logout, session rehydration from localStorage
- [ ] API auth functions with TypeScript types in public-client.ts
- [ ] request<T> injects Authorization header when token exists
- [ ] Login page with email/password form, error handling, redirect on success
- [ ] Register page with invite code + full form, validation, error handling
- [ ] CSS modules for both pages (not App.css)
- [ ] Routes /login and /register added to App.tsx
- [ ] `npm run build` passes with zero errors
## Verification
- `cd frontend && npm run build` — zero TypeScript errors
- `grep -r 'AuthProvider' frontend/src/App.tsx` — context is wired
- `test -f frontend/src/context/AuthContext.tsx` — context file exists
- `test -f frontend/src/pages/Login.tsx && test -f frontend/src/pages/Register.tsx` — both pages exist
- `test -f frontend/src/pages/Login.module.css && test -f frontend/src/pages/Register.module.css` — CSS modules exist
- Estimate: 1.5h
- Files: frontend/src/context/AuthContext.tsx, frontend/src/api/public-client.ts, frontend/src/pages/Login.tsx, frontend/src/pages/Login.module.css, frontend/src/pages/Register.tsx, frontend/src/pages/Register.module.css, frontend/src/App.tsx
- Verify: cd frontend && npm run build && test -f src/context/AuthContext.tsx && test -f src/pages/Login.tsx && test -f src/pages/Register.tsx
- [ ] **T04: Build dashboard shell, profile settings, ProtectedRoute, and nav auth state** — Create the creator dashboard shell page with sidebar navigation, profile settings page, ProtectedRoute wrapper component, and update the main nav to show authenticated user state.
## Steps
1. Create `frontend/src/components/ProtectedRoute.tsx`: Wrapper component that checks `useAuth().isAuthenticated`. If not authenticated, redirects to `/login` with a `returnTo` search param preserving the intended destination. If authenticated, renders children.
2. Create `frontend/src/pages/CreatorDashboard.tsx` (CSS module: `CreatorDashboard.module.css`):
- Two-column layout: left sidebar (240px) + main content area
- Sidebar nav items: Dashboard (active), Content (placeholder, disabled), Settings (links to /creator/settings). Use NavLink for active state.
- Main content: Welcome message with user's display_name from `useAuth().user`, placeholder cards for future analytics ("Content stats coming in M020")
- Uses `useDocumentTitle('Creator Dashboard')`
- Mobile: sidebar collapses to top horizontal nav or hamburger
3. Create `frontend/src/pages/CreatorSettings.tsx` (CSS module: `CreatorSettings.module.css`):
- Same dashboard layout (sidebar + content) for navigation consistency
- Profile section: display_name input (pre-filled), email display (read-only)
- Password section: current password + new password + confirm new password
- Submit calls `authUpdateProfile()` via AuthContext, shows success/error feedback
- Uses `useDocumentTitle('Settings')`
4. In `frontend/src/App.tsx`, add protected routes:
- `<Route path="/creator/dashboard" element={<ProtectedRoute><CreatorDashboard /></ProtectedRoute>} />`
- `<Route path="/creator/settings" element={<ProtectedRoute><CreatorSettings /></ProtectedRoute>} />`
5. Update the header/nav in `frontend/src/App.tsx`:
- When `useAuth().isAuthenticated`: show user display_name, link to /creator/dashboard, and Logout button
- When not authenticated: show Login link (subtle, in nav area)
- The existing AdminDropdown remains unchanged — it's a separate concern
6. Ensure all new CSS uses CSS modules. Follow the existing dark theme (use CSS custom properties from App.css where applicable). Dashboard sidebar should use `var(--color-surface-*) / var(--color-text-*)` tokens.
## Must-Haves
- [ ] ProtectedRoute redirects unauthenticated users to /login with returnTo param
- [ ] Dashboard shell with sidebar nav (Dashboard, Content placeholder, Settings)
- [ ] Profile settings page with display_name edit and password change
- [ ] Protected routes wired in App.tsx
- [ ] Nav shows auth state (logged in: name + dashboard + logout; logged out: login link)
- [ ] All CSS in CSS modules, using existing custom properties for dark theme consistency
- [ ] `npm run build` passes with zero errors
## Verification
- `cd frontend && npm run build` — zero TypeScript errors
- `test -f frontend/src/components/ProtectedRoute.tsx` — component exists
- `test -f frontend/src/pages/CreatorDashboard.tsx && test -f frontend/src/pages/CreatorSettings.tsx` — both pages exist
- `grep -q 'ProtectedRoute' frontend/src/App.tsx` — protected routes wired
- `grep -q 'useAuth' frontend/src/App.tsx` — nav auth state wired
- Estimate: 1.5h
- Files: frontend/src/components/ProtectedRoute.tsx, frontend/src/pages/CreatorDashboard.tsx, frontend/src/pages/CreatorDashboard.module.css, frontend/src/pages/CreatorSettings.tsx, frontend/src/pages/CreatorSettings.module.css, frontend/src/App.tsx
- Verify: cd frontend && npm run build && test -f src/components/ProtectedRoute.tsx && test -f src/pages/CreatorDashboard.tsx && test -f src/pages/CreatorSettings.tsx && grep -q 'ProtectedRoute' src/App.tsx

View file

@ -0,0 +1,140 @@
# S02 Research: [A] Creator Authentication + Dashboard Shell
## Summary
This is a greenfield auth system for an existing FastAPI + React codebase that has zero authentication today. The slice adds: a User model (separate from Creator), invite-code-gated registration, JWT-based login sessions, role-based access (creator/admin), and a creator dashboard shell page. The existing Creator model has no auth fields — users will be a new entity linked to creators via foreign key.
**Depth: Targeted.** Auth with PyJWT + bcrypt on FastAPI is well-understood, but this codebase has never had auth, so the integration surface is broad: new models, new migration, new middleware, new API router, new frontend pages, auth context, and protected routes.
## Recommendation
**Stack:** PyJWT (trust 9.9, benchmark 92.5) for JWT encode/decode, passlib[bcrypt] for password hashing. Both are the standard FastAPI auth libraries — no need for heavier frameworks (FastAPI-Users, etc.) given the simple role model.
**Architecture:**
- New `User` model in `backend/models.py` with email, hashed_password, role enum (creator/admin), and optional `creator_id` FK.
- New `InviteCode` model for invite-gated registration (code, uses_remaining, created_by, expires_at).
- `backend/routers/auth.py` with POST /register, POST /login, GET /me, PUT /me (profile settings).
- `backend/auth.py` utility module: password hashing, JWT creation/verification, FastAPI dependency `get_current_user`.
- Frontend: auth context provider, login/register pages, protected route wrapper, dashboard shell page with sidebar nav.
- Existing routes remain public. Only new `/creator/*` and `/admin/*` API routes require auth.
## Implementation Landscape
### Existing Code (Key Files)
| File | Lines | Relevance |
|------|-------|-----------|
| `backend/models.py` | ~520 | All 13 models in one file. Add User + InviteCode here. Site audit warns about splitting but that's S05's job. |
| `backend/schemas.py` | ~500 | Pydantic schemas. Add auth request/response schemas here. |
| `backend/database.py` | ~30 | Async engine + session factory. No changes needed. |
| `backend/config.py` | ~90 | Settings via pydantic-settings. Add JWT_SECRET_KEY, JWT_EXPIRY_MINUTES, INVITE_CODES config. `app_secret_key` already exists but is unused — repurpose for JWT signing. |
| `backend/main.py` | ~100 | FastAPI app setup. Register new auth router here. |
| `backend/routers/` | 10 files | Existing pattern: APIRouter with prefix, Depends(get_session). Auth router follows same pattern. |
| `frontend/src/App.tsx` | ~140 | React Router setup. Add /login, /register, /creator/dashboard routes. |
| `frontend/src/api/public-client.ts` | ~860 | All API calls. `request<T>` function is the injection point for Authorization headers. |
| `frontend/src/App.css` | 5820 | Monolithic CSS. Site audit recommends CSS modules for new Phase 2 components — use `.p2-` namespace prefix at minimum. |
| `alembic/versions/` | 15 migrations | Migration 016 will add users + invite_codes tables. |
### Data Model Design
```
User
id: UUID (PK)
email: String(255), unique, not null
hashed_password: String(255), not null
display_name: String(255), not null
role: Enum('creator', 'admin'), not null, default 'creator'
creator_id: UUID FK -> creators.id, nullable (linked after registration)
is_active: Boolean, default true
created_at: DateTime
updated_at: DateTime
InviteCode
id: UUID (PK)
code: String(50), unique, not null
uses_remaining: Integer, default 1
created_by: UUID FK -> users.id, nullable (null for seed codes)
expires_at: DateTime, nullable
created_at: DateTime
```
**Key design decisions for planner:**
- User is separate from Creator. A Creator is content data (extracted from videos); a User is an account. The `creator_id` FK links them after registration. This preserves the existing Creator model untouched.
- Role enum is minimal: `creator` and `admin`. No `user` role yet — public visitors don't need accounts in Phase 2.
- `is_active` enables account deactivation without deletion.
- InviteCode tracks remaining uses, not a boolean — supports multi-use codes for batch invites.
### Auth Flow
1. **Registration:** POST /api/v1/auth/register — requires valid invite code + email + password + display_name. Creates User, decrements invite code uses. If role is creator, optionally links to existing Creator row by slug or creates new one.
2. **Login:** POST /api/v1/auth/login — email + password → JWT access token (HS256, signed with `app_secret_key`). Token payload: `{sub: user_id, role: role, exp: now + JWT_EXPIRY_MINUTES}`.
3. **Session:** Frontend stores JWT in localStorage. `request<T>` injects `Authorization: Bearer <token>` for authenticated endpoints. No refresh tokens for MVP — just re-login on expiry.
4. **Auth dependency:** `get_current_user(token: str = Depends(oauth2_scheme))` decodes JWT, loads User from DB, returns it. `require_role("admin")` sub-dependency for admin-only routes.
### Frontend Architecture
- **AuthContext** (`frontend/src/context/AuthContext.tsx`): React context with `user`, `token`, `login()`, `register()`, `logout()`, `isAuthenticated`. Wraps the app in App.tsx.
- **Login page** (`/login`): Email + password form. Redirects to dashboard on success.
- **Register page** (`/register`): Invite code + email + password + display name form. Redirects to login on success.
- **Dashboard shell** (`/creator/dashboard`): Left sidebar nav (Dashboard, Content, Settings), main content area. Initially empty "Welcome" content — M020/S02 fills in real analytics.
- **Profile settings** (`/creator/settings`): Display name, email, password change form.
- **ProtectedRoute** component: Wraps creator/admin routes, redirects to /login if not authenticated.
- **Nav update**: Show user avatar/name in header when logged in, with dropdown for Dashboard/Logout.
### CSS Strategy
Per site audit recommendation, new Phase 2 components should use CSS modules or at minimum a `p2-` namespace prefix. The dashboard shell is a good boundary for this. Options:
1. **CSS Modules** (`.module.css` files) — requires no Vite config change (Vite supports them natively). Cleanest isolation.
2. **Namespaced classes** in App.css — consistent with existing pattern but adds to the 5820-line monolith.
Recommend CSS Modules for all new dashboard/auth pages. Existing public pages stay in App.css.
### Migration Plan
Single Alembic migration `016_add_users_and_invite_codes.py`:
- Create `users` table
- Create `invite_codes` table
- No FK from `creators``users` (the link is `users.creator_id → creators.id`, one-directional)
### Dependencies to Add
```
# backend/requirements.txt additions:
PyJWT>=2.8,<3.0
passlib[bcrypt]>=1.7,<2.0
```
### Verification Strategy
1. **Backend:** Integration tests in `backend/tests/test_auth.py`:
- Register with valid invite code → 201 + user created
- Register with invalid/expired invite code → 403
- Register duplicate email → 409
- Login with correct credentials → 200 + JWT
- Login with wrong password → 401
- GET /me with valid token → user profile
- GET /me with expired/invalid token → 401
- Protected endpoint without token → 401
2. **Frontend:** `npm run build` passes with zero TypeScript errors.
3. **End-to-end:** Register → login → see dashboard shell → navigate to settings. Verify via browser tools or curl.
### Natural Task Seams
1. **Backend models + migration** — User model, InviteCode model, role enum, Alembic migration. Independent, unblocks everything else.
2. **Auth utilities + router** — password hashing, JWT functions, auth dependencies, POST /register, POST /login, GET /me, PUT /me. Depends on (1).
3. **Seed invite codes** — CLI command or migration data to create initial invite codes for testing.
4. **Frontend auth context + login/register pages** — AuthContext, login form, register form, token storage. Depends on (2) for API contract.
5. **Dashboard shell + protected routes** — Dashboard page, sidebar nav, profile settings page, ProtectedRoute component, nav auth state. Depends on (4).
### Risks
| Risk | Impact | Mitigation |
|------|--------|------------|
| JWT secret key rotation | Invalidates all sessions | Use `app_secret_key` from config.py (already exists). Document rotation procedure. |
| Password hashing performance in async context | bcrypt is CPU-bound, blocks event loop | passlib's bcrypt is fast enough for low-volume registration. Use `run_in_executor` if needed — defer until measured. |
| Creator-User linking | Which creator does a new user map to? | Registration accepts optional `creator_slug`. Admin can link/unlink later. |
| localStorage XSS risk | Token theft via XSS | Acceptable for self-hosted single-admin tool. httpOnly cookies would be more secure but add CSRF complexity. Revisit if the tool becomes public-facing. |
### Skill Discovery
No installed skills are directly relevant to FastAPI auth implementation. Searched `npx skills find "fastapi authentication"` — top results had 27-84 installs with narrow scope (JWT bridge, router generation). Not worth installing — the auth pattern is standard and well-understood.

View file

@ -0,0 +1,65 @@
---
estimated_steps: 27
estimated_files: 6
skills_used: []
---
# T01: Add User + InviteCode models, migration, and auth utilities
Create the User and InviteCode SQLAlchemy models in backend/models.py, generate Alembic migration 016, add PyJWT + passlib[bcrypt] to requirements.txt, and implement the auth utility module (password hashing, JWT encode/decode, FastAPI get_current_user dependency).
## Steps
1. Add `PyJWT>=2.8,<3.0` and `passlib[bcrypt]>=1.7,<2.0` to `backend/requirements.txt` and run `pip install -r backend/requirements.txt`.
2. In `backend/models.py`, add a `UserRole` enum (creator, admin) and the `User` model: id (UUID PK), email (unique, not null), hashed_password, display_name, role (UserRole, default creator), creator_id (FK to creators.id, nullable), is_active (default true), created_at, updated_at. Add the `InviteCode` model: id (UUID PK), code (unique, not null), uses_remaining (default 1), created_by (FK to users.id, nullable), expires_at (nullable), created_at.
3. In `backend/schemas.py`, add Pydantic schemas: `RegisterRequest` (email, password, display_name, invite_code, optional creator_slug), `LoginRequest` (email, password), `TokenResponse` (access_token, token_type), `UserResponse` (id, email, display_name, role, creator_id, is_active, created_at), `UpdateProfileRequest` (optional display_name, optional current_password, optional new_password).
4. Create `backend/auth.py` with: `hash_password(plain) -> str` using passlib bcrypt, `verify_password(plain, hashed) -> bool`, `create_access_token(user_id, role) -> str` using PyJWT HS256 with `settings.app_secret_key` and configurable expiry, `decode_access_token(token) -> dict` with expiry validation, `get_current_user` FastAPI dependency using `OAuth2PasswordBearer(tokenUrl='/api/v1/auth/login')` that decodes token and loads User from DB, `require_role(role)` sub-dependency.
5. Generate Alembic migration: `cd backend && alembic revision --autogenerate -m 'add users and invite_codes'`. Review the generated migration to ensure it creates both tables with correct constraints.
## Must-Haves
- [ ] User model with email, hashed_password, display_name, role enum, creator_id FK, is_active
- [ ] InviteCode model with code, uses_remaining, created_by FK, expires_at
- [ ] Alembic migration 016 creates both tables
- [ ] auth.py exports hash_password, verify_password, create_access_token, decode_access_token, get_current_user, require_role
- [ ] Pydantic request/response schemas for register, login, token, user profile, update profile
- [ ] PyJWT and passlib[bcrypt] in requirements.txt
## Failure Modes
| Dependency | On error | On timeout | On malformed response |
|------------|----------|-----------|----------------------|
| PostgreSQL (migration) | Alembic prints traceback — fix SQL/model and retry | N/A | N/A |
| passlib bcrypt | ImportError at import time — verify pip install | N/A | N/A |
## Negative Tests
- **Malformed inputs**: decode_access_token with expired token raises, with garbage string raises, with missing claims raises
- **Boundary conditions**: InviteCode with uses_remaining=0 should not be usable
## Verification
- `cd backend && python -c "from models import User, InviteCode, UserRole; print('Models OK')"` exits 0
- `cd backend && python -c "from auth import hash_password, verify_password, create_access_token, decode_access_token; print('Auth OK')"` exits 0
- `cd backend && python -c "from schemas import RegisterRequest, LoginRequest, TokenResponse, UserResponse, UpdateProfileRequest; print('Schemas OK')"` exits 0
- Alembic migration file exists in `alembic/versions/` with 'users' and 'invite_codes' in the upgrade function
## Inputs
- `backend/models.py`
- `backend/schemas.py`
- `backend/config.py`
- `backend/requirements.txt`
- `backend/database.py`
## Expected Output
- `backend/models.py`
- `backend/schemas.py`
- `backend/auth.py`
- `backend/requirements.txt`
- `alembic/versions/016_add_users_and_invite_codes.py`
## Verification
cd backend && python -c "from models import User, InviteCode, UserRole; print('OK')" && python -c "from auth import hash_password, verify_password, create_access_token, decode_access_token; print('OK')" && python -c "from schemas import RegisterRequest, LoginRequest, TokenResponse, UserResponse; print('OK')"

View file

@ -0,0 +1,88 @@
---
id: T01
parent: S02
milestone: M019
provides: []
requires: []
affects: []
key_files: ["backend/models.py", "backend/auth.py", "backend/schemas.py", "backend/requirements.txt", "alembic/versions/016_add_users_and_invite_codes.py"]
key_decisions: ["24-hour JWT expiry with HS256 using existing app_secret_key", "OAuth2PasswordBearer tokenUrl /api/v1/auth/login for T02 router", "User.creator_id FK uses SET NULL on delete"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "All slice verification checks pass: model imports OK, auth utility imports OK, schema imports OK, migration file contains users and invite_codes. Additional integration test verified password roundtrip, JWT roundtrip, expired token rejection, and garbage token rejection."
completed_at: 2026-04-03T21:46:54.281Z
blocker_discovered: false
---
# T01: Added User/InviteCode models, Alembic migration 016, auth utilities (bcrypt hashing, JWT, FastAPI dependencies), and Pydantic auth schemas
> Added User/InviteCode models, Alembic migration 016, auth utilities (bcrypt hashing, JWT, FastAPI dependencies), and Pydantic auth schemas
## What Happened
---
id: T01
parent: S02
milestone: M019
key_files:
- backend/models.py
- backend/auth.py
- backend/schemas.py
- backend/requirements.txt
- alembic/versions/016_add_users_and_invite_codes.py
key_decisions:
- 24-hour JWT expiry with HS256 using existing app_secret_key
- OAuth2PasswordBearer tokenUrl /api/v1/auth/login for T02 router
- User.creator_id FK uses SET NULL on delete
duration: ""
verification_result: passed
completed_at: 2026-04-03T21:46:54.282Z
blocker_discovered: false
---
# T01: Added User/InviteCode models, Alembic migration 016, auth utilities (bcrypt hashing, JWT, FastAPI dependencies), and Pydantic auth schemas
**Added User/InviteCode models, Alembic migration 016, auth utilities (bcrypt hashing, JWT, FastAPI dependencies), and Pydantic auth schemas**
## What Happened
Added PyJWT and passlib[bcrypt] to requirements.txt. Created UserRole enum and two new models: User (email, hashed_password, display_name, role, creator_id FK, is_active) and InviteCode (code, uses_remaining, created_by FK, expires_at). Added five Pydantic schemas for auth flows. Created backend/auth.py with password hashing, JWT encode/decode, get_current_user dependency, and require_role factory. Wrote Alembic migration 016 creating both tables with constraints and user_role enum.
## Verification
All slice verification checks pass: model imports OK, auth utility imports OK, schema imports OK, migration file contains users and invite_codes. Additional integration test verified password roundtrip, JWT roundtrip, expired token rejection, and garbage token rejection.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd backend && python -c "from models import User, InviteCode, UserRole; print('Models OK')"` | 0 | ✅ pass | 500ms |
| 2 | `cd backend && python -c "from auth import hash_password, verify_password, create_access_token, decode_access_token; print('Auth OK')"` | 0 | ✅ pass | 500ms |
| 3 | `cd backend && python -c "from schemas import RegisterRequest, LoginRequest, TokenResponse, UserResponse, UpdateProfileRequest; print('Schemas OK')"` | 0 | ✅ pass | 500ms |
| 4 | `grep -l 'users|invite_codes' alembic/versions/016_add_users_and_invite_codes.py` | 0 | ✅ pass | 100ms |
| 5 | `cd backend && python -c "(auth function integration tests)"` | 0 | ✅ pass | 800ms |
## Deviations
Alembic migration written manually (no live DB for autogenerate). Added Boolean import to models.py.
## Known Issues
None.
## Files Created/Modified
- `backend/models.py`
- `backend/auth.py`
- `backend/schemas.py`
- `backend/requirements.txt`
- `alembic/versions/016_add_users_and_invite_codes.py`
## Deviations
Alembic migration written manually (no live DB for autogenerate). Added Boolean import to models.py.
## Known Issues
None.

View file

@ -0,0 +1,83 @@
---
estimated_steps: 43
estimated_files: 5
skills_used: []
---
# T02: Implement auth API router with registration, login, profile endpoints and integration tests
Create the auth API router with all endpoints (register, login, me, update profile), register it in main.py, seed initial invite codes, and write comprehensive integration tests.
## Steps
1. Create `backend/routers/auth.py` with APIRouter prefix `/api/v1/auth`:
- `POST /register`: Validate invite code (exists, uses_remaining > 0, not expired), check email uniqueness, hash password, create User, decrement invite code uses_remaining, optionally link creator_id by slug. Return 201 + UserResponse. Errors: 403 invalid/expired code, 409 duplicate email.
- `POST /login`: Find user by email, verify password, return TokenResponse with JWT. Errors: 401 invalid credentials.
- `GET /me`: Requires auth (get_current_user dependency). Return UserResponse.
- `PUT /me`: Requires auth. Accept UpdateProfileRequest — update display_name if provided, verify current_password and set new_password if provided. Return updated UserResponse.
2. In `backend/main.py`, import and include the auth router.
3. Create a seed invite code mechanism: add a function `seed_invite_codes()` in `backend/auth.py` that creates a default invite code if none exist (code='CHRYSOPEDIA-ALPHA-2026', uses_remaining=100). Call it from a startup event or CLI command. For the test suite, create codes in fixtures.
4. Update `backend/tests/conftest.py`: import User, InviteCode, UserRole into the model imports so they're included in `Base.metadata.create_all`. Add an `invite_code` fixture that creates a test invite code. Add a `registered_user` fixture that creates a user via the register endpoint. Add an `auth_headers` fixture that logs in and returns `{"Authorization": "Bearer <token>"}`.
5. Create `backend/tests/test_auth.py` with integration tests:
- Register with valid invite code → 201 + user created in DB
- Register with invalid invite code → 403
- Register with expired invite code → 403
- Register duplicate email → 409
- Login with correct credentials → 200 + JWT token
- Login with wrong password → 401
- Login with nonexistent email → 401
- GET /me with valid token → 200 + user profile
- GET /me with expired/invalid token → 401
- GET /me without token → 401
- PUT /me updates display_name → 200 + updated profile
- PUT /me changes password → 200, can login with new password
- Existing public endpoints still work without auth (GET /api/v1/techniques, GET /api/v1/creators)
## Must-Haves
- [ ] POST /register with invite code validation, email uniqueness, password hashing
- [ ] POST /login returning JWT
- [ ] GET /me and PUT /me with auth dependency
- [ ] Auth router registered in main.py
- [ ] All 13 integration tests pass
- [ ] Existing public endpoints verified unaffected
## Failure Modes
| Dependency | On error | On timeout | On malformed response |
|------------|----------|-----------|----------------------|
| PostgreSQL (test DB) | Test fails with connection error — check TEST_DATABASE_URL | N/A | N/A |
| bcrypt hashing | Slow on first call (key derivation) — acceptable for test suite | N/A | N/A |
## Negative Tests
- **Malformed inputs**: Register with missing fields → 422, Register with empty password → 422, Login with empty body → 422
- **Error paths**: Invalid invite code → 403, expired invite code → 403, duplicate email → 409, wrong password → 401, invalid JWT → 401, expired JWT → 401
- **Boundary conditions**: Invite code with uses_remaining=1 decrements to 0 and becomes unusable, invite code with uses_remaining=0 rejected
## Verification
- `cd backend && python -m pytest tests/test_auth.py -v` — all tests pass
- `cd backend && python -m pytest tests/test_public_api.py -v` — existing tests still pass (no auth regression)
## Inputs
- `backend/models.py`
- `backend/schemas.py`
- `backend/auth.py`
- `backend/config.py`
- `backend/database.py`
- `backend/main.py`
- `backend/routers/__init__.py`
- `backend/tests/conftest.py`
## Expected Output
- `backend/routers/auth.py`
- `backend/main.py`
- `backend/tests/conftest.py`
- `backend/tests/test_auth.py`
## Verification
cd backend && python -m pytest tests/test_auth.py -v && python -m pytest tests/test_public_api.py -v

View file

@ -0,0 +1,74 @@
---
estimated_steps: 39
estimated_files: 7
skills_used: []
---
# T03: Build frontend AuthContext, API auth functions, login and register pages
Create the React auth infrastructure: AuthContext provider with JWT persistence, API client auth functions, login page, register page, and wire AuthContext into App.tsx.
## Steps
1. Create `frontend/src/context/` directory.
2. Create `frontend/src/context/AuthContext.tsx`: React context providing `user` (UserResponse | null), `token` (string | null), `isAuthenticated` (boolean), `login(email, password)` (calls API, stores token in localStorage, fetches /me to populate user), `register(data)` (calls API, returns success/error), `logout()` (clears token + user from state and localStorage). On mount, check localStorage for existing token and call /me to rehydrate session. Export `AuthProvider` component and `useAuth()` hook.
3. Add auth API functions to `frontend/src/api/public-client.ts`:
- `authRegister(data: RegisterRequest): Promise<UserResponse>` — POST /api/v1/auth/register
- `authLogin(email: string, password: string): Promise<TokenResponse>` — POST /api/v1/auth/login
- `authGetMe(token: string): Promise<UserResponse>` — GET /api/v1/auth/me with Authorization header
- `authUpdateProfile(token: string, data: UpdateProfileRequest): Promise<UserResponse>` — PUT /api/v1/auth/me
- Add TypeScript interfaces: `RegisterRequest`, `LoginRequest`, `TokenResponse`, `UserResponse`, `UpdateProfileRequest`
- Modify `request<T>` to automatically inject Authorization header from localStorage when token exists
4. Create `frontend/src/pages/Login.tsx` (CSS module: `Login.module.css`):
- Email + password form with validation (required fields, email format)
- Error display for invalid credentials
- Submit calls `useAuth().login()`, redirects to /creator/dashboard on success
- Link to register page
- Uses `useDocumentTitle('Login')` for consistent page titles
5. Create `frontend/src/pages/Register.tsx` (CSS module: `Register.module.css`):
- Invite code + email + password + confirm password + display name form
- Client-side validation: required fields, email format, password match, min password length (8 chars)
- Error display for API errors (invalid code, email taken)
- Submit calls `useAuth().register()`, redirects to /login with success message on completion
- Uses `useDocumentTitle('Register')`
6. Wrap `<App />` content with `<AuthProvider>` in App.tsx. Add Route entries for `/login` and `/register`.
## Must-Haves
- [ ] AuthContext with login, register, logout, session rehydration from localStorage
- [ ] API auth functions with TypeScript types in public-client.ts
- [ ] request<T> injects Authorization header when token exists
- [ ] Login page with email/password form, error handling, redirect on success
- [ ] Register page with invite code + full form, validation, error handling
- [ ] CSS modules for both pages (not App.css)
- [ ] Routes /login and /register added to App.tsx
- [ ] `npm run build` passes with zero errors
## Verification
- `cd frontend && npm run build` — zero TypeScript errors
- `grep -r 'AuthProvider' frontend/src/App.tsx` — context is wired
- `test -f frontend/src/context/AuthContext.tsx` — context file exists
- `test -f frontend/src/pages/Login.tsx && test -f frontend/src/pages/Register.tsx` — both pages exist
- `test -f frontend/src/pages/Login.module.css && test -f frontend/src/pages/Register.module.css` — CSS modules exist
## Inputs
- `frontend/src/api/public-client.ts`
- `frontend/src/App.tsx`
- `frontend/src/App.css`
- `backend/schemas.py`
## Expected Output
- `frontend/src/context/AuthContext.tsx`
- `frontend/src/api/public-client.ts`
- `frontend/src/pages/Login.tsx`
- `frontend/src/pages/Login.module.css`
- `frontend/src/pages/Register.tsx`
- `frontend/src/pages/Register.module.css`
- `frontend/src/App.tsx`
## Verification
cd frontend && npm run build && test -f src/context/AuthContext.tsx && test -f src/pages/Login.tsx && test -f src/pages/Register.tsx

View file

@ -0,0 +1,71 @@
---
estimated_steps: 37
estimated_files: 6
skills_used: []
---
# T04: Build dashboard shell, profile settings, ProtectedRoute, and nav auth state
Create the creator dashboard shell page with sidebar navigation, profile settings page, ProtectedRoute wrapper component, and update the main nav to show authenticated user state.
## Steps
1. Create `frontend/src/components/ProtectedRoute.tsx`: Wrapper component that checks `useAuth().isAuthenticated`. If not authenticated, redirects to `/login` with a `returnTo` search param preserving the intended destination. If authenticated, renders children.
2. Create `frontend/src/pages/CreatorDashboard.tsx` (CSS module: `CreatorDashboard.module.css`):
- Two-column layout: left sidebar (240px) + main content area
- Sidebar nav items: Dashboard (active), Content (placeholder, disabled), Settings (links to /creator/settings). Use NavLink for active state.
- Main content: Welcome message with user's display_name from `useAuth().user`, placeholder cards for future analytics ("Content stats coming in M020")
- Uses `useDocumentTitle('Creator Dashboard')`
- Mobile: sidebar collapses to top horizontal nav or hamburger
3. Create `frontend/src/pages/CreatorSettings.tsx` (CSS module: `CreatorSettings.module.css`):
- Same dashboard layout (sidebar + content) for navigation consistency
- Profile section: display_name input (pre-filled), email display (read-only)
- Password section: current password + new password + confirm new password
- Submit calls `authUpdateProfile()` via AuthContext, shows success/error feedback
- Uses `useDocumentTitle('Settings')`
4. In `frontend/src/App.tsx`, add protected routes:
- `<Route path="/creator/dashboard" element={<ProtectedRoute><CreatorDashboard /></ProtectedRoute>} />`
- `<Route path="/creator/settings" element={<ProtectedRoute><CreatorSettings /></ProtectedRoute>} />`
5. Update the header/nav in `frontend/src/App.tsx`:
- When `useAuth().isAuthenticated`: show user display_name, link to /creator/dashboard, and Logout button
- When not authenticated: show Login link (subtle, in nav area)
- The existing AdminDropdown remains unchanged — it's a separate concern
6. Ensure all new CSS uses CSS modules. Follow the existing dark theme (use CSS custom properties from App.css where applicable). Dashboard sidebar should use `var(--color-surface-*) / var(--color-text-*)` tokens.
## Must-Haves
- [ ] ProtectedRoute redirects unauthenticated users to /login with returnTo param
- [ ] Dashboard shell with sidebar nav (Dashboard, Content placeholder, Settings)
- [ ] Profile settings page with display_name edit and password change
- [ ] Protected routes wired in App.tsx
- [ ] Nav shows auth state (logged in: name + dashboard + logout; logged out: login link)
- [ ] All CSS in CSS modules, using existing custom properties for dark theme consistency
- [ ] `npm run build` passes with zero errors
## Verification
- `cd frontend && npm run build` — zero TypeScript errors
- `test -f frontend/src/components/ProtectedRoute.tsx` — component exists
- `test -f frontend/src/pages/CreatorDashboard.tsx && test -f frontend/src/pages/CreatorSettings.tsx` — both pages exist
- `grep -q 'ProtectedRoute' frontend/src/App.tsx` — protected routes wired
- `grep -q 'useAuth' frontend/src/App.tsx` — nav auth state wired
## Inputs
- `frontend/src/App.tsx`
- `frontend/src/context/AuthContext.tsx`
- `frontend/src/api/public-client.ts`
- `frontend/src/App.css`
## Expected Output
- `frontend/src/components/ProtectedRoute.tsx`
- `frontend/src/pages/CreatorDashboard.tsx`
- `frontend/src/pages/CreatorDashboard.module.css`
- `frontend/src/pages/CreatorSettings.tsx`
- `frontend/src/pages/CreatorSettings.module.css`
- `frontend/src/App.tsx`
## Verification
cd frontend && npm run build && test -f src/components/ProtectedRoute.tsx && test -f src/pages/CreatorDashboard.tsx && test -f src/pages/CreatorSettings.tsx && grep -q 'ProtectedRoute' src/App.tsx

View file

@ -0,0 +1,50 @@
"""Add users and invite_codes tables for creator authentication.
Revision ID: 016_add_users_and_invite_codes
Revises: 015_add_creator_profile
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "016_add_users_and_invite_codes"
down_revision = "015_add_creator_profile"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create user_role enum type
user_role_enum = sa.Enum("creator", "admin", name="user_role", create_constraint=True)
user_role_enum.create(op.get_bind(), checkfirst=True)
# Create users table
op.create_table(
"users",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid()),
sa.Column("email", sa.String(255), nullable=False, unique=True),
sa.Column("hashed_password", sa.String(255), nullable=False),
sa.Column("display_name", sa.String(255), nullable=False),
sa.Column("role", user_role_enum, nullable=False, server_default="creator"),
sa.Column("creator_id", UUID(as_uuid=True), sa.ForeignKey("creators.id", ondelete="SET NULL"), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
# Create invite_codes table
op.create_table(
"invite_codes",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid()),
sa.Column("code", sa.String(100), nullable=False, unique=True),
sa.Column("uses_remaining", sa.Integer(), nullable=False, server_default="1"),
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("expires_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("invite_codes")
op.drop_table("users")
sa.Enum(name="user_role").drop(op.get_bind(), checkfirst=True)

116
backend/auth.py Normal file
View file

@ -0,0 +1,116 @@
"""Authentication utilities — password hashing, JWT, FastAPI dependencies."""
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from typing import Annotated
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import get_settings
from database import get_session
from models import User, UserRole
# ── Password hashing ─────────────────────────────────────────────────────────
_pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
"""Hash a plaintext password with bcrypt."""
return _pwd_ctx.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
"""Verify a plaintext password against a bcrypt hash."""
return _pwd_ctx.verify(plain, hashed)
# ── JWT ──────────────────────────────────────────────────────────────────────
_ALGORITHM = "HS256"
_ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 hours
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
def create_access_token(
user_id: uuid.UUID | str,
role: str,
*,
expires_minutes: int = _ACCESS_TOKEN_EXPIRE_MINUTES,
) -> str:
"""Create a signed JWT with user_id and role claims."""
settings = get_settings()
now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id),
"role": role,
"iat": now,
"exp": now + timedelta(minutes=expires_minutes),
}
return jwt.encode(payload, settings.app_secret_key, algorithm=_ALGORITHM)
def decode_access_token(token: str) -> dict:
"""Decode and validate a JWT. Raises on expiry or malformed tokens."""
settings = get_settings()
try:
payload = jwt.decode(
token,
settings.app_secret_key,
algorithms=[_ALGORITHM],
options={"require": ["sub", "role", "exp"]},
)
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired",
)
except jwt.InvalidTokenError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid token: {exc}",
)
return payload
# ── FastAPI dependencies ─────────────────────────────────────────────────────
async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> User:
"""Decode JWT, load User from DB, raise 401 if missing or inactive."""
payload = decode_access_token(token)
user_id = payload.get("sub")
result = await session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive",
)
return user
def require_role(required_role: UserRole):
"""Return a dependency that checks the current user has the given role."""
async def _check(
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
if current_user.role != required_role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requires {required_role.value} role",
)
return current_user
return _check

View file

@ -12,6 +12,7 @@ import uuid
from datetime import datetime, timezone
from sqlalchemy import (
Boolean,
Enum,
Float,
ForeignKey,
@ -73,6 +74,12 @@ class RelationshipType(str, enum.Enum):
general_cross_reference = "general_cross_reference"
class UserRole(str, enum.Enum):
"""Roles for authenticated users."""
creator = "creator"
admin = "admin"
# ── Helpers ──────────────────────────────────────────────────────────────────
def _uuid_pk() -> Mapped[uuid.UUID]:
@ -123,6 +130,52 @@ class Creator(Base):
technique_pages: Mapped[list[TechniquePage]] = sa_relationship(back_populates="creator")
class User(Base):
"""Authenticated user account for the creator dashboard."""
__tablename__ = "users"
id: Mapped[uuid.UUID] = _uuid_pk()
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[UserRole] = mapped_column(
Enum(UserRole, name="user_role", create_constraint=True),
default=UserRole.creator,
server_default="creator",
)
creator_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("creators.id", ondelete="SET NULL"), nullable=True
)
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, server_default="true"
)
created_at: Mapped[datetime] = mapped_column(
default=_now, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
default=_now, server_default=func.now(), onupdate=_now
)
# relationships
creator: Mapped[Creator | None] = sa_relationship()
class InviteCode(Base):
"""Single-use or limited-use invite codes for registration gating."""
__tablename__ = "invite_codes"
id: Mapped[uuid.UUID] = _uuid_pk()
code: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
uses_remaining: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
created_by: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
expires_at: Mapped[datetime | None] = mapped_column(nullable=True)
created_at: Mapped[datetime] = mapped_column(
default=_now, server_default=func.now()
)
class SourceVideo(Base):
__tablename__ = "source_videos"

View file

@ -15,6 +15,8 @@ qdrant-client>=1.9,<2.0
pyyaml>=6.0,<7.0
psycopg2-binary>=2.9,<3.0
watchdog>=4.0,<5.0
PyJWT>=2.8,<3.0
passlib[bcrypt]>=1.7,<2.0
# Test dependencies
pytest>=8.0,<10.0
pytest-asyncio>=0.24,<1.0

View file

@ -502,3 +502,46 @@ class AdminTechniquePageListResponse(BaseModel):
total: int = 0
offset: int = 0
limit: int = 50
# ── Auth ─────────────────────────────────────────────────────────────────────
class RegisterRequest(BaseModel):
"""Registration payload — requires a valid invite code."""
email: str = Field(..., min_length=3, max_length=255)
password: str = Field(..., min_length=8, max_length=128)
display_name: str = Field(..., min_length=1, max_length=255)
invite_code: str = Field(..., min_length=1, max_length=100)
creator_slug: str | None = Field(None, max_length=255)
class LoginRequest(BaseModel):
"""Login payload."""
email: str
password: str
class TokenResponse(BaseModel):
"""JWT token response."""
access_token: str
token_type: str = "bearer"
class UserResponse(BaseModel):
"""Public user profile response."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: str
display_name: str
role: str
creator_id: uuid.UUID | None = None
is_active: bool = True
created_at: datetime
class UpdateProfileRequest(BaseModel):
"""Self-service profile update."""
display_name: str | None = Field(None, min_length=1, max_length=255)
current_password: str | None = None
new_password: str | None = Field(None, min_length=8, max_length=128)