diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b8a13b47437a..2e1a01ec1301 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ Welcome! Thanks for checking out Expensify.cash and for taking the time to contribute! ## Getting Started -This guide is specifically for external contributors. For a general overview of the repo, check out our README located [here](https://github.com/Expensify/Expensify.cash/blob/master/README.md). The parts of the README to pay particular attention to are how to [run the web app](https://github.com/Expensify/Expensify.cash#running-the-web-app-via-production-api-proxy-contributors-) and how to [run the desktop/mobile apps](https://github.com/Expensify/Expensify.cash#running-the-desktop-and-mobile-apps-via-production-api-contributors-) locally using our production API. +This guide is specifically for external contributors. For a general overview of the repo, check out our README located [here](https://github.com/Expensify/Expensify.cash/blob/master/README.md). The part of the README to pay particular attention to is how to [run the app](https://github.com/Expensify/Expensify.cash#running-the-apps-via-production-api-proxy-contributors-) locally using our production API. #### Test Accounts You can create as many accounts as needed in order to test your changes. You can create your test accounts directly from [expensify.cash](https://expensify.cash/). Right now, accounts can't chat with each other by default. If you want your test accounts to be able to chat with each other, post in the #expensify-contributors [Slack channel](https://github.com/Expensify/Expensify.cash/blob/master/CONTRIBUTING.md#asking-questions) to ask someone to add your test accounts to a policy that allows chatting. @@ -11,7 +11,7 @@ You can create as many accounts as needed in order to test your changes. You can This project and everyone participating in it is governed by the Expensify [Code of Conduct](https://github.com/Expensify/Expensify.cash/blob/master/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to [contributors@expensify.com](mailto:contributors@expensify.com). ## Asking Questions -The best way to ask questions is to join our #expensify-contributors Slack channel. To request an invite to the channel, just email contributors@expensify.com with the subject "Slack Channel Invite" and we'll send you an invite! Please do not create issues to ask questions. +The best way to ask questions is to join our #expensify-contributors Slack channel. To request an invite to the channel, just email contributors@expensify.com with the subject "Slack Channel Invite" and we'll send you an invite! Please do not create issues to ask questions. ## Reporting Vulnerabilities If you've found a vulnerability, please email security@expensify.com with the subject `Vulnerability Report` instead of creating an issue. @@ -20,12 +20,12 @@ If you've found a vulnerability, please email security@expensify.com with the su If you'd like to create a new issue, please first make sure the issue does not exist in the [issue list](https://github.com/Expensify/Expensify.cash/issues). When creating a new issue, please include all the required information on the issue template. ## Payment for Contributions -We are currently managing payment via Upwork. If you'd like to be paid for your contributions, please apply to fix the issue from our [Upwork issue list](https://www.upwork.com/ab/jobs/search/?q=Expensify%20React%20Native&sort=recency&user_location_match=2). Each issue in this repo will also link out to the associated Upwork job. +We are currently managing payment via Upwork. If you'd like to be paid for your contributions, please apply to fix the issue from our [Upwork issue list](https://www.upwork.com/ab/jobs/search/?q=Expensify%20React%20Native&sort=recency&user_location_match=2). Each issue in this repo will also link out to the associated Upwork job. ## Submitting a Pull Request #### Proposing a Change 1. Fork this repository and create a new branch -1. [Open a PR](https://docs.github.com/en/free-pro-team@latest/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork) and be sure to fill in all the required information on the PR template. +1. [Open a PR](https://docs.github.com/en/free-pro-team@latest/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork). Be sure to fill in all the required information on the PR template, and be sure all of your [commits are signed](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/signing-commits). 1. An Expensify engineer will be automatically assigned to review your PR 1. You will need all checks to pass: 1. CLA - You must sign our [Contributor License Agreement](https://github.com/Expensify/Expensify.cash/blob/master/CLA.md) by following the CLA bot instructions that will be posted on your PR diff --git a/README.md b/README.md index 136840447fa0..f4127fcdea4d 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,14 @@
- + + Expensify.cash Icon + +

+ + Expensify.cash + +

