Skip to content

Commit 36cf5f4

Browse files
committed
feat: add session-sync endpoint
1 parent 4cd7ffb commit 36cf5f4

File tree

14 files changed

+529
-36
lines changed

14 files changed

+529
-36
lines changed

.changeset/lazy-plants-listen.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@bigcommerce/catalyst-core": minor
3+
---
4+
5+
Adds a session sync endpoint to help sync customer sessions between hosts. The primary use-case is syncing the session between redirected checkout and Catalyst.

core/.eslintrc.cjs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
// @ts-check
22

3-
// eslint-disable-next-line import/no-extraneous-dependencies
4-
require('@bigcommerce/eslint-config/patch');
5-
63
/** @type {import('eslint').Linter.Config} */
74
const config = {
85
root: true,

core/app/[locale]/(default)/(auth)/login/page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ export default async function Login() {
4141
<ButtonLink href="/register" variant="secondary">
4242
{t('CreateAccount.createLink')}
4343
</ButtonLink>
44+
<ButtonLink className="ms-4" href="https://session-sync-example.vercel.app/">
45+
Go to checkout
46+
</ButtonLink>
4447
</div>
4548
</SignInSection>
4649
</>

core/app/[locale]/layout.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,13 @@ import { PropsWithChildren } from 'react';
1111
import '../globals.css';
1212

1313
import { fonts } from '~/app/fonts';
14+
import { CookieNotifications } from '~/app/notifications';
15+
import { Providers } from '~/app/providers';
1416
import { client } from '~/client';
1517
import { graphql } from '~/client/graphql';
1618
import { revalidate } from '~/client/revalidate-target';
1719
import { routing } from '~/i18n/routing';
18-
19-
import { getToastNotification } from '../../lib/server-toast';
20-
import { CookieNotifications } from '../notifications';
21-
import { Providers } from '../providers';
20+
import { getToastNotification } from '~/lib/server-toast';
2221

2322
const RootLayoutMetadataQuery = graphql(`
2423
query RootLayoutMetadataQuery {
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { revalidatePath } from 'next/cache';
2+
import { NextRequest, NextResponse } from 'next/server';
3+
4+
import { signIn, signOut } from '~/auth';
5+
import { client } from '~/client';
6+
import { graphql } from '~/client/graphql';
7+
import { clearCartId, setCartId } from '~/lib/cart';
8+
9+
const VerifySessionSyncJwtMutation = graphql(`
10+
mutation VerifySessionSyncJwt($jwt: String!) {
11+
validateSessionSyncJwt(jwt: $jwt) {
12+
content {
13+
redirectTo
14+
customer {
15+
entityId
16+
firstName
17+
lastName
18+
email
19+
}
20+
customerAccessToken {
21+
value
22+
expiresAt
23+
}
24+
cart {
25+
entityId
26+
}
27+
}
28+
errors {
29+
__typename
30+
... on InvalidSessionSyncJwtError {
31+
errorType
32+
message
33+
}
34+
... on JwtTokenExpiredError {
35+
message
36+
}
37+
}
38+
}
39+
}
40+
`);
41+
42+
export const GET = async (
43+
request: NextRequest,
44+
{ params }: { params: Promise<{ jwt: string }> },
45+
) => {
46+
const { jwt } = await params;
47+
const query = request.nextUrl.searchParams;
48+
49+
const { data, errors } = await client.fetch({
50+
document: VerifySessionSyncJwtMutation,
51+
variables: { jwt },
52+
fetchOptions: { cache: 'no-store' },
53+
});
54+
55+
// Handle any API Errors
56+
if (errors?.length) {
57+
return new NextResponse('', { status: 500, statusText: 'Server error' });
58+
}
59+
60+
const { content, errors: validationErrors } = data.validateSessionSyncJwt;
61+
62+
// Handle any validation errors or if the content is null
63+
if (validationErrors.length || content === null) {
64+
const error = validationErrors.at(0);
65+
66+
switch (error?.__typename) {
67+
case 'InvalidSessionSyncJwtError':
68+
return new NextResponse(null, { status: 400, statusText: 'Bad request' });
69+
70+
case 'JwtTokenExpiredError':
71+
return NextResponse.redirect(request.referrer);
72+
73+
default:
74+
return new NextResponse(null, { status: 500, statusText: 'Server error' });
75+
}
76+
}
77+
78+
// Update the cart id if it exists regardless of if the customer is logged in or not
79+
if (content.cart?.entityId) {
80+
await setCartId(content.cart.entityId);
81+
} else {
82+
await clearCartId();
83+
}
84+
85+
// Soon there will always be a session, whether guest or logged in.
86+
// We will need to remove this check once that work is complete. We can just
87+
// update the session in that case.
88+
if (content.customer && content.customerAccessToken) {
89+
await signIn('session-sync', {
90+
name: `${content.customer.firstName} ${content.customer.lastName}`,
91+
email: content.customer.email,
92+
customerAccessToken: content.customerAccessToken.value,
93+
redirect: false,
94+
});
95+
} else {
96+
await signOut({ redirect: false });
97+
}
98+
99+
// We want to revalidate all the data as we are either logged in or out.
100+
revalidatePath('/', 'layout');
101+
102+
if (query.get('redirect') === 'false') {
103+
return new NextResponse(null, {
104+
status: 204,
105+
headers: {
106+
'Access-Control-Allow-Origin': 'https://session-sync-example.vercel.app',
107+
'Access-Control-Allow-Credentials': 'true',
108+
},
109+
});
110+
}
111+
112+
// Note: redirectTo is going to include the full url, not a partial path
113+
return NextResponse.redirect(content.redirectTo);
114+
};
115+
116+
export const runtime = 'edge';
117+
export const dynamic = 'force-dynamic';

core/app/providers.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import { PropsWithChildren } from 'react';
44

55
import { Toaster } from '@/vibes/soul/primitives/toaster';
66
import { CartProvider } from '~/components/header/cart-provider';
7+
import { SessionSyncProvider } from '~/components/session-sync/provider';
78

89
export function Providers({ children }: PropsWithChildren) {
910
return (
1011
<>
1112
<Toaster position="top-right" />
13+
<SessionSyncProvider />
1214
<CartProvider>{children}</CartProvider>
1315
</>
1416
);

core/auth/index.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ const SessionUpdate = z.object({
7777
}),
7878
});
7979

80+
const SessionSyncCredentials = z.object({
81+
name: z.string(),
82+
email: z.string().email(),
83+
customerAccessToken: z.string(),
84+
});
85+
8086
async function handleLoginCart(guestCartId?: string, loginResultCartId?: string) {
8187
const t = await getTranslations('Cart');
8288

@@ -168,6 +174,10 @@ function loginWithAnonymous(credentials: unknown): User | null {
168174
};
169175
}
170176

177+
function loginWithSessionSync(credentials: unknown): User {
178+
return SessionSyncCredentials.parse(credentials);
179+
}
180+
171181
const config = {
172182
// Explicitly setting this value to be undefined. We want the library to handle CSRF checks when taking sensitive actions.
173183
// When handling sensitive actions like sign in, sign out, etc., the library will automatically check for CSRF tokens.
@@ -181,6 +191,19 @@ const config = {
181191
},
182192
pages: {
183193
signIn: '/login',
194+
signOut: '/logout',
195+
},
196+
cookies: {
197+
callbackUrl: {
198+
options: {
199+
sameSite: 'none',
200+
},
201+
},
202+
sessionToken: {
203+
options: {
204+
sameSite: 'none',
205+
},
206+
},
184207
},
185208
callbacks: {
186209
jwt: ({ token, user, session, trigger }) => {
@@ -276,6 +299,13 @@ const config = {
276299
},
277300
authorize: loginWithJwt,
278301
}),
302+
CredentialsProvider({
303+
id: 'session-sync',
304+
credentials: {
305+
jwt: { label: 'JWT', type: 'text' },
306+
},
307+
authorize: loginWithSessionSync,
308+
}),
279309
],
280310
} satisfies NextAuthConfig;
281311

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
'use client';
2+
3+
import { useCallback, useEffect } from 'react';
4+
5+
export const SessionSyncProvider = () => {
6+
const handleBrowserBackButtonEvent = useCallback((event: PageTransitionEvent) => {
7+
if (event.persisted) {
8+
window.location.reload();
9+
}
10+
}, []);
11+
12+
useEffect(() => {
13+
window.addEventListener('pageshow', handleBrowserBackButtonEvent);
14+
15+
return () => {
16+
window.removeEventListener('pageshow', handleBrowserBackButtonEvent);
17+
};
18+
}, [handleBrowserBackButtonEvent]);
19+
20+
return null;
21+
};

core/middlewares/with-auth.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ export const withAuth: MiddlewareFactory = (next) => {
88
const authWithCallback = auth(async (req) => {
99
if (!req.auth) {
1010
await signIn('anonymous', { redirect: false });
11-
12-
return next(req, event);
1311
}
1412

1513
// Continue the middleware chain

core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@
9494
"dotenv": "^16.4.7",
9595
"dotenv-cli": "^8.0.0",
9696
"eslint": "^8.57.1",
97-
"eslint-config-next": "15.2.3",
97+
"eslint-config-next": "15.2.4",
9898
"postcss": "^8.5.3",
9999
"prettier": "^3.5.3",
100100
"prettier-plugin-tailwindcss": "^0.6.11",

packages/eslint-config-catalyst/base.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
require("@bigcommerce/eslint-config/patch");
2+
13
/** @type {import("eslint").Linter.Config} */
24
const config = {
35
extends: ['@bigcommerce/eslint-config/configs/base', 'prettier'],

packages/eslint-config-catalyst/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@
1010
],
1111
"dependencies": {
1212
"@bigcommerce/eslint-config": "^2.11.0",
13-
"@next/eslint-plugin-next": "^15.2.3",
13+
"@next/eslint-plugin-next": "^15.2.4",
1414
"eslint-config-prettier": "^10.1.1",
1515
"eslint-plugin-check-file": "^2.8.0",
16-
"eslint-plugin-prettier": "^5.2.4"
16+
"eslint-plugin-prettier": "^5.2.5"
1717
},
1818
"peerDependencies": {
1919
"eslint": "^8.0.0",

0 commit comments

Comments
 (0)