From 1f84997794ab1b3dc4dcec763f25450b0c7927a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20Ben=C3=ADtez?= Date: Tue, 7 Jul 2026 11:30:21 -0300 Subject: [PATCH] =?UTF-8?q?Qu=C3=A9=20se=20implement=C3=B3:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- sprints/sprint-7/ETAPA_6_PROMPT.md | 169 ++++++++++++++++++++ src/features/auth/ConfirmInvitePage.tsx | 112 +++++++++++++ src/router.tsx | 10 +- tests/features/auth/confirm-invite.test.tsx | 110 +++++++++++++ 4 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 sprints/sprint-7/ETAPA_6_PROMPT.md create mode 100644 src/features/auth/ConfirmInvitePage.tsx create mode 100644 tests/features/auth/confirm-invite.test.tsx diff --git a/sprints/sprint-7/ETAPA_6_PROMPT.md b/sprints/sprint-7/ETAPA_6_PROMPT.md new file mode 100644 index 0000000..7c0c5a2 --- /dev/null +++ b/sprints/sprint-7/ETAPA_6_PROMPT.md @@ -0,0 +1,169 @@ +# 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` diff --git a/src/features/auth/ConfirmInvitePage.tsx b/src/features/auth/ConfirmInvitePage.tsx new file mode 100644 index 0000000..433827f --- /dev/null +++ b/src/features/auth/ConfirmInvitePage.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react' +import { useNavigate, useSearch, Link } from '@tanstack/react-router' +import { Loader2 } from 'lucide-react' +import { supabase } from '@/lib/supabase' +import { Button } from '@/components/ui/button' + +type Status = 'idle' | 'loading' | 'error' + +export function ConfirmInvitePage() { + const navigate = useNavigate() + const search = useSearch({ from: '/auth/confirm' }) + const { token_hash: tokenHash, type: inviteType } = search as { + token_hash?: string + type?: string + } + + const paramsValid = Boolean(tokenHash) && inviteType === 'invite' + + const [status, setStatus] = useState(paramsValid ? 'idle' : 'error') + const [errorMessage, setErrorMessage] = useState(null) + + async function handleConfirm() { + setStatus('loading') + const { error } = await supabase.auth.verifyOtp({ + type: 'invite', + token_hash: tokenHash!, + }) + + if (error) { + setStatus('error') + setErrorMessage(error.message) + } else { + void navigate({ to: '/reset-password' }) + } + } + + return ( +
+
+ + + InQuality + + {status === 'error' ? ( + <> +
+

+ 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. +

+ {errorMessage && ( +

Detalle: {errorMessage}

+ )} +
+ + + + + + ) : ( + <> +
+

+ Bienvenido a InQ ROI +

+

+ Tu consultor te ha dado acceso a la plataforma. +

+

+ Hacé click para confirmar tu acceso y establecer tu contraseña. +

+
+ + + + )} +
+
+ ) +} diff --git a/src/router.tsx b/src/router.tsx index a0cac13..92910c7 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -7,6 +7,7 @@ import { SettingsPage } from '@/features/settings/SettingsPage' import { ResourceCatalogPage } from '@/features/resources/ResourceCatalogPage' import { LoginPage } from '@/features/login/LoginPage' import { ResetPasswordPage } from '@/features/auth/ResetPasswordPage' +import { ConfirmInvitePage } from '@/features/auth/ConfirmInvitePage' import { AdminLayout } from '@/features/admin/AdminLayout' import { OrganizationsPage } from '@/features/admin/OrganizationsPage' import { OrganizationDetailPage } from '@/features/admin/OrganizationDetailPage' @@ -31,7 +32,7 @@ function ReportPageWithSuspense() { ) } -const PUBLIC_PATHS = ['/login', '/reset-password'] +const PUBLIC_PATHS = ['/login', '/reset-password', '/auth/confirm'] function RootComponent() { const { user, loading, pendingPasswordReset } = useAuth() @@ -90,6 +91,12 @@ const resetPasswordRoute = createRoute({ component: ResetPasswordPage, }) +const confirmInviteRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/auth/confirm', + component: ConfirmInvitePage, +}) + const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', @@ -161,6 +168,7 @@ const adminUsersRoute = createRoute({ const routeTree = rootRoute.addChildren([ loginRoute, resetPasswordRoute, + confirmInviteRoute, indexRoute, importRoute, workspaceRoute, diff --git a/tests/features/auth/confirm-invite.test.tsx b/tests/features/auth/confirm-invite.test.tsx new file mode 100644 index 0000000..cc92ae6 --- /dev/null +++ b/tests/features/auth/confirm-invite.test.tsx @@ -0,0 +1,110 @@ +/** + * Tests de ConfirmInvitePage — flujo de invitación resiliente a email scanners. + * El token NO se consume al montar: solo cuando el usuario hace click en el botón. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import React from 'react' + +// ─── Referencias mutables para controlar los mocks entre tests ──────────────── + +const { mockVerifyOtp, mockNavigate, searchRef } = vi.hoisted(() => ({ + mockVerifyOtp: vi.fn(), + mockNavigate: vi.fn(), + searchRef: { current: { token_hash: 'valid-hash-abc123', type: 'invite' } as Record }, +})) + +vi.mock('@/lib/supabase', () => ({ + supabase: { + auth: { + verifyOtp: mockVerifyOtp, + }, + }, +})) + +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => mockNavigate, + useSearch: () => searchRef.current, + Link: ({ children, to, className }: { children: React.ReactNode; to: string; className?: string }) => ( + {children} + ), +})) + +// Importar después de los mocks +import { ConfirmInvitePage } from '@/features/auth/ConfirmInvitePage' + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('ConfirmInvitePage', () => { + beforeEach(() => { + vi.clearAllMocks() + searchRef.current = { token_hash: 'valid-hash-abc123', type: 'invite' } + mockVerifyOtp.mockResolvedValue({ data: {}, error: null }) + }) + + it('1. renderiza el botón "Confirmar acceso" sin llamar a verifyOtp al montar', () => { + render() + + expect(screen.getByRole('button', { name: /confirmar acceso/i })).toBeInTheDocument() + expect(mockVerifyOtp).not.toHaveBeenCalled() + }) + + it('2. token_hash vacío muestra estado de error directamente (sin botón de confirmación)', () => { + searchRef.current = { token_hash: '', type: 'invite' } + render() + + expect(screen.queryByRole('button', { name: /confirmar acceso/i })).not.toBeInTheDocument() + expect(screen.getByText(/link de invitación no es válido o ya fue utilizado/i)).toBeInTheDocument() + expect(screen.getByRole('link', { name: /ir al login/i })).toBeInTheDocument() + expect(mockVerifyOtp).not.toHaveBeenCalled() + }) + + it('3. type distinto de "invite" muestra error directamente', () => { + searchRef.current = { token_hash: 'some-hash', type: 'recovery' } + render() + + expect(screen.queryByRole('button', { name: /confirmar acceso/i })).not.toBeInTheDocument() + expect(screen.getByText(/link de invitación no es válido o ya fue utilizado/i)).toBeInTheDocument() + expect(mockVerifyOtp).not.toHaveBeenCalled() + }) + + it('4. click en el botón llama verifyOtp con el token_hash y type correctos', async () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /confirmar acceso/i })) + + await waitFor(() => { + expect(mockVerifyOtp).toHaveBeenCalledOnce() + expect(mockVerifyOtp).toHaveBeenCalledWith({ + type: 'invite', + token_hash: 'valid-hash-abc123', + }) + }) + }) + + it('5. verifyOtp exitoso navega a /reset-password', async () => { + mockVerifyOtp.mockResolvedValue({ data: {}, error: null }) + render() + + fireEvent.click(screen.getByRole('button', { name: /confirmar acceso/i })) + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith({ to: '/reset-password' }) + }) + expect(screen.queryByText(/link de invitación no es válido/i)).not.toBeInTheDocument() + }) + + it('6. verifyOtp con error muestra mensaje de error y no navega', async () => { + mockVerifyOtp.mockResolvedValue({ data: null, error: { message: 'Token has expired or already used' } }) + render() + + fireEvent.click(screen.getByRole('button', { name: /confirmar acceso/i })) + + await waitFor(() => { + expect(screen.getByText(/link de invitación no es válido o ya fue utilizado/i)).toBeInTheDocument() + }) + expect(screen.getByText(/Token has expired or already used/i)).toBeInTheDocument() + expect(mockNavigate).not.toHaveBeenCalled() + expect(screen.getByRole('link', { name: /ir al login/i })).toBeInTheDocument() + }) +})