Files
inq-roi-simulador-web/sprints/sprint-7/ETAPA_6_PROMPT.md
Marcos Benítez 1f84997794
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Qué se implementó:
src/features/auth/ConfirmInvitePage.tsx — nuevo componente con tres estados:

idle: pantalla de bienvenida + botón "Confirmar acceso" (estado inicial con params válidos)
loading: botón con spinner mientras se llama a verifyOtp
error: mensaje descriptivo + link a /login (token vacío/incorrecto en params, o fallo en verifyOtp)
El verifyOtp solo se llama al click del usuario — nunca al montar, por eso el pre-scan del email no consume el token.

src/router.tsx — ruta /auth/confirm agregada como pública (PUBLIC_PATHS) + confirmInviteRoute en el árbol de rutas.

tests/features/auth/confirm-invite.test.tsx — 6 tests cubriendo: no-call on mount, error con params vacíos, error con type distinto, llamada correcta a verifyOtp, navegación en éxito, y error con mensaje de detalle.

ResetPasswordPage no requiere cambios — updateUser({ password }) solo necesita sesión activa, que verifyOtp crea en el paso previo.
2026-07-07 11:30:21 -03:00

6.3 KiB

Etapa 6 — Hotfix: flujo de invitación resiliente a email scanners

Sprint: 7 (hotfix post-cierre) Fecha: 2026-07-07 Alcance: nueva ruta /auth/confirm + componente ConfirmInvitePage. Sin tocar migraciones, Edge Functions, panel admin, ni lógica de simulación.


Problema

Gmail y Outlook pre-escanean los links del email de invitación antes de mostrárselos al usuario. El link generado por inviteUserByEmail apunta directamente a /auth/v1/verify en Supabase — un endpoint que consume el token de un solo uso al ser fetched. Los scanners lo fetchean → token consumido → "Not Found" cuando el usuario hace click.


Solución

Cambiar el template del email de invitación para que el link apunte a una página de la app (/auth/confirm) en lugar del endpoint de Supabase. La página muestra un botón de confirmación. El token se consume solo cuando el usuario hace click en el botón — no cuando el scanner pre-fetchea la URL.


Cambio en Supabase Dashboard (Marcos lo hace manualmente — NO es tarea de Claude Code)

Dashboard → Authentication → Email Templates → "Invite User"

Buscar la línea con {{ .ConfirmationURL }} en el template HTML y reemplazar el href del botón/link por:

https://inq-roi.inqualityhq.com/auth/confirm?token_hash={{ .TokenHash }}&type=invite

Guardar. Este cambio no requiere redeploy.


Cambios en el código

ANTES de empezar: leer src/router.tsx y src/features/auth/LoginPage.tsx para entender el patrón de rutas públicas y el estilo visual existente.

Nueva ruta /auth/confirm en src/router.tsx

Ruta pública (no requiere sesión activa). Misma estructura que /login y /reset-password ya existentes.

// Ruta pública — sin guard de auth
{
  path: '/auth/confirm',
  component: ConfirmInvitePage,
}

Nuevo componente src/features/auth/ConfirmInvitePage.tsx

Leer los query params (TanStack Router usa useSearch o el hook equivalente):

  • token_hash — el hash del token de Supabase
  • type — debe ser 'invite'

Estados del componente:

  • idle — muestra la pantalla de bienvenida con botón (estado inicial)
  • loading — botón con spinner + disabled, mientras se llama a Supabase
  • error — muestra mensaje de error + link a /login
  • success — no se muestra (navega inmediatamente a /reset-password)

Comportamiento:

// Al montar: NO llamar a verifyOtp automáticamente — esperar click del usuario
// Al hacer click en el botón:
const { error } = await supabase.auth.verifyOtp({
  type: 'invite',
  token_hash: tokenHash,
})

if (error) {
  setStatus('error')
  setErrorMessage(error.message)
} else {
  // Sesión activa — navegar a reset-password para que el usuario setee su contraseña
  navigate({ to: '/reset-password' })
}

UI en estado idle:

[Logo InQuality]

Bienvenido a InQ ROI

Tu consultor te ha dado acceso a la plataforma.
Hacé click para confirmar tu acceso y establecer tu contraseña.

[  Confirmar acceso  ]

UI en estado error:

[Logo InQuality]

El link de invitación no es válido o ya fue utilizado.

Podría haber expirado (duración: 24 horas) o el link
fue abierto más de una vez.

Contactá a tu consultor InQuality para recibir una nueva invitación.

[  Ir al login  ]

Validación de parámetros: si token_hash está vacío o falta type=invite, mostrar directamente el estado de error (sin intentar verificar).

Estilo: misma estructura visual que LoginPage — centrado en pantalla, card con sombra, logo de InQ en la parte superior. Usar los componentes shadcn/ui existentes (Button, Card o equivalente). No introducir librerías nuevas.

src/features/auth/ResetPasswordPage.tsx — verificar comportamiento

Después de verifyOtp({ type: 'invite' }), Supabase dispara SIGNED_IN en onAuthStateChange (no PASSWORD_RECOVERY). El navigate a /reset-password lo hace ConfirmInvitePage explícitamente — no depende del evento de auth.

Verificar que ResetPasswordPage funciona cuando ya hay una sesión activa (sin necesidad de PASSWORD_RECOVERY). La página llama a supabase.auth.updateUser({ password }) que solo requiere sesión activa — no un evento específico. Si ya funciona así, no hay cambios necesarios.


Tests nuevos

// tests/features/auth/confirm-invite.test.tsx

// 1. ConfirmInvitePage renderiza el botón sin llamar a verifyOtp al montar
// 2. ConfirmInvitePage con token_hash vacío muestra estado de error directamente
// 3. ConfirmInvitePage llama supabase.auth.verifyOtp solo al hacer click en el botón
// 4. ConfirmInvitePage navega a /reset-password cuando verifyOtp tiene éxito
// 5. ConfirmInvitePage muestra mensaje de error cuando verifyOtp falla

Flujo completo esperado post-fix

  1. Admin invita usuario@cliente.com desde el panel /admin/users
  2. Edge Function invite-user crea el usuario en Supabase Auth y envía el email
  3. Email llega con link: https://inq-roi.inqualityhq.com/auth/confirm?token_hash=xxx&type=invite
  4. Gmail/Outlook escanea la URL → ve la página de bienvenida de la app → no consume el token
  5. Usuario abre el email → hace click en el link → ve la pantalla de bienvenida con el botón
  6. Usuario hace click en "Confirmar acceso" → la app llama a verifyOtp → sesión activa
  7. App navega a /reset-password → usuario setea su contraseña
  8. Usuario puede acceder a la app con email + contraseña

Criterios de validación

  • Abrir el link de invite directamente en browser → muestra la pantalla de bienvenida con botón (NO redirige ni consume el token automáticamente)
  • Hacer click en "Confirmar acceso" → el token se consume, sesión activa, navega a /reset-password
  • En /reset-password, el usuario puede setear su contraseña
  • Después de setear contraseña, puede hacer logout y volver a entrar con email + contraseña
  • Link con token_hash vacío o inválido → muestra error con link a /login
  • npm run test → tests verdes (más los nuevos ~5)
  • npm run build limpio
  • Validación visual del director antes del OK formal

NO modificar

  • Edge Functions (supabase/functions/)
  • Migraciones (001-015)
  • Panel de admin (/admin/*)
  • WorkspacePage, ReportPage, LibraryPage
  • Lógica de simulación, dominio, ROI, PDF, CSV
  • AppHeader