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

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:

  • 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.tsexport 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):

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

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)