Skip to content

Commit

Permalink
Add UserModule#settings endpoint (#231)
Browse files Browse the repository at this point in the history
* Add 'UserModule#settings' endpoint

* Add support for user settings via PnP-based implementation

* Clear URL query upon settings callback

* Update 'magic.user.settings' method name to 'magic.user.showSettings'

* Add PnP-specific settings endpoint

* Update yarn.lock
  • Loading branch information
smithki committed Oct 22, 2021
1 parent 5dd1f82 commit af0a37d
Show file tree
Hide file tree
Showing 14 changed files with 226 additions and 89 deletions.
72 changes: 52 additions & 20 deletions packages/@magic-sdk/pnp/src/context/callback.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,24 @@
import { createMagicInstance, getScriptData } from '../utils';
import { getScriptData } from '../utils/script-data';
import { createMagicInstance } from '../utils/magic-instance';
import { dispatchReadyEvent } from '../utils/events';

export async function callback(): Promise<void> {
const { src, apiKey } = getScriptData();
// In this context, `loginURI` and `redirectURI` are the same.
// We simply need a location to redirect to upon callback failure.
const { src, apiKey, loginURI, redirectURI = window.location.origin } = getScriptData();

const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);

const isOAuthCallback = !!urlParams.get('state');
const isMagicLinkRedirectCallback = !isOAuthCallback && !!urlParams.get('magic_credential');

const magic = createMagicInstance(apiKey, src.origin);

function clearURLQuery() {
const urlWithoutQuery = window.location.origin + window.location.pathname;
window.history.replaceState(null, '', urlWithoutQuery);
}

function dispatchReadyEvent(data: any) {
const evt = new CustomEvent('@magic/ready', { detail: { magic, ...data } });
window.dispatchEvent(evt);
}

