feat(auth): Supabase Auth email/password + middleware + login [auth]

Protege /admin/* con Supabase Auth. Tabla user_roles con custom claims
(app_role, org_id) en JWT. Middleware Next.js redirige a /login sin sesión.
Página /login con email/password. /admin/responses migrado de service_role
a sesión de usuario autenticado.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
markosbenitez
2026-06-27 09:39:52 -03:00
parent ddaba6866a
commit a484ba7849
9 changed files with 485 additions and 35 deletions

View File

@@ -1,8 +1,8 @@
import { createClient } from '@supabase/supabase-js'
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { ResponsesTable, type DisplayRow } from './responses-table'
import { SignOutButton } from '../sign-out-button'
// Server Component sin auth — herramienta interna de diagnóstico (Etapa 3V-B).
// Usa SUPABASE_SERVICE_ROLE_KEY: vive solo acá, server-side, nunca al cliente.
export const dynamic = 'force-dynamic'
const MAX_SUBMISSIONS = 500
@@ -36,10 +36,28 @@ interface ResponseRow {
answers: AnswerRow[]
}
function getSupabaseAdmin() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co'
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'placeholder'
return createClient(url, serviceKey, { auth: { persistSession: false } })
async function getSupabaseSession() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Server Component — no puede mutar cookies, se ignora
}
},
},
}
)
}
function formatDate(iso: string): string {
@@ -76,7 +94,7 @@ export default async function AdminResponsesPage({
searchParams: Promise<{ survey_id?: string }>
}) {
const { survey_id: surveyId } = await searchParams
const supabase = getSupabaseAdmin()
const supabase = await getSupabaseSession()
// 1. Preguntas permitidas — text_long e is_sensitive=true se excluyen ACÁ,
// antes de tocar la tabla answers. Sus valores nunca se consultan.
@@ -214,7 +232,10 @@ export default async function AdminResponsesPage({
))}
</nav>
</div>
<a href="/admin/responses" className="admin-refresh">Actualizar</a>
<div className="admin-header-actions">
<a href="/admin/responses" className="admin-refresh">Actualizar</a>
<SignOutButton />
</div>
</header>
<div className="admin-table-wrapper">

View File

@@ -0,0 +1,24 @@
'use client'
import { createBrowserClient } from '@supabase/ssr'
import { useRouter } from 'next/navigation'
export function SignOutButton() {
const router = useRouter()
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
async function handleSignOut() {
await supabase.auth.signOut()
router.push('/login')
}
return (
<button type="button" onClick={handleSignOut} className="admin-signout">
Cerrar sesión
</button>
)
}