Skip to content

Commit

Permalink
feat(DIA-785): fuse signup and login flows into a single user experie…
Browse files Browse the repository at this point in the history
…nce (#10746)

* adds a feature flag

* uses the feature flag to display a new onboarding screen

* uses a shared formik context for all steps

* uses individual formik contexts for all steps

* uses react-navigation to transition between steps

* refactors the context to be smaller

* adds placeholders for all of the steps

* adds rough end-to-end sign up functionality

* adds the end-to-end login with otp flow

* adds rough end-to-end forgot password flow

* adds email verification

* begins to add social sign-in and -up

* includes recaptcha in email step

* addresses code review

* polished validation of email step

* adds os version to user agent string

* gives each step its own formik context

* tidied up a bit
  • Loading branch information
iskounen authored Oct 1, 2024
1 parent 9f48610 commit 1448580
Show file tree
Hide file tree
Showing 12 changed files with 1,187 additions and 41 deletions.
2 changes: 0 additions & 2 deletions src/app/Components/Recaptcha/RecaptchaWebView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useIsStaging } from "app/utils/hooks/useIsStaging"
import { FC, useRef } from "react"
import { Alert } from "react-native"
import Config from "react-native-config"
import WebView, { WebViewMessageEvent } from "react-native-webview"
import { ShouldStartLoadRequest } from "react-native-webview/lib/WebViewTypes"
Expand Down Expand Up @@ -53,7 +52,6 @@ export const RecaptchaWebView: FC<RecaptchaWebViewProps> = ({
allowFileAccessFromFileURLs
javaScriptCanOpenWindowsAutomatically
onShouldStartLoadWithRequest={(e: ShouldStartLoadRequest) => {
Alert.alert("onShouldStartLoadWithRequest", e.url)
if (
e.url.startsWith("https://www.google.com/recaptcha") ||
e.url.startsWith("https://staging.artsy.net") ||
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Flex, useTheme } from "@artsy/palette-mobile"
import BottomSheet from "@gorhom/bottom-sheet"
import { NavigationContainer, NavigationState } from "@react-navigation/native"
import { createStackNavigator } from "@react-navigation/stack"
import { EmailStep } from "app/Scenes/Onboarding/AuthenticationDialog/EmailStep"
import { ForgotPasswordStep } from "app/Scenes/Onboarding/AuthenticationDialog/ForgotPasswordStep"
import { LoginOTPStep } from "app/Scenes/Onboarding/AuthenticationDialog/LoginOTPStep"
import { LoginPasswordStep } from "app/Scenes/Onboarding/AuthenticationDialog/LoginPasswordStep"
import { SignUpNameStep } from "app/Scenes/Onboarding/AuthenticationDialog/SignUpNameStep"
import { SignUpPasswordStep } from "app/Scenes/Onboarding/AuthenticationDialog/SignUpPasswordStep"
import { WelcomeStep } from "app/Scenes/Onboarding/AuthenticationDialog/WelcomeStep"
import {
OnboardingHomeNavigationStack,
OnboardingHomeStore,
} from "app/Scenes/Onboarding/OnboardingHome"
import React from "react"

export const AuthenticationDialog: React.FC = () => {
const setCurrentStep = OnboardingHomeStore.useStoreActions((actions) => actions.setCurrentStep)

const { space } = useTheme()

const handleStateChange = (state: NavigationState | undefined) => {
const currentStep = state?.routes?.at(state.index)?.name

if (currentStep) {
setCurrentStep(currentStep)
} else {
setCurrentStep(undefined)
}
}

return (
<Flex flex={1}>
<BottomSheet
snapPoints={["100%"]}
detached
enableContentPanningGesture={false}
handleComponent={null}
>
<Flex style={{ borderRadius: space(2), overflow: "hidden", flex: 1 }}>
<NavigationContainer independent onStateChange={handleStateChange}>
<Stack.Navigator
screenOptions={{
headerShown: false,
gestureEnabled: false,
cardStyle: { backgroundColor: "white" },
}}
initialRouteName="WelcomeStep"
>
<Stack.Screen name="WelcomeStep" component={WelcomeStep} />
<Stack.Screen name="EmailStep" component={EmailStep} />
<Stack.Screen name="SignUpPasswordStep" component={SignUpPasswordStep} />
<Stack.Screen name="SignUpNameStep" component={SignUpNameStep} />
<Stack.Screen name="LoginPasswordStep" component={LoginPasswordStep} />
<Stack.Screen name="LoginOTPStep" component={LoginOTPStep} />
<Stack.Screen name="ForgotPasswordStep" component={ForgotPasswordStep} />
</Stack.Navigator>
</NavigationContainer>
</Flex>
</BottomSheet>
</Flex>
)
}

const Stack = createStackNavigator<OnboardingHomeNavigationStack>()
110 changes: 110 additions & 0 deletions src/app/Scenes/Onboarding/AuthenticationDialog/EmailStep.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { BackButton, Button, Flex, Text, useTheme } from "@artsy/palette-mobile"
import { BottomSheetScrollView } from "@gorhom/bottom-sheet"
import { useNavigation } from "@react-navigation/native"
import { StackNavigationProp, StackScreenProps } from "@react-navigation/stack"
import { BottomSheetInput } from "app/Components/BottomSheetInput"
import { useRecaptcha } from "app/Components/Recaptcha/Recaptcha"
import { OnboardingHomeNavigationStack } from "app/Scenes/Onboarding/OnboardingHome"
import { GlobalStore } from "app/store/GlobalStore"
import { FormikProvider, useFormik, useFormikContext } from "formik"
import { useEffect } from "react"
import { Alert } from "react-native"
import * as Yup from "yup"

type EmailStepProps = StackScreenProps<OnboardingHomeNavigationStack, "EmailStep">

interface EmailStepFormValues {
email: string
recaptchaToken: string | null
}

export const EmailStep: React.FC<EmailStepProps> = ({ navigation }) => {
const formik = useFormik<EmailStepFormValues>({
initialValues: { email: "", recaptchaToken: null },
onSubmit: async ({ email, recaptchaToken }, { setFieldValue }) => {
if (!recaptchaToken) {
Alert.alert("Something went wrong. Please try again, or contact [email protected]")
return
}

const res = await GlobalStore.actions.auth.verifyUser({ email, recaptchaToken })

setFieldValue("recaptchaToken", null)

if (res === "user_exists") {
navigation.navigate("LoginPasswordStep", { email })
} else if (res === "user_does_not_exist") {
navigation.navigate("SignUpPasswordStep", { email })
} else if (res === "something_went_wrong") {
Alert.alert("Something went wrong. Please try again, or contact [email protected]")
}
},
validateOnMount: false,
validationSchema: Yup.object().shape({
email: Yup.string()
.email("Please provide a valid email address")
.required("Email field is required"),
}),
})

return (
<BottomSheetScrollView>
<FormikProvider value={formik}>
<EmailStepForm />
</FormikProvider>
</BottomSheetScrollView>
)
}

const EmailStepForm: React.FC = () => {
const { errors, handleChange, handleSubmit, isValid, setFieldValue } =
useFormikContext<EmailStepFormValues>()

const navigation = useNavigation<StackNavigationProp<OnboardingHomeNavigationStack>>()

const { Recaptcha, token } = useRecaptcha({ source: "authentication", action: "verify_email" })

const { color, space } = useTheme()

// TODO: reset recaptchaToken when the user navigates back
useEffect(() => {
setFieldValue("recaptchaToken", token)
}, [setFieldValue, token])

const handleBackButtonPress = () => {
navigation.goBack()
}

return (
<Flex padding={2} gap={space(1)}>
<BackButton onPress={handleBackButtonPress} />

<Text variant="sm-display">Sign up or log in</Text>
<Recaptcha />

<BottomSheetInput
autoCapitalize="none"
autoComplete="email"
autoFocus={true}
keyboardType="email-address"
onChangeText={(text) => {
handleChange("email")(text.trim())
}}
blurOnSubmit={false} // This is needed to avoid UI jump when the user submits
placeholderTextColor={color("black30")}
title="Email"
returnKeyType="next"
spellCheck={false}
autoCorrect={false}
// We need to to set textContentType to username (instead of emailAddress) here
// enable autofill of login details from the device keychain.
textContentType="username"
error={errors.email}
/>

<Button block width={100} onPress={handleSubmit} disabled={!isValid}>
Continue
</Button>
</Flex>
)
}
147 changes: 147 additions & 0 deletions src/app/Scenes/Onboarding/AuthenticationDialog/ForgotPasswordStep.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { BackButton, Button, Flex, Spacer, Text, useTheme } from "@artsy/palette-mobile"
import { BottomSheetScrollView } from "@gorhom/bottom-sheet"
import { RouteProp, useNavigation, useRoute } from "@react-navigation/native"
import { StackNavigationProp, StackScreenProps } from "@react-navigation/stack"
import { BottomSheetInput } from "app/Components/BottomSheetInput"
import { OnboardingNavigationStack } from "app/Scenes/Onboarding/Onboarding"
import { OnboardingHomeNavigationStack } from "app/Scenes/Onboarding/OnboardingHome"
import { GlobalStore } from "app/store/GlobalStore"
import { FormikProvider, useFormik, useFormikContext } from "formik"
import * as Yup from "yup"

type ForgotPasswordStepProps = StackScreenProps<OnboardingHomeNavigationStack, "ForgotPasswordStep">

interface ForgotPasswordStepFormValues {
email: string
}

export const ForgotPasswordStep: React.FC<ForgotPasswordStepProps> = ({ navigation }) => {
const formik = useFormik<ForgotPasswordStepFormValues>({
initialValues: { email: "" },
onSubmit: async ({ email }, { setErrors }) => {
const res = await GlobalStore.actions.auth.forgotPassword({
email,
})
if (!res) {
// For security purposes, we are returning a generic error message
setErrors({
email:
"Couldn’t send reset password link. Please try again, or contact [email protected]",
})
} else {
navigation.navigate("ForgotPasswordStep", { requestedPasswordReset: true })
}
},
validationSchema: Yup.string().email("Please provide a valid email address"),
})

return (
<BottomSheetScrollView>
<FormikProvider value={formik}>
<ForgotPasswordStepForm />
</FormikProvider>
</BottomSheetScrollView>
)
}

const ForgotPasswordStepForm: React.FC = () => {
const { dirty, handleChange, handleSubmit, isSubmitting, isValid, validateForm, values } =
useFormikContext<ForgotPasswordStepFormValues>()

const navigation = useNavigation<StackNavigationProp<OnboardingNavigationStack>>()
const route = useRoute<RouteProp<OnboardingHomeNavigationStack, "ForgotPasswordStep">>()

const { color, space } = useTheme()

const handleBackButtonPress = () => {
navigation.goBack()
}

const requestedPasswordReset = route.params?.requestedPasswordReset

return (
<Flex padding={2} gap={space(1)}>
<BackButton onPress={handleBackButtonPress} />
<Flex flex={1} px={2} pt={6} justifyContent="flex-start">
<Text variant="lg-display">Forgot Password?</Text>

<Text pt={0.5} color="black100" variant="xs">
Please enter the email address associated with your Artsy account to receive a reset link.
</Text>

<Spacer y={2} />

{!!requestedPasswordReset ? (
<Text color="blue100">Password reset link sent. Please check your email.</Text>
) : (
<BottomSheetInput
autoCapitalize="none"
autoComplete="email"
enableClearButton
keyboardType="email-address"
onChangeText={(text) => {
handleChange("email")(text.trim())
}}
onSubmitEditing={() => {
if (dirty) {
handleSubmit()
}
}}
onBlur={() => {
validateForm()
}}
blurOnSubmit={false} // This is needed to avoid UI jump when the user submits
placeholder="Email address"
placeholderTextColor={color("black30")}
value={values.email}
returnKeyType="done"
spellCheck={false}
autoCorrect={false}
textContentType="emailAddress"
testID="email-address"
/>
)}
</Flex>

<Flex px={2} paddingBottom={2}>
{!!requestedPasswordReset ? (
<>
<Button
variant="fillDark"
onPress={() => navigation.goBack()}
block
haptic="impactMedium"
testID="returnToLoginButton"
>
Return to login
</Button>
<Spacer y={1} />
<Button
onPress={handleSubmit}
block
haptic="impactMedium"
disabled={!isValid || !dirty}
loading={isSubmitting}
testID="resetButton"
variant="outline"
>
Send Again
</Button>
</>
) : (
<Button
onPress={handleSubmit}
block
variant="fillDark"
haptic="impactMedium"
disabled={!isValid || !dirty}
loading={isSubmitting}
testID="resetButton"
>
Send Reset Link
</Button>
)}
</Flex>
</Flex>
)
}
Loading

0 comments on commit 1448580

Please sign in to comment.