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

@W-16889880 - Complete Social Login Integration: Connect Backend API to UI #2124

Open
wants to merge 30 commits into
base: feature-passwordless-social-login
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
eb1d1d0
wire up slas api calls
yunakim714 Nov 11, 2024
5bc3223
Merge branch 'feature-passwordless-social-login' into W-16889880-soci…
yunakim714 Nov 12, 2024
05a867e
Merge branch 'feature-passwordless-social-login' into W-16889880-soci…
yunakim714 Nov 13, 2024
2285322
lint
yunakim714 Nov 13, 2024
8e501d2
add redirect uri as param
yunakim714 Nov 13, 2024
248e81c
lint
yunakim714 Nov 13, 2024
9987857
use session storage to navigate back to checkout page
yunakim714 Nov 13, 2024
62ce20f
cleanup
yunakim714 Nov 14, 2024
2b8ff51
upadte tests
yunakim714 Nov 14, 2024
ae0b15b
implement util function to build redirecturi
yunakim714 Nov 14, 2024
48ad244
add tests for utils function
yunakim714 Nov 14, 2024
891f2cb
add initial outline of social login e2e tests
yunakim714 Nov 14, 2024
7c4217a
complete e2e test
yunakim714 Nov 18, 2024
8388a6d
mergebasket after social login
yunakim714 Nov 18, 2024
acb3f9e
run merge basket if customer is registered
yunakim714 Nov 18, 2024
166b522
cleanup
yunakim714 Nov 18, 2024
9bc048e
use local storage to indicate user is a social login user
yunakim714 Nov 18, 2024
a8cefcd
check that password card is hidden in e2e test
yunakim714 Nov 18, 2024
31dd1ba
add uido to auth data keys and customer type
yunakim714 Nov 20, 2024
c104f4a
cleanup
yunakim714 Nov 20, 2024
ebf67ef
add error handling and invert conditions
yunakim714 Nov 21, 2024
5f4a5e6
make social redirect url path dynamic
yunakim714 Nov 22, 2024
97ee0cb
cleanup
yunakim714 Nov 22, 2024
76e670f
slas and ecom are same user types
yunakim714 Nov 22, 2024
4058063
add profile page tests
yunakim714 Nov 22, 2024
3c0317d
Merge branch 'feature-passwordless-social-login' into W-16889880-soci…
yunakim714 Nov 25, 2024
f7d9b34
change prop name to be more descriptive
yunakim714 Nov 27, 2024
71243c1
Merge branch 'W-16889880-social-login' of github.com:SalesforceCommer…
yunakim714 Nov 27, 2024
0e12f93
set e2e user credentials as env vars
yunakim714 Nov 27, 2024
77afb51
fix env var name
yunakim714 Nov 27, 2024
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
65 changes: 65 additions & 0 deletions e2e/scripts/pageHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,34 @@ export const navigateToPDPDesktop = async ({page}) => {
await productTile.click();
}

/**
* Navigates to the `Cotton Turtleneck Sweater` PDP (Product Detail Page) on Desktop
* with the black variant selected.
*
* @param {Object} options.page - Object that represents a tab/window in the browser provided by playwright
*/
export const navigateToPDPDesktopSocial = async ({page, productName, productColor, productPrice}) => {
await page.goto(config.RETAIL_APP_HOME);

await page.getByRole("link", { name: "Womens" }).hover();
const topsNav = await page.getByRole("link", { name: "Tops", exact: true });
await expect(topsNav).toBeVisible();

await topsNav.click();

// PLP
const productTile = page.getByRole("link", {
name: RegExp(productName, 'i'),
});
// selecting swatch
const productTileImg = productTile.locator("img");
await productTileImg.waitFor({state: 'visible'})
await expect(productTile.getByText(RegExp(`From \\${productPrice}`, 'i'))).toBeVisible();

await productTile.getByLabel(RegExp(productColor, 'i'), { exact: true }).hover();
await productTile.click();
}

