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:
@@ -2,10 +2,12 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireAdmin } from '@/lib/auth/require-admin'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
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 supabase = await createClient()
|
||||
|
||||
const body: { kind?: 'site' | 'zone'; name?: string; address?: string; site_id?: string } =
|
||||
await request.json().catch(() => ({}))
|
||||
@@ -47,8 +49,9 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
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: { kind?: 'site' | 'zone'; id?: string; active?: boolean } =
|
||||
await request.json().catch(() => ({}))
|
||||
@@ -70,8 +73,9 @@ 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 supabase = await createClient()
|
||||
|
||||
const body: { kind?: 'site' | 'zone'; id?: string } = await request.json().catch(() => ({}))
|
||||
if (!body.id) return NextResponse.json({ error: 'id required' }, { status: 422 })
|
||||
|
||||
@@ -2,10 +2,12 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireAdmin } from '@/lib/auth/require-admin'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
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 supabase = await createClient()
|
||||
|
||||
const body: { truck_no?: string; carrier?: string } = await request.json().catch(() => ({}))
|
||||
const truck_no = (body.truck_no ?? '').trim()
|
||||
@@ -34,8 +36,9 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
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; active?: boolean } = await request.json().catch(() => ({}))
|
||||
if (!body.id) return NextResponse.json({ error: 'id required' }, { status: 422 })
|
||||
@@ -55,8 +58,9 @@ 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 supabase = await createClient()
|
||||
|
||||
const body: { id?: string } = await request.json().catch(() => ({}))
|
||||
if (!body.id) return NextResponse.json({ error: 'id required' }, { status: 422 })
|
||||
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user