From 67042b0941581b2653ce28cd647b7c0413539f27 Mon Sep 17 00:00:00 2001 From: markosbenitez Date: Tue, 30 Jun 2026 11:18:37 -0300 Subject: [PATCH] fix(auth): parsear error_code del fragmento de GoTrue en /auth/confirm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GoTrue redirige con #error=...&error_code=... cuando el token de invitación/recovery ya fue usado o venció, en vez de emitir PASSWORD_RECOVERY. Antes esto se descubría recién a los 8s (timeout genérico); ahora se lee el fragmento al montar y se muestra un mensaje específico (otp_expired, access_denied, etc.) al instante. Co-Authored-By: Claude Sonnet 4.6 --- app/auth/confirm/page.tsx | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/app/auth/confirm/page.tsx b/app/auth/confirm/page.tsx index 563169e..2b9ab7c 100644 --- a/app/auth/confirm/page.tsx +++ b/app/auth/confirm/page.tsx @@ -6,9 +6,26 @@ import { useRouter } from 'next/navigation' type Stage = 'validating' | 'ready' | 'submitting' | 'error' +const DEFAULT_LINK_ERROR = 'Pedile al administrador que te envíe una nueva invitación.' + +// GoTrue redirige con #error=...&error_code=...&error_description=... cuando +// el token ya fue usado, venció, o es inválido — en vez de emitir PASSWORD_RECOVERY. +// Mapeo a mensajes accionables; default cubre códigos no contemplados acá. +function describeLinkError(errorCode: string | null): string { + switch (errorCode) { + case 'otp_expired': + return 'Este link ya fue usado o venció. ' + DEFAULT_LINK_ERROR + case 'access_denied': + return 'El acceso fue denegado para este link. ' + DEFAULT_LINK_ERROR + default: + return 'El link de invitación no es válido. ' + DEFAULT_LINK_ERROR + } +} + export default function ConfirmPage() { const [stage, setStage] = useState('validating') const [error, setError] = useState(null) + const [linkErrorMessage, setLinkErrorMessage] = useState(DEFAULT_LINK_ERROR) const [showPassword, setShowPassword] = useState(false) const router = useRouter() @@ -18,6 +35,20 @@ export default function ConfirmPage() { ) useEffect(() => { + // GoTrue ya nos dice en el fragmento si el token es inválido/vencido/usado + // (#error=...&error_code=...) — leerlo evita esperar el timeout genérico. + const hash = window.location.hash.startsWith('#') + ? window.location.hash.slice(1) + : window.location.hash + const hashParams = new URLSearchParams(hash) + const errorCode = hashParams.get('error_code') + + if (errorCode) { + setLinkErrorMessage(describeLinkError(errorCode)) + setStage('error') + return + } + // Supabase emite PASSWORD_RECOVERY tanto para recovery como para invite // cuando el token de la URL (#access_token) es válido. const { data: listener } = supabase.auth.onAuthStateChange((event) => { @@ -30,7 +61,8 @@ export default function ConfirmPage() { setStage((s) => (s === 'validating' && session ? 'ready' : s)) }) - // Sin evento ni sesión tras unos segundos = token inválido/vencido/ausente. + // Sin evento ni sesión tras unos segundos = token inválido/vencido/ausente + // sin que GoTrue haya mandado un error explícito en el fragmento. const timeout = setTimeout(() => { setStage((s) => (s === 'validating' ? 'error' : s)) }, 8000) @@ -87,7 +119,7 @@ export default function ConfirmPage() {

Link inválido o vencido

- Pedile al administrador que te envíe una nueva invitación. + {linkErrorMessage}