Google sign-in only worked for people who already had a linked google row in auth_accounts. Anyone who first appeared another way -- a guest ticket purchase, or an email/password signup made after the Better Auth migration -- got a 401 "account not linked". trustedProviders: ['google'] defeats only one of better-auth's two linking gates. The second, requireLocalEmailVerified, defaults to true and refuses the link whenever the LOCAL users.email_verified is false, independently of whether the provider is trusted. That flag is false for every guest-booking row and for every post-migration signup, since requireEmailVerification is off and no verification mail is sent. Turn that gate off: the Google id_token is signature-verified against Google's JWKS with issuer/audience/max-age checks and carries its own email_verified, so the local column proves nothing extra here. Linking alone was not enough. getAuthUser() rejects any session whose user is not 'active', so a ticket buyer would link Google, receive a cookie, and still look logged out. A databaseHooks.account.create.after hook now promotes unclaimed rows to claimed/active when a google account is attached, scoped in the WHERE clause so a suspended account is never reactivated this way. Also normalize users.email. The unique index is case-sensitive while better-auth lowercases every lookup, so someone who booked as John@Gmail.com was invisible to sign-in and Google minted a SECOND user row, stranding their tickets on the first. normalizeEmail() covers the find-or-create sites in tickets.ts and door.ts plus the claim-eligibility lookup, and an idempotent migration lowercases existing rows -- skipping any that would collide and reporting those for manual merge, since merging two people's tickets and payments is not a migration's call. tickets.attendeeEmail still stores the address exactly as typed. Tests drive the real signInSocial id-token path with Google stubbed by signing tokens with a throwaway RS256 key and serving our own JWKS, so the actual verification runs without network or credentials. That also makes the deprecation risk loud: requireLocalEmailVerified is marked for removal upstream, and an upgrade that drops it now fails CI instead of silently locking ticket buyers out again. Frontend carries error.code through so OAUTH_LINK_ERROR renders an actionable message in both locales rather than a bare "account not linked". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
235 lines
7.3 KiB
TypeScript
235 lines
7.3 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
import { useAuth } from '@/context/AuthContext';
|
|
import { useLanguage } from '@/context/LanguageContext';
|
|
import { safeInternalPath } from '@/lib/safeRedirect';
|
|
import { redirectAfterAuth } from '@/lib/authRedirect';
|
|
import toast from 'react-hot-toast';
|
|
|
|
declare global {
|
|
interface Window {
|
|
google?: {
|
|
accounts: {
|
|
id: {
|
|
initialize: (config: GoogleInitConfig) => void;
|
|
renderButton: (element: HTMLElement | null, options: GoogleButtonOptions) => void;
|
|
prompt: () => void;
|
|
cancel: () => void;
|
|
};
|
|
};
|
|
};
|
|
}
|
|
}
|
|
|
|
interface GoogleInitConfig {
|
|
client_id: string;
|
|
callback: (response: GoogleCredentialResponse) => void;
|
|
auto_select?: boolean;
|
|
cancel_on_tap_outside?: boolean;
|
|
}
|
|
|
|
interface GoogleButtonOptions {
|
|
type?: 'standard' | 'icon';
|
|
theme?: 'outline' | 'filled_blue' | 'filled_black';
|
|
size?: 'large' | 'medium' | 'small';
|
|
text?: 'signin_with' | 'signup_with' | 'continue_with' | 'signin';
|
|
shape?: 'rectangular' | 'pill' | 'circle' | 'square';
|
|
logo_alignment?: 'left' | 'center';
|
|
width?: string | number;
|
|
locale?: string;
|
|
}
|
|
|
|
interface GoogleCredentialResponse {
|
|
credential: string;
|
|
select_by?: string;
|
|
}
|
|
|
|
interface GoogleSignInButtonProps {
|
|
onSuccess?: () => void;
|
|
onError?: (error: string) => void;
|
|
redirectTo?: string;
|
|
text?: 'signin_with' | 'signup_with' | 'continue_with' | 'signin';
|
|
className?: string;
|
|
}
|
|
|
|
export default function GoogleSignInButton({
|
|
onSuccess,
|
|
onError,
|
|
redirectTo = '/dashboard',
|
|
text = 'continue_with',
|
|
className = '',
|
|
}: GoogleSignInButtonProps) {
|
|
const buttonRef = useRef<HTMLDivElement>(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [scriptLoaded, setScriptLoaded] = useState(false);
|
|
const [scriptError, setScriptError] = useState(false);
|
|
const { loginWithGoogle } = useAuth();
|
|
const { locale } = useLanguage();
|
|
|
|
const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
|
|
|
|
const handleGoogleCallback = useCallback(
|
|
async (response: GoogleCredentialResponse) => {
|
|
if (!response.credential) {
|
|
const errorMsg = locale === 'es' ? 'No se recibio credencial de Google' : 'No credential received from Google';
|
|
onError?.(errorMsg);
|
|
toast.error(errorMsg);
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
await loginWithGoogle(response.credential);
|
|
toast.success(locale === 'es' ? 'Bienvenido!' : 'Welcome!');
|
|
onSuccess?.();
|
|
|
|
// Full page load for a clean state, recording the attempt so the login page can
|
|
// tell a bounced redirect from a fresh visit. Constrained to a same-origin path
|
|
// to avoid open redirects.
|
|
redirectAfterAuth(safeInternalPath(redirectTo, '/dashboard'));
|
|
} catch (error: unknown) {
|
|
// better-auth returns OAUTH_LINK_ERROR (message: "account not linked")
|
|
// when it refuses to attach the Google identity to the existing user
|
|
// row for that address. The backend now links unverified local rows
|
|
// (see lib/betterAuth.ts accountLinking), so this should be
|
|
// unreachable — but a bare "account not linked" toast is a dead end,
|
|
// so keep an actionable fallback rather than a generic one.
|
|
const isLinkError = (error as { code?: string } | null)?.code === 'OAUTH_LINK_ERROR';
|
|
const errorMessage = isLinkError
|
|
? 'This email is already registered. Sign in with your password, or use the "Email Link" option on the login page.'
|
|
: error instanceof Error
|
|
? error.message
|
|
: 'Google login failed';
|
|
const displayError =
|
|
locale === 'es'
|
|
? isLinkError
|
|
? 'Este correo ya esta registrado. Inicia sesion con tu contrasena o usa la opcion "Enlace por correo".'
|
|
: 'Error al iniciar sesion con Google'
|
|
: errorMessage;
|
|
onError?.(displayError);
|
|
toast.error(displayError);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
},
|
|
[loginWithGoogle, locale, onSuccess, onError, redirectTo]
|
|
);
|
|
|
|
const initializeGoogleSignIn = useCallback(() => {
|
|
if (!clientId) {
|
|
console.warn('Google Client ID not configured');
|
|
return;
|
|
}
|
|
|
|
if (!window.google?.accounts?.id) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
window.google.accounts.id.initialize({
|
|
client_id: clientId,
|
|
callback: handleGoogleCallback,
|
|
auto_select: false,
|
|
cancel_on_tap_outside: true,
|
|
});
|
|
|
|
if (buttonRef.current) {
|
|
// Clear any existing button
|
|
buttonRef.current.innerHTML = '';
|
|
|
|
window.google.accounts.id.renderButton(buttonRef.current, {
|
|
type: 'standard',
|
|
theme: 'outline',
|
|
size: 'large',
|
|
text: text,
|
|
shape: 'rectangular',
|
|
logo_alignment: 'left',
|
|
width: 280,
|
|
locale: locale === 'es' ? 'es' : 'en',
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error initializing Google Sign-In:', error);
|
|
setScriptError(true);
|
|
}
|
|
}, [clientId, handleGoogleCallback, text, locale]);
|
|
|
|
// Load Google Sign-In script
|
|
useEffect(() => {
|
|
if (!clientId) {
|
|
return;
|
|
}
|
|
|
|
// Check if script is already loaded
|
|
if (window.google?.accounts?.id) {
|
|
setScriptLoaded(true);
|
|
return;
|
|
}
|
|
|
|
// Check if script tag already exists
|
|
const existingScript = document.querySelector('script[src="https://accounts.google.com/gsi/client"]');
|
|
if (existingScript) {
|
|
// Script exists but may not be loaded yet
|
|
existingScript.addEventListener('load', () => setScriptLoaded(true));
|
|
existingScript.addEventListener('error', () => setScriptError(true));
|
|
return;
|
|
}
|
|
|
|
// Load the script
|
|
const script = document.createElement('script');
|
|
script.src = 'https://accounts.google.com/gsi/client';
|
|
script.async = true;
|
|
script.defer = true;
|
|
script.onload = () => setScriptLoaded(true);
|
|
script.onerror = () => setScriptError(true);
|
|
document.head.appendChild(script);
|
|
|
|
return () => {
|
|
// Cleanup is handled by checking for existing script
|
|
};
|
|
}, [clientId]);
|
|
|
|
// Initialize when script is loaded
|
|
useEffect(() => {
|
|
if (scriptLoaded) {
|
|
// Small delay to ensure Google object is fully available
|
|
const timer = setTimeout(initializeGoogleSignIn, 100);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [scriptLoaded, initializeGoogleSignIn]);
|
|
|
|
// Don't render if no client ID configured
|
|
if (!clientId) {
|
|
return null;
|
|
}
|
|
|
|
if (scriptError) {
|
|
return (
|
|
<div className={`text-center text-sm text-gray-500 py-2 ${className}`}>
|
|
{locale === 'es'
|
|
? 'Google Sign-In no disponible'
|
|
: 'Google Sign-In unavailable'}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={`relative ${className}`}>
|
|
{/* Google Sign-In Button Container */}
|
|
<div
|
|
ref={buttonRef}
|
|
className="flex justify-center min-h-[44px]"
|
|
aria-label={locale === 'es' ? 'Iniciar sesion con Google' : 'Sign in with Google'}
|
|
/>
|
|
|
|
{/* Loading overlay */}
|
|
{isLoading && (
|
|
<div className="absolute inset-0 bg-white/80 flex items-center justify-center rounded">
|
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-secondary-blue" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|