Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[commerce-sdk-react] Multisite support #769

Merged
merged 14 commits into from
Oct 20, 2022
16 changes: 16 additions & 0 deletions packages/commerce-sdk-react/src/auth/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ jest.mock('./storage', () => {
},
get(key: string) {
return map.get(key)
},
delete(key: string) {
map.delete(key)
}
}
})
Expand Down Expand Up @@ -149,6 +152,19 @@ describe('Auth', () => {
// @ts-expect-error private method
expect(() => auth.isTokenExpired()).toThrow()
})
test('site switch clears auth storage', () => {
const auth = new Auth(config)
// @ts-expect-error private method
auth.set('access_token', '123')
// @ts-expect-error private method
auth.set('refresh_token_guest', '456')
const switchSiteConfig = {...config, siteId: 'another site'}
const newAuth = new Auth(switchSiteConfig)
// @ts-expect-error private method
expect(newAuth.get('access_token')).not.toBe('123')
// @ts-expect-error private method
expect(newAuth.get('refresh_token_guest')).not.toBe('456')
})
test('isTokenExpired', () => {
const auth = new Auth(config)
const JWTNotExpired = jwt.sign({exp: Math.floor(Date.now() / 1000) + 1000}, 'secret')
Expand Down
61 changes: 42 additions & 19 deletions packages/commerce-sdk-react/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type AuthDataKeys =
| 'refresh_token_registered'
| 'token_type'
| 'usid'
| 'site_id'
type AuthDataMap = Record<
AuthDataKeys,
{
Expand All @@ -58,50 +59,54 @@ const cookieStorage = onClient ? new CookieStorage() : new Map()
const DATA_MAP: AuthDataMap = {
access_token: {
storage: localStorage,
key: 'access_token'
key: 'access_token',
},
customer_id: {
storage: localStorage,
key: 'customer_id'
key: 'customer_id',
},
usid: {
storage: localStorage,
key: 'usid'
key: 'usid',
},
enc_user_id: {
storage: localStorage,
key: 'enc_user_id'
key: 'enc_user_id',
},
expires_in: {
storage: localStorage,
key: 'expires_in'
key: 'expires_in',
},
id_token: {
storage: localStorage,
key: 'id_token'
key: 'id_token',
},
idp_access_token: {
storage: localStorage,
key: 'idp_access_token'
key: 'idp_access_token',
},
token_type: {
storage: localStorage,
key: 'token_type'
key: 'token_type',
},
refresh_token_guest: {
storage: cookieStorage,
key: 'cc-nx-g',
callback: () => {
cookieStorage.delete('cc-nx')
}
},
},
refresh_token_registered: {
storage: cookieStorage,
key: 'cc-nx',
callback: () => {
cookieStorage.delete('cc-nx-g')
}
}
},
},
site_id: {
storage: cookieStorage,
key: 'cc-site-id',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ticket mentions about namespacing the token. Do we still want to do that?

Without namespacing it, I guess the consequence would be that when you switch back to a previously-visited site, you would lose your basket progress?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this cookie is used as the "namespacer". It's not realistic to store tokens for every single site as we are bound by the size limitation of Cookie/Localstorage. A customer could have 100+ sites and we can't keep them all.

You are right that customer will lose the basket progress unfortunately.

},
}

/**
Expand All @@ -125,12 +130,23 @@ class Auth {
clientId: config.clientId,
organizationId: config.organizationId,
shortCode: config.shortCode,
siteId: config.siteId
siteId: config.siteId,
},
throwOnBadResponse: true,
fetchOptions: config.fetchOptions
fetchOptions: config.fetchOptions,
})

if (this.get('site_id') && this.get('site_id') !== config.siteId) {
// if site is switched, remove all existing auth data in storage
// and the next auth.ready() call with restart the auth flow
this.clearStorage()
this.pendingToken = undefined
}

if (!this.get('site_id')) {
this.set('site_id', config.siteId)
}

this.redirectURI = config.redirectURI
}

Expand All @@ -146,6 +162,13 @@ class Auth {
DATA_MAP[name].callback?.()
}

private clearStorage() {
Object.keys(DATA_MAP).forEach((key) => {
type Key = keyof AuthDataMap
DATA_MAP[key as Key].storage.delete(DATA_MAP[key as Key].key)
})
}

/**
* Every method in this class that returns a `TokenResponse` constructs it via this getter.
*/
Expand All @@ -159,7 +182,7 @@ class Auth {
idp_access_token: this.get('idp_access_token'),
refresh_token: this.get('refresh_token_registered') || this.get('refresh_token_guest'),
token_type: this.get('token_type'),
usid: this.get('usid')
usid: this.get('usid'),
}
}

