diff --git a/packages/shared/src/components/auth/AuthOptionsInner.tsx b/packages/shared/src/components/auth/AuthOptionsInner.tsx index 3fb5595f14..d8e8818813 100644 --- a/packages/shared/src/components/auth/AuthOptionsInner.tsx +++ b/packages/shared/src/components/auth/AuthOptionsInner.tsx @@ -16,11 +16,17 @@ import { } from '../../lib/auth'; import { getBetterAuthErrorMessage, - getBetterAuthSocialRedirectData, betterAuthSignInWithIdToken, betterAuthSendVerificationOTP, betterAuthVerifyEmailOTP, + getBetterAuthSocialRedirectData, } from '../../lib/betterAuth'; +import { + getSocialAuthCallbackError, + hasSocialAuthBootUser, + refetchSocialAuthBoot, + SOCIAL_AUTH_RETRY_MESSAGE, +} from './socialAuth'; import { webappUrl, broadcastChannel, isTesting } from '../../lib/constants'; import { getUserDefaultTimezone } from '../../lib/timezones'; import { shouldUseSocialAuthPopup } from '../../lib/func'; @@ -112,33 +118,6 @@ const EmailCodeVerification = dynamic( ); const CHOSEN_PROVIDER_KEY = 'chosen_provider'; -const SOCIAL_AUTH_RETRY_MESSAGE = - "We couldn't complete your social sign-in. Please try again."; - -const getSocialAuthCallbackError = (data?: unknown): string | undefined => { - if (!data || typeof data !== 'object') { - return undefined; - } - - const callbackData = data as Record; - const { error, error_description: errorDescription, message } = callbackData; - if (typeof error === 'string' && error.trim().length > 0) { - return error; - } - - if ( - typeof errorDescription === 'string' && - errorDescription.trim().length > 0 - ) { - return errorDescription; - } - - if (typeof message === 'string' && message.trim().length > 0) { - return message; - } - - return undefined; -}; function AuthOptionsInner({ onClose, @@ -208,6 +187,7 @@ function AuthOptionsInner({ null, ); const authFlowCompletedRef = useRef(false); + const authFlowSucceededRef = useRef(false); const clearPopupCheck = () => { if (popupCheckIntervalRef.current) { @@ -422,27 +402,15 @@ function AuthOptionsInner({ } const callbackError = getSocialAuthCallbackError(e?.data); - if (callbackError) { - setIsSocialAuthLoading(false); - logEvent({ - event_name: socialErrorEventName.current, - extra: JSON.stringify({ - provider: chosenProvider, - error: callbackError, - origin: 'betterauth social auth callback', - data: - typeof e?.data === 'object' ? JSON.stringify(e.data) : undefined, - }), - }); - displayToast(SOCIAL_AUTH_RETRY_MESSAGE); - return; - } + const callbackData = + typeof e?.data === 'object' ? JSON.stringify(e.data) : undefined; let boot; try { - ({ data: boot } = await refetchBoot()); + boot = await refetchSocialAuthBoot(refetchBoot); } catch (error) { setIsSocialAuthLoading(false); + onAuthStateUpdate?.({ isLoading: false }); logEvent({ event_name: socialErrorEventName.current, extra: JSON.stringify({ @@ -451,32 +419,41 @@ function AuthOptionsInner({ error, 'Failed to refresh Better Auth social auth state', ), + callbackError, origin: 'betterauth social auth boot', - data: - typeof e?.data === 'object' ? JSON.stringify(e.data) : undefined, + data: callbackData, }), }); - displayToast(SOCIAL_AUTH_RETRY_MESSAGE); + if (!authFlowSucceededRef.current) { + displayToast(SOCIAL_AUTH_RETRY_MESSAGE); + } return; } - if (!boot.user || !('email' in boot.user)) { + if (!hasSocialAuthBootUser(boot?.user)) { setIsSocialAuthLoading(false); + onAuthStateUpdate?.({ isLoading: false }); logEvent({ event_name: socialErrorEventName.current, extra: JSON.stringify({ provider: chosenProvider, error: + callbackError || 'Could not find authenticated user after social authentication', - origin: 'betterauth social auth boot', - data: - typeof e?.data === 'object' ? JSON.stringify(e.data) : undefined, + origin: callbackError + ? 'betterauth social auth callback' + : 'betterauth social auth boot', + data: callbackData, }), }); - displayToast(SOCIAL_AUTH_RETRY_MESSAGE); + if (!authFlowSucceededRef.current) { + displayToast(SOCIAL_AUTH_RETRY_MESSAGE); + } return; } + authFlowSucceededRef.current = true; + // If user is confirmed we can proceed with logging them in if ('infoConfirmed' in boot.user && boot.user.infoConfirmed) { setIsSocialAuthLoading(false); @@ -524,6 +501,7 @@ function AuthOptionsInner({ extra: JSON.stringify({ trigger }), }); socialErrorEventName.current = authErrorEventName; + authFlowSucceededRef.current = false; setIsSocialAuthLoading(true); const additionalData = { timezone: getUserDefaultTimezone() }; diff --git a/packages/shared/src/components/auth/socialAuth.spec.ts b/packages/shared/src/components/auth/socialAuth.spec.ts new file mode 100644 index 0000000000..0683cf9baf --- /dev/null +++ b/packages/shared/src/components/auth/socialAuth.spec.ts @@ -0,0 +1,73 @@ +import { + getSocialAuthCallbackError, + hasSocialAuthBootUser, + refetchSocialAuthBoot, +} from './socialAuth'; + +describe('socialAuth', () => { + describe('getSocialAuthCallbackError', () => { + it('returns the first callback error message', () => { + expect( + getSocialAuthCallbackError({ + error: 'access_denied', + error_description: 'User denied access', + message: 'Fallback message', + }), + ).toBe('access_denied'); + }); + + it('ignores empty callback error fields', () => { + expect( + getSocialAuthCallbackError({ + error: ' ', + error_description: '', + message: 'Fallback message', + }), + ).toBe('Fallback message'); + }); + }); + + describe('hasSocialAuthBootUser', () => { + it('requires a boot user with an email field', () => { + expect(hasSocialAuthBootUser({ id: 'anonymous' })).toBe(false); + expect( + hasSocialAuthBootUser({ + id: 'user', + email: 'user@daily.dev', + }), + ).toBe(true); + }); + }); + + describe('refetchSocialAuthBoot', () => { + it('retries until boot contains the authenticated user', async () => { + const refetchBoot = jest + .fn() + .mockResolvedValueOnce({ data: { user: { id: 'anonymous' } } }) + .mockResolvedValueOnce({ + data: { user: { id: 'user', email: 'user@daily.dev' } }, + }); + + await expect(refetchSocialAuthBoot(refetchBoot, [0, 0])).resolves.toEqual( + { + user: { id: 'user', email: 'user@daily.dev' }, + }, + ); + expect(refetchBoot).toHaveBeenCalledTimes(2); + }); + + it('returns the last boot response when auth never completes', async () => { + const refetchBoot = jest + .fn() + .mockResolvedValueOnce({ data: { user: { id: 'anonymous-1' } } }) + .mockResolvedValueOnce({ data: { user: { id: 'anonymous-2' } } }); + + await expect(refetchSocialAuthBoot(refetchBoot, [0, 0])).resolves.toEqual( + { + user: { id: 'anonymous-2' }, + }, + ); + expect(refetchBoot).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/shared/src/components/auth/socialAuth.ts b/packages/shared/src/components/auth/socialAuth.ts new file mode 100644 index 0000000000..c3d24fc010 --- /dev/null +++ b/packages/shared/src/components/auth/socialAuth.ts @@ -0,0 +1,82 @@ +import type { Boot } from '../../lib/boot'; + +export const SOCIAL_AUTH_RETRY_MESSAGE = + "We couldn't complete your social sign-in. Please try again."; + +const SOCIAL_AUTH_BOOT_RETRY_DELAYS_MS = [0, 250, 750] as const; + +type BootUser = Partial['user']; +type RefetchBoot = () => Promise<{ data?: Partial }>; + +const delay = (delayMs: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); + +export const hasSocialAuthBootUser = ( + user?: BootUser, +): user is NonNullable => + !!user && typeof user === 'object' && 'email' in user; + +const refetchSocialAuthBootAttempt = async ( + refetchBoot: RefetchBoot, + retryDelaysMs: readonly number[], + attempt = 0, + lastBoot?: Partial, +): Promise | undefined> => { + const delayMs = retryDelaysMs[attempt]; + + if (typeof delayMs === 'undefined') { + return lastBoot; + } + + if (delayMs > 0) { + await delay(delayMs); + } + + const { data: boot } = await refetchBoot(); + + if (hasSocialAuthBootUser(boot?.user)) { + return boot; + } + + return refetchSocialAuthBootAttempt( + refetchBoot, + retryDelaysMs, + attempt + 1, + boot, + ); +}; + +export const getSocialAuthCallbackError = ( + data?: unknown, +): string | undefined => { + if (!data || typeof data !== 'object') { + return undefined; + } + + const callbackData = data as Record; + const { error, error_description: errorDescription, message } = callbackData; + if (typeof error === 'string' && error.trim().length > 0) { + return error; + } + + if ( + typeof errorDescription === 'string' && + errorDescription.trim().length > 0 + ) { + return errorDescription; + } + + if (typeof message === 'string' && message.trim().length > 0) { + return message; + } + + return undefined; +}; + +export const refetchSocialAuthBoot = async ( + refetchBoot: RefetchBoot, + retryDelaysMs: readonly number[] = SOCIAL_AUTH_BOOT_RETRY_DELAYS_MS, +): Promise | undefined> => + refetchSocialAuthBootAttempt(refetchBoot, retryDelaysMs);