diff --git a/.changeset/bright-mice-connect.md b/.changeset/bright-mice-connect.md new file mode 100644 index 000000000..a80448ff8 --- /dev/null +++ b/.changeset/bright-mice-connect.md @@ -0,0 +1,5 @@ +--- +'@0xsequence/connect': patch +--- + +Fix Google WaaS v2 sign-in clicks failing in Chromium by replacing the hidden, transformed iframe with a directly interactive Google button. diff --git a/examples/react/src/App.tsx b/examples/react/src/App.tsx index fa735287f..2894ad2db 100644 --- a/examples/react/src/App.tsx +++ b/examples/react/src/App.tsx @@ -1,6 +1,7 @@ import { SequenceCheckoutProvider } from '@0xsequence/checkout' import { SequenceConnect } from '@0xsequence/connect' import { SequenceWalletProvider } from '@0xsequence/wallet-widget' +import { useMemo, useState } from 'react' import { BrowserRouter, Route, Routes } from 'react-router-dom' import { Homepage } from './components/Homepage' @@ -10,13 +11,33 @@ import { XAuthCallback } from './components/XAuthCallback' import { checkoutConfig, config } from './config' export const App = () => { + const [useFullWidthSocials, setUseFullWidthSocials] = useState(config.connectConfig.signIn?.descriptiveSocials ?? false) + const demoConfig = useMemo( + () => ({ + ...config, + connectConfig: { + ...config.connectConfig, + signIn: { + ...config.connectConfig.signIn, + descriptiveSocials: useFullWidthSocials + } + } + }), + [useFullWidthSocials] + ) + return ( - + - } /> + + } + /> } /> } /> } /> diff --git a/examples/react/src/components/Homepage.tsx b/examples/react/src/components/Homepage.tsx index 463c77b15..34a68ae15 100644 --- a/examples/react/src/components/Homepage.tsx +++ b/examples/react/src/components/Homepage.tsx @@ -1,5 +1,5 @@ import { useOpenConnectModal, useWallets, WalletType } from '@0xsequence/connect' -import { Button, Card, CheckmarkIcon, Image, Text } from '@0xsequence/design-system' +import { Button, Card, CheckmarkIcon, Image, Switch, Text } from '@0xsequence/design-system' import { clsx } from 'clsx' import { Footer } from 'example-shared-components' import { Link } from 'react-router-dom' @@ -10,7 +10,12 @@ import { Connected } from './Connected' const searchParams = new URLSearchParams(location.search) const walletType: WalletType = searchParams.get('type') === 'universal' ? 'universal' : 'waas' -export const Homepage = () => { +interface HomepageProps { + useFullWidthSocials: boolean + onUseFullWidthSocialsChange: (useFullWidthSocials: boolean) => void +} + +export const Homepage = ({ useFullWidthSocials, onUseFullWidthSocialsChange }: HomepageProps) => { const { wallets } = useWallets() const { setOpenConnectModal } = useOpenConnectModal() @@ -40,6 +45,21 @@ export const Homepage = () => { + +
{ const showSocialConnectorSection = socialAuthConnectors.length > 0 const showEmailInputSection = !!emailConnector - const showMoreSocialOptions = socialAuthConnectors.length > MAX_ITEM_PER_ROW const showMoreWalletOptions = walletConnectors.length > MAX_ITEM_PER_ROW - const socialConnectorsPerRow = showMoreSocialOptions && !descriptiveSocials ? MAX_ITEM_PER_ROW - 1 : socialAuthConnectors.length const walletConnectorsPerRow = showMoreWalletOptions ? MAX_ITEM_PER_ROW - 1 : walletConnectors.length + const showCompactGoogleButton = + !hideSocialConnectOptions && + showSocialConnectorSection && + !descriptiveSocials && + socialAuthConnectors.some(isGoogleWaasConnector) + + const renderSocialConnectorButton = (connector: ExtendedConnector, options: SocialConnectorButtonOptions) => { + const commonProps = { + connector, + onConnect, + isDescriptive: options.isDescriptive + } + + switch (connector._wallet.id) { + case 'guest-waas': + return + case 'google-waas': + return + case 'apple-waas': + return + case 'epic-waas': + return + case 'X-waas': + return + default: + return + } + } const WalletConnectorsSection = () => { return ( @@ -398,7 +425,10 @@ export const Connect = (props: ConnectProps) => { if (showExtendedList) { const SEARCHABLE_TRESHOLD = 8 - const connectors = showExtendedList === 'social' ? socialAuthConnectors : walletConnectors + const connectors = + showExtendedList === 'social' + ? socialAuthConnectors.filter(connector => !isGoogleWaasConnector(connector)) + : walletConnectors const searchable = connectors.length > SEARCHABLE_TRESHOLD return ( @@ -522,61 +552,15 @@ export const Connect = (props: ConnectProps) => { {showWalletAuthOptionsFirst && !hideExternalConnectOptions && walletConnectors.length > 0 && ( )} -
+
<> {!hideSocialConnectOptions && showSocialConnectorSection && ( -
- {socialAuthConnectors.slice(0, socialConnectorsPerRow).map(connector => { - return ( -
- {connector._wallet.id === 'guest-waas' ? ( - - ) : connector._wallet.id === 'google-waas' ? ( - - ) : connector._wallet.id === 'apple-waas' ? ( - - ) : connector._wallet.id === 'epic-waas' ? ( - - ) : connector._wallet.id === 'X-waas' ? ( - - ) : ( - - )} -
- ) - })} - {showMoreSocialOptions && ( -
- setShowExtendedList('social')} /> -
- )} -
+ setShowExtendedList('social')} + renderConnector={renderSocialConnectorButton} + /> )} {!hideSocialConnectOptions && showSocialConnectorSection && showEmailInputSection && (
diff --git a/packages/connect/src/components/Connect/SocialConnectorsSection.tsx b/packages/connect/src/components/Connect/SocialConnectorsSection.tsx new file mode 100644 index 000000000..b24f9c6a8 --- /dev/null +++ b/packages/connect/src/components/Connect/SocialConnectorsSection.tsx @@ -0,0 +1,81 @@ +import { Divider } from '@0xsequence/design-system' +import type { ReactNode } from 'react' + +import type { ExtendedConnector } from '../../types.js' +import { ShowAllWalletsButton } from '../ConnectButton/index.js' + +const MAX_ITEMS_PER_ROW = 4 +const GOOGLE_WAAS_CONNECTOR_ID = 'google-waas' + +export interface SocialConnectorButtonOptions { + isDescriptive: boolean + googleButtonTheme?: 'filled_blue' | 'outline' +} + +interface SocialConnectorsSectionProps { + connectors: ExtendedConnector[] + descriptive: boolean + onShowMore: () => void + renderConnector: (connector: ExtendedConnector, options: SocialConnectorButtonOptions) => ReactNode +} + +export const isGoogleWaasConnector = (connector: ExtendedConnector) => connector._wallet?.id === GOOGLE_WAAS_CONNECTOR_ID + +export const SocialConnectorsSection = ({ + connectors, + descriptive, + onShowMore, + renderConnector +}: SocialConnectorsSectionProps) => { + const hasMoreConnectors = connectors.length > MAX_ITEMS_PER_ROW + const visibleConnectorCount = hasMoreConnectors && !descriptive ? MAX_ITEMS_PER_ROW - 1 : connectors.length + const visibleConnectors = connectors.slice(0, visibleConnectorCount) + const googleConnector = connectors.find(isGoogleWaasConnector) + const otherConnectors = visibleConnectors.filter(connector => !isGoogleWaasConnector(connector)) + + const renderConnectorGroup = (groupConnectors: ExtendedConnector[]) => ( +
+ {groupConnectors.map(connector => ( +
+ {renderConnector(connector, { isDescriptive: descriptive })} +
+ ))} + {hasMoreConnectors && ( +
+ +
+ )} +
+ ) + + if (!googleConnector) { + return renderConnectorGroup(visibleConnectors) + } + + if (descriptive) { + return ( +
+ +
+ {renderConnector(googleConnector, { + isDescriptive: true, + googleButtonTheme: 'filled_blue' + })} +
+ + {renderConnectorGroup(otherConnectors)} +
+ ) + } + + return ( +
+ {renderConnectorGroup(otherConnectors)} + + {renderConnector(googleConnector, { + isDescriptive: true, + googleButtonTheme: 'outline' + })} +
+ ) +} diff --git a/packages/connect/src/components/ConnectButton/ConnectButton.tsx b/packages/connect/src/components/ConnectButton/ConnectButton.tsx index fb1a15f28..cc0d7feda 100644 --- a/packages/connect/src/components/ConnectButton/ConnectButton.tsx +++ b/packages/connect/src/components/ConnectButton/ConnectButton.tsx @@ -1,15 +1,18 @@ import { Card, ContextMenuIcon, Text, Tooltip, useTheme } from '@0xsequence/design-system' -import { GoogleLogin } from '@react-oauth/google' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { appleAuthHelpers } from 'react-apple-signin-auth' import { getXIdToken } from '../../connectors/X/XAuth.js' import { LocalStorageKey } from '../../constants/localStorage.js' import { useStorage, useStorageItem } from '../../hooks/useStorage.js' import type { ExtendedConnector, WalletProperties } from '../../types.js' +import { GoogleSignInButton, type GoogleButtonTheme } from '../GoogleSignInButton/GoogleSignInButton.js' const BUTTON_HEIGHT = '52px' const BUTTON_HEIGHT_DESCRIPTIVE = '44px' +const GOOGLE_BUTTON_HEIGHT_DESCRIPTIVE = '40px' +// Standard Google buttons have an intrinsic localized text width; narrow connector cells use the official icon variant. +const GOOGLE_STANDARD_BUTTON_MIN_WIDTH = 240 const iconSizeClasses = 'w-8 h-8' const iconDescriptiveSizeClasses = 'w-6 h-6' @@ -26,6 +29,12 @@ interface ConnectButtonProps { disableTooltip?: boolean } +type GoogleWaasConnector = ExtendedConnector & { + params?: { + googleClientId?: string + } +} + export const ConnectButton = (props: ConnectButtonProps) => { const { connector, label, disableTooltip, onConnect } = props const { theme } = useTheme() @@ -105,71 +114,98 @@ export const GuestWaasConnectButton = (props: ConnectButtonProps & { setIsLoadin ) } -export const GoogleWaasConnectButton = (props: ConnectButtonProps) => { - const { connector, onConnect, isDescriptive = false } = props +export const GoogleWaasConnectButton = ( + props: ConnectButtonProps & { + buttonTheme?: 'filled_blue' | 'outline' + } +) => { + const { connector, onConnect, isDescriptive = false, buttonTheme = 'outline' } = props const storage = useStorage() + const containerRef = useRef(null) + const isMountedRef = useRef(true) + const [buttonWidth, setButtonWidth] = useState(0) const { theme } = useTheme() - const walletProps = connector._wallet + const googleClientId = (connector as GoogleWaasConnector).params?.googleClientId ?? '' - const Logo = getLogo(theme, walletProps) + useEffect(() => { + isMountedRef.current = true + return () => { + isMountedRef.current = false + } + }, []) - const WaasLoginContent = () => { - if (isDescriptive) { - return ( -
- - - Continue with Google - -
- ) + useEffect(() => { + const measureWidth = () => { + const availableWidth = containerRef.current?.clientWidth ?? 0 + if (availableWidth === 0) { + return + } + + const nextButtonWidth = Math.min(400, Math.floor(availableWidth)) + setButtonWidth(currentWidth => (currentWidth === nextButtonWidth ? currentWidth : nextButtonWidth)) } - return ( -
- -
- ) - } + measureWidth() + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', measureWidth) + return () => window.removeEventListener('resize', measureWidth) + } + + const resizeObserver = new ResizeObserver(measureWidth) + if (containerRef.current) { + resizeObserver.observe(containerRef.current) + } + + return () => resizeObserver.disconnect() + }, []) - const buttonHeight = isDescriptive ? BUTTON_HEIGHT_DESCRIPTIVE : BUTTON_HEIGHT + const useIconButton = buttonWidth < GOOGLE_STANDARD_BUTTON_MIN_WIDTH + const buttonHeight = isDescriptive ? GOOGLE_BUTTON_HEIGHT_DESCRIPTIVE : BUTTON_HEIGHT + // GIS supports outline_dark, but @react-oauth/google's theme type has not caught up with the current API. + const googleButtonTheme: GoogleButtonTheme = + buttonTheme === 'filled_blue' ? 'filled_blue' : theme === 'dark' ? 'outline_dark' : 'outline' return ( - - -
- +
+ {buttonWidth > 0 && ( + { + // GIS may finish after the modal has unmounted; ignore stale callbacks after dismissal. + if (!isMountedRef.current) { + return + } + if (credentialResponse.credential) { storage?.setItem(LocalStorageKey.WaasGoogleIdToken, credentialResponse.credential) onConnect(connector) } }} onError={() => { + if (!isMountedRef.current) { + return + } + console.log('Login Failed') }} /> -
- - - - + )} +
+
) } diff --git a/packages/connect/src/components/GoogleSignInButton/GoogleSignInButton.tsx b/packages/connect/src/components/GoogleSignInButton/GoogleSignInButton.tsx new file mode 100644 index 000000000..1df7e239c --- /dev/null +++ b/packages/connect/src/components/GoogleSignInButton/GoogleSignInButton.tsx @@ -0,0 +1,224 @@ +import { Skeleton } from '@0xsequence/design-system' +import type { CredentialResponse, GsiButtonConfiguration, IdConfiguration } from '@react-oauth/google' +import { useEffect, useRef, useState } from 'react' + +export type GoogleButtonTheme = 'outline' | 'outline_dark' | 'filled_blue' | 'filled_black' + +interface RoutedCredentialResponse extends CredentialResponse { + state?: string +} + +interface GoogleButtonConfiguration extends Omit { + state?: string + theme?: GoogleButtonTheme +} + +interface GoogleIdentityApi { + initialize: (config: IdConfiguration) => void + renderButton: (parent: HTMLElement, options: GoogleButtonConfiguration) => void +} + +interface GoogleSignInButtonProps extends Omit { + clientId: string + theme?: GoogleButtonTheme + onSuccess: (credentialResponse: CredentialResponse) => void + onError?: () => void +} + +const buttonHeights = { + large: 40, + medium: 32, + small: 20 +} as const +const MIN_LOADING_DURATION_MS = 600 + +type CredentialHandler = (credentialResponse: CredentialResponse) => void + +let initializedGoogleIdentity: GoogleIdentityApi | undefined +let initializedGoogleClientId: string | undefined +let nextGoogleButtonId = 0 +const credentialHandlers = new Map() + +const getGoogleIdentity = (): GoogleIdentityApi | undefined => (window as any).google?.accounts?.id + +const initializeGoogleIdentity = (googleIdentity: GoogleIdentityApi, clientId: string) => { + if (initializedGoogleIdentity === googleIdentity) { + if (initializedGoogleClientId !== clientId) { + console.error('Google Identity Services cannot be initialized with multiple client IDs on the same page.') + return false + } + return true + } + + googleIdentity.initialize({ + client_id: clientId, + callback: credentialResponse => { + const buttonState = (credentialResponse as RoutedCredentialResponse).state + const credentialHandler = buttonState ? credentialHandlers.get(buttonState) : undefined + + if (!credentialHandler) { + console.error('Unable to match the Google credential response to the button that initiated it.') + return + } + + credentialHandler(credentialResponse) + } + }) + initializedGoogleIdentity = googleIdentity + initializedGoogleClientId = clientId + return true +} + +export const GoogleSignInButton = ({ + clientId, + onSuccess, + onError, + type = 'standard', + theme = 'outline', + size = 'large', + text, + shape, + logo_alignment, + width, + locale +}: GoogleSignInButtonProps) => { + const containerRef = useRef(null) + const onSuccessRef = useRef(onSuccess) + const onErrorRef = useRef(onError) + const buttonStateRef = useRef(null) + if (buttonStateRef.current === null) { + buttonStateRef.current = `sequence-google-${++nextGoogleButtonId}` + } + const buttonState = buttonStateRef.current + const renderSignature = [clientId, type, theme, size, text, shape, logo_alignment, width, locale].join('|') + const [readySignature, setReadySignature] = useState() + const isReady = readySignature === renderSignature + const buttonHeight = buttonHeights[size] + const containerWidth = + type === 'icon' ? buttonHeight : typeof width === 'string' && /^\d+$/.test(width) ? Number(width) : (width ?? '100%') + + onSuccessRef.current = onSuccess + onErrorRef.current = onError + + useEffect(() => { + if (!clientId) { + containerRef.current?.replaceChildren() + return + } + + let isCancelled = false + let retryTimer: ReturnType | undefined + let revealTimer: ReturnType | undefined + let renderedIframe: HTMLIFrameElement | undefined + const loadingStartedAt = performance.now() + + const handleCredential = (credentialResponse: CredentialResponse) => { + if (credentialResponse.credential) { + onSuccessRef.current(credentialResponse) + } else { + onErrorRef.current?.() + } + } + + const handleIframeLoad = () => { + if (!isCancelled && renderedIframe?.src.includes('accounts.google.com/gsi/button')) { + const remainingLoadingTime = Math.max(0, MIN_LOADING_DURATION_MS - (performance.now() - loadingStartedAt)) + if (revealTimer) { + clearTimeout(revealTimer) + } + revealTimer = setTimeout(() => setReadySignature(renderSignature), remainingLoadingTime) + } + } + + const findRenderedIframe = () => { + const nextIframe = containerRef.current?.querySelector('iframe[src*="accounts.google.com/gsi/button"]') + if (!nextIframe || nextIframe === renderedIframe) { + return + } + + renderedIframe?.removeEventListener('load', handleIframeLoad) + renderedIframe = nextIframe + renderedIframe.addEventListener('load', handleIframeLoad, { once: true }) + } + + const mutationObserver = new MutationObserver(findRenderedIframe) + + const renderButton = () => { + if (isCancelled) { + return + } + + const googleIdentity = getGoogleIdentity() + const container = containerRef.current + if (!googleIdentity || !container) { + retryTimer = setTimeout(renderButton, 50) + return + } + + container.replaceChildren() + credentialHandlers.set(buttonState, handleCredential) + if (!initializeGoogleIdentity(googleIdentity, clientId)) { + credentialHandlers.delete(buttonState) + onErrorRef.current?.() + return + } + + mutationObserver.observe(container, { childList: true, subtree: true }) + googleIdentity.renderButton(container, { + type, + theme, + size, + text, + shape, + logo_alignment, + width, + locale, + state: buttonState + }) + findRenderedIframe() + } + + renderButton() + + return () => { + isCancelled = true + if (retryTimer) { + clearTimeout(retryTimer) + } + if (revealTimer) { + clearTimeout(revealTimer) + } + mutationObserver.disconnect() + renderedIframe?.removeEventListener('load', handleIframeLoad) + containerRef.current?.replaceChildren() + if (credentialHandlers.get(buttonState) === handleCredential) { + credentialHandlers.delete(buttonState) + } + } + }, [buttonState, clientId, type, theme, size, text, shape, logo_alignment, width, locale, renderSignature]) + + return ( +
+
+ +
+
+ {!isReady && ( + + Loading Google sign-in + + )} +
+ ) +} diff --git a/packages/connect/src/components/SocialLink/SocialLink.tsx b/packages/connect/src/components/SocialLink/SocialLink.tsx index 1d5589efe..bc2663283 100644 --- a/packages/connect/src/components/SocialLink/SocialLink.tsx +++ b/packages/connect/src/components/SocialLink/SocialLink.tsx @@ -1,6 +1,6 @@ import { Button, Divider, PINCodeInput, Spinner, Text, TextInput } from '@0xsequence/design-system' import { type Account } from '@0xsequence/waas' -import { GoogleLogin, type CredentialResponse } from '@react-oauth/google' +import { type CredentialResponse } from '@react-oauth/google' import { ethers } from 'ethers' import { useEffect, useRef, useState, type SetStateAction } from 'react' import AppleSignin from 'react-apple-signin-auth' @@ -9,6 +9,7 @@ import { LocalStorageKey } from '../../constants/localStorage.js' import { useSequenceWaaS } from '../../hooks/useSequenceWaaS.js' import { useStorageItem } from '../../hooks/useStorage.js' import { isAccountAlreadyLinkedError, useEmailAuth } from '../../utils/useEmailAuth.js' +import { GoogleSignInButton } from '../GoogleSignInButton/GoogleSignInButton.js' import { AccountName } from './AccountName.js' @@ -142,7 +143,9 @@ export function SocialLink() {
- {googleClientId && } + {googleClientId && ( + + )} {appleClientId && ( // @ts-ignore