Files
Curio/docs/user-management-plan.md
arnaudne 4e38b5a791 feat: initial commit — Curio mastery tutor
Full Next.js App Router application with:
- AI-graded lesson checkpoints (BKT/Elo mastery tracking)
- Auth.js v5: credentials, Google, GitHub, generic OIDC
- Anonymous-session-first with migrate-on-signin
- Admin panel: users, blueprints, reports, site settings
- Password reset + email verification (nodemailer/SMTP)
- Site config: require_auth + signups_enabled flags
- server-only guards on all DB/generation/verification modules
- PostgreSQL 16 + pgvector, Redis cache, Drizzle ORM
- 271 unit tests (Vitest), golden-eval harness, Playwright e2e stubs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:25:43 +02:00

181 lines
6.5 KiB
Markdown

# User Management Plan (T1 + T2 + T3)
## Context
Curio is anonymous-session-first (sessionStorage UUID). Adding Auth.js v5 (NextAuth) with email/password + OAuth (Google, GitHub), anonymous→auth migration, profile/settings, and admin panel. `users` table exists (id, createdAt) — needs column additions + 3 new Auth.js tables.
---
## Stack additions
```
next-auth@beta # Auth.js v5
@auth/drizzle-adapter # Drizzle adapter for Auth.js
bcryptjs + @types/bcryptjs # Password hashing
nodemailer + @types/nodemailer # Email verification
```
---
## Phase 1 — Schema migration
New migration via drizzle-kit generate → `drizzle/migrations/0004_auth.sql`
**Alter `user` table** — add columns:
- `email` varchar(255) unique nullable (nullable: existing anonymous rows have none)
- `email_verified` timestamp nullable
- `name` varchar(255) nullable
- `image` text nullable
- `role` varchar(20) not null default `'learner'` — values: `learner | admin | suspended`
- `password_hash` text nullable (null for OAuth users)
**New tables** (Auth.js Drizzle adapter required):
- `account` — OAuth provider links (userId FK → user.id, provider, providerAccountId, etc.)
- `session` — DB sessions (sessionToken PK, userId FK, expires)
- `verification_token` — email verification (identifier + token composite PK, expires)
Schema file: `src/lib/db/schema.ts` — add 3 new tables, extend users inline.
---
## Phase 2 — Auth.js core config
**`src/auth.ts`** — single source of truth:
- Adapter: `DrizzleAdapter(db, { usersTable: users, ... })`
- Session strategy: `database` (allows session revocation for admin ban)
- Providers: `Credentials` (email+bcrypt), `Google`, `GitHub`
- Callbacks: `session` + `jwt` embed `user.id` + `user.role`
- Pages: `signIn: '/auth/login'`, `error: '/auth/error'`
- Exports: `{ handlers, auth, signIn, signOut }`
**`src/app/api/auth/[...nextauth]/route.ts`** — `export const { GET, POST } = handlers`
**`src/lib/auth-helpers.ts`** — server utilities:
- `requireAuth()` — redirects to `/auth/login` if no session
- `requireAdmin()` — requireAuth + role check
- `getOptionalSession()` — returns session or null
---
## Phase 3 — Middleware
**`src/middleware.ts`**:
- Protected prefixes: `/profile`, `/settings`, `/admin`
- Redirect unauthenticated → `/auth/login?callbackUrl=...`
- `/admin` prefix: redirect non-admin → `/`
- All other paths pass through
---
## Phase 4 — Auth UI pages
`src/app/auth/login/page.tsx` — email+password form + Google/GitHub buttons + link to register
`src/app/auth/register/page.tsx` — email + name + password + confirm; POST `/api/auth/register`; then signIn + migrate anonymous session
`src/app/auth/verify-email/page.tsx` — "check inbox" + token handler
`src/app/auth/error/page.tsx` — maps Auth.js error codes to readable messages
Design: ink & light tokens, no card shadows, no rounded-xl. Labels above inputs.
---
## Phase 5 — Anonymous session migration
**`src/app/api/auth/migrate-session/route.ts`** — POST `{ anonUserId }`:
- Auth-gated (requires valid session)
- Transfers all rows: `responses`, `mastery`, `journey_instance` — UPDATE user_id = auth WHERE user_id = anon
- Renames Redis budget key `budget:{anon}``budget:{auth}`
- Idempotent
**Client side**: after `signIn` resolves, read `sessionStorage.getItem('curio_uid')`, POST migrate, clear key.
---
## Phase 6 — Update existing API routes
All 12 routes: prefer server session, fall back to client-supplied userId (keeps anonymous flow):
```typescript
const session = await getOptionalSession();
const userId = session?.user?.id ?? body.userId ?? searchParams.get('userId');
```
Routes with body userId: `/api/grade`, `/api/advance`, `/api/explain`, `/api/tangent`, `/api/socratic`
Routes with query userId: `/api/mastery`, `/api/review`
---
## Phase 7 — Learner account pages (T2)
**`src/app/profile/page.tsx`** (RSC, protected) — name, email, joined date; mastery overview per blueprint
**`src/app/settings/page.tsx`** (RSC, protected) — Profile section (name, avatar), Security (change password, linked OAuth accounts), Danger (delete account)
New API routes (all auth-gated):
- `PATCH /api/user/profile` — update name/image
- `POST /api/user/change-password` — verify old bcrypt → hash new
- `DELETE /api/user/account` — delete + cascade
---
## Phase 8 — Admin panel (T3)
All protected by `requireAdmin()`.
**`src/app/admin/layout.tsx`** — sidebar: Users | Reports | Blueprints
**`src/app/admin/page.tsx`** — stats: total users, active today
`GET /api/admin/stats`
**`src/app/admin/users/page.tsx`** — paginated user list; actions: promote to admin, suspend, delete
`GET /api/admin/users`, `PATCH /api/admin/users/[id]`, `DELETE /api/admin/users/[id]`
**`src/app/admin/reports/page.tsx`** — content_report triage; actions: dismiss, flag for re-verify
`GET /api/admin/reports`, `PATCH /api/admin/reports/[id]`
**`src/app/admin/blueprints/page.tsx`** — blueprint status management
`GET /api/admin/blueprints`, `PATCH /api/admin/blueprints/[id]`
---
## New env vars
```
AUTH_SECRET= # openssl rand -hex 32
AUTH_URL=http://localhost:3000
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=
AUTH_GITHUB_ID=
AUTH_GITHUB_SECRET=
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
EMAIL_FROM=noreply@yourdomain.com
```
---
## Files created / modified (summary)
**New:** `src/auth.ts`, `src/middleware.ts`, `src/lib/auth-helpers.ts`, `src/app/api/auth/[...nextauth]/route.ts`, `src/app/api/auth/register/route.ts`, `src/app/api/auth/migrate-session/route.ts`, `src/app/auth/login|register|verify-email|error/page.tsx`, `src/app/profile/page.tsx`, `src/app/settings/page.tsx`, `src/app/api/user/profile|change-password|account/route.ts`, `src/app/admin/layout|page.tsx`, `src/app/admin/users|reports|blueprints/page.tsx`, `src/app/api/admin/stats|users/[id]|reports/[id]|blueprints/[id]/route.ts`
**Modified:** `src/lib/db/schema.ts` (extend users + 3 tables), all 12 existing API routes (userId fallback), `src/app/page.tsx` (nav link), `src/app/layout.tsx` (SessionProvider), `.env.example`
---
## Verification
```bash
pnpm drizzle-kit generate && pnpm drizzle-kit migrate # 0004_auth applies clean
pnpm dev
curl http://localhost:3000/api/auth/providers # returns Google, GitHub, credentials
# Register → lesson → grade works (anonymous flow intact)
# Register → verify email → profile shows mastery
# Set role='admin' in DB → /admin accessible
# Non-admin → /admin redirects to /
pnpm test # existing suite passes (userId fallback preserves behavior)
```