-
Notifications
You must be signed in to change notification settings - Fork 138
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
base: feature-passwordless-social-login
Are you sure you want to change the base?
@W-16889880 - Complete Social Login Integration: Connect Backend API to UI #2124
Conversation
…al-login Signed-off-by: Yuna Kim <[email protected]>
@@ -987,7 +987,7 @@ class Auth { | |||
* | |||
*/ | |||
async authorizeIDP(parameters: AuthorizeIDPParams) { | |||
const redirectURI = this.redirectURI | |||
const redirectURI = parameters.redirectURI || this.redirectURI |
There was a problem hiding this comment.
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') |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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
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 | ||
} | ||
}) |
There was a problem hiding this comment.
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' |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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:
There was a problem hiding this comment.
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 && ( |
There was a problem hiding this comment.
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}) => { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
}
if (searchParams.code && searchParams.usid) { | ||
loginIDPUser.mutateAsync({ | ||
code: searchParams.code, | ||
redirectURI: redirectURI | ||
}) | ||
} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
if (locatedFrom) { | ||
navigate(locatedFrom) | ||
} else { | ||
navigate('/account') | ||
} |
There was a problem hiding this comment.
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.
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' | ||
}, |
There was a problem hiding this comment.
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.
const uido: string | null = onClient | ||
? // eslint-disable-next-line react-hooks/rules-of-hooks | ||
useLocalStorage(`uido_${config.siteId}`) | ||
: auth.get('uido') | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
!
There was a problem hiding this comment.
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', |
There was a problem hiding this comment.
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 () => { |
There was a problem hiding this comment.
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
e2e/scripts/pageHelpers.js
Outdated
await page.waitForLoadState(); | ||
|
||
// Fill in the password input | ||
await page.fill('input[type="password"]', 'hpv_pek-JZK_xkz0wzf'); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:
Line 10 in 57c24e1
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 }) => { |
There was a problem hiding this comment.
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') |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 || '' |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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}) => { |
There was a problem hiding this comment.
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.
…ceCloud/pwa-kit into W-16889880-social-login
Description
Wire up Social Login API calls to the newly implemented UI.
Login should work in 3 locations:
We have configured Google and Apple as OOTB IDPs.
Types of Changes
Changes
Add
commerce-sdk-react
API calls to the newly implemented UIAdd
redirectURI
as parameter, as this will be different from the defaultcallback
path that has already been set inapp/components/_app-config
hereIf there is an error with the login API call, there will be an error on the redirect page as so:
How to Test-Drive This PR
/login
), login modal, and checkout page (/checkout
)To run the e2e test:
export RETAIL_APP_HOME=https://wasatch-mrt-social-login-poc.mrt-storefront-staging.com
npx playwright test --ui
from the monorepo rootChecklists
General
Accessibility Compliance
You must check off all items in one of the follow two lists:
or...
Localization