diff --git a/app/actions/invite-user.ts b/app/actions/invite-user.ts new file mode 100644 index 0000000..b3e1b2a --- /dev/null +++ b/app/actions/invite-user.ts @@ -0,0 +1,106 @@ +'use server' +import { createServerClient } from '@supabase/ssr' +import { cookies } from 'next/headers' +import { getSupabaseAdmin } from '@/lib/supabase' + +export type InviteUserResult = { success: true } | { success: false; error: string } + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +// Cliente ligado a la sesión (cookies) — anon key, respeta RLS. Se usa solo +// para identificar a quien llama y leer SU PROPIA fila de user_roles +// (policy user_roles_select_own: user_id = auth.uid()). Nunca para las +// escrituras de esta acción — esas requieren service_role (ver getSupabaseAdmin). +async function getSessionClient() { + 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 Action no siempre puede mutar cookies — se ignora + } + }, + }, + } + ) +} + +// Invita a un usuario de empresa y lo asocia a su organización con rol org_admin. +// Seguridad: quien llama debe ser platform_admin — se verifica leyendo user_roles +// en la DB con la sesión real (auth.uid()), nunca confiando en datos del formData. +// El rol del nuevo usuario es siempre 'org_admin', fijo en código — no es un +// valor que el formulario pueda controlar para escalar privilegios. +export async function inviteUser(formData: FormData): Promise { + const sessionClient = await getSessionClient() + + // auth.getUser() valida el token contra el servidor de Auth — no decodifica + // la cookie localmente (mismo patrón que middleware.ts). + const { data: { user: caller } } = await sessionClient.auth.getUser() + if (!caller) return { success: false, error: 'no_autenticado' } + + // Verificación de autorización — lee la fila real de user_roles del llamante. + const { data: callerRole, error: roleErr } = await sessionClient + .from('user_roles') + .select('app_role') + .eq('user_id', caller.id) + .maybeSingle() + + if (roleErr || callerRole?.app_role !== 'platform_admin') { + return { success: false, error: 'forbidden' } + } + + const email = String(formData.get('email') ?? '').trim().toLowerCase() + const orgId = String(formData.get('orgId') ?? '').trim() + + if (!EMAIL_RE.test(email)) return { success: false, error: 'email_invalido' } + if (!orgId) return { success: false, error: 'organizacion_requerida' } + + const admin = getSupabaseAdmin() + + // La organización debe existir — admin client porque ya está autorizado el + // llamante (paso anterior) y queremos evitar depender del claim app_role del + // JWT (puede estar stale si el rol cambió desde la última emisión de token). + const { data: org } = await admin + .from('organizations') + .select('id') + .eq('id', orgId) + .maybeSingle() + + if (!org) return { success: false, error: 'organizacion_no_encontrada' } + + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? '' + const { data: inviteData, error: inviteErr } = await admin.auth.admin.inviteUserByEmail( + email, + { redirectTo: `${siteUrl}/auth/confirm` } + ) + + if (inviteErr || !inviteData?.user) { + return { success: false, error: `invite_failed: ${inviteErr?.message ?? 'sin detalle'}` } + } + + const newUserId = inviteData.user.id + + // Insert inmediato — antes de que el usuario confirme nada. user_roles no + // tiene policy de INSERT (RLS deniega por defecto), por eso requiere admin. + const { error: roleInsertErr } = await admin + .from('user_roles') + .insert({ user_id: newUserId, app_role: 'org_admin', org_id: orgId }) + + if (roleInsertErr) { + // Rollback — no dejar un usuario en auth.users sin rol asignado. + await admin.auth.admin.deleteUser(newUserId) + return { success: false, error: `role_insert_failed: ${roleInsertErr.message}` } + } + + return { success: true } +} diff --git a/app/admin/responses/page.tsx b/app/admin/responses/page.tsx index 6dd9ac9..9d80169 100644 --- a/app/admin/responses/page.tsx +++ b/app/admin/responses/page.tsx @@ -241,6 +241,7 @@ export default async function AdminResponsesPage({
Actualizar + Usuarios
diff --git a/app/admin/usuarios/invite-form.tsx b/app/admin/usuarios/invite-form.tsx new file mode 100644 index 0000000..c8a01c6 --- /dev/null +++ b/app/admin/usuarios/invite-form.tsx @@ -0,0 +1,67 @@ +'use client' +import { useActionState } from 'react' +import { inviteUser, type InviteUserResult } from '@/app/actions/invite-user' + +interface OrgOption { + id: string + name: string +} + +const INITIAL_STATE: InviteUserResult = { success: false, error: '' } + +const ERROR_MESSAGES: Record = { + no_autenticado: 'Tu sesión expiró — volvé a iniciar sesión.', + forbidden: 'No tenés permisos para invitar usuarios.', + email_invalido: 'El email no es válido.', + organizacion_requerida: 'Elegí una organización.', + organizacion_no_encontrada: 'La organización seleccionada no existe.', +} + +function describeError(code: string): string { + if (code.startsWith('invite_failed')) + return `No se pudo enviar la invitación: ${code.slice('invite_failed: '.length)}` + if (code.startsWith('role_insert_failed')) + return `Error interno al asignar el rol: ${code.slice('role_insert_failed: '.length)}` + return ERROR_MESSAGES[code] ?? 'Ocurrió un error inesperado.' +} + +export function InviteForm({ organizations }: { organizations: OrgOption[] }) { + const [state, formAction, isPending] = useActionState( + async (_prev: InviteUserResult, formData: FormData) => inviteUser(formData), + INITIAL_STATE + ) + + return ( +
+
+ + +
+
+ + +
+ {!state.success && state.error && ( +

{describeError(state.error)}

+ )} + {state.success && ( +

Invitación enviada correctamente.

+ )} + +
+ ) +} diff --git a/app/admin/usuarios/page.tsx b/app/admin/usuarios/page.tsx new file mode 100644 index 0000000..6755476 --- /dev/null +++ b/app/admin/usuarios/page.tsx @@ -0,0 +1,196 @@ +import { createServerClient } from '@supabase/ssr' +import { cookies } from 'next/headers' +import { getSupabaseAdmin } from '@/lib/supabase' +import { formatDate } from '../responses/admin-format' +import { SignOutButton } from '../sign-out-button' +import { InviteForm } from './invite-form' + +export const dynamic = 'force-dynamic' + +interface OrgOption { + id: string + name: string +} + +interface RoleRow { + user_id: string + app_role: string + org_id: string | null + organizations: { name: string } | null +} + +interface UserDisplayRow { + userId: string + email: string + orgName: string + role: string + createdAt: string +} + +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 + } + }, + }, + } + ) +} + +// auth.users no está expuesto vía PostgREST (no es schema público) — no hay +// forma de hacer un .from('auth.users') ni un join SQL directo desde +// supabase-js. listUsers() es la API admin soportada para resolver el email; +// pagina hasta agotar resultados (tope defensivo de 2000 usuarios). +async function fetchAllUsersMap(admin: ReturnType) { + const map = new Map() + const perPage = 200 + for (let page = 1; page <= 10; page++) { + const { data, error } = await admin.auth.admin.listUsers({ page, perPage }) + if (error || !data) break + data.users.forEach((u) => { + map.set(u.id, { email: u.email ?? '—', createdAt: u.created_at }) + }) + if (data.users.length < perPage) break + } + return map +} + +export default async function AdminUsuariosPage() { + const supabase = await getSupabaseSession() + + // 1. Identificar a quien llama — defensivo, el middleware ya debería haber + // redirigido a /login si no hay sesión. + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + return ( +
+