/**
* Adds the `Cotton Turtleneck Sweater` product to the cart with the variant:
* Color: Black
Expand Down Expand Up @@ -254,6 +282,43 @@ export const loginShopper = async ({page, userCredentials}) => {
}
}

/**
* Attempts to log in a shopper with provided user credentials.
*
* @param {Object} options.page - Object that represents a tab/window in the browser provided by playwright
* @return {Boolean} - denotes whether or not login was successful
*/
export const socialLoginShopper = async ({page}) => {
try {
await page.goto(config.RETAIL_APP_HOME + "/login");

await page.getByRole("button", { name: /Google/i }).click();
await expect(page.getByText(/Sign in with Google/i)).toBeVisible({ timeout: 10000 });
await page.waitForSelector('input[type="email"]');

// Fill in the email input
await page.fill('input[type="email"]', '[email protected]');
await page.click('#identifierNext');

await page.waitForLoadState();

// Fill in the password input
await page.fill('input[type="password"]', 'hpv_pek-JZK_xkz0wzf');
Copy link
Collaborator

Choose a reason for hiding this comment

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

I wonder if this is going to get caught by security? @shethj do we have an precedence for this, are we using secrets and environment variables for anything that we can copy the implementation?

@yunakim714 can you reach out to jainam for this.. I think he's knowledgable in the e2e landscape.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah we set all secrets in Github Actions settings and they can be set as env vars at runtime.
Values can be read from process.env in the tests.
Refer this code where we read the storefront domain from env var:

process.env.RETAIL_APP_HOME ||

await page.click('#passwordNext');
await page.waitForLoadState();

await expect(page.getByRole("heading", { name: /Account Details/i })).toBeVisible({timeout: 20000})
await expect(page.getByText(/[email protected]/i)).toBeVisible()

// Password card should be hidden for social login user
await expect(page.getByRole("heading", { name: /Password/i })).toBeHidden()

return true;
} catch {
return false;
}
}

/**
* Search for products by query string that takes you to the PLP
*
Expand Down
43 changes: 43 additions & 0 deletions e2e/tests/desktop/registered-shopper.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const {
validateWishlist,
loginShopper,
navigateToPDPDesktop,
navigateToPDPDesktopSocial,
socialLoginShopper,
} = require("../../scripts/pageHelpers");
const {
generateUserCredentials,
Expand Down Expand Up @@ -165,3 +167,44 @@ test("Registered shopper can add item to wishlist", async ({ page }) => {
// wishlist
await validateWishlist({page})
});

/**
* Test that social login persists a user's shopping cart
*/
test("Registered shopper logged in through social retains persisted cart", async ({ page }) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Will the basket persist for this user over time. For example will the e2e run one day to the next and accumulate items in that cart until it potentially errors out?

We could possibly remove the item from the cart after checking for its existence to be safe.

navigateToPDPDesktopSocial({page, productName: "Floral Ruffle Top", productColor: "Cardinal Red Multi", productPrice: "£35.19"});

// Add to Cart
await expect(
page.getByRole("heading", { name: /Floral Ruffle Top/i })
).toBeVisible({timeout: 15000});
await page.getByRole("radio", { name: "L", exact: true }).click();

await page.locator("button[data-testid='quantity-increment']").click();

// Selected Size and Color texts are broken into multiple elements on the page.
// So we need to look at the page URL to verify selected variants
const updatedPageURL = await page.url();
const params = updatedPageURL.split("?")[1];
expect(params).toMatch(/size=9LG/i);
expect(params).toMatch(/color=JJ9DFXX/i);
await page.getByRole("button", { name: /Add to Cart/i }).click();

const addedToCartModal = page.getByText(/2 items added to cart/i);

await addedToCartModal.waitFor();

await page.getByLabel("Close").click();

// Social Login
const isLoggedIn = await socialLoginShopper({
page
})

// Check Items in Cart
await page.getByLabel(/My cart/i).click();
await page.waitForLoadState();
await expect(
page.getByRole("link", { name: /Floral Ruffle Top/i })
).toBeVisible();
})
15 changes: 12 additions & 3 deletions packages/commerce-sdk-react/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ type AuthDataKeys =
| typeof DNT_COOKIE_NAME
| typeof DWSID_COOKIE_NAME
| 'code_verifier'
| 'uido'

type AuthDataMap = Record<
AuthDataKeys,
Expand Down Expand Up @@ -191,6 +192,10 @@ const DATA_MAP: AuthDataMap = {
code_verifier: {
storageType: 'local',
key: 'code_verifier'
},
uido: {
storageType: 'local',
key: 'uido'
}
}

Expand Down Expand Up @@ -574,11 +579,13 @@ class Auth {
responseValue,
defaultValue
)
const {uido} = this.parseSlasJWT(res.access_token)
const expiresDate = this.convertSecondsToDate(refreshTokenTTLValue)
this.set('refresh_token_expires_in', refreshTokenTTLValue.toString())
this.set(refreshTokenKey, res.refresh_token, {
expires: expiresDate
})
this.set('uido', uido)
Copy link
Collaborator

Choose a reason for hiding this comment

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

We are setting this value.. but where are we getting it?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We are getting it here const {uido} = this.parseSlasJWT(res.access_token) in line 582

}

