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>
6.5 KiB
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:
emailvarchar(255) unique nullable (nullable: existing anonymous rows have none)email_verifiedtimestamp nullablenamevarchar(255) nullableimagetext nullablerolevarchar(20) not null default'learner'— values:learner | admin | suspendedpassword_hashtext 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+jwtembeduser.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/loginif no sessionrequireAdmin()— requireAuth + role checkgetOptionalSession()— returns session or null
Phase 3 — Middleware
src/middleware.ts:
- Protected prefixes:
/profile,/settings,/admin - Redirect unauthenticated →
/auth/login?callbackUrl=... /adminprefix: 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):
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/imagePOST /api/user/change-password— verify old bcrypt → hash newDELETE /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
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)