Qué se implementó:
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled

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.
This commit is contained in:
2026-07-07 11:30:21 -03:00
parent 074f726d62
commit 1f84997794
4 changed files with 400 additions and 1 deletions

View File

@@ -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`

View File

@@ -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<Status>(paramsValid ? 'idle' : 'error')
const [errorMessage, setErrorMessage] = useState<string | null>(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 (
<div className="min-h-screen bg-white flex items-center justify-center">
<div
className="flex flex-col items-center gap-6 px-8 w-full max-w-sm"
style={{ animation: 'confirmFadeIn 0.4s ease-out both' }}
>
<style>{`
@keyframes confirmFadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
<img
src="/inquality-logo-color.png"
alt="InQuality"
className="h-12 object-contain"
/>
{status === 'error' ? (
<>
<div className="text-center">
<h1 className="text-xl font-bold text-slate-900 tracking-tight">
El link de invitación no es válido o ya fue utilizado.
</h1>
<p className="text-slate-500 mt-3 text-sm leading-relaxed">
Podría haber expirado (duración: 24 horas) o el link fue abierto más de una vez.
</p>
<p className="text-slate-500 mt-2 text-sm leading-relaxed font-medium">
Contactá a tu consultor InQuality para recibir una nueva invitación.
</p>
{errorMessage && (
<p className="text-xs text-slate-400 mt-3">Detalle: {errorMessage}</p>
)}
</div>
<Link to="/login" className="w-full">
<Button variant="outline" className="w-full">
Ir al login
</Button>
</Link>
</>
) : (
<>
<div className="text-center">
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">
Bienvenido a InQ ROI
</h1>
<p className="text-slate-500 mt-2 text-sm leading-relaxed">
Tu consultor te ha dado acceso a la plataforma.
</p>
<p className="text-slate-500 mt-1 text-sm leading-relaxed">
Hacé click para confirmar tu acceso y establecer tu contraseña.
</p>
</div>
<Button
onClick={handleConfirm}
disabled={status === 'loading'}
className="w-full"
>
{status === 'loading' ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Confirmando
</>
) : (
'Confirmar acceso'
)}
</Button>
</>
)}
</div>
</div>
)
}

View File

@@ -7,6 +7,7 @@ import { SettingsPage } from '@/features/settings/SettingsPage'
import { ResourceCatalogPage } from '@/features/resources/ResourceCatalogPage' import { ResourceCatalogPage } from '@/features/resources/ResourceCatalogPage'
import { LoginPage } from '@/features/login/LoginPage' import { LoginPage } from '@/features/login/LoginPage'
import { ResetPasswordPage } from '@/features/auth/ResetPasswordPage' import { ResetPasswordPage } from '@/features/auth/ResetPasswordPage'
import { ConfirmInvitePage } from '@/features/auth/ConfirmInvitePage'
import { AdminLayout } from '@/features/admin/AdminLayout' import { AdminLayout } from '@/features/admin/AdminLayout'
import { OrganizationsPage } from '@/features/admin/OrganizationsPage' import { OrganizationsPage } from '@/features/admin/OrganizationsPage'
import { OrganizationDetailPage } from '@/features/admin/OrganizationDetailPage' 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() { function RootComponent() {
const { user, loading, pendingPasswordReset } = useAuth() const { user, loading, pendingPasswordReset } = useAuth()
@@ -90,6 +91,12 @@ const resetPasswordRoute = createRoute({
component: ResetPasswordPage, component: ResetPasswordPage,
}) })
const confirmInviteRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/auth/confirm',
component: ConfirmInvitePage,
})
const indexRoute = createRoute({ const indexRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/', path: '/',
@@ -161,6 +168,7 @@ const adminUsersRoute = createRoute({
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
loginRoute, loginRoute,
resetPasswordRoute, resetPasswordRoute,
confirmInviteRoute,
indexRoute, indexRoute,
importRoute, importRoute,
workspaceRoute, workspaceRoute,

View File

@@ -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<string, string> },
}))
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 }) => (
<a href={to} className={className}>{children}</a>
),
}))
// 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(<ConfirmInvitePage />)
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(<ConfirmInvitePage />)
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(<ConfirmInvitePage />)
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(<ConfirmInvitePage />)
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(<ConfirmInvitePage />)
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(<ConfirmInvitePage />)
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()
})
})