4e38b5a791
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>
181 lines
6.5 KiB
Markdown
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)
|
|
```
|