diff --git a/assets/images/signIn/apple-logo.svg b/assets/images/signIn/apple-logo.svg new file mode 100644 index 000000000000..4e428fc41aed --- /dev/null +++ b/assets/images/signIn/apple-logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/images/signIn/google-logo.svg b/assets/images/signIn/google-logo.svg new file mode 100644 index 000000000000..ebdd4be8cade --- /dev/null +++ b/assets/images/signIn/google-logo.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/contributingGuides/TESTING_APPLE_GOOGLE_SIGNIN.md b/contributingGuides/TESTING_APPLE_GOOGLE_SIGNIN.md new file mode 100644 index 000000000000..4a5ad4b63e24 --- /dev/null +++ b/contributingGuides/TESTING_APPLE_GOOGLE_SIGNIN.md @@ -0,0 +1,128 @@ +# Testing Apple/Google sign-in + +Due to some technical constraints, Apple and Google sign-in may require additional configuration to be able to work in the development environment as expected. This document describes any additional steps for each platform. + +## Apple + +### Web + +The Sign in with Apple process will break after the user signs in if the pop-up process is not started from a page at an HTTPS domain registered with Apple. To fix this, you could make a new configuration with your own HTTPS domain, but then the Apple configuration won't match that of Expensify's backend. + +So to be able to test this, we have two parts: +1. Create a valid Sign in with Apple token using valid configuration for the Expensify app, by creating and intercepting one on Android +2. Host the development web app at an HTTPS domain using SSH tunneling, and in the web app use a custom Apple config wiht this HTTPS domain registered + +Requirements: +1. Authorization on an Apple Development account or team to create new Service IDs +2. A paid ngrok.io account, to be able to use custom subdomains, or use serveo.net for a free alternative (must be signed in to use custom subdomains) + +#### Generate the token to use + +On an Android build, alter the `AppleSignIn` component to log the token generated, instead of sending it to the Expensify API: + +```js +// .then((token) => Session.beginAppleSignIn(token)) + .then((token) => console.log("TOKEN: ", token)) +``` + +If you need to check that you received the correct data, check it on [jwt.io](https://jwt.io), which will decode it if it is a valid JWT token. It will also show when the token expires. + +Add this token to a `.env` file at the root of the project: + +``` +ASI_TOKEN_OVERRIDE="..." +``` + +#### Configure the SSH tunneling + +You can use any SSH tunneling service that allows you to configure custom subdomains so that we have a consistent address to use. We'll use ngrok in these examples, but ngrok requires a paid account for this. If you need a free option, try serveo.net. + +After you've set ngrok up to be able to run on your machine (requires configuring a key with the command line tool), test hosting the web app on a custom subdomain. This example assumes the development web app is running at `localhost:8080`: + +``` +ngrok http 8080 --host-header="localhost:8080" --subdomain=mysubdomain +``` + +The `--host-header` flag is there to avoid webpack errors with header validation. In addition, add `allowedHosts: 'all'` to the dev server config in `webpack.dev.js`: + +```js +devServer: { + ..., + allowedHosts: 'all', +} +``` + +#### Configure Apple Service ID + +Now that you have an HTTPS address to use, you can create an Apple Service ID configuration that will work with it. + +1. Create a new app ID on your Apple development team that can be used to test this, following the instructions [here](https://github.com/invertase/react-native-apple-authentication/blob/main/docs/INITIAL_SETUP.md). +2. Create a new service ID following the instructions [here](https://github.com/invertase/react-native-apple-authentication/blob/main/docs/ANDROID_EXTRA.md). For allowed domains, enter your SSH tunnel address (e.g., `https://mysubdomain.ngrok.io`), and for redirect URLs, just make up an endpoint, it's never actually invoked (e.g., `mysubdomain.ngrok.io/appleauth`). + +Notes: +- Depending on your Apple account configuration, you may need additional permissions to access some of the features described in the instructions above. +- While the Apple Sign In configuration requires a `clientId`, the Apple Developer console calls this a `Service ID`. + +Finally, edit `.env` to use your client (service) ID and redirect URL config: + +``` +ASI_CLIENTID_OVERRIDE=com.example.test +ASI_REDIRECTURI_OVERRIDE=https://mysubdomain.ngrok.io/appleauth +``` + +#### Run the app + +Remember that you will need to restart the web server if you make a change to the `.env` file. + +### Desktop + +Desktop will require the same configuration, with these additional steps: + +#### Configure web app URL in .env + +Add `NEW_EXPENSIFY_URL` to .env, and set it to the HTTPS URL where the web app can be found, for example: + +``` +NEW_EXPENSIFY_URL=https://subdomain.ngrok.io +``` + +This is required because the desktop app needs to know the address of the web app, and must open it at +the HTTPS domain configured to work with Sign in with Apple. + +#### Set Environment to something other than "Development" + +The DeepLinkWrapper component will not handle deep links in the development environment. To be able to test deep linking, you must set the environment to something other than "Development". + +Within the `.env` file, set `envName` to something other than "Development", for example: + +``` +envName=Staging +``` + +Alternatively, within the `DeepLinkWrapper/index.website.js` file you can set the `CONFIG.ENVIRONMENT` to something other than "Development". + +#### Handle deep links in dev on MacOS + +If developing on MacOS, the development desktop app can't handle deeplinks correctly. To be able to test deeplinking back to the app, follow these steps: + +1. Create a "real" build of the desktop app, which can handle deep links, open the build folder, and install the dmg there: + +``` +npm run desktop-build --publish=never +open desktop-build +# Then double-click "NewExpensify.dmg" in Finder window +``` + +2. Even with this build, the deep link may not be handled by the correct app, as the development Electron config seems to intercept it sometimes. To manage this, install [SwiftDefaultApps](https://github.com/Lord-Kamina/SwiftDefaultApps), which adds a preference pane that can be used to configure which app should handle deep links. + +## Google + +### Web + +#### Port requirements + +Google allows the web app to be hosted at localhost, but according to the current Google console configuration, it must be hosted on port 8080. + +#### Visual differences + +Google's web button has a visible rectangular iframe around it when the app is running at `localhost`. When the app is hosted at an HTTPS address, this iframe is not shown. diff --git a/ios/NewExpensify/Chat.entitlements b/ios/NewExpensify/Chat.entitlements index 33bb7f9feff8..5300e35eadbf 100644 --- a/ios/NewExpensify/Chat.entitlements +++ b/ios/NewExpensify/Chat.entitlements @@ -4,6 +4,10 @@ aps-environment development + com.apple.developer.applesignin + + Default + com.apple.developer.associated-domains applinks:new.expensify.com diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 9765bc89e635..f8354f794ab8 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -629,6 +629,8 @@ PODS: - React-jsi (= 0.71.2-alpha.3) - React-logger (= 0.71.2-alpha.3) - React-perflogger (= 0.71.2-alpha.3) + - RNAppleAuthentication (2.2.2): + - React-Core - RNCAsyncStorage (1.17.11): - React-Core - RNCClipboard (1.5.1): @@ -800,6 +802,7 @@ DEPENDENCIES: - React-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`) - React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`) - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) + - "RNAppleAuthentication (from `../node_modules/@invertase/react-native-apple-authentication`)" - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" - "RNCClipboard (from `../node_modules/@react-native-community/clipboard`)" - "RNCPicker (from `../node_modules/@react-native-picker/picker`)" @@ -980,6 +983,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/runtimeexecutor" ReactCommon: :path: "../node_modules/react-native/ReactCommon" + RNAppleAuthentication: + :path: "../node_modules/@invertase/react-native-apple-authentication" RNCAsyncStorage: :path: "../node_modules/@react-native-async-storage/async-storage" RNCClipboard: @@ -1114,6 +1119,7 @@ SPEC CHECKSUMS: React-RCTVibration: 53291ee889eb2e1558a1507168af310926ad1ce1 React-runtimeexecutor: 2c2c364acf7d90ec4d503e9f97b83683e040de08 ReactCommon: 470b1793330b7254a68741f071c5ae432a0a25d6 + RNAppleAuthentication: 0571c08da8c327ae2afc0261b48b4a515b0286a6 RNCAsyncStorage: 8616bd5a58af409453ea4e1b246521bb76578d60 RNCClipboard: 41d8d918092ae8e676f18adada19104fa3e68495 RNCPicker: 0b65be85fe7954fbb2062ef079e3d1cde252d888 diff --git a/package-lock.json b/package-lock.json index 484580d4c86a..85390207ffd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@formatjs/intl-numberformat": "^8.5.0", "@formatjs/intl-pluralrules": "^5.2.2", "@gorhom/portal": "^1.0.14", + "@invertase/react-native-apple-authentication": "^2.2.2", "@oguzhnatly/react-native-image-manipulator": "github:Expensify/react-native-image-manipulator#5cdae3d4455b03a04c57f50be3863e2fe6c92c52", "@onfido/react-native-sdk": "7.4.0", "@react-native-async-storage/async-storage": "^1.17.10", @@ -3141,6 +3142,11 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@invertase/react-native-apple-authentication": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@invertase/react-native-apple-authentication/-/react-native-apple-authentication-2.2.2.tgz", + "integrity": "sha512-uNZcUn9WbAQP5zSOFXI1+kEUokLwZG9imUulFdt5t22CU2ozGq6zyPm+BAVVg8D5eUUXduX/dJFhbuOpJxiEhQ==" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -45694,6 +45700,11 @@ "version": "1.2.1", "dev": true }, + "@invertase/react-native-apple-authentication": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@invertase/react-native-apple-authentication/-/react-native-apple-authentication-2.2.2.tgz", + "integrity": "sha512-uNZcUn9WbAQP5zSOFXI1+kEUokLwZG9imUulFdt5t22CU2ozGq6zyPm+BAVVg8D5eUUXduX/dJFhbuOpJxiEhQ==" + }, "@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", diff --git a/package.json b/package.json index 7c7939672e0e..de77e380b2ca 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@formatjs/intl-numberformat": "^8.5.0", "@formatjs/intl-pluralrules": "^5.2.2", "@gorhom/portal": "^1.0.14", + "@invertase/react-native-apple-authentication": "^2.2.2", "@oguzhnatly/react-native-image-manipulator": "github:Expensify/react-native-image-manipulator#5cdae3d4455b03a04c57f50be3863e2fe6c92c52", "@onfido/react-native-sdk": "7.4.0", "@react-native-async-storage/async-storage": "^1.17.10", diff --git a/src/CONST.js b/src/CONST.js index b7a730821fdb..cb4dd97547d0 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -474,6 +474,16 @@ const CONST = { // Use Environment.getEnvironmentURL to get the complete URL with port number DEV_NEW_EXPENSIFY_URL: 'http://localhost:', + SIGN_IN_FORM_WIDTH: 300, + + APPLE_SIGN_IN_SERVICE_ID: 'com.chat.expensify.chat.AppleSignIn', + APPLE_SIGN_IN_REDIRECT_URI: 'https://new.expensify.com/appleauth', + + SIGN_IN_METHOD: { + APPLE: 'Apple', + GOOGLE: 'Google', + }, + OPTION_TYPE: { REPORT: 'report', PERSONAL_DETAIL: 'personalDetail', diff --git a/src/Expensify.js b/src/Expensify.js index c85c2862e96e..845c26a21adb 100644 --- a/src/Expensify.js +++ b/src/Expensify.js @@ -4,7 +4,6 @@ import PropTypes from 'prop-types'; import React, {useCallback, useState, useEffect, useRef, useLayoutEffect, useMemo} from 'react'; import {AppState, Linking} from 'react-native'; import Onyx, {withOnyx} from 'react-native-onyx'; - import * as Report from './libs/actions/Report'; import BootSplash from './libs/BootSplash'; import * as ActiveClientManager from './libs/ActiveClientManager'; @@ -24,11 +23,11 @@ import withLocalize, {withLocalizePropTypes} from './components/withLocalize'; import * as User from './libs/actions/User'; import NetworkConnection from './libs/NetworkConnection'; import Navigation from './libs/Navigation/Navigation'; -import DeeplinkWrapper from './components/DeeplinkWrapper'; import PopoverReportActionContextMenu from './pages/home/report/ContextMenu/PopoverReportActionContextMenu'; import * as ReportActionContextMenu from './pages/home/report/ContextMenu/ReportActionContextMenu'; import SplashScreenHider from './components/SplashScreenHider'; import KeyboardShortcutsModal from './components/KeyboardShortcutsModal'; +import AppleAuthWrapper from './components/SignInButtons/AppleAuthWrapper'; import EmojiPicker from './components/EmojiPicker/EmojiPicker'; import * as EmojiPickerAction from './libs/actions/EmojiPickerAction'; @@ -183,7 +182,7 @@ function Expensify(props) { } return ( - + <> {shouldInit && ( <> @@ -206,6 +205,7 @@ function Expensify(props) { )} + {hasAttemptedToOpenPublicRoom && ( } - + ); } diff --git a/src/ROUTES.js b/src/ROUTES.js index 333a5e527112..d32deaa63ab0 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -137,6 +137,8 @@ export default { getGetAssistanceRoute: (taskID) => `get-assistance/${taskID}`, UNLINK_LOGIN: 'u/:accountID/:validateCode', + APPLE_SIGN_IN: 'sign-in-with-apple', + // This is a special validation URL that will take the user to /workspace/new after validation. This is used // when linking users from e.com in order to share a session in this app. ENABLE_PAYMENTS: 'enable-payments', diff --git a/src/components/Icon/Expensicons.js b/src/components/Icon/Expensicons.js index 3b1470197f73..4f40230b03f0 100644 --- a/src/components/Icon/Expensicons.js +++ b/src/components/Icon/Expensicons.js @@ -3,6 +3,7 @@ import AdminRoomAvatar from '../../../assets/images/avatars/admin-room.svg'; import Android from '../../../assets/images/android.svg'; import AnnounceRoomAvatar from '../../../assets/images/avatars/announce-room.svg'; import Apple from '../../../assets/images/apple.svg'; +import AppleLogo from '../../../assets/images/signIn/apple-logo.svg'; import ArrowRight from '../../../assets/images/arrow-right.svg'; import ArrowRightLong from '../../../assets/images/arrow-right-long.svg'; import ArrowsUpDown from '../../../assets/images/arrows-updown.svg'; @@ -124,6 +125,7 @@ export { Android, AnnounceRoomAvatar, Apple, + AppleLogo, ArrowRight, ArrowRightLong, ArrowsUpDown, diff --git a/src/components/SignInButtons/AppleAuthWrapper/index.ios.js b/src/components/SignInButtons/AppleAuthWrapper/index.ios.js new file mode 100644 index 000000000000..280a71121bf2 --- /dev/null +++ b/src/components/SignInButtons/AppleAuthWrapper/index.ios.js @@ -0,0 +1,26 @@ +import {useEffect} from 'react'; +import appleAuth from '@invertase/react-native-apple-authentication'; +import * as Session from '../../../libs/actions/Session'; + +/** + * Apple Sign In wrapper for iOS + * revokes the session if the credential is revoked. + */ + +function AppleAuthWrapper() { + useEffect(() => { + if (!appleAuth.isSupported) { + return; + } + const listener = appleAuth.onCredentialRevoked(() => { + Session.signOut(); + }); + return () => { + listener.remove(); + }; + }, []); + + return null; +} + +export default AppleAuthWrapper; diff --git a/src/components/SignInButtons/AppleAuthWrapper/index.js b/src/components/SignInButtons/AppleAuthWrapper/index.js new file mode 100644 index 000000000000..7586d01f0213 --- /dev/null +++ b/src/components/SignInButtons/AppleAuthWrapper/index.js @@ -0,0 +1,5 @@ +function AppleAuthWrapper() { + return null; +} + +export default AppleAuthWrapper; diff --git a/src/components/SignInButtons/AppleSignIn/index.android.js b/src/components/SignInButtons/AppleSignIn/index.android.js new file mode 100644 index 000000000000..b49bd56e1a7d --- /dev/null +++ b/src/components/SignInButtons/AppleSignIn/index.android.js @@ -0,0 +1,58 @@ +import React from 'react'; +import {appleAuthAndroid} from '@invertase/react-native-apple-authentication'; +import Log from '../../../libs/Log'; +import IconButton from '../IconButton'; +import * as Session from '../../../libs/actions/Session'; +import CONST from '../../../CONST'; + +/** + * Apple Sign In Configuration for Android. + */ + +const config = { + clientId: CONST.APPLE_SIGN_IN_SERVICE_ID, + redirectUri: CONST.APPLE_SIGN_IN_REDIRECT_URI, + responseType: appleAuthAndroid.ResponseType.ALL, + scope: appleAuthAndroid.Scope.ALL, +}; + +/** + * Apple Sign In method for Android that returns authToken. + * @returns {Promise} + */ + +function appleSignInRequest() { + appleAuthAndroid.configure(config); + return appleAuthAndroid + .signIn() + .then((response) => response.id_token) + .catch((e) => { + throw e; + }); +} + +/** + * Apple Sign In button for Android. + * @returns {React.Component} + */ + +function AppleSignIn() { + const handleSignIn = () => { + appleSignInRequest() + .then((token) => Session.beginAppleSignIn(token)) + .catch((e) => { + if (e.message === appleAuthAndroid.Error.SIGNIN_CANCELLED) return null; + Log.error('Apple authentication failed', e); + }); + }; + return ( + + ); +} + +AppleSignIn.displayName = 'AppleSignIn'; + +export default AppleSignIn; diff --git a/src/components/SignInButtons/AppleSignIn/index.desktop.js b/src/components/SignInButtons/AppleSignIn/index.desktop.js new file mode 100644 index 000000000000..f6aeb180bb5b --- /dev/null +++ b/src/components/SignInButtons/AppleSignIn/index.desktop.js @@ -0,0 +1,31 @@ +import React from 'react'; +import {View} from 'react-native'; +import IconButton from '../IconButton'; +import CONFIG from '../../../CONFIG'; +import ROUTES from '../../../ROUTES'; +import styles from '../../../styles/styles'; +import CONST from '../../../CONST'; + +const appleSignInWebRouteForDesktopFlow = `${CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL}${ROUTES.APPLE_SIGN_IN}`; + +/** + * Apple Sign In button for desktop flow + * @returns {React.Component} + */ + +function AppleSignIn() { + return ( + + { + window.open(appleSignInWebRouteForDesktopFlow); + }} + provider={CONST.SIGN_IN_METHOD.APPLE} + /> + + ); +} + +AppleSignIn.displayName = 'AppleSignIn'; + +export default AppleSignIn; diff --git a/src/components/SignInButtons/AppleSignIn/index.ios.js b/src/components/SignInButtons/AppleSignIn/index.ios.js new file mode 100644 index 000000000000..735b80a2e6ef --- /dev/null +++ b/src/components/SignInButtons/AppleSignIn/index.ios.js @@ -0,0 +1,56 @@ +import React from 'react'; +import appleAuth from '@invertase/react-native-apple-authentication'; +import Log from '../../../libs/Log'; +import IconButton from '../IconButton'; +import * as Session from '../../../libs/actions/Session'; +import CONST from '../../../CONST'; + +/** + * Apple Sign In method for iOS that returns identityToken. + * @returns {Promise} + */ + +function appleSignInRequest() { + return appleAuth + .performRequest({ + requestedOperation: appleAuth.Operation.LOGIN, + + // FULL_NAME must come first, see https://github.com/invertase/react-native-apple-authentication/issues/293. + requestedScopes: [appleAuth.Scope.FULL_NAME, appleAuth.Scope.EMAIL], + }) + .then((response) => + appleAuth.getCredentialStateForUser(response.user).then((credentialState) => { + if (credentialState !== appleAuth.State.AUTHORIZED) { + Log.error('Authentication failed. Original response: ', response); + throw new Error('Authentication failed'); + } + return response.identityToken; + }), + ); +} + +/** + * Apple Sign In button for iOS. + * @returns {React.Component} + */ + +function AppleSignIn() { + const handleSignIn = () => { + appleSignInRequest() + .then((token) => Session.beginAppleSignIn(token)) + .catch((e) => { + if (e.code === appleAuth.Error.CANCELED) return null; + Log.error('Apple authentication failed', e); + }); + }; + return ( + + ); +} + +AppleSignIn.displayName = 'AppleSignIn'; + +export default AppleSignIn; diff --git a/src/components/SignInButtons/AppleSignIn/index.website.js b/src/components/SignInButtons/AppleSignIn/index.website.js new file mode 100644 index 000000000000..8865587280fa --- /dev/null +++ b/src/components/SignInButtons/AppleSignIn/index.website.js @@ -0,0 +1,151 @@ +import React, {useEffect, useState} from 'react'; +import PropTypes from 'prop-types'; +import Config from 'react-native-config'; +import get from 'lodash/get'; +import getUserLanguage from '../GetUserLanguage'; +import * as Session from '../../../libs/actions/Session'; +import Log from '../../../libs/Log'; +import * as Environment from '../../../libs/Environment/Environment'; +import CONST from '../../../CONST'; +import withNavigationFocus from '../../withNavigationFocus'; + +// react-native-config doesn't trim whitespace on iOS for some reason so we +// add a trim() call to lodashGet here to prevent headaches. +const lodashGet = (config, key, defaultValue) => get(config, key, defaultValue).trim(); + +const requiredPropTypes = { + isDesktopFlow: PropTypes.bool.isRequired, +}; + +const singletonPropTypes = { + ...requiredPropTypes, + + // From withNavigationFocus + isFocused: PropTypes.bool.isRequired, +}; + +const propTypes = { + // Prop to indicate if this is the desktop flow or not. + isDesktopFlow: PropTypes.bool, +}; +const defaultProps = { + isDesktopFlow: false, +}; + +/** + * Apple Sign In Configuration for Web. + */ +const config = { + clientId: lodashGet(Config, 'ASI_CLIENTID_OVERRIDE', CONST.APPLE_SIGN_IN_SERVICE_ID), + scope: 'name email', + // never used, but required for configuration + redirectURI: lodashGet(Config, 'ASI_REDIRECTURI_OVERRIDE', CONST.APPLE_SIGN_IN_REDIRECT_URI), + state: '', + nonce: '', + usePopup: true, +}; + +/** + * Apple Sign In success and failure listeners. + */ + +const successListener = (event) => { + const token = !Environment.isDevelopment() ? event.detail.id_token : lodashGet(Config, 'ASI_TOKEN_OVERRIDE', event.detail.id_token); + Session.beginAppleSignIn(token); +}; + +const failureListener = (event) => { + if (!event.detail || event.detail.error === 'popup_closed_by_user') return null; + Log.warn(`Apple sign-in failed: ${event.detail}`); +}; + +/** + * Apple Sign In button for Web. + * @returns {React.Component} + */ + +function AppleSignInDiv({isDesktopFlow}) { + useEffect(() => { + // `init` renders the button, so it must be called after the div is + // first mounted. + window.AppleID.auth.init(config); + }, []); + // Result listeners need to live within the focused item to avoid duplicate + // side effects on success and failure. + React.useEffect(() => { + document.addEventListener('AppleIDSignInOnSuccess', successListener); + document.addEventListener('AppleIDSignInOnFailure', failureListener); + return () => { + document.removeEventListener('AppleIDSignInOnSuccess', successListener); + document.removeEventListener('AppleIDSignInOnFailure', failureListener); + }; + }, []); + + return isDesktopFlow ? ( +
+ ) : ( +
+ ); +} + +AppleSignInDiv.propTypes = requiredPropTypes; + +// The Sign in with Apple script may fail to render button if there are multiple +// of these divs present in the app, as it matches based on div id. So we'll +// only mount the div when it should be visible. +function SingletonAppleSignInButton({isFocused, isDesktopFlow}) { + if (!isFocused) { + return null; + } + return ; +} + +SingletonAppleSignInButton.propTypes = singletonPropTypes; + +// withNavigationFocus is used to only render the button when it is visible. +const SingletonAppleSignInButtonWithFocus = withNavigationFocus(SingletonAppleSignInButton); + +function AppleSignIn({isDesktopFlow}) { + const [scriptLoaded, setScriptLoaded] = useState(false); + useEffect(() => { + if (window.appleAuthScriptLoaded) return; + + const localeCode = getUserLanguage(); + const script = document.createElement('script'); + script.src = `https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1//${localeCode}/appleid.auth.js`; + script.async = true; + script.onload = () => setScriptLoaded(true); + + document.body.appendChild(script); + }, []); + + if (scriptLoaded === false) { + return null; + } + + return ; +} + +AppleSignIn.propTypes = propTypes; +AppleSignIn.defaultProps = defaultProps; + +export default withNavigationFocus(AppleSignIn); diff --git a/src/components/SignInButtons/GetUserLanguage.js b/src/components/SignInButtons/GetUserLanguage.js new file mode 100644 index 000000000000..7f45f1fa1e89 --- /dev/null +++ b/src/components/SignInButtons/GetUserLanguage.js @@ -0,0 +1,14 @@ +const localeCodes = { + en: 'en_US', + es: 'es_ES', +}; + +const GetUserLanguage = () => { + const userLanguage = navigator.language || navigator.userLanguage; + const languageCode = userLanguage.split('-')[0]; + return localeCodes[languageCode] || 'en_US'; +}; + +GetUserLanguage.displayName = 'GetUserLanguage'; + +export default GetUserLanguage; diff --git a/src/components/SignInButtons/IconButton.js b/src/components/SignInButtons/IconButton.js new file mode 100644 index 000000000000..18113c4c5814 --- /dev/null +++ b/src/components/SignInButtons/IconButton.js @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styles from '../../styles/styles'; +import PressableWithoutFeedback from '../Pressable/PressableWithoutFeedback'; +import withLocalize, {withLocalizePropTypes} from '../withLocalize'; +import CONST from '../../CONST'; +import * as Expensicons from '../Icon/Expensicons'; +import Icon from '../Icon'; + +const propTypes = { + /** The on press method */ + onPress: PropTypes.func, + /** Which provider you are using to sign in */ + provider: PropTypes.string.isRequired, + + ...withLocalizePropTypes, +}; + +const defaultProps = { + onPress: () => {}, +}; + +const providerData = { + [CONST.SIGN_IN_METHOD.APPLE]: { + icon: Expensicons.AppleLogo, + accessibilityLabel: 'common.signInWithApple', + }, +}; + +function IconButton({onPress, translate, provider}) { + return ( + + + + ); +} + +IconButton.displayName = 'IconButton'; +IconButton.propTypes = propTypes; +IconButton.defaultProps = defaultProps; + +export default withLocalize(IconButton); diff --git a/src/languages/en.js b/src/languages/en.js index b5172917c847..aed209dc0944 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -34,6 +34,9 @@ export default { view: 'View', not: 'Not', signIn: 'Sign in', + signInWithGoogle: 'Sign in with Google', + signInWithApple: 'Sign in with Apple', + signInWith: 'Sign in with', continue: 'Continue', firstName: 'First name', lastName: 'Last name', @@ -238,6 +241,15 @@ export default { body: 'Welcome to the future of Expensify, your new go-to place for financial collaboration with friends and teammates alike.', }, }, + thirdPartySignIn: { + alreadySignedIn: ({email}) => `You are already signed in as ${email}.`, + goBackMessage: ({provider}) => `Don't want to sign in with ${provider}?`, + continueWithMyCurrentSession: 'Continue with my current session', + redirectToDesktopMessage: "We'll redirect you to the desktop app once you finish signing in.", + signInAgreementMessage: 'By logging in, you agree to the', + termsOfService: 'Terms of Service', + privacy: 'Privacy', + }, reportActionCompose: { addAction: 'Actions', dropToUpload: 'Drop to upload', diff --git a/src/languages/es.js b/src/languages/es.js index f1ccb01c79a4..fb6a0990305b 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -33,6 +33,9 @@ export default { view: 'Ver', not: 'No', signIn: 'Conectarse', + signInWithGoogle: 'Iniciar sesión con Google', + signInWithApple: 'Iniciar sesión con Apple', + signInWith: 'Iniciar sesión con', continue: 'Continuar', firstName: 'Nombre', lastName: 'Apellidos', @@ -237,6 +240,15 @@ export default { body: 'Bienvenido al futuro de Expensify, tu nuevo lugar de referencia para la colaboración financiera con amigos y compañeros de equipo por igual.', }, }, + thirdPartySignIn: { + alreadySignedIn: ({email}) => `Ya has iniciado sesión con ${email}.`, + goBackMessage: ({provider}) => `No quieres iniciar sesión con ${provider}?`, + continueWithMyCurrentSession: 'Continuar con mi sesión actual', + redirectToDesktopMessage: 'Lo redirigiremos a la aplicación de escritorio una vez que termine de iniciar sesión.', + signInAgreementMessage: 'Al iniciar sesión, aceptas las', + termsOfService: 'Términos de servicio', + privacy: 'Privacidad', + }, reportActionCompose: { addAction: 'Acción', dropToUpload: 'Suelta el archivo aquí para compartirlo', diff --git a/src/libs/Navigation/AppNavigator/PublicScreens.js b/src/libs/Navigation/AppNavigator/PublicScreens.js index 40325918451a..ace04552969a 100644 --- a/src/libs/Navigation/AppNavigator/PublicScreens.js +++ b/src/libs/Navigation/AppNavigator/PublicScreens.js @@ -6,6 +6,7 @@ import LogInWithShortLivedAuthTokenPage from '../../../pages/LogInWithShortLived import SCREENS from '../../../SCREENS'; import defaultScreenOptions from './defaultScreenOptions'; import UnlinkLoginPage from '../../../pages/UnlinkLoginPage'; +import AppleSignInDesktopPage from '../../../pages/signin/AppleSignInDesktopPage'; const RootStack = createStackNavigator(); @@ -32,6 +33,11 @@ function PublicScreens() { options={defaultScreenOptions} component={UnlinkLoginPage} /> + ); } diff --git a/src/libs/Navigation/AppNavigator/index.js b/src/libs/Navigation/AppNavigator/index.js index dee8027b2f30..2aa7fe8c4f39 100644 --- a/src/libs/Navigation/AppNavigator/index.js +++ b/src/libs/Navigation/AppNavigator/index.js @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import DeeplinkWrapper from '../../../components/DeeplinkWrapper'; const propTypes = { /** If we have an authToken this is true */ @@ -11,7 +12,11 @@ function AppNavigator(props) { const AuthScreens = require('./AuthScreens').default; // These are the protected screens and only accessible when an authToken is present - return ; + return ( + + + + ); } const PublicScreens = require('./PublicScreens').default; return ; diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index 9cbd7a37af0a..2657be156a01 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -13,6 +13,7 @@ export default { UnlinkLogin: ROUTES.UNLINK_LOGIN, [SCREENS.TRANSITION_BETWEEN_APPS]: ROUTES.TRANSITION_BETWEEN_APPS, Concierge: ROUTES.CONCIERGE, + AppleSignInDesktop: ROUTES.APPLE_SIGN_IN, [SCREENS.REPORT_ATTACHMENTS]: ROUTES.REPORT_ATTACHMENTS, // Sidebar diff --git a/src/libs/actions/Session/index.js b/src/libs/actions/Session/index.js index b226435adfe2..db7a2a66c645 100644 --- a/src/libs/actions/Session/index.js +++ b/src/libs/actions/Session/index.js @@ -198,57 +198,81 @@ function resendValidateCode(login = credentials.login) { } /** - * Checks the API to see if an account exists for the given login - * - * @param {String} login + +/** + * Constructs the state object for the BeginSignIn && BeginAppleSignIn API calls. + * @returns {Object} */ -function beginSignIn(login) { - const optimisticData = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.ACCOUNT, - value: { - ...CONST.DEFAULT_ACCOUNT_DATA, - isLoading: true, - message: null, - loadingForm: CONST.FORMS.LOGIN_FORM, - }, - }, - ]; - const successData = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.ACCOUNT, - value: { - isLoading: false, - loadingForm: null, +function signInAttemptState() { + return { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + ...CONST.DEFAULT_ACCOUNT_DATA, + isLoading: true, + message: null, + loadingForm: CONST.FORMS.LOGIN_FORM, + }, }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.CREDENTIALS, - value: { - validateCode: null, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + isLoading: false, + loadingForm: null, + }, }, - }, - ]; - - const failureData = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.ACCOUNT, - value: { - isLoading: false, - loadingForm: null, - errors: ErrorUtils.getMicroSecondOnyxError('loginForm.cannotGetAccountDetails'), + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.CREDENTIALS, + value: { + validateCode: null, + }, }, - }, - ]; + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + isLoading: false, + loadingForm: null, + // eslint-disable-next-line rulesdir/prefer-localization + errors: ErrorUtils.getMicroSecondOnyxError('loginForm.cannotGetAccountDetails'), + }, + }, + ], + }; +} + +/** + * Checks the API to see if an account exists for the given login. + * + * @param {String} login + */ +function beginSignIn(login) { + const {optimisticData, successData, failureData} = signInAttemptState(); API.read('BeginSignIn', {email: login}, {optimisticData, successData, failureData}); } +/** + * Given an idToken from Sign in with Apple, checks the API to see if an account + * exists for that email address and signs the user in if so. + * + * @param {String} idToken + */ + +function beginAppleSignIn(idToken) { + const {optimisticData, successData, failureData} = signInAttemptState(); + API.write('SignInWithApple', {idToken}, {optimisticData, successData, failureData}); +} + /** * Will create a temporary login for the user in the passed authenticate response which is used when * re-authenticating after an authToken expires. @@ -879,6 +903,7 @@ function validateTwoFactorAuth(twoFactorAuthCode) { export { beginSignIn, + beginAppleSignIn, setSupportAuthToken, checkIfActionIsAllowed, updatePasswordAndSignin, diff --git a/src/pages/signin/AppleSignInDesktopPage/index.js b/src/pages/signin/AppleSignInDesktopPage/index.js new file mode 100644 index 000000000000..9ec74c1c9c8f --- /dev/null +++ b/src/pages/signin/AppleSignInDesktopPage/index.js @@ -0,0 +1,8 @@ +/* This component's alternate implementation is a screen made for the sign-in + * flow when the desktop app opens the web app to continue signing in, and only + * works when rendered in the web app. */ +function AppleSignInDesktopPage() { + return null; +} + +export default AppleSignInDesktopPage; diff --git a/src/pages/signin/AppleSignInDesktopPage/index.website.js b/src/pages/signin/AppleSignInDesktopPage/index.website.js new file mode 100644 index 000000000000..10887e0ebdee --- /dev/null +++ b/src/pages/signin/AppleSignInDesktopPage/index.website.js @@ -0,0 +1,9 @@ +import React from 'react'; +import ThirdPartySignInPage from '../ThirdPartySignInPage'; +import CONST from '../../../CONST'; + +function AppleSignInDesktopPage() { + return ; +} + +export default AppleSignInDesktopPage; diff --git a/src/pages/signin/LoginForm.js b/src/pages/signin/LoginForm.js index 6e099d170c1f..4386ad5842da 100644 --- a/src/pages/signin/LoginForm.js +++ b/src/pages/signin/LoginForm.js @@ -24,6 +24,7 @@ import * as ErrorUtils from '../../libs/ErrorUtils'; import DotIndicatorMessage from '../../components/DotIndicatorMessage'; import * as CloseAccount from '../../libs/actions/CloseAccount'; import CONST from '../../CONST'; +import AppleSignIn from '../../components/SignInButtons/AppleSignIn'; import isInputAutoFilled from '../../libs/isInputAutoFilled'; import * as PolicyUtils from '../../libs/PolicyUtils'; import Log from '../../libs/Log'; @@ -106,6 +107,10 @@ function LoginForm(props) { [props.account, props.closeAccount, input, setFormError, setLogin], ); + function getSignInWithStyles() { + return props.isSmallScreenWidth ? [styles.mt1] : [styles.mt5, styles.mb5]; + } + /** * Check that all the form fields are valid, then trigger the submit callback */ @@ -224,6 +229,12 @@ function LoginForm(props) { isAlertVisible={!_.isEmpty(serverErrorText)} containerStyles={[styles.mh0]} /> + + {props.translate('common.signInWith')} + + + + ) } diff --git a/src/pages/signin/SignInPage.js b/src/pages/signin/SignInPage.js index 2387c59da0d4..9cc8c074ae16 100644 --- a/src/pages/signin/SignInPage.js +++ b/src/pages/signin/SignInPage.js @@ -167,7 +167,9 @@ function SignInPage({credentials, account}) { } return ( - + // Bottom SafeAreaView is removed so that login screen svg displays correctly on mobile. + // The SVG should flow under the Home Indicator on iOS. + {props.isSmallScreenWidth ? ( - + ) : null} diff --git a/src/pages/signin/ThirdPartySignInPage.js b/src/pages/signin/ThirdPartySignInPage.js new file mode 100644 index 000000000000..0ab046cc392d --- /dev/null +++ b/src/pages/signin/ThirdPartySignInPage.js @@ -0,0 +1,74 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {SafeAreaView} from 'react-native-safe-area-context'; +import styles from '../../styles/styles'; +import compose from '../../libs/compose'; +import SignInPageLayout from './SignInPageLayout'; +import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; +import Text from '../../components/Text'; +import TextLink from '../../components/TextLink'; +import AppleSignIn from '../../components/SignInButtons/AppleSignIn'; +import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions'; +import ROUTES from '../../ROUTES'; +import Navigation from '../../libs/Navigation/Navigation'; +import CONST from '../../CONST'; + +const propTypes = { + /** Which sign in provider we are using. */ + signInProvider: PropTypes.oneOf([CONST.SIGN_IN_METHOD.APPLE, CONST.SIGN_IN_METHOD.GOOGLE]).isRequired, + + ...withLocalizePropTypes, + + ...windowDimensionsPropTypes, +}; + +/* Dedicated screen that the desktop app links to on the web app, as Apple/Google + * sign-in cannot work fully within Electron, so we escape to web and redirect + * to desktop once we have an Expensify auth token. + */ +function ThirdPartySignInPage(props) { + const goBack = () => { + Navigation.navigate(ROUTES.HOME); + }; + + return ( + + + {props.signInProvider === CONST.SIGN_IN_METHOD.APPLE ? : null} + {props.translate('thirdPartySignIn.redirectToDesktopMessage')} + {props.translate('thirdPartySignIn.goBackMessage', {provider: props.signInProvider})} + + {props.translate('common.goBack')}. + + + {props.translate('thirdPartySignIn.signInAgreementMessage')} + + {` ${props.translate('common.termsOfService')}`} + + {` ${props.translate('common.and')} `} + + {props.translate('common.privacy')} + + . + + + + ); +} + +ThirdPartySignInPage.propTypes = propTypes; +ThirdPartySignInPage.displayName = 'ThirdPartySignInPage'; + +export default compose(withLocalize, withWindowDimensions)(ThirdPartySignInPage); diff --git a/src/styles/styles.js b/src/styles/styles.js index 64fb3bc6b30a..62eee861877b 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -1157,8 +1157,8 @@ const styles = { }, signInPageLeftContainer: { - paddingLeft: 40, - paddingRight: 40, + paddingLeft: 48, + paddingRight: 48, }, signInPageLeftContainerWide: { @@ -1166,11 +1166,11 @@ const styles = { }, signInPageWelcomeFormContainer: { - maxWidth: 300, + maxWidth: CONST.SIGN_IN_FORM_WIDTH, }, signInPageWelcomeTextContainer: { - width: 300, + width: CONST.SIGN_IN_FORM_WIDTH, }, changeExpensifyLoginLinkContainer: { @@ -3521,6 +3521,31 @@ const styles = { textAlign: 'center', }, + loginButtonRow: { + justifyContent: 'center', + width: '100%', + ...flex.flexRow, + }, + + loginButtonRowSmallScreen: { + justifyContent: 'center', + width: '100%', + marginBottom: 10, + ...flex.flexRow, + }, + + appleButtonContainer: { + width: 40, + height: 40, + marginRight: 20, + }, + + signInIconButton: { + margin: 10, + marginTop: 0, + padding: 2, + }, + /** * @param {String} backgroundColor * @param {Number} height diff --git a/tests/ui/UnreadIndicatorsTest.js b/tests/ui/UnreadIndicatorsTest.js index 938e26a1aeb9..3986dec082bf 100644 --- a/tests/ui/UnreadIndicatorsTest.js +++ b/tests/ui/UnreadIndicatorsTest.js @@ -28,6 +28,7 @@ import * as Localize from '../../src/libs/Localize'; jest.setTimeout(30000); jest.mock('../../src/libs/Notification/LocalNotification'); +jest.mock('../../src/components/Icon/Expensicons'); beforeAll(() => { // In this test, we are generically mocking the responses of all API requests by mocking fetch() and having it diff --git a/web/index.html b/web/index.html index d207fa54b97a..ea8cce7a6918 100644 --- a/web/index.html +++ b/web/index.html @@ -20,6 +20,10 @@ <% } %>