Skip to content

Commit

Permalink
[Commerce SDK React Integration] Shopper login (#951)
Browse files Browse the repository at this point in the history
* wip

* fix error handling

* wip

* implement shopper login calls

* Update packages/commerce-sdk-react/src/auth/index.ts

Co-authored-by: Will Harney <[email protected]>

* Update packages/commerce-sdk-react/src/auth/index.ts

Co-authored-by: Will Harney <[email protected]>

* Update packages/commerce-sdk-react/src/hooks/useCustomerType.ts

Co-authored-by: Will Harney <[email protected]>

* improve useCustomerType

* add comment for hook integration WIP

* update useEffect dependencies

* fix useShopperLoginHelper type

* implement getresetpasswordtoken

* fix mutation issue

* remove reset

* fix error message

---------

Co-authored-by: Will Harney <[email protected]>
  • Loading branch information
kevinxh and wjhsf authored Feb 1, 2023
1 parent e0fc559 commit b1a165c
Show file tree
Hide file tree
Showing 15 changed files with 288 additions and 160 deletions.
7 changes: 1 addition & 6 deletions packages/commerce-sdk-react/src/auth/index.test.ts
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 Auth, {injectAccessToken} from './'
import Auth from './'
import jwt from 'jsonwebtoken'
import {helpers} from 'commerce-sdk-isomorphic'

Expand Down Expand Up @@ -55,11 +55,6 @@ jest.mock('commerce-sdk-isomorphic', () => {
}
})

test('injectAccessToken', () => {
expect(injectAccessToken({}, 'test')).toEqual({Authorization: 'Bearer test'})
expect(injectAccessToken(undefined, 'test')).toEqual({Authorization: 'Bearer test'})
})

const config = {
clientId: 'clientId',
organizationId: 'organizationId',
Expand Down
100 changes: 56 additions & 44 deletions packages/commerce-sdk-react/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
* 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 {helpers, ShopperLogin, ShopperLoginTypes} from 'commerce-sdk-isomorphic'
import {helpers, ShopperLogin, ShopperCustomers, ShopperLoginTypes, ShopperCustomersTypes} from 'commerce-sdk-isomorphic'
import jwtDecode from 'jwt-decode'
import {ApiClientConfigParams} from '../hooks/types'
import {ApiClientConfigParams, Argument} from '../hooks/types'
import {BaseStorage, LocalStorage, CookieStorage} from './storage'
import {CustomerType} from '../hooks/useCustomerType'

type Helpers = typeof helpers
interface AuthConfig extends ApiClientConfigParams {
Expand Down Expand Up @@ -55,7 +56,7 @@ type AuthDataMap = Record<
* and it's not easy to grab this info in user land, so we add it into the Auth object, and expose it via a hook
*/
type AuthData = ShopperLoginTypes.TokenResponse & {
customer_type: string
customer_type: CustomerType
}

const onClient = typeof window !== 'undefined'
Expand Down Expand Up @@ -134,6 +135,7 @@ const DATA_MAP: AuthDataMap = {
*/
class Auth {
private client: ShopperLogin<ApiClientConfigParams>
private shopperCustomersClient: ShopperCustomers<ApiClientConfigParams>
private redirectURI: string
private pendingToken: Promise<ShopperLoginTypes.TokenResponse> | undefined
private REFRESH_TOKEN_EXPIRATION_DAYS = 90
Expand All @@ -150,6 +152,17 @@ class Auth {
throwOnBadResponse: true,
fetchOptions: config.fetchOptions
})
this.shopperCustomersClient = new ShopperCustomers({
proxy: config.proxy,
parameters: {
clientId: config.clientId,
organizationId: config.organizationId,
shortCode: config.shortCode,
siteId: config.siteId
},
throwOnBadResponse: true,
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
Expand Down Expand Up @@ -200,7 +213,7 @@ class Auth {
refresh_token: this.get('refresh_token_registered') || this.get('refresh_token_guest'),
token_type: this.get('token_type'),
usid: this.get('usid'),
customer_type: this.get('customer_type')
customer_type: this.get('customer_type') as CustomerType
}
}

Expand Down Expand Up @@ -318,6 +331,35 @@ class Auth {
)
}

/**
* This is a wrapper method for ShopperCustomer API registerCustomer endpoint.
*
*/
async register(
body: ShopperCustomersTypes.CustomerRegistration
) {
const {
customer: {email},
password
} = body

// email is optional field from isomorphic library
// type CustomerRegistration
// here we had to guard it to avoid ts error
if (!email) {
throw new Error('Customer registration is missing email address.')
}

const res = await this.shopperCustomersClient.registerCustomer({
headers: {
authorization: `Bearer ${this.get('access_token')}`
},
body
})
await this.loginRegisteredUserB2C({username: email, password})
return res
}

/**
* A wrapper method for commerce-sdk-isomorphic helper: loginRegisteredUserB2C.
*
Expand All @@ -326,54 +368,24 @@ class Auth {
const redirectURI = this.redirectURI
const usid = this.get('usid')
const isGuest = false
return this.queueRequest(
() =>
helpers.loginRegisteredUserB2C(this.client, credentials, {
redirectURI,
...(usid && {usid})
}),
isGuest
)
const token = await helpers.loginRegisteredUserB2C(this.client, credentials, {
redirectURI,
...(usid && {usid})
})
this.handleTokenResponse(token, isGuest)
return token
}

/**
* A wrapper method for commerce-sdk-isomorphic helper: logout.
*
*/
async logout() {
const isGuest = true
return this.queueRequest(
() =>
// TODO: are we missing a call to /logout?
// Ticket: https://gus.lightning.force.com/lightning/r/ADM_Work__c/a07EE00001EFF4nYAH/view
helpers.loginGuestUser(this.client, {
redirectURI: this.redirectURI
}),
isGuest
)
// TODO: are we missing a call to /logout?
// Ticket: https://gus.lightning.force.com/lightning/r/ADM_Work__c/a07EE00001EFF4nYAH/view
this.clearStorage()
return this.loginGuestUser()
}
}

export default Auth

/**
* A ultility function to inject access token into a headers object.
*
* @Internal
*/
export const injectAccessToken = (
headers:
| {
[key: string]: string
}
| undefined,
accessToken: string
) => {
const _headers = headers
? {
...headers,
Authorization: `Bearer ${accessToken}`
}
: {Authorization: `Bearer ${accessToken}`}
return _headers
}
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,6 @@ export const SHOPPER_CUSTOMERS_NOT_IMPLEMENTED = [
'authorizeCustomer',
'authorizeTrustedSystem',
'deleteCustomerProductList',
'getResetPasswordToken',
'invalidateCustomerAuth',
'registerExternalProfile',
'resetPassword',
Expand Down
17 changes: 9 additions & 8 deletions packages/commerce-sdk-react/src/hooks/ShopperLogin/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {UseMutationResult} from '@tanstack/react-query'
export const ShopperLoginHelpers = {
LoginGuestUser: 'loginGuestUser',
LoginRegisteredUserB2C: 'loginRegisteredUserB2C',
Register: 'register',
Logout: 'logout'
} as const

Expand All @@ -27,14 +28,16 @@ type ShopperLoginHelpersType = typeof ShopperLoginHelpers[keyof typeof ShopperLo
* Avaliable helpers:
* - loginRegisteredUserB2C
* - loginGuestUser
* - register
* - logout
*/
export function useShopperLoginHelper<Action extends ShopperLoginHelpersType>(
action: Action
): UseMutationResult<
ShopperLoginTypes.TokenResponse,
// TODO: what's the better way for declaring the types?
any,
Error,
void | Argument<Auth['loginRegisteredUserB2C']>
any
> {
const auth = useAuth()
if (action === ShopperLoginHelpers.LoginGuestUser) {
Expand All @@ -43,13 +46,11 @@ export function useShopperLoginHelper<Action extends ShopperLoginHelpersType>(
if (action === ShopperLoginHelpers.Logout) {
return useMutation(() => auth.logout())
}
if (action === ShopperLoginHelpers.Register) {
return useMutation((body) => auth.register(body))
}
if (action === ShopperLoginHelpers.LoginRegisteredUserB2C) {
return useMutation((credentials) => {
if (!credentials) {
throw new Error('Missing registered user credentials.')
}
return auth.loginRegisteredUserB2C(credentials)
})
return useMutation((credentials) => auth.loginRegisteredUserB2C(credentials))
}

throw new Error('Unknown ShopperLogin helper.')
Expand Down
40 changes: 35 additions & 5 deletions packages/commerce-sdk-react/src/hooks/useCustomerType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,47 @@ import useAuth from './useAuth'
import useLocalStorage from './useLocalStorage'

const onClient = typeof window !== 'undefined'
export type CustomerType = null | 'guest' | 'registered'
type useCustomerType = {
customerType: CustomerType
isGuest: boolean
isRegistered: boolean
}

/**
* A hook to return customer auth type, either guest or registered user
* A hook to return customer auth type.
*
* Customer type can have 3 values:
* - null
* - guest
* - registered
*
* During initialization, type is null. And it is possible that
* isGuest and isRegistered to both be false.
*
*/
const useCustomerType = (): string | null => {
const useCustomerType = (): useCustomerType => {
let customerType = null
if (onClient) {
return useLocalStorage('customer_type')
customerType = useLocalStorage('customer_type')
} else {
const auth = useAuth()
customerType = auth.get('customer_type')
}

const isGuest = customerType === 'guest'
const isRegistered = customerType === 'registered'

if (customerType !== null && customerType !== 'guest' && customerType !== 'registered') {
console.warn(`Unrecognized customer type found in storage: ${customerType}`)
customerType = null
}

return {
customerType,
isGuest,
isRegistered
}
const auth = useAuth()
return auth.get('customer_type')
}

export default useCustomerType
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ class Auth {
* @returns {Promise}
*/
async login(credentials) {
console.warn('@TODO: old login method is still being used.')
// Calling login while its already pending will return a reference
// to the existing promise.
if (this._pendingLogin) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import PropTypes from 'prop-types'
import {useHistory, useLocation} from 'react-router-dom'
import {getAssetUrl} from 'pwa-kit-react-sdk/ssr/universal/utils'
import {getAppOrigin} from 'pwa-kit-react-sdk/utils/url'
import {useCustomerType} from 'commerce-sdk-react-preview'

// Chakra
import {Box, useDisclosure, useStyleConfig} from '@chakra-ui/react'
Expand Down Expand Up @@ -82,6 +83,7 @@ Learn more with our localization guide. https://sfdc.co/localization-guide
const location = useLocation()
const authModal = useAuthModal()
const customer = useCustomer()
const {isRegistered} = useCustomerType()
const {site, locale, buildUrl} = useMultiSite()

const [isOnline, setIsOnline] = useState(true)
Expand Down Expand Up @@ -145,7 +147,7 @@ Learn more with our localization guide. https://sfdc.co/localization-guide

const onAccountClick = () => {
// Link to account page for registered customer, open auth modal otherwise
if (customer.isRegistered) {
if (isRegistered) {
const path = buildUrl('/account')
history.push(path)
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,18 @@ import {
useBreakpointValue,
useMultiStyleConfig
} from '@chakra-ui/react'
import {
ShopperLoginHelpers,
useShopperLoginHelper,
useCustomerType
} from 'commerce-sdk-react-preview'
import Link from '../../components/link'
// Icons
import {BrandLogo, LocationIcon, SignoutIcon, UserIcon} from '../icons'

// Others
import {noop} from '../../utils/utils'
import {getPathWithLocale, categoryUrlBuilder} from '../../utils/url'
import useCustomer from '../../commerce-api/hooks/useCustomer'
import LoadingSpinner from '../loading-spinner'

import useNavigation from '../../hooks/use-navigation'
Expand Down Expand Up @@ -79,17 +83,18 @@ const STORE_LOCATOR_HREF = '/store-locator'
*/
const DrawerMenu = ({isOpen, onClose = noop, onLogoClick = noop, root}) => {
const intl = useIntl()
const customer = useCustomer()
const {isRegistered} = useCustomerType()
const navigate = useNavigation()
const styles = useMultiStyleConfig('DrawerMenu')
const drawerSize = useBreakpointValue({sm: PHONE_DRAWER_SIZE, md: TABLET_DRAWER_SIZE})
const socialIconVariant = useBreakpointValue({base: 'flex', md: 'flex-start'})
const {site, buildUrl} = useMultiSite()
const {l10n} = site
const [showLoading, setShowLoading] = useState(false)
const logout = useShopperLoginHelper(ShopperLoginHelpers.Logout)
const onSignoutClick = async () => {
setShowLoading(true)
await customer.logout()
await logout.mutateAsync()
navigate('/login')
setShowLoading(false)
}
Expand Down Expand Up @@ -163,7 +168,7 @@ const DrawerMenu = ({isOpen, onClose = noop, onLogoClick = noop, root}) => {
{/* Application Actions */}
<VStack align="stretch" spacing={0} {...styles.actions} px={0}>
<Box {...styles.actionsItem}>
{customer.isRegistered ? (
{isRegistered ? (
<NestedAccordion
urlBuilder={(item, locale) =>
`/${locale}/account${item.path}`
Expand Down
Loading

0 comments on commit b1a165c

Please sign in to comment.