Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 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
2,210 changes: 592 additions & 1,618 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"jsdom": "^22.1.0",
"lerna": "^6.6.1",
"semver": "^7.5.2",
"shelljs": "^0.8.5",
"shelljs": "^0.9.2",
"syncpack": "^10.1.0"
},
"engines": {
Expand Down
5 changes: 5 additions & 0 deletions packages/commerce-sdk-react/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
## v3.3.0-extensibility-preview.4 (Feb 12, 2025)
- Add `ServerContext` type for `useServerContext` hook [#2239](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2239)

## v3.3.0-dev (Feb 18, 2025)
- Invalidate cache instead of removing cache when triggering logout [#2323](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2323)
- Fix dependencies vulnerabilities [#2338](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2338)
- Allow custom parameters/body to be passed to SLAS authorize/authenticate calls via commerce-sdk-react auth helpers [#2358](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2358)

## v3.2.1 (Mar 05, 2025)
- Update PWA-Kit SDKs to v3.9.1 [#2301](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2301)

Expand Down
1,331 changes: 584 additions & 747 deletions packages/commerce-sdk-react/package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/commerce-sdk-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"version": "node ./scripts/version.js"
},
"dependencies": {
"commerce-sdk-isomorphic": "^3.2.0",
"commerce-sdk-isomorphic": "^3.3.0",
"js-cookie": "^3.0.1",
"jwt-decode": "^4.0.0"
},
Expand Down Expand Up @@ -69,7 +69,7 @@
"react-helmet": "^6.1.0",
"react-router-dom": "^5.3.4",
"semver": "^7.5.2",
"shelljs": "^0.8.5",
"shelljs": "^0.9.2",
"typedoc": "^0.24.7",
"typedoc-plugin-missing-exports": "^2.0.0",
"typescript": "4.9.5"
Expand Down
112 changes: 95 additions & 17 deletions packages/commerce-sdk-react/src/auth/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
import Auth, {AuthData} from './'
import {waitFor} from '@testing-library/react'
import jwt from 'jsonwebtoken'
import {helpers, ShopperCustomersTypes, ShopperLogin} from 'commerce-sdk-isomorphic'
import {
helpers,
ShopperCustomersTypes,
ShopperCustomers,
ShopperLogin
} from 'commerce-sdk-isomorphic'
import * as utils from '../utils'
import {SLAS_SECRET_PLACEHOLDER} from '../constant'
import {ShopperLoginTypes} from 'commerce-sdk-isomorphic'
Expand Down Expand Up @@ -49,23 +54,23 @@ jest.mock('commerce-sdk-isomorphic', () => {
authorizeIDP: jest.fn().mockResolvedValue(''),
authorizePasswordless: jest.fn().mockResolvedValue(''),
getPasswordLessAccessToken: jest.fn().mockResolvedValue('')
},
ShopperCustomers: jest.fn().mockImplementation(() => {
return {
updateCustomerPassword: () => {}
}
})
}
}
})

jest.mock('../utils', () => ({
__esModule: true,
onClient: () => true,
getParentOrigin: jest.fn().mockResolvedValue(''),
isOriginTrusted: () => false,
getDefaultCookieAttributes: () => {},
isAbsoluteUrl: () => true
}))
jest.mock('../utils', () => {
const originalModule = jest.requireActual('../utils')

return {
...originalModule,
__esModule: true,
onClient: () => true,
getParentOrigin: jest.fn().mockResolvedValue(''),
isOriginTrusted: () => false,
getDefaultCookieAttributes: () => {},
isAbsoluteUrl: () => true
}
})

/** The auth data we store has a slightly different shape than what we use. */
type StoredAuthData = Omit<AuthData, 'refresh_token'> & {refresh_token_guest?: string}
Expand Down Expand Up @@ -491,6 +496,48 @@ describe('Auth', () => {
expect(helpers.loginGuestUser).toHaveBeenCalled()
})

test('loginGuestUser can pass along custom parameters', async () => {
const parameters = {c_test: 'custom parameter'}
const auth = new Auth(config)
await auth.loginGuestUser(parameters)
// The first argument is the SLAS config, which we don't need to verify in this case
// We only want to see that the custom parameters were included in the second argument
expect(helpers.loginGuestUser).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({c_test: 'custom parameter'})
)
})

test('register only sends custom parameters to registered login', async () => {
const registerCustomerSpy = jest
.spyOn(ShopperCustomers.prototype, 'registerCustomer')
.mockImplementation()
const auth = new Auth(config)
const inputToRegister = {
customer: baseCustomer,
password: 'test',
someOtherParameter: 'this should not be passed to login',
c_test: 'custom parameter'
}

await auth.register(inputToRegister)

// Body should only include credentials. No other parameters
expect(registerCustomerSpy).toHaveBeenCalledWith(
expect.objectContaining({body: {customer: baseCustomer, password: 'test'}})
)

// We don't need to verify the first and third parameters as they correspond to the SLAS client and mandatory parameters
// The second argument is credentials
// We want to see that only the custom parameters were included in the fourth argument and not any other parameters
expect(helpers.loginRegisteredUserB2C).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
{body: {c_test: 'custom parameter'}}
)
})

