# 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) ```