From ebba764906ec12bc8555976c296f574a60e8f096 Mon Sep 17 00:00:00 2001 From: Ken-ichi Date: Thu, 19 Dec 2024 12:24:16 -0500 Subject: [PATCH] feat: sign in with Google (#2579) Closes #2515 --- env.example | 4 + ios/Podfile.lock | 31 +++- ios/iNaturalistReactNative/Info.plist | 12 +- jest.config.ts | 1 + package-lock.json | 20 +++ package.json | 1 + src/components/LoginSignUp/LoginForm.tsx | 90 +--------- .../LoginSignUp/loginFormHelpers.ts | 158 ++++++++++++++++++ .../SharedComponents/Buttons/Button.tsx | 2 +- src/i18n/l10n/en.ftl | 5 + src/i18n/l10n/en.ftl.json | 4 + src/i18n/strings.ftl | 5 + 12 files changed, 248 insertions(+), 85 deletions(-) create mode 100644 src/components/LoginSignUp/loginFormHelpers.ts diff --git a/env.example b/env.example index 4b4e89d13..05e53171c 100644 --- a/env.example +++ b/env.example @@ -36,3 +36,7 @@ IOS_PROVISIONING_PROFILE_NAME="provisioning profile name" IOS_SHARE_BUNDLE_ID="share bundle ID" IOS_SHARE_PROVISIONING_PROFILE_NAME="share provisioning profile name" SLACK_URL="Slack webhook URL" + +# Third-party Sign in +GOOGLE_WEB_CLIENT_ID=your-google-web-client-id +GOOGLE_IOS_CLIENT_ID=your-google-ios-client-id diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 79cce0d5f..db0062cb0 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,4 +1,10 @@ PODS: + - AppAuth (1.7.6): + - AppAuth/Core (= 1.7.6) + - AppAuth/ExternalUserAgent (= 1.7.6) + - AppAuth/Core (1.7.6) + - AppAuth/ExternalUserAgent (1.7.6): + - AppAuth/Core - boost (1.83.0) - BVLinearGradient (2.8.3): - React-Core @@ -21,6 +27,14 @@ PODS: - ReactCommon/turbomodule/core (= 0.73.7) - fmt (6.2.1) - glog (0.3.5) + - GoogleSignIn (7.1.0): + - AppAuth (< 2.0, >= 1.7.3) + - GTMAppAuth (< 5.0, >= 4.1.1) + - GTMSessionFetcher/Core (~> 3.3) + - GTMAppAuth (4.1.1): + - AppAuth/Core (~> 1.7) + - GTMSessionFetcher/Core (< 4.0, >= 3.3) + - GTMSessionFetcher/Core (3.5.0) - hermes-engine (0.73.7): - hermes-engine/Pre-built (= 0.73.7) - hermes-engine/Pre-built (0.73.7) @@ -1151,6 +1165,9 @@ PODS: - glog - RCT-Folly (= 2022.05.16.00) - React-Core + - RNGoogleSignin (13.1.0): + - GoogleSignIn (~> 7.1) + - React-Core - RNLocalize (3.1.0): - React-Core - RNPermissions (4.1.5): @@ -1276,6 +1293,7 @@ DEPENDENCIES: - "RNFlashList (from `../node_modules/@shopify/flash-list`)" - RNFS (from `../node_modules/react-native-fs`) - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) + - "RNGoogleSignin (from `../node_modules/@react-native-google-signin/google-signin`)" - RNLocalize (from `../node_modules/react-native-localize`) - RNPermissions (from `../node_modules/react-native-permissions`) - RNReanimated (from `../node_modules/react-native-reanimated`) @@ -1290,7 +1308,11 @@ DEPENDENCIES: SPEC REPOS: trunk: + - AppAuth - fmt + - GoogleSignIn + - GTMAppAuth + - GTMSessionFetcher - libevent - MMKV - MMKVCore @@ -1461,6 +1483,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-fs" RNGestureHandler: :path: "../node_modules/react-native-gesture-handler" + RNGoogleSignin: + :path: "../node_modules/@react-native-google-signin/google-signin" RNLocalize: :path: "../node_modules/react-native-localize" RNPermissions: @@ -1485,6 +1509,7 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: + AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73 boost: d3f49c53809116a5d38da093a8aa78bf551aed09 BVLinearGradient: cb006ba232a1f3e4f341bb62c42d1098c284da70 DoubleConversion: fea03f2699887d960129cc54bba7e52542b6f953 @@ -1493,6 +1518,9 @@ SPEC CHECKSUMS: FBReactNativeSpec: 40b791f4a1df779e7e4aa12c000319f4f216d40a fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2 + GoogleSignIn: d4281ab6cf21542b1cfaff85c191f230b399d2db + GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 hermes-engine: 39589e9c297d024e90fe68f6830ff86c4e01498a libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 MMKV: f7d1d5945c8765f97f39c3d121f353d46735d801 @@ -1571,6 +1599,7 @@ SPEC CHECKSUMS: RNFlashList: 818cb6cff1f47cabe1acd6298c98af1a39b9b18c RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8 RNGestureHandler: e262eeb792addec0705a116456f210ee1be0dcd0 + RNGoogleSignin: ba93c1137f8d5cebdd39b04f493fd212ddf5ecd6 RNLocalize: 080849cb8a824d9f759b8a5ae00c8321d46dbed0 RNPermissions: f14c20f4eb7a20fff611ad9f467da7bb5872ac4f RNReanimated: b158619f02f1384a5be9e1203b58e0e4a80407d7 @@ -1586,4 +1615,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: eff4b75123af5d6680139a78c055b44ad37c269b -COCOAPODS: 1.15.2 +COCOAPODS: 1.14.3 diff --git a/ios/iNaturalistReactNative/Info.plist b/ios/iNaturalistReactNative/Info.plist index 6accb2d20..d4f1074da 100644 --- a/ios/iNaturalistReactNative/Info.plist +++ b/ios/iNaturalistReactNative/Info.plist @@ -30,6 +30,14 @@ inaturalistmobile + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + com.googleusercontent.apps.796686868523-4m5ne09fa17p5me04s1qdlsnci9e8qeo + + CFBundleVersion 132 @@ -55,6 +63,8 @@ Add existing sounds to your observations. NSCameraUsageDescription iNaturalist Next uses the camera to add photos to your observations of nature. + NSHumanReadableCopyright + © iNaturalist NSLocationAlwaysAndWhenInUseUsageDescription We do not intentionally request this permission. If you are seeing this, please take a screenshot and email it to help@inaturalist.org NSLocationWhenInUseUsageDescription @@ -94,7 +104,5 @@ UIViewControllerBasedStatusBarAppearance - NSHumanReadableCopyright - © iNaturalist diff --git a/jest.config.ts b/jest.config.ts index 218f919bf..89c96887d 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -12,6 +12,7 @@ const config: Config = { preset: "react-native", setupFiles: [ "./node_modules/react-native-gesture-handler/jestSetup.js", + "./node_modules/@react-native-google-signin/google-signin/jest/build/jest/setup.js", "/tests/jest.setup.js" ], globalSetup: "/tests/jest.globalSetup.js", diff --git a/package-lock.json b/package-lock.json index f9466edc4..a5108b739 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@react-native-community/hooks": "^3.0.0", "@react-native-community/netinfo": "^11.3.1", "@react-native-community/slider": "^4.5.0", + "@react-native-google-signin/google-signin": "^13.1.0", "@react-native-picker/picker": "^2.7.2", "@react-navigation/bottom-tabs": "^6.6.1", "@react-navigation/drawer": "^6.6.15", @@ -4893,6 +4894,25 @@ "resolved": "https://registry.npmjs.org/@react-native-community/slider/-/slider-4.5.0.tgz", "integrity": "sha512-pyUvNTvu5IfCI5abzqRfO/dd3A009RC66RXZE6t0gyOwI/j0QDlq9VZRv3rjkpuIvNTnsYj+m5BHlh0DkSYUyA==" }, + "node_modules/@react-native-google-signin/google-signin": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/@react-native-google-signin/google-signin/-/google-signin-13.1.0.tgz", + "integrity": "sha512-C2/sqb0/s0c+Dwc/mykASZsRuHxGqn7SFrCxCY9D8p8IOQO05haInhCc7lzraJshRixGva5c/4usQZ71HMYSEQ==", + "peerDependencies": { + "expo": ">=50.0.0", + "react": "*", + "react-dom": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/@react-native-picker/picker": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.7.2.tgz", diff --git a/package.json b/package.json index a615b713c..64f96610e 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@react-native-community/hooks": "^3.0.0", "@react-native-community/netinfo": "^11.3.1", "@react-native-community/slider": "^4.5.0", + "@react-native-google-signin/google-signin": "^13.1.0", "@react-native-picker/picker": "^2.7.2", "@react-navigation/bottom-tabs": "^6.6.1", "@react-navigation/drawer": "^6.6.15", diff --git a/src/components/LoginSignUp/LoginForm.tsx b/src/components/LoginSignUp/LoginForm.tsx index 49e4f2ecb..af6d978dd 100644 --- a/src/components/LoginSignUp/LoginForm.tsx +++ b/src/components/LoginSignUp/LoginForm.tsx @@ -1,4 +1,4 @@ -import { appleAuth, AppleButton, AppleError } from "@invertase/react-native-apple-authentication"; +import { AppleButton } from "@invertase/react-native-apple-authentication"; import { RouteProp, useNavigation, useRoute } from "@react-navigation/native"; import classnames from "classnames"; import { @@ -9,21 +9,16 @@ import { t } from "i18next"; import { RealmContext } from "providers/contexts.ts"; import React, { useEffect, useRef, useState } from "react"; import { - Alert, Platform, TextInput, TouchableWithoutFeedback } from "react-native"; -import Realm from "realm"; -import { log } from "sharedHelpers/logger"; import useKeyboardInfo from "sharedHooks/useKeyboardInfo"; import colors from "styles/tailwindColors"; -import { - authenticateUser, - authenticateUserByAssertion -} from "./AuthenticationService"; +import { authenticateUser } from "./AuthenticationService"; import Error from "./Error"; +import { signInWithApple, signInWithGoogle } from "./loginFormHelpers"; import LoginSignUpInputField from "./LoginSignUpInputField"; const { useRealm } = RealmContext; @@ -42,85 +37,12 @@ type ParamList = { LoginFormParams: LoginFormParams } -interface AppleAuthError { - code: AppleError; -} - const APPLE_BUTTON_STYLE = { maxWidth: 500, height: 45, // You must specify a height marginTop: 10 }; -const logger = log.extend( "LoginForm" ); - -function showSignInWithAppleFailed() { - Alert.alert( - t( "Sign-in-with-Apple-Failed" ), - t( "If-you-have-an-existing-account-try-sign-in-reset" ) - ); -} - -async function signInWithApple( realm: Realm ) { - // Request sign in w/ apple. This should pop up some system UI for signing - // in - let appleAuthRequestResponse; - try { - appleAuthRequestResponse = await appleAuth.performRequest( { - requestedOperation: appleAuth.Operation.LOGIN, - // Note: it appears putting FULL_NAME first is important, see issue #293 - requestedScopes: [appleAuth.Scope.FULL_NAME, appleAuth.Scope.EMAIL] - } ); - } catch ( appleAuthRequestError ) { - if ( ( appleAuthRequestError as AppleAuthError ).code === appleAuth.Error.CANCELED ) { - // The user canceled sign in, no need to log - return false; - } - logger.error( "Apple auth request failed", appleAuthRequestError ); - showSignInWithAppleFailed(); - return false; - } - - // Check if auth was successful - const credentialState = await appleAuth.getCredentialStateForUser( - appleAuthRequestResponse.user - ); - - // If it was, send the identity token to iNat for verification and iNat - // auth - if ( credentialState === appleAuth.State.AUTHORIZED ) { - // Note that we're supporting an irregular assertion that is a JSON object - // w/ the actual identityToken (which is itself a JSON Web Token), and - // the user's name, which we only get from Apple the *first* time that - // grant permission, so the server cannot access it when it verifies the - // token - const assertion = JSON.stringify( { - id_token: appleAuthRequestResponse.identityToken, - name: t( "apple-full-name", { - namePrefix: appleAuthRequestResponse?.fullName?.namePrefix, - givenName: appleAuthRequestResponse?.fullName?.givenName, - middleName: appleAuthRequestResponse?.fullName?.middleName, - nickname: appleAuthRequestResponse?.fullName?.nickname, - familyName: appleAuthRequestResponse?.fullName?.familyName, - nameSuffix: appleAuthRequestResponse?.fullName?.nameSuffix - } ) - } ); - try { - await authenticateUserByAssertion( "apple", assertion, realm ); - } catch ( authenticateUserByAssertionError ) { - logger.error( "Assertion with Apple token failed", authenticateUserByAssertionError ); - showSignInWithAppleFailed(); - return false; - } - return true; - } - // We only get here if the user does not grant access... I think, so no need - // to log an error - logger.info( "Apple auth failed, credentialState: ", credentialState ); - showSignInWithAppleFailed(); - return false; -} - const LoginForm = ( { hideFooter }: Props ) => { @@ -276,6 +198,12 @@ const LoginForm = ( { onPress={() => logIn( async ( ) => signInWithApple( realm ) )} /> ) } +