# 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. ```typescript // 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:** ```typescript // 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 ```typescript // 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`