test.each([
// When user has not selected DNT pref
[true, undefined, {dnt: true}],
Expand Down Expand Up @@ -614,6 +661,28 @@ describe('Auth', () => {
})
})

test('loginRegisteredUserB2C can pass along custom parameters', async () => {
const options = {
body: {c_test: 'custom parameter'}
}
const credentials = {
username: 'test',
password: 'test'
}
const auth = new Auth(config)
await auth.loginRegisteredUserB2C({...credentials, options})
// We don't need to verify the first and third parameters as they correspond to the SLAS client and mandatory parameters
// The second argument is credentials, including the client secret
// The fourth argument is custom parameters
// We only want to see that the custom parameters were included in the fourth argument
expect(helpers.loginRegisteredUserB2C).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining(credentials),
expect.anything(),
options
)
})

test('loginIDPUser calls isomorphic loginIDPUser', async () => {
const auth = new Auth(config)
await auth.loginIDPUser({redirectURI: 'redirectURI', code: 'test'})
Expand All @@ -634,10 +703,18 @@ describe('Auth', () => {

test('authorizeIDP calls isomorphic authorizeIDP', async () => {
const auth = new Auth(config)
await auth.authorizeIDP({redirectURI: 'redirectURI', hint: 'test'})
await auth.authorizeIDP({
redirectURI: 'redirectURI',
hint: 'test',
c_customParam: 'customParam'
})
expect(helpers.authorizeIDP).toHaveBeenCalled()
const functionArg = (helpers.authorizeIDP as jest.Mock).mock.calls[0][1]
expect(functionArg).toMatchObject({redirectURI: 'redirectURI', hint: 'test'})
expect(functionArg).toMatchObject({
redirectURI: 'redirectURI',
hint: 'test',
c_customParam: 'customParam'
})
})

test('authorizeIDP adds clientSecret to parameters when using private client', async () => {
Expand Down Expand Up @@ -698,6 +775,7 @@ describe('Auth', () => {
expect(helpers.loginGuestUser).toHaveBeenCalled()
})
test('updateCustomerPassword calls registered login', async () => {
jest.spyOn(ShopperCustomers.prototype, 'updateCustomerPassword').mockImplementation()
const auth = new Auth(config)
await auth.updateCustomerPassword({
customer: baseCustomer,
Expand Down
55 changes: 42 additions & 13 deletions packages/commerce-sdk-react/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import {
onClient,
getDefaultCookieAttributes,
isAbsoluteUrl,
stringToBase64
stringToBase64,
extractCustomParameters
} from '../utils'
import {
MOBIFY_PATH,
Expand Down Expand Up @@ -71,6 +72,15 @@ type AuthorizePasswordlessParams = Parameters<Helpers['authorizePasswordless']>[
type LoginPasswordlessParams = Parameters<Helpers['getPasswordLessAccessToken']>[2]
type LoginRegisteredUserB2CCredentials = Parameters<Helpers['loginRegisteredUserB2C']>[1]

/**
* This is a temporary type until we can make a breaking change and modify the signature for
* loginRegisteredUserB2C so that it takes in a body rather than just credentials
*
*/
type LoginRegisteredUserCredentialsWithCustomParams = LoginRegisteredUserB2CCredentials & {
options?: {body: helpers.CustomRequestBody}
}

/**
* The extended field is not from api response, we manually store the auth type,
* so we don't need to make another API call when we already have the data.
Expand Down Expand Up @@ -792,7 +802,7 @@ class Auth {
* A wrapper method for commerce-sdk-isomorphic helper: loginGuestUser.
*
*/
async loginGuestUser() {
async loginGuestUser(parameters?: helpers.CustomQueryParameters) {
if (this.clientSecret && onClient() && this.clientSecret !== SLAS_SECRET_PLACEHOLDER) {
this.logWarning(SLAS_SECRET_WARNING_MSG)
}
Expand All @@ -812,7 +822,9 @@ class Auth {
{
redirectURI: this.redirectURI,
dnt: dntPref,
...(usid && {usid})
...(usid && {usid}),
// custom parameters are sent only into the /authorize endpoint.
...parameters
}
] as const
const callback = this.clientSecret
Expand All @@ -837,10 +849,9 @@ class Auth {
*
*/
async register(body: ShopperCustomersTypes.CustomerRegistration) {
const {
customer: {login},
password
} = body
const {customer, password, ...parameters} = body
const {login} = customer
const customParameters = extractCustomParameters(parameters)

// login is optional field from isomorphic library
// type CustomerRegistration
Expand All @@ -849,24 +860,38 @@ class Auth {
throw new Error('Customer registration is missing login field.')
}

// The registerCustomer endpoint currently does not support custom parameters
// so we make sure not to send any custom params here
const res = await this.shopperCustomersClient.registerCustomer({
headers: {
authorization: `Bearer ${this.get('access_token')}`
},
body
body: {
customer,
password
}
})
await this.loginRegisteredUserB2C({
username: login,
password
password,
options: {
body: customParameters
}
})
return res
}

/**
* A wrapper method for commerce-sdk-isomorphic helper: loginRegisteredUserB2C.
*
* Note: This uses the type LoginRegisteredUserCredentialsWithCustomParams rather than LoginRegisteredUserB2CCredentials
* as a workaround to allow custom parameters through because the login.mutateAsync hook will only pass through a single
* 'body' argument into this function.
*
* In the next major version release, we should modify this method so that it's input is a body containing credentials,
* similar to the input for the register function.
*/
async loginRegisteredUserB2C(credentials: LoginRegisteredUserB2CCredentials) {
async loginRegisteredUserB2C(credentials: LoginRegisteredUserCredentialsWithCustomParams) {
if (this.clientSecret && onClient() && this.clientSecret !== SLAS_SECRET_PLACEHOLDER) {
this.logWarning(SLAS_SECRET_WARNING_MSG)
}
Expand All @@ -877,14 +902,16 @@ class Auth {
const token = await helpers.loginRegisteredUserB2C(
this.client,
{
...credentials,
username: credentials.username,
password: credentials.password,
clientSecret: this.clientSecret
},
{
redirectURI,
dnt: dntPref,
...(usid && {usid})
}
},
credentials.options
)
this.handleTokenResponse(token, isGuest)
if (onClient()) {
Expand Down Expand Up @@ -1066,12 +1093,14 @@ class Auth {
async authorizeIDP(parameters: AuthorizeIDPParams) {
const redirectURI = parameters.redirectURI || this.redirectURI
const usid = this.get('usid')
const customParameters = extractCustomParameters(parameters)
const {url, codeVerifier} = await helpers.authorizeIDP(
this.client,
{
redirectURI,
hint: parameters.hint,
...(usid && {usid})
...(usid && {usid}),
...customParameters
},
this.isPrivate
)
Expand Down
2 changes: 1 addition & 1 deletion packages/commerce-sdk-react/src/hooks/useAuthHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ const cacheUpdateMatrix: CacheUpdateMatrix = {
loginGuestUser: noop,
logout() {
return {
remove: [{queryKey: ['/commerce-sdk-react']}]
invalidate: [{queryKey: ['/commerce-sdk-react']}]
}
},
register: noop,
Expand Down
16 changes: 16 additions & 0 deletions packages/commerce-sdk-react/src/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,20 @@ describe('Utils', () => {
const isURL = utils.isAbsoluteUrl(url)
expect(isURL).toBe(expected)
})
test('extractCustomParameters only returns custom parameters', () => {
const parameters = {
c_param1: 'this is a custom',
param1: 'this is not a custom',
c_param2: 1,
param2: 2,
param3: false,
c_param3: true
}
const customParameters = utils.extractCustomParameters(parameters)
expect(customParameters).toEqual({
c_param1: 'this is a custom',
c_param2: 1,
c_param3: true
})
})
})
18 changes: 18 additions & 0 deletions packages/commerce-sdk-react/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import Cookies, {CookieAttributes} from 'js-cookie'
import {IFRAME_HOST_ALLOW_LIST} from './constant'
import {helpers} from 'commerce-sdk-isomorphic'

/** Utility to determine if you are on the browser (client) or not. */
export const onClient = (): boolean => typeof window !== 'undefined'
Expand Down Expand Up @@ -143,3 +144,20 @@ export const stringToBase64 =
typeof window === 'object' && typeof window.document === 'object'
? btoa
: (unencoded: string): string => Buffer.from(unencoded).toString('base64')

/**
* Extracts custom parameters from a set of SCAPI parameters
*
* Custom parameters are identified by the 'c_' prefix before their key
*
* @param parameters object containing all parameters for a SCAPI / SLAS call
* @returns new object containing only custom parameters
*/
export const extractCustomParameters = (
parameters: {[key: string]: string | number | boolean | string[] | number[]} | null
): helpers.CustomQueryParameters | helpers.CustomRequestBody => {
if (typeof parameters !== 'object' || parameters === null) {
throw new Error('Invalid input. Expecting an object as an input.')
}
return Object.fromEntries(Object.entries(parameters).filter(([key]) => key.startsWith('c_')))
}
Loading
Loading