Expand Down Expand Up @@ -189,7 +212,7 @@ class Auth {

const refreshTokenKey = isGuest ? 'refresh_token_guest' : 'refresh_token_registered'
this.set(refreshTokenKey, res.refresh_token, {
expires: this.REFRESH_TOKEN_EXPIRATION_DAYS
expires: this.REFRESH_TOKEN_EXPIRATION_DAYS,
})
}

Expand Down Expand Up @@ -270,7 +293,7 @@ class Auth {
() =>
helpers.loginGuestUser(this.client, {
redirectURI,
...(usid && {usid})
...(usid && {usid}),
}),
isGuest
)
Expand All @@ -288,7 +311,7 @@ class Auth {
() =>
helpers.loginRegisteredUserB2C(this.client, credentials, {
redirectURI,
...(usid && {usid})
...(usid && {usid}),
}),
isGuest
)
Expand All @@ -303,7 +326,7 @@ class Auth {
return this.queueRequest(
() =>
helpers.loginGuestUser(this.client, {
redirectURI: this.redirectURI
redirectURI: this.redirectURI,
}),
isGuest
)
Expand All @@ -328,7 +351,7 @@ export const injectAccessToken = (
const _headers = headers
? {
...headers,
Authorization: `Bearer ${accessToken}`
Authorization: `Bearer ${accessToken}`,
}
: {Authorization: `Bearer ${accessToken}`}
return _headers
Expand Down
12 changes: 12 additions & 0 deletions packages/commerce-sdk-react/src/hooks/ShopperProducts/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/
import {ApiClients, Argument, DataType} from '../types'
import {useQuery} from '../useQuery'
import useConfig from '../useConfig'
import {UseQueryOptions, UseQueryResult} from '@tanstack/react-query'

type Client = ApiClients['shopperProducts']
Expand Down Expand Up @@ -33,6 +34,9 @@ function useProducts(
options?: UseQueryOptions<DataType<Client['getProducts']> | Response, Error>
): UseQueryResult<DataType<Client['getProducts']> | Response, Error> {
const {headers, rawResponse, ...parameters} = arg
const {locale, currency} = useConfig()
parameters.locale = parameters.locale || locale
parameters.currency = parameters.currency || currency
Comment on lines +37 to +39
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see.. we haven't been using and passing in the locale until now 👍

This change is worthy to share with everyone in the team. For every hooks we're working on, we'll need to make sure to pass in the locale, if it's supported for that particular API endpoint.

return useQuery(
['products', arg],
(_, {shopperProducts}) => {
Expand Down Expand Up @@ -65,9 +69,13 @@ function useProduct(
options?: UseQueryOptions<DataType<Client['getProduct']> | Response, Error>
): UseQueryResult<DataType<Client['getProduct']> | Response, Error> {
const {headers, rawResponse, ...parameters} = arg
const {locale, currency} = useConfig()
parameters.locale = parameters.locale || locale
parameters.currency = parameters.currency || currency
return useQuery(
['product', arg],
(_, {shopperProducts}) => {
console.log('getproduct')
return shopperProducts.getProduct({parameters, headers}, rawResponse)
},
options
Expand Down Expand Up @@ -100,6 +108,8 @@ function useCategories(
options?: UseQueryOptions<DataType<Client['getCategories']> | Response, Error>
): UseQueryResult<DataType<Client['getCategories']> | Response, Error> {
const {headers, rawResponse, ...parameters} = arg
const {locale} = useConfig()
parameters.locale = parameters.locale || locale
return useQuery(
['categories', arg],
(_, {shopperProducts}) => {
Expand Down Expand Up @@ -137,6 +147,8 @@ function useCategory(
options?: UseQueryOptions<DataType<Client['getCategory']> | Response, Error>
): UseQueryResult<DataType<Client['getCategory']> | Response, Error> {
const {headers, rawResponse, ...parameters} = arg
const {locale} = useConfig()
parameters.locale = parameters.locale || locale
return useQuery(
['category', arg],
(_, {shopperProducts}) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
QueryFunctionContext,
QueryKey,
QueryFunction,
MutationFunction
MutationFunction,
} from '@tanstack/react-query'
import useAuth from './useAuth'
import useCommerceApi from './useCommerceApi'
Expand Down Expand Up @@ -43,7 +43,7 @@ function useAuthenticatedClient<TData, TVariables = unknown>(
apiClientKeys.forEach((client) => {
apiClients[client].clientConfig.headers = {
...apiClients[client].clientConfig.headers,
Authorization: `Bearer ${access_token}`
Authorization: `Bearer ${access_token}`,
}
})
return apiClients
Expand Down
18 changes: 18 additions & 0 deletions packages/commerce-sdk-react/src/hooks/useConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2022, salesforce.com, inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import React from 'react'
import {ConfigContext, CommerceApiProviderProps} from '../provider'

/**
* @Internal
*/
const useConfig = (): Omit<CommerceApiProviderProps, 'children'> => {
return React.useContext(ConfigContext)
}

export default useConfig
42 changes: 29 additions & 13 deletions packages/commerce-sdk-react/src/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import React, {Fragment, ReactElement, useEffect, useMemo} from 'react'
import React, {ReactElement, useEffect, useMemo} from 'react'
import {
ShopperBaskets,
ShopperContexts,
Expand All @@ -16,7 +16,7 @@ import {
ShopperDiscoverySearch,
ShopperGiftCertificates,
ShopperSearch,
ShopperBasketsTypes
ShopperBasketsTypes,
} from 'commerce-sdk-isomorphic'
import Auth from './auth'
import {ApiClientConfigParams, ApiClients} from './hooks/types'
Expand All @@ -37,6 +37,11 @@ export interface CommerceApiProviderProps extends ApiClientConfigParams {
*/
export const CommerceApiContext = React.createContext({} as ApiClients)

/**
* @internal
*/
export const ConfigContext = React.createContext({} as Omit<CommerceApiProviderProps, 'children'>)

/**
* @internal
*/
Expand All @@ -58,7 +63,9 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
redirectURI,
fetchOptions,
siteId,
shortCode
shortCode,
locale,
currency,
} = props

const config = {
Expand All @@ -68,12 +75,11 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
clientId,
organizationId,
shortCode,
siteId
siteId,
},
throwOnBadResponse: true,
fetchOptions
fetchOptions,
}

const apiClients = useMemo(() => {
return {
shopperBaskets: new ShopperBaskets(config),
Expand All @@ -85,7 +91,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
shopperOrders: new ShopperOrders(config),
shopperProducts: new ShopperProducts(config),
shopperPromotions: new ShopperPromotions(config),
shopperSearch: new ShopperSearch(config)
shopperSearch: new ShopperSearch(config),
}
}, [clientId, organizationId, shortCode, siteId, proxy, fetchOptions])

Expand All @@ -97,24 +103,34 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
siteId,
proxy,
redirectURI,
fetchOptions
fetchOptions,
})
}, [clientId, organizationId, shortCode, siteId, proxy, redirectURI, fetchOptions])

useEffect(() => {
auth.ready()
}, [auth])

// TODO: wrap the children with:
// - context for enabling useServerEffect hook
// - context for sharing the auth object that would manage the tokens -> this will probably be for internal use only
return (
<Fragment>
<ConfigContext.Provider
value={{
clientId,
headers,
organizationId,
proxy,
redirectURI,
fetchOptions,
siteId,
shortCode,
locale,
currency,
}}
>
<CommerceApiContext.Provider value={apiClients}>
<AuthContext.Provider value={auth}>{children}</AuthContext.Provider>
</CommerceApiContext.Provider>
<ReactQueryDevtools />
</Fragment>
</ConfigContext.Provider>
)
}

Expand Down
Loading