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:
169
sprints/sprint-7/ETAPA_6_PROMPT.md
Normal file
169
sprints/sprint-7/ETAPA_6_PROMPT.md
Normal 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`
|
||||
Reference in New Issue
Block a user