Skip to content

Commit 2cb9c0e

Browse files
authored
Merge pull request #2079 from SalesforceCommerceCloud/feature-passwordless-social-login
Feature passwordless social login
2 parents 9a5d421 + 4e15295 commit 2cb9c0e

File tree

81 files changed

+4928
-584
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

81 files changed

+4928
-584
lines changed

.github/workflows/e2e.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,9 @@ jobs:
336336

337337
- name: Set Retail App Private Client Home
338338
run: export RETAIL_APP_HOME=https://scaffold-pwa-e2e-pwa-kit-private.mobify-storefront.com/
339+
340+
- name: Set PWA Kit E2E Test User
341+
run: export [email protected] PWA_E2E_USER_PASSWORD=hpv_pek-JZK_xkz0wzf
339342

340343
- name: Install Playwright Browsers
341344
run: npx playwright install --with-deps

e2e/config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,4 +156,7 @@ module.exports = {
156156
"worker",
157157
],
158158
},
159+
PWA_E2E_USER_EMAIL: process.env.PWA_E2E_USER_EMAIL,
160+
PWA_E2E_USER_PASSWORD: process.env.PWA_E2E_USER_PASSWORD,
161+
SOCIAL_LOGIN_RETAIL_APP_HOME: "https://wasatch-mrt-feature-public.mrt-storefront-staging.com"
159162
};

e2e/scripts/pageHelpers.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,35 @@ export const navigateToPDPDesktop = async ({page}) => {
119119
await productTile.click()
120120
}
121121