Acceso restringido

+

No se encontró una sesión activa.

+ ← Iniciar sesión +
+ ) + } + + // 2. Verificar platform_admin leyendo la fila REAL en user_roles — esta + // página usa getSupabaseAdmin() más abajo (bypasa RLS por completo), así + // que el gate de autorización tiene que ir acá, no delegarse a las policies. + const { data: callerRole } = await supabase + .from('user_roles') + .select('app_role') + .eq('user_id', user.id) + .maybeSingle() + + if (callerRole?.app_role !== 'platform_admin') { + return ( +
+

Acceso restringido

+

+ Esta sección es exclusiva para administradores de plataforma. +

+ ← Volver +
+ ) + } + + // 3. Datos — admin client para todo a partir de acá. La autorización ya + // quedó probada en el paso 2 con una lectura directa a la DB; usar admin + // para el resto evita depender de policies/claims del JWT que podrían + // haber quedado stale. + const admin = getSupabaseAdmin() + + const [{ data: orgsData, error: orgsError }, { data: rolesData, error: rolesError }] = + await Promise.all([ + admin.from('organizations').select('id, name').order('name'), + admin.from('user_roles').select('user_id, app_role, org_id, organizations(name)'), + ]) + + if (orgsError || rolesError) { + return ( +
+

Usuarios

+

