diff --git a/packages/passport/sdk/src/Passport.int.test.ts b/packages/passport/sdk/src/Passport.int.test.ts index 13a430ef9e..0ed65b9c1c 100644 --- a/packages/passport/sdk/src/Passport.int.test.ts +++ b/packages/passport/sdk/src/Passport.int.test.ts @@ -76,8 +76,8 @@ const oidcConfiguration: OidcConfiguration = { logoutRedirectUri, }; -const getZkEvmProvider = async () => { - const passport = new Passport({ +const getPassport = () => ( + new Passport({ baseConfig: new ImmutableConfiguration({ environment: Environment.SANDBOX, }), @@ -87,8 +87,14 @@ const getZkEvmProvider = async () => { popupRedirectUri, logoutRedirectUri, scope: 'openid offline_access profile email transact', - }); + popupOverlayOptions: { + disableHeadlessLoginPromptOverlay: true, + }, + }) +); +const getZkEvmProvider = async () => { + const passport = getPassport(); return await passport.connectEvm(); }; @@ -357,17 +363,7 @@ describe('Passport', () => { mockSigninPopup.mockResolvedValue(mockOidcUserZkevm); mockSigninSilent.mockResolvedValueOnce(mockOidcUserZkevm); - const passport = new Passport({ - baseConfig: new ImmutableConfiguration({ - environment: Environment.SANDBOX, - }), - audience: 'platform_api', - clientId, - redirectUri, - popupRedirectUri, - logoutRedirectUri, - scope: 'openid offline_access profile email transact', - }); + const passport = getPassport(); // user isn't logged in, so wont set signer when provider is instantiated // #doc request-accounts diff --git a/packages/passport/sdk/src/Passport.ts b/packages/passport/sdk/src/Passport.ts index 272ed7e5fd..fe30e68c98 100644 --- a/packages/passport/sdk/src/Passport.ts +++ b/packages/passport/sdk/src/Passport.ts @@ -27,7 +27,7 @@ import { User, UserProfile, } from './types'; -import { ConfirmationScreen } from './confirmation'; +import { ConfirmationScreen, EmbeddedLoginPrompt } from './confirmation'; import { ZkEvmProvider } from './zkEvm'; import { Provider } from './zkEvm/types'; import TypedEventEmitter from './utils/typedEventEmitter'; @@ -57,7 +57,8 @@ const buildImxApiClients = (passportModuleConfiguration: PassportModuleConfigura export const buildPrivateVars = (passportModuleConfiguration: PassportModuleConfiguration) => { const config = new PassportConfiguration(passportModuleConfiguration); - const authManager = new AuthManager(config); + const embeddedLoginPrompt = new EmbeddedLoginPrompt(config); + const authManager = new AuthManager(config, embeddedLoginPrompt); const magicProviderProxyFactory = new MagicProviderProxyFactory(authManager, config); const magicAdapter = new MagicAdapter(config, magicProviderProxyFactory); const confirmationScreen = new ConfirmationScreen(config); @@ -91,6 +92,7 @@ export const buildPrivateVars = (passportModuleConfiguration: PassportModuleConf authManager, magicAdapter, confirmationScreen, + embeddedLoginPrompt, immutableXClient, multiRollupApiClients, passportEventEmitter, @@ -106,6 +108,8 @@ export class Passport { private readonly confirmationScreen: ConfirmationScreen; + private readonly embeddedLoginPrompt: EmbeddedLoginPrompt; + private readonly immutableXClient: IMXClient; private readonly magicAdapter: MagicAdapter; @@ -125,6 +129,7 @@ export class Passport { this.authManager = privateVars.authManager; this.magicAdapter = privateVars.magicAdapter; this.confirmationScreen = privateVars.confirmationScreen; + this.embeddedLoginPrompt = privateVars.embeddedLoginPrompt; this.immutableXClient = privateVars.immutableXClient; this.multiRollupApiClients = privateVars.multiRollupApiClients; this.passportEventEmitter = privateVars.passportEventEmitter; diff --git a/packages/passport/sdk/src/authManager.test.ts b/packages/passport/sdk/src/authManager.test.ts index c790348ff4..ea7917c898 100644 --- a/packages/passport/sdk/src/authManager.test.ts +++ b/packages/passport/sdk/src/authManager.test.ts @@ -2,7 +2,8 @@ import { Environment, ImmutableConfiguration } from '@imtbl/config'; import { User as OidcUser, UserManager, WebStorageStateStore } from 'oidc-client-ts'; import jwt_decode from 'jwt-decode'; import AuthManager from './authManager'; -import Overlay from './overlay'; +import ConfirmationOverlay from './overlay/confirmationOverlay'; +import EmbeddedLoginPrompt from './confirmation/embeddedLoginPrompt'; import { PassportError, PassportErrorType } from './errors/passportError'; import { PassportConfiguration } from './config'; import { mockUser, mockUserImx, mockUserZkEvm } from './test/mocks'; @@ -17,7 +18,8 @@ jest.mock('oidc-client-ts', () => ({ WebStorageStateStore: jest.fn(), })); jest.mock('./utils/token'); -jest.mock('./overlay'); +jest.mock('./overlay/confirmationOverlay'); +jest.mock('./confirmation/embeddedLoginPrompt'); const authenticationDomain = 'auth.immutable.com'; const clientId = '11111'; @@ -35,6 +37,9 @@ const getConfig = (values?: Partial) => new Passpor redirectUri, popupRedirectUri, scope: 'email profile', + popupOverlayOptions: { + disableHeadlessLoginPromptOverlay: true, + }, ...values, }); @@ -94,6 +99,7 @@ describe('AuthManager', () => { let mockOverlayAppend: jest.Mock; let mockOverlayRemove: jest.Mock; let mockRevokeTokens: jest.Mock; + let mockEmbeddedLoginPrompt: jest.Mocked; let originalWindowOpen: any; beforeEach(() => { @@ -139,11 +145,18 @@ describe('AuthManager', () => { }, }, })); - (Overlay as jest.Mock).mockReturnValue({ + (ConfirmationOverlay as jest.Mock).mockReturnValue({ append: mockOverlayAppend, remove: mockOverlayRemove, }); - authManager = new AuthManager(getConfig()); + + mockEmbeddedLoginPrompt = { + displayEmbeddedLoginPrompt: jest.fn(), + } as unknown as jest.Mocked; + + (EmbeddedLoginPrompt as unknown as jest.Mock).mockImplementation(() => mockEmbeddedLoginPrompt); + + authManager = new AuthManager(getConfig(), mockEmbeddedLoginPrompt); }); afterEach(() => { @@ -155,7 +168,7 @@ describe('AuthManager', () => { describe('constructor', () => { it('should initialise AuthManager with the correct default configuration', () => { const config = getConfig(); - const am = new AuthManager(config); + const am = new AuthManager(config, mockEmbeddedLoginPrompt); expect(am).toBeDefined(); expect(UserManager).toBeCalledWith({ authority: config.authenticationDomain, @@ -184,7 +197,7 @@ describe('AuthManager', () => { const configWithAudience = getConfig({ audience: 'audience', }); - const am = new AuthManager(configWithAudience); + const am = new AuthManager(configWithAudience, mockEmbeddedLoginPrompt); expect(am).toBeDefined(); expect(UserManager).toBeCalledWith(expect.objectContaining({ extraQueryParams: { @@ -198,7 +211,7 @@ describe('AuthManager', () => { describe('when a logoutRedirectUri is specified', () => { it('should set the endSessionEndpoint `returnTo` and `client_id` query string params', () => { const configWithLogoutRedirectUri = getConfig({ logoutRedirectUri }); - const am = new AuthManager(configWithLogoutRedirectUri); + const am = new AuthManager(configWithLogoutRedirectUri, mockEmbeddedLoginPrompt); const uri = new URL(logoutEndpoint, `https://${authenticationDomain}`); uri.searchParams.append('client_id', clientId); @@ -298,10 +311,10 @@ describe('AuthManager', () => { }); it('should use correct polling duration constant', async () => { - const neverResolvingPromise = new Promise(() => {}); + const neverResolvingPromise = new Promise(() => { }); mockSigninPopup.mockReturnValue(neverResolvingPromise); - authManager.login().catch(() => {}); // Ignore rejection for this test + authManager.login().catch(() => { }); // Ignore rejection for this test // Verify the polling interval uses the correct constant (500ms) expect(mockSetInterval).toHaveBeenCalledWith(expect.any(Function), 500); @@ -400,7 +413,7 @@ describe('AuthManager', () => { disableBlockedPopupOverlay: false, }, }); - const am = new AuthManager(configWithPopupOverlayOptions); + const am = new AuthManager(configWithPopupOverlayOptions, mockEmbeddedLoginPrompt); mockSigninPopup.mockReturnValue(mockOidcUser); // Simulate `tryAgainOnClick` being called so that the `login()` promise can resolve @@ -411,7 +424,7 @@ describe('AuthManager', () => { const result = await am.login(); expect(result).toEqual(mockUser); - expect(Overlay).toHaveBeenCalledWith(configWithPopupOverlayOptions.popupOverlayOptions, true); + expect(ConfirmationOverlay).toHaveBeenCalledWith(configWithPopupOverlayOptions.popupOverlayOptions, true); expect(mockOverlayAppend).toHaveBeenCalledTimes(1); }); @@ -555,7 +568,7 @@ describe('AuthManager', () => { }, }, })); - authManager = new AuthManager(getConfig()); + authManager = new AuthManager(getConfig(), mockEmbeddedLoginPrompt); }); it('should call login callback', async () => { @@ -577,7 +590,7 @@ describe('AuthManager', () => { const configuration = getConfig({ logoutMode: 'redirect', }); - const manager = new AuthManager(configuration); + const manager = new AuthManager(configuration, mockEmbeddedLoginPrompt); await manager.logout(); @@ -589,7 +602,7 @@ describe('AuthManager', () => { const configuration = getConfig({ logoutMode: undefined, }); - const manager = new AuthManager(configuration); + const manager = new AuthManager(configuration, mockEmbeddedLoginPrompt); await manager.logout(); @@ -601,7 +614,7 @@ describe('AuthManager', () => { const configuration = getConfig({ logoutMode: 'silent', }); - const manager = new AuthManager(configuration); + const manager = new AuthManager(configuration, mockEmbeddedLoginPrompt); await manager.logout(); @@ -614,7 +627,7 @@ describe('AuthManager', () => { const configuration = getConfig({ logoutMode: 'redirect', }); - const manager = new AuthManager(configuration); + const manager = new AuthManager(configuration, mockEmbeddedLoginPrompt); mockRevokeTokens.mockImplementation(() => { throw new Error(mockErrorMsg); }); @@ -632,7 +645,7 @@ describe('AuthManager', () => { const configuration = getConfig({ logoutMode: 'silent', }); - const manager = new AuthManager(configuration); + const manager = new AuthManager(configuration, mockEmbeddedLoginPrompt); mockRevokeTokens.mockImplementation(() => { throw new Error(mockErrorMsg); }); @@ -651,7 +664,7 @@ describe('AuthManager', () => { const configuration = getConfig({ logoutMode: 'redirect', }); - const manager = new AuthManager(configuration); + const manager = new AuthManager(configuration, mockEmbeddedLoginPrompt); mockSignoutRedirect.mockImplementation(() => { throw new Error(mockErrorMsg); @@ -670,7 +683,7 @@ describe('AuthManager', () => { const configuration = getConfig({ logoutMode: 'silent', }); - const manager = new AuthManager(configuration); + const manager = new AuthManager(configuration, mockEmbeddedLoginPrompt); mockSignoutSilent.mockImplementation(() => { throw new Error(mockErrorMsg); @@ -908,7 +921,7 @@ describe('AuthManager', () => { it('should set the endSessionEndpoint `returnTo` and `client_id` query string params', async () => { mockGetUser.mockReturnValue(mockOidcUser); - const am = new AuthManager(getConfig({ logoutRedirectUri })); + const am = new AuthManager(getConfig({ logoutRedirectUri }), mockEmbeddedLoginPrompt); const result = await am.getLogoutUrl(); expect(result).not.toBeNull(); @@ -925,7 +938,7 @@ describe('AuthManager', () => { it('should return the endSessionEndpoint without a `returnTo` or `client_id` query string params', async () => { mockGetUser.mockReturnValue(mockOidcUser); - const am = new AuthManager(getConfig()); + const am = new AuthManager(getConfig(), mockEmbeddedLoginPrompt); const result = await am.getLogoutUrl(); expect(result).not.toBeNull(); @@ -941,7 +954,13 @@ describe('AuthManager', () => { it('should use the bridge logout endpoint path', async () => { mockGetUser.mockReturnValue(mockOidcUser); - const am = new AuthManager(getConfig({ crossSdkBridgeEnabled: true, logoutRedirectUri })); + const am = new AuthManager( + getConfig({ + crossSdkBridgeEnabled: true, + logoutRedirectUri, + }), + mockEmbeddedLoginPrompt, + ); const result = await am.getLogoutUrl(); expect(result).not.toBeNull(); @@ -957,7 +976,7 @@ describe('AuthManager', () => { describe('when end_session_endpoint is not available', () => { it('should return null', async () => { - const am = new AuthManager(getConfig()); + const am = new AuthManager(getConfig(), mockEmbeddedLoginPrompt); // eslint-disable-next-line @typescript-eslint/dot-notation am['userManager'].settings.metadata!.end_session_endpoint = undefined; @@ -1042,7 +1061,7 @@ describe('AuthManager', () => { it('should include audience parameter when specified in config', async () => { const configWithAudience = getConfig({ audience: 'test-audience' }); - const am = new AuthManager(configWithAudience); + const am = new AuthManager(configWithAudience, mockEmbeddedLoginPrompt); const result = await am.getPKCEAuthorizationUrl(); const url = new URL(result); @@ -1052,7 +1071,7 @@ describe('AuthManager', () => { it('should include both direct and audience parameters', async () => { const configWithAudience = getConfig({ audience: 'test-audience' }); - const am = new AuthManager(configWithAudience); + const am = new AuthManager(configWithAudience, mockEmbeddedLoginPrompt); const result = await am.getPKCEAuthorizationUrl({ directLoginMethod: 'apple', marketingConsentStatus: MarketingConsentStatus.OptedIn }); const url = new URL(result); @@ -1120,7 +1139,7 @@ describe('AuthManager', () => { signinRedirect: mockSigninRedirect, clearStaleState: jest.fn(), }); - authManager = new AuthManager(getConfig()); + authManager = new AuthManager(getConfig(), mockEmbeddedLoginPrompt); }); it('should pass directLoginMethod to redirect login', async () => { @@ -1146,4 +1165,114 @@ describe('AuthManager', () => { }); }); }); + + describe('login with displayEmbeddedLoginPrompt', () => { + beforeEach(() => { + // Enable headless login prompt overlay for these tests + const configWithEmbeddedPrompt = getConfig({ + popupOverlayOptions: { + disableHeadlessLoginPromptOverlay: false, + }, + }); + authManager = new AuthManager(configWithEmbeddedPrompt, mockEmbeddedLoginPrompt); + }); + + it('should call displayEmbeddedLoginPrompt when no directLoginOptions provided and overlay is enabled', async () => { + const mockDirectLoginOptions = { directLoginMethod: 'google' as const }; + mockEmbeddedLoginPrompt.displayEmbeddedLoginPrompt.mockResolvedValue(mockDirectLoginOptions); + mockSigninPopup.mockResolvedValue(mockOidcUser); + + await authManager.login(); + + expect(mockEmbeddedLoginPrompt.displayEmbeddedLoginPrompt).toHaveBeenCalledTimes(1); + expect(mockSigninPopup).toHaveBeenCalledWith({ + extraQueryParams: { + rid: '', + third_party_a_id: '', + direct: 'google', + }, + popupWindowFeatures: { + width: 410, + height: 450, + }, + popupWindowTarget: expect.any(String), + }); + }); + + it('should not call displayEmbeddedLoginPrompt when directLoginOptions are provided', async () => { + mockSigninPopup.mockResolvedValue(mockOidcUser); + + await authManager.login('anonymous-id', { directLoginMethod: 'apple' }); + + expect(mockEmbeddedLoginPrompt.displayEmbeddedLoginPrompt).not.toHaveBeenCalled(); + expect(mockSigninPopup).toHaveBeenCalledWith({ + extraQueryParams: { + rid: '', + third_party_a_id: 'anonymous-id', + direct: 'apple', + }, + popupWindowFeatures: { + width: 410, + height: 450, + }, + popupWindowTarget: expect.any(String), + }); + }); + + it('should not call displayEmbeddedLoginPrompt when overlay is disabled', async () => { + const configWithDisabledPrompt = getConfig({ + popupOverlayOptions: { + disableHeadlessLoginPromptOverlay: true, + }, + }); + const authManagerWithDisabledPrompt = new AuthManager(configWithDisabledPrompt, mockEmbeddedLoginPrompt); + mockSigninPopup.mockResolvedValue(mockOidcUser); + + await authManagerWithDisabledPrompt.login(); + + expect(mockEmbeddedLoginPrompt.displayEmbeddedLoginPrompt).not.toHaveBeenCalled(); + }); + + it('should handle email login method from embedded prompt', async () => { + const mockDirectLoginOptions = { + directLoginMethod: 'email' as const, + email: 'test@example.com', + marketingConsentStatus: MarketingConsentStatus.OptedIn, + }; + mockEmbeddedLoginPrompt.displayEmbeddedLoginPrompt.mockResolvedValue(mockDirectLoginOptions); + mockSigninPopup.mockResolvedValue(mockOidcUser); + + await authManager.login('anonymous-id'); + + expect(mockEmbeddedLoginPrompt.displayEmbeddedLoginPrompt).toHaveBeenCalledTimes(1); + expect(mockSigninPopup).toHaveBeenCalledWith({ + extraQueryParams: { + rid: '', + third_party_a_id: 'anonymous-id', + direct: 'email', + email: 'test@example.com', + marketingConsent: MarketingConsentStatus.OptedIn, + }, + popupWindowFeatures: { + width: 410, + height: 450, + }, + popupWindowTarget: expect.any(String), + }); + }); + + it('should propagate errors from displayEmbeddedLoginPrompt', async () => { + const embeddedPromptError = new Error('Popup closed by user'); + mockEmbeddedLoginPrompt.displayEmbeddedLoginPrompt.mockRejectedValue(embeddedPromptError); + + await expect(() => authManager.login()).rejects.toThrow( + new PassportError( + 'Popup closed by user', + PassportErrorType.AUTHENTICATION_ERROR, + ), + ); + + expect(mockSigninPopup).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/passport/sdk/src/authManager.ts b/packages/passport/sdk/src/authManager.ts index c0702d80a3..c54a4f6279 100644 --- a/packages/passport/sdk/src/authManager.ts +++ b/packages/passport/sdk/src/authManager.ts @@ -28,8 +28,9 @@ import { isUserImx, } from './types'; import { PassportConfiguration } from './config'; -import Overlay from './overlay'; +import ConfirmationOverlay from './overlay/confirmationOverlay'; import { LocalForageAsyncStorage } from './storage/LocalForageAsyncStorage'; +import { EmbeddedLoginPrompt } from './confirmation'; const LOGIN_POPUP_CLOSED_POLLING_DURATION = 500; @@ -111,6 +112,8 @@ export default class AuthManager { private readonly config: PassportConfiguration; + private readonly embeddedLoginPrompt: EmbeddedLoginPrompt; + private readonly logoutMode: Exclude; /** @@ -118,10 +121,11 @@ export default class AuthManager { */ private refreshingPromise: Promise | null = null; - constructor(config: PassportConfiguration) { + constructor(config: PassportConfiguration, embeddedLoginPrompt: EmbeddedLoginPrompt) { this.config = config; this.userManager = new UserManager(getAuthConfiguration(config)); this.deviceCredentialsManager = new DeviceCredentialsManager(); + this.embeddedLoginPrompt = embeddedLoginPrompt; this.logoutMode = config.oidcConfiguration.logoutMode || 'redirect'; } @@ -228,9 +232,16 @@ export default class AuthManager { */ public async login(anonymousId?: string, directLoginOptions?: DirectLoginOptions): Promise { return withPassportError(async () => { + let directLoginOptionsToUse: DirectLoginOptions | undefined; + if (directLoginOptions) { + directLoginOptionsToUse = directLoginOptions; + } else if (!this.config.popupOverlayOptions.disableHeadlessLoginPromptOverlay) { + directLoginOptionsToUse = await this.embeddedLoginPrompt.displayEmbeddedLoginPrompt(); + } + const popupWindowTarget = window.crypto.randomUUID(); const signinPopup = async () => { - const extraQueryParams = this.buildExtraQueryParams(anonymousId, directLoginOptions); + const extraQueryParams = this.buildExtraQueryParams(anonymousId, directLoginOptionsToUse); const userPromise = this.userManager.signinPopup({ extraQueryParams, @@ -287,7 +298,7 @@ export default class AuthManager { // Popup was blocked; append the blocked popup overlay to allow the user to try again. let popupHasBeenOpened: boolean = false; - const overlay = new Overlay(this.config.popupOverlayOptions, true); + const overlay = new ConfirmationOverlay(this.config.popupOverlayOptions, true); overlay.append( async () => { try { diff --git a/packages/passport/sdk/src/config/config.ts b/packages/passport/sdk/src/config/config.ts index 2a9e42fb22..0e6f61e90c 100644 --- a/packages/passport/sdk/src/config/config.ts +++ b/packages/passport/sdk/src/config/config.ts @@ -74,6 +74,7 @@ export class PassportConfiguration { this.popupOverlayOptions = popupOverlayOptions || { disableGenericPopupOverlay: false, disableBlockedPopupOverlay: false, + disableHeadlessLoginPromptOverlay: false, }; if (overrides) { validateConfiguration( diff --git a/packages/passport/sdk/src/confirmation/confirmation.test.ts b/packages/passport/sdk/src/confirmation/confirmation.test.ts index a7b219d6ac..45098f30f1 100644 --- a/packages/passport/sdk/src/confirmation/confirmation.test.ts +++ b/packages/passport/sdk/src/confirmation/confirmation.test.ts @@ -7,7 +7,7 @@ import ConfirmationScreen from './confirmation'; import SpyInstance = jest.SpyInstance; import { testConfig } from '../test/mocks'; import { PassportConfiguration } from '../config'; -import { PASSPORT_EVENT_TYPE, ReceiveMessage } from './types'; +import { PASSPORT_CONFIRMATION_EVENT_TYPE, ConfirmationReceiveMessage } from './types'; let windowSpy: SpyInstance; const closeMock = jest.fn(); @@ -92,8 +92,8 @@ describe('confirmation', () => { const mockedWindowReadyValue = { origin: testConfig.passportDomain, data: { - eventType: PASSPORT_EVENT_TYPE, - messageType: ReceiveMessage.CONFIRMATION_WINDOW_READY, + eventType: PASSPORT_CONFIRMATION_EVENT_TYPE, + messageType: ConfirmationReceiveMessage.CONFIRMATION_WINDOW_READY, }, }; addEventListenerMock @@ -126,8 +126,8 @@ describe('confirmation', () => { callback({ origin: testConfig.passportDomain, data: { - eventType: PASSPORT_EVENT_TYPE, - messageType: ReceiveMessage.TRANSACTION_REJECTED, + eventType: PASSPORT_CONFIRMATION_EVENT_TYPE, + messageType: ConfirmationReceiveMessage.TRANSACTION_REJECTED, }, }); }); @@ -179,8 +179,8 @@ describe('confirmation', () => { const mockedWindowReadyValue = { origin: testConfig.passportDomain, data: { - eventType: PASSPORT_EVENT_TYPE, - messageType: ReceiveMessage.CONFIRMATION_WINDOW_READY, + eventType: PASSPORT_CONFIRMATION_EVENT_TYPE, + messageType: ConfirmationReceiveMessage.CONFIRMATION_WINDOW_READY, }, }; addEventListenerMock @@ -209,8 +209,8 @@ describe('confirmation', () => { callback({ origin: testConfig.passportDomain, data: { - eventType: PASSPORT_EVENT_TYPE, - messageType: ReceiveMessage.MESSAGE_REJECTED, + eventType: PASSPORT_CONFIRMATION_EVENT_TYPE, + messageType: ConfirmationReceiveMessage.MESSAGE_REJECTED, }, }); }); diff --git a/packages/passport/sdk/src/confirmation/confirmation.ts b/packages/passport/sdk/src/confirmation/confirmation.ts index b445ae3bb4..f8069ca7cb 100644 --- a/packages/passport/sdk/src/confirmation/confirmation.ts +++ b/packages/passport/sdk/src/confirmation/confirmation.ts @@ -3,13 +3,13 @@ import { trackError } from '@imtbl/metrics'; import { ConfirmationResult, - PASSPORT_EVENT_TYPE, - ReceiveMessage, - SendMessage, + PASSPORT_CONFIRMATION_EVENT_TYPE, + ConfirmationReceiveMessage, + ConfirmationSendMessage, } from './types'; import { openPopupCenter } from './popup'; import { PassportConfiguration } from '../config'; -import Overlay from '../overlay'; +import ConfirmationOverlay from '../overlay/confirmationOverlay'; const CONFIRMATION_WINDOW_TITLE = 'Confirm this transaction'; const CONFIRMATION_WINDOW_HEIGHT = 720; @@ -30,7 +30,7 @@ export default class ConfirmationScreen { private popupOptions: { width: number; height: number } | undefined; - private overlay: Overlay | undefined; + private overlay: ConfirmationOverlay | undefined; private overlayClosed: boolean; @@ -67,30 +67,30 @@ export default class ConfirmationScreen { const messageHandler = ({ data, origin }: MessageEvent) => { if ( origin !== this.config.passportDomain - || data.eventType !== PASSPORT_EVENT_TYPE + || data.eventType !== PASSPORT_CONFIRMATION_EVENT_TYPE ) { return; } - switch (data.messageType as ReceiveMessage) { - case ReceiveMessage.CONFIRMATION_WINDOW_READY: { + switch (data.messageType as ConfirmationReceiveMessage) { + case ConfirmationReceiveMessage.CONFIRMATION_WINDOW_READY: { this.confirmationWindow?.postMessage({ - eventType: PASSPORT_EVENT_TYPE, - messageType: SendMessage.CONFIRMATION_START, + eventType: PASSPORT_CONFIRMATION_EVENT_TYPE, + messageType: ConfirmationSendMessage.CONFIRMATION_START, }, this.config.passportDomain); break; } - case ReceiveMessage.TRANSACTION_CONFIRMED: { + case ConfirmationReceiveMessage.TRANSACTION_CONFIRMED: { this.closeWindow(); resolve({ confirmed: true }); break; } - case ReceiveMessage.TRANSACTION_REJECTED: { + case ConfirmationReceiveMessage.TRANSACTION_REJECTED: { this.closeWindow(); resolve({ confirmed: false }); break; } - case ReceiveMessage.TRANSACTION_ERROR: { + case ConfirmationReceiveMessage.TRANSACTION_ERROR: { this.closeWindow(); reject(new Error('Error during transaction confirmation')); break; @@ -123,29 +123,29 @@ export default class ConfirmationScreen { const messageHandler = ({ data, origin }: MessageEvent) => { if ( origin !== this.config.passportDomain - || data.eventType !== PASSPORT_EVENT_TYPE + || data.eventType !== PASSPORT_CONFIRMATION_EVENT_TYPE ) { return; } - switch (data.messageType as ReceiveMessage) { - case ReceiveMessage.CONFIRMATION_WINDOW_READY: { + switch (data.messageType as ConfirmationReceiveMessage) { + case ConfirmationReceiveMessage.CONFIRMATION_WINDOW_READY: { this.confirmationWindow?.postMessage({ - eventType: PASSPORT_EVENT_TYPE, - messageType: SendMessage.CONFIRMATION_START, + eventType: PASSPORT_CONFIRMATION_EVENT_TYPE, + messageType: ConfirmationSendMessage.CONFIRMATION_START, }, this.config.passportDomain); break; } - case ReceiveMessage.MESSAGE_CONFIRMED: { + case ConfirmationReceiveMessage.MESSAGE_CONFIRMED: { this.closeWindow(); resolve({ confirmed: true }); break; } - case ReceiveMessage.MESSAGE_REJECTED: { + case ConfirmationReceiveMessage.MESSAGE_REJECTED: { this.closeWindow(); resolve({ confirmed: false }); break; } - case ReceiveMessage.MESSAGE_ERROR: { + case ConfirmationReceiveMessage.MESSAGE_ERROR: { this.closeWindow(); reject(new Error('Error during message confirmation')); break; @@ -194,12 +194,12 @@ export default class ConfirmationScreen { width: popupOptions?.width || CONFIRMATION_WINDOW_WIDTH, height: popupOptions?.height || CONFIRMATION_WINDOW_HEIGHT, }); - this.overlay = new Overlay(this.config.popupOverlayOptions); + this.overlay = new ConfirmationOverlay(this.config.popupOverlayOptions); } catch (error) { // If an error is thrown here then the popup is blocked const errorMessage = error instanceof Error ? error.message : String(error); trackError('passport', 'confirmationPopupDenied', new Error(errorMessage)); - this.overlay = new Overlay(this.config.popupOverlayOptions, true); + this.overlay = new ConfirmationOverlay(this.config.popupOverlayOptions, true); } this.overlay.append( diff --git a/packages/passport/sdk/src/confirmation/embeddedLoginPrompt.test.ts b/packages/passport/sdk/src/confirmation/embeddedLoginPrompt.test.ts new file mode 100644 index 0000000000..d4cfe9d4a3 --- /dev/null +++ b/packages/passport/sdk/src/confirmation/embeddedLoginPrompt.test.ts @@ -0,0 +1,349 @@ +import EmbeddedLoginPrompt from './embeddedLoginPrompt'; +import EmbeddedLoginPromptOverlay from '../overlay/embeddedLoginPromptOverlay'; +import { PassportConfiguration } from '../config'; +import { + EMBEDDED_LOGIN_PROMPT_EVENT_TYPE, + EmbeddedLoginPromptReceiveMessage, + EmbeddedLoginPromptResult, +} from './types'; +import { DirectLoginOptions, MarketingConsentStatus } from '../types'; + +// Mock dependencies +jest.mock('../overlay/embeddedLoginPromptOverlay'); +jest.mock('../config'); + +describe('EmbeddedLoginPrompt', () => { + let embeddedLoginPrompt: EmbeddedLoginPrompt; + let mockConfig: jest.Mocked; + let mockOverlay: jest.Mocked; + + const mockClientId = 'test-client-id'; + + beforeEach(() => { + // Clear all mocks + jest.clearAllMocks(); + + // Mock DOM methods + document.createElement = jest.fn().mockImplementation((tagName: string) => { + if (tagName === 'iframe') { + return { id: '', src: '', style: {} }; + } + return { id: '', textContent: '' }; // For style elements + }); + document.getElementById = jest.fn(); + document.head.appendChild = jest.fn(); + + // Mock config + mockConfig = { + oidcConfiguration: { + clientId: mockClientId, + }, + authenticationDomain: 'https://auth.immutable.com', + } as jest.Mocked; + + // Mock overlay + mockOverlay = EmbeddedLoginPromptOverlay as jest.Mocked; + mockOverlay.appendOverlay = jest.fn(); + mockOverlay.remove = jest.fn(); + + embeddedLoginPrompt = new EmbeddedLoginPrompt(mockConfig); + }); + + afterEach(() => { + // Clean up event listeners + window.removeEventListener = jest.fn(); + }); + + describe('constructor', () => { + it('should initialize with provided config', () => { + expect(embeddedLoginPrompt).toBeInstanceOf(EmbeddedLoginPrompt); + }); + }); + + describe('getHref', () => { + it('should generate correct href with client ID', () => { + const href = (embeddedLoginPrompt as any).getHref(); + expect(href).toBe(`https://auth.immutable.com/im-embedded-login-prompt?client_id=${mockClientId}`); + }); + }); + + describe('appendIFrameStylesIfNeeded', () => { + it('should not append styles if they already exist', () => { + const mockElement = { id: 'passport-embedded-login-keyframes' }; + (document.getElementById as jest.Mock).mockReturnValue(mockElement); + + // Clear the mock call count from beforeEach + jest.clearAllMocks(); + (document.getElementById as jest.Mock).mockReturnValue(mockElement); + + (EmbeddedLoginPrompt as any).appendIFrameStylesIfNeeded(); + + expect(document.createElement).not.toHaveBeenCalled(); + expect(document.head.appendChild).not.toHaveBeenCalled(); + }); + + it('should append styles if they do not exist', () => { + const mockStyleElement = { + id: '', + textContent: '', + }; + + (document.getElementById as jest.Mock).mockReturnValue(null); + (document.createElement as jest.Mock).mockReturnValue(mockStyleElement); + + (EmbeddedLoginPrompt as any).appendIFrameStylesIfNeeded(); + + expect(document.createElement).toHaveBeenCalledWith('style'); + expect(mockStyleElement.id).toBe('passport-embedded-login-keyframes'); + }); + }); + + describe('getEmbeddedLoginIFrame', () => { + it('should create iframe with correct properties', () => { + const mockIframe = { + id: '', + src: '', + style: {}, + }; + + // Mock createElement to return different elements based on tag name + (document.createElement as jest.Mock).mockImplementation((tagName: string) => { + if (tagName === 'iframe') { + return mockIframe; + } + return { id: '', textContent: '' }; // For style elements + }); + (document.getElementById as jest.Mock).mockReturnValue(null); + + const iframe = (embeddedLoginPrompt as any).getEmbeddedLoginIFrame(); + + expect(document.createElement).toHaveBeenCalledWith('iframe'); + expect(iframe.id).toBe('passport-embedded-login-iframe'); + }); + }); + + describe('displayEmbeddedLoginPrompt', () => { + let mockIframe: any; + let mockAddEventListener: jest.Mock; + let mockRemoveEventListener: jest.Mock; + + beforeEach(() => { + mockIframe = { + id: 'passport-embedded-login-iframe', + src: '', + style: {}, + }; + + mockAddEventListener = jest.fn(); + mockRemoveEventListener = jest.fn(); + + (document.createElement as jest.Mock).mockImplementation((tagName: string) => { + if (tagName === 'iframe') { + return mockIframe; + } + return { id: '', textContent: '' }; // For style elements + }); + (document.getElementById as jest.Mock).mockReturnValue(null); + + Object.defineProperty(window, 'addEventListener', { + value: mockAddEventListener, + writable: true, + }); + Object.defineProperty(window, 'removeEventListener', { + value: mockRemoveEventListener, + writable: true, + }); + }); + + it('should resolve with email login options when email method is selected', async () => { + const mockLoginResult: EmbeddedLoginPromptResult = { + directLoginMethod: 'email', + email: 'test@example.com', + marketingConsentStatus: MarketingConsentStatus.OptedIn, + }; + + const promise = embeddedLoginPrompt.displayEmbeddedLoginPrompt(); + + // Simulate message event + const messageHandler = mockAddEventListener.mock.calls[0][1]; + const mockEvent = { + data: { + eventType: EMBEDDED_LOGIN_PROMPT_EVENT_TYPE, + messageType: EmbeddedLoginPromptReceiveMessage.LOGIN_METHOD_SELECTED, + payload: mockLoginResult, + }, + origin: mockConfig.authenticationDomain, + }; + + messageHandler(mockEvent); + + const result = await promise; + const expectedResult: DirectLoginOptions = { + directLoginMethod: 'email', + marketingConsentStatus: MarketingConsentStatus.OptedIn, + email: 'test@example.com', + }; + + expect(result).toEqual(expectedResult); + expect(mockRemoveEventListener).toHaveBeenCalledWith('message', messageHandler); + expect(mockOverlay.remove).toHaveBeenCalled(); + }); + + it('should resolve with non-email login options when non-email method is selected', async () => { + const mockLoginResult: EmbeddedLoginPromptResult = { + directLoginMethod: 'google', + marketingConsentStatus: MarketingConsentStatus.Unsubscribed, + }; + + const promise = embeddedLoginPrompt.displayEmbeddedLoginPrompt(); + + // Simulate message event + const messageHandler = mockAddEventListener.mock.calls[0][1]; + const mockEvent = { + data: { + eventType: EMBEDDED_LOGIN_PROMPT_EVENT_TYPE, + messageType: EmbeddedLoginPromptReceiveMessage.LOGIN_METHOD_SELECTED, + payload: mockLoginResult, + }, + origin: mockConfig.authenticationDomain, + }; + + messageHandler(mockEvent); + + const result = await promise; + const expectedResult: DirectLoginOptions = { + directLoginMethod: 'google', + marketingConsentStatus: MarketingConsentStatus.Unsubscribed, + }; + + expect(result).toEqual(expectedResult); + expect(mockRemoveEventListener).toHaveBeenCalledWith('message', messageHandler); + expect(mockOverlay.remove).toHaveBeenCalled(); + }); + + it('should reject with error when login prompt error occurs', async () => { + const promise = embeddedLoginPrompt.displayEmbeddedLoginPrompt(); + + // Simulate error message event + const messageHandler = mockAddEventListener.mock.calls[0][1]; + const mockEvent = { + data: { + eventType: EMBEDDED_LOGIN_PROMPT_EVENT_TYPE, + messageType: EmbeddedLoginPromptReceiveMessage.LOGIN_PROMPT_ERROR, + }, + origin: mockConfig.authenticationDomain, + }; + + messageHandler(mockEvent); + + await expect(promise).rejects.toThrow('Error during embedded login prompt'); + expect(mockRemoveEventListener).toHaveBeenCalledWith('message', messageHandler); + expect(mockOverlay.remove).toHaveBeenCalled(); + }); + + it('should reject with error when login prompt is closed', async () => { + const promise = embeddedLoginPrompt.displayEmbeddedLoginPrompt(); + + // Simulate close message event + const messageHandler = mockAddEventListener.mock.calls[0][1]; + const mockEvent = { + data: { + eventType: EMBEDDED_LOGIN_PROMPT_EVENT_TYPE, + messageType: EmbeddedLoginPromptReceiveMessage.LOGIN_PROMPT_CLOSED, + }, + origin: mockConfig.authenticationDomain, + }; + + messageHandler(mockEvent); + + await expect(promise).rejects.toThrow('Popup closed by user'); + expect(mockRemoveEventListener).toHaveBeenCalledWith('message', messageHandler); + expect(mockOverlay.remove).toHaveBeenCalled(); + }); + + it('should reject with error for unsupported message type', async () => { + const promise = embeddedLoginPrompt.displayEmbeddedLoginPrompt(); + + // Simulate unsupported message event + const messageHandler = mockAddEventListener.mock.calls[0][1]; + const mockEvent = { + data: { + eventType: EMBEDDED_LOGIN_PROMPT_EVENT_TYPE, + messageType: 'UNKNOWN_MESSAGE_TYPE', + }, + origin: mockConfig.authenticationDomain, + }; + + messageHandler(mockEvent); + + await expect(promise).rejects.toThrow('Unsupported message type'); + expect(mockRemoveEventListener).toHaveBeenCalledWith('message', messageHandler); + expect(mockOverlay.remove).toHaveBeenCalled(); + }); + + it('should ignore messages from wrong origin', async () => { + embeddedLoginPrompt.displayEmbeddedLoginPrompt(); + + // Simulate message from wrong origin + const messageHandler = mockAddEventListener.mock.calls[0][1]; + const mockEvent = { + data: { + eventType: EMBEDDED_LOGIN_PROMPT_EVENT_TYPE, + messageType: EmbeddedLoginPromptReceiveMessage.LOGIN_METHOD_SELECTED, + payload: { directLoginMethod: 'google', marketingConsentStatus: MarketingConsentStatus.OptedIn }, + }, + origin: 'https://malicious-site.com', + }; + + messageHandler(mockEvent); + + // Should not resolve or reject yet + expect(mockRemoveEventListener).not.toHaveBeenCalled(); + expect(mockOverlay.remove).not.toHaveBeenCalled(); + }); + + it('should ignore messages with wrong event type', async () => { + embeddedLoginPrompt.displayEmbeddedLoginPrompt(); + + // Simulate message with wrong event type + const messageHandler = mockAddEventListener.mock.calls[0][1]; + const mockEvent = { + data: { + eventType: 'WRONG_EVENT_TYPE', + messageType: EmbeddedLoginPromptReceiveMessage.LOGIN_METHOD_SELECTED, + payload: { directLoginMethod: 'google', marketingConsentStatus: MarketingConsentStatus.OptedIn }, + }, + origin: mockConfig.authenticationDomain, + }; + + messageHandler(mockEvent); + + // Should not resolve or reject yet + expect(mockRemoveEventListener).not.toHaveBeenCalled(); + expect(mockOverlay.remove).not.toHaveBeenCalled(); + }); + + it('should setup overlay with close callback', async () => { + const promise = embeddedLoginPrompt.displayEmbeddedLoginPrompt(); + + expect(mockOverlay.appendOverlay).toHaveBeenCalledWith( + mockIframe, + expect.any(Function), + ); + + // Test the close callback + const closeCallback = mockOverlay.appendOverlay.mock.calls[0][1]; + closeCallback(); + + await expect(promise).rejects.toThrow('Popup closed by user'); + expect(mockRemoveEventListener).toHaveBeenCalled(); + expect(mockOverlay.remove).toHaveBeenCalled(); + }); + + it('should add message event listener', () => { + embeddedLoginPrompt.displayEmbeddedLoginPrompt(); + + expect(mockAddEventListener).toHaveBeenCalledWith('message', expect.any(Function)); + }); + }); +}); diff --git a/packages/passport/sdk/src/confirmation/embeddedLoginPrompt.ts b/packages/passport/sdk/src/confirmation/embeddedLoginPrompt.ts new file mode 100644 index 0000000000..50de56619d --- /dev/null +++ b/packages/passport/sdk/src/confirmation/embeddedLoginPrompt.ts @@ -0,0 +1,152 @@ +import { + EMBEDDED_LOGIN_PROMPT_EVENT_TYPE, + EmbeddedLoginPromptResult, + EmbeddedLoginPromptReceiveMessage, +} from './types'; +import { PassportConfiguration } from '../config'; +import EmbeddedLoginPromptOverlay from '../overlay/embeddedLoginPromptOverlay'; +import { DirectLoginOptions } from '../types'; + +const LOGIN_PROMPT_WINDOW_HEIGHT = 560; +const LOGIN_PROMPT_WINDOW_WIDTH = 440; +const LOGIN_PROMPT_WINDOW_BORDER_RADIUS = '16px'; +const LOGIN_PROMPT_KEYFRAME_STYLES_ID = 'passport-embedded-login-keyframes'; +const LOGIN_PROMPT_IFRAME_ID = 'passport-embedded-login-iframe'; + +export default class EmbeddedLoginPrompt { + private config: PassportConfiguration; + + constructor(config: PassportConfiguration) { + this.config = config; + } + + private getHref = () => ( + `${this.config.authenticationDomain}/im-embedded-login-prompt?client_id=${this.config.oidcConfiguration.clientId}` + ); + + private static appendIFrameStylesIfNeeded = () => { + if (document.getElementById(LOGIN_PROMPT_KEYFRAME_STYLES_ID)) { + return; + } + + const style = document.createElement('style'); + style.id = LOGIN_PROMPT_KEYFRAME_STYLES_ID; + style.textContent = ` + @keyframes passportEmbeddedLoginPromptPopBounceIn { + 0% { + opacity: 0.5; + transform: scale(0.9); + } + 50% { + opacity: 1; + transform: scale(1.05); + } + 75% { + transform: scale(0.98); + } + 100% { + opacity: 1; + transform: scale(1); + } + } + + @media (max-height: 400px) { + #${LOGIN_PROMPT_IFRAME_ID} { + width: 100% !important; + max-width: none !important; + } + } + + @keyframes passportEmbeddedLoginPromptOverlayFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + `; + + document.head.appendChild(style); + }; + + private getEmbeddedLoginIFrame = () => { + const embeddedLoginPrompt = document.createElement('iframe'); + embeddedLoginPrompt.id = LOGIN_PROMPT_IFRAME_ID; + embeddedLoginPrompt.src = this.getHref(); + embeddedLoginPrompt.style.height = '100vh'; + embeddedLoginPrompt.style.width = '100vw'; + embeddedLoginPrompt.style.maxHeight = `${LOGIN_PROMPT_WINDOW_HEIGHT}px`; + embeddedLoginPrompt.style.maxWidth = `${LOGIN_PROMPT_WINDOW_WIDTH}px`; + embeddedLoginPrompt.style.borderRadius = LOGIN_PROMPT_WINDOW_BORDER_RADIUS; + + // Animation styles + embeddedLoginPrompt.style.opacity = '0'; + embeddedLoginPrompt.style.transform = 'scale(0.9)'; + embeddedLoginPrompt.style.animation = 'passportEmbeddedLoginPromptPopBounceIn 0.8s ease 0.2s forwards'; + EmbeddedLoginPrompt.appendIFrameStylesIfNeeded(); + + return embeddedLoginPrompt; + }; + + public displayEmbeddedLoginPrompt(): Promise { + return new Promise((resolve, reject) => { + const embeddedLoginPrompt = this.getEmbeddedLoginIFrame(); + const messageHandler = ({ data, origin }: MessageEvent) => { + if ( + origin !== this.config.authenticationDomain + || data.eventType !== EMBEDDED_LOGIN_PROMPT_EVENT_TYPE + ) { + return; + } + + switch (data.messageType as EmbeddedLoginPromptReceiveMessage) { + case EmbeddedLoginPromptReceiveMessage.LOGIN_METHOD_SELECTED: { + const loginMethod = data.payload as EmbeddedLoginPromptResult; + let result: DirectLoginOptions; + if (loginMethod.directLoginMethod === 'email') { + result = { + directLoginMethod: 'email', + marketingConsentStatus: loginMethod.marketingConsentStatus, + email: loginMethod.email, + }; + } else { + result = { + directLoginMethod: loginMethod.directLoginMethod, + marketingConsentStatus: loginMethod.marketingConsentStatus, + }; + } + window.removeEventListener('message', messageHandler); + EmbeddedLoginPromptOverlay.remove(); + resolve(result); + break; + } + case EmbeddedLoginPromptReceiveMessage.LOGIN_PROMPT_ERROR: { + window.removeEventListener('message', messageHandler); + EmbeddedLoginPromptOverlay.remove(); + reject(new Error('Error during embedded login prompt', { cause: data.payload })); + break; + } + case EmbeddedLoginPromptReceiveMessage.LOGIN_PROMPT_CLOSED: { + window.removeEventListener('message', messageHandler); + EmbeddedLoginPromptOverlay.remove(); + reject(new Error('Popup closed by user')); + break; + } + default: + window.removeEventListener('message', messageHandler); + EmbeddedLoginPromptOverlay.remove(); + reject(new Error('Unsupported message type')); + break; + } + }; + + window.addEventListener('message', messageHandler); + EmbeddedLoginPromptOverlay.appendOverlay(embeddedLoginPrompt, () => { + window.removeEventListener('message', messageHandler); + EmbeddedLoginPromptOverlay.remove(); + reject(new Error('Popup closed by user')); + }); + }); + } +} diff --git a/packages/passport/sdk/src/confirmation/index.ts b/packages/passport/sdk/src/confirmation/index.ts index 491f50e900..3ef532c235 100644 --- a/packages/passport/sdk/src/confirmation/index.ts +++ b/packages/passport/sdk/src/confirmation/index.ts @@ -1,2 +1,3 @@ export { default as ConfirmationScreen } from './confirmation'; +export { default as EmbeddedLoginPrompt } from './embeddedLoginPrompt'; export * from './types'; diff --git a/packages/passport/sdk/src/confirmation/types.ts b/packages/passport/sdk/src/confirmation/types.ts index 311b07be88..12214b6edd 100644 --- a/packages/passport/sdk/src/confirmation/types.ts +++ b/packages/passport/sdk/src/confirmation/types.ts @@ -1,8 +1,10 @@ -export enum SendMessage { +import { DirectLoginMethod, MarketingConsentStatus } from '../types'; + +export enum ConfirmationSendMessage { CONFIRMATION_START = 'confirmation_start', } -export enum ReceiveMessage { +export enum ConfirmationReceiveMessage { CONFIRMATION_WINDOW_READY = 'confirmation_window_ready', TRANSACTION_CONFIRMED = 'transaction_confirmed', TRANSACTION_ERROR = 'transaction_error', @@ -12,8 +14,22 @@ export enum ReceiveMessage { MESSAGE_REJECTED = 'message_rejected', } +export enum EmbeddedLoginPromptReceiveMessage { + LOGIN_METHOD_SELECTED = 'login_method_selected', + LOGIN_PROMPT_ERROR = 'login_prompt_error', + LOGIN_PROMPT_CLOSED = 'login_prompt_closed', +} + export type ConfirmationResult = { confirmed: boolean; }; -export const PASSPORT_EVENT_TYPE = 'imx_passport_confirmation'; +export type EmbeddedLoginPromptResult = { + marketingConsentStatus: MarketingConsentStatus; +} & ( + | { directLoginMethod: 'email'; email: string } + | { directLoginMethod: Exclude; email?: never } +); + +export const PASSPORT_CONFIRMATION_EVENT_TYPE = 'imx_passport_confirmation'; +export const EMBEDDED_LOGIN_PROMPT_EVENT_TYPE = 'im_passport_embedded_login_prompt'; diff --git a/packages/passport/sdk/src/overlay/overlay.test.ts b/packages/passport/sdk/src/overlay/confirmationOverlay.test.ts similarity index 64% rename from packages/passport/sdk/src/overlay/overlay.test.ts rename to packages/passport/sdk/src/overlay/confirmationOverlay.test.ts index ba811be03a..76a9cb5890 100644 --- a/packages/passport/sdk/src/overlay/overlay.test.ts +++ b/packages/passport/sdk/src/overlay/confirmationOverlay.test.ts @@ -1,54 +1,60 @@ -import Overlay from './index'; +import ConfirmationOverlay from './confirmationOverlay'; -describe('overlay', () => { +describe('confirmationOverlay', () => { beforeEach(() => { document.body.innerHTML = ''; }); it('should append generic overlay', () => { - const overlay = new Overlay({}, false); + const overlay = new ConfirmationOverlay({}, false); overlay.append(() => {}, () => {}); expect(document.body.innerHTML).toContain('passport-overlay'); }); it('should append blocked overlay', () => { - const overlay = new Overlay({}, true); + const overlay = new ConfirmationOverlay({}, true); overlay.append(() => {}, () => {}); expect(document.body.innerHTML).toContain('passport-overlay'); }); it('should not append generic overlay when generic disabled', () => { - const overlay = new Overlay({ disableGenericPopupOverlay: true }, false); + const overlay = new ConfirmationOverlay({ disableGenericPopupOverlay: true }, false); overlay.append(() => {}, () => {}); expect(document.body.innerHTML).not.toContain('passport-overlay'); }); it('should append overlay if only generic disabled and is blocked', () => { - const overlay = new Overlay({ disableGenericPopupOverlay: true }, true); + const overlay = new ConfirmationOverlay({ disableGenericPopupOverlay: true }, true); overlay.append(() => {}, () => {}); expect(document.body.innerHTML).toContain('passport-overlay'); }); it('should not append blocked overlay when blocked disabled', () => { - const overlay = new Overlay({ disableBlockedPopupOverlay: true }, true); + const overlay = new ConfirmationOverlay({ disableBlockedPopupOverlay: true }, true); overlay.append(() => {}, () => {}); expect(document.body.innerHTML).not.toContain('passport-overlay'); }); it('should append generic overlay when only blocked disabled', () => { - const overlay = new Overlay({ disableBlockedPopupOverlay: true }, false); + const overlay = new ConfirmationOverlay({ disableBlockedPopupOverlay: true }, false); overlay.append(() => {}, () => {}); expect(document.body.innerHTML).toContain('passport-overlay'); }); it('should not append generic overlay when overlays disabled', () => { - const overlay = new Overlay({ disableGenericPopupOverlay: true, disableBlockedPopupOverlay: true }, false); + const overlay = new ConfirmationOverlay({ + disableGenericPopupOverlay: true, + disableBlockedPopupOverlay: true, + }, false); overlay.append(() => {}, () => {}); expect(document.body.innerHTML).not.toContain('passport-overlay'); }); it('should not append blocked overlay when overlays disabled', () => { - const overlay = new Overlay({ disableGenericPopupOverlay: true, disableBlockedPopupOverlay: true }, true); + const overlay = new ConfirmationOverlay({ + disableGenericPopupOverlay: true, + disableBlockedPopupOverlay: true, + }, true); overlay.append(() => {}, () => {}); expect(document.body.innerHTML).not.toContain('passport-overlay'); }); diff --git a/packages/passport/sdk/src/overlay/index.ts b/packages/passport/sdk/src/overlay/confirmationOverlay.ts similarity index 98% rename from packages/passport/sdk/src/overlay/index.ts rename to packages/passport/sdk/src/overlay/confirmationOverlay.ts index 771d5cd4bf..fe3808525f 100644 --- a/packages/passport/sdk/src/overlay/index.ts +++ b/packages/passport/sdk/src/overlay/confirmationOverlay.ts @@ -2,7 +2,7 @@ import { PopupOverlayOptions } from '../types'; import { PASSPORT_OVERLAY_CLOSE_ID, PASSPORT_OVERLAY_TRY_AGAIN_ID } from './constants'; import { addLink, getBlockedOverlay, getGenericOverlay } from './elements'; -export default class Overlay { +export default class ConfirmationOverlay { private disableGenericPopupOverlay: boolean; private disableBlockedPopupOverlay: boolean; diff --git a/packages/passport/sdk/src/overlay/constants.ts b/packages/passport/sdk/src/overlay/constants.ts index 741379bba7..3cebc8a883 100644 --- a/packages/passport/sdk/src/overlay/constants.ts +++ b/packages/passport/sdk/src/overlay/constants.ts @@ -1,6 +1,7 @@ /* eslint-disable max-len */ export const PASSPORT_OVERLAY_ID = 'passport-overlay'; +export const PASSPORT_OVERLAY_CONTENTS_ID = 'passport-overlay-contents'; export const PASSPORT_OVERLAY_CLOSE_ID = `${PASSPORT_OVERLAY_ID}-close`; export const PASSPORT_OVERLAY_TRY_AGAIN_ID = `${PASSPORT_OVERLAY_ID}-try-again`; diff --git a/packages/passport/sdk/src/overlay/elements.ts b/packages/passport/sdk/src/overlay/elements.ts index 6853171b21..180712562a 100644 --- a/packages/passport/sdk/src/overlay/elements.ts +++ b/packages/passport/sdk/src/overlay/elements.ts @@ -5,6 +5,7 @@ import { PASSPORT_OVERLAY_CLOSE_ID, PASSPORT_OVERLAY_ID, PASSPORT_OVERLAY_TRY_AGAIN_ID, + PASSPORT_OVERLAY_CONTENTS_ID, } from './constants'; const getCloseButton = (): string => ` @@ -29,7 +30,27 @@ const getCloseButton = (): string => ` `; +const getTryAgainButton = () => ` + +`; + const getBlockedContents = () => ` + ${IMMUTABLE_LOGO_SVG}
` > Secure pop-up not showing?
We'll help you re-launch

+ ${getTryAgainButton()} `; -const getTryAgainButton = () => ` - - `; - -const getOverlay = (contents: string): string => ` +export const getOverlay = (contents: string): string => `
- ${IMMUTABLE_LOGO_SVG} - ${contents} - ${getTryAgainButton()} + ${contents ?? ''}
`; +export const getEmbeddedLoginPromptOverlay = (): string => ` +
+
+
+ `; + type LinkParams = { id: string; href: string; diff --git a/packages/passport/sdk/src/overlay/embeddedLoginPromptOverlay.test.ts b/packages/passport/sdk/src/overlay/embeddedLoginPromptOverlay.test.ts new file mode 100644 index 0000000000..88bc9bb114 --- /dev/null +++ b/packages/passport/sdk/src/overlay/embeddedLoginPromptOverlay.test.ts @@ -0,0 +1,211 @@ +import EmbeddedLoginPromptOverlay from './embeddedLoginPromptOverlay'; +import { PASSPORT_OVERLAY_CONTENTS_ID } from './constants'; +import { getEmbeddedLoginPromptOverlay } from './elements'; + +// Mock dependencies +jest.mock('./elements'); + +describe('EmbeddedLoginPromptOverlay', () => { + let mockOverlayHTML: string; + let mockOverlayDiv: HTMLDivElement; + let mockOverlayContents: HTMLDivElement; + let mockIframe: HTMLIFrameElement; + let mockCloseCallback: jest.Mock; + + beforeEach(() => { + // Clear all mocks + jest.clearAllMocks(); + + // Reset static properties + (EmbeddedLoginPromptOverlay as any).overlay = undefined; + (EmbeddedLoginPromptOverlay as any).onCloseListener = undefined; + (EmbeddedLoginPromptOverlay as any).closeButton = undefined; + + // Mock HTML elements + mockOverlayHTML = '
Test overlay content
'; + mockOverlayDiv = { + innerHTML: '', + addEventListener: jest.fn(), + remove: jest.fn(), + } as unknown as HTMLDivElement; + + mockOverlayContents = { + appendChild: jest.fn(), + } as unknown as HTMLDivElement; + + mockIframe = { + id: 'test-iframe', + } as HTMLIFrameElement; + + mockCloseCallback = jest.fn(); + + // Mock DOM methods + document.createElement = jest.fn().mockReturnValue(mockOverlayDiv); + document.body.insertAdjacentElement = jest.fn(); + document.querySelector = jest.fn().mockReturnValue(mockOverlayContents); + + // Mock the getEmbeddedLoginPromptOverlay function + (getEmbeddedLoginPromptOverlay as jest.Mock).mockReturnValue(mockOverlayHTML); + }); + + describe('remove', () => { + it('should remove event listener and overlay when they exist', () => { + const mockCloseButton = { + removeEventListener: jest.fn(), + } as unknown as HTMLButtonElement; + + // Set up the static properties as if overlay was created + (EmbeddedLoginPromptOverlay as any).overlay = mockOverlayDiv; + (EmbeddedLoginPromptOverlay as any).closeButton = mockCloseButton; + (EmbeddedLoginPromptOverlay as any).onCloseListener = mockCloseCallback; + + EmbeddedLoginPromptOverlay.remove(); + + expect(mockCloseButton.removeEventListener).toHaveBeenCalledWith('click', mockCloseCallback); + expect(mockOverlayDiv.remove).toHaveBeenCalled(); + expect((EmbeddedLoginPromptOverlay as any).overlay).toBeUndefined(); + expect((EmbeddedLoginPromptOverlay as any).closeButton).toBeUndefined(); + expect((EmbeddedLoginPromptOverlay as any).onCloseListener).toBeUndefined(); + }); + + it('should handle removal when overlay does not exist', () => { + // Ensure overlay is undefined + (EmbeddedLoginPromptOverlay as any).overlay = undefined; + (EmbeddedLoginPromptOverlay as any).closeButton = undefined; + (EmbeddedLoginPromptOverlay as any).onCloseListener = undefined; + + // Should not throw an error + expect(() => EmbeddedLoginPromptOverlay.remove()).not.toThrow(); + }); + + it('should handle removal when close button does not exist but overlay does', () => { + (EmbeddedLoginPromptOverlay as any).overlay = mockOverlayDiv; + (EmbeddedLoginPromptOverlay as any).closeButton = undefined; + (EmbeddedLoginPromptOverlay as any).onCloseListener = mockCloseCallback; + + EmbeddedLoginPromptOverlay.remove(); + + expect(mockOverlayDiv.remove).toHaveBeenCalled(); + expect((EmbeddedLoginPromptOverlay as any).overlay).toBeUndefined(); + expect((EmbeddedLoginPromptOverlay as any).closeButton).toBeUndefined(); + expect((EmbeddedLoginPromptOverlay as any).onCloseListener).toBeUndefined(); + }); + + it('should handle removal when close listener does not exist', () => { + const mockCloseButton = { + removeEventListener: jest.fn(), + } as unknown as HTMLButtonElement; + + (EmbeddedLoginPromptOverlay as any).overlay = mockOverlayDiv; + (EmbeddedLoginPromptOverlay as any).closeButton = mockCloseButton; + (EmbeddedLoginPromptOverlay as any).onCloseListener = undefined; + + EmbeddedLoginPromptOverlay.remove(); + + expect(mockCloseButton.removeEventListener).not.toHaveBeenCalled(); + expect(mockOverlayDiv.remove).toHaveBeenCalled(); + }); + }); + + describe('appendOverlay', () => { + it('should create and append overlay when it does not exist', () => { + EmbeddedLoginPromptOverlay.appendOverlay(mockIframe, mockCloseCallback); + + expect(document.createElement).toHaveBeenCalledWith('div'); + expect(mockOverlayDiv.innerHTML).toBe(mockOverlayHTML); + expect(document.body.insertAdjacentElement).toHaveBeenCalledWith('beforeend', mockOverlayDiv); + expect(document.querySelector).toHaveBeenCalledWith(`#${PASSPORT_OVERLAY_CONTENTS_ID}`); + expect(mockOverlayContents.appendChild).toHaveBeenCalledWith(mockIframe); + expect(mockOverlayDiv.addEventListener).toHaveBeenCalledWith('click', mockCloseCallback); + expect((EmbeddedLoginPromptOverlay as any).overlay).toBe(mockOverlayDiv); + }); + + it('should not create overlay if it already exists', () => { + // Set overlay as already existing + (EmbeddedLoginPromptOverlay as any).overlay = mockOverlayDiv; + + EmbeddedLoginPromptOverlay.appendOverlay(mockIframe, mockCloseCallback); + + expect(document.createElement).not.toHaveBeenCalled(); + expect(document.body.insertAdjacentElement).not.toHaveBeenCalled(); + expect(document.querySelector).not.toHaveBeenCalled(); + }); + + it('should handle case when overlay contents element is not found', () => { + (document.querySelector as jest.Mock).mockReturnValue(null); + + EmbeddedLoginPromptOverlay.appendOverlay(mockIframe, mockCloseCallback); + + expect(document.createElement).toHaveBeenCalledWith('div'); + expect(mockOverlayDiv.innerHTML).toBe(mockOverlayHTML); + expect(document.body.insertAdjacentElement).toHaveBeenCalledWith('beforeend', mockOverlayDiv); + expect(document.querySelector).toHaveBeenCalledWith(`#${PASSPORT_OVERLAY_CONTENTS_ID}`); + // Should not try to append iframe if contents element not found + expect(mockOverlayContents.appendChild).not.toHaveBeenCalled(); + expect(mockOverlayDiv.addEventListener).toHaveBeenCalledWith('click', mockCloseCallback); + expect((EmbeddedLoginPromptOverlay as any).overlay).toBe(mockOverlayDiv); + }); + + it('should use getEmbeddedLoginPromptOverlay for overlay HTML', () => { + EmbeddedLoginPromptOverlay.appendOverlay(mockIframe, mockCloseCallback); + + expect(getEmbeddedLoginPromptOverlay).toHaveBeenCalled(); + expect(mockOverlayDiv.innerHTML).toBe(mockOverlayHTML); + }); + + it('should add click event listener to overlay', () => { + EmbeddedLoginPromptOverlay.appendOverlay(mockIframe, mockCloseCallback); + + expect(mockOverlayDiv.addEventListener).toHaveBeenCalledWith('click', mockCloseCallback); + }); + }); + + describe('static properties', () => { + it('should initialize static properties as undefined', () => { + expect((EmbeddedLoginPromptOverlay as any).overlay).toBeUndefined(); + expect((EmbeddedLoginPromptOverlay as any).onCloseListener).toBeUndefined(); + expect((EmbeddedLoginPromptOverlay as any).closeButton).toBeUndefined(); + }); + + it('should maintain overlay reference after creation', () => { + EmbeddedLoginPromptOverlay.appendOverlay(mockIframe, mockCloseCallback); + + expect((EmbeddedLoginPromptOverlay as any).overlay).toBe(mockOverlayDiv); + }); + }); + + describe('integration scenarios', () => { + it('should handle complete lifecycle: create, use, remove', () => { + // Create overlay + EmbeddedLoginPromptOverlay.appendOverlay(mockIframe, mockCloseCallback); + + expect((EmbeddedLoginPromptOverlay as any).overlay).toBe(mockOverlayDiv); + expect(mockOverlayContents.appendChild).toHaveBeenCalledWith(mockIframe); + expect(mockOverlayDiv.addEventListener).toHaveBeenCalledWith('click', mockCloseCallback); + + // Remove overlay + EmbeddedLoginPromptOverlay.remove(); + + expect(mockOverlayDiv.remove).toHaveBeenCalled(); + expect((EmbeddedLoginPromptOverlay as any).overlay).toBeUndefined(); + }); + + it('should handle multiple append calls without creating multiple overlays', () => { + const secondIframe = { id: 'second-iframe' } as HTMLIFrameElement; + const secondCallback = jest.fn(); + + // First append + EmbeddedLoginPromptOverlay.appendOverlay(mockIframe, mockCloseCallback); + + // Reset mocks to track second call + jest.clearAllMocks(); + + // Second append + EmbeddedLoginPromptOverlay.appendOverlay(secondIframe, secondCallback); + + // Should not create new overlay + expect(document.createElement).not.toHaveBeenCalled(); + expect(document.body.insertAdjacentElement).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/passport/sdk/src/overlay/embeddedLoginPromptOverlay.ts b/packages/passport/sdk/src/overlay/embeddedLoginPromptOverlay.ts new file mode 100644 index 0000000000..db8ebe0b3f --- /dev/null +++ b/packages/passport/sdk/src/overlay/embeddedLoginPromptOverlay.ts @@ -0,0 +1,37 @@ +import { PASSPORT_OVERLAY_CONTENTS_ID } from './constants'; +import { getEmbeddedLoginPromptOverlay } from './elements'; + +export default class EmbeddedLoginPromptOverlay { + private static overlay: HTMLDivElement | undefined; + + private static onCloseListener: (() => void) | undefined; + + private static closeButton: HTMLButtonElement | undefined; + + static remove() { + if (this.onCloseListener) { + this.closeButton?.removeEventListener?.('click', this.onCloseListener); + } + this.overlay?.remove(); + + this.closeButton = undefined; + this.onCloseListener = undefined; + this.overlay = undefined; + } + + static appendOverlay(embeddedLoginPrompt: HTMLIFrameElement, onCloseListener: () => void) { + if (!this.overlay) { + const overlay = document.createElement('div'); + overlay.innerHTML = getEmbeddedLoginPromptOverlay(); + document.body.insertAdjacentElement('beforeend', overlay); + const overlayContents = document.querySelector(`#${PASSPORT_OVERLAY_CONTENTS_ID}`); + if (overlayContents) { + overlayContents.appendChild(embeddedLoginPrompt); + } + + overlay.addEventListener('click', onCloseListener); + + this.overlay = overlay; + } + } +} diff --git a/packages/passport/sdk/src/types.ts b/packages/passport/sdk/src/types.ts index dbc9229a08..36f39aa8a2 100644 --- a/packages/passport/sdk/src/types.ts +++ b/packages/passport/sdk/src/types.ts @@ -89,6 +89,7 @@ export interface PassportOverrides { export interface PopupOverlayOptions { disableGenericPopupOverlay?: boolean; disableBlockedPopupOverlay?: boolean; + disableHeadlessLoginPromptOverlay?: boolean; } export interface PassportModuleConfiguration @@ -183,7 +184,6 @@ export enum MarketingConsentStatus { } export type DirectLoginOptions = { - directLoginMethod: DirectLoginMethod; marketingConsentStatus?: MarketingConsentStatus; } & ( | { directLoginMethod: 'email'; email: string }