Skip to content

Commit

Permalink
Merge pull request #818 from magiclabs/PDEEXP-350-White-label-recover…
Browse files Browse the repository at this point in the history
…y-factor-flow

feat: recovery facto whitelabeling
  • Loading branch information
sukhrobbekodilov authored Oct 18, 2024
2 parents 51f12e8 + c3345da commit 135def7
Show file tree
Hide file tree
Showing 6 changed files with 312 additions and 11 deletions.
2 changes: 1 addition & 1 deletion packages/@magic-sdk/provider/src/modules/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ export class AuthModule extends BaseModule {
this.createIntermediaryEvent(RecencyCheckEventEmit.Cancel, requestPayload.id as any)();
});
handle.on(RecencyCheckEventEmit.VerifyEmailOtp, (otp: string) => {
this.createIntermediaryEvent(RecencyCheckEventEmit.VerifyEmailOtp, requestPayload.id as any)(otp);
this.createIntermediaryEvent(RecencyCheckEventEmit.VerifyEmailOtp, requestPayload.id as string)(otp);
});
handle.on(RecencyCheckEventEmit.VerifyMFACode, (mfa: string) => {
this.createIntermediaryEvent(RecencyCheckEventEmit.VerifyMFACode, requestPayload.id as string)(mfa);
Expand Down
24 changes: 23 additions & 1 deletion packages/@magic-sdk/provider/src/modules/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import {
DisableMFAConfiguration,
DisableMFAEventHandlers,
DisableMFAEventEmit,
RecencyCheckEventEmit,
RecoveryFactorEventHandlers,
RecoveryFactorEventEmit,
} from '@magic-sdk/types';
import { getItem, setItem, removeItem } from '../util/storage';
import { BaseModule } from './base-module';
Expand Down Expand Up @@ -110,11 +113,30 @@ export class UserModule extends BaseModule {
}

public showSettings(configuration?: ShowSettingsConfiguration) {
const { showUI = true } = configuration || {};
const requestPayload = createJsonRpcRequestPayload(
this.sdk.testMode ? MagicPayloadMethod.UserSettingsTestMode : MagicPayloadMethod.UserSettings,
[configuration],
);
return this.request<MagicUserMetadata>(requestPayload);
const handle = this.request<string | null, RecoveryFactorEventHandlers>(requestPayload);
if (!showUI && handle) {
handle.on(RecoveryFactorEventEmit.SendNewPhoneNumber, (phone_number: string) => {
this.createIntermediaryEvent(
RecoveryFactorEventEmit.SendNewPhoneNumber,
requestPayload.id as string,
)(phone_number);
});
handle.on(RecoveryFactorEventEmit.SendOtpCode, (otp: string) => {
this.createIntermediaryEvent(RecoveryFactorEventEmit.SendOtpCode, requestPayload.id as string)(otp);
});
handle.on(RecoveryFactorEventEmit.StartEditPhoneNumber, () => {
this.createIntermediaryEvent(RecoveryFactorEventEmit.StartEditPhoneNumber, requestPayload.id as string)();
});
handle.on(RecencyCheckEventEmit.VerifyEmailOtp, (otp: string) => {
this.createIntermediaryEvent(RecencyCheckEventEmit.VerifyEmailOtp, requestPayload.id as string)(otp);
});
}
return handle;
}

public recoverAccount(configuration: RecoverAccountConfiguration) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,21 @@ import { DeepLinkPage } from '@magic-sdk/types/src/core/deep-link-pages';
import { createMagicSDK, createMagicSDKTestMode } from '../../../factories';
import { isPromiEvent } from '../../../../src/util';

jest.mock('@magic-sdk/types', () => ({
...jest.requireActual('@magic-sdk/types'),
RecoveryFactorEventEmit: {
SendNewPhoneNumber: 'send-new-phone-number',
SendOtpCode: 'send-otp-code',
StartEditPhoneNumber: 'start-edit-phone-number',
},
}));

beforeEach(() => {
browserEnv.restore();
jest.restoreAllMocks();
});

test('Generate JSON RPC request payload with method `magic_auth_settings`', async () => {
test('Generate JSON RPC request payload with method `magic_auth_settings`', () => {
const magic = createMagicSDK();
magic.user.request = jest.fn();

Expand All @@ -18,7 +28,7 @@ test('Generate JSON RPC request payload with method `magic_auth_settings`', asyn
expect(requestPayload.params).toEqual([undefined]);
});

test('If `testMode` is enabled, testing-specific RPC method is used', async () => {
test('If `testMode` is enabled, testing-specific RPC method is used', () => {
const magic = createMagicSDKTestMode();
magic.user.request = jest.fn();

Expand All @@ -29,7 +39,7 @@ test('If `testMode` is enabled, testing-specific RPC method is used', async () =
expect(requestPayload.params).toEqual([undefined]);
});

test('Generate JSON RPC request payload with method `magic_auth_settings` and page params `update-email`', async () => {
test('Generate JSON RPC request payload with method `magic_auth_settings` and page params `update-email`', () => {
const magic = createMagicSDK();
magic.user.request = jest.fn();

Expand All @@ -40,28 +50,265 @@ test('Generate JSON RPC request payload with method `magic_auth_settings` and pa
expect(requestPayload.params).toEqual([{ page: 'update-email' }]);
});

test('Generate JSON RPC request payload with method `magic_auth_settings` and page params `mfa`', async () => {
test('Generate JSON RPC request payload with method `magic_auth_settings` and page params `mfa`', () => {
const magic = createMagicSDK();
magic.user.request = jest.fn();

magic.user.showSettings({ page: DeepLinkPage.MFA, showUI: false });

const requestPayload = magic.user.request.mock.calls[0][0];
expect(requestPayload.method).toBe('magic_auth_settings');
expect(requestPayload.params).toEqual([{ page: 'mfa', showUI: false }]);
});

test('Generate JSON RPC request payload with method `magic_auth_settings` and page params `recovery`', () => {
const magic = createMagicSDK();
magic.user.request = jest.fn();

magic.user.showSettings({ page: DeepLinkPage.Recovery, showUI: false });

const requestPayload = magic.user.request.mock.calls[0][0];
expect(requestPayload.method).toBe('magic_auth_settings');
expect(requestPayload.params).toEqual([{ page: 'recovery', showUI: false }]);
});

test('Generate JSON RPC request payload with `showUI` undefined', () => {
const magic = createMagicSDK();
magic.user.request = jest.fn();

magic.user.showSettings({ page: DeepLinkPage.MFA });

const requestPayload = magic.user.request.mock.calls[0][0];
expect(requestPayload.params).toEqual([{ page: 'mfa', showUI: undefined }]);
});

test('Generate JSON RPC request payload with method `magic_auth_settings` and page params `recovery` and showUI true', () => {
const magic = createMagicSDK();
magic.user.request = jest.fn();

magic.user.showSettings({ page: DeepLinkPage.Recovery, showUI: true });

const requestPayload = magic.user.request.mock.calls[0][0];
expect(requestPayload.method).toBe('magic_auth_settings');
expect(requestPayload.params).toEqual([{ page: 'mfa' }]);
expect(requestPayload.params).toEqual([{ page: 'recovery', showUI: true }]);
});

test('Generate JSON RPC request payload with method `magic_auth_settings` and page params `recovery`', async () => {
test('Generate JSON RPC request payload without `page` parameter', () => {
const magic = createMagicSDK();
magic.user.request = jest.fn();

magic.user.showSettings({ page: DeepLinkPage.Recovery });
magic.user.showSettings();

const requestPayload = magic.user.request.mock.calls[0][0];
expect(requestPayload.method).toBe('magic_auth_settings');
expect(requestPayload.params).toEqual([{ page: 'recovery' }]);
expect(requestPayload.params).toEqual([undefined]);
});

test('ShowSettings should attach event listeners for recovery factor events', () => {
const magic = createMagicSDK();

const mockOn = jest.fn();
const mockHandle = {
on: mockOn,
};

magic.user.request = jest.fn().mockReturnValue(mockHandle);

const config = { page: DeepLinkPage.Recovery, showUI: false };
magic.user.showSettings(config);

expect(mockOn).toHaveBeenCalledWith('send-new-phone-number', expect.any(Function));
expect(mockOn).toHaveBeenCalledWith('send-otp-code', expect.any(Function));

const phoneNumber = '123-456-7890';
const otp = '123456';

const createIntermediaryEventFn = jest.fn();
magic.user.createIntermediaryEvent = jest.fn(() => createIntermediaryEventFn);

mockOn.mock.calls[0][1](phoneNumber);
mockOn.mock.calls[1][1](otp);

expect(createIntermediaryEventFn).toHaveBeenCalledWith(phoneNumber);
expect(createIntermediaryEventFn).toHaveBeenCalledWith(otp);
});
test('ShowSettings should call createIntermediaryEvent with StartEditPhoneNumber', () => {
const magic = createMagicSDK();
const mockOn = jest.fn();
const mockHandle = { on: mockOn };

magic.user.request = jest.fn().mockReturnValue(mockHandle);

const config = { page: DeepLinkPage.Recovery, showUI: false };
magic.user.showSettings(config);

const createIntermediaryEventFn = jest.fn();
magic.user.createIntermediaryEvent = jest.fn(() => createIntermediaryEventFn);

const startEditPhoneNumberListener = mockOn.mock.calls.find((call) => call[0] === 'start-edit-phone-number')[1];

startEditPhoneNumberListener();

expect(createIntermediaryEventFn).toHaveBeenCalled();
});

test('ShowSettings should attach event listener for StartEditPhoneNumber event', () => {
const magic = createMagicSDK();
const mockOn = jest.fn();
const mockHandle = { on: mockOn };

magic.user.request = jest.fn().mockReturnValue(mockHandle);

const config = { page: DeepLinkPage.Recovery, showUI: false };
magic.user.showSettings(config);

expect(mockOn).toHaveBeenCalledWith('Recency/auth-factor-verify-email-otp', expect.any(Function));

expect(mockOn).toHaveBeenCalledWith('start-edit-phone-number', expect.any(Function));
});
test('ShowSettings should not call createIntermediaryEvent if StartEditPhoneNumber event is not triggered', () => {
const magic = createMagicSDK();
const mockOn = jest.fn();
const mockHandle = { on: mockOn };

magic.user.request = jest.fn().mockReturnValue(mockHandle);

const config = { page: DeepLinkPage.Recovery, showUI: false };
magic.user.showSettings(config);

const createIntermediaryEventFn = jest.fn();
magic.user.createIntermediaryEvent = jest.fn(() => createIntermediaryEventFn);

expect(magic.user.createIntermediaryEvent).not.toHaveBeenCalled();
expect(createIntermediaryEventFn).not.toHaveBeenCalled();
});
test('ShowSettings should handle missing config gracefully', () => {
const magic = createMagicSDK();
const mockOn = jest.fn();
const mockHandle = { on: mockOn };

magic.user.request = jest.fn().mockReturnValue(mockHandle);

magic.user.showSettings(undefined);

expect(mockOn).not.toHaveBeenCalled();
});

test('Generate JSON RPC request payload with invalid page param', () => {
const magic = createMagicSDK();
magic.user.request = jest.fn();

magic.user.showSettings({ page: 'invalid-page' });

const requestPayload = magic.user.request.mock.calls[0][0];
expect(requestPayload.params).toEqual([{ page: 'invalid-page' }]);
});
test('method should not return a PromiEvent if called with invalid options', () => {
const magic = createMagicSDK();
const result = magic.user.showSettings({ invalidOption: true });
expect(isPromiEvent(result)).toBeTruthy();
});
test('ShowSettings should not attach event listeners when `showUI` is true', () => {
const magic = createMagicSDK();
const mockHandle = { on: jest.fn() };
magic.user.request = jest.fn().mockReturnValue(mockHandle);

const config = { page: DeepLinkPage.Recovery, showUI: true };
magic.user.showSettings(config);

expect(mockHandle.on).not.toHaveBeenCalled();
});
test('ShowSettings should handle no configuration passed gracefully', () => {
const magic = createMagicSDK();
magic.user.request = jest.fn();

magic.user.showSettings();

const requestPayload = magic.user.request.mock.calls[0][0];
expect(requestPayload.method).toBe('magic_auth_settings');
expect(requestPayload.params).toEqual([undefined]);
});
test('ShowSettings should return a PromiEvent for valid config', () => {
const magic = createMagicSDK();
const result = magic.user.showSettings({ page: DeepLinkPage.Recovery });

expect(isPromiEvent(result)).toBeTruthy();
});

test('ShowSettings should not return a PromiEvent for invalid config', () => {
const magic = createMagicSDK();
const result = magic.user.showSettings({ invalidOption: true });

expect(isPromiEvent(result)).toBeTruthy();
});

test('ShowSettings called multiple times should maintain state correctly', () => {
const magic = createMagicSDK();
const createIntermediaryEventFn = jest.fn();
magic.user.createIntermediaryEvent = jest.fn(() => createIntermediaryEventFn);

const handle1 = magic.user.showSettings({ page: DeepLinkPage.Recovery, showUI: false });
handle1.emit('send-otp-code', '123456');

const handle2 = magic.user.showSettings({ page: DeepLinkPage.MFA, showUI: false });
handle2.emit('send-new-phone-number', '987654');

expect(createIntermediaryEventFn).toHaveBeenCalledWith('123456');
expect(createIntermediaryEventFn).toHaveBeenCalledWith('987654');
});
test('ShowSettings should handle request failure gracefully', () => {
const magic = createMagicSDK();
magic.user.request = jest.fn(() => {
throw new Error('Request failed');
});

try {
magic.user.showSettings();
} catch (error) {
expect(error.message).toBe('Request failed');
}
});

test('ShowSettings should attach event listeners for VerifyEmailOtp event', () => {
const magic = createMagicSDK();
const mockOn = jest.fn();
const mockHandle = { on: mockOn };

magic.user.request = jest.fn().mockReturnValue(mockHandle);

const config = { page: DeepLinkPage.Recovery, showUI: false };
magic.user.showSettings(config);

expect(mockOn).toHaveBeenCalledWith('Recency/auth-factor-verify-email-otp', expect.any(Function));

const otp = '123456';
const createIntermediaryEventFn = jest.fn();
magic.user.createIntermediaryEvent = jest.fn(() => createIntermediaryEventFn);

const verifyEmailOtpListener = mockOn.mock.calls.find(
(call) => call[0] === 'Recency/auth-factor-verify-email-otp',
)[1];

verifyEmailOtpListener(otp);

expect(createIntermediaryEventFn).toHaveBeenCalledWith(otp);
});

test('ShowSettings should return a PromiEvent even for invalid config', () => {
const magic = createMagicSDK();
const result = magic.user.showSettings({ invalidOption: true });

expect(isPromiEvent(result)).toBeTruthy();
});
test('Generate JSON RPC request payload with undefined showUI', () => {
const magic = createMagicSDK();
magic.user.request = jest.fn();

magic.user.showSettings({ page: DeepLinkPage.MFA });

const requestPayload = magic.user.request.mock.calls[0][0];
expect(requestPayload.params).toEqual([{ page: 'mfa', showUI: undefined }]);
});
test('method should return a PromiEvent', () => {
const magic = createMagicSDK();
expect(isPromiEvent(magic.user.showSettings())).toBeTruthy();
Expand Down
2 changes: 1 addition & 1 deletion packages/@magic-sdk/types/src/modules/auth-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ type DeviceVerificationEventHandlers = {
* Update Email
*/

type RecencyCheckEventHandlers = {
export type RecencyCheckEventHandlers = {
[RecencyCheckEventOnReceived.PrimaryAuthFactorNeedsVerification]: () => void;
[RecencyCheckEventOnReceived.PrimaryAuthFactorVerified]: () => void;
[RecencyCheckEventOnReceived.InvalidEmailOtp]: () => void;
Expand Down
4 changes: 4 additions & 0 deletions packages/@magic-sdk/types/src/modules/intermediary-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { NftCheckoutIntermediaryEvents } from './nft-types';

import { WalletEventOnReceived } from './wallet-types';
import { UiEventsEmit } from './common-types';
import { RecoveryFactorEventEmit, RecoveryFactorEventOnReceived } from './user-types';

export type IntermediaryEvents =
// EmailOTP
Expand All @@ -45,6 +46,9 @@ export type IntermediaryEvents =
// Auth Events
| `${AuthEventOnReceived}`
| `${WalletEventOnReceived}`
// Show Settings Events
| `${RecoveryFactorEventOnReceived}`
| `${RecoveryFactorEventEmit}`
// Nft Checkout Events
| `${NftCheckoutIntermediaryEvents}`
// Farcaster Login Events
Expand Down
Loading

0 comments on commit 135def7

Please sign in to comment.