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

Conversation

yunakim714
Copy link
Collaborator

@yunakim714 yunakim714 commented Nov 11, 2024

Description

Wire up Social Login API calls to the newly implemented UI.
Login should work in 3 locations:

  1. Login modal
  2. Login page
  3. Checkout page

We have configured Google and Apple as OOTB IDPs.

Types of Changes

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Documentation update
  • Breaking change (could cause existing functionality to not work as expected)
  • Other changes (non-breaking changes that does not fit any of the above)

Breaking changes include:

  • Removing a public function or component or prop
  • Adding a required argument to a function
  • Changing the data type of a function parameter or return value
  • Adding a new peer dependency to package.json

Changes

  • Add commerce-sdk-react API calls to the newly implemented UI

  • Add redirectURI as parameter, as this will be different from the default callback path that has already been set in app/components/_app-config here

  • If there is an error with the login API call, there will be an error on the redirect page as so:
    Screenshot 2024-11-22 at 12 45 26 PM

How to Test-Drive This PR

  • Navigate to https://wasatch-mrt-social-login-poc.mrt-storefront-staging.com/ where social and passwordless login have been enabled
  • Open the login modal
  • Verify that you can log in with both Google and Apple
    • Verify that you can log in from all 3 locations: login page (/login), login modal, and checkout page (/checkout)
    • If using the login modal or checkout page, verify that you are redirected back to the page where you initiated login

To run the e2e test:

  • Point the test to the deployed environment by setting an env var like so: export RETAIL_APP_HOME=https://wasatch-mrt-social-login-poc.mrt-storefront-staging.com
  • Run npx playwright test --ui from the monorepo root
  • Run the test: Registered shopper logged in through social retains persisted cart
  • Verify that the user is logged in and sees their user details in the secure account page as well as the items they added to cart as a guest
  • ⚠️I have added the NEW Google PWA Kit account credentials into Password Manager in the Imported Mobify - Product folder !!⚠️

Checklists

General

  • Changes are covered by test cases
  • CHANGELOG.md updated with a short description of changes (not required for documentation updates)

Accessibility Compliance

You must check off all items in one of the follow two lists:

  • There are no changes to UI

or...

Localization

  • Changes include a UI text update in the Retail React App (which requires translation)

@yunakim714 yunakim714 marked this pull request as ready for review November 13, 2024 20:44
@yunakim714 yunakim714 requested a review from a team as a code owner November 13, 2024 20:44
@yunakim714 yunakim714 added the skip changelog Skip the "Changelog Check" GitHub Actions step even if the Changelog.md files are not updated label Nov 14, 2024
@@ -987,7 +987,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

