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/.gitignore b/.gitignore index 059c54524..5012cd20e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ dist/ .DS_Store .vscode .idea +.codex/ *.iml .cache package-lock.json diff --git a/examples/react-waas/src/App.tsx b/examples/react-waas/src/App.tsx index 4384c1515..95915b394 100644 --- a/examples/react-waas/src/App.tsx +++ b/examples/react-waas/src/App.tsx @@ -3,6 +3,7 @@ import { SequenceConnect } from '@0xsequence/connect' import { ThemeProvider } from '@0xsequence/design-system' import { SequenceWalletProvider } from '@0xsequence/wallet-widget' import { BrowserRouter, Route, Routes } from 'react-router-dom' +import { useMemo, useState } from 'react' import { Homepage } from './components/Homepage' import { InlineDemo } from './components/InlineDemo' @@ -10,14 +11,34 @@ 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-waas/src/components/Homepage.tsx b/examples/react-waas/src/components/Homepage.tsx index 9847fcd7b..9608e8e28 100644 --- a/examples/react-waas/src/components/Homepage.tsx +++ b/examples/react-waas/src/components/Homepage.tsx @@ -1,11 +1,16 @@ import { useOpenConnectModal, useWallets } from '@0xsequence/connect' -import { Button, Image, useTheme } from '@0xsequence/design-system' +import { Button, Image, Switch, Text, useTheme } from '@0xsequence/design-system' import { Footer } from 'example-shared-components' import { Link } from 'react-router-dom' import { Connected } from './Connected' -export const Homepage = () => { +interface HomepageProps { + useFullWidthSocials: boolean + onUseFullWidthSocialsChange: (useFullWidthSocials: boolean) => void +} + +export const Homepage = ({ useFullWidthSocials, onUseFullWidthSocialsChange }: HomepageProps) => { const { wallets } = useWallets() const { setOpenConnectModal } = useOpenConnectModal() const { theme } = useTheme() @@ -33,6 +38,22 @@ export const Homepage = () => { + + ) : ( diff --git a/packages/connect/src/components/Connect/Connect.tsx b/packages/connect/src/components/Connect/Connect.tsx index 7ae9f815d..44e9ee639 100644 --- a/packages/connect/src/components/Connect/Connect.tsx +++ b/packages/connect/src/components/Connect/Connect.tsx @@ -588,13 +588,19 @@ export const Connect = (props: ConnectProps) => { const renderConnectorButton = ( connector: ExtendedConnector, - options?: { isDescriptive?: boolean; disableTooltip?: boolean } + options?: { + isDescriptive?: boolean + disableTooltip?: boolean + forceIcon?: boolean + googleButtonTheme?: 'filled_blue' | 'outline' + } ) => { const commonProps = { connector, onConnect, isDescriptive: options?.isDescriptive, - disableTooltip: options?.disableTooltip + disableTooltip: options?.disableTooltip, + forceIcon: options?.forceIcon } // Special handling for ecosystem connector - use config data for display @@ -650,9 +656,7 @@ export const Connect = (props: ConnectProps) => { ) case 'google-waas': - return ( - - ) + return case 'apple-waas': return ( @@ -831,6 +835,7 @@ export const Connect = (props: ConnectProps) => { const showMoreSocialOptions = socialAuthConnectors.length > MAX_ITEM_PER_ROW const showMoreWalletOptions = walletConnectors.length > MAX_ITEM_PER_ROW + const googleSocialConnector = socialAuthConnectors.find(connector => connector._wallet?.id === 'google-waas') const socialConnectorsPerRow = showMoreSocialOptions && !descriptiveSocials ? MAX_ITEM_PER_ROW - 1 : socialAuthConnectors.length const walletConnectorsPerRow = showMoreWalletOptions ? MAX_ITEM_PER_ROW - 1 : walletConnectors.length @@ -1045,7 +1050,7 @@ export const Connect = (props: ConnectProps) => { )} {!hasPrimarySequenceConnection && ( -
+
<> {showEcosystemConnectorSection && ecosystemConnector && (
{
)} - {!hideSocialConnectOptions && showSocialConnectorSection && ( -
- {socialAuthConnectors.slice(0, socialConnectorsPerRow).map(connector => { - return ( -
- {renderConnectorButton(connector, { + {!hideSocialConnectOptions && + showSocialConnectorSection && + (() => { + const visibleSocialConnectors = socialAuthConnectors.slice(0, socialConnectorsPerRow) + const otherConnectors = visibleSocialConnectors.filter( + connector => connector._wallet?.id !== 'google-waas' + ) + const renderConnectorGroup = (connectors: ExtendedConnector[]) => ( +
+ {connectors.map(connector => { + const connectorButton = renderConnectorButton(connector, { isDescriptive: descriptiveSocials, - disableTooltip: config?.signIn?.disableTooltipForDescriptiveSocials - })} + disableTooltip: config?.signIn?.disableTooltipForDescriptiveSocials, + forceIcon: !descriptiveSocials && connector._wallet?.id === 'guest-waas' + }) + + return ( +
+ {connectorButton} +
+ ) + })} +
+ ) + if (!googleSocialConnector) { + return ( +
+ {renderConnectorGroup(visibleSocialConnectors)} + {showMoreSocialOptions && setShowExtendedList('social')} />}
) - })} - {showMoreSocialOptions && ( -
- setShowExtendedList('social')} /> + } + + if (descriptiveSocials) { + return ( +
+ +
+ {renderConnectorButton(googleSocialConnector, { + isDescriptive: true, + disableTooltip: true, + googleButtonTheme: 'filled_blue' + })} +
+ + {renderConnectorGroup(otherConnectors)} + {showMoreSocialOptions && setShowExtendedList('social')} />} +
+ ) + } + + return ( +
+ {renderConnectorGroup(otherConnectors)} + {showMoreSocialOptions && setShowExtendedList('social')} />} + + {renderConnectorButton(googleSocialConnector, { + isDescriptive: true, + disableTooltip: true, + googleButtonTheme: 'outline' + })}
- )} -
- )} + ) + })()} {!hideSocialConnectOptions && showSocialConnectorSection && showEmailInputSection && (
- + or diff --git a/packages/connect/src/components/ConnectButton/ConnectButton.tsx b/packages/connect/src/components/ConnectButton/ConnectButton.tsx index a5e1b06d1..c4a51af43 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' @@ -24,14 +27,21 @@ interface ConnectButtonProps { onConnect: (connector: ExtendedConnector) => void isDescriptive?: boolean disableTooltip?: boolean + forceIcon?: boolean +} + +type GoogleWaasConnector = ExtendedConnector & { + params?: { + googleClientId?: string + } } export const ConnectButton = (props: ConnectButtonProps) => { - const { connector, label, disableTooltip, onConnect } = props + const { connector, label, disableTooltip, forceIcon = false, onConnect } = props const { theme } = useTheme() const walletProps = connector._wallet const isDescriptive = props.isDescriptive || false - const shouldRenderTextButton = isDescriptive || !!walletProps.ctaText + const shouldRenderTextButton = !forceIcon && (isDescriptive || !!walletProps.ctaText) const buttonCopy = walletProps.ctaText || `Continue with ${label || walletProps.name}`.trim() const Logo = getLogo(theme, walletProps) @@ -97,7 +107,7 @@ export const GuestWaasConnectButton = ( setConnectingConnector?: (connector: ExtendedConnector | null) => void } ) => { - const { connector, onConnect, setIsLoading, setConnectingConnector } = props + const { connector, onConnect, forceIcon, setIsLoading, setConnectingConnector } = props return ( ) } export const GoogleWaasConnectButton = ( props: ConnectButtonProps & { - setIsLoading?: (isLoading: boolean) => void - setConnectingConnector?: (connector: ExtendedConnector | null) => void + buttonTheme?: 'filled_blue' | 'outline' } ) => { - const { connector, onConnect, isDescriptive = false, label, setIsLoading, setConnectingConnector } = props + 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 = () => { - const baseClasses = 'flex items-center w-full h-full bg-background-secondary absolute pointer-events-none top-0 right-0' - const layoutClasses = isDescriptive ? 'gap-3 justify-start px-4' : 'justify-center' + useEffect(() => { + let hasMeasured = false + let resizeObserver: ResizeObserver | undefined + + const measureInitialWidth = () => { + if (hasMeasured) { + return + } + + const availableWidth = containerRef.current?.clientWidth ?? 0 + if (availableWidth === 0) { + return + } + + hasMeasured = true + const nextButtonWidth = Math.min(400, Math.floor(availableWidth)) + setButtonWidth(nextButtonWidth) + resizeObserver?.disconnect() + window.removeEventListener('resize', measureInitialWidth) + } - const copy = walletProps?.ctaText || 'Continue with Google' - if (isDescriptive) { - return ( -
- - - {copy} - -
- ) + measureInitialWidth() + + if (!hasMeasured) { + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', measureInitialWidth) + } else { + resizeObserver = new ResizeObserver(measureInitialWidth) + if (containerRef.current) { + resizeObserver.observe(containerRef.current) + } + } } - return ( -
- -
- ) - } + return () => { + resizeObserver?.disconnect() + window.removeEventListener('resize', measureInitialWidth) + } + }, []) - 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 ( - - { - setIsLoading?.(true) - setConnectingConnector?.(connector) - }} - > -
- +
+ {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') - setIsLoading?.(false) - setConnectingConnector?.(null) }} /> -
- - - - + )} +
+
) } diff --git a/packages/connect/src/components/GoogleSignInButton/GoogleSignInButton.tsx b/packages/connect/src/components/GoogleSignInButton/GoogleSignInButton.tsx new file mode 100644 index 000000000..e593eb6b1 --- /dev/null +++ b/packages/connect/src/components/GoogleSignInButton/GoogleSignInButton.tsx @@ -0,0 +1,216 @@ +import { Skeleton } from '@0xsequence/design-system' +import type { CredentialResponse, GsiButtonConfiguration, IdConfiguration } from '@react-oauth/google' +import { useEffect, useId, 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 +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, + click_listener +}: GoogleSignInButtonProps) => { + const containerRef = useRef(null) + const onSuccessRef = useRef(onSuccess) + const onErrorRef = useRef(onError) + const buttonState = `sequence-google-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}` + 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) { + 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)) + 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 + } + + credentialHandlers.set(buttonState, handleCredential) + if (!initializeGoogleIdentity(googleIdentity, clientId)) { + credentialHandlers.delete(buttonState) + onErrorRef.current?.() + return + } + + mutationObserver.observe(container, { childList: true, subtree: true }) + container.replaceChildren() + googleIdentity.renderButton(container, { + type, + theme, + size, + text, + shape, + logo_alignment, + width, + locale, + click_listener, + state: buttonState + }) + findRenderedIframe() + } + + renderButton() + + return () => { + isCancelled = true + if (retryTimer) { + clearTimeout(retryTimer) + } + if (revealTimer) { + clearTimeout(revealTimer) + } + mutationObserver.disconnect() + renderedIframe?.removeEventListener('load', handleIframeLoad) + if (credentialHandlers.get(buttonState) === handleCredential) { + credentialHandlers.delete(buttonState) + } + } + }, [buttonState, clientId, type, theme, size, text, shape, logo_alignment, width, locale, click_listener, 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 f676c391d..7e5ff8c5b 100644 --- a/packages/connect/src/components/SocialLink/SocialLink.tsx +++ b/packages/connect/src/components/SocialLink/SocialLink.tsx @@ -1,6 +1,6 @@ import { Button, Card, PINCodeInput, Separator, 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 { useEffect, useRef, useState, type SetStateAction } from 'react' import AppleSignin from 'react-apple-signin-auth' import { english } from 'viem/accounts' @@ -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' @@ -168,7 +169,9 @@ export function SocialLink() {
- {googleClientId && } + {googleClientId && ( + + )} {appleClientId && ( // @ts-ignore