122+
/**
123+
* Navigates to the `Cotton Turtleneck Sweater` PDP (Product Detail Page) on Desktop
124+
* with the black variant selected.
125+
*
126+
* @param {Object} options.page - Object that represents a tab/window in the browser provided by playwright
127+
*/
128+
export const navigateToPDPDesktopSocial = async ({page, productName, productColor, productPrice}) => {
129+
await page.goto(config.SOCIAL_LOGIN_RETAIL_APP_HOME)
130+
await answerConsentTrackingForm(page)
131+
132+
await page.getByRole("link", { name: "Womens" }).hover()
133+
const topsNav = await page.getByRole("link", { name: "Tops", exact: true })
134+
await expect(topsNav).toBeVisible()
135+
136+
await topsNav.click()
137+
138+
// PLP
139+
const productTile = page.getByRole("link", {
140+
name: RegExp(productName, 'i'),
141+
})
142+
// selecting swatch
143+
const productTileImg = productTile.locator("img")
144+
await productTileImg.waitFor({state: 'visible'})
145+
await expect(productTile.getByText(RegExp(`From \\${productPrice}`, 'i'))).toBeVisible()
146+
147+
await productTile.getByLabel(RegExp(productColor, 'i'), { exact: true }).hover()
148+
await productTile.click()
149+
}
150+
122151
/**
123152
* Adds the `Cotton Turtleneck Sweater` product to the cart with the variant:
124153
* Color: Black
@@ -273,6 +302,43 @@ export const loginShopper = async ({page, userCredentials}) => {
273302
}
274303
}
275304

305+
/**
306+
* Attempts to log in a shopper with provided user credentials.
307+
*
308+
* @param {Object} options.page - Object that represents a tab/window in the browser provided by playwright
309+
* @return {Boolean} - denotes whether or not login was successful
310+
*/
311+
export const socialLoginShopper = async ({page}) => {
312+
try {
313+
await page.goto(config.SOCIAL_LOGIN_RETAIL_APP_HOME + "/login")
314+
315+
await page.getByText(/Google/i).click()
316+
await expect(page.getByText(/Sign in with Google/i)).toBeVisible({ timeout: 10000 })
317+
await page.waitForSelector('input[type="email"]')
318+
319+
// Fill in the email input
320+
await page.fill('input[type="email"]', config.PWA_E2E_USER_EMAIL)
321+
await page.click('#identifierNext')
322+
323+
await page.waitForSelector('input[type="password"]')
324+
325+
// Fill in the password input
326+
await page.fill('input[type="password"]', config.PWA_E2E_USER_PASSWORD)
327+
await page.click('#passwordNext')
328+
await page.waitForLoadState()
329+
330+
await expect(page.getByRole("heading", { name: /Account Details/i })).toBeVisible({timeout: 20000})
331+
await expect(page.getByText(/e2e.pwa.kit@gmail.com/i)).toBeVisible()
332+
333+
// Password card should be hidden for social login user
334+
await expect(page.getByRole("heading", { name: /Password/i })).toBeHidden()
335+
336+
return true
337+
} catch {
338+
return false
339+
}
340+
}
341+
276342
/**
277343
* Search for products by query string that takes you to the PLP
278344
*

e2e/tests/desktop/registered-shopper.spec.js

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,16 @@
88
const {test, expect} = require('@playwright/test')
99
const config = require('../../config')
1010
const {
11-
addProductToCart,
12-
registerShopper,
13-
validateOrderHistory,
14-
validateWishlist,
15-
loginShopper,
16-
navigateToPDPDesktop
17-
} = require('../../scripts/pageHelpers')
11+
addProductToCart,
12+
registerShopper,
13+
validateOrderHistory,
14+
validateWishlist,
15+
loginShopper,
16+
navigateToPDPDesktop,
17+
navigateToPDPDesktopSocial,
18+
socialLoginShopper,
19+
} = require("../../scripts/pageHelpers")
1820
const {generateUserCredentials, getCreditCardExpiry} = require('../../scripts/utils.js')
19-
2021
let registeredUserCredentials = {}
2122

2223
test.beforeAll(async () => {
@@ -156,3 +157,46 @@ test('Registered shopper can add item to wishlist', async ({page}) => {
156157
// wishlist
157158
await validateWishlist({page})
158159
})
160+
161+
/**
162+
* Test that social login persists a user's shopping cart
163+
* TODO: Fix flaky test
164+
* Skipping this test for now because Google login requires 2FA, which Playwright cannot get past.
165+
*/
166+
test.skip("Registered shopper logged in through social retains persisted cart", async ({ page }) => {
167+
navigateToPDPDesktopSocial({page, productName: "Floral Ruffle Top", productColor: "Cardinal Red Multi", productPrice: "£35.19"})
168+
169+
// Add to Cart
170+
await expect(
171+
page.getByRole("heading", { name: /Floral Ruffle Top/i })
172+
).toBeVisible({timeout: 15000})
173+
await page.getByRole("radio", { name: "L", exact: true }).click()
174+
175+
await page.locator("button[data-testid='quantity-increment']").click()
176+
177+
// Selected Size and Color texts are broken into multiple elements on the page.
178+
// So we need to look at the page URL to verify selected variants
179+
const updatedPageURL = await page.url()
180+
const params = updatedPageURL.split("?")[1]
181+
expect(params).toMatch(/size=9LG/i)
182+
expect(params).toMatch(/color=JJ9DFXX/i)
183+
await page.getByRole("button", { name: /Add to Cart/i }).click()
184+
185+
const addedToCartModal = page.getByText(/2 items added to cart/i)
186+
187+
await addedToCartModal.waitFor()
188+
189+
await page.getByLabel("Close", { exact: true }).click()
190+
191+
// Social Login
192+
await socialLoginShopper({
193+
page
194+
})
195+
196+
// Check Items in Cart
197+
await page.getByLabel(/My cart/i).click()
198+
await page.waitForLoadState()
199+
await expect(
200+
page.getByRole("link", { name: /Floral Ruffle Top/i })
201+
).toBeVisible()
202+
})

