Files
adminandClaude Sonnet 4.6 d18d29168a feat(auth): phase 3 — replace Supabase GoTrue with bcryptjs+jose
Custom auth stack: bcryptjs password hashing (cost 10, GoTrue-compatible),
jose JWT session cookies (edge-safe, 8hr TTL), new API routes for
login/logout/reset/change-password, middleware rewritten to JWT-only
verification with no DB access. All 38 protected pages and API routes
migrated from supabase.auth.getUser() to getSession(). Supabase .from()
queries retained for Phase 4. lib/db/index.ts refactored to lazy Proxy
singleton to avoid module-level throw during Next.js build.

tsc: clean, build: clean, tests: 4/4 passed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 16:20:04 +08:00

5.4 KiB

Phase 3 Report — Custom Auth (bcryptjs + jose)

Status: COMPLETE
Date: 2026-07-23
Branch: main


Summary

Replaced Supabase GoTrue authentication with a custom bcryptjs + jose stack. All 38 protected pages and API routes migrated. TypeScript clean, build clean, auth unit tests passing.


New Files Created

File Purpose
lib/auth/session.ts JWT sign/verify via jose; createSession, verifySession, SessionPayload
lib/auth/password.ts hashPassword / verifyPassword via bcryptjs at cost 10
lib/auth/get-session.ts Server-only cookie reader; returns SessionPayload | null
lib/notifications/mailer.ts sendPasswordResetEmail via Brevo raw fetch
app/api/auth/login/route.ts POST: bcrypt verify → JWT → set ims_session cookie
app/api/auth/logout/route.ts POST: clear ims_session cookie
app/api/auth/reset-request/route.ts POST: create password_reset_tokens row, send email
app/api/auth/reset-confirm/route.ts POST: validate token hash, update password hash
app/api/auth/change-password/route.ts POST: verify current password, update hash
supabase/migrations/20260724000001_password_reset_tokens.sql password_reset_tokens table
tests/lib/auth/session.test.ts Vitest: JWT create/verify round-trip, invalid token
tests/lib/auth/password.test.ts Vitest: hash/verify, Supabase-style $2a$ hash compat

Modified Files

Core Auth Infrastructure

  • lib/auth/require-admin.ts — rewritten: returns { session: SessionPayload | null } (was { supabase, user })
  • middleware.ts — rewritten: edge-safe JWT-only verification, no DB imports
  • lib/db/index.ts — lazy singleton Proxy pattern to prevent module-level throw during Next.js build static analysis when DATABASE_URL not set in build env
  • lib/db/schema.ts — added passwordResetTokens table definition

UI Components

  • components/auth/login-form.tsx — POST to /api/auth/login instead of supabase signIn
  • components/layout/sidebar.tsx — POST to /api/auth/logout instead of supabase signOut
  • app/(auth)/forgot-password/page.tsx — POST to /api/auth/reset-request
  • app/(auth)/reset-password/page.tsx — POST to /api/auth/reset-confirm, reads token from search params
  • components/account/change-password-form.tsx — POST to /api/auth/change-password

Admin User CRUD

  • app/api/admin/users/route.ts — POST creates user with hashPassword + Drizzle insert; DELETE removes row directly; all handlers use { session } from requireAdmin()
  • app/api/admin/sites/route.ts — updated to { session } pattern + explicit createClient() for .from() calls
  • app/api/admin/trucks/route.ts — same

Storage

  • lib/supabase/storage.ts — removed supabase.auth.getUser() call; added userId: string as 5th parameter to uploadEvidenceFile

38 Protected Pages and API Routes

Pattern applied to all:

  • import { getSession } from '@/lib/auth/get-session'
  • const session = await getSession() replaces supabase.auth.getUser()
  • session.sub replaces user.id
  • session.role replaces profile fetch from DB
  • session.siteId replaces profile.site_id
  • createClient() retained where .from() queries still exist (Phase 4 will remove these)

Issues Fixed During Implementation

  1. app/api/admin/sites/route.ts and app/api/admin/trucks/route.ts — not in original brief scope but broken by requireAdmin signature change; fixed.
  2. tests/lib/auth/password.test.ts — brief had describe(name, fn, options) which is wrong Vitest API; fixed to describe(name, fn).
  3. tests/lib/supabase/storage.test.tsuploadEvidenceFile signature added userId; all 3 call sites updated; removed now-unused auth.getUser mock.
  4. .next/types/validator.ts — stale reference to deleted /api/auth/callback/route.ts; removed the block.
  5. All 5 auth API routes — missing export const dynamic = 'force-dynamic'; added to prevent Next.js static pre-rendering.
  6. lib/db/index.ts — module-level throw when DATABASE_URL unset failed build's "collect page data" phase even with force-dynamic; refactored to lazy Proxy singleton.
  7. Test environment — vitest global config uses jsdom; jose and bcryptjs use native Uint8Array which fails instanceof check across jsdom/Node realms; fixed with // @vitest-environment node in both auth test files.

Invariants Preserved

  • middleware.ts imports only jose and next/server — no pg, drizzle-orm, or DB connections
  • All .from() Supabase queries retained untouched (Phase 4 scope)
  • server-only import in get-session.ts prevents client-side use
  • Passwords hashed at bcrypt cost 10 — compatible with existing Supabase GoTrue hashes

Verification

npx tsc --noEmit   → clean (0 errors)
npm run build      → clean (warnings only: img tag, unused UserDb type)
npm test -- tests/lib/auth/  → 4/4 passed

Env Vars Required at Runtime

Var Purpose
JWT_SECRET ≥32 char random string for HMAC-SHA256 signing
DATABASE_URL app_user pool (RLS enforced)
DATABASE_URL_ADMIN app_admin pool (BYPASSRLS)
BREVO_API_KEY Transactional email for password reset
BREVO_FROM_EMAIL Sender address for password reset emails
APP_URL Base URL for reset link generation (server-side only)