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:
112
src/features/auth/ConfirmInvitePage.tsx
Normal file
112
src/features/auth/ConfirmInvitePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user