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
+5 -22
View File
@@ -2,36 +2,25 @@
import { useState } from 'react'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!email) return
setLoading(true)
setError(null)
const supabase = createClient()
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? ''
const redirectTo = `${appUrl}/api/auth/callback?next=/reset-password`
const { error: resetError } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo,
await fetch('/api/auth/reset-request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
setLoading(false)
if (resetError) {
setError('Something went wrong. Please try again.')
return
}
// Always show success — never reveal whether email exists
// Always show "check your email" regardless of response (silent for non-existent users)
setSubmitted(true)
}
@@ -61,12 +50,6 @@ export default function ForgotPasswordPage() {
</p>
</div>
{error && (
<p className="bg-red-50 border border-red-200 text-red-700 text-sm rounded px-3 py-2">
{error}
</p>
)}
<input
type="email"
placeholder="Email"
+13 -41
View File
@@ -1,24 +1,16 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
export default function ResetPasswordPage() {
const router = useRouter()
const searchParams = useSearchParams()
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [sessionReady, setSessionReady] = useState<boolean | null>(null)
useEffect(() => {
const supabase = createClient()
supabase.auth.getSession().then(({ data: { session } }) => {
setSessionReady(!!session)
})
}, [])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
@@ -34,39 +26,19 @@ export default function ResetPasswordPage() {
}
setLoading(true)
const supabase = createClient()
const { error: updateError } = await supabase.auth.updateUser({ password })
const res = await fetch('/api/auth/reset-confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: searchParams.get('token'), password }),
})
setLoading(false)
if (updateError) {
setError('Reset link has expired. Request a new one.')
return
if (res.ok) {
router.push('/login?reset=success')
} else {
const data = await res.json()
setError(data.error ?? 'Reset failed')
}
router.push('/login?message=password_reset')
}
if (sessionReady === null) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<p className="text-sm text-gray-500">Loading</p>
</div>
)
}
if (!sessionReady) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-sm text-center space-y-3">
<p className="bg-red-50 border border-red-200 text-red-700 text-sm rounded px-3 py-2">
Reset link has expired or is invalid.
</p>
<Link href="/forgot-password" className="text-sm text-blue-600 hover:underline">
Request a new reset link
</Link>
</div>
</div>
)
}
return (
+5 -6
View File
@@ -3,18 +3,17 @@ export const dynamic = 'force-dynamic'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
import { UserManager, type AdminUser, type SiteOption } from '@/components/admin/user-manager'
import { SiteZoneManager, type SiteWithZones } from '@/components/admin/site-zone-manager'
import { TruckManager, type Truck } from '@/components/admin/truck-manager'
export default async function AdminHome() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const session = await getSession()
if (!session) redirect('/login')
if (session.role !== 'admin') redirect('/login')
const { data: profile } = await supabase
.from('users').select('role').eq('id', user.id).single()
if (!profile || profile.role !== 'admin') redirect('/login')
const supabase = await createClient()
const [{ data: users }, { data: sites }, { data: trucks }] = await Promise.all([
supabase
+6 -6
View File
@@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth/get-session'
import { StatCard } from '@/components/dashboard/stat-card'
import { CapaOwnerActions } from '@/components/capa/capa-owner-actions'
import { CapaOwnerNotesForm } from '@/components/capa/capa-owner-notes-form'
@@ -28,17 +29,16 @@ const STATUS_COLORS: Record<string, string> = {
}
export default async function CapaOwnerPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const session = await getSession()
if (!session) redirect('/login')
if (!['capa_owner', 'admin', 'hse', 'supervisor'].includes(session.role)) redirect('/')
const { data: profile } = await supabase.from('users').select('name, role').eq('id', user.id).single()
if (!profile || !['capa_owner', 'admin', 'hse', 'supervisor'].includes(profile.role)) redirect('/')
const supabase = await createClient()
const { data: capas } = await supabase
.from('capa_actions')
.select('id, description, due_date, priority, status, incident_id, owner_notes, incidents (reference_no)')
.eq('owner_user_id', user.id)
.eq('owner_user_id', session.sub)
.order('due_date', { ascending: true, nullsFirst: false })
const rows = capas ?? []
+5 -6
View File
@@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic'
import { notFound, redirect } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
import { VerifyForm } from '@/components/capa/verify-form'
import { CloseCapaButton } from '@/components/capa/close-capa-button'
@@ -18,12 +19,10 @@ const PRIORITY_BADGE: Record<string, string> = {
export default async function CapaDetailPage({ params }: Props) {
const { id } = await params
const supabase = await createClient()
const { data, error: authError } = await supabase.auth.getUser()
if (authError || !data?.user) redirect(`/login?redirect=/hse/capa/${id}`)
const session = await getSession()
if (!session) redirect(`/login?redirect=/hse/capa/${id}`)
const { data: profile } = await supabase
.from('users').select('role').eq('id', data.user.id).single()
const supabase = await createClient()
const { data: capa } = await supabase
.from('capa_actions')
@@ -39,7 +38,7 @@ export default async function CapaDetailPage({ params }: Props) {
if (!capa) notFound()
const isHse = profile && ['hse', 'admin'].includes(profile.role)
const isHse = session && ['hse', 'admin'].includes(session.role)
const status = (capa as { status: string }).status
return (
+4 -2
View File
@@ -3,12 +3,14 @@ export const dynamic = 'force-dynamic'
import { redirect } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
import { CapaBoard } from '@/components/capa/capa-board'
export default async function CapaListPage() {
const session = await getSession()
if (!session) redirect('/login?redirect=/hse/capa')
const supabase = await createClient()
const { data, error: authError } = await supabase.auth.getUser()
if (authError || !data?.user) redirect('/login?redirect=/hse/capa')
const { data: capas } = await supabase
.from('capa_actions')
@@ -1,6 +1,7 @@
import { notFound, redirect } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
import { CapaForm } from '@/components/capa/capa-form'
interface Props {
@@ -9,13 +10,11 @@ interface Props {
export default async function NewCapaPage({ params }: Props) {
const { id } = await params
const supabase = await createClient()
const { data, error: authError } = await supabase.auth.getUser()
if (authError || !data?.user) redirect(`/login?redirect=/hse/incidents/${id}/capa/new`)
const session = await getSession()
if (!session) redirect(`/login?redirect=/hse/incidents/${id}/capa/new`)
if (!['hse', 'admin'].includes(session.role)) redirect('/hse/incidents')
const { data: profile } = await supabase
.from('users').select('role').eq('id', data.user.id).single()
if (!profile || !['hse', 'admin'].includes(profile.role)) redirect('/hse/incidents')
const supabase = await createClient()
const { data: incident } = await supabase
.from('incidents').select('id, reference_no, status').eq('id', id).single()
@@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic'
import { notFound, redirect } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
import { InvestigationForm } from '@/components/incidents/investigation-form'
interface Props {
@@ -11,15 +12,12 @@ interface Props {
export default async function InvestigationPage({ params }: Props) {
const { id } = await params
const session = await getSession()
if (!session) redirect(`/login?redirect=/hse/incidents/${id}/investigation`)
if (!['hse', 'admin'].includes(session.role)) redirect('/hse/incidents')
const supabase = await createClient()
const { data, error: authError } = await supabase.auth.getUser()
if (authError || !data?.user) redirect(`/login?redirect=/hse/incidents/${id}/investigation`)
const { data: profile } = await supabase
.from('users').select('role').eq('id', data.user.id).single()
if (!profile || !['hse', 'admin'].includes(profile.role)) redirect('/hse/incidents')
const { data: incident } = await supabase
.from('incidents')
.select('id, reference_no, status')
@@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic'
import { notFound, redirect } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
import { TriageForm } from '@/components/incidents/triage-form'
interface Props {
@@ -11,15 +12,12 @@ interface Props {
export default async function TriagePage({ params }: Props) {
const { id } = await params
const session = await getSession()
if (!session) redirect(`/login?redirect=/hse/incidents/${id}/triage`)
if (!['hse', 'admin'].includes(session.role)) redirect('/hse/incidents')
const supabase = await createClient()
const { data, error: authError } = await supabase.auth.getUser()
if (authError || !data?.user) redirect(`/login?redirect=/hse/incidents/${id}/triage`)
const { data: profile } = await supabase
.from('users').select('role').eq('id', data.user.id).single()
if (!profile || !['hse', 'admin'].includes(profile.role)) redirect('/hse/incidents')
const { data: incident } = await supabase
.from('incidents')
.select('id, reference_no, incident_type, status, severity')
+4 -7
View File
@@ -1,20 +1,17 @@
export const dynamic = 'force-dynamic'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth/get-session'
export default async function HsePage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: profile } = await supabase.from('users').select('name').eq('id', user.id).single()
const session = await getSession()
if (!session) redirect('/login')
return (
<main className="max-w-4xl mx-auto px-4 py-6">
<h1 className="text-2xl font-bold text-gray-900 mb-1">HSE Officer Portal</h1>
<p className="text-gray-500 text-sm mb-8">Welcome, {profile?.name ?? user.email}</p>
<p className="text-gray-500 text-sm mb-8">Welcome, {session.name}</p>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<Link href="/hse/incidents" className="bg-white rounded-xl shadow-sm p-5 hover:shadow-md transition-shadow border border-gray-100">
<div className="text-2xl mb-2">📋</div>
+5 -5
View File
@@ -2,15 +2,15 @@ export const dynamic = 'force-dynamic'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
import { ApiKeyForm } from '@/components/settings/api-key-form'
export default async function SettingsPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const session = await getSession()
if (!session) redirect('/login')
if (session.role !== 'admin') redirect('/hse/dashboard')
const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single()
if (!profile || profile.role !== 'admin') redirect('/hse/dashboard')
const supabase = await createClient()
const { data: settings } = await supabase
.from('app_settings')
+6 -16
View File
@@ -1,7 +1,7 @@
export const dynamic = 'force-dynamic'
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth/get-session'
import { Sidebar } from '@/components/layout/sidebar'
export default async function ProtectedLayout({
@@ -9,25 +9,15 @@ export default async function ProtectedLayout({
}: {
children: React.ReactNode
}) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: profile } = await supabase
.from('users')
.select('role, name, email')
.eq('id', user.id)
.single()
const session = await getSession()
if (!session) redirect('/login')
return (
<div className="min-h-screen bg-gray-50">
<Sidebar
role={profile?.role ?? 'reporter'}
userName={profile?.name ?? user.email ?? ''}
userEmail={profile?.email ?? user.email ?? ''}
role={session.role}
userName={session.name}
userEmail=""
/>
<div className="sm:ml-60 pb-16 sm:pb-0">
{children}
+5 -5
View File
@@ -2,16 +2,16 @@ export const dynamic = 'force-dynamic'
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth/get-session'
import { StatCard } from '@/components/dashboard/stat-card'
import { RiskFlagsPanel } from '@/components/dashboard/risk-flags-panel'
export default async function ManagementPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const session = await getSession()
if (!session) redirect('/login')
if (!['management', 'admin'].includes(session.role)) redirect('/')
const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single()
if (!profile || !['management', 'admin'].includes(profile.role)) redirect('/')
const supabase = await createClient()
const now = new Date()
const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString()
@@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { redirect, notFound } from 'next/navigation'
import { getSession } from '@/lib/auth/get-session'
const STATUS_LABELS: Record<string, string> = {
reported: 'Reported', triaged: 'Triaged', investigating: 'Investigating',
@@ -20,9 +21,10 @@ export default async function ReporterIncidentDetail({
params: Promise<{ id: string }>
}) {
const { id } = await params
const session = await getSession()
if (!session) redirect('/login')
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: inc } = await supabase
.from('incidents')
@@ -33,7 +35,7 @@ export default async function ReporterIncidentDetail({
capa_actions (id, description, status, due_date)
`)
.eq('id', id)
.eq('reported_by', user.id)
.eq('reported_by', session.sub)
.single()
if (!inc) notFound()
+6 -6
View File
@@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth/get-session'
const STATUS_LABELS: Record<string, string> = {
reported: 'Reported',
@@ -28,16 +29,15 @@ const TYPE_LABELS: Record<string, string> = {
}
export default async function ReporterPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const session = await getSession()
if (!session) redirect('/login')
const { data: profile } = await supabase.from('users').select('name').eq('id', user.id).single()
const supabase = await createClient()
const { data: incidents } = await supabase
.from('incidents')
.select('id, reference_no, incident_type, status, reported_at, severity, sites (name)')
.eq('reported_by', user.id)
.eq('reported_by', session.sub)
.order('reported_at', { ascending: false })
const rows = incidents ?? []
@@ -49,7 +49,7 @@ export default async function ReporterPage() {
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">My Reports</h1>
<p className="text-sm text-gray-400 mt-0.5">Welcome, {profile?.name ?? user.email}</p>
<p className="text-sm text-gray-400 mt-0.5">Welcome, {session.name}</p>
</div>
<Link
href="/report"
+12 -15
View File
@@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth/get-session'
import { StatCard } from '@/components/dashboard/stat-card'
const STATUS_LABELS: Record<string, string> = {
@@ -24,17 +25,13 @@ const STATUS_COLORS: Record<string, string> = {
}
export default async function SupervisorPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const session = await getSession()
if (!session) redirect('/login')
if (!['supervisor', 'admin'].includes(session.role)) redirect('/')
const { data: profile } = await supabase
.from('users')
.select('name, site_id, role')
.eq('id', user.id)
.single()
if (!profile || !['supervisor', 'admin'].includes(profile.role)) redirect('/')
if (!profile.site_id) {
const supabase = await createClient()
if (!session.siteId) {
return (
<main className="max-w-4xl mx-auto px-4 py-6">
<h1 className="text-2xl font-bold text-gray-900 mb-4">Supervisor Portal</h1>
@@ -43,14 +40,14 @@ export default async function SupervisorPage() {
)
}
const { data: siteRow } = await supabase.from('sites').select('name').eq('id', profile.site_id).single()
const { data: siteRow } = await supabase.from('sites').select('name').eq('id', session.siteId).single()
const siteName = (siteRow as unknown as { name: string } | null)?.name ?? 'Your Site'
// Resolve incident IDs once to avoid duplicate queries inside Promise.all
const { data: siteIncidents } = await supabase
.from('incidents')
.select('id')
.eq('site_id', profile.site_id)
.eq('site_id', session.siteId)
const incidentIds = siteIncidents?.map(r => r.id) ?? []
const [
@@ -63,19 +60,19 @@ export default async function SupervisorPage() {
supabase
.from('incidents')
.select('id, reference_no, incident_type, status, reported_at')
.eq('site_id', profile.site_id)
.eq('site_id', session.siteId)
.neq('status', 'closed')
.order('reported_at', { ascending: false })
.limit(10),
supabase
.from('incidents')
.select('*', { count: 'exact', head: true })
.eq('site_id', profile.site_id)
.eq('site_id', session.siteId)
.eq('status', 'closed'),
supabase
.from('incidents')
.select('*', { count: 'exact', head: true })
.eq('site_id', profile.site_id)
.eq('site_id', session.siteId)
.neq('status', 'closed'),
supabase
.from('capa_actions')
+10 -6
View File
@@ -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 })
+10 -6
View File
@@ -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 })
+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', {
-25
View File
@@ -1,25 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { EmailOtpType } from '@supabase/supabase-js'
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const code = searchParams.get('code')
const token_hash = searchParams.get('token_hash')
const type = searchParams.get('type') as EmailOtpType | null
const nextRaw = searchParams.get('next') ?? '/'
const next = nextRaw.startsWith('/') && !nextRaw.startsWith('//') ? nextRaw : '/'
const appUrl = (process.env.NEXT_PUBLIC_APP_URL ?? '').replace(/\/$/, '')
const supabase = await createClient()
if (code) {
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) return NextResponse.redirect(`${appUrl}${next}`)
} else if (token_hash && type) {
const { error } = await supabase.auth.verifyOtp({ token_hash, type })
if (!error) return NextResponse.redirect(`${appUrl}${next}`)
}
return NextResponse.redirect(`${appUrl}/login?error=auth_callback_failed`)
}
+32
View File
@@ -0,0 +1,32 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth/get-session'
import { asAdmin } from '@/lib/db/with-user'
import { users } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'
import { verifyPassword, hashPassword } from '@/lib/auth/password'
export async function POST(req: NextRequest) {
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { currentPassword, newPassword } = await req.json()
if (!currentPassword || !newPassword || newPassword.length < 8) {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
}
const [user] = await asAdmin(db =>
db.select({ passwordHash: users.passwordHash })
.from(users).where(eq(users.id, session.sub)).limit(1)
)
if (!user || !await verifyPassword(currentPassword, user.passwordHash)) {
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 400 })
}
const newHash = await hashPassword(newPassword)
await asAdmin(db => db.update(users).set({ passwordHash: newHash }).where(eq(users.id, session.sub)))
return NextResponse.json({ ok: true })
}
+61
View File
@@ -0,0 +1,61 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { asAdmin } from '@/lib/db/with-user'
import { users } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'
import { verifyPassword } from '@/lib/auth/password'
import { createSession } from '@/lib/auth/session'
export async function POST(req: NextRequest) {
const { email, password } = await req.json()
if (!email || !password) {
return NextResponse.json({ error: 'Email and password required' }, { status: 400 })
}
const [user] = await asAdmin(db =>
db.select({
id: users.id,
email: users.email,
name: users.name,
role: users.role,
siteId: users.siteId,
passwordHash: users.passwordHash,
active: users.active,
}).from(users).where(eq(users.email, email.toLowerCase())).limit(1)
)
if (!user || !user.active) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
}
if (!user.passwordHash) {
return NextResponse.json({ error: 'Account not configured — contact admin' }, { status: 401 })
}
const valid = await verifyPassword(password, user.passwordHash)
if (!valid) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
}
// Update last_login_at
await asAdmin(db => db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, user.id)))
const token = await createSession({
sub: user.id,
role: user.role,
siteId: user.siteId ?? null,
name: user.name,
})
const appUrl = process.env.APP_URL ?? ''
const res = NextResponse.json({ ok: true })
res.cookies.set('ims_session', token, {
httpOnly: true,
secure: appUrl.startsWith('https'),
sameSite: 'lax',
maxAge: 8 * 60 * 60,
path: '/',
})
return res
}
+13
View File
@@ -0,0 +1,13 @@
export const dynamic = 'force-dynamic'
import { NextResponse } from 'next/server'
export async function POST() {
const res = NextResponse.json({ ok: true })
res.cookies.set('ims_session', '', {
httpOnly: true,
maxAge: 0,
path: '/',
})
return res
}
+41
View File
@@ -0,0 +1,41 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { asAdmin } from '@/lib/db/with-user'
import { users, passwordResetTokens } from '@/lib/db/schema'
import { eq, and, gt, isNull } from 'drizzle-orm'
import { createHash } from 'crypto'
import { hashPassword } from '@/lib/auth/password'
export async function POST(req: NextRequest) {
const { token, password } = await req.json()
if (!token || !password || password.length < 8) {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
}
const tokenHash = createHash('sha256').update(token).digest('hex')
const now = new Date()
const [row] = await asAdmin(db =>
db.select({ id: passwordResetTokens.id, userId: passwordResetTokens.userId })
.from(passwordResetTokens)
.where(and(
eq(passwordResetTokens.tokenHash, tokenHash),
gt(passwordResetTokens.expiresAt, now),
isNull(passwordResetTokens.usedAt),
)).limit(1)
)
if (!row) {
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 400 })
}
const newHash = await hashPassword(password)
await asAdmin(async db => {
await db.update(users).set({ passwordHash: newHash }).where(eq(users.id, row.userId))
await db.update(passwordResetTokens).set({ usedAt: now }).where(eq(passwordResetTokens.id, row.id))
})
return NextResponse.json({ ok: true })
}
+37
View File
@@ -0,0 +1,37 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { asAdmin } from '@/lib/db/with-user'
import { users, passwordResetTokens } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'
import { createHash, randomBytes } from 'crypto'
import { sendPasswordResetEmail } from '@/lib/notifications/mailer'
export async function POST(req: NextRequest) {
const { email } = await req.json()
if (!email) return NextResponse.json({ ok: true }) // don't reveal user existence
const [user] = await asAdmin(db =>
db.select({ id: users.id, name: users.name }).from(users)
.where(eq(users.email, email.toLowerCase())).limit(1)
)
if (!user) return NextResponse.json({ ok: true }) // silent
const rawToken = randomBytes(32).toString('hex')
const tokenHash = createHash('sha256').update(rawToken).digest('hex')
const expiresAt = new Date(Date.now() + 60 * 60 * 1000) // 1 hour
await asAdmin(db => db.insert(passwordResetTokens).values({
userId: user.id,
tokenHash,
expiresAt,
}))
const appUrl = process.env.APP_URL ?? ''
const resetLink = `${appUrl}/reset-password?token=${rawToken}`
await sendPasswordResetEmail({ to: email, name: user.name, resetLink })
return NextResponse.json({ ok: true })
}
+11 -11
View File
@@ -3,15 +3,17 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { getSession } from '@/lib/auth/get-session'
export async function GET(
_: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data, error } = await supabase
.from('capa_actions')
@@ -27,9 +29,8 @@ export async function GET(
if (error || !data) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single()
const role = profile?.role ?? ''
const isOwner = data.owner_user_id === user.id
const role = session.role
const isOwner = data.owner_user_id === session.sub
const canRead = ['hse', 'admin', 'supervisor'].includes(role) || isOwner
if (!canRead) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
@@ -46,15 +47,14 @@ export async function PATCH(
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single()
const role = profile?.role ?? ''
const supabase = await createClient()
const role = session.role
const { data: capa } = await supabase.from('capa_actions').select('owner_user_id, status').eq('id', id).single()
const isOwner = capa?.owner_user_id === user.id
const isOwner = capa?.owner_user_id === session.sub
const canEdit = ['hse', 'admin'].includes(role) || isOwner
if (!canEdit) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
+7 -9
View File
@@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
import { createInAppNotifications } from '@/lib/notifications/in-app'
export async function POST(
@@ -9,16 +10,13 @@ export async function POST(
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase
.from('users').select('role').eq('id', user.id).single()
if (!profile || !['hse', 'admin'].includes(profile.role))
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!['hse', 'admin'].includes(session.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const supabase = await createClient()
const { data: capa } = await supabase
.from('capa_actions').select('status, incident_id, owner_user_id').eq('id', id).single()
if (!capa) return NextResponse.json({ error: 'Not found' }, { status: 404 })
@@ -35,7 +33,7 @@ export async function POST(
const update: Record<string, unknown> = {
status: body.verdict,
verified_by: user.id,
verified_by: session.sub,
verified_at: verifiedAt.toISOString(),
...(body.verdict === 'verified'
? {
+11 -15
View File
@@ -2,16 +2,14 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
import { createInAppNotifications } from '@/lib/notifications/in-app'
export async function GET() {
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase
.from('users').select('role').eq('id', user.id).single()
if (!profile) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const supabase = await createClient()
let query = supabase
.from('capa_actions')
@@ -23,8 +21,8 @@ export async function GET() {
`)
.order('due_date', { ascending: true })
if (profile.role === 'supervisor' || profile.role === 'worker') {
query = query.eq('owner_user_id', user.id)
if (session.role === 'supervisor' || session.role === 'worker') {
query = query.eq('owner_user_id', session.sub)
}
const { data, error } = await query
@@ -33,15 +31,13 @@ export async function GET() {
}
export async function POST(request: NextRequest) {
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase
.from('users').select('role').eq('id', user.id).single()
if (!profile || !['hse', 'admin'].includes(profile.role))
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!['hse', 'admin'].includes(session.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const supabase = await createClient()
const body = await request.json()
const { incident_id, description, owner_user_id, department, due_date, priority, root_cause_ref } = body
+8 -8
View File
@@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic'
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
import { createDeepSeekClient } from '@/lib/claude/client'
import { getApiKey } from '@/lib/settings'
@@ -18,14 +19,13 @@ type ZoneAggregate = {
}
export async function POST() {
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single()
if (!profile || !['hse', 'admin', 'management'].includes(profile.role))
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!['hse', 'admin', 'management'].includes(session.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const supabase = await createClient()
const now = new Date()
const ninetyDaysAgo = new Date(now)
ninetyDaysAgo.setDate(now.getDate() - 90)
@@ -82,7 +82,7 @@ export async function POST() {
const { data: lastCall } = await supabase
.from('audit_log')
.select('changed_at')
.eq('changed_by', user.id)
.eq('changed_by', session.sub)
.eq('action', 'ai_risk_flags')
.order('changed_at', { ascending: false })
.limit(1)
@@ -166,7 +166,7 @@ ${JSON.stringify(aggregates, null, 2)}
await supabase.rpc('write_audit_log', {
p_table_name: 'incidents',
p_record_id: user.id,
p_record_id: session.sub,
p_action: 'ai_risk_flags',
p_new_value: { flags, summary: input.summary, model: 'deepseek-chat' } as never,
})
+7 -8
View File
@@ -2,15 +2,12 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
import { rowsToCsv } from '@/lib/csv'
export async function GET(request: NextRequest) {
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single()
if (!profile) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const role = request.nextUrl.searchParams.get('role') ?? 'hse'
@@ -19,10 +16,12 @@ export async function GET(request: NextRequest) {
management: ['management', 'admin'],
}
if (!allowedRoles[role] || !allowedRoles[role].includes(profile.role)) {
if (!allowedRoles[role] || !allowedRoles[role].includes(session.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const supabase = await createClient()
const { data: incidents } = await supabase
.from('incidents')
.select(`
@@ -64,7 +63,7 @@ export async function GET(request: NextRequest) {
await supabase.rpc('write_audit_log', {
p_table_name: 'incidents',
p_record_id: user.id,
p_record_id: session.sub,
p_action: 'export_csv',
p_new_value: { role, row_count: rows.length } as never,
})
+6 -6
View File
@@ -2,16 +2,16 @@ export const dynamic = 'force-dynamic'
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single()
if (!profile || !['hse', 'admin', 'management'].includes(profile.role))
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!['hse', 'admin', 'management'].includes(session.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const supabase = await createClient()
const { data: incidents, error } = await supabase
.from('incidents')
.select('id, status, incident_type, sites (name)')
+13 -17
View File
@@ -2,21 +2,20 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single()
if (!profile || !['hse', 'admin', 'supervisor'].includes(profile.role))
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!['hse', 'admin', 'supervisor'].includes(session.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const supabase = await createClient()
const { data, error } = await supabase
.from('incident_addenda')
.select('id, body, created_at, author:users!author (name)')
@@ -32,16 +31,13 @@ export async function POST(
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase
.from('users').select('role').eq('id', user.id).single()
if (!profile || !['hse', 'admin', 'supervisor'].includes(profile.role))
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!['hse', 'admin', 'supervisor'].includes(session.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const supabase = await createClient()
const body: { body?: string } = await request.json().catch(() => ({}))
const text = (body.body ?? '').trim()
if (!text) return NextResponse.json({ error: 'body required' }, { status: 422 })
@@ -50,7 +46,7 @@ export async function POST(
const { data: addendum, error } = await supabase
.from('incident_addenda')
.insert({ incident_id: id, author: user.id, body: text })
.insert({ incident_id: id, author: session.sub, body: text })
.select('id')
.single()
@@ -60,7 +56,7 @@ export async function POST(
p_table_name: 'incident_addenda',
p_record_id: addendum.id,
p_action: 'INSERT',
p_new_value: { incident_id: id, author: user.id, body: text },
p_new_value: { incident_id: id, author: session.sub, body: text },
})
return NextResponse.json({ id: addendum.id }, { status: 201 })
+7 -7
View File
@@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
import { createDeepSeekClient } from '@/lib/claude/client'
import { getApiKey } from '@/lib/settings'
@@ -10,19 +11,18 @@ export async function POST(
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single()
if (!profile || !['hse', 'admin'].includes(profile.role))
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!['hse', 'admin'].includes(session.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const supabase = await createClient()
const since = new Date(Date.now() - 60_000).toISOString()
const { count: recentCount } = await supabase
.from('audit_log')
.select('id', { count: 'exact', head: true })
.eq('changed_by', user.id)
.eq('changed_by', session.sub)
.eq('action', 'ai_rca_draft')
.gte('changed_at', since)
if ((recentCount ?? 0) > 0)
@@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
import { createDeepSeekClient } from '@/lib/claude/client'
import { getApiKey } from '@/lib/settings'
@@ -10,19 +11,18 @@ export async function POST(
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single()
if (!profile || !['hse', 'admin'].includes(profile.role))
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!['hse', 'admin'].includes(session.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const supabase = await createClient()
const since = new Date(Date.now() - 60_000).toISOString()
const { count: recentCount } = await supabase
.from('audit_log')
.select('id', { count: 'exact', head: true })
.eq('changed_by', user.id)
.eq('changed_by', session.sub)
.eq('action', 'ai_triage_suggest')
.gte('changed_at', since)
if ((recentCount ?? 0) > 0)
+7 -9
View File
@@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
import { createInAppNotifications } from '@/lib/notifications/in-app'
export async function POST(
@@ -9,16 +10,13 @@ export async function POST(
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase
.from('users').select('role').eq('id', user.id).single()
if (!profile || !['hse', 'admin'].includes(profile.role))
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!['hse', 'admin'].includes(session.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const supabase = await createClient()
const { data: incident } = await supabase
.from('incidents')
.select('status, reference_no, reported_by')
@@ -49,7 +47,7 @@ export async function POST(
p_table_name: 'incidents',
p_record_id: id,
p_action: 'closed',
p_new_value: { status: 'closed', closed_at: closedAt, closed_by: user.id },
p_new_value: { status: 'closed', closed_at: closedAt, closed_by: session.sub },
})
if (incident.reported_by) {
+12 -17
View File
@@ -2,22 +2,20 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase
.from('users').select('role').eq('id', user.id).single()
if (!profile || !['hse', 'admin'].includes(profile.role))
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!['hse', 'admin'].includes(session.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const supabase = await createClient()
const { data: incident } = await supabase
.from('incidents').select('status').eq('id', id).single()
if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 })
@@ -38,7 +36,7 @@ export async function POST(
.from('investigations')
.insert({
incident_id: id,
investigator_id: user.id,
investigator_id: session.sub,
method,
findings_text: body.findings_text ?? null,
root_cause_summary: body.root_cause_summary ?? null,
@@ -73,16 +71,13 @@ export async function PATCH(
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase
.from('users').select('role').eq('id', user.id).single()
if (!profile || !['hse', 'admin'].includes(profile.role))
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!['hse', 'admin'].includes(session.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const supabase = await createClient()
const body = await request.json()
const { investigation_id, complete, ...fields } = body
+6 -7
View File
@@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
import { buildJkkp6Pdf, buildJkkp7Pdf, type JkkpIncident } from '@/lib/pdf/jkkp'
import { computeDoshObligation } from '@/lib/incidents/dosh'
@@ -14,15 +15,13 @@ export async function GET(
if (form !== 'jkkp6' && form !== 'jkkp7')
return NextResponse.json({ error: 'form must be jkkp6 or jkkp7' }, { status: 422 })
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase
.from('users').select('role').eq('id', user.id).single()
if (!profile || !['hse', 'admin'].includes(profile.role))
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!['hse', 'admin'].includes(session.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const supabase = await createClient()
const { data: incident } = await supabase
.from('incidents')
.select(`
+6 -7
View File
@@ -1,17 +1,16 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
export const dynamic = 'force-dynamic'
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const supabase = await createClient()
const { data, error: authError } = await supabase.auth.getUser()
if (authError || !data?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const user = data.user
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase.from('users').select('role, site_id').eq('id', user.id).single()
const role = profile?.role ?? ''
const supabase = await createClient()
const role = session.role
const { data: incident, error } = await supabase
.from('incidents')
@@ -33,7 +32,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
}
const reporter = incident.reporter as unknown as { id: string } | null
const isOwner = reporter?.id === user.id
const isOwner = reporter?.id === session.sub
const isSiteStaff = ['hse', 'supervisor', 'admin'].includes(role)
if (!isOwner && !isSiteStaff) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
+6 -6
View File
@@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
import { embedText } from '@/lib/claude/embed'
import { getApiKey } from '@/lib/settings'
@@ -10,14 +11,13 @@ export async function GET(
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single()
if (!profile || !['hse', 'admin'].includes(profile.role))
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!['hse', 'admin'].includes(session.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const supabase = await createClient()
const googleAiKey = await getApiKey(supabase, 'GOOGLE_AI_API_KEY')
const { data: incident } = await supabase
+8 -10
View File
@@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
interface TriageBody {
severity: number
@@ -17,16 +18,13 @@ export async function PATCH(
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase
.from('users').select('role').eq('id', user.id).single()
if (!profile || !['hse', 'admin'].includes(profile.role))
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!['hse', 'admin'].includes(session.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const supabase = await createClient()
const body: TriageBody = await request.json()
if (body.severity < 1 || body.severity > 5)
return NextResponse.json({ error: 'severity must be 15' }, { status: 422 })
@@ -46,7 +44,7 @@ export async function PATCH(
is_dangerous_occurrence: body.is_dangerous_occurrence,
is_occupational_disease: body.is_occupational_disease,
triage_notes: body.triage_notes ?? null,
triaged_by: user.id,
triaged_by: session.sub,
triaged_at: new Date().toISOString(),
status: 'triaged',
})
@@ -58,7 +56,7 @@ export async function PATCH(
p_table_name: 'incidents',
p_record_id: id,
p_action: 'triage',
p_new_value: { severity: body.severity, status: 'triaged', triaged_by: user.id },
p_new_value: { severity: body.severity, status: 'triaged', triaged_by: session.sub },
})
return NextResponse.json({ ok: true })
+6 -4
View File
@@ -2,19 +2,21 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
import { createDeepSeekClient } from '@/lib/claude/client'
import { getApiKey } from '@/lib/settings'
export async function POST(request: NextRequest) {
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const since = new Date(Date.now() - 60_000).toISOString()
const { count: recentCount } = await supabase
.from('audit_log')
.select('id', { count: 'exact', head: true })
.eq('changed_by', user.id)
.eq('changed_by', session.sub)
.eq('action', 'ai_quality_check')
.gte('changed_at', since)
if ((recentCount ?? 0) > 0)
@@ -92,7 +94,7 @@ Score 110 based on: specificity (location, time, persons involved), completen
await supabase.rpc('write_audit_log', {
p_table_name: 'incidents',
p_record_id: user.id,
p_record_id: session.sub,
p_action: 'ai_quality_check',
p_new_value: { score: input.score, passes: input.passes, model: 'deepseek-chat' } as never,
})
+9 -8
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
import { validateIncidentInput, validateTypeDetails, type IncidentType, type MedicalStatus } from '@/lib/incidents/validate'
import { uploadEvidenceFile, type EvidenceStage } from '@/lib/supabase/storage'
import { sendNewIncidentEmail } from '@/lib/notifications/email'
@@ -19,16 +20,16 @@ export async function POST(request: Request) {
}
async function handlePost(request: Request) {
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const supabase = await createClient()
const { data, error: authError } = await supabase.auth.getUser()
if (authError || !data?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const user = data.user
const since = new Date(Date.now() - 60_000).toISOString()
const { count: recentIncidents } = await supabase
.from('audit_log')
.select('id', { count: 'exact', head: true })
.eq('changed_by', user.id)
.eq('changed_by', session.sub)
.eq('table_name', 'incidents')
.eq('action', 'INSERT')
.gte('changed_at', since)
@@ -106,7 +107,7 @@ async function handlePost(request: Request) {
incident_type: input.incident_type,
site_id: zone.site_id,
zone_id: zone.id,
reported_by: user.id,
reported_by: session.sub,
description: input.description.trim(),
injury_involved: input.injury_involved,
asset_involved: input.asset_involved,
@@ -133,14 +134,14 @@ async function handlePost(request: Request) {
for (const file of files) {
try {
const { publicUrl, hash } = await uploadEvidenceFile(supabase, file, incident.id, 'report')
const { publicUrl, hash } = await uploadEvidenceFile(supabase, file, incident.id, 'report', session.sub)
evidenceRows.push({
incident_id: incident.id,
stage: 'report',
file_url: publicUrl,
file_type: file.type,
file_hash: hash,
uploaded_by: user.id,
uploaded_by: session.sub,
})
} catch (err) {
console.error('file upload error:', err)
@@ -155,7 +156,7 @@ async function handlePost(request: Request) {
p_table_name: 'incidents',
p_record_id: incident.id,
p_action: 'INSERT',
p_new_value: { incident_type: input.incident_type, reported_by: user.id },
p_new_value: { incident_type: input.incident_type, reported_by: session.sub },
})
sendNewIncidentEmail(incident.id, zone.site_id, incident.reference_no ?? '', input.incident_type)
+10 -7
View File
@@ -2,17 +2,19 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
export async function GET() {
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: notifications, error } = await supabase
.from('notifications_log')
.select('id, title, link, incident_id, capa_id, sent_at, read_at')
.eq('channel', 'in_app')
.eq('recipient_user_id', user.id)
.eq('recipient_user_id', session.sub)
.order('sent_at', { ascending: false })
.limit(20)
@@ -22,23 +24,24 @@ export async function GET() {
.from('notifications_log')
.select('id', { count: 'exact', head: true })
.eq('channel', 'in_app')
.eq('recipient_user_id', user.id)
.eq('recipient_user_id', session.sub)
.is('read_at', null)
return NextResponse.json({ notifications: notifications ?? [], unread: count ?? 0 })
}
export async function POST(request: NextRequest) {
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body: { ids?: string[]; all?: boolean } = await request.json().catch(() => ({}))
let query = supabase
.from('notifications_log')
.update({ read_at: new Date().toISOString() })
.eq('recipient_user_id', user.id)
.eq('recipient_user_id', session.sub)
.is('read_at', null)
if (!body.all) {
+7 -8
View File
@@ -2,18 +2,17 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
import { buildJkkp8Rows, jkkp8Csv, type Jkkp8Incident } from '@/lib/reports/jkkp8'
export async function GET(request: NextRequest) {
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase
.from('users').select('role').eq('id', user.id).single()
if (!profile || !['hse', 'admin'].includes(profile.role))
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!['hse', 'admin'].includes(session.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const supabase = await createClient()
const yearParam = request.nextUrl.searchParams.get('year')
const year = Number.parseInt(yearParam ?? '', 10) || new Date().getFullYear()
@@ -39,7 +38,7 @@ export async function GET(request: NextRequest) {
await supabase.rpc('write_audit_log', {
p_table_name: 'incidents',
p_record_id: user.id,
p_record_id: session.sub,
p_action: 'jkkp8_register_export',
p_new_value: { year, row_count: rows.length },
})
+9 -14
View File
@@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
const ALLOWED_KEYS = [
'DEEPSEEK_API_KEY',
@@ -11,18 +12,11 @@ const ALLOWED_KEYS = [
] as const
type SettingKey = typeof ALLOWED_KEYS[number]
async function requireAdmin(supabase: Awaited<ReturnType<typeof createClient>>) {
const { data: { user }, error } = await supabase.auth.getUser()
if (error || !user) return null
const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single()
if (!profile || profile.role !== 'admin') return null
return user
}
export async function GET() {
const session = await getSession()
if (!session || session.role !== 'admin') return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const supabase = await createClient()
const user = await requireAdmin(supabase)
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { data } = await supabase.from('app_settings').select('key, value, updated_at')
const masked = (data ?? []).map(row => ({
@@ -35,9 +29,10 @@ export async function GET() {
}
export async function POST(request: NextRequest) {
const session = await getSession()
if (!session || session.role !== 'admin') return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const supabase = await createClient()
const user = await requireAdmin(supabase)
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
let body: { key?: string; value?: string }
try { body = await request.json() } catch {
@@ -55,13 +50,13 @@ export async function POST(request: NextRequest) {
key: body.key,
value: body.value,
updated_at: new Date().toISOString(),
updated_by: user.id,
updated_by: session.sub,
})
if (error) return NextResponse.json({ error: 'Save failed' }, { status: 500 })
await supabase.rpc('write_audit_log', {
p_table_name: 'app_settings',
p_record_id: user.id,
p_record_id: session.sub,
p_action: 'UPDATE',
p_new_value: { key: body.key, set: Boolean(body.value) } as never,
})
+5 -14
View File
@@ -2,25 +2,16 @@
export const dynamic = 'force-dynamic'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
import { getRoleHome, isValidRole, type UserRole } from '@/lib/auth/roles'
export default async function RootPage() {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
const session = await getSession()
if (!user) redirect('/login')
if (!session) redirect('/login')
const { data: profile } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single()
if (profile?.role && isValidRole(profile.role)) {
redirect(getRoleHome(profile.role as UserRole))
if (session.role && isValidRole(session.role)) {
redirect(getRoleHome(session.role as UserRole))
}
redirect('/login')
+4 -3
View File
@@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
import { ReportForm } from '@/components/incidents/report-form'
import { LanguageSwitcher } from '@/components/language-switcher'
import { OfflineSync } from '@/components/incidents/offline-sync'
@@ -12,10 +13,10 @@ interface Props {
export default async function ReportPage({ searchParams }: Props) {
const { zone, truck_id } = await searchParams
const supabase = await createClient()
const { data, error: authError } = await supabase.auth.getUser()
const session = await getSession()
if (!session) redirect(`/login?redirect=/report${zone ? `?zone=${zone}` : ''}`)
if (authError || !data?.user) redirect(`/login?redirect=/report${zone ? `?zone=${zone}` : ''}`)
const supabase = await createClient()
let zoneName: string | null = null
let siteName: string | null = null