diff --git a/src/context/ReactRowndProvider.tsx b/src/context/ReactRowndProvider.tsx index 5203f11..b080597 100644 --- a/src/context/ReactRowndProvider.tsx +++ b/src/context/ReactRowndProvider.tsx @@ -1,5 +1,9 @@ import React, { useCallback, useEffect } from 'react'; -import { RowndContext, RowndProviderProps } from './RowndContext'; +import { + HubListenerProps, + RowndContext, + RowndProviderProps, +} from './RowndContext'; import InternalProviderHubScriptInjector from './HubScriptInjector/InternalProviderHubScriptInjector'; import useHub from '../hooks/useHub'; import { TRowndContext, UserDataContext } from './types'; @@ -40,6 +44,7 @@ export const ReactRowndProvider: React.FC = ({ const { user, is_authenticated, is_initializing } = hubState; useSuperTokensMigration({ accessToken: hubState.access_token, + authLevel: hubState.auth_level, events: hubState.events, supertokens: props.supertokens, }); @@ -55,7 +60,7 @@ export const ReactRowndProvider: React.FC = ({ }, [is_authenticated, is_initializing, user.data.user_id]); const stateListener = useCallback( - ({ state, api }) => { + ({ state, api }: HubListenerProps) => { hubListenerCb({ state, api, callback: setHubState }); }, [hubListenerCb] diff --git a/src/hooks/useSuperTokensMigration.test.tsx b/src/hooks/useSuperTokensMigration.test.tsx index efacb54..e25b844 100644 --- a/src/hooks/useSuperTokensMigration.test.tsx +++ b/src/hooks/useSuperTokensMigration.test.tsx @@ -24,9 +24,16 @@ describe('useSuperTokensMigration', () => { removeEventListener, } as unknown as TRowndContext['events']; - function TestComponent() { + function TestComponent({ + accessToken = 'rownd-access-token', + authLevel, + }: { + accessToken?: string | null; + authLevel?: TRowndContext['auth_level']; + }) { useSuperTokensMigration({ - accessToken: 'rownd-access-token', + accessToken, + authLevel, events, supertokens: { appInfo: { @@ -76,6 +83,136 @@ describe('useSuperTokensMigration', () => { ); }); + it('waits for the access token before syncing new Rownd users', async () => { + const { rerender } = render(); + + await waitFor(() => { + expect(addEventListener).toHaveBeenCalledWith( + 'sign_in_completed', + expect.any(Function) + ); + }); + + await act(async () => { + getSignInCompletedHandler()(new CustomEvent('sign_in_completed', { + detail: { user_type: 'new_user' }, + })); + }); + + expect(syncUserToSuperTokens).not.toHaveBeenCalled(); + + rerender(); + + await waitFor(() => { + expect(syncUserToSuperTokens).toHaveBeenCalledWith( + 'rownd-access-token', + { + appName: 'test-app', + apiDomain: 'https://api.example.com', + apiBasePath: '/auth', + } + ); + }); + }); + + it('syncs converted instant users after receiving a non-instant token', async () => { + const { rerender } = render( + + ); + + await waitFor(() => { + expect(addEventListener).toHaveBeenCalledWith( + 'sign_in_completed', + expect.any(Function) + ); + }); + + await act(async () => { + getSignInCompletedHandler()(new CustomEvent('sign_in_completed', { + detail: { user_type: 'existing_user' }, + })); + }); + + expect(syncUserToSuperTokens).not.toHaveBeenCalled(); + + rerender( + + ); + + await waitFor(() => { + expect(syncUserToSuperTokens).toHaveBeenCalledWith( + 'verified-token', + { + appName: 'test-app', + apiDomain: 'https://api.example.com', + apiBasePath: '/auth', + } + ); + }); + }); + + it('syncs converted instant users when verified state arrives before the sign-in event', async () => { + const { rerender } = render( + + ); + + await waitFor(() => { + expect(addEventListener).toHaveBeenCalledWith( + 'sign_in_completed', + expect.any(Function) + ); + }); + + rerender( + + ); + + expect(syncUserToSuperTokens).not.toHaveBeenCalled(); + + await act(async () => { + getSignInCompletedHandler()(new CustomEvent('sign_in_completed', { + detail: { user_type: 'existing_user' }, + })); + }); + + await waitFor(() => { + expect(syncUserToSuperTokens).toHaveBeenCalledWith( + 'verified-token', + { + appName: 'test-app', + apiDomain: 'https://api.example.com', + apiBasePath: '/auth', + } + ); + }); + }); + + it('does not carry instant conversion state across sign-out', async () => { + const { rerender } = render( + + ); + + await waitFor(() => { + expect(addEventListener).toHaveBeenCalledWith( + 'sign_in_completed', + expect.any(Function) + ); + }); + + rerender(); + rerender( + + ); + + await act(async () => { + getSignInCompletedHandler()(new CustomEvent('sign_in_completed', { + detail: { user_type: 'existing_user' }, + })); + }); + + expect(syncUserToSuperTokens).not.toHaveBeenCalled(); + }); + it('ignores non-CustomEvent sign-in events', async () => { render(); diff --git a/src/hooks/useSuperTokensMigration.ts b/src/hooks/useSuperTokensMigration.ts index 9fe37b5..5141349 100644 --- a/src/hooks/useSuperTokensMigration.ts +++ b/src/hooks/useSuperTokensMigration.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { RowndProviderProps } from '../context/RowndContext'; import { TRowndContext } from '../context/types'; import { @@ -8,57 +8,107 @@ import { type UseSuperTokensMigrationProps = { accessToken: string | null; + authLevel: TRowndContext['auth_level']; events: TRowndContext['events']; supertokens?: RowndProviderProps['supertokens']; }; -function isNewUserSignInCompletedEvent(event: Event): boolean { +function getSignInCompletedUserType(event: Event): unknown { if (typeof CustomEvent === 'undefined' || !(event instanceof CustomEvent)) { - return false; + return undefined; } const detail = event.detail; if (!detail || typeof detail !== 'object') { - return false; + return undefined; } - return (detail as { user_type?: unknown }).user_type === 'new_user'; + return (detail as { user_type?: unknown }).user_type; +} + +function shouldMigrateSignIn( + userType: unknown, + hasSeenInstantSession: boolean +): boolean { + return ( + userType === 'new_user' || + (userType === 'existing_user' && hasSeenInstantSession) + ); } export function useSuperTokensMigration({ accessToken, + authLevel, events, supertokens, }: UseSuperTokensMigrationProps): void { const appInfo = supertokens?.appInfo; const accessTokenRef = useRef(accessToken); - const supertokensAppInfoRef = useRef( - normalizeSuperTokensAppInfo(appInfo) - ); + const authLevelRef = useRef(authLevel); + const hasSeenInstantSessionRef = useRef(false); + const pendingMigrationRef = useRef(false); + const supertokensAppInfoRef = useRef(normalizeSuperTokensAppInfo(appInfo)); + + const flushPendingMigration = useCallback(() => { + const currentAccessToken = accessTokenRef.current; + const currentAuthLevel = authLevelRef.current; + const appInfo = supertokensAppInfoRef.current; + + if ( + !pendingMigrationRef.current || + !currentAccessToken || + !appInfo || + currentAuthLevel === 'instant' + ) { + return; + } + + pendingMigrationRef.current = false; + void syncUserToSuperTokens(currentAccessToken, appInfo); + }, []); useEffect(() => { + const hadAccessToken = !!accessTokenRef.current; + accessTokenRef.current = accessToken; - }, [accessToken]); + authLevelRef.current = authLevel; + + if (hadAccessToken && !accessToken) { + hasSeenInstantSessionRef.current = false; + pendingMigrationRef.current = false; + } + + if (accessToken && authLevel === 'instant') { + hasSeenInstantSessionRef.current = true; + } + + flushPendingMigration(); + }, [accessToken, authLevel, flushPendingMigration]); useEffect(() => { - supertokensAppInfoRef.current = normalizeSuperTokensAppInfo( - appInfo - ); - }, [appInfo?.appName, appInfo?.apiDomain, appInfo?.apiBasePath]); + supertokensAppInfoRef.current = normalizeSuperTokensAppInfo(appInfo); + flushPendingMigration(); + }, [ + appInfo?.appName, + appInfo?.apiDomain, + appInfo?.apiBasePath, + flushPendingMigration, + ]); useEffect(() => { const handleSignInCompleted = (event: Event) => { - if (!isNewUserSignInCompletedEvent(event)) { + const userType = getSignInCompletedUserType(event); + + if (!shouldMigrateSignIn(userType, hasSeenInstantSessionRef.current)) { return; } - const currentAccessToken = accessTokenRef.current; - const appInfo = supertokensAppInfoRef.current; - if (!currentAccessToken || !appInfo) { - return; + pendingMigrationRef.current = true; + if (userType === 'existing_user') { + hasSeenInstantSessionRef.current = false; } - void syncUserToSuperTokens(currentAccessToken, appInfo); + flushPendingMigration(); }; events.addEventListener('sign_in_completed', handleSignInCompleted); @@ -66,5 +116,5 @@ export function useSuperTokensMigration({ return () => { events.removeEventListener('sign_in_completed', handleSignInCompleted); }; - }, [events]); + }, [events, flushPendingMigration]); } diff --git a/src/next/client/index.tsx b/src/next/client/index.tsx index 732b1da..cd212c4 100644 --- a/src/next/client/index.tsx +++ b/src/next/client/index.tsx @@ -1,7 +1,10 @@ 'use client'; import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { RowndProviderProps } from '../../context/RowndContext'; +import { + HubListenerProps, + RowndProviderProps, +} from '../../context/RowndContext'; import { store } from './store'; import HubScriptInjector from '../../context/HubScriptInjector/HubScriptInjector'; import useHub from '../../hooks/useHub'; @@ -33,11 +36,18 @@ const Client: React.FC> = (props) => { [store] ); - const { access_token, events, is_initializing, is_authenticated, user } = - useRownd(); + const { + access_token, + auth_level, + events, + is_initializing, + is_authenticated, + user, + } = useRownd(); const { cookieSignIn, cookieSignOut } = useCookie(useRownd); useSuperTokensMigration({ accessToken: access_token, + authLevel: auth_level, events, supertokens: props.supertokens, }); @@ -65,7 +75,7 @@ const Client: React.FC> = (props) => { } }, [hasCookieSignedIn, user.data.user_id, is_initializing, is_authenticated]); - const stateListener = useCallback(({ state, api }) => { + const stateListener = useCallback(({ state, api }: HubListenerProps) => { hubListenerCb({ state, api,