diff --git a/app/components/home-screen/QuickStart.tsx b/app/components/home-screen/QuickStart.tsx index 129de243d..f1ff6bda6 100644 --- a/app/components/home-screen/QuickStart.tsx +++ b/app/components/home-screen/QuickStart.tsx @@ -142,7 +142,7 @@ const QuickStart = () => { carouselData = carouselData.filter((el) => el.type !== "nonCustodialWallet") } if ( - data?.me?.defaultAccount.level === AccountLevel.Zero || + // data?.me?.defaultAccount.level === AccountLevel.Zero || !!data?.me?.email?.address || persistentState?.closedQuickStartTypes?.includes("email") ) { diff --git a/app/graphql/generated.gql b/app/graphql/generated.gql index 242a55b4a..db431e8a7 100644 --- a/app/graphql/generated.gql +++ b/app/graphql/generated.gql @@ -447,6 +447,31 @@ mutation lnUsdInvoiceFeeProbe($input: LnUsdInvoiceFeeProbeInput!) { } } +mutation newUserEmailRegistrationInitiate($input: NewUserEmailRegistrationInitiateInput!) { + newUserEmailRegistrationInitiate(input: $input) { + errors { + message + code + __typename + } + emailFlowId + __typename + } +} + +mutation newUserEmailRegistrationValidate($input: NewUserEmailRegistrationValidateInput!) { + newUserEmailRegistrationValidate(input: $input) { + errors { + message + code + __typename + } + authToken + totpRequired + __typename + } +} + mutation onChainAddressCurrent($input: OnChainAddressCurrentInput!) { onChainAddressCurrent(input: $input) { errors { diff --git a/app/graphql/generated.ts b/app/graphql/generated.ts index 53407af7b..2862bbe87 100644 --- a/app/graphql/generated.ts +++ b/app/graphql/generated.ts @@ -822,6 +822,8 @@ export type Mutation = { readonly lnUsdInvoiceCreateOnBehalfOfRecipient: LnInvoicePayload; readonly lnUsdInvoiceFeeProbe: CentAmountPayload; readonly merchantMapSuggest: MerchantPayload; + readonly newUserEmailRegistrationInitiate: NewUserEmailRegistrationInitiatePayload; + readonly newUserEmailRegistrationValidate: AuthTokenPayload; readonly onChainAddressCreate: OnChainAddressPayload; readonly onChainAddressCurrent: OnChainAddressPayload; readonly onChainPaymentSend: PaymentSendPayload; @@ -997,6 +999,16 @@ export type MutationMerchantMapSuggestArgs = { }; +export type MutationNewUserEmailRegistrationInitiateArgs = { + input: NewUserEmailRegistrationInitiateInput; +}; + + +export type MutationNewUserEmailRegistrationValidateArgs = { + input: NewUserEmailRegistrationValidateInput; +}; + + export type MutationOnChainAddressCreateArgs = { input: OnChainAddressCreateInput; }; @@ -1126,6 +1138,21 @@ export const Network = { } as const; export type Network = typeof Network[keyof typeof Network]; +export type NewUserEmailRegistrationInitiateInput = { + readonly email: Scalars['EmailAddress']['input']; +}; + +export type NewUserEmailRegistrationInitiatePayload = { + readonly __typename: 'NewUserEmailRegistrationInitiatePayload'; + readonly emailFlowId?: Maybe; + readonly errors: ReadonlyArray; +}; + +export type NewUserEmailRegistrationValidateInput = { + readonly code: Scalars['OneTimeAuthCode']['input']; + readonly emailFlowId: Scalars['String']['input']; +}; + export const NotificationChannel = { Push: 'PUSH' } as const; @@ -2413,6 +2440,20 @@ export type QuizCompletedMutationVariables = Exact<{ export type QuizCompletedMutation = { readonly __typename: 'Mutation', readonly quizCompleted: { readonly __typename: 'QuizCompletedPayload', readonly errors: ReadonlyArray<{ readonly __typename: 'GraphQLApplicationError', readonly message: string }>, readonly quiz?: { readonly __typename: 'Quiz', readonly id: string, readonly completed: boolean } | null } }; +export type NewUserEmailRegistrationInitiateMutationVariables = Exact<{ + input: NewUserEmailRegistrationInitiateInput; +}>; + + +export type NewUserEmailRegistrationInitiateMutation = { readonly __typename: 'Mutation', readonly newUserEmailRegistrationInitiate: { readonly __typename: 'NewUserEmailRegistrationInitiatePayload', readonly emailFlowId?: string | null, readonly errors: ReadonlyArray<{ readonly __typename: 'GraphQLApplicationError', readonly message: string, readonly code?: string | null }> } }; + +export type NewUserEmailRegistrationValidateMutationVariables = Exact<{ + input: NewUserEmailRegistrationValidateInput; +}>; + + +export type NewUserEmailRegistrationValidateMutation = { readonly __typename: 'Mutation', readonly newUserEmailRegistrationValidate: { readonly __typename: 'AuthTokenPayload', readonly authToken?: string | null, readonly totpRequired?: boolean | null, readonly errors: ReadonlyArray<{ readonly __typename: 'GraphQLApplicationError', readonly message: string, readonly code?: string | null }> } }; + export type UserEmailRegistrationInitiateMutationVariables = Exact<{ input: UserEmailRegistrationInitiateInput; }>; @@ -5246,6 +5287,81 @@ export function useQuizCompletedMutation(baseOptions?: Apollo.MutationHookOption export type QuizCompletedMutationHookResult = ReturnType; export type QuizCompletedMutationResult = Apollo.MutationResult; export type QuizCompletedMutationOptions = Apollo.BaseMutationOptions; +export const NewUserEmailRegistrationInitiateDocument = gql` + mutation newUserEmailRegistrationInitiate($input: NewUserEmailRegistrationInitiateInput!) { + newUserEmailRegistrationInitiate(input: $input) { + errors { + message + code + } + emailFlowId + } +} + `; +export type NewUserEmailRegistrationInitiateMutationFn = Apollo.MutationFunction; + +/** + * __useNewUserEmailRegistrationInitiateMutation__ + * + * To run a mutation, you first call `useNewUserEmailRegistrationInitiateMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useNewUserEmailRegistrationInitiateMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [newUserEmailRegistrationInitiateMutation, { data, loading, error }] = useNewUserEmailRegistrationInitiateMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useNewUserEmailRegistrationInitiateMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(NewUserEmailRegistrationInitiateDocument, options); + } +export type NewUserEmailRegistrationInitiateMutationHookResult = ReturnType; +export type NewUserEmailRegistrationInitiateMutationResult = Apollo.MutationResult; +export type NewUserEmailRegistrationInitiateMutationOptions = Apollo.BaseMutationOptions; +export const NewUserEmailRegistrationValidateDocument = gql` + mutation newUserEmailRegistrationValidate($input: NewUserEmailRegistrationValidateInput!) { + newUserEmailRegistrationValidate(input: $input) { + errors { + message + code + } + authToken + totpRequired + } +} + `; +export type NewUserEmailRegistrationValidateMutationFn = Apollo.MutationFunction; + +/** + * __useNewUserEmailRegistrationValidateMutation__ + * + * To run a mutation, you first call `useNewUserEmailRegistrationValidateMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useNewUserEmailRegistrationValidateMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [newUserEmailRegistrationValidateMutation, { data, loading, error }] = useNewUserEmailRegistrationValidateMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useNewUserEmailRegistrationValidateMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(NewUserEmailRegistrationValidateDocument, options); + } +export type NewUserEmailRegistrationValidateMutationHookResult = ReturnType; +export type NewUserEmailRegistrationValidateMutationResult = Apollo.MutationResult; +export type NewUserEmailRegistrationValidateMutationOptions = Apollo.BaseMutationOptions; export const UserEmailRegistrationInitiateDocument = gql` mutation userEmailRegistrationInitiate($input: UserEmailRegistrationInitiateInput!) { userEmailRegistrationInitiate(input: $input) { diff --git a/app/i18n/en/index.ts b/app/i18n/en/index.ts index 8ef6c6245..6471b70c8 100644 --- a/app/i18n/en/index.ts +++ b/app/i18n/en/index.ts @@ -979,7 +979,7 @@ const en: BaseTranslation = { phone: "Phone", importUsingPhone: "Import or Create your Cash Wallet using phone number", email: "Email", - importUsingEmail: "Import your Cash Wallet using email address", + importUsingEmail: "Import or Create your Cash wallet using email address", login: "Login/Create", done: "Done", }, diff --git a/app/i18n/i18n-types.ts b/app/i18n/i18n-types.ts index 3aed41fed..c941669e1 100644 --- a/app/i18n/i18n-types.ts +++ b/app/i18n/i18n-types.ts @@ -3200,7 +3200,7 @@ type RootTranslation = { */ email: string /** - * I​m​p​o​r​t​ ​y​o​u​r​ ​C​a​s​h​ ​W​a​l​l​e​t​ ​u​s​i​n​g​ ​e​m​a​i​l​ ​a​d​d​r​e​s​s + * I​m​p​o​r​t​ ​o​r​ ​C​r​e​a​t​e​ ​y​o​u​r​ ​C​a​s​h​ ​w​a​l​l​e​t​ ​u​s​i​n​g​ ​e​m​a​i​l​ ​a​d​d​r​e​s​s */ importUsingEmail: string /** @@ -7910,7 +7910,7 @@ export type TranslationFunctions = { */ email: () => LocalizedString /** - * Import your Cash Wallet using email address + * Import or Create your Cash wallet using email address */ importUsingEmail: () => LocalizedString /** diff --git a/app/i18n/raw-i18n/source/en.json b/app/i18n/raw-i18n/source/en.json index ea9650313..4a2429ec4 100644 --- a/app/i18n/raw-i18n/source/en.json +++ b/app/i18n/raw-i18n/source/en.json @@ -916,7 +916,7 @@ "phone": "Phone", "importUsingPhone": "Import or Create your Cash Wallet using phone number", "email": "Email", - "importUsingEmail": "Import your Cash Wallet using email address", + "importUsingEmail": "Import or Create your Cash wallet using email address", "login": "Login/Create", "done": "Done" }, diff --git a/app/navigation/stack-param-lists.ts b/app/navigation/stack-param-lists.ts index cb8e8d321..0187e41be 100644 --- a/app/navigation/stack-param-lists.ts +++ b/app/navigation/stack-param-lists.ts @@ -124,7 +124,7 @@ export type RootStackParamList = { emailRegistrationInitiate: undefined emailRegistrationValidate: { email: string; emailRegistrationId: string } emailLoginInitiate: undefined - emailLoginValidate: { email: string; emailLoginId: string } + emailLoginValidate: { email: string; emailFlowId: string } totpRegistrationInitiate: undefined totpRegistrationValidate: { totpRegistrationId: string } totpLoginValidate: { authToken: string } diff --git a/app/screens/email-login-screen/email-login-initiate.tsx b/app/screens/email-login-screen/email-login-initiate.tsx index 4e07bd39f..85047559e 100644 --- a/app/screens/email-login-screen/email-login-initiate.tsx +++ b/app/screens/email-login-screen/email-login-initiate.tsx @@ -1,9 +1,11 @@ -import React, { useEffect, useState } from "react" +import React, { useState } from "react" import { View } from "react-native" -import axios, { isAxiosError } from "axios" +import crashlytics from "@react-native-firebase/crashlytics" import { Input, Text, makeStyles } from "@rneui/themed" import { StackScreenProps } from "@react-navigation/stack" import { RootStackParamList } from "@app/navigation/stack-param-lists" +import { gql } from "@apollo/client" +import { useNewUserEmailRegistrationInitiateMutation } from "@app/graphql/generated" // components import { Screen } from "../../components/screen" @@ -12,23 +14,35 @@ import { GaloyErrorBox } from "@app/components/atomic/galoy-error-box" // hooks import { useI18nContext } from "@app/i18n/i18n-react" -import { useAppConfig } from "@app/hooks" // utils import { testProps } from "@app/utils/testProps" import validator from "validator" +gql` + mutation newUserEmailRegistrationInitiate($input: NewUserEmailRegistrationInitiateInput!) { + newUserEmailRegistrationInitiate(input: $input) { + errors { + message + code + } + emailFlowId + } + } +` + type Props = StackScreenProps export const EmailLoginInitiateScreen: React.FC = ({ navigation }) => { const styles = useStyles() const { LL } = useI18nContext() - const { authUrl } = useAppConfig().appConfig.galoyInstance const [emailInput, setEmailInput] = useState("") const [errorMessage, setErrorMessage] = useState("") const [loading, setLoading] = useState(false) + const [newUserEmailRegistrationInitiate] = useNewUserEmailRegistrationInitiateMutation() + const updateInput = (text: string) => { setEmailInput(text) setErrorMessage("") @@ -42,40 +56,39 @@ export const EmailLoginInitiateScreen: React.FC = ({ navigation }) => { setLoading(true) - const url = `${authUrl}/auth/email/code` - try { - const res = await axios({ - url, - method: "POST", - data: { - email: emailInput, + const { data } = await newUserEmailRegistrationInitiate({ + variables: { + input: { + email: emailInput, + }, }, }) - // TODO: manage error on ip rate limit - // TODO: manage error when trying the same email too often - const emailLoginId = res.data.result - if (emailLoginId) { - console.log({ emailLoginId }) - navigation.navigate("emailLoginValidate", { emailLoginId, email: emailInput }) + const errors = data?.newUserEmailRegistrationInitiate?.errors + const emailFlowId = data?.newUserEmailRegistrationInitiate?.emailFlowId + + if (emailFlowId) { + navigation.navigate("emailLoginValidate", { + emailFlowId, + email: emailInput + }) + } else if (errors && errors.length > 0) { + // Handle specific error codes + const error = errors[0] + if (error.code === "TOO_MANY_REQUEST") { + setErrorMessage(LL.errors.tooManyRequestsPhoneCode()) + } else { + setErrorMessage(error.message || LL.errors.generic()) + } } else { - console.warn("no flow returned") + setErrorMessage(LL.errors.generic()) } } catch (err) { - console.error(err, "error in setEmailMutation") - if (isAxiosError(err)) { - console.log(err.message) // Gives you the basic error message - console.log(err.response?.data) // Gives you the response payload from the server - console.log(err.response?.status) // Gives you the HTTP status code - console.log(err.response?.headers) // Gives you the response headers - - // If the request was made but no response was received - if (!err.response) { - console.log(err.request) - } - setErrorMessage(err.message) + if (err instanceof Error) { + crashlytics().recordError(err) } + setErrorMessage(LL.errors.generic()) } finally { setLoading(false) } diff --git a/app/screens/email-login-screen/email-login-validate.tsx b/app/screens/email-login-screen/email-login-validate.tsx index cd3e3f380..0f87dce35 100644 --- a/app/screens/email-login-screen/email-login-validate.tsx +++ b/app/screens/email-login-screen/email-login-validate.tsx @@ -2,7 +2,8 @@ import React, { useCallback, useState } from "react" import { getAnalytics } from "@react-native-firebase/analytics" import { StackScreenProps } from "@react-navigation/stack" import { RootStackParamList } from "@app/navigation/stack-param-lists" -import axios, { isAxiosError } from "axios" +import { gql } from "@apollo/client" +import { useNewUserEmailRegistrationValidateMutation } from "@app/graphql/generated" // components import { CodeInput } from "@app/components/code-input" @@ -11,36 +12,49 @@ import { CodeInput } from "@app/components/code-input" import { useAppConfig } from "@app/hooks" import { useI18nContext } from "@app/i18n/i18n-react" +gql` + mutation newUserEmailRegistrationValidate($input: NewUserEmailRegistrationValidateInput!) { + newUserEmailRegistrationValidate(input: $input) { + errors { + message + code + } + authToken + totpRequired + } + } +` + type Props = StackScreenProps export const EmailLoginValidateScreen: React.FC = ({ navigation, route }) => { const { LL } = useI18nContext() - const { authUrl } = useAppConfig().appConfig.galoyInstance const { saveToken } = useAppConfig() const [errorMessage, setErrorMessage] = useState("") const [loading, setLoading] = useState(false) - const { emailLoginId, email } = route.params + const { emailFlowId, email } = route.params + + const [newUserEmailRegistrationValidate] = useNewUserEmailRegistrationValidateMutation() const send = useCallback( async (code: string) => { try { setLoading(true) - const url = `${authUrl}/auth/email/login` - - const res2 = await axios({ - url, - method: "POST", - data: { - code, - emailLoginId, + const { data } = await newUserEmailRegistrationValidate({ + variables: { + input: { + code, + emailFlowId, + }, }, }) - const authToken = res2.data.result.authToken - const totpRequired = res2.data.result.totpRequired + const authToken = data?.newUserEmailRegistrationValidate?.authToken + const totpRequired = data?.newUserEmailRegistrationValidate?.totpRequired + const errors = data?.newUserEmailRegistrationValidate?.errors if (authToken) { if (totpRequired) { @@ -55,33 +69,28 @@ export const EmailLoginValidateScreen: React.FC = ({ navigation, route }) routes: [{ name: "authenticationCheck" }], }) } + } else if (errors && errors.length > 0) { + const error = errors[0] + if (error.code === "INVALID_CODE") { + setErrorMessage(LL.errors.generic()) + } else if (error.code === "TOO_MANY_REQUEST") { + setErrorMessage(LL.errors.tooManyRequestsPhoneCode()) + } else { + setErrorMessage(error.message || LL.errors.generic()) + } } else { - throw new Error(LL.common.errorAuthToken()) + setErrorMessage(LL.errors.generic()) } } catch (err) { - console.error(err, "error axios") - if (isAxiosError(err)) { - console.log(err.message) // Gives you the basic error message - console.log(err.response?.data) // Gives you the response payload from the server - console.log(err.response?.status) // Gives you the HTTP status code - console.log(err.response?.headers) // Gives you the response headers - - // If the request was made but no response was received - if (!err.response) { - console.log(err.request) - } - - if (err.response?.data?.error) { - setErrorMessage(err.response?.data?.error) - } else { - setErrorMessage(err.message) - } + if (err instanceof Error) { + crashlytics().recordError(err) } + setErrorMessage(LL.errors.generic()) } finally { setLoading(false) } }, - [emailLoginId, navigation, authUrl, saveToken, LL], + [emailFlowId, navigation, saveToken, LL, newUserEmailRegistrationValidate], ) const header = LL.EmailLoginValidateScreen.header({ email }) diff --git a/app/screens/email-registration-screen/email-registration-validate.tsx b/app/screens/email-registration-screen/email-registration-validate.tsx index d8042cc4d..03eabb23b 100644 --- a/app/screens/email-registration-screen/email-registration-validate.tsx +++ b/app/screens/email-registration-screen/email-registration-validate.tsx @@ -5,10 +5,17 @@ import { useI18nContext } from "@app/i18n/i18n-react" import { RootStackParamList } from "@app/navigation/stack-param-lists" import { RouteProp, useNavigation } from "@react-navigation/native" import { StackNavigationProp } from "@react-navigation/stack" -import * as React from "react" import { useCallback, useState } from "react" import { Alert } from "react-native" +/** + * Validates the email verification code for existing users adding email to their account. + * + * For TRIAL accounts (AccountLevel.Zero), this mutation also handles the schema upgrade + * from 'username_password_deviceid_v0' to support email authentication. + * The backend will automatically upgrade TRIAL accounts to PERSONAL (AccountLevel.One) + * when email verification is successful. + */ gql` mutation userEmailRegistrationValidate($input: UserEmailRegistrationValidateInput!) { userEmailRegistrationValidate(input: $input) { @@ -33,14 +40,13 @@ type Props = { export const EmailRegistrationValidateScreen: React.FC = ({ route }) => { const navigation = useNavigation>() - - const [errorMessage, setErrorMessage] = React.useState("") - const { LL } = useI18nContext() + const [errorMessage, setErrorMessage] = useState("") + const [loading, setLoading] = useState(false) + const [emailVerify] = useUserEmailRegistrationValidateMutation() - const [loading, setLoading] = useState(false) const { emailRegistrationId, email } = route.params const send = useCallback( @@ -52,12 +58,14 @@ export const EmailRegistrationValidateScreen: React.FC = ({ route }) => { variables: { input: { code, emailRegistrationId } }, }) + // Handle validation errors (invalid code, expired code, etc.) if (res.data?.userEmailRegistrationValidate.errors) { const error = res.data.userEmailRegistrationValidate.errors[0]?.message // TODO: manage translation for errors setErrorMessage(error) } + // Email verification successful if (res.data?.userEmailRegistrationValidate.me?.email?.verified) { Alert.alert( LL.common.success(), @@ -66,7 +74,20 @@ export const EmailRegistrationValidateScreen: React.FC = ({ route }) => { { text: LL.common.ok(), onPress: () => { - navigation.navigate("settings") + /** + * Use navigation.reset() instead of navigation.navigate() to: + * 1. Clear the navigation stack + * 2. Force re-authentication via authenticationCheck + * 3. Refresh all user data (including account level) + * + * This is critical for TRIAL accounts, as the backend upgrades them + * to PERSONAL (AccountLevel.One) during email verification. + * The reset ensures the app displays the updated account level. + */ + navigation.reset({ + index: 0, + routes: [{ name: "authenticationCheck" }], + }) }, }, ],