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-environmentdevelopment
+ com.apple.developer.applesignin
+
+ Default
+ com.apple.developer.associated-domainsapplinks: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 @@
<% } %>