Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[User Settings] Allow users to add secondary logins #2169

Merged
merged 14 commits into from
Apr 6, 2021
4 changes: 4 additions & 0 deletions src/CONST.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ 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',
},
};

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 @@ -529,7 +529,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
Maftalion marked this conversation as resolved.
Show resolved Hide resolved
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
25 changes: 21 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,33 @@ function setExpensifyNewsStatus(subscribed) {
*
* @param {String} login
* @param {String} password
* @returns {Promise}
*
Maftalion marked this conversation as resolved.
Show resolved Hide resolved
*/
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
156 changes: 156 additions & 0 deletions src/pages/settings/AddSecondaryLoginPage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
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({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are missing inline comments, please add them

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() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing method docs

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

// Determines whether form is valid
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not valid method docs

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 ? 'phone-pad' : undefined}
Maftalion marked this conversation as resolved.
Show resolved Hide resolved
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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing inline comments

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() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing method docs

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

// Revert checkmark back to "Resend" after 5seconds
Maftalion marked this conversation as resolved.
Show resolved Hide resolved
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
Maftalion marked this conversation as resolved.
Show resolved Hide resolved
} 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
Maftalion marked this conversation as resolved.
Show resolved Hide resolved
} else {
note = 'Use your phone number to settle up via Venmo.';
}

// Has unvalidated email
Maftalion marked this conversation as resolved.
Show resolved Hide resolved
} 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