@@ -1013,7 +1013,7 @@ class Auth {
async loginIDPUser(parameters: LoginIDPUserParams) {
const codeVerifier = this.get('code_verifier')
const code = parameters.code
const usid = parameters.usid
const usid = this.get('usid')
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 need to use the guest usid in order to run mergeBasket

Copy link
Collaborator

Choose a reason for hiding this comment

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

Can you tell me more about this change here? We are now ignoring a parameter that is being passed in? Why aren't we doing something like the fallback where const usid = parameters.usid || this.get('usid') like you did for the redirect uri?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I changed this to use this.get('usid')because merge basket will only work if the /authorize and /token calls receive the same guest usid. The Auth class stores the same usid until the user is logged in so this would work.

However, I can still also do const usid = parameters.usid || this.get('usid') - the /authorize response body will return the same usid that is passed in the request params, and will only generate a new usid if there isn't one provided.

@@ -122,6 +124,23 @@ test('Renders login modal by default', async () => {
})

test('Renders check email modal on email mode', async () => {
// Store the original useForm function
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Mocking the form's success state in order to render the Check Your Email modal

Comment on lines 58 to 67
mergeBasket.mutate({
headers: {
// This is not required since the request has no body
// but CommerceAPI throws a '419 - Unsupported Media Type' error if this header is removed.
'Content-Type': 'application/json'
},
parameters: {
createDestinationBasket: true
}
})
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 merge the user's basket if the user is logged in successfully through social login!

@@ -22,7 +22,8 @@ module.exports = {
},
social: {
enabled: false,
idps: ['google', 'apple']
idps: ['google', 'apple'],
redirectURI: '/social-callback'
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I have added the redirectURI here so that users can configure the redirectURI they want to use specifically for social login. This may differ from the redirectURI they have configured in AppConfig here

@@ -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 car
Copy link
Collaborator

Choose a reason for hiding this comment

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

[NIT] shopping cart :)

// Save the path where the user logged in
setSessionJSONItem('returnToPage', window.location.pathname)
// Set local storage item to indicate this is a external login
window.localStorage.setItem('isSocialProfile', true)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Hey @bendvc - Wanted to get your input on this. Nate, Pratik, and I have been discussing ways to resolve the "Social Login first" issue, where profiles that have been created through Social Login are unable to edit any profile credentials, because well, there aren't any.

For a short-term solution, I was thinking we could use local storage to denote that the current user is a social login user, and if so, disable edit in the Account page. I think in future work we're hoping the getCustomer endpoint will somehow be able to indicate whether the user profile was created through an external IDP or ECOM.

Here's how the account page would look like for social login users:
Screenshot 2024-11-18 at 4 24 23 PM

Copy link
Collaborator

Choose a reason for hiding this comment

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

That is an interesting problem. If there is absolutely no way to tell via the JWT, I think this should be an acceptable solution. To make things a little more robust I might seek to use something like loginSource = credentials|idp instead of isSocialProfile because you can imagine a non-social first profile that just happened to log in via IDP doesn't mean that it's a social profile.

I guess the next question is someone that has a "non-social first" account, but logs in via IDP.. can they change there password? If they can we there isn't really a way to tell if we should be limiting them on changing their password, and it would be more like an artificial limitation that we impose and they have to log in with their password in order to make those types of changes?

This might deserve a short convo

@@ -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.

@@ -343,7 +349,7 @@ const PasswordCard = () => {
)
}

const AccountDetail = () => {
const AccountDetail = ({isSocialProfile = false}) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

As I get further down in this PR I see more and more prop drilling of this isSocialProfile. It's not a horrible pattern but no used as much these days.

Did you ever thing about having this information made available via a provider/hook combo? Something that comes to mind is to have it implemented in the commerce-sdk-react so you can reuse some code there.

Copy link
Collaborator

Choose a reason for hiding this comment

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

@kevinxh would be a good person to ask about whether or not thats a good idea to implement in that lib.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think ideally the commerce-sdk-react library should handle the state isSocialProfile, it would be easy if that information is embedded in the jwt token, if not, we should seek to manually store the state in the Auth class as a class property.

something like

// commerce-sdk-react auth.js

class Auth {
  isSocialLogin = false
  
  loginSocial = () => {
    // ...do the login
    this.isSocialLogin = true
    
    // potentially also store the IDP information like google, facebook, etc
  }
}

// Then we can provide a utility hook
// commerce-sdk-react useSocialLogin()
export const useSocialLogin = () => {
   const auth = useAuth()
   return auth.isSocialLogin
}

Comment on lines 46 to 51
if (searchParams.code && searchParams.usid) {
loginIDPUser.mutateAsync({
code: searchParams.code,
redirectURI: redirectURI
})
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Think about inverting this condition so that your code is a little flatter, like:

        if (!searchParams.code || !searchParams.usid) {
            return
        }

        loginIDPUser.mutateAsync({
            code: searchParams.code,
            redirectURI: redirectURI
        })

Also.. what happens if that condition doesn't result in a login? Or if someone goes to this page without the params?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@bendvc I added error handling in case this condition doesn't result in a login.

If someone goes to this page without the params they will not be redirected to the secure account page as this login API will not be called.

useEffect(() => {
if (isRegistered) {
navigate('/account')
if (customer?.isRegistered) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

You can think about inverting this condition again. As a rule of thumb if all the code is wrapped in a condition, then it's a good candidate to invert, but if you have an equally size "else" then maybe not.

Comment on lines 68 to 72
if (locatedFrom) {
navigate(locatedFrom)
} else {
navigate('/account')
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Good call on checking for this value before navigating.

Comment on lines 59 to 63
headers: {
// This is not required since the request has no body
// but CommerceAPI throws a '419 - Unsupported Media Type' error if this header is removed.
'Content-Type': 'application/json'
},
Copy link
Collaborator

Choose a reason for hiding this comment

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

I noticed that this is a copy of out other basket merge. I'm wondering if the commerce-sdk-react should be adding this for us. Kevin might be a good person to ask about that. Looking at the api docs the body is alway json, so I don't know why we would have to specify that all the time.

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

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.

@@ -89,7 +90,7 @@ export const routes = [
exact: true
},
{
path: '/social-callback',
path: config.login.social?.redirectURI || '/social-callback',
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

If the user sets the redirectURI to be a different relative path in their config file, we want this reflected here in routes.jsx. The default will be /social-callback

})
})

test('Non ECOM user cannot see the password card', async () => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Adding test to verify that non ECOM users cannot edit nor see the Password card

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 ||

/**
* 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.

@@ -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 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


// 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

@@ -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.

@@ -60,7 +62,7 @@ const Skeleton = forwardRef(({children, height, width, ...rest}, ref) => {

Skeleton.displayName = 'Skeleton'

const ProfileCard = () => {
const ProfileCard = ({isEcomAccount = false}) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

There might be other use cases for wanting to disable the password reset. Might be wise to make this a little more generic by making this literal.. like "canResetPassword" or "allowPasswordChange". This way the component will be more flexible.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
skip changelog Skip the "Changelog Check" GitHub Actions step even if the Changelog.md files are not updated
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants