From a484ba7849048bbbd5806b7844950e1da3069d75 Mon Sep 17 00:00:00 2001 From: markosbenitez Date: Sat, 27 Jun 2026 09:39:52 -0300 Subject: [PATCH] feat(auth): Supabase Auth email/password + middleware + login [auth] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/admin/responses/page.tsx | 39 ++++-- app/admin/sign-out-button.tsx | 24 ++++ app/globals.css | 61 +++++++++ app/login/page.tsx | 81 +++++++++++ middleware.ts | 43 ++++++ package-lock.json | 78 +++++++---- package.json | 1 + scripts/setup_first_admin.sql | 64 +++++++++ .../20260627000001_auth_user_roles.sql | 129 ++++++++++++++++++ 9 files changed, 485 insertions(+), 35 deletions(-) create mode 100644 app/admin/sign-out-button.tsx create mode 100644 app/login/page.tsx create mode 100644 middleware.ts create mode 100644 scripts/setup_first_admin.sql create mode 100644 supabase/migrations/20260627000001_auth_user_roles.sql diff --git a/app/admin/responses/page.tsx b/app/admin/responses/page.tsx index c42b4d2..1f9c3a8 100644 --- a/app/admin/responses/page.tsx +++ b/app/admin/responses/page.tsx @@ -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({ ))} - Actualizar +
+ Actualizar + +
diff --git a/app/admin/sign-out-button.tsx b/app/admin/sign-out-button.tsx new file mode 100644 index 0000000..fa55353 --- /dev/null +++ b/app/admin/sign-out-button.tsx @@ -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 ( + + ) +} diff --git a/app/globals.css b/app/globals.css index e9aa2c9..1d1b857 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1060,3 +1060,64 @@ body { @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } } + +/* ── Login ── */ +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-bg); + padding: 24px; +} +.login-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + box-shadow: var(--shadow-card); + padding: 40px 36px; + width: 100%; + max-width: 380px; +} +.login-title { + font-size: 1.25rem; + font-weight: 700; + color: var(--color-text); + margin: 0 0 28px; +} +.login-form { display: flex; flex-direction: column; gap: 16px; } +.login-field { display: flex; flex-direction: column; gap: 6px; } +.login-label { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text); +} +.login-error { margin: 0; } +.login-submit { + margin-top: 8px; + padding: 10px 20px; + background: var(--color-primary); + color: #fff; + border: none; + border-radius: var(--radius); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} +.login-submit:hover:not(:disabled) { background: var(--color-primary-hover); } +.login-submit:disabled { opacity: 0.6; cursor: not-allowed; } + +/* ── Admin sign-out ── */ +.admin-header-actions { display: flex; align-items: center; gap: 12px; } +.admin-signout { + font-size: 0.8125rem; + color: var(--color-text-muted); + background: none; + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 5px 12px; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} +.admin-signout:hover { color: var(--color-error); border-color: var(--color-error); } diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..5e07380 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,81 @@ +'use client' + +import { useState } from 'react' +import { createBrowserClient } from '@supabase/ssr' +import { useRouter } from 'next/navigation' + +export default function LoginPage() { + const [isPending, setIsPending] = useState(false) + const [error, setError] = useState(null) + const router = useRouter() + + const supabase = createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setIsPending(true) + setError(null) + + const fd = new FormData(e.currentTarget) + const { error: authError } = await supabase.auth.signInWithPassword({ + email: fd.get('email') as string, + password: fd.get('password') as string, + }) + + if (authError) { + setError('Email o contraseña incorrectos') + setIsPending(false) + return + } + + router.push('/admin/responses') + router.refresh() + } + + return ( +
+
+

Acceso interno

+
+
+ + +
+
+ + +
+ {error && ( +

{error}

+ )} + +
+
+
+ ) +} diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..494b501 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,43 @@ +import { createServerClient } from '@supabase/ssr' +import { NextRequest, NextResponse } from 'next/server' + +export async function middleware(request: NextRequest) { + let supabaseResponse = NextResponse.next({ request }) + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll() + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => + request.cookies.set(name, value) + ) + supabaseResponse = NextResponse.next({ request }) + cookiesToSet.forEach(({ name, value, options }) => + supabaseResponse.cookies.set(name, value, options) + ) + }, + }, + } + ) + + // getUser() valida el token contra el servidor — no confiar solo en la cookie local + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + const loginUrl = new URL('/login', request.url) + return NextResponse.redirect(loginUrl) + } + + return supabaseResponse +} + +export const config = { + matcher: ['/admin/:path*', '/dashboard/:path*'], +} diff --git a/package-lock.json b/package-lock.json index 429d650..fb39343 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@hookform/resolvers": "^5.4.0", + "@supabase/ssr": "^0.12.0", "@supabase/supabase-js": "^2.49.8", "next": "15.3.8", "react": "^19.0.0", @@ -1047,9 +1048,9 @@ "license": "MIT" }, "node_modules/@supabase/auth-js": { - "version": "2.107.0", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.107.0.tgz", - "integrity": "sha512-XA7x+WIeIvuC3GTZ2ey67QcBbGw4n+o5B7M+dMm9KT1lL3wX1B52DfEWW00WuPt/LnniJLLIn1WIm9YPtuxzKQ==", + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.108.2.tgz", + "integrity": "sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -1183,9 +1184,9 @@ ] }, "node_modules/@supabase/functions-js": { - "version": "2.107.0", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.107.0.tgz", - "integrity": "sha512-iMtRUmEj1KOgQd/a3MR4hnBlPnZc62DW8+z8aPpnzbxWkexEZUVL2fSgvvp15gqFg1V55e2yMGqgK+yhSQxp5w==", + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.108.2.tgz", + "integrity": "sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -1195,15 +1196,15 @@ } }, "node_modules/@supabase/phoenix": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz", - "integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==", + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz", + "integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==", "license": "MIT" }, "node_modules/@supabase/postgrest-js": { - "version": "2.107.0", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.107.0.tgz", - "integrity": "sha512-7ARs47/tyIjX7T0Ive20d4NY8zQYXsP5/P07jJWxffSIM2gpnSnGRnL/Fe15GPbdjsW2sTYeckHcyaoKbM6yWQ==", + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.108.2.tgz", + "integrity": "sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -1213,9 +1214,9 @@ } }, "node_modules/@supabase/realtime-js": { - "version": "2.107.0", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.107.0.tgz", - "integrity": "sha512-cF2KYdR3JIn9YlWGeluY9S0G+otqTdL6hB8GzpatlEIY6fZudCcyFo6Dc3+X9tjeb+x9XcIyNAk9qhNAknjH1A==", + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.108.2.tgz", + "integrity": "sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==", "license": "MIT", "dependencies": { "@supabase/phoenix": "^0.4.2", @@ -1225,10 +1226,22 @@ "node": ">=20.0.0" } }, + "node_modules/@supabase/ssr": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.12.0.tgz", + "integrity": "sha512-d9XV5XzJvzzZbeAIM7fWTCUYxQJZ2Ru6ny3dJHmHGp/LIrJ+o9FpD7N9Rf/UhhWEvHXSoDe8SI32Z2ouOdMjBg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.108.0" + } + }, "node_modules/@supabase/storage-js": { - "version": "2.107.0", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.107.0.tgz", - "integrity": "sha512-/X8OOVwKBn8aVKuHAGOz2yLA0d2OauqhVuy4mNtN+o7wttHOgx1/j+pqOzlsjmhOHrYykF6AJNZhs3gKZzcMUw==", + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.108.2.tgz", + "integrity": "sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==", "license": "MIT", "dependencies": { "iceberg-js": "^0.8.1", @@ -1239,16 +1252,16 @@ } }, "node_modules/@supabase/supabase-js": { - "version": "2.107.0", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.107.0.tgz", - "integrity": "sha512-ChKzdlWVweMUUhr0U79JhMmgm1haS/C5JquaiCDr70JaGARRtjjoY9rkIheXWybXxTSNzRiQs3Sk8IAg1HS3ZA==", + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.108.2.tgz", + "integrity": "sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==", "license": "MIT", "dependencies": { - "@supabase/auth-js": "2.107.0", - "@supabase/functions-js": "2.107.0", - "@supabase/postgrest-js": "2.107.0", - "@supabase/realtime-js": "2.107.0", - "@supabase/storage-js": "2.107.0" + "@supabase/auth-js": "2.108.2", + "@supabase/functions-js": "2.108.2", + "@supabase/postgrest-js": "2.108.2", + "@supabase/realtime-js": "2.108.2", + "@supabase/storage-js": "2.108.2" }, "engines": { "node": ">=20.0.0" @@ -1789,6 +1802,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", diff --git a/package.json b/package.json index 7351380..5c5b6f7 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@hookform/resolvers": "^5.4.0", + "@supabase/ssr": "^0.12.0", "@supabase/supabase-js": "^2.49.8", "next": "15.3.8", "react": "^19.0.0", diff --git a/scripts/setup_first_admin.sql b/scripts/setup_first_admin.sql new file mode 100644 index 0000000..3d1e929 --- /dev/null +++ b/scripts/setup_first_admin.sql @@ -0,0 +1,64 @@ +-- ============================================================ +-- Script: crear primer usuario platform_admin +-- +-- USO: +-- docker exec -i supabase-db psql -U postgres -d postgres \ +-- -v email='admin@ejemplo.com' -v password='CAMBIAR' \ +-- < scripts/setup_first_admin.sql +-- +-- CAMBIAR CONTRASEÑA LUEGO DEL PRIMER LOGIN: +-- El usuario puede cambiarla desde la app (si se implementa esa UI) +-- o el director puede actualizarla con: +-- UPDATE auth.users +-- SET encrypted_password = extensions.crypt('NuevaContraseña', extensions.gen_salt('bf')) +-- WHERE email = 'admin@ejemplo.com'; +-- ============================================================ + +DO $$ +DECLARE + v_user_id uuid; + v_inq_org uuid := '10000000-0000-0000-0000-000000000002'; +BEGIN + + -- Insertar usuario en auth.users + INSERT INTO auth.users ( + id, + instance_id, + aud, + role, + email, + encrypted_password, + email_confirmed_at, + created_at, + updated_at, + raw_app_meta_data, + raw_user_meta_data, + confirmation_token, + recovery_token, + is_super_admin + ) VALUES ( + gen_random_uuid(), + '00000000-0000-0000-0000-000000000000', + 'authenticated', + 'authenticated', + :'email', + extensions.crypt(:'password', extensions.gen_salt('bf')), + now(), + now(), + now(), + '{"provider":"email","providers":["email"]}', + '{}', + '', + '', + false + ) + RETURNING id INTO v_user_id; + + -- Asignar rol platform_admin vinculado a InQuality + INSERT INTO public.user_roles (user_id, app_role, org_id) + VALUES (v_user_id, 'platform_admin', v_inq_org); + + RAISE NOTICE 'Usuario creado: % (id=%)', :'email', v_user_id; + +END; +$$; diff --git a/supabase/migrations/20260627000001_auth_user_roles.sql b/supabase/migrations/20260627000001_auth_user_roles.sql new file mode 100644 index 0000000..671be6c --- /dev/null +++ b/supabase/migrations/20260627000001_auth_user_roles.sql @@ -0,0 +1,129 @@ +-- ============================================================ +-- Migration 004 / ETAPA_AUTH 4.2 +-- Tabla user_roles + hook de custom claims para JWT +-- +-- Después de aplicar esta migración, el director debe: +-- 1. Agregar en Dokploy (servicio supabase-auth): +-- GOTRUE_HOOKS_CUSTOM_ACCESS_TOKEN_ENABLED=true +-- GOTRUE_HOOKS_CUSTOM_ACCESS_TOKEN_URI=pg-functions://postgres/public/custom_access_token_hook +-- 2. Reiniciar el servicio supabase-auth (NO la app Next.js). +-- ============================================================ + +-- ── 1. Tabla user_roles ────────────────────────────────────── +CREATE TABLE public.user_roles ( + user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + app_role text NOT NULL CHECK (app_role IN ('platform_admin', 'org_admin')), + org_id uuid REFERENCES public.organizations(id) +); + +ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY; + +-- El usuario ve su propio rol; platform_admin ve todos +CREATE POLICY "user_roles_select_own" + ON public.user_roles + FOR SELECT + TO authenticated + USING ( + user_id = auth.uid() + OR public.current_app_role() = 'platform_admin' + ); + +-- ── 2. Extender policies existentes para incluir platform_admin ── + +-- organizations +DROP POLICY IF EXISTS "org_select_own" ON public.organizations; +CREATE POLICY "org_select_own" + ON public.organizations + FOR SELECT + TO authenticated + USING ( + public.current_app_role() IN ('analyst', 'platform_admin') + OR ( + public.current_app_role() = 'org_admin' + AND id = public.current_org_id() + ) + ); + +-- surveys (no tocar surveys_select_live_anon — sigue igual) +DROP POLICY IF EXISTS "surveys_select_auth" ON public.surveys; +CREATE POLICY "surveys_select_auth" + ON public.surveys + FOR SELECT + TO authenticated + USING ( + public.current_app_role() IN ('analyst', 'platform_admin') + OR ( + public.current_app_role() = 'org_admin' + AND org_id = public.current_org_id() + ) + ); + +-- questions (no tocar questions_select_live — sigue igual) +DROP POLICY IF EXISTS "questions_select_auth" ON public.questions; +CREATE POLICY "questions_select_auth" + ON public.questions + FOR SELECT + TO authenticated + USING ( + public.current_app_role() IN ('analyst', 'platform_admin') + OR ( + public.current_app_role() = 'org_admin' + AND EXISTS ( + SELECT 1 FROM public.surveys s + WHERE s.id = survey_id + AND s.org_id = public.current_org_id() + ) + ) + ); + +-- ── 3. Nuevas policies SELECT para platform_admin (responses/answers no tenían) ── +CREATE POLICY "responses_select_platform_admin" + ON public.responses + FOR SELECT + TO authenticated + USING (public.current_app_role() IN ('analyst', 'platform_admin')); + +CREATE POLICY "answers_select_platform_admin" + ON public.answers + FOR SELECT + TO authenticated + USING (public.current_app_role() IN ('analyst', 'platform_admin')); + +-- ── 4. Hook de custom claims ───────────────────────────────── +-- Inyecta app_role y org_id en el JWT en cada emisión de token. +-- SECURITY DEFINER + search_path garantiza que bypasea RLS para leer user_roles. +CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + claims jsonb; + v_role text; + v_org uuid; +BEGIN + claims := event -> 'claims'; + + SELECT app_role, org_id + INTO v_role, v_org + FROM public.user_roles + WHERE user_id = (event ->> 'user_id')::uuid; + + IF v_role IS NOT NULL THEN + claims := jsonb_set(claims, '{app_role}', to_jsonb(v_role)); + IF v_org IS NOT NULL THEN + claims := jsonb_set(claims, '{org_id}', to_jsonb(v_org::text)); + END IF; + END IF; + + RETURN jsonb_set(event, '{claims}', claims); +END; +$$; + +-- GoTrue necesita EXECUTE para llamar la función y SELECT para que el SECURITY DEFINER +-- pueda leer user_roles internamente (aunque SECURITY DEFINER corre como postgres, +-- el grant explicito documenta la intención y protege contra cambios de ownership). +GRANT EXECUTE ON FUNCTION public.custom_access_token_hook(jsonb) TO supabase_auth_admin; +GRANT SELECT ON public.user_roles TO supabase_auth_admin;