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>
This commit is contained in:
2026-07-23 16:20:04 +08:00
co-authored by Claude Sonnet 4.6
parent a95273b182
commit d18d29168a
67 changed files with 966 additions and 591 deletions
+44 -62
View File
@@ -1,14 +1,19 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { createClient } from '@/lib/supabase/server'
import { isValidRole } from '@/lib/auth/roles'
import { requireAdmin } from '@/lib/auth/require-admin'
import { hashPassword } from '@/lib/auth/password'
import { asAdmin } from '@/lib/db/with-user'
import { users } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'
export async function GET() {
const { supabase, user } = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { session } = await requireAdmin()
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const supabase = await createClient()
const { data, error } = await supabase
.from('users')
.select('id, name, email, phone, role, department, site_id, active, created_at')
@@ -19,10 +24,10 @@ export async function GET() {
}
export async function POST(request: NextRequest) {
const { supabase, user } = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { session } = await requireAdmin()
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const body: { email?: string; name?: string; phone?: string; role?: string; site_id?: string; password?: string } =
const body: { email?: string; name?: string; phone?: string; role?: string; site_id?: string; department?: string; password?: string } =
await request.json().catch(() => ({}))
const email = (body.email ?? '').trim().toLowerCase()
if (!email || !email.includes('@'))
@@ -35,59 +40,39 @@ export async function POST(request: NextRequest) {
if (password.length < 8)
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 422 })
let admin
try {
admin = createAdminClient()
} catch {
return NextResponse.json(
{ error: 'User creation unavailable: SUPABASE_SERVICE_ROLE_KEY not configured' },
{ status: 503 },
)
}
const passwordHash = await hashPassword(password)
const { data: created, error: createError } = await admin.auth.admin.createUser({
const [created] = await asAdmin(db => db.insert(users).values({
name: body.name ?? '',
email,
password,
email_confirm: true,
user_metadata: { full_name: body.name ?? '' },
})
if (createError || !created?.user)
return NextResponse.json(
{ error: createError?.message ?? 'User creation failed' },
{ status: (createError as { status?: number } | null)?.status ?? 500 },
)
const newUserId = created.user.id
phone: (body.phone ?? '').trim() || null,
role: (body.role as typeof users.$inferInsert['role']) ?? 'reporter',
siteId: body.site_id ?? null,
department: body.department ?? null,
passwordHash,
emailVerifiedAt: new Date(),
}).returning({ id: users.id }))
const { error: profileError } = await admin
.from('users')
.update({
name: body.name ?? '',
phone: (body.phone ?? '').trim() || null,
role: body.role ?? 'reporter',
site_id: body.site_id ?? null,
})
.eq('id', newUserId)
if (profileError) {
await admin.auth.admin.deleteUser(newUserId)
return NextResponse.json(
{ error: 'User created but profile update failed' },
{ status: 500 },
)
if (!created) {
return NextResponse.json({ error: 'User creation failed' }, { status: 500 })
}
const supabase = await createClient()
await supabase.rpc('write_audit_log', {
p_table_name: 'users',
p_record_id: newUserId,
p_record_id: created.id,
p_action: 'created',
p_new_value: { email, role: body.role ?? 'reporter', site_id: body.site_id ?? null },
})
return NextResponse.json({ id: newUserId }, { status: 201 })
return NextResponse.json({ id: created.id }, { status: 201 })
}
export async function PATCH(request: NextRequest) {
const { supabase, user } = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { session } = await requireAdmin()
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const supabase = await createClient()
const body: {
id?: string
@@ -100,9 +85,9 @@ export async function PATCH(request: NextRequest) {
if (!body.id) return NextResponse.json({ error: 'id required' }, { status: 422 })
if (body.role !== undefined && !isValidRole(body.role))
return NextResponse.json({ error: 'Invalid role' }, { status: 422 })
if (body.id === user.id && body.active === false)
if (body.id === session.sub && body.active === false)
return NextResponse.json({ error: 'Cannot deactivate your own account' }, { status: 422 })
if (body.id === user.id && body.role !== undefined && body.role !== 'admin')
if (body.id === session.sub && body.role !== undefined && body.role !== 'admin')
return NextResponse.json({ error: 'Cannot demote your own account' }, { status: 422 })
const update: Record<string, unknown> = {}
@@ -132,32 +117,29 @@ export async function PATCH(request: NextRequest) {
}
export async function DELETE(request: NextRequest) {
const { supabase, user } = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { session } = await requireAdmin()
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { searchParams } = new URL(request.url)
const id = searchParams.get('id')
if (!id) return NextResponse.json({ error: 'id required' }, { status: 422 })
if (id === user.id)
if (id === session.sub)
return NextResponse.json({ error: 'Cannot delete your own account' }, { status: 422 })
const supabase = await createClient()
// fetch user info for audit before deletion
const { data: target } = await supabase
.from('users').select('email, name, role').eq('id', id).single()
let admin
try {
admin = createAdminClient()
} catch {
return NextResponse.json(
{ error: 'SUPABASE_SERVICE_ROLE_KEY not configured' },
{ status: 503 },
)
}
// Delete user directly from DB
const [deleted] = await asAdmin(db =>
db.delete(users).where(eq(users.id, id)).returning({ id: users.id })
)
const { error } = await admin.auth.admin.deleteUser(id)
if (error)
return NextResponse.json({ error: error.message ?? 'Deletion failed' }, { status: 500 })
if (!deleted) {
return NextResponse.json({ error: 'User not found or deletion failed' }, { status: 500 })
}
if (target) {
await supabase.rpc('write_audit_log', {