No fue posible conectarse a la base de datos.

+
+          {(orgsError ?? rolesError)?.message}
+        
+
+ ) + } + + const organizations = (orgsData ?? []) as OrgOption[] + const roles = (rolesData ?? []) as unknown as RoleRow[] + const usersMap = await fetchAllUsersMap(admin) + + const userRows: UserDisplayRow[] = roles.map((r) => { + const u = usersMap.get(r.user_id) + return { + userId: r.user_id, + email: u?.email ?? '—', + orgName: r.organizations?.name ?? '—', + role: r.app_role, + createdAt: u?.createdAt ? formatDate(u.createdAt) : '—', + } + }) + + return ( +
+
+
+

Usuarios

+

{userRows.length} usuarios registrados

+
+
+ Respuestas + +
+
+ +
+

Invitar usuario

+ +
+ +
+

Usuarios existentes

+
+ + + + + + + + + + + {userRows.map((row) => ( + + + + + + + ))} + +
EmailOrganizaciónRolFecha de creación
{row.email}{row.orgName}{row.role}{row.createdAt}
+ {userRows.length === 0 && ( +

Todavía no hay usuarios invitados.

+ )} +
+
+
+ ) +} diff --git a/app/auth/confirm/page.tsx b/app/auth/confirm/page.tsx new file mode 100644 index 0000000..563169e --- /dev/null +++ b/app/auth/confirm/page.tsx @@ -0,0 +1,159 @@ +'use client' + +import { useEffect, useState } from 'react' +import { createBrowserClient } from '@supabase/ssr' +import { useRouter } from 'next/navigation' + +type Stage = 'validating' | 'ready' | 'submitting' | 'error' + +export default function ConfirmPage() { + const [stage, setStage] = useState('validating') + const [error, setError] = useState(null) + const [showPassword, setShowPassword] = useState(false) + const router = useRouter() + + const supabase = createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ) + + useEffect(() => { + // Supabase emite PASSWORD_RECOVERY tanto para recovery como para invite + // cuando el token de la URL (#access_token) es válido. + const { data: listener } = supabase.auth.onAuthStateChange((event) => { + if (event === 'PASSWORD_RECOVERY') setStage('ready') + }) + + // Carrera posible: si el token ya se procesó antes de montar el listener, + // ya hay sesión activa aunque el evento no se vuelva a emitir. + supabase.auth.getSession().then(({ data: { session } }) => { + setStage((s) => (s === 'validating' && session ? 'ready' : s)) + }) + + // Sin evento ni sesión tras unos segundos = token inválido/vencido/ausente. + const timeout = setTimeout(() => { + setStage((s) => (s === 'validating' ? 'error' : s)) + }, 8000) + + return () => { + listener.subscription.unsubscribe() + clearTimeout(timeout) + } + }, [supabase]) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + const fd = new FormData(e.currentTarget) + const password = String(fd.get('password') ?? '') + const confirmPassword = String(fd.get('confirmPassword') ?? '') + + if (password.length < 8) { + setError('La contraseña debe tener al menos 8 caracteres.') + return + } + if (password !== confirmPassword) { + setError('Las contraseñas no coinciden.') + return + } + + setError(null) + setStage('submitting') + + const { error: updateErr } = await supabase.auth.updateUser({ password }) + + if (updateErr) { + setError('No pudimos guardar tu contraseña. Probá de nuevo.') + setStage('ready') + return + } + + router.push('/dashboard') + router.refresh() + } + + if (stage === 'validating') { + return ( +
+
+

Validando tu invitación...

+
+
+ ) + } + + if (stage === 'error') { + return ( +
+
+

Link inválido o vencido

+

+ Pedile al administrador que te envíe una nueva invitación. +

+
+
+ ) + } + + return ( +
+
+

Creá tu contraseña

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

{error}

+ )} + +
+
+
+ ) +} diff --git a/lib/supabase.ts b/lib/supabase.ts index 62678d7..d407db7 100644 --- a/lib/supabase.ts +++ b/lib/supabase.ts @@ -11,3 +11,14 @@ export function createClient() { return createSupabaseClient(url, anonKey) } + +// service_role — bypasa RLS por completo (regla 9 CLAUDE.md). Solo se importa +// desde Server Components / Server Actions ('use server'), nunca desde código +// que se bundlea al cliente. No exportar este cliente ni su resultado hacia props +// de un Client Component. +export function getSupabaseAdmin() { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co' + const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? 'placeholder' + + return createSupabaseClient(url, serviceKey) +}