packages/commerce-sdk-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- Clear auth state if session has been invalidated by a password change [#2092](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2092)
88
- DNT interface improvement [#2203](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2203)
99
- Support Node 22 [#2218](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2218)
10+
- Add `authorizeIDP`, `loginIDPUser`, `authorizePasswordless`, `getPasswordLessAccessToken`, `getPasswordResetToken`, and `resetPassword` wrapper functions to support Social Login, Passwordless Login, and Password Reset [#2079] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2079)
1011

1112
## v3.1.0 (Oct 28, 2024)
1213

packages/commerce-sdk-react/package-lock.json

Lines changed: 12 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/commerce-sdk-react/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
"version": "node ./scripts/version.js"
4141
},
4242
"dependencies": {
43-
"commerce-sdk-isomorphic": "^3.1.1",
43+
"commerce-sdk-isomorphic": "^3.2.0",
4444
"js-cookie": "^3.0.1",
4545
"jwt-decode": "^4.0.0"
4646
},

packages/commerce-sdk-react/src/auth/index.test.ts

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@ jest.mock('commerce-sdk-isomorphic', () => {
4444
loginGuestUserPrivate: jest.fn().mockResolvedValue(''),
4545
loginRegisteredUserB2C: jest.fn().mockResolvedValue(''),
4646
logout: jest.fn().mockResolvedValue(''),
47-
handleTokenResponse: jest.fn().mockResolvedValue('')
47+
handleTokenResponse: jest.fn().mockResolvedValue(''),
48+
loginIDPUser: jest.fn().mockResolvedValue(''),
49+
authorizeIDP: jest.fn().mockResolvedValue(''),
50+
authorizePasswordless: jest.fn().mockResolvedValue(''),
51+
getPasswordLessAccessToken: jest.fn().mockResolvedValue('')
4852
},
4953
ShopperCustomers: jest.fn().mockImplementation(() => {
5054
return {
@@ -59,7 +63,8 @@ jest.mock('../utils', () => ({
5963
onClient: () => true,
6064
getParentOrigin: jest.fn().mockResolvedValue(''),
6165
isOriginTrusted: () => false,
62-
getDefaultCookieAttributes: () => {}
66+
getDefaultCookieAttributes: () => {},
67+
isAbsoluteUrl: () => true
6368
}))
6469

6570
/** The auth data we store has a slightly different shape than what we use. */
@@ -72,7 +77,8 @@ const config = {
7277
siteId: 'siteId',
7378
proxy: 'proxy',
7479
redirectURI: 'redirectURI',
75-
logger: console
80+
logger: console,
81+
passwordlessLoginCallbackURI: 'passwordlessLoginCallbackURI'
7682
}
7783

7884
const configSLASPrivate = {
@@ -96,10 +102,21 @@ const JWTExpired = jwt.sign(
96102
'secret'
97103
)
98104

105+
const configPasswordlessSms = {
106+
clientId: 'clientId',
107+
organizationId: 'organizationId',
108+
shortCode: 'shortCode',
109+
siteId: 'siteId',
110+
proxy: 'proxy',
111+
redirectURI: 'redirectURI',
112+
logger: console
113+
}
114+
99115
const FAKE_SLAS_EXPIRY = DEFAULT_SLAS_REFRESH_TOKEN_REGISTERED_TTL - 1
100116

101117
const TOKEN_RESPONSE: ShopperLoginTypes.TokenResponse = {
102-
access_token: 'access_token_xyz',
118+
access_token:
119+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjYy1zbGFzOjp6enJmXzAwMTo6c2NpZDpjOWM0NWJmZC0wZWQzLTRhYTIteHh4eC00MGY4ODk2MmI4MzY6OnVzaWQ6YjQ4NjUyMzMtZGU5Mi00MDM5LXh4eHgtYWEyZGZjOGMxZWE1IiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJpc2IiOiJ1aWRvOmVjb206OnVwbjpHdWVzdHx8am9obi5kb2VAZXhhbXBsZS5jb206OnVpZG46Sm9obiBEb2U6OmdjaWQ6Z3Vlc3QtMTIzNDU6OnJjaWQ6cmVnaXN0ZXJlZC02Nzg5MCIsImRudCI6InRlc3QifQ.9yKtUb22ExO-Q4VNQRAyIgTm63l3x5z45Uu1FIQa5dQ',
103120
customer_id: 'customer_id_xyz',
104121
enc_user_id: 'enc_user_id_xyz',
105122
expires_in: 1800,
@@ -596,6 +613,73 @@ describe('Auth', () => {
596613
clientSecret: SLAS_SECRET_PLACEHOLDER
597614
})
598615
})
616+
617+
test('loginIDPUser calls isomorphic loginIDPUser', async () => {
618+
const auth = new Auth(config)
619+
await auth.loginIDPUser({redirectURI: 'redirectURI', code: 'test'})
620+
expect(helpers.loginIDPUser).toHaveBeenCalled()
621+
const functionArg = (helpers.loginIDPUser as jest.Mock).mock.calls[0][2]
622+
expect(functionArg).toMatchObject({redirectURI: 'redirectURI', code: 'test'})
623+
})
624+
625+
test('loginIDPUser adds clientSecret to parameters when using private client', async () => {
626+
const auth = new Auth(configSLASPrivate)
627+
await auth.loginIDPUser({redirectURI: 'test', code: 'test'})
628+
expect(helpers.loginIDPUser).toHaveBeenCalled()
629+
const functionArg = (helpers.loginIDPUser as jest.Mock).mock.calls[0][1]
630+
expect(functionArg).toMatchObject({
631+
clientSecret: SLAS_SECRET_PLACEHOLDER
632+
})
633+
})
634+
635+
test('authorizeIDP calls isomorphic authorizeIDP', async () => {
636+
const auth = new Auth(config)
637+
await auth.authorizeIDP({redirectURI: 'redirectURI', hint: 'test'})
638+
expect(helpers.authorizeIDP).toHaveBeenCalled()
639+
const functionArg = (helpers.authorizeIDP as jest.Mock).mock.calls[0][1]
640+
expect(functionArg).toMatchObject({redirectURI: 'redirectURI', hint: 'test'})
641+
})
642+
643+
test('authorizeIDP adds clientSecret to parameters when using private client', async () => {
644+
const auth = new Auth(configSLASPrivate)
645+
await auth.authorizeIDP({redirectURI: 'test', hint: 'test'})
646+
expect(helpers.authorizeIDP).toHaveBeenCalled()
647+
const privateClient = (helpers.authorizeIDP as jest.Mock).mock.calls[0][2]
648+
expect(privateClient).toBe(true)
649+
})
650+
651+
test('authorizePasswordless calls isomorphic authorizePasswordless', async () => {
652+
const auth = new Auth(config)
653+
await auth.authorizePasswordless({
654+
callbackURI: 'callbackURI',
655+
userid: 'userid',
656+
mode: 'callback'
657+
})
658+
expect(helpers.authorizePasswordless).toHaveBeenCalled()
659+
const functionArg = (helpers.authorizePasswordless as jest.Mock).mock.calls[0][2]
660+
expect(functionArg).toMatchObject({
661+
callbackURI: 'callbackURI',
662+
userid: 'userid',
663+
mode: 'callback'
664+
})
665+
})
666+
667+
test('authorizePasswordless sets mode to sms as configured', async () => {
668+
const auth = new Auth(configPasswordlessSms)
669+
await auth.authorizePasswordless({userid: 'userid', mode: 'sms'})
670+
expect(helpers.authorizePasswordless).toHaveBeenCalled()
671+
const functionArg = (helpers.authorizePasswordless as jest.Mock).mock.calls[0][2]
672+
expect(functionArg).toMatchObject({userid: 'userid', mode: 'sms'})
673+
})
674+
675+
test('getPasswordLessAccessToken calls isomorphic getPasswordLessAccessToken', async () => {
676+
const auth = new Auth(config)
677+
await auth.getPasswordLessAccessToken({pwdlessLoginToken: '12345678'})
678+
expect(helpers.getPasswordLessAccessToken).toHaveBeenCalled()
679+
const functionArg = (helpers.getPasswordLessAccessToken as jest.Mock).mock.calls[0][2]
680+
expect(functionArg).toMatchObject({pwdlessLoginToken: '12345678'})
681+
})
682+
599683
test('logout as registered user calls isomorphic logout', async () => {
600684
const auth = new Auth(config)
601685

0 commit comments

Comments
 (0)