From 477609375cafb8eacd691a1ce6271e88b87d49a4 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 23 Feb 2018 17:29:06 -0300 Subject: [PATCH] [NEW] OAuth (#241) * Layout * tmp * test iscordova * Webview redirecting * Open and Close login actions * Login services saved on redux * OAuth Github * Server regex fix * OAuth modal style * - Twitter login - Remove services from redux - Open login saga fix * - Facebook login - Fixed user agent - Reactions fix - Message url unique key fix * Google login * Email keyboard removed from messagebox * - Login buttons refactored - RoomList header * Layout improvements * Meteor login redirect_uri changed * fix * Random credentialToken state --- app/actions/actionsTypes.js | 6 +- app/actions/login.js | 26 +- app/containers/MessageBox/index.js | 1 - app/containers/message/index.js | 2 +- app/lib/rocketchat.js | 35 ++- app/reducers/login.js | 16 +- app/sagas/login.js | 16 +- app/utils/random.js | 8 + app/views/LoginView.js | 371 ++++++++++++++++++++++------- app/views/RoomsListView/index.js | 2 +- app/views/Styles.js | 45 +++- ios/RocketChatRN/AppDelegate.m | 4 + package-lock.json | 5 + package.json | 1 + 14 files changed, 439 insertions(+), 99 deletions(-) create mode 100644 app/utils/random.js diff --git a/app/actions/actionsTypes.js b/app/actions/actionsTypes.js index bb737dac1..8546f7f41 100644 --- a/app/actions/actionsTypes.js +++ b/app/actions/actionsTypes.js @@ -20,7 +20,11 @@ export const LOGIN = createRequestTypes('LOGIN', [ 'REGISTER_INCOMPLETE', 'SET_USERNAME_SUBMIT', 'SET_USERNAME_REQUEST', - 'SET_USERNAME_SUCCESS' + 'SET_USERNAME_SUCCESS', + 'OPEN', + 'CLOSE', + 'SET_SERVICES', + 'REMOVE_SERVICES' ]); export const FORGOT_PASSWORD = createRequestTypes('FORGOT_PASSWORD', [ ...defaultTypes, diff --git a/app/actions/login.js b/app/actions/login.js index d2a31cb55..875c46706 100644 --- a/app/actions/login.js +++ b/app/actions/login.js @@ -13,7 +13,6 @@ export function loginRequest(credentials) { }; } - export function registerSubmit(credentials) { return { type: types.LOGIN.REGISTER_SUBMIT, @@ -125,3 +124,28 @@ export function setUser(action) { ...action }; } + +export function open() { + return { + type: types.LOGIN.OPEN + }; +} + +export function close() { + return { + type: types.LOGIN.CLOSE + }; +} + +export function setLoginServices(data) { + return { + type: types.LOGIN.SET_SERVICES, + data + }; +} + +export function removeLoginServices() { + return { + type: types.LOGIN.REMOVE_SERVICES + }; +} diff --git a/app/containers/MessageBox/index.js b/app/containers/MessageBox/index.js index 34d6b94a2..d9811895b 100644 --- a/app/containers/MessageBox/index.js +++ b/app/containers/MessageBox/index.js @@ -460,7 +460,6 @@ export default class MessageBox extends React.PureComponent { this.component = component} style={styles.textBoxInput} - keyboardType='email-address' returnKeyType='default' blurOnSubmit={false} placeholder='New Message' diff --git a/app/containers/message/index.js b/app/containers/message/index.js index f6ce250aa..e5d85e905 100644 --- a/app/containers/message/index.js +++ b/app/containers/message/index.js @@ -165,7 +165,7 @@ export default class Message extends React.Component { } return this.props.item.urls.map(url => ( - + )); } diff --git a/app/lib/rocketchat.js b/app/lib/rocketchat.js index 0a4ac8a1a..8f97fef73 100644 --- a/app/lib/rocketchat.js +++ b/app/lib/rocketchat.js @@ -10,7 +10,7 @@ import messagesStatus from '../constants/messagesStatus'; import database from './realm'; import * as actions from '../actions'; import { someoneTyping, roomMessageReceived } from '../actions/room'; -import { setUser } from '../actions/login'; +import { setUser, setLoginServices, removeLoginServices } from '../actions/login'; import { disconnect, disconnect_by_user, connectSuccess, connectFailure } from '../actions/connect'; import { requestActiveUser } from '../actions/activeUsers'; import { starredMessageReceived, starredMessageUnstarred } from '../actions/starredMessages'; @@ -27,6 +27,8 @@ const SERVER_TIMEOUT = 30000; const normalizeMessage = (lastMessage) => { if (lastMessage) { lastMessage.attachments = lastMessage.attachments || []; + lastMessage.reactions = _.map(lastMessage.reactions, (value, key) => + ({ emoji: key, usernames: value.usernames.map(username => ({ value: username })) })); } return lastMessage; }; @@ -95,10 +97,11 @@ const RocketChat = { this.ddp.on('disconnected', () => { reduxStore.dispatch(disconnect()); }); - this.ddp.on('open', async() => { - resolve(reduxStore.dispatch(connectSuccess())); - }); + // this.ddp.on('open', async() => { + // resolve(reduxStore.dispatch(connectSuccess())); + // }); this.ddp.on('connected', () => { + resolve(reduxStore.dispatch(connectSuccess())); RocketChat.getSettings(); RocketChat.getPermissions(); RocketChat.getCustomEmoji(); @@ -171,6 +174,28 @@ const RocketChat = { return reduxStore.dispatch(pinnedMessageUnpinned(ddpMessage.id)); } }); + + this.ddp.on('meteor_accounts_loginServiceConfiguration', (ddpMessage) => { + if (ddpMessage.msg === 'added') { + this.loginServices = this.loginServices || {}; + if (this.loginServiceTimer) { + clearTimeout(this.loginServiceTimer); + this.loginServiceTimer = null; + } + this.loginServiceTimer = setTimeout(() => { + reduxStore.dispatch(setLoginServices(this.loginServices)); + this.loginServiceTimer = null; + return this.loginServices = {}; + }, 1000); + this.loginServices[ddpMessage.fields.service] = { ...ddpMessage.fields }; + delete this.loginServices[ddpMessage.fields.service].service; + } else if (ddpMessage.msg === 'removed') { + if (this.loginServiceTimer) { + clearTimeout(this.loginServiceTimer); + } + this.loginServiceTimer = setTimeout(() => reduxStore.dispatch(removeLoginServices()), 1000); + } + }); }).catch(console.log); }, @@ -302,8 +327,6 @@ const RocketChat = { // loadHistory returns message.starred as object // stream-room-messages returns message.starred as an array message.starred = message.starred && (Array.isArray(message.starred) ? message.starred.length > 0 : !!message.starred); - message.reactions = _.map(message.reactions, (value, key) => - ({ emoji: key, usernames: value.usernames.map(username => ({ value: username })) })); return message; }, loadMessagesForRoom(rid, end, cb) { diff --git a/app/reducers/login.js b/app/reducers/login.js index cccf73b1f..2fbb295b3 100644 --- a/app/reducers/login.js +++ b/app/reducers/login.js @@ -6,7 +6,8 @@ const initialState = { isRegistering: false, token: '', user: {}, - error: '' + error: '', + services: {} }; export default function login(state = initialState, action) { @@ -115,6 +116,19 @@ export default function login(state = initialState, action) { ...action } }; + case types.LOGIN.SET_SERVICES: + return { + ...state, + services: { + ...state.services, + ...action.data + } + }; + case types.LOGIN.REMOVE_SERVICES: + return { + ...state, + services: {} + }; default: return state; } diff --git a/app/sagas/login.js b/app/sagas/login.js index fae216993..9cb590794 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -1,5 +1,5 @@ import { AsyncStorage } from 'react-native'; -import { put, call, takeLatest, select, all } from 'redux-saga/effects'; +import { put, call, takeLatest, select, all, take } from 'redux-saga/effects'; import * as types from '../actions/actionsTypes'; import { loginRequest, @@ -21,7 +21,8 @@ import * as NavigationService from '../containers/routes/NavigationService'; const getUser = state => state.login; const getServer = state => state.server.server; -const loginCall = args => (args.resume ? RocketChat.login(args) : RocketChat.loginWithPassword(args)); +const getIsConnected = state => state.meteor.connected; +const loginCall = args => ((args.resume || args.oauth) ? RocketChat.login(args) : RocketChat.loginWithPassword(args)); const registerCall = args => RocketChat.register(args); const setUsernameCall = args => RocketChat.setUsername(args); const logoutCall = args => RocketChat.logout(args); @@ -148,6 +149,16 @@ const handleForgotPasswordRequest = function* handleForgotPasswordRequest({ emai } }; +const watchLoginOpen = function* watchLoginOpen() { + const isConnected = yield select(getIsConnected); + if (!isConnected) { + yield take(types.METEOR.SUCCESS); + } + const sub = yield RocketChat.subscribe('meteor.loginServiceConfiguration'); + yield take(types.LOGIN.CLOSE); + sub.unsubscribe().catch(e => alert(e)); +}; + const root = function* root() { yield takeLatest(types.METEOR.SUCCESS, handleLoginWhenServerChanges); yield takeLatest(types.LOGIN.REQUEST, handleLoginRequest); @@ -161,5 +172,6 @@ const root = function* root() { yield takeLatest(types.LOGIN.SET_USERNAME_REQUEST, handleSetUsernameRequest); yield takeLatest(types.LOGOUT, handleLogout); yield takeLatest(types.FORGOT_PASSWORD.REQUEST, handleForgotPasswordRequest); + yield takeLatest(types.LOGIN.OPEN, watchLoginOpen); }; export default root; diff --git a/app/utils/random.js b/app/utils/random.js new file mode 100644 index 000000000..8f6adb880 --- /dev/null +++ b/app/utils/random.js @@ -0,0 +1,8 @@ +export default function random(length) { + let text = ''; + const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + for (let i = 0; i < length; i += 1) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; +} diff --git a/app/views/LoginView.js b/app/views/LoginView.js index 1cb7da245..f05520e1c 100644 --- a/app/views/LoginView.js +++ b/app/views/LoginView.js @@ -1,23 +1,57 @@ import React from 'react'; import Spinner from 'react-native-loading-spinner-overlay'; import PropTypes from 'prop-types'; -import { Keyboard, Text, TextInput, View, ScrollView, TouchableOpacity, SafeAreaView } from 'react-native'; +import { Keyboard, Text, TextInput, View, ScrollView, TouchableOpacity, SafeAreaView, WebView, Platform, LayoutAnimation } from 'react-native'; import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; -import * as loginActions from '../actions/login'; +import Icon from 'react-native-vector-icons/FontAwesome'; +import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; +import { Base64 } from 'js-base64'; +import Modal from 'react-native-modal'; + +import { loginSubmit, open, close } from '../actions/login'; import KeyboardView from '../presentation/KeyboardView'; import styles from './Styles'; import scrollPersistTaps from '../utils/scrollPersistTaps'; import { showToast } from '../utils/info'; +import random from '../utils/random'; -class LoginView extends React.Component { +@connect(state => ({ + server: state.server.server, + login: state.login, + Accounts_EmailOrUsernamePlaceholder: state.settings.Accounts_EmailOrUsernamePlaceholder, + Accounts_PasswordPlaceholder: state.settings.Accounts_PasswordPlaceholder, + Accounts_OAuth_Facebook: state.settings.Accounts_OAuth_Facebook, + Accounts_OAuth_Github: state.settings.Accounts_OAuth_Github, + Accounts_OAuth_Gitlab: state.settings.Accounts_OAuth_Gitlab, + Accounts_OAuth_Google: state.settings.Accounts_OAuth_Google, + Accounts_OAuth_Linkedin: state.settings.Accounts_OAuth_Linkedin, + Accounts_OAuth_Meteor: state.settings.Accounts_OAuth_Meteor, + Accounts_OAuth_Twitter: state.settings.Accounts_OAuth_Twitter, + services: state.login.services +}), dispatch => ({ + loginSubmit: params => dispatch(loginSubmit(params)), + open: () => dispatch(open()), + close: () => dispatch(close()) +})) +export default class LoginView extends React.Component { static propTypes = { loginSubmit: PropTypes.func.isRequired, - Accounts_EmailOrUsernamePlaceholder: PropTypes.string, - Accounts_PasswordPlaceholder: PropTypes.string, + open: PropTypes.func.isRequired, + close: PropTypes.func.isRequired, + navigation: PropTypes.object.isRequired, login: PropTypes.object, - navigation: PropTypes.object.isRequired + server: PropTypes.string, + Accounts_EmailOrUsernamePlaceholder: PropTypes.bool, + Accounts_PasswordPlaceholder: PropTypes.string, + Accounts_OAuth_Facebook: PropTypes.bool, + Accounts_OAuth_Github: PropTypes.bool, + Accounts_OAuth_Gitlab: PropTypes.bool, + Accounts_OAuth_Google: PropTypes.bool, + Accounts_OAuth_Linkedin: PropTypes.bool, + Accounts_OAuth_Meteor: PropTypes.bool, + Accounts_OAuth_Twitter: PropTypes.bool, + services: PropTypes.object } static navigationOptions = () => ({ @@ -29,8 +63,99 @@ class LoginView extends React.Component { this.state = { username: '', - password: '' + password: '', + modalVisible: false, + oAuthUrl: '' }; + this.redirectRegex = new RegExp(`(?=.*(${ this.props.server }))(?=.*(credentialToken))(?=.*(credentialSecret))`, 'g'); + } + + componentWillMount() { + this.props.open(); + } + + componentWillReceiveProps(nextProps) { + if (this.props.services !== nextProps.services) { + LayoutAnimation.easeInEaseOut(); + } + } + + componentWillUnmount() { + this.props.close(); + } + + onPressFacebook = () => { + const { appId } = this.props.services.facebook; + const endpoint = 'https://m.facebook.com/v2.9/dialog/oauth'; + const redirect_uri = `${ this.props.server }/_oauth/facebook?close`; + const scope = 'email'; + const state = this.getOAuthState(); + const params = `?client_id=${ appId }&redirect_uri=${ redirect_uri }&scope=${ scope }&state=${ state }&display=touch`; + this.openOAuth(`${ endpoint }${ params }`); + } + + onPressGithub = () => { + const { clientId } = this.props.services.github; + const endpoint = `https://github.com/login?client_id=${ clientId }&return_to=${ encodeURIComponent('/login/oauth/authorize') }`; + const redirect_uri = `${ this.props.server }/_oauth/github?close`; + const scope = 'user:email'; + const state = this.getOAuthState(); + const params = `?client_id=${ clientId }&redirect_uri=${ redirect_uri }&scope=${ scope }&state=${ state }`; + this.openOAuth(`${ endpoint }${ encodeURIComponent(params) }`); + } + + onPressGitlab = () => { + const { clientId } = this.props.services.gitlab; + const endpoint = 'https://gitlab.com/oauth/authorize'; + const redirect_uri = `${ this.props.server }/_oauth/gitlab?close`; + const scope = 'read_user'; + const state = this.getOAuthState(); + const params = `?client_id=${ clientId }&redirect_uri=${ redirect_uri }&scope=${ scope }&state=${ state }&response_type=code`; + this.openOAuth(`${ endpoint }${ params }`); + } + + onPressGoogle = () => { + const { clientId } = this.props.services.google; + const endpoint = 'https://accounts.google.com/o/oauth2/auth'; + const redirect_uri = `${ this.props.server }/_oauth/google?close`; + const scope = 'email'; + const state = this.getOAuthState(); + const params = `?client_id=${ clientId }&redirect_uri=${ redirect_uri }&scope=${ scope }&state=${ state }&response_type=code`; + this.openOAuth(`${ endpoint }${ params }`); + } + + onPressLinkedin = () => { + const { clientId } = this.props.services.linkedin; + const endpoint = 'https://www.linkedin.com/uas/oauth2/authorization'; + const redirect_uri = `${ this.props.server }/_oauth/linkedin?close`; + const scope = 'r_emailaddress'; + const state = this.getOAuthState(); + const params = `?client_id=${ clientId }&redirect_uri=${ redirect_uri }&scope=${ scope }&state=${ state }&response_type=code`; + this.openOAuth(`${ endpoint }${ params }`); + } + + onPressMeteor = () => { + const { clientId } = this.props.services['meteor-developer']; + const endpoint = 'https://www.meteor.com/oauth2/authorize'; + const redirect_uri = `${ this.props.server }/_oauth/meteor-developer`; + const state = this.getOAuthState(); + const params = `?client_id=${ clientId }&redirect_uri=${ redirect_uri }&state=${ state }&response_type=code`; + this.openOAuth(`${ endpoint }${ params }`); + } + + onPressTwitter = () => { + const state = this.getOAuthState(); + const url = `${ this.props.server }/_oauth/twitter/?requestTokenAndRedirect=true&state=${ state }`; + this.openOAuth(url); + } + + getOAuthState = () => { + const credentialToken = random(43); + return Base64.encodeURI(JSON.stringify({ loginStyle: 'popup', credentialToken, isCordova: true })); + } + + openOAuth = (oAuthUrl) => { + this.setState({ oAuthUrl, modalVisible: true }); } submit = () => { @@ -40,10 +165,14 @@ class LoginView extends React.Component { return; } - this.props.loginSubmit({ username, password, code }); + this.props.loginSubmit({ username, password, code }); Keyboard.dismiss(); } + submitOAuth = (code, credentialToken) => { + this.props.loginSubmit({ code, credentialToken }); + } + register = () => { this.props.navigation.navigate('Register'); } @@ -60,6 +189,10 @@ class LoginView extends React.Component { this.props.navigation.navigate('ForgotPassword'); } + closeOAuth = () => { + this.setState({ modalVisible: false }); + } + renderTOTP = () => { if (/totp/ig.test(this.props.login.error.error)) { return ( @@ -82,88 +215,158 @@ class LoginView extends React.Component { render() { return ( - - - - - this.setState({ username })} - keyboardType='email-address' - autoCorrect={false} - returnKeyType='next' - autoCapitalize='none' - underlineColorAndroid='transparent' - onSubmitEditing={() => { this.password.focus(); }} - placeholder={this.props.Accounts_EmailOrUsernamePlaceholder || 'Email or username'} - /> - { this.password = e; }} - style={styles.input_white} - onChangeText={password => this.setState({ password })} - secureTextEntry - autoCorrect={false} - returnKeyType='done' - autoCapitalize='none' - underlineColorAndroid='transparent' - onSubmitEditing={this.submit} - placeholder={this.props.Accounts_PasswordPlaceholder || 'Password'} - /> + + + + this.setState({ username })} + keyboardType='email-address' + autoCorrect={false} + returnKeyType='next' + autoCapitalize='none' + underlineColorAndroid='transparent' + onSubmitEditing={() => { this.password.focus(); }} + placeholder={this.props.Accounts_EmailOrUsernamePlaceholder || 'Email or username'} + /> + { this.password = e; }} + style={styles.input_white} + onChangeText={password => this.setState({ password })} + secureTextEntry + autoCorrect={false} + returnKeyType='done' + autoCapitalize='none' + underlineColorAndroid='transparent' + onSubmitEditing={this.submit} + placeholder={this.props.Accounts_PasswordPlaceholder || 'Password'} + /> - {this.renderTOTP()} + {this.renderTOTP()} - - LOGIN - - - - - REGISTER + + LOGIN - - FORGOT MY PASSWORD + + + REGISTER + + + + FORGOT MY PASSWORD + + + + + {this.props.Accounts_OAuth_Facebook && this.props.services.facebook && + + + + } + {this.props.Accounts_OAuth_Github && this.props.services.github && + + + + } + {this.props.Accounts_OAuth_Gitlab && this.props.services.gitlab && + + + + } + {this.props.Accounts_OAuth_Google && this.props.services.google && + + + + } + {this.props.Accounts_OAuth_Linkedin && this.props.services.linkedin && + + + + } + {this.props.Accounts_OAuth_Meteor && this.props.services['meteor-developer'] && + + + + } + {this.props.Accounts_OAuth_Twitter && this.props.services.twitter && + + + + } + + + + + By proceeding you are agreeing to our + Terms of Service + and + Privacy Policy + + {this.props.login.failure && {this.props.login.error.reason}} - - - - By proceeding you are agreeing to our - Terms of Service - and - Privacy Policy - - - {this.props.login.failure && {this.props.login.error.reason}} - - - - - + + + + , + + { + const url = decodeURIComponent(webViewState.url); + if (this.redirectRegex.test(url)) { + const parts = url.split('#'); + const credentials = JSON.parse(parts[1]); + this.props.loginSubmit({ oauth: { ...credentials } }); + this.setState({ modalVisible: false }); + } + }} + /> + + + ] ); } } - -function mapStateToProps(state) { - return { - server: state.server.server, - Accounts_EmailOrUsernamePlaceholder: state.settings.Accounts_EmailOrUsernamePlaceholder, - Accounts_PasswordPlaceholder: state.settings.Accounts_PasswordPlaceholder, - login: state.login - }; -} - -function mapDispatchToProps(dispatch) { - return bindActionCreators(loginActions, dispatch); -} - -export default connect(mapStateToProps, mapDispatchToProps)(LoginView); diff --git a/app/views/RoomsListView/index.js b/app/views/RoomsListView/index.js index e7c3c634f..bc68396e4 100644 --- a/app/views/RoomsListView/index.js +++ b/app/views/RoomsListView/index.js @@ -241,7 +241,7 @@ export default class RoomsListView extends React.Component { dataSource={this.state.dataSource} style={styles.list} renderItem={this.renderItem} - renderHeader={Platform.OS === 'ios' ? this.renderSearchBar : null} + ListHeaderComponent={Platform.OS === 'ios' ? this.renderSearchBar : null} contentOffset={Platform.OS === 'ios' ? { x: 0, y: 38 } : {}} enableEmptySections keyboardShouldPersistTaps='always' diff --git a/app/views/Styles.js b/app/views/Styles.js index de5ba7b91..113dd875c 100644 --- a/app/views/Styles.js +++ b/app/views/Styles.js @@ -1,4 +1,4 @@ -import { StyleSheet, Dimensions } from 'react-native'; +import { StyleSheet, Dimensions, Platform } from 'react-native'; export default StyleSheet.create({ container: { @@ -157,6 +157,11 @@ export default StyleSheet.create({ flexWrap: 'wrap', justifyContent: 'space-around' }, + loginOAuthButtons: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center' + }, validText: { color: 'green' }, @@ -165,5 +170,43 @@ export default StyleSheet.create({ }, validatingText: { color: '#aaa' + }, + oauthButton: { + width: 50, + height: 50, + alignItems: 'center', + justifyContent: 'center', + margin: 4, + borderRadius: 4 + }, + facebookButton: { + backgroundColor: '#3b5998' + }, + githubButton: { + backgroundColor: '#4c4c4c' + }, + gitlabButton: { + backgroundColor: '#373d47' + }, + googleButton: { + backgroundColor: '#dd4b39' + }, + linkedinButton: { + backgroundColor: '#1b86bc' + }, + meteorButton: { + backgroundColor: '#de4f4f' + }, + twitterButton: { + backgroundColor: '#02acec' + }, + closeOAuth: { + position: 'absolute', + left: 5, + top: Platform.OS === 'ios' ? 20 : 0, + backgroundColor: 'transparent' + }, + oAuthModal: { + margin: 0 } }); diff --git a/ios/RocketChatRN/AppDelegate.m b/ios/RocketChatRN/AppDelegate.m index 690a8196d..15d6ace56 100644 --- a/ios/RocketChatRN/AppDelegate.m +++ b/ios/RocketChatRN/AppDelegate.m @@ -27,6 +27,10 @@ initialProperties:nil launchOptions:launchOptions]; rootView.backgroundColor = [[UIColor alloc] initWithRed:1.0f green:1.0f blue:1.0f alpha:1]; + + NSString *newAgent = @"Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1"; + NSDictionary *dictionary = [[NSDictionary alloc] initWithObjectsAndKeys:newAgent, @"UserAgent", nil]; + [[NSUserDefaults standardUserDefaults] registerDefaults:dictionary]; self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; UIViewController *rootViewController = [UIViewController new]; diff --git a/package-lock.json b/package-lock.json index 153272319..82fa1c15e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9073,6 +9073,11 @@ "jsdom": "11.6.2" } }, + "js-base64": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.4.3.tgz", + "integrity": "sha512-H7ErYLM34CvDMto3GbD6xD0JLUGYXR3QTcH6B/tr4Hi/QpSThnCsIp+Sy5FRTw3B0d6py4HcNkW7nO/wdtGWEw==" + }, "jest-environment-node": { "version": "22.3.0", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-22.3.0.tgz", diff --git a/package.json b/package.json index d4ad455ab..056227823 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "babel-preset-expo": "^4.0.0", "deep-equal": "^1.0.1", "ejson": "^2.1.2", + "js-base64": "^2.4.3", "lodash": "^4.17.4", "moment": "^2.20.1", "prop-types": "^15.6.0",