async function handleOAuthCallback() {
const res = await magic.oauth.getRedirectResult();
dispatchReadyEvent({
dispatchReadyEvent(magic, {
idToken: res.magic.idToken,
userMetadata: res.magic.userMetadata,
oauth: res.oauth,
Expand All @@ -33,7 +28,16 @@ export async function callback(): Promise<void> {
async function handleMagicLinkRedirectCallback() {
const idToken = await magic.auth.loginWithCredential();
const userMetadata = await magic.user.getMetadata();
dispatchReadyEvent({ idToken, userMetadata });
dispatchReadyEvent(magic, { idToken, userMetadata });
}

async function handleSettingsCallback() {
const idToken = await magic.user.getIdToken();
const prevUserMetadata = magic.pnp.decodeUserMetadata(urlParams.get('prev_user_metadata')) ?? undefined;
const currUserMetadata =
magic.pnp.decodeUserMetadata(urlParams.get('curr_user_metadata')) ?? (await magic.user.getMetadata());
clearURLQuery();
dispatchReadyEvent(magic, { idToken, userMetadata: currUserMetadata, prevUserMetadata });
}

/**
Expand All @@ -50,14 +54,42 @@ export async function callback(): Promise<void> {
const idToken = urlParams.get('didt') || (await magic.user.getIdToken());
clearURLQuery();
const userMetadata = await magic.user.getMetadata();
dispatchReadyEvent({ idToken: decodeURIComponent(idToken), userMetadata });
dispatchReadyEvent(magic, { idToken: decodeURIComponent(idToken), userMetadata });
}

const redirectToLoginURI = () => {
window.location.href = loginURI || redirectURI;
};

switch (getCallbackType(urlParams)) {
case 'oauth':
return handleOAuthCallback().catch(redirectToLoginURI);

case 'magic_credential':
return handleMagicLinkRedirectCallback().catch(redirectToLoginURI);

case 'settings':
return handleSettingsCallback().catch(redirectToLoginURI);

default:
return handleGenericCallback().catch(redirectToLoginURI);
}
}

type CallbackType = 'oauth' | 'magic_credential' | 'settings';

function getCallbackType(urlParams: URLSearchParams): CallbackType | null {
if (urlParams.get('state')) {
return 'oauth';
}

if (isOAuthCallback) {
await handleOAuthCallback().catch(() => {});
} else if (isMagicLinkRedirectCallback) {
await handleMagicLinkRedirectCallback().catch(() => {});
} else {
await handleGenericCallback().catch(() => {});
if (urlParams.get('magic_credential')) {
return 'magic_credential';
}

if (urlParams.get('prev_user_metadata')) {
return 'settings';
}

return null;
}
3 changes: 2 additions & 1 deletion packages/@magic-sdk/pnp/src/context/login.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createMagicInstance, getScriptData } from '../utils';
import { getScriptData } from '../utils/script-data';
import { createMagicInstance } from '../utils/magic-instance';

export async function login(): Promise<void> {
const { src, apiKey, redirectURI = `${window.location.origin}/callback`, debug } = getScriptData();
Expand Down
9 changes: 6 additions & 3 deletions packages/@magic-sdk/pnp/src/context/logout.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { createMagicInstance, getScriptData } from '../utils';
import { getScriptData } from '../utils/script-data';
import { createMagicInstance } from '../utils/magic-instance';

export async function logout(): Promise<void> {
const { src, apiKey, redirectURI = window.location.origin } = getScriptData();
// In this context, `loginURI` and `redirectURI` are the same.
// We simply need a location to redirect to after attempting to logout.
const { src, apiKey, loginURI, redirectURI = window.location.origin } = getScriptData();
const magic = createMagicInstance(apiKey, src.origin);
await magic.user.logout().catch(() => {});
window.location.href = redirectURI;
window.location.href = loginURI || redirectURI;
}
24 changes: 24 additions & 0 deletions packages/@magic-sdk/pnp/src/context/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { getScriptData } from '../utils/script-data';
import { createMagicInstance } from '../utils/magic-instance';

export async function settings(): Promise<void> {
// In this context, `loginURI` and `redirectURI` have distinct purposes.
// `loginURI` is the location we redirect to when calling into settings fails.
// `redirectURI` is the location we redirect to after the user has dismissed the settings page.
const {
src,
apiKey,
redirectURI = `${window.location.origin}/callback`,
loginURI = window.location.origin,
} = getScriptData();

const magic = createMagicInstance(apiKey, src.origin);

try {
const prevUserMetadata = magic.pnp.encodeUserMetadata(await magic.user.getMetadata());
const currUserMetadata = magic.pnp.encodeUserMetadata(await magic.pnp.showSettings());
window.location.href = `${redirectURI}?prev_user_metadata=${prevUserMetadata}&curr_user_metadata=${currUserMetadata}`;
} catch {
window.location.href = loginURI;
}
}
8 changes: 7 additions & 1 deletion packages/@magic-sdk/pnp/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
/* eslint-disable no-useless-return */
/* eslint-disable consistent-return */

import { getScriptData } from './utils';
import { getScriptData } from './utils/script-data';

// PnP contexts
import { login } from './context/login';
import { logout } from './context/logout';
import { callback } from './context/callback';
import { settings } from './context/settings';

function main() {
const { src } = getScriptData();
Expand All @@ -19,6 +22,9 @@ function main() {
case '/pnp/callback':
return callback();

case '/pnp/settings':
return settings();

default:
return;
}
Expand Down
23 changes: 19 additions & 4 deletions packages/@magic-sdk/pnp/src/pnp-extension.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type { MagicUserMetadata } from '@magic-sdk/types';

export class PlugNPlayExtension extends window.Magic.Extension.Internal<'pnp', { isPnP: boolean }> {
config = { isPnP: true };
name = 'pnp' as const;

static storageKeys = {
public static storageKeys = {
lastUsedProvider: 'pnp/lastUsedProvider',
};

getLoginMethod(debug?: boolean) {
return this.utils.createPromiEvent<[string, string | undefined], { yolo: () => void }>(async (resolve) => {
public getLoginMethod(debug?: boolean) {
return this.utils.createPromiEvent<[string, string | undefined]>(async (resolve) => {
const lastUsedProvider = await this.utils.storage.getItem<string | undefined>(
PlugNPlayExtension.storageKeys.lastUsedProvider,
);
Expand All @@ -16,9 +18,22 @@ export class PlugNPlayExtension extends window.Magic.Extension.Internal<'pnp', {
});
}

async saveLastUsedProvider(provider?: string) {
public showSettings() {
return this.request(this.utils.createJsonRpcRequestPayload('pnp/settings'));
}

public async saveLastUsedProvider(provider?: string) {
if (provider) {
await this.utils.storage.setItem(PlugNPlayExtension.storageKeys.lastUsedProvider, provider);
}
}

public encodeUserMetadata(userMetadata: MagicUserMetadata) {
return this.utils.encodeJSON(userMetadata);
}

public decodeUserMetadata(userMetadataQueryString?: string | null): MagicUserMetadata | null {
if (!userMetadataQueryString) return null;
return this.utils.decodeJSON(userMetadataQueryString);
}
}
38 changes: 0 additions & 38 deletions packages/@magic-sdk/pnp/src/utils.ts

This file was deleted.

6 changes: 6 additions & 0 deletions packages/@magic-sdk/pnp/src/utils/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { PNPMagicInstance } from './magic-instance';

export function dispatchReadyEvent(magic: PNPMagicInstance, data: any = {}) {
const evt = new CustomEvent('@magic/ready', { detail: { magic, ...data } });
window.dispatchEvent(evt);
}
20 changes: 20 additions & 0 deletions packages/@magic-sdk/pnp/src/utils/magic-instance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { PlugNPlayExtension } from '../pnp-extension';
import type { Magic, OAuthExtension } from '../types';

export function createMagicInstance(apiKey?: string, endpoint?: string): Magic<[PlugNPlayExtension, OAuthExtension]> {
const extensions = removeFalsey([
new PlugNPlayExtension(),
window.MagicOAuthExtension && new window.MagicOAuthExtension(),
]);

return new window.Magic(apiKey!, {
endpoint,
extensions,
});
}

export type PNPMagicInstance = ReturnType<typeof createMagicInstance>;

function removeFalsey<T>(arr: T[]): Array<NonNullable<T>> {
return arr.filter(Boolean) as unknown as Array<NonNullable<T>>;
}
24 changes: 24 additions & 0 deletions packages/@magic-sdk/pnp/src/utils/script-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const allPossiblePNPScripts = document.querySelectorAll('script[data-magic-publishable-api-key]');
const thisScript = (document.currentScript ??
allPossiblePNPScripts[allPossiblePNPScripts.length - 1]) as HTMLScriptElement;

export function getScriptData() {
const src = new URL(thisScript.getAttribute('src')!);
const apiKey = thisScript.dataset.magicPublishableApiKey;
const debug = !!thisScript.dataset.debug;
const redirectURI = getAbsoluteURL(thisScript.dataset.redirectUri);
const loginURI = getAbsoluteURL(thisScript.dataset.loginUri);

return { script: thisScript, src, apiKey, redirectURI, loginURI, debug };
}

export type ScriptData = ReturnType<typeof getScriptData>;

/**
* Resolve `relativeURL` to an absolute path against current origin by
* (naively) checking if `relativeURL` starts with "/".
*/
function getAbsoluteURL(relativeURL?: string) {
if (relativeURL?.startsWith('/')) return `${window.location.origin}${relativeURL}`;
return relativeURL;
}
8 changes: 8 additions & 0 deletions packages/@magic-sdk/provider/src/modules/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,12 @@ export class UserModule extends BaseModule {
);
return this.request<boolean>(requestPayload);
}

/** */
public showSettings() {
const requestPayload = createJsonRpcRequestPayload(
this.sdk.testMode ? MagicPayloadMethod.UserSettingsTestMode : MagicPayloadMethod.UserSettings,
);
return this.request<MagicUserMetadata>(requestPayload);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import browserEnv from '@ikscodes/browser-env';
import { createMagicSDK, createMagicSDKTestMode } from '../../../factories';
import { isPromiEvent } from '../../../../src/util';

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

test('Generate JSON RPC request payload with method `magic_auth_settings`', async () => {
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([]);
});

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

magic.user.showSettings();

const requestPayload = magic.user.request.mock.calls[0][0];
expect(requestPayload.method).toBe('magic_auth_settings_testing_mode');
expect(requestPayload.params).toEqual([]);
});

test('method should return a PromiEvent', () => {
const magic = createMagicSDK();
expect(isPromiEvent(magic.user.showSettings())).toBeTruthy();
});
2 changes: 2 additions & 0 deletions packages/@magic-sdk/types/src/core/json-rpc-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export enum MagicPayloadMethod {
IsLoggedIn = 'magic_auth_is_logged_in',
Logout = 'magic_auth_logout',
UpdateEmail = 'magic_auth_update_email',
UserSettings = 'magic_auth_settings',
UserSettingsTestMode = 'magic_auth_settings_testing_mode',
LoginWithSmsTestMode = 'magic_auth_login_with_sms_testing_mode',
LoginWithMagicLinkTestMode = 'magic_login_with_magic_link_testing_mode',
LoginWithCredentialTestMode = 'magic_auth_login_with_credential_testing_mode',
Expand Down

0 comments on commit af0a37d

Please sign in to comment.