async refreshAccessToken() {
Expand Down Expand Up @@ -1030,7 +1037,7 @@ class Auth {
*
*/
async authorizeIDP(parameters: AuthorizeIDPParams) {
const redirectURI = this.redirectURI
const redirectURI = parameters.redirectURI || this.redirectURI
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Similar to loginIDPUser, if a user passes in a redirectURI, we want to prioritize using this value

const usid = this.get('usid')
const {url, codeVerifier} = await helpers.authorizeIDP(
this.client,
Expand All @@ -1056,7 +1063,7 @@ class Auth {
async loginIDPUser(parameters: LoginIDPUserParams) {
const codeVerifier = this.get('code_verifier')
const code = parameters.code
const usid = parameters.usid
const usid = parameters.usid || this.get('usid')
Copy link
Collaborator

Choose a reason for hiding this comment

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

Under what circumstances would the usid on parameters be undefined?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Is usid on LoginIDPUserParams type optional? .. seems a little odd that it would be.

const redirectURI = parameters.redirectURI || this.redirectURI

const token = await helpers.loginIDPUser(
Expand Down Expand Up @@ -1139,6 +1146,7 @@ class Auth {
// ISB format
// 'uido:ecom::upn:Guest||xxxEmailxxx::uidn:FirstName LastName::gcid:xxxGuestCustomerIdxxx::rcid:xxxRegisteredCustomerIdxxx::chid:xxxSiteIdxxx',
const isbParts = isb.split('::')
const uido = isbParts[0].split('uido:')[1]
const isGuest = isbParts[1] === 'upn:Guest'
const customerId = isGuest
? isbParts[3].replace('gcid:', '')
Expand All @@ -1159,7 +1167,8 @@ class Auth {
dnt,
loginId,
isAgent,
agentId
agentId,
uido
}
}
}
Expand Down
9 changes: 8 additions & 1 deletion packages/commerce-sdk-react/src/hooks/useCustomerType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type useCustomerType = {
customerType: CustomerType
isGuest: boolean
isRegistered: boolean
uido: string | null
}

/**
Expand Down Expand Up @@ -50,10 +51,16 @@ const useCustomerType = (): useCustomerType => {
customerType = null
}

const uido: string | null = onClient
? // eslint-disable-next-line react-hooks/rules-of-hooks
useLocalStorage(`uido_${config.siteId}`)
: auth.get('uido')

Comment on lines +54 to +58
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@kevinxh @bendvc The isb claim in the SLAS JWT access token tells us whether the user is an ecom user or not. Would it be okay to return this value as part of the existing useCustomerType hook ?

Copy link
Collaborator

Choose a reason for hiding this comment

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

That useCustomerType hook has this return type:

export type CustomerType = null | 'guest' | 'registered'

Are you going to change that? what would the return type look like?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The return type is:

type useCustomerType = {
    customerType: CustomerType
    isGuest: boolean
    isRegistered: boolean
    uido: string | null
}

So I would just add uido !

Copy link
Collaborator

Choose a reason for hiding this comment

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

My gut is telling me that this right. First, the name of this type if probably not the best.. but imagine the type is called "CustomerType" ... you can see that it doesn't make sense to have anything but "type" in it. Is seems like we are sneaking uido into this hook. Maybe we can talk about it a little more.

return {
customerType,
isGuest,
isRegistered
isRegistered,
uido
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,21 @@
*/
import React from 'react'
import PropTypes from 'prop-types'
import {FormattedMessage, defineMessage, useIntl} from 'react-intl'
import {SimpleGrid, Stack} from '@salesforce/retail-react-app/app/components/shared/ui'
import useProfileFields from '@salesforce/retail-react-app/app/components/forms/useProfileFields'
import Field from '@salesforce/retail-react-app/app/components/field'

const ProfileFields = ({form, prefix = ''}) => {
const fields = useProfileFields({form, prefix})
const intl = useIntl()
const formTitleAriaLabel = defineMessage({
defaultMessage: 'Profile Form',
id: 'profile_fields.label.profile_form'
})

return (
<Stack spacing={5}>
<Stack spacing={5} aria-label={intl.formatMessage(formTitleAriaLabel)}>
<SimpleGrid columns={[1, 1, 1, 2]} spacing={5}>
<Field {...fields.firstName} />
<Field {...fields.lastName} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ const LoginForm = ({
form,
isPasswordlessEnabled = false,
isSocialEnabled = false,
idps = []
idps = [],
setLoginType
}) => {
return (
<Fragment>
Expand Down Expand Up @@ -56,6 +57,7 @@ const LoginForm = ({
handlePasswordlessLoginClick={handlePasswordlessLoginClick}
isSocialEnabled={isSocialEnabled}
idps={idps}
setLoginType={setLoginType}
/>
) : (
<StandardLogin
Expand Down Expand Up @@ -94,7 +96,8 @@ LoginForm.propTypes = {
form: PropTypes.object,
isPasswordlessEnabled: PropTypes.bool,
isSocialEnabled: PropTypes.bool,
idps: PropTypes.arrayOf(PropTypes.string)
idps: PropTypes.arrayOf(PropTypes.string),
setLoginType: PropTypes.func
Copy link
Collaborator

Choose a reason for hiding this comment

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

Lets talk about this setLoginType.. it's being prop drilled pretty deep and I kinda gave up after a couple files.. maybe there is a better solution here.

}

export default LoginForm
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ const PasswordlessLogin = ({
handleForgotPasswordClick,
handlePasswordlessLoginClick,
isSocialEnabled = false,
idps = []
idps = [],
setLoginType
}) => {
const [showPasswordView, setShowPasswordView] = useState(false)

const handlePasswordButton = async (e) => {
setLoginType('password')
const isValid = await form.trigger()
// Manually trigger the browser native form validations
const domForm = e.target.closest('form')
Expand Down Expand Up @@ -95,7 +97,8 @@ PasswordlessLogin.propTypes = {
handlePasswordlessLoginClick: PropTypes.func,
isSocialEnabled: PropTypes.bool,
idps: PropTypes.arrayOf(PropTypes.string),
hideEmail: PropTypes.bool
hideEmail: PropTypes.bool,
setLoginType: PropTypes.func
}

export default PasswordlessLogin
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import PropTypes from 'prop-types'
import {defineMessage, useIntl} from 'react-intl'
import {Button} from '@salesforce/retail-react-app/app/components/shared/ui'
import logger from '@salesforce/retail-react-app/app/utils/logger-instance'
import {useAuthHelper, AuthHelpers} from '@salesforce/commerce-sdk-react'
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
import {useAppOrigin} from '@salesforce/retail-react-app/app/hooks/use-app-origin'
import {setSessionJSONItem, buildRedirectURI} from '@salesforce/retail-react-app/app/utils/utils'

// Icons
import {AppleIcon, GoogleIcon} from '@salesforce/retail-react-app/app/components/icons'
Expand Down Expand Up @@ -38,6 +42,12 @@ const IDP_CONFIG = {
*/
const SocialLogin = ({idps}) => {
const {formatMessage} = useIntl()
const authorizeIDP = useAuthHelper(AuthHelpers.AuthorizeIDP)

// Build redirectURI from config values
const appOrigin = useAppOrigin()
const redirectPath = getConfig().app.login.social?.redirectURI || ''
Copy link
Collaborator

Choose a reason for hiding this comment

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

NIT: It would be safer to have the optional chaining throughout this chain. E.g. getConfig()?.app?.login?.social?.redirect

const redirectURI = buildRedirectURI(appOrigin, redirectPath)

const isIdpValid = (name) => {
return name in IDP_CONFIG && IDP_CONFIG[name.toLowerCase()]
Expand All @@ -64,17 +74,20 @@ const SocialLogin = ({idps}) => {
const config = IDP_CONFIG[name.toLowerCase()]
const Icon = config?.icon
const message = formatMessage(config?.message)

return (
config && (
<Button
onClick={() => {
alert(message)
onClick={async () => {
// Save the path where the user logged in
setSessionJSONItem('returnToPage', window.location.pathname)
await authorizeIDP.mutateAsync({
hint: name,
redirectURI: redirectURI
})
}}
borderColor="gray.500"
color="blue.600"
variant="outline"
key={`${name}-button`}
>
<Icon sx={{marginRight: 2}} />
{message}
Expand All @@ -88,7 +101,8 @@ const SocialLogin = ({idps}) => {
}

SocialLogin.propTypes = {
idps: PropTypes.arrayOf(PropTypes.string)
idps: PropTypes.arrayOf(PropTypes.string),
redirectURI: PropTypes.string
}

export default SocialLogin
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const ToggleCard = ({
title,
editing,
disabled,
disableEdit,
onEdit,
editLabel,
isLoading,
Expand Down Expand Up @@ -63,7 +64,7 @@ export const ToggleCard = ({
>
{title}
</Heading>
{!editing && !disabled && onEdit && (
{!editing && !disabled && onEdit && !disableEdit && (
Copy link
Collaborator

Choose a reason for hiding this comment

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

NIT: The new prop disableEdit seems to have the same outcome as not passing in a onEdit value. This isn't a big deal, but typically you might have a different visual treatment if you were supplying 2 params like this. E.g. you have an onEdit callback, but it's disabled via disableEdit so it shows as disabled. But here we are not showing it, same as no passing in onEdit.

No action to take here, but just food for thought.

<Button
variant="link"
size="sm"
Expand Down Expand Up @@ -105,6 +106,7 @@ ToggleCard.propTypes = {
editing: PropTypes.bool,
isLoading: PropTypes.bool,
disabled: PropTypes.bool,
disableEdit: PropTypes.bool,
onEdit: PropTypes.func,
children: PropTypes.any
}
Expand Down
Loading
Loading