diff --git a/packages/edge-login-ui-rn/package.json b/packages/edge-login-ui-rn/package.json index ecfb8f6f3..7f3c976df 100644 --- a/packages/edge-login-ui-rn/package.json +++ b/packages/edge-login-ui-rn/package.json @@ -27,6 +27,7 @@ "prepare": "echo nothing to do" }, "dependencies": { + "cleaners": "^0.3.1", "material-ui": "^0.20.0", "moment": "^2.19.3", "qrcode-generator": "^1.4.4", diff --git a/packages/edge-login-ui-rn/src/actions/LoginInitActions.js b/packages/edge-login-ui-rn/src/actions/LoginInitActions.js index a894f9f6e..77f1c33d1 100644 --- a/packages/edge-login-ui-rn/src/actions/LoginInitActions.js +++ b/packages/edge-login-ui-rn/src/actions/LoginInitActions.js @@ -26,18 +26,18 @@ export const initializeLogin = () => async ( // Loading is done, so send the user to the initial route: const { previousUsers, touch } = state - const firstUser = previousUsers.lastUser + const { startupUser } = previousUsers if (imports.recoveryKey) { dispatch({ type: 'SET_RECOVERY_KEY', data: imports.recoveryKey }) - } else if (firstUser == null) { + } else if (startupUser == null) { dispatch({ type: 'WORKFLOW_START', data: 'landingWF' }) } else if ( - firstUser.pinEnabled || - (firstUser.touchEnabled && touch !== 'none') + startupUser.pinEnabled || + (startupUser.touchEnabled && touch !== 'none') ) { dispatch({ type: 'WORKFLOW_START', data: 'pinWF' }) } else { diff --git a/packages/edge-login-ui-rn/src/actions/PreviousUsersActions.js b/packages/edge-login-ui-rn/src/actions/PreviousUsersActions.js index 67069452b..bc545b720 100644 --- a/packages/edge-login-ui-rn/src/actions/PreviousUsersActions.js +++ b/packages/edge-login-ui-rn/src/actions/PreviousUsersActions.js @@ -1,30 +1,102 @@ // @flow -import type { DiskletFolder } from 'disklet' -import { makeReactNativeDisklet } from 'disklet' -import type { EdgeContext } from 'edge-core-js' +import { asArray, asJSON, asObject, asString } from 'cleaners' +import { type Disklet, makeReactNativeDisklet } from 'disklet' -import { isTouchEnabled } from '../keychain.js' -import { type LoginUserInfo } from '../reducers/PreviousUsersReducer.js' +import { getTouchEnabledUsers, supportsTouchId } from '../keychain.js' +import { + type LoginUserInfo, + type PreviousUsersState +} from '../reducers/PreviousUsersReducer.js' import type { Dispatch, GetState, Imports } from '../types/ReduxTypes.js' -function sortUserList( - lastUsers: string[], - userList: LoginUserInfo[] -): LoginUserInfo[] { - if (!userList || userList.length === 0) { - return [] +/** + * Load the user list from core & disk into redux. + */ +export const getPreviousUsers = () => async ( + dispatch: Dispatch, + getState: GetState, + imports: Imports +): Promise => { + const { context, folder } = imports + const disklet = makeReactNativeDisklet() + + // Load disk information: + const lastUsernames: string[] = await getRecentUsers(disklet) + const touchEnabledUsers: string[] = await getTouchEnabledUsers(folder) + const touchSupported: boolean = await supportsTouchId() + + // Figure out which users have biometric logins: + const coreUsers: LoginUserInfo[] = [] + for (const userInfo of context.localUsers) { + const { username, pinLoginEnabled, keyLoginEnabled = true } = userInfo + const touchEnabled = + keyLoginEnabled && + touchSupported && + touchEnabledUsers.indexOf(username) >= 0 + coreUsers.push({ username, pinEnabled: pinLoginEnabled, touchEnabled }) } - const limitLastUsers = lastUsers.length > 0 ? lastUsers.slice(0, 3) : [] - const detailedLastUsers: LoginUserInfo[] = [] - for (const lastUser of limitLastUsers) { - const info = userList.find(user => user.username === lastUser) - if (info != null) detailedLastUsers.push(info) + + // Move the top three users to their own list: + const topUsers: LoginUserInfo[] = [] + for (const username of lastUsernames.slice(0, 3)) { + for (let i = 0; i < coreUsers.length; ++i) { + if (coreUsers[i].username === username) { + topUsers.push(coreUsers[i]) + coreUsers.splice(i, 1) + break + } + } } - const filteredUserList = userList.filter( - user => !limitLastUsers.find(lastUser => user.username === lastUser) + const userList: LoginUserInfo[] = [...topUsers, ...sortUsers(coreUsers)] + + // Try to find the user requested by the LoginScreen props: + const requestedUser = userList.find( + user => user.username === imports.username ) - const sortedUserList = filteredUserList.sort((a: Object, b: Object) => { + + // Dispatch to redux: + const data: PreviousUsersState = { + loaded: true, + startupUser: requestedUser != null ? requestedUser : userList[0], + userList, + usernameOnlyList: userList.map(userInfo => userInfo.username) + } + dispatch({ type: 'SET_PREVIOUS_USERS', data }) +} + +export const setMostRecentUsers = async (username: string) => { + const disklet = makeReactNativeDisklet() + const lastUsers = await getRecentUsers(disklet) + + const filteredLastUsers = lastUsers.filter( + (lastUser: string) => lastUser !== username + ) + return disklet.setText( + 'lastusers.json', + JSON.stringify([username, ...filteredLastUsers]) + ) +} + +async function getRecentUsers(disklet: Disklet): Promise { + // Load the last users array: + try { + const lastUsernames: string[] = await disklet + .getText('lastusers.json') + .then(asLastUsersFile) + return lastUsernames + } catch (e) {} + + // Fall back on the older file: + return disklet + .getText('lastuser.json') + .then(asLastUsernameFile) + .then(file => [file.username]) + .catch(() => []) +} + +function sortUsers(users: LoginUserInfo[]): LoginUserInfo[] { + return users.sort((a: LoginUserInfo, b: LoginUserInfo) => { const stringA = a.username.toUpperCase() const stringB = b.username.toUpperCase() if (stringA < stringB) { @@ -35,84 +107,11 @@ function sortUserList( } return 0 }) - - return [...detailedLastUsers, ...sortedUserList] -} - -async function getDiskStuff(context: EdgeContext, folder: DiskletFolder) { - const userList = await context.listUsernames().then(usernames => - Promise.all( - usernames.map(username => { - return context.pinLoginEnabled(username).then(async pinEnabled => { - return { - username, - pinEnabled, - touchEnabled: await isTouchEnabled(folder, username) - } - }) - }) - ) - ) - - const disklet = makeReactNativeDisklet() - const lastUsers = await disklet - .getText('lastusers.json') - .then(text => JSON.parse(text)) - .catch(_ => []) - if (lastUsers && lastUsers.length > 0) { - return { - lastUser: lastUsers[0], - userList: sortUserList(lastUsers, userList) - } - } - const lastUser = await disklet - .getText('lastuser.json') - .then(text => JSON.parse(text)) - .then(json => (json && json.username ? json.username : '')) - .catch(_ => '') - return { - lastUser, - userList: sortUserList([lastUser], userList) - } -} - -export function getPreviousUsers() { - return async (dispatch: Dispatch, getState: GetState, imports: Imports) => { - const { context, folder, username } = imports - await getDiskStuff(context, folder).then((data: Object) => { - const focusUser = username || data.lastUser - if (data.userList && data.userList.length > 0) { - data.usernameOnlyList = [] - data.userList.forEach(function(element) { - if (element.username === focusUser) { - data.lastUser = { - username: focusUser, - pinEnabled: element.pinEnabled, - touchEnabled: element.touchEnabled - } - } - data.usernameOnlyList.push(element.username) - }, this) - } - dispatch({ type: 'SET_PREVIOUS_USERS', data: data }) - }) - } } -export const setMostRecentUsers = async (username: string) => { - const disklet = makeReactNativeDisklet() - const lastUsers = await disklet - .getText('lastusers.json') - .then(text => JSON.parse(text)) - .catch(_ => []) - if (lastUsers && lastUsers.length > 0) { - const filteredLastUsers = lastUsers.filter( - (lastUser: string) => lastUser !== username - ) - return disklet.setText( - 'lastusers.json', - JSON.stringify([username, ...filteredLastUsers]) - ) - } - return disklet.setText('lastusers.json', JSON.stringify([username])) -} +const asLastUsersFile = asJSON(asArray(asString)) +const asLastUsernameFile = asJSON( + asObject({ + username: asString + }) +) diff --git a/packages/edge-login-ui-rn/src/components/screens/PasswordLoginScreen.js b/packages/edge-login-ui-rn/src/components/screens/PasswordLoginScreen.js index d62a40a0d..de080789b 100644 --- a/packages/edge-login-ui-rn/src/components/screens/PasswordLoginScreen.js +++ b/packages/edge-login-ui-rn/src/components/screens/PasswordLoginScreen.js @@ -23,12 +23,6 @@ import { StaticModal } from '../common/StaticModal.js' import { DeleteUserModal } from '../modals/DeleteUserModal.js' import { connect } from '../services/ReduxStore.js' -const Offsets = { - USERNAME_OFFSET_LOGIN_SCREEN: -50, - PASSWORD_OFFSET_LOGIN_SCREEN: -80, - LOGIN_SCREEN_NO_OFFSET: -200 -} - type OwnProps = { appId?: string, backgroundImage?: any, @@ -38,7 +32,6 @@ type OwnProps = { } type StateProps = { error: string, - hasUsers: boolean, loginSuccess: boolean, password: string, previousUsers: LoginUserInfo[], @@ -64,7 +57,6 @@ type State = { loggingIn: boolean, focusFirst: boolean, focusSecond: boolean, - offset: number, showRecoveryModalOne: boolean, showRecoveryModalTwo: boolean, usernameList: boolean @@ -85,7 +77,6 @@ class PasswordLoginScreenComponent extends Component { loggingIn: false, focusFirst: true, focusSecond: false, - offset: Offsets.USERNAME_OFFSET_LOGIN_SCREEN, showRecoveryModalOne: false, showRecoveryModalTwo: false, usernameList: false @@ -128,8 +119,7 @@ class PasswordLoginScreenComponent extends Component { Keyboard.dismiss() this.setState({ focusFirst: false, - focusSecond: false, - offset: Offsets.LOGIN_SCREEN_NO_OFFSET + focusSecond: false }) } @@ -156,8 +146,7 @@ class PasswordLoginScreenComponent extends Component { password: '', loggingIn: false, focusFirst: true, - focusSecond: false, - offset: Offsets.USERNAME_OFFSET_LOGIN_SCREEN + focusSecond: false }) } @@ -350,26 +339,21 @@ class PasswordLoginScreenComponent extends Component { onfocusOne() { this.setState({ focusFirst: true, - focusSecond: false, - offset: this.props.hasUsers - ? Offsets.USERNAME_OFFSET_LOGIN_SCREEN - : Offsets.LOGIN_SCREEN_NO_OFFSET + focusSecond: false }) } onfocusTwo() { this.setState({ focusFirst: false, - focusSecond: true, - offset: Offsets.PASSWORD_OFFSET_LOGIN_SCREEN + focusSecond: true }) } onSetNextFocus() { this.setState({ focusFirst: false, - focusSecond: true, - offset: Offsets.PASSWORD_OFFSET_LOGIN_SCREEN + focusSecond: true }) } @@ -557,7 +541,6 @@ const LoginPasswordScreenStyle = { export const PasswordLoginScreen = connect( (state: RootState) => ({ error: state.login.errorMessage || '', - hasUsers: state.previousUsers.userList.length > 0, loginSuccess: state.login.loginSuccess, password: state.login.password || '', previousUsers: state.previousUsers.userList, diff --git a/packages/edge-login-ui-rn/src/index.js b/packages/edge-login-ui-rn/src/index.js index bc51c5e3d..7ae69f0c3 100644 --- a/packages/edge-login-ui-rn/src/index.js +++ b/packages/edge-login-ui-rn/src/index.js @@ -1,5 +1,7 @@ // @flow +import './util/androidFetch.js' + export * from './components/publicApi/ChangePasswordScreen.js' export * from './components/publicApi/ChangePinScreen.js' export * from './components/publicApi/ChooseTestAppScreen.js' diff --git a/packages/edge-login-ui-rn/src/keychain.js b/packages/edge-login-ui-rn/src/keychain.js index ceaf6df37..605c52d59 100644 --- a/packages/edge-login-ui-rn/src/keychain.js +++ b/packages/edge-login-ui-rn/src/keychain.js @@ -1,7 +1,8 @@ // @flow -import type { DiskletFolder } from 'disklet' -import type { EdgeAccount, EdgeContext } from 'edge-core-js' +import { asArray, asJSON, asObject, asOptional, asString } from 'cleaners' +import { type DiskletFolder } from 'disklet' +import { type EdgeAccount, type EdgeContext } from 'edge-core-js' import { NativeModules, Platform } from 'react-native' const { AbcCoreJsUi } = NativeModules @@ -9,76 +10,70 @@ const LOGINKEY_KEY = 'key_loginkey' // const USE_TOUCHID_KEY = 'key_use_touchid' // const RECOVERY2_KEY = 'key_recovery2' -// TODO: Remove this hack! Pass via io object in Core - -if (Platform.OS === 'android' && AbcCoreJsUi.fetch) { - global.androidFetch = async (url: string, options: Object) => { - const headers: Array = [] - for (const h of Object.keys(options.headers)) { - headers.push(`${h}______${options.headers[h]}`) - } - const result = await AbcCoreJsUi.fetch( - url, - options.method, - options.body, - headers - ) - return result - } -} - function createKeyWithUsername(username, key) { return username + '___' + key } -const emptyTouchIdUsers = { +const asFingerprintFile = asJSON( + asObject({ + enabledUsers: asOptional(asArray(asString), []), + disabledUsers: asOptional(asArray(asString), []) + }) +) +type FingerprintFile = $Call + +const emptyTouchIdUsers: FingerprintFile = { enabledUsers: [], disabledUsers: [] } -export async function isTouchEnabled(folder: DiskletFolder, username: string) { +export async function getTouchEnabledUsers( + folder: DiskletFolder +): Promise { + const fingerprint = await folder + .file('fingerprint.json') + .getText() + .then(asFingerprintFile) + .catch(e => emptyTouchIdUsers) + + return fingerprint.enabledUsers +} + +export async function isTouchEnabled( + folder: DiskletFolder, + username: string +): Promise { const supported = await supportsTouchId() - if (supported) { - const fingerprint = await folder - .file('fingerprint.json') - .getText() - .then(text => JSON.parse(text)) - .catch(e => emptyTouchIdUsers) - - // Check if user is in array - if ( - fingerprint.enabledUsers && - fingerprint.enabledUsers.indexOf(username) !== -1 - ) { - return true - } - } - return false + if (!supported) return false + + const fingerprint = await folder + .file('fingerprint.json') + .getText() + .then(asFingerprintFile) + .catch(e => emptyTouchIdUsers) + + // Check if user is in array + return fingerprint.enabledUsers.indexOf(username) !== -1 } -export async function isTouchDisabled(folder: DiskletFolder, username: string) { +export async function isTouchDisabled( + folder: DiskletFolder, + username: string +): Promise { const supported = await supportsTouchId() - if (supported) { - const fingerprint = await folder - .file('fingerprint.json') - .getText() - .then(text => JSON.parse(text)) - .catch(e => emptyTouchIdUsers) - - // Check if user is in array - if ( - fingerprint.disabledUsers && - fingerprint.disabledUsers.indexOf(username) !== -1 - ) { - return true - } else { - return false - } - } - return true + if (!supported) return true + + const fingerprint = await folder + .file('fingerprint.json') + .getText() + .then(asFingerprintFile) + .catch(e => emptyTouchIdUsers) + + // Check if user is in array + return fingerprint.disabledUsers.indexOf(username) !== -1 } -export async function supportsTouchId() { +export async function supportsTouchId(): Promise { if (!AbcCoreJsUi) { console.warn('AbcCoreJsUi is unavailable') return false @@ -91,7 +86,7 @@ async function addTouchIdUser(folder: DiskletFolder, username: string) { const fingerprint = await folder .file('fingerprint.json') .getText() - .then(text => JSON.parse(text)) + .then(asFingerprintFile) .catch(e => emptyTouchIdUsers) if (fingerprint.enabledUsers.indexOf(username) === -1) { @@ -109,7 +104,7 @@ async function removeTouchIdUser(folder: DiskletFolder, username: string) { const fingerprint = await folder .file('fingerprint.json') .getText() - .then(text => JSON.parse(text)) + .then(asFingerprintFile) .catch(e => emptyTouchIdUsers) if (fingerprint.disabledUsers.indexOf(username) === -1) { @@ -179,58 +174,58 @@ export async function loginWithTouchId( ): Promise { const supported = await supportsTouchId() - if (supported) { - const disabled = await isTouchDisabled(folder, username) - if (disabled) { - return null - } - const enabled = await isTouchEnabled(folder, username) - if (!enabled) { - return null - } - const loginKeyKey = createKeyWithUsername(username, LOGINKEY_KEY) - - if (Platform.OS === 'ios') { - const loginKey = await AbcCoreJsUi.getKeychainString(loginKeyKey) - if (loginKey && loginKey.length > 10) { - console.log('loginKey valid. Launching TouchID modal...') - - const success = await AbcCoreJsUi.authenticateTouchID( - promptString, - fallbackString - ) - if (success) { - console.log('TouchID authenticated. Calling loginWithKey') - callback() - const abcAccount = abcContext.loginWithKey(username, loginKey, opts) - console.log('abcAccount logged in: ' + username) - return abcAccount - } else { - console.log('Failed to authenticate TouchID') - return null - } - } else { - console.log('No valid loginKey for TouchID') - return null - } - } else if (Platform.OS === 'android') { - try { - const loginKey = await AbcCoreJsUi.getKeychainStringWithFingerprint( - loginKeyKey, - promptString - ) + if (!supported) { + // throw new Error('TouchIdNotSupportedError') + console.log('TouchIdNotSupportedError') + return null + } + + const disabled = await isTouchDisabled(folder, username) + if (disabled) { + return null + } + const enabled = await isTouchEnabled(folder, username) + if (!enabled) { + return null + } + const loginKeyKey = createKeyWithUsername(username, LOGINKEY_KEY) + + if (Platform.OS === 'ios') { + const loginKey = await AbcCoreJsUi.getKeychainString(loginKeyKey) + if (loginKey && loginKey.length > 10) { + console.log('loginKey valid. Launching TouchID modal...') + + const success = await AbcCoreJsUi.authenticateTouchID( + promptString, + fallbackString + ) + if (success) { + console.log('TouchID authenticated. Calling loginWithKey') callback() const abcAccount = abcContext.loginWithKey(username, loginKey, opts) console.log('abcAccount logged in: ' + username) return abcAccount - } catch (e) { - console.log(e) + } else { + console.log('Failed to authenticate TouchID') return null } + } else { + console.log('No valid loginKey for TouchID') + return null + } + } else if (Platform.OS === 'android') { + try { + const loginKey = await AbcCoreJsUi.getKeychainStringWithFingerprint( + loginKeyKey, + promptString + ) + callback() + const abcAccount = abcContext.loginWithKey(username, loginKey, opts) + console.log('abcAccount logged in: ' + username) + return abcAccount + } catch (e) { + console.log(e) + return null } - } else { - console.log('TouchIdNotSupportedError') - return null - // throw new Error('TouchIdNotSupportedError') } } diff --git a/packages/edge-login-ui-rn/src/reducers/LoginReducer.js b/packages/edge-login-ui-rn/src/reducers/LoginReducer.js index 183bcbc5c..12222c9ce 100644 --- a/packages/edge-login-ui-rn/src/reducers/LoginReducer.js +++ b/packages/edge-login-ui-rn/src/reducers/LoginReducer.js @@ -3,7 +3,6 @@ import { type EdgeAccount, type OtpError } from 'edge-core-js' import { type Reducer } from 'redux' -import { type PreviousUsersState } from '../reducers/PreviousUsersReducer.js' import { type Action } from '../types/ReduxTypes.js' const flowHack: any = {} @@ -57,13 +56,9 @@ export const login: Reducer = function( case 'START_RECOVERY_LOGIN': return { ...state, otpErrorMessage: null } case 'SET_PREVIOUS_USERS': { - const data: PreviousUsersState = action.data - if (data.lastUser) { - return { ...state, username: data.lastUser.username } - } - if (data.userList.length > 0) { - const topUser = data.userList[0] - return { ...state, username: topUser.username } + const { startupUser } = action.data + if (startupUser != null) { + return { ...state, username: startupUser.username } } return state } diff --git a/packages/edge-login-ui-rn/src/reducers/PreviousUsersReducer.js b/packages/edge-login-ui-rn/src/reducers/PreviousUsersReducer.js index b8c8e740c..2429ef3c1 100644 --- a/packages/edge-login-ui-rn/src/reducers/PreviousUsersReducer.js +++ b/packages/edge-login-ui-rn/src/reducers/PreviousUsersReducer.js @@ -11,8 +11,8 @@ export type LoginUserInfo = { } export type PreviousUsersState = { - +lastUser?: LoginUserInfo, +loaded: boolean, + +startupUser?: LoginUserInfo, +userList: LoginUserInfo[], +usernameOnlyList: string[] } @@ -27,11 +27,5 @@ export const previousUsers: Reducer = function( state = initialState, action ) { - switch (action.type) { - case 'SET_PREVIOUS_USERS': - return { ...action.data, loaded: true } - - default: - return state - } + return action.type === 'SET_PREVIOUS_USERS' ? action.data : state } diff --git a/packages/edge-login-ui-rn/src/util/androidFetch.js b/packages/edge-login-ui-rn/src/util/androidFetch.js new file mode 100644 index 000000000..5e36ba0c1 --- /dev/null +++ b/packages/edge-login-ui-rn/src/util/androidFetch.js @@ -0,0 +1,21 @@ +// @flow + +import { type EdgeFetchOptions } from 'edge-core-js' +import { NativeModules, Platform } from 'react-native' + +const { AbcCoreJsUi } = NativeModules + +// TODO: Remove this hack! Pass via io object in Core +if (Platform.OS === 'android' && AbcCoreJsUi.fetch) { + global.androidFetch = async (url: string, options: EdgeFetchOptions) => { + const { method, body, headers = {} } = options + + const result = await AbcCoreJsUi.fetch( + url, + method, + body, + Object.keys(headers).map(h => `${h}______${headers[h]}`) + ) + return result + } +} diff --git a/yarn.lock b/yarn.lock index dfa56abe7..32b49f55b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2834,6 +2834,11 @@ cleaners@^0.2.1: resolved "https://registry.yarnpkg.com/cleaners/-/cleaners-0.2.1.tgz#535849cfabc91e3ae1fc872b43d450446cd1a1a1" integrity sha512-DSz39+aAcvfqwruSz7H7xY9kE3tRbMTiq2GQtZHpNWox2npyoQGdMUYR7pYgApJNSDY5Q9GNRo+JyncxI5USIA== +cleaners@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/cleaners/-/cleaners-0.3.1.tgz#f8fe89d76b1731e07258148a1648ea251d72096d" + integrity sha512-PIV0B/TVBnTJlcQtjkLtUOnzBlKUT9QTL/LVw4M22vlgrm+/858oNK3w2umc3Ll9Q9hT2oTCURUOsiSDOpT9bA== + cli-cursor@^2.0.0, cli-cursor@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5"