Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3,321 changes: 3,321 additions & 0 deletions cookbook/llms/multipass.prompt.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ import type {
export async function multipass(
options: MultipassOptions,
): Promise<void | MultipassResponse> {
// eslint-disable-next-line @typescript-eslint/naming-convention
const {redirect, return_to} = options;
const {redirect, return_to: returnTo} = options;

try {
// Generate multipass token POST `/account/login/multipass`
Expand All @@ -26,7 +25,7 @@ export async function multipass(
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({return_to}),
body: JSON.stringify({return_to: returnTo}),
});

if (!response.ok) {
Expand Down Expand Up @@ -54,8 +53,7 @@ export async function multipass(
window.location.href = data.url;
return data;
} catch (error) {
// @ts-expect-error - error may not have message property
// eslint-disable-next-line no-console
//@ts-expect-error error might not have message property
console.log('⚠️ Bypassing multipass checkout due to', error.message);

const message = error instanceof Error ? error.message : 'Unknown error';
Expand All @@ -67,8 +65,8 @@ export async function multipass(
};
}

if (return_to) {
window.location.href = return_to;
if (returnTo) {
window.location.href = returnTo;
}

return {url: null, token: null, error: message};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,33 @@
import snakecaseKeys from 'snakecase-keys';
import CryptoJS from 'crypto-js';
import type {MultipassCustomer} from './types';

// Simple snake_case converter for ESM/Worker runtime
function toSnakeCase(str: string): string {
return str
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '');
}

function snakecaseKeys(obj: any): any {
if (typeof obj !== 'object' || obj === null) {
return obj;
}

if (Array.isArray(obj)) {
return obj.map(snakecaseKeys);
}

const result: any = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const snakeKey = toSnakeCase(key);
result[snakeKey] = snakecaseKeys(obj[key]);
}
}
return result;
}

/*
Shopify multipassify implementation for node and v8/worker runtime
based on crypto-js. This library is used to generate and parse multipass tokens.
Expand Down Expand Up @@ -103,7 +129,7 @@ export class Multipassify {
}

// signs the encrypted customer data
private sign(encrypted: CryptoJS.lib.WordArray) {
private sign(encrypted: any) {
return CryptoJS.HmacSHA256(encrypted, this.signingKey);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,22 @@
import {
Form,
useActionData,
data,
redirect,
type ActionFunctionArgs,
type LoaderFunctionArgs,
type MetaFunction,
} from 'react-router';
import {Form, useActionData, data, redirect} from 'react-router';
import type {Route} from './+types/account_.activate.$id.$activationToken';

type ActionResponse = {
error: string | null;
};

export const meta: MetaFunction = () => {
export const meta: Route.MetaFunction = () => {
return [{title: 'Activate Account'}];
};

export async function loader({context}: LoaderFunctionArgs) {
export async function loader({context}: Route.LoaderArgs) {
if (await context.session.get('customerAccessToken')) {
return redirect('/account');
}
return {};
}

export async function action({request, context, params}: ActionFunctionArgs) {
export async function action({request, context, params}: Route.ActionArgs) {
const {session, storefront} = context;
const {id, activationToken} = params;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,37 +1,33 @@
import {
data as remixData,
redirect,
type ActionFunctionArgs,
type LoaderFunctionArgs,
type HeadersFunction,
} from 'react-router';
import {data as remixData, redirect} from 'react-router';
import type {Route} from './+types/account_.login.multipass';
import {Multipassify} from '~/lib/multipass/multipassify.server';
import type {
CustomerInfoType,
MultipassRequestBody,
NotLoggedInResponseType,
} from '~/lib/multipass/types';

export const headers: HeadersFunction = ({actionHeaders}) => actionHeaders;
export const headers: Route.HeadersFunction = ({actionHeaders}) =>
actionHeaders;

/*
Redirect document GET requests to the login page (housekeeping)
*/
export async function loader({params, context}: LoaderFunctionArgs) {
export async function loader({params, context}: Route.LoaderArgs) {
const customerAccessToken = context.session.get('customerAccessToken');

if (customerAccessToken) {
return redirect(params.lang ? `${params.lang}/account` : '/account');
return redirect('/account');
}
return redirect(params.lang ? `${params.lang}/account` : '/account/login');
return redirect('/account/login');
}

/*
Generates a multipass token for a given customer and return_to url.
Handles POST requests to `/account/login/multipass`
expects body: { return_to?: string, customer }
*/
export async function action({request, context}: ActionFunctionArgs) {
export async function action({request, context}: Route.ActionArgs) {
const {session, storefront, env} = context;
const origin = request.headers.get('Origin') || '';
const isOptionsReq = request.method === 'OPTIONS';
Expand Down Expand Up @@ -65,7 +61,7 @@ export async function action({request, context}: ActionFunctionArgs) {
if (!customerAccessToken) {
return handleLoggedOutResponse({
return_to: body?.return_to ?? null,
checkoutDomain: env.SHOPIFY_CHECKOUT_DOMAIN,
checkoutDomain: env.PUBLIC_CHECKOUT_DOMAIN,
});
}

Expand Down Expand Up @@ -101,6 +97,22 @@ export async function action({request, context}: ActionFunctionArgs) {
}

try {
// @description Check for multipass secret before using
if (!env.PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET) {
console.error('Multipass secret not configured');
return remixData(
{
error:
'Multipass is not configured. Please set PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET.',
},
{
status: 500,
},
);
}

console.log('Generating multipass token for customer:', customer?.email);

// generate a multipass url and token
const multipassify = new Multipassify(
env.PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET,
Expand Down Expand Up @@ -135,11 +147,19 @@ export async function action({request, context}: ActionFunctionArgs) {
},
);
} catch (error) {
// eslint-disable-next-line no-console
console.log(JSON.stringify(error, null, 2));
let message = 'unknown error';
if (error instanceof Error) {
message = error.message;
console.error(
'Multipass generation error:',
error.message,
error.stack,
);
} else {
message = JSON.stringify(error);
console.error('Multipass generation error (non-Error):', error);
}

return notLoggedInResponse({
Expand Down Expand Up @@ -193,21 +213,20 @@ async function handleLoggedOutResponse(options: {
return_to: string | null;
checkoutDomain: string | undefined;
}) {
// eslint-disable-next-line @typescript-eslint/naming-convention
const {return_to, checkoutDomain} = options;
const {return_to: returnTo, checkoutDomain} = options;
// Match checkout urls such as:
// https://checkout.example.com/cart/c/c1-dd274dd3e6dca2f6a6ea899e8fe9b90f?key=6900d0a8b227761f88cf2e523ae2e662
const isCheckoutReq = /[\w-]{32}\?key/g.test(return_to || '');
const isCheckoutReq = /[\w-]{32}\?key/g.test(returnTo || '');

if (!return_to || !isCheckoutReq) {
if (!returnTo || !isCheckoutReq) {
return notLoggedInResponse({
url: null,
error: 'NOT_AUTHORIZED',
});
}

// Force logging off the user in the checkout
const encodedCheckoutUrl = encodeURIComponent(return_to);
const encodedCheckoutUrl = encodeURIComponent(returnTo);

// For example, checkoutDomain `checkout.hydrogen.shop` or `shop.example.com` or `{shop}.myshopify.com`.
const logOutUrl = `https://${checkoutDomain}/account/logout?return_url=${encodedCheckoutUrl}&step=contact_information`;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
import {
Form,
Link,
useActionData,
data,
redirect,
type LoaderFunctionArgs,
type ActionFunctionArgs,
} from 'react-router';
import {Form, Link, useActionData, data, redirect} from 'react-router';
import type {Route} from './+types/account_.recover';

type ActionResponse = {
error?: string;
resetRequested?: boolean;
};

export async function loader({context}: LoaderFunctionArgs) {
export async function loader({context}: Route.LoaderArgs) {
const customerAccessToken = await context.session.get('customerAccessToken');
if (customerAccessToken) {
return redirect('/account');
Expand All @@ -22,7 +15,7 @@ export async function loader({context}: LoaderFunctionArgs) {
return {};
}

export async function action({request, context}: ActionFunctionArgs) {
export async function action({request, context}: Route.ActionArgs) {
const {storefront} = context;
const form = await request.formData();
const email = form.has('email') ? String(form.get('email')) : null;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
import {
Form,
Link,
useActionData,
data,
HeadersFunction,
redirect,
type ActionFunctionArgs,
type LoaderFunctionArgs,
} from 'react-router';
import {Form, Link, useActionData, data, redirect} from 'react-router';
import type {Route} from './+types/account_.register';
import type {CustomerCreateMutation} from 'storefrontapi.generated';

type ActionResponse = {
Expand All @@ -17,9 +9,10 @@ type ActionResponse = {
| null;
};

export const headers: HeadersFunction = ({actionHeaders}) => actionHeaders;
export const headers: Route.HeadersFunction = ({actionHeaders}) =>
actionHeaders;

export async function loader({context}: LoaderFunctionArgs) {
export async function loader({context}: Route.LoaderArgs) {
const customerAccessToken = await context.session.get('customerAccessToken');
if (customerAccessToken) {
return redirect('/account');
Expand All @@ -28,7 +21,7 @@ export async function loader({context}: LoaderFunctionArgs) {
return {};
}

export async function action({request, context}: ActionFunctionArgs) {
export async function action({request, context}: Route.ActionArgs) {
if (request.method !== 'POST') {
return data({error: 'Method not allowed'}, {status: 405});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,15 @@
import {
data,
Form,
redirect,
useActionData,
type ActionFunctionArgs,
type MetaFunction,
} from 'react-router';
import {data, Form, redirect, useActionData} from 'react-router';
import type {Route} from './+types/account_.reset.$id.$resetToken';

type ActionResponse = {
error: string | null;
};

export const meta: MetaFunction = () => {
export const meta: Route.MetaFunction = () => {
return [{title: 'Reset Password'}];
};

export async function action({request, context, params}: ActionFunctionArgs) {
export async function action({request, context, params}: Route.ActionArgs) {
if (request.method !== 'POST') {
return data({error: 'Method not allowed'}, {status: 405});
}
Expand Down
25 changes: 25 additions & 0 deletions cookbook/recipes/multipass/patches/CartSummary.tsx.eb8134.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
index 3eae48c76..3e3f5f56e 100644
--- a/templates/skeleton/app/components/CartSummary.tsx
+++ b/templates/skeleton/app/components/CartSummary.tsx
@@ -12,6 +12,8 @@ import {useFetcher} from 'react-router';
import type {FetcherWithComponents} from 'react-router';
import {CartWarnings} from '~/components/CartWarnings';
import {CartUserErrors} from '~/components/CartUserErrors';
+// @description Import MultipassCheckoutButton for Shopify Plus multipass checkout
+import {MultipassCheckoutButton} from '~/components/MultipassCheckoutButton';

type CartSummaryProps = {
cart: OptimisticCart<CartApiQueryFragment | null>;
@@ -58,9 +60,10 @@ function CartCheckoutActions({checkoutUrl}: {checkoutUrl?: string}) {

return (
<div>
- <a href={checkoutUrl} target="_self">
+ {/* @description Use MultipassCheckoutButton for Shopify Plus stores to persist customer session */}
+ <MultipassCheckoutButton checkoutUrl={checkoutUrl}>
<p>Continue to Checkout &rarr;</p>
- </a>
+ </MultipassCheckoutButton>
<br />
</div>
);
Loading
Loading