Skip to content

Commit

Permalink
Merge pull request #2169 from Maftalion/matt-2029-secondary-logins
Browse files Browse the repository at this point in the history
[User Settings] Allow users to add secondary logins
  • Loading branch information
Tim Szot authored Apr 6, 2021
2 parents ab3bd6d + 4ce0892 commit 5ab2683
Show file tree
Hide file tree
Showing 11 changed files with 405 additions and 36 deletions.
8 changes: 8 additions & 0 deletions src/CONST.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ const CONST = {

// at least 8 characters, 1 capital letter, 1 lowercase number, 1 number
PASSWORD_COMPLEXITY_REGEX_STRING: '^(?=.*[A-Z])(?=.*[0-9])(?=.*[a-z]).{8,}$',
LOGIN_TYPE: {
PHONE: 'phone',
EMAIL: 'email',
},
KEYBOARD_TYPE: {
NUMERIC: 'numeric',
PHONE_PAD: 'phone-pad',
},
};

export default CONST;
2 changes: 2 additions & 0 deletions src/ROUTES.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export default {
SETTINGS_PREFERENCES: 'settings/preferences',
SETTINGS_PASSWORD: 'settings/password',
SETTINGS_PAYMENTS: 'settings/payments',
SETTINGS_ADD_LOGIN: 'settings/addlogin/:type',
getSettingsAddLoginRoute: type => `settings/addlogin/${type}`,
NEW_GROUP: 'new/group',
NEW_CHAT: 'new/chat',
REPORT: 'r',
Expand Down
2 changes: 1 addition & 1 deletion src/libs/API.js
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,7 @@ function Report_UpdateLastRead(parameters) {

/**
* @param {Object} parameters
* @param {Number} parameters.email
* @param {String} parameters.email
* @returns {Promise}
*/
function ResendValidateCode(parameters) {
Expand Down
5 changes: 5 additions & 0 deletions src/libs/Navigation/AppNavigator/ModalStackNavigators.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import SettingsProfilePage from '../../../pages/settings/ProfilePage';
import SettingsPreferencesPage from '../../../pages/settings/PreferencesPage';
import SettingsPasswordPage from '../../../pages/settings/PasswordPage';
import SettingsPaymentsPage from '../../../pages/settings/PaymentsPage';
import SettingsAddSecondaryLoginPage from '../../../pages/settings/AddSecondaryLoginPage';

// Setup the modal stack navigators so we only have to create them once
const SettingsModalStack = createStackNavigator();
Expand Down Expand Up @@ -146,6 +147,10 @@ const SettingsModalStackNavigator = () => (
name="Settings_Profile"
component={SettingsProfilePage}
/>
<SettingsModalStack.Screen
name="Settings_Add_Seconday_Login"
component={SettingsAddSecondaryLoginPage}
/>
<SettingsModalStack.Screen
name="Settings_Preferences"
component={SettingsPreferencesPage}
Expand Down
3 changes: 3 additions & 0 deletions src/libs/Navigation/linkingConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export default {
path: ROUTES.SETTINGS_PROFILE,
exact: true,
},
Settings_Add_Seconday_Login: {
path: ROUTES.SETTINGS_ADD_LOGIN,
},
},
},
NewGroup: {
Expand Down
24 changes: 20 additions & 4 deletions src/libs/actions/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,10 @@ function fetch() {
/**
* Resends a validation link to a given login
*
* @param {String} email
* @param {String} login
*/
function resendValidateCode(email) {
API.ResendValidateCode({email});
function resendValidateCode(login) {
API.ResendValidateCode({email: login});
}

/**
Expand All @@ -90,16 +90,32 @@ function setExpensifyNewsStatus(subscribed) {
*
* @param {String} login
* @param {String} password
* @returns {Promise}
*/
function setSecondaryLogin(login, password) {
API.User_SecondaryLogin_Send({
Onyx.merge(ONYXKEYS.ACCOUNT, {error: '', loading: true});

return API.User_SecondaryLogin_Send({
email: login,
password,
}).then((response) => {
if (response.jsonCode === 200) {
const loginList = _.where(response.loginList, {partnerName: 'expensify.com'});
Onyx.merge(ONYXKEYS.USER, {loginList});
} else {
let error = lodashGet(response, 'message', 'Unable to add secondary login. Please try again.');

// Replace error with a friendlier message
if (error.includes('already belongs to an existing Expensify account.')) {
error = 'This login already belongs to an existing Expensify account.';
}

Onyx.merge(ONYXKEYS.USER, {error});
}
return response;
}).finally((response) => {
Onyx.merge(ONYXKEYS.ACCOUNT, {loading: false});
return response;
});
}

Expand Down
157 changes: 157 additions & 0 deletions src/pages/settings/AddSecondaryLoginPage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import React, {Component} from 'react';
import Onyx, {withOnyx} from 'react-native-onyx';
import PropTypes from 'prop-types';
import {View, TextInput} from 'react-native';
import _ from 'underscore';
import Str from 'expensify-common/lib/str';
import HeaderWithCloseButton from '../../components/HeaderWithCloseButton';
import Navigation from '../../libs/Navigation/Navigation';
import ScreenWrapper from '../../components/ScreenWrapper';
import Text from '../../components/Text';
import styles from '../../styles/styles';
import {setSecondaryLogin} from '../../libs/actions/User';
import ONYXKEYS from '../../ONYXKEYS';
import ButtonWithLoader from '../../components/ButtonWithLoader';
import ROUTES from '../../ROUTES';
import CONST from '../../CONST';

const propTypes = {
/* Onyx Props */
// The details about the user that is signed in
user: PropTypes.shape({
// error associated with adding a secondary login
error: PropTypes.string,

// Whether the form is being submitted
loading: PropTypes.bool,

// Whether or not the user is subscribed to news updates
loginList: PropTypes.arrayOf(PropTypes.shape({

// Value of partner name
partnerName: PropTypes.string,

// Phone/Email associated with user
partnerUserID: PropTypes.string,

// Date of when login was validated
validatedDate: PropTypes.string,
})),
}),

// Route object from navigation
route: PropTypes.shape({
params: PropTypes.shape({
type: PropTypes.string,
}),
}),
};

const defaultProps = {
user: {},
route: {},
};

class AddSecondaryLoginPage extends Component {
constructor(props) {
super(props);

this.state = {
login: '',
password: '',
};
this.formType = props.route.params.type;
this.submitForm = this.submitForm.bind(this);
this.validateForm = this.validateForm.bind(this);
}

componentWillUnmount() {
Onyx.merge(ONYXKEYS.USER, {error: ''});
}

submitForm() {
setSecondaryLogin(this.state.login, this.state.password)
.then((response) => {
if (response.jsonCode === 200) {
Navigation.navigate(ROUTES.SETTINGS_PROFILE);
}
});
}

// Determines whether form is valid
validateForm() {
const validationMethod = this.formType === CONST.LOGIN_TYPE.PHONE ? Str.isValidPhone : Str.isValidEmail;
return !this.state.password || !validationMethod(this.state.login);
}

render() {
return (
<ScreenWrapper>
<HeaderWithCloseButton
title={this.formType === CONST.LOGIN_TYPE.PHONE ? 'Add Phone Number' : 'Add Email Address'}
shouldShowBackButton
onBackButtonPress={() => Navigation.navigate(ROUTES.SETTINGS_PROFILE)}
onCloseButtonPress={Navigation.dismissModal}
/>
<View style={[styles.p5, styles.flex1, styles.overflowScroll]}>
<View style={styles.flexGrow1}>
<Text style={[styles.mb6, styles.textP]}>
{this.formType === CONST.LOGIN_TYPE.PHONE
? 'Enter your preferred phone number and password to send a validation link.'
: 'Enter your preferred email address and password to send a validation link.'}
</Text>
<View style={styles.mb6}>
<Text style={[styles.mb1, styles.formLabel]}>
{this.formType === CONST.LOGIN_TYPE.PHONE ? 'Phone Number' : 'Email Address'}
</Text>
<TextInput
style={styles.textInput}
value={this.state.login}
onChangeText={login => this.setState({login})}
autoFocus
keyboardType={this.formType === CONST.LOGIN_TYPE.PHONE
? CONST.KEYBOARD_TYPE.PHONE_PAD : undefined}
returnKeyType="done"
/>
</View>
<View style={styles.mb6}>
<Text style={[styles.mb1, styles.formLabel]}>Password</Text>
<TextInput
style={styles.textInput}
value={this.state.password}
onChangeText={password => this.setState({password})}
secureTextEntry
autoCompleteType="password"
textContentType="password"
onSubmitEditing={this.submitForm}
/>
</View>
{!_.isEmpty(this.props.user.error) && (
<Text style={styles.formError}>
{this.props.user.error}
</Text>
)}
</View>
<View style={[styles.flexGrow0]}>
<ButtonWithLoader
isDisabled={this.validateForm()}
isLoading={this.props.user.loading}
text="Send Validation"
onClick={this.submitForm}
/>
</View>
</View>
</ScreenWrapper>
);
}
}

AddSecondaryLoginPage.propTypes = propTypes;
AddSecondaryLoginPage.defaultProps = defaultProps;
AddSecondaryLoginPage.displayName = 'AddSecondaryLoginPage';

export default withOnyx({
user: {
key: ONYXKEYS.USER,
},
})(AddSecondaryLoginPage);
126 changes: 126 additions & 0 deletions src/pages/settings/ProfilePage/LoginField.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import React, {Component} from 'react';
import {View, Pressable} from 'react-native';
import PropTypes from 'prop-types';
import Text from '../../../components/Text';
import styles from '../../../styles/styles';
import colors from '../../../styles/colors';
import {Plus, Checkmark} from '../../../components/Icon/Expensicons';
import Icon from '../../../components/Icon';
import ROUTES from '../../../ROUTES';
import CONST from '../../../CONST';
import Navigation from '../../../libs/Navigation/Navigation';
import {resendValidateCode} from '../../../libs/actions/User';

const propTypes = {
// Label to display on login form
label: PropTypes.string.isRequired,

// Type associated with the login
type: PropTypes.oneOf([CONST.LOGIN_TYPE.EMAIL, CONST.LOGIN_TYPE.PHONE]).isRequired,

// Login associated with the user
login: PropTypes.shape({
partnerUserID: PropTypes.string,
validatedDate: PropTypes.string,
}).isRequired,
};

export default class LoginField extends Component {
constructor(props) {
super(props);
this.state = {
showCheckmarkIcon: false,
};
this.timeout = null;
this.onResendClicked = this.onResendClicked.bind(this);
}

onResendClicked() {
resendValidateCode(this.props.login.partnerUserID);
this.setState({showCheckmarkIcon: true});

// Revert checkmark back to "Resend" after 5 seconds
if (!this.timeout) {
this.timeout = setTimeout(() => {
if (this.timeout) {
this.setState({showCheckmarkIcon: false});
this.timeout = null;
}
}, 5000);
}
}

render() {
let note;
if (this.props.type === CONST.LOGIN_TYPE.PHONE) {
// No phone number
if (!this.props.login.partnerUserID) {
note = 'Add your phone number to settle up via Venmo.';

// Has unvalidated phone number
} else if (!this.props.login.validatedDate) {
// eslint-disable-next-line max-len
note = 'The number has not yet been validated. Click the button to resend the validation link via text.';

// Has verified phone number
} else {
note = 'Use your phone number to settle up via Venmo.';
}

// Has unvalidated email
} else if (this.props.login.partnerUserID && !this.props.login.validatedDate) {
note = 'The email has not yet been validated. Click the button to resend the validation link via text.';
}

return (
<View style={styles.mb6}>
<Text style={styles.formLabel}>{this.props.label}</Text>
{!this.props.login.partnerUserID ? (
<Pressable
style={[styles.createMenuItem, styles.ph0]}
onPress={() => Navigation.navigate(ROUTES.getSettingsAddLoginRoute(this.props.type))}
>
<View style={styles.flexRow}>
<View style={styles.createMenuIcon}>
<Icon src={Plus} />
</View>
<View style={styles.justifyContentCenter}>
<Text style={[styles.createMenuText, styles.ml3]}>
{`Add ${this.props.label}`}
</Text>
</View>
</View>
</Pressable>
) : (
<View style={[styles.flexRow, styles.justifyContentBetween, styles.alignItemsCenter]}>
<Text style={[styles.textP]} numberOfLines={1}>
{this.props.login.partnerUserID}
</Text>
{!this.props.login.validatedDate && (
<Pressable
style={[styles.button, styles.mb2]}
onPress={this.onResendClicked}
>
{this.state.showCheckmarkIcon ? (
<Icon fill={colors.black} src={Checkmark} />
) : (
<Text style={styles.createMenuText}>
Resend
</Text>
)}
</Pressable>
)}
</View>
)}
{note && (
<Text style={[styles.textLabel, styles.colorMuted]}>
{note}
</Text>
)}
</View>
);
}
}

LoginField.propTypes = propTypes;
LoginField.displayName = 'LoginField';
Loading

0 comments on commit 5ab2683

Please sign in to comment.