-# [Expensify.cash](https://Expensify.cash) - ## Philosophy This application is built with the following principles. 1. **Data Flow** - Ideally, this is how data flows through the app: @@ -92,16 +97,14 @@ Now, all of your API calls will be using the ngrok route. ## Running the MacOS desktop app 🖥 * To run the **Development app**, run: `npm run desktop`, this will start a new Electron process running on your MacOS desktop in the `dist/Mac` folder. -## Running the web app via production API proxy (Contributors) 🧑‍💻 +## Running the apps via production API proxy (Contributors) 🧑‍💻 If you don't have full-access to Expensify's development environment you will need to run the app against the production API. * Copy the `.env.production` variables into your `.env` file * Set `EXPENSIFY_URL_COM` environment variable to be empty (**Note:** this means it should be `EXPENSIFY_URL_COM=`, not completely omitted) -* Run the **Development Server**: `npm run proxy` - -## Running the desktop and mobile apps via production API (Contributors) 🧑‍💻 -If you don't have full-access to Expensify's development environment you will need to run the app against the production API. -* Copy the `.env.production` variables into your `.env` file (**Note:** Reset `EXPENSIFY_URL_COM` if you previously deleted it) -* Run the desktop, iOS, or Android builds via `npm run desktop`, `npm run ios`, `npm run android` respectively +* To run the web app, run the **Development Server**: `npm run proxy` +* To run the desktop app: `npm run desktop` +* To run the iOS app: `npm run ios` +* To run the Android app: `npm run android` ## Running the tests 🎰 ### Unit tests diff --git a/android/app/build.gradle b/android/app/build.gradle index 6927bfd0a91a..c9e7754b051a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -148,8 +148,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 281 - versionName "1.0.1-280" + versionCode 292 + versionName "1.0.1-291" } splits { abi { diff --git a/android/app/src/main/res/drawable-hdpi/ic_notification.png b/android/app/src/main/res/drawable-hdpi/ic_notification.png index e084753fb90e..15320cfb4d44 100644 Binary files a/android/app/src/main/res/drawable-hdpi/ic_notification.png and b/android/app/src/main/res/drawable-hdpi/ic_notification.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_notification.png b/android/app/src/main/res/drawable-mdpi/ic_notification.png index 669579148c61..24b381c77b93 100644 Binary files a/android/app/src/main/res/drawable-mdpi/ic_notification.png and b/android/app/src/main/res/drawable-mdpi/ic_notification.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_notification.png b/android/app/src/main/res/drawable-xhdpi/ic_notification.png index 18c06a8d16c3..4f88da13fbc7 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_notification.png and b/android/app/src/main/res/drawable-xhdpi/ic_notification.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_notification.png b/android/app/src/main/res/drawable-xxhdpi/ic_notification.png index da94014c33f0..ad6e656c672b 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_notification.png and b/android/app/src/main/res/drawable-xxhdpi/ic_notification.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png b/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png index 96f1cb18a2be..972b817fa33b 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png and b/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 62bcc8e5d368..1c978ebca5f7 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index 5add8a771a06..7a5cf1884b2e 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/assets/images/expensify-logo-round.png b/assets/images/expensify-logo-round.png index 01e85cfc4e8a..0e150943264e 100644 Binary files a/assets/images/expensify-logo-round.png and b/assets/images/expensify-logo-round.png differ diff --git a/assets/images/welcome-screenshot-wide.png b/assets/images/welcome-screenshot-wide.png new file mode 100644 index 000000000000..3b236c219f1b Binary files /dev/null and b/assets/images/welcome-screenshot-wide.png differ diff --git a/assets/images/welcome-screenshot.png b/assets/images/welcome-screenshot.png new file mode 100644 index 000000000000..e4708fea4eaa Binary files /dev/null and b/assets/images/welcome-screenshot.png differ diff --git a/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/Contents.json b/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/Contents.json index a90a5722ff43..2c82e7765222 100644 --- a/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/Contents.json @@ -31,13 +31,13 @@ "size" : "40x40" }, { - "filename" : "iOSIcon@2x.png", + "filename" : "iOS@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "60x60" }, { - "filename" : "iOSIcon@3x.png", + "filename" : "iOS@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "60x60" @@ -91,7 +91,7 @@ "size" : "83.5x83.5" }, { - "filename" : "iOSIcon_AppStore@2x.png", + "filename" : "Store.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" diff --git a/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/Store.png b/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/Store.png new file mode 100644 index 000000000000..cd3bdb9a4b07 Binary files /dev/null and b/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/Store.png differ diff --git a/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iOS@2x.png b/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iOS@2x.png new file mode 100644 index 000000000000..a0ba63905920 Binary files /dev/null and b/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iOS@2x.png differ diff --git a/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iOS@3x.png b/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iOS@3x.png new file mode 100644 index 000000000000..4e5679068784 Binary files /dev/null and b/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iOS@3x.png differ diff --git a/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iOSIcon@2x.png b/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iOSIcon@2x.png deleted file mode 100644 index 8f4ddc74a644..000000000000 Binary files a/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iOSIcon@2x.png and /dev/null differ diff --git a/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iOSIcon@3x.png b/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iOSIcon@3x.png deleted file mode 100644 index 341d8b607f9e..000000000000 Binary files a/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iOSIcon@3x.png and /dev/null differ diff --git a/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iOSIcon_AppStore@2x.png b/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iOSIcon_AppStore@2x.png deleted file mode 100644 index e6c4814e34cc..000000000000 Binary files a/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iOSIcon_AppStore@2x.png and /dev/null differ diff --git a/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iPad.png b/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iPad.png index 2ae019f646eb..5d4cbe5f4c19 100644 Binary files a/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iPad.png and b/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iPad.png differ diff --git a/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iPad@2x.png b/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iPad@2x.png index e250046114dc..3d73e33910a1 100644 Binary files a/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iPad@2x.png and b/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iPad@2x.png differ diff --git a/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iPadPro.png b/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iPadPro.png index 9f8af3a99843..338d9fc1422e 100644 Binary files a/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iPadPro.png and b/ios/ExpensifyCash/Images.xcassets/AppIcon.appiconset/iPadPro.png differ diff --git a/ios/ExpensifyCash/Info.plist b/ios/ExpensifyCash/Info.plist index 5b9691187a9b..f7186704c5ab 100644 --- a/ios/ExpensifyCash/Info.plist +++ b/ios/ExpensifyCash/Info.plist @@ -21,7 +21,7 @@ CFBundleSignature ???? CFBundleVersion - 281 + 292 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/ios/ExpensifyCashTests/Info.plist b/ios/ExpensifyCashTests/Info.plist index a838c15c13a2..761748984535 100644 --- a/ios/ExpensifyCashTests/Info.plist +++ b/ios/ExpensifyCashTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 281 + 292 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 2b0958a7bb48..2e7bc7f46517 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -338,7 +338,7 @@ PODS: - React - react-native-safe-area-context (3.1.8): - React-Core - - react-native-webview (10.10.0): + - react-native-webview (11.0.2): - React-Core - React-RCTActionSheet (0.63.3): - React-Core/RCTActionSheetHeaders (= 0.63.3) @@ -649,7 +649,7 @@ SPEC CHECKSUMS: react-native-progress-bar-android: ce95a69f11ac580799021633071368d08aaf9ad8 react-native-progress-view: 5816e8a6be812c2b122c6225a2a3db82d9008640 react-native-safe-area-context: 01158a92c300895d79dee447e980672dc3fb85a6 - react-native-webview: 2e330b109bfd610e9818bf7865d1979f898960a7 + react-native-webview: b2542d6fd424bcc3e3b2ec5f854f0abb4ec86c87 React-RCTActionSheet: 53ea72699698b0b47a6421cb1c8b4ab215a774aa React-RCTAnimation: 1befece0b5183c22ae01b966f5583f42e69a83c2 React-RCTBlob: 0b284339cbe4b15705a05e2313a51c6d8b51fa40 diff --git a/package-lock.json b/package-lock.json index 99ba25be8bc9..d87716693791 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "expensify.cash", - "version": "1.0.1-280", + "version": "1.0.1-291", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -10072,8 +10072,8 @@ } }, "expensify-common": { - "version": "git+https://github.com/Expensify/expensify-common.git#cd9f195ed1fd340e7e890c41672a97af4f2956ca", - "from": "git+https://github.com/Expensify/expensify-common.git#cd9f195ed1fd340e7e890c41672a97af4f2956ca", + "version": "git+https://github.com/Expensify/expensify-common.git#d1bfacca6fb92f7e89a2a9cdf0863fad6481c0f2", + "from": "git+https://github.com/Expensify/expensify-common.git#d1bfacca6fb92f7e89a2a9cdf0863fad6481c0f2", "requires": { "classnames": "2.2.5", "clipboard": "2.0.4", @@ -19505,6 +19505,59 @@ "lodash.merge": "^4.6.2", "react": "^16.13.1", "underscore": "^1.11.0" + }, + "dependencies": { + "expensify-common": { + "version": "git+https://github.com/Expensify/expensify-common.git#cd9f195ed1fd340e7e890c41672a97af4f2956ca", + "from": "git+https://github.com/Expensify/expensify-common.git#cd9f195ed1fd340e7e890c41672a97af4f2956ca", + "requires": { + "classnames": "2.2.5", + "clipboard": "2.0.4", + "html-entities": "^1.3.1", + "jquery": "3.3.1", + "lodash.get": "4.4.2", + "lodash.has": "4.5.2", + "moment": "2.20.1", + "prop-types": "15.7.2", + "react": "16.12.0", + "react-dom": "16.12.0", + "simply-deferred": "git+https://github.com/Expensify/simply-deferred.git#77a08a95754660c7bd6e0b6979fdf84e8e831bf5", + "underscore": "1.9.1" + }, + "dependencies": { + "react": { + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/react/-/react-16.12.0.tgz", + "integrity": "sha512-fglqy3k5E+81pA8s+7K0/T3DBCF0ZDOher1elBFzF7O6arXJgzyu/FW+COxFvAWXJoJN9KIZbT2LXlukwphYTA==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2" + } + }, + "underscore": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.9.1.tgz", + "integrity": "sha512-5/4etnCkd9c8gwgowi5/om/mYO5ajCaOgdzj/oW+0eQV9WxKBDZw5+ycmKmeaTXjInS/W0BzpGLo2xR2aBwZdg==" + } + } + }, + "moment": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.20.1.tgz", + "integrity": "sha512-Yh9y73JRljxW5QxN08Fner68eFLxM5ynNOAw2LbIB1YAGeQzZT8QFSUvkAz609Zf+IHhhaUxqZK8dG3W/+HEvg==" + }, + "react-dom": { + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.12.0.tgz", + "integrity": "sha512-LMxFfAGrcS3kETtQaCkTKjMiifahaMySFDn71fZUNpPHZQEzmk/GiAeIT8JSOrHB23fnuCOMruL2a8NYlw+8Gw==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.18.0" + } + } } }, "react-native-pdf": { diff --git a/package.json b/package.json index e2fabe60de36..2caccadc4648 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "expensify.cash", - "version": "1.0.1-280", + "version": "1.0.1-291", "author": "Expensify, Inc.", "homepage": "https://expensify.cash", "description": "Expensify.cash is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -42,7 +42,7 @@ "electron-log": "^4.2.4", "electron-serve": "^1.0.0", "electron-updater": "^4.3.4", - "expensify-common": "git+https://github.com/Expensify/expensify-common.git#cd9f195ed1fd340e7e890c41672a97af4f2956ca", + "expensify-common": "git+https://github.com/Expensify/expensify-common.git#d1bfacca6fb92f7e89a2a9cdf0863fad6481c0f2", "file-loader": "^6.0.0", "html-entities": "^1.3.1", "lodash.get": "^4.4.2", diff --git a/src/CONST.js b/src/CONST.js index b237c1c24db5..709e7b5c7005 100644 --- a/src/CONST.js +++ b/src/CONST.js @@ -4,6 +4,7 @@ const CONST = { CLOUDFRONT_URL, PDF_VIEWER_URL: '/pdf/web/viewer.html', EXPENSIFY_ICON_URL: `${CLOUDFRONT_URL}/images/favicon-2019.png`, + UPWORK_URL: 'https://www.upwork.com/ab/jobs/search/?q=Expensify%20React%20Native&user_location_match=2', REPORT: { SINGLE_USER_DM: 'singleUserDM', GROUP_USERS_DM: 'groupUsersDM', diff --git a/src/Expensify.js b/src/Expensify.js index ecea4da00124..21324aaa0ae6 100644 --- a/src/Expensify.js +++ b/src/Expensify.js @@ -6,7 +6,7 @@ import {recordCurrentlyViewedReportID, recordCurrentRoute} from './libs/actions/ import HomePage from './pages/home/HomePage'; import NotFoundPage from './pages/NotFound'; import SetPasswordPage from './pages/SetPasswordPage'; -import SignInPage from './pages/SignInPage'; +import SignInPage from './pages/signin/SignInPage'; import listenToStorageEvents from './libs/listenToStorageEvents'; import * as ActiveClientManager from './libs/ActiveClientManager'; import ONYXKEYS from './ONYXKEYS'; @@ -104,12 +104,11 @@ class Expensify extends Component { ); } - const redirectTo = !this.state.authToken ? ROUTES.SIGNIN : this.props.redirectTo; return ( {/* If there is ever a property for redirecting, we do the redirect here */} {/* Leave this as a ternary or else iOS throws an error about text not being wrapped in */} - {redirectTo ? : null} + {this.props.redirectTo ? : null} {/* We must record the currentlyViewedReportID when hitting the 404 page so */} @@ -119,17 +118,27 @@ class Expensify extends Component { ( this.state.authToken ? : )} /> + - + ( + + // Need to do this for every page that the user needs to be logged in to access + this.state.authToken + ? + : + )} + /> ); diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js index b89355a0ebbb..1e1bdb60aa6d 100644 --- a/src/ONYXKEYS.js +++ b/src/ONYXKEYS.js @@ -2,6 +2,11 @@ * This is a file containing constants for all the top level keys in our store */ export default { + // Holds information about the users account that is logging in + ACCOUNT: 'account', + + // Holds an array of client IDs which is used for multi-tabs on web in order to know + // which tab is the leader, and which ones are the followers ACTIVE_CLIENTS: 'activeClients2', // When this key is changed, the active page changes (see Expensify.js and `redirect` in actions/App.js) diff --git a/src/libs/API.js b/src/libs/API.js index 66f3485be9f1..aeab7db04d12 100644 --- a/src/libs/API.js +++ b/src/libs/API.js @@ -26,7 +26,15 @@ Onyx.connect({ * @return {Boolean} */ function isAuthTokenRequired(command) { - return !_.contains(['Log', 'Graphite_Timer', 'Authenticate'], command); + return !_.contains([ + 'Log', + 'Graphite_Timer', + 'Authenticate', + 'GetAccountStatus', + 'SetGithubUsername', + 'SetPassword', + 'User_SignUp', + ], command); } /** @@ -254,6 +262,19 @@ function CreateChatReport(parameters) { return request(commandName, parameters); } +/** + * @param {Object} parameters + * @param {String} parameters.email + * @returns {Promise} + */ +function User_SignUp(parameters) { + const commandName = 'User_SignUp'; + requireParameters([ + 'email', + ], parameters, commandName); + return request(commandName, parameters); +} + /** * @param {Object} parameters * @param {String} parameters.authToken @@ -297,8 +318,18 @@ function DeleteLogin(parameters) { */ function Get(parameters) { const commandName = 'Get'; - requireParameters(['returnValueList'], - parameters, commandName); + requireParameters(['returnValueList'], parameters, commandName); + return request(commandName, parameters); +} + +/** + * @param {Object} parameters + * @param {String} parameters.email + * @returns {Promise} + */ +function GetAccountStatus(parameters) { + const commandName = 'GetAccountStatus'; + requireParameters(['email'], parameters, commandName); return request(commandName, parameters); } @@ -407,6 +438,28 @@ function Report_UpdateLastRead(parameters) { return request(commandName, parameters); } +/** + * @param {Object} parameters + * @param {Number} parameters.email + * @returns {Promise} + */ +function ResendValidateCode(parameters) { + const commandName = 'ResendValidateCode'; + requireParameters(['email'], parameters, commandName); + return request(commandName, parameters); +} + +/** + * @param {Object} parameters + * @param {String} parameters.githubUsername + * @returns {Promise} + */ +function SetGithubUsername(parameters) { + const commandName = 'SetGithubUsername'; + requireParameters(['email', 'githubUsername'], parameters, commandName); + return request(commandName, parameters); +} + /** * @param {Object} parameters * @param {String} parameters.password @@ -415,7 +468,7 @@ function Report_UpdateLastRead(parameters) { */ function SetPassword(parameters) { const commandName = 'SetPassword'; - requireParameters(['password', 'validateCode'], parameters, commandName); + requireParameters(['email', 'password', 'validateCode'], parameters, commandName); return request(commandName, parameters); } @@ -426,6 +479,7 @@ export { CreateLogin, DeleteLogin, Get, + GetAccountStatus, Graphite_Timer, Log, PersonalDetails_GetForEmails, @@ -434,5 +488,8 @@ export { Report_GetHistory, Report_TogglePinned, Report_UpdateLastRead, + ResendValidateCode, + SetGithubUsername, SetPassword, + User_SignUp, }; diff --git a/src/libs/Notification/LocalNotification/BrowserNotifications.js b/src/libs/Notification/LocalNotification/BrowserNotifications.js index 7d562a51ba3e..01c8a7930ceb 100644 --- a/src/libs/Notification/LocalNotification/BrowserNotifications.js +++ b/src/libs/Notification/LocalNotification/BrowserNotifications.js @@ -1,9 +1,8 @@ // Web and desktop implementation only. Do not import for direct use. Use LocalNotification. import Str from 'expensify-common/lib/str'; -import CONST from '../../../CONST'; import focusApp from './focusApp'; +import EXPENSIFY_ICON_URL from '../../../../assets/images/expensify-logo-round.png'; -const EXPENSIFY_ICON_URL = `${CONST.CLOUDFRONT_URL}/images/favicon-2019.png`; const DEFAULT_DELAY = 4000; /** diff --git a/src/libs/actions/Session.js b/src/libs/actions/Session.js index e6336329478a..f853f8720500 100644 --- a/src/libs/actions/Session.js +++ b/src/libs/actions/Session.js @@ -7,10 +7,9 @@ import * as API from '../API'; import CONFIG from '../../CONFIG'; import PushNotification from '../Notification/PushNotification'; import ROUTES from '../../ROUTES'; -import {redirect} from './App'; import Timing from './Timing'; -let credentials; +let credentials = {}; Onyx.connect({ key: ONYXKEYS.CREDENTIALS, callback: val => credentials = val, @@ -33,37 +32,103 @@ function setSuccessfulSignInData(data, exitTo) { } /** - * Sign in with the API + * Create an account for the user logging in. + * This will send them a notification with a link to click on to validate the account and set a password * - * @param {String} partnerUserID - * @param {String} partnerUserSecret + * @param {String} login + */ +function createAccount(login) { + Onyx.merge(ONYXKEYS.SESSION, {error: ''}); + + API.User_SignUp({ + email: login, + isViaExpensifyCash: true + }).then((response) => { + if (response.jsonCode !== 200) { + let errorMessage = response.message || `Unknown API Error: ${response.jsonCode}`; + if (!response.message && response.jsonCode === 405) { + errorMessage = 'Cannot create an account that is under a controlled domain'; + } + Onyx.merge(ONYXKEYS.SESSION, {error: errorMessage}); + Onyx.merge(ONYXKEYS.CREDENTIALS, {login: null}); + } + }); +} + +/** + * Clears the Onyx store and redirects user to the sign in page + */ +function signOut() { + Timing.clearData(); + redirectToSignIn(); + if (!credentials || !credentials.login) { + return; + } + + API.DeleteLogin({ + partnerUserID: credentials.login, + partnerName: CONFIG.EXPENSIFY.PARTNER_NAME, + partnerPassword: CONFIG.EXPENSIFY.PARTNER_PASSWORD, + doNotRetry: true, + }) + .catch(error => Onyx.merge(ONYXKEYS.SESSION, {error: error.message})); +} + +/** + * Checks the API to see if an account exists for the given login + * + * @param {String} login + */ +function fetchAccountDetails(login) { + Onyx.merge(ONYXKEYS.SESSION, {error: ''}); + + API.GetAccountStatus({email: login, isViaExpensifyCash: true}) + .then((response) => { + if (response.jsonCode === 200) { + Onyx.merge(ONYXKEYS.CREDENTIALS, {login}); + Onyx.merge(ONYXKEYS.ACCOUNT, { + accountExists: response.accountExists, + canAccessExpensifyCash: response.canAccessExpensifyCash, + requiresTwoFactorAuth: response.requiresTwoFactorAuth, + }); + + if (!response.accountExists) { + createAccount(login); + } + } + }); +} + +/** + * Sign the user into the application. This will first authenticate their account + * then it will create a temporary login for them which is used when re-authenticating + * after an authToken expires. + * + * @param {String} password + * @param {String} exitTo * @param {String} [twoFactorAuthCode] - * @param {String} [exitTo] */ -function signIn(partnerUserID, partnerUserSecret, twoFactorAuthCode = '', exitTo) { - Onyx.merge(ONYXKEYS.SESSION, {loading: true, error: ''}); +function signIn(password, exitTo, twoFactorAuthCode) { + Onyx.merge(ONYXKEYS.SESSION, {error: ''}); API.Authenticate({ useExpensifyLogin: true, partnerName: CONFIG.EXPENSIFY.PARTNER_NAME, partnerPassword: CONFIG.EXPENSIFY.PARTNER_PASSWORD, - partnerUserID, - partnerUserSecret, + partnerUserID: credentials.login, + partnerUserSecret: password, twoFactorAuthCode, }) - - // After the user authenticates, create a new login for the user so that we can reauthenticate when the - // authtoken expires .then((authenticateResponse) => { const login = Str.guid('expensify.cash-'); - const password = Str.guid(); + const temporaryPassword = Str.guid(); API.CreateLogin({ authToken: authenticateResponse.authToken, partnerName: CONFIG.EXPENSIFY.PARTNER_NAME, partnerPassword: CONFIG.EXPENSIFY.PARTNER_PASSWORD, partnerUserID: login, - partnerUserSecret: password, + partnerUserSecret: temporaryPassword, doNotRetry: true, }) .then((createLoginResponse) => { @@ -73,44 +138,62 @@ function signIn(partnerUserID, partnerUserSecret, twoFactorAuthCode = '', exitTo setSuccessfulSignInData(createLoginResponse, exitTo); - if (credentials && credentials.login) { - // If we have an old login for some reason, we should delete it before storing the new details + // If we have an old login for some reason, we should delete it before storing the new details + if (credentials.login) { API.DeleteLogin({ partnerUserID: credentials.login, partnerName: CONFIG.EXPENSIFY.PARTNER_NAME, partnerPassword: CONFIG.EXPENSIFY.PARTNER_PASSWORD, doNotRetry: true, }) - .catch(error => Onyx.merge(ONYXKEYS.SESSION, {error: error.message})); + .catch(console.debug); } - Onyx.merge(ONYXKEYS.CREDENTIALS, {login, password}); + Onyx.merge(ONYXKEYS.CREDENTIALS, {password}); + }) + .catch((error) => { + Onyx.merge(ONYXKEYS.SESSION, {error: error.message}); }); }) .catch((error) => { - console.debug('[SIGNIN] Request error', error); Onyx.merge(ONYXKEYS.SESSION, {error: error.message}); - }) - .finally(() => Onyx.merge(ONYXKEYS.SESSION, {loading: false})); + }); } /** - * Clears the Onyx store and redirects user to the sign in page + * Puts the github username into Onyx so that it can be used when creating accounts or logins + * + * @param {String} username */ -function signOut() { - Timing.clearData(); - redirectToSignIn(); - if (!credentials || !credentials.login) { - return; - } +function setGitHubUsername(username) { + Onyx.merge(ONYXKEYS.SESSION, {error: ''}); - API.DeleteLogin({ - partnerUserID: credentials.login, - partnerName: CONFIG.EXPENSIFY.PARTNER_NAME, - partnerPassword: CONFIG.EXPENSIFY.PARTNER_PASSWORD, - doNotRetry: true, - }) - .catch(error => Onyx.merge(ONYXKEYS.SESSION, {error: error.message})); + API.SetGithubUsername({email: credentials.login, githubUsername: username}) + .then((response) => { + if (response.jsonCode === 200) { + Onyx.merge(ONYXKEYS.CREDENTIALS, {githubUsername: username}); + Onyx.merge(ONYXKEYS.ACCOUNT, {canAccessExpensifyCash: true}); + return; + } + + // This request can fail if an invalid GitHub username was entered + Onyx.merge(ONYXKEYS.SESSION, {error: 'Please enter a valid GitHub username'}); + }); +} + +/** + * Resend the validation link to the user that is validating their account + * this happens in the createAccount() flow + */ +function resendValidationLink() { + API.ResendValidateCode({email: credentials.login}); +} + +/** + * Restart the sign in process by clearing everything from Onyx + */ +function restartSignin() { + Onyx.clear(); } /** @@ -121,19 +204,28 @@ function signOut() { */ function setPassword(password, validateCode) { API.SetPassword({ + email: credentials.login, password, validateCode, }) - .then(() => { - // @TODO check for 200 response and log the user in properly (like the sign in flow). - // For now we can just redirect to root - Onyx.merge(ONYXKEYS.CREDENTIALS, {password}); - redirect('/'); + .then((response) => { + if (response.jsonCode === 200) { + Onyx.merge(ONYXKEYS.CREDENTIALS, {password}); + setSuccessfulSignInData(response, '/home/'); + return; + } + + // This request can fail if the password is not complex enough + Onyx.merge(ONYXKEYS.SESSION, {error: response.message}); }); } export { + fetchAccountDetails, + setGitHubUsername, + setPassword, signIn, signOut, - setPassword, + resendValidationLink, + restartSignin, }; diff --git a/src/pages/SetPasswordPage.js b/src/pages/SetPasswordPage.js index bfe58939efdd..c4fe0be14a2a 100644 --- a/src/pages/SetPasswordPage.js +++ b/src/pages/SetPasswordPage.js @@ -85,7 +85,7 @@ class SetPasswordPage extends Component { <> - + ; - } - - const isLoading = session.loading; - return ( - <> - - - - - - - - Login - this.setState({login: text})} - onSubmitEditing={this.submitForm} - autoCapitalize="none" - /> - - - Password - this.setState({password: text})} - onSubmitEditing={this.submitForm} - /> - - - Two Factor Code - this.setState({twoFactorAuthCode: text})} - onSubmitEditing={this.submitForm} - /> - - - - {isLoading ? ( - - ) : ( - Log In - )} - - {this.props.session && !_.isEmpty(this.props.session.error) && ( - - {this.props.session.error} - - )} - - - - - ); - } -} - -App.propTypes = propTypes; -App.defaultProps = defaultProps; - -export default compose( - withRouter, - withOnyx({ - session: {key: ONYXKEYS.SESSION}, - }) -)(App); diff --git a/src/pages/signin/GithubUsernameForm.js b/src/pages/signin/GithubUsernameForm.js new file mode 100644 index 000000000000..9ee86ba89b43 --- /dev/null +++ b/src/pages/signin/GithubUsernameForm.js @@ -0,0 +1,85 @@ +import React from 'react'; +import {Text, TextInput, View} from 'react-native'; +import styles from '../../styles/styles'; +import SubmitButton from './SubmitButton'; +import {setGitHubUsername} from '../../libs/actions/Session'; + +class GithubUsernameForm extends React.Component { + constructor(props) { + super(props); + + this.validateAndSubmitForm = this.validateAndSubmitForm.bind(this); + + this.state = { + formError: false, + githubUsername: '', + isLoading: false, + }; + } + + /** + * Check that all the form fields are valid, then trigger the submit callback + */ + validateAndSubmitForm() { + if (!this.state.githubUsername.trim()) { + this.setState({formError: 'Please enter your GitHub username'}); + return; + } + + this.setState({ + formError: null, + isLoading: true, + }); + + // Save the github username to their account + setGitHubUsername(this.state.githubUsername); + } + + render() { + return ( + <> + + + GitHub Username + this.setState({githubUsername: text})} + onSubmitEditing={this.validateAndSubmitForm} + autoCapitalize="none" + autoFocus + /> + + + + + {this.state.formError && ( + + {this.state.formError} + + )} + + + + + You're on the waitlist! + + + Thanks for adding yourself to the waitlist. If you're a developer, just enter your + {' '} + GitHub username, and we'll grant you instant access to the dev-only beta. Otherwise, + {' '} + you're all set -- we'll let you know when to check back. + + + + ); + } +} + +export default GithubUsernameForm; diff --git a/src/pages/signin/LoginForm/LoginFormNarrow.js b/src/pages/signin/LoginForm/LoginFormNarrow.js new file mode 100644 index 000000000000..4d1897170257 --- /dev/null +++ b/src/pages/signin/LoginForm/LoginFormNarrow.js @@ -0,0 +1,116 @@ +import React from 'react'; +import { + Image, Text, TextInput, View +} from 'react-native'; +import styles from '../../../styles/styles'; +import themeColors from '../../../styles/themes/default'; +import SubmitButton from '../SubmitButton'; +import openURLInNewTab from '../../../libs/openURLInNewTab'; +import {fetchAccountDetails} from '../../../libs/actions/Session'; +import welcomeScreenshot from '../../../../assets/images/welcome-screenshot.png'; +import CONST from '../../../CONST'; + +class LoginFormNarrow extends React.Component { + constructor(props) { + super(props); + + this.validateAndSubmitForm = this.validateAndSubmitForm.bind(this); + + this.state = { + formError: false, + login: '', + isLoading: false, + }; + } + + /** + * Check that all the form fields are valid, then trigger the submit callback + */ + validateAndSubmitForm() { + if (!this.state.login.trim()) { + this.setState({formError: 'Please enter an email or phone number'}); + return; + } + + this.setState({ + formError: null, + isLoading: true, + }); + + // Check if this login has an account associated with it or not + fetchAccountDetails(this.state.login); + } + + render() { + return ( + + + Sign up for the waitlist + this.setState({login: text})} + onSubmitEditing={this.validateAndSubmitForm} + autoCapitalize="none" + placeholder="Email or phone" + placeholderTextColor={themeColors.textSupporting} + /> + + + + + + {this.state.formError && ( + + {this.state.formError} + + )} + + + + + + + + With Expensify.cash, chat and payments are the same thing. Launching Summer 2021, + {' '} + join the waitlist to be first in line! + + + + + + Attention Open Source Developers: + + + Enter your Github handle on the next page to skip the wait and join our dev-only beta; + {' '} + help build tomorrow and + {' '} + openURLInNewTab(CONST.UPWORK_URL)} + > + earn cash + + {' '} + today! + + + + ); + } +} + +export default LoginFormNarrow; diff --git a/src/pages/signin/LoginForm/LoginFormWide.js b/src/pages/signin/LoginForm/LoginFormWide.js new file mode 100644 index 000000000000..81fe78a4e219 --- /dev/null +++ b/src/pages/signin/LoginForm/LoginFormWide.js @@ -0,0 +1,108 @@ +import React from 'react'; +import {Text, TextInput, View} from 'react-native'; +import {fetchAccountDetails} from '../../../libs/actions/Session'; +import styles from '../../../styles/styles'; +import SubmitButton from '../SubmitButton'; +import openURLInNewTab from '../../../libs/openURLInNewTab'; +import CONST from '../../../CONST'; + +class LoginFormWide extends React.Component { + constructor(props) { + super(props); + + this.validateAndSubmitForm = this.validateAndSubmitForm.bind(this); + + this.state = { + formError: false, + login: '', + isLoading: false, + }; + } + + /** + * Check that all the form fields are valid, then trigger the submit callback + */ + validateAndSubmitForm() { + if (!this.state.login.trim()) { + this.setState({formError: 'Please enter an email or phone number'}); + return; + } + + this.setState({ + formError: null, + isLoading: true, + }); + + // Check if this login has an account associated with it or not + fetchAccountDetails(this.state.login); + } + + render() { + return ( + <> + + + Sign up for the waitlist + this.setState({login: text})} + onSubmitEditing={this.validateAndSubmitForm} + autoCapitalize="none" + placeholder="Email or phone" + autoFocus + /> + + + + + + {this.state.formError && ( + + {this.state.formError} + + )} + + + + + + With Expensify.cash, chat and payments are the same thing. Launching Summer 2021, + {' '} + join the waitlist to be first in line! + + + + + + Attention Open Source Developers: + + + Enter your Github handle on the next page to skip the wait and join our dev-only beta; + {' '} + help build tomorrow and + {' '} + openURLInNewTab(CONST.UPWORK_URL)} + > + earn cash + + {' '} + today! + + + + + ); + } +} + +export default LoginFormWide; diff --git a/src/pages/signin/LoginForm/index.js b/src/pages/signin/LoginForm/index.js new file mode 100644 index 000000000000..0d2869c53aaf --- /dev/null +++ b/src/pages/signin/LoginForm/index.js @@ -0,0 +1,49 @@ +import React from 'react'; +import {Dimensions} from 'react-native'; +import _ from 'underscore'; +import variables from '../../../styles/variables'; +import LoginFormNarrow from './LoginFormNarrow'; +import LoginFormWide from './LoginFormWide'; + +class LoginForm extends React.Component { + constructor(props) { + super(props); + + this.toggleScreenWidth = _.debounce(this.toggleScreenWidth.bind(this), 1000, true); + + this.state = { + isWideScreen: null, + }; + } + + componentDidMount() { + Dimensions.addEventListener('change', this.toggleScreenWidth); + this.toggleScreenWidth({window: Dimensions.get('window')}); + } + + componentWillUnmount() { + Dimensions.removeEventListener('change', this.toggleScreenWidth); + } + + /** + * Fired when the windows dimensions changes + * @param {Object} changedWindow + */ + toggleScreenWidth({window: changedWindow}) { + this.setState({ + isWideScreen: changedWindow.width > variables.mobileResponsiveWidthBreakpoint, + }); + } + + render() { + if (this.state.isWideScreen === null) { + return null; + } + + return this.state.isWideScreen + ? + : ; + } +} + +export default LoginForm; diff --git a/src/pages/signin/LoginForm/index.native.js b/src/pages/signin/LoginForm/index.native.js new file mode 100644 index 000000000000..e0f7e358f4fd --- /dev/null +++ b/src/pages/signin/LoginForm/index.native.js @@ -0,0 +1,3 @@ +import LoginFormNarrow from './LoginFormNarrow'; + +export default LoginFormNarrow; diff --git a/src/pages/signin/PasswordForm.js b/src/pages/signin/PasswordForm.js new file mode 100644 index 000000000000..545e088dc8d1 --- /dev/null +++ b/src/pages/signin/PasswordForm.js @@ -0,0 +1,124 @@ +import React from 'react'; +import {Text, TextInput, View} from 'react-native'; +import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; +import {withRouter} from '../../libs/Router'; +import styles from '../../styles/styles'; +import SubmitButton from './SubmitButton'; +import themeColors from '../../styles/themes/default'; +import {signIn} from '../../libs/actions/Session'; +import compose from '../../libs/compose'; +import ONYXKEYS from '../../ONYXKEYS'; + +const propTypes = { + // These are from withRouter + // eslint-disable-next-line react/forbid-prop-types + match: PropTypes.object.isRequired, + + /* Onyx Props */ + + // The details about the account that the user is signing in with + account: PropTypes.shape({ + // Whether or not the account already exists + accountExists: PropTypes.bool, + + // Whether or not there have been chat reports shared with this user + canAccessExpensifyCash: PropTypes.bool, + + // Whether or not two factor authentication is required + requiresTwoFactorAuth: PropTypes.bool, + }), +}; + +const defaultProps = { + account: {}, +}; + +class PasswordForm extends React.Component { + constructor(props) { + super(props); + + this.validateAndSubmitForm = this.validateAndSubmitForm.bind(this); + + this.state = { + formError: false, + password: '', + twoFactorAuthCode: '', + isLoading: false, + }; + } + + /** + * Check that all the form fields are valid, then trigger the submit callback + */ + validateAndSubmitForm() { + if (!this.state.password.trim() + || (this.props.account.requiresTwoFactorAuth && !this.state.twoFactorAuthCode.trim()) + ) { + this.setState({formError: 'Please fill out all fields'}); + return; + } + + this.setState({ + formError: null, + isLoading: true, + }); + + signIn(this.state.password, this.props.match.params.exitTo, this.state.twoFactorAuthCode); + } + + render() { + return ( + + + Password + this.setState({password: text})} + onSubmitEditing={this.validateAndSubmitForm} + autoFocus + /> + + {this.props.account.requiresTwoFactorAuth && ( + + Two Factor Code + this.setState({twoFactorAuthCode: text})} + onSubmitEditing={this.validateAndSubmitForm} + /> + + )} + + + + {this.state.formError && ( + + {this.state.formError} + + )} + + ); + } +} + +PasswordForm.propTypes = propTypes; +PasswordForm.defaultProps = defaultProps; + +export default compose( + withRouter, + withOnyx({ + account: {key: ONYXKEYS.ACCOUNT}, + }), +)(PasswordForm); diff --git a/src/pages/signin/ResendValidationForm.js b/src/pages/signin/ResendValidationForm.js new file mode 100644 index 000000000000..765e6eeaaa11 --- /dev/null +++ b/src/pages/signin/ResendValidationForm.js @@ -0,0 +1,60 @@ +import React from 'react'; +import {Text, View} from 'react-native'; +import styles from '../../styles/styles'; +import SubmitButton from './SubmitButton'; +import {resendValidationLink} from '../../libs/actions/Session'; + +class ResendValidationForm extends React.Component { + constructor(props) { + super(props); + + this.validateAndSubmitForm = this.validateAndSubmitForm.bind(this); + + this.state = { + formSuccess: '', + isLoading: false, + }; + } + + /** + * Check that all the form fields are valid, then trigger the submit callback + */ + validateAndSubmitForm() { + this.setState({ + formSuccess: 'Link has been re-sent', + }); + + resendValidationLink(); + + setTimeout(() => { + this.setState({formSuccess: ''}); + }, 5000); + } + + render() { + return ( + + + + Please validate your account by clicking on the link we just sent you. + + + + + + + {this.state.formSuccess && ( + + {this.state.formSuccess} + + )} + + ); + } +} + +export default ResendValidationForm; diff --git a/src/pages/signin/SignInPage.js b/src/pages/signin/SignInPage.js new file mode 100644 index 000000000000..41eb0975712c --- /dev/null +++ b/src/pages/signin/SignInPage.js @@ -0,0 +1,147 @@ +import React, {Component} from 'react'; +import { + SafeAreaView, Text, View +} from 'react-native'; +import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; +import _ from 'underscore'; +import compose from '../../libs/compose'; +import {Redirect} from '../../libs/Router'; +import ROUTES from '../../ROUTES'; +import ONYXKEYS from '../../ONYXKEYS'; +import styles from '../../styles/styles'; +import CustomStatusBar from '../../components/CustomStatusBar'; +import updateUnread from '../../libs/UnreadIndicatorUpdater/updateUnread/index'; +import SignInPageLayout from './SignInPageLayout'; +import LoginForm from './LoginForm'; +import GithubUsernameForm from './GithubUsernameForm'; +import PasswordForm from './PasswordForm'; +import ResendValidationForm from './ResendValidationForm'; + +const propTypes = { + /* Onyx Props */ + + // The details about the account that the user is signing in with + account: PropTypes.shape({ + // Whether or not the account already exists + accountExists: PropTypes.bool, + + // Whether or not there have been chat reports shared with this user + canAccessExpensifyCash: PropTypes.bool, + }), + + // The credentials of the person signing in + credentials: PropTypes.shape({ + login: PropTypes.string, + githubUsername: PropTypes.string, + password: PropTypes.string, + twoFactorAuthCode: PropTypes.string, + }), + + // The session of the logged in person + session: PropTypes.shape({ + // Error to display when there is a session error returned + authToken: PropTypes.string, + + // Error to display when there is a session error returned + error: PropTypes.string, + }), +}; + +const defaultProps = { + account: {}, + session: {}, + credentials: {}, +}; + +class SignInPage extends Component { + componentDidMount() { + // Always reset the unread counter to zero on this page + updateUnread(0); + } + + render() { + // If we end up on the sign in page and have an authToken then + // we are signed in and should be brought back to the site root + if (this.props.session.authToken) { + return ; + } + + // Show the login form if + // - A login has not been entered yet + const showLoginForm = !this.props.credentials.login; + + // Show the GitHub username form if + // - A login has been entered + // - AND they do not have access to this app yet + // - AND the user hasn't entered a GitHub username yet + // - AND a password hasn't been entered yet + const showGithubUsernameForm = this.props.credentials.login + && !this.props.account.canAccessExpensifyCash + && !this.props.credentials.githubUsername + && !this.props.credentials.password; + + // Show the password form if + // - A login has been entered + // - AND a GitHub username has been entered OR they already have access to expensify cash + // - AND an account exists already for this login + // - AND a password hasn't been entered yet + const showPasswordForm = this.props.credentials.login + && ( + this.props.credentials.githubUsername + || this.props.account.canAccessExpensifyCash + ) + && this.props.account.accountExists + && !this.props.credentials.password; + + // Show the resend validation link form if + // - A login has been entered + // - AND a GitHub username has been entered OR they already have access to this app + // - AND an account did not exist for that login + const showResendValidationLinkForm = this.props.credentials.login + && ( + this.props.credentials.githubUsername + || this.props.account.canAccessExpensifyCash + ) + && !this.props.account.accountExists; + + return ( + <> + + + + {showLoginForm && } + + {showGithubUsernameForm && } + + {showPasswordForm && } + + {showResendValidationLinkForm && } + + {/* Because of the custom layout of the login form, session errors are displayed differently */} + {!showLoginForm && ( + + {this.props.session && !_.isEmpty(this.props.session.error) && ( + + {this.props.session.error} + + )} + + )} + + + + ); + } +} + +SignInPage.propTypes = propTypes; +SignInPage.defaultProps = defaultProps; + +export default compose( + withOnyx({ + account: {key: ONYXKEYS.ACCOUNT}, + credentials: {key: ONYXKEYS.CREDENTIALS}, + session: {key: ONYXKEYS.SESSION}, + }) +)(SignInPage); diff --git a/src/pages/signin/SignInPageLayout/SignInPageLayoutNarrow.js b/src/pages/signin/SignInPageLayout/SignInPageLayoutNarrow.js new file mode 100644 index 000000000000..897350120580 --- /dev/null +++ b/src/pages/signin/SignInPageLayout/SignInPageLayoutNarrow.js @@ -0,0 +1,42 @@ +import React from 'react'; +import { + Image, ScrollView, Text, View +} from 'react-native'; +import PropTypes from 'prop-types'; +import styles from '../../../styles/styles'; +import logo from '../../../../assets/images/expensify-logo-round.png'; + +const propTypes = { + // The children to show inside the layout + children: PropTypes.node.isRequired, +}; + +const SignInPageLayoutNarrow = ({children}) => ( + + + + + + + + + + Expensify.cash + + + + {children} + + + +); + +SignInPageLayoutNarrow.propTypes = propTypes; +SignInPageLayoutNarrow.displayName = 'SignInPageLayoutNarrow'; + + +export default SignInPageLayoutNarrow; diff --git a/src/pages/signin/SignInPageLayout/SignInPageLayoutWide.js b/src/pages/signin/SignInPageLayout/SignInPageLayoutWide.js new file mode 100644 index 000000000000..d6c1ceaa8096 --- /dev/null +++ b/src/pages/signin/SignInPageLayout/SignInPageLayoutWide.js @@ -0,0 +1,52 @@ +import React from 'react'; +import { + Image, Text, View +} from 'react-native'; +import PropTypes from 'prop-types'; +import styles from '../../../styles/styles'; +import logo from '../../../../assets/images/expensify-logo-round.png'; +import welcomeScreenshot from '../../../../assets/images/welcome-screenshot-wide.png'; + +const propTypes = { + // The children to show inside the layout + children: PropTypes.node.isRequired, +}; + +const SignInPageLayoutWide = ({children}) => ( + + + + + + + + + + + + + + + Expensify.cash + + + + {children} + + + +); + +SignInPageLayoutWide.propTypes = propTypes; +SignInPageLayoutWide.displayName = 'SignInPageLayoutWide'; + + +export default SignInPageLayoutWide; diff --git a/src/pages/signin/SignInPageLayout/index.js b/src/pages/signin/SignInPageLayout/index.js new file mode 100644 index 000000000000..1e19b2d35921 --- /dev/null +++ b/src/pages/signin/SignInPageLayout/index.js @@ -0,0 +1,51 @@ +import React from 'react'; +import {Dimensions} from 'react-native'; +import _ from 'underscore'; +import variables from '../../../styles/variables'; +import SignInPageLayoutNarrow from './SignInPageLayoutNarrow'; +import SignInPageLayoutWide from './SignInPageLayoutWide'; + +class LoginForm extends React.Component { + constructor(props) { + super(props); + + this.toggleScreenWidth = _.debounce(this.toggleScreenWidth.bind(this), 1000, true); + + this.state = { + isWideScreen: null, + }; + } + + componentDidMount() { + Dimensions.addEventListener('change', this.toggleScreenWidth); + this.toggleScreenWidth({window: Dimensions.get('window')}); + } + + componentWillUnmount() { + Dimensions.removeEventListener('change', this.toggleScreenWidth); + } + + /** + * Fired when the windows dimensions changes + * @param {Object} changedWindow + */ + toggleScreenWidth({window: changedWindow}) { + this.setState({ + isWideScreen: changedWindow.width > variables.mobileResponsiveWidthBreakpoint, + }); + } + + render() { + if (this.state.isWideScreen === null) { + return null; + } + + return this.state.isWideScreen + // eslint-disable-next-line react/jsx-props-no-spreading + ? + // eslint-disable-next-line react/jsx-props-no-spreading + : ; + } +} + +export default LoginForm; diff --git a/src/pages/signin/SignInPageLayout/index.native.js b/src/pages/signin/SignInPageLayout/index.native.js new file mode 100644 index 000000000000..775b3cd9fc53 --- /dev/null +++ b/src/pages/signin/SignInPageLayout/index.native.js @@ -0,0 +1,3 @@ +import SignInPageLayoutNarrow from './SignInPageLayoutNarrow'; + +export default SignInPageLayoutNarrow; diff --git a/src/pages/signin/SubmitButton.js b/src/pages/signin/SubmitButton.js new file mode 100644 index 000000000000..12215cf873c8 --- /dev/null +++ b/src/pages/signin/SubmitButton.js @@ -0,0 +1,82 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + ActivityIndicator, Text, TouchableOpacity, View +} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import styles from '../../styles/styles'; +import themeColors from '../../styles/themes/default'; +import ONYXKEYS from '../../ONYXKEYS'; +import {restartSignin} from '../../libs/actions/Session'; + +const propTypes = { + // The text for the button label + text: PropTypes.string.isRequired, + + // Indicates whether the button should be disabled and in the loading state + isLoading: PropTypes.bool.isRequired, + + // A function that is called when the button is clicked on + onClick: PropTypes.func.isRequired, + + // Whether or not to show the restart sign in button + showRestartButton: PropTypes.bool, + + /* Onyx Props */ + + // The session of the logged in person + session: PropTypes.shape({ + // Error to display when there is a session error returned + error: PropTypes.string, + }), +}; +const defaultProps = { + showRestartButton: true, + session: {}, +}; + +const SubmitButton = (props) => { + // When there is an error in the session (a sign on error) then the button should be + // enabled so the form can be submitted again + const isLoading = props.isLoading && !props.session.error; + return ( + <> + + {isLoading ? ( + + ) : ( + + {props.text} + + )} + + + {props.showRestartButton && ( + + + + Change Expensify login + + + + )} + + ); +}; + +SubmitButton.propTypes = propTypes; +SubmitButton.defaultProps = defaultProps; +SubmitButton.displayName = 'SubmitButton'; + +export default withOnyx({ + session: {key: ONYXKEYS.SESSION}, +})(SubmitButton); diff --git a/src/styles/styles.js b/src/styles/styles.js index 9bdce32db1fd..e1335dd3f627 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -37,17 +37,24 @@ const styles = { marginLeft: 8, }, - mt2: { - marginTop: 20, - }, - mt1: { marginTop: 4, }, - + mt2: { + marginTop: 8, + }, mt3: { marginTop: 12, }, + mt4: { + marginTop: 16, + }, + mt5: { + marginTop: 20, + }, + mt6: { + marginTop: 24, + }, mb1: { marginBottom: 4, @@ -61,7 +68,12 @@ const styles = { mb4: { marginBottom: 16, }, - + mb5: { + marginBottom: 20, + }, + mb6: { + marginBottom: 24, + }, mbn5: { marginBottom: -5, }, @@ -83,6 +95,9 @@ const styles = { width: '100%', height: '100%', }, + width50p: { + width: '50%', + }, flex0: { flex: 0, @@ -160,6 +175,24 @@ const styles = { height: '100%', }, + link: { + color: themeColors.link, + textDecorationColor: themeColors.link, + }, + + h1: { + color: themeColors.heading, + fontFamily: fontFamily.GTA_BOLD, + fontSize: variables.fontSizeh1, + fontWeight: fontWeightBold, + }, + + h3: { + fontFamily: fontFamily.GTA_BOLD, + fontSize: variables.fontSizeNormal, + fontWeight: fontWeightBold, + }, + h4: { fontFamily: fontFamily.GTA_BOLD, fontSize: variables.fontSizeLabel, @@ -168,6 +201,7 @@ const styles = { textP: { color: themeColors.text, + fontFamily: fontFamily.GTA, fontSize: variables.fontSizeNormal, lineHeight: 20, }, @@ -342,15 +376,21 @@ const styles = { textInputNoOutline: addOutlineWidth({}, 0), formLabel: { - color: themeColors.heading, + color: themeColors.text, fontSize: variables.fontSizeLabel, - fontWeight: '600', lineHeight: 18, - marginBottom: 4, + marginBottom: 8, }, formError: { - color: themeColors.errorText, + color: themeColors.textError, + fontSize: variables.fontSizeLabel, + lineHeight: 18, + marginBottom: 4, + }, + + formSuccess: { + color: themeColors.textSuccess, fontSize: variables.fontSizeLabel, lineHeight: 18, marginBottom: 4, @@ -358,21 +398,37 @@ const styles = { signInPage: { backgroundColor: themeColors.sidebar, - height: '100%', padding: 20, + minHeight: '100%', }, signInPageLogo: { + height: variables.componentSizeLarge, + marginBottom: 24, + }, + + signInPageLogoNative: { alignItems: 'center', - height: variables.componentSizeNormal, + height: variables.componentSizeLarge, justifyContent: 'center', width: '100%', - marginBottom: 24, + marginBottom: 20, + marginTop: 20, }, signinLogo: { - height: variables.componentSizeNormal, - width: variables.componentSizeNormal, + height: variables.componentSizeLarge, + width: variables.componentSizeLarge, + }, + + signinWelcomeScreenshot: { + height: 354, + width: 295, + }, + + signinWelcomeScreenshotWide: { + height: 592, + width: 295, }, genericView: { @@ -381,9 +437,22 @@ const styles = { }, signInPageInner: { + paddingTop: 40, marginLeft: 'auto', marginRight: 'auto', - maxWidth: 325, + maxWidth: 800, + width: '100%', + }, + + signInPageInnerNative: { + marginLeft: 'auto', + marginRight: 'auto', + maxWidth: 295, + width: '100%', + }, + + loginFormContainer: { + maxWidth: 295, width: '100%', }, @@ -1006,10 +1075,7 @@ const webViewStyles = { fontWeight: fontWeightBold, }, - a: { - color: themeColors.link, - textDecorationColor: themeColors.link, - }, + a: styles.link, blockquote: { borderLeftColor: themeColors.border, diff --git a/src/styles/themes/default.js b/src/styles/themes/default.js index 242e2970edbc..912114ea9044 100644 --- a/src/styles/themes/default.js +++ b/src/styles/themes/default.js @@ -5,20 +5,21 @@ export default { link: colors.blue, componentBG: colors.white, appBG: colors.white, + heading: colors.charcoal, sidebar: colors.gray1, border: colors.gray2, borderFocus: colors.blue, icon: colors.gray3, textSupporting: colors.gray4, text: colors.gray5, - heading: colors.charcoal, + textError: colors.red, + textSuccess: colors.green, textBackground: colors.gray1, textReversed: colors.white, textMutedReversed: colors.gray3, buttonSuccessBG: colors.green, online: colors.green, offline: colors.gray3, - errorText: colors.red, sidebarButtonBG: 'rgba(198, 201, 202, 0.25)', modalBackdrop: colors.gray3, pillBG: colors.gray2, diff --git a/src/styles/variables.js b/src/styles/variables.js index 4332c688549f..f29bdf4a0523 100644 --- a/src/styles/variables.js +++ b/src/styles/variables.js @@ -1,11 +1,14 @@ export default { contentHeaderHeight: 65, - componentSizeNormal: 40, componentSizeSmall: 28, + componentSizeNormal: 40, + componentSizeLarge: 50, componentBorderRadius: 8, fontSizeSmall: 11, fontSizeLabel: 13, fontSizeNormal: 15, + fontSizeLarge: 17, + fontSizeh1: 19, mobileResponsiveWidthBreakpoint: 1000, safeInsertPercentage: 0.7, }; diff --git a/tests/e2e/loginTest.e2e.js b/tests/e2e/loginTest.e2e.js index 154f32fe9367..f69ab4b5399b 100644 --- a/tests/e2e/loginTest.e2e.js +++ b/tests/e2e/loginTest.e2e.js @@ -5,5 +5,5 @@ jest.setTimeout(120000 * 10); describe('Test login page', () => { beforeEach(() => device.reloadReactNative()); - it('should have a Log In button visible', () => expect(element(by.text('Log In'))).toBeVisible()); + it('should have a Log In button visible', () => expect(element(by.text('Continue'))).toBeVisible()); }); diff --git a/web/favicon-unread.png b/web/favicon-unread.png index c5de4c8308ec..b1057d32c600 100644 Binary files a/web/favicon-unread.png and b/web/favicon-unread.png differ diff --git a/web/favicon.png b/web/favicon.png index fc79905bf7fb..0e150943264e 100644 Binary files a/web/favicon.png and b/web/favicon.png differ