diff --git a/native/account/log-in-panel.react.js b/native/account/log-in-panel.react.js index 4c911a7d4..dac2960c2 100644 --- a/native/account/log-in-panel.react.js +++ b/native/account/log-in-panel.react.js @@ -1,378 +1,378 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, StyleSheet, Alert, Keyboard, Platform } from 'react-native'; import Animated from 'react-native-reanimated'; import { logInActionTypes, logIn } from 'lib/actions/user-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { validEmailRegex, oldValidUsernameRegex, } from 'lib/shared/account-utils'; import { type LogInInfo, type LogInExtraInfo, type LogInResult, type LogInStartingPayload, loginActionSources, } from 'lib/types/account-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils'; import SWMansionIcon from '../components/swmansion-icon.react'; import { NavContext } from '../navigation/navigation-context'; import { useSelector } from '../redux/redux-utils'; import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors'; import type { KeyPressEvent } from '../types/react-native'; import type { StateContainer } from '../utils/state-container'; import { TextInput } from './modal-components.react'; import { fetchNativeCredentials, setNativeCredentials, } from './native-credentials'; import { PanelButton, Panel } from './panel-components.react'; export type LogInState = { +usernameInputText: ?string, +passwordInputText: ?string, }; type BaseProps = { +setActiveAlert: (activeAlert: boolean) => void, +opacityValue: Animated.Node, +logInState: StateContainer, }; type Props = { ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +logInExtraInfo: () => LogInExtraInfo, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +logIn: (logInInfo: LogInInfo) => Promise, }; class LogInPanel extends React.PureComponent { usernameInput: ?TextInput; passwordInput: ?TextInput; componentDidMount() { this.attemptToFetchCredentials(); } get usernameInputText(): string { return this.props.logInState.state.usernameInputText || ''; } get passwordInputText(): string { return this.props.logInState.state.passwordInputText || ''; } async attemptToFetchCredentials() { if ( this.props.logInState.state.usernameInputText !== null && this.props.logInState.state.usernameInputText !== undefined ) { return; } const credentials = await fetchNativeCredentials(); if (!credentials) { return; } if ( this.props.logInState.state.usernameInputText !== null && this.props.logInState.state.usernameInputText !== undefined ) { return; } this.props.logInState.setState({ usernameInputText: credentials.username, passwordInputText: credentials.password, }); } render(): React.Node { return ( ); } usernameInputRef: (usernameInput: ?TextInput) => void = usernameInput => { this.usernameInput = usernameInput; if (Platform.OS === 'ios' && usernameInput) { setTimeout(() => usernameInput.focus()); } }; focusUsernameInput: () => void = () => { invariant(this.usernameInput, 'ref should be set'); this.usernameInput.focus(); }; passwordInputRef: (passwordInput: ?TextInput) => void = passwordInput => { this.passwordInput = passwordInput; }; focusPasswordInput: () => void = () => { invariant(this.passwordInput, 'ref should be set'); this.passwordInput.focus(); }; onChangeUsernameInputText: (text: string) => void = text => { - this.props.logInState.setState({ usernameInputText: text }); + this.props.logInState.setState({ usernameInputText: text.trim() }); }; onUsernameKeyPress: (event: KeyPressEvent) => void = event => { const { key } = event.nativeEvent; if ( key.length > 1 && key !== 'Backspace' && key !== 'Enter' && this.passwordInputText.length === 0 ) { this.focusPasswordInput(); } }; onChangePasswordInputText: (text: string) => void = text => { this.props.logInState.setState({ passwordInputText: text }); }; onSubmit: () => void = () => { this.props.setActiveAlert(true); if (this.usernameInputText.search(validEmailRegex) > -1) { Alert.alert( 'Can’t log in with email', 'You need to log in with your username now', [{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }], { cancelable: false }, ); return; } else if (this.usernameInputText.search(oldValidUsernameRegex) === -1) { Alert.alert( 'Invalid username', 'Alphanumeric usernames only', [{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }], { cancelable: false }, ); return; } else if (this.passwordInputText === '') { Alert.alert( 'Empty password', 'Password cannot be empty', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); return; } Keyboard.dismiss(); const extraInfo = this.props.logInExtraInfo(); this.props.dispatchActionPromise( logInActionTypes, this.logInAction(extraInfo), undefined, ({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload), ); }; onUsernameAlertAcknowledged: () => void = () => { this.props.setActiveAlert(false); this.props.logInState.setState( { usernameInputText: '', }, this.focusUsernameInput, ); }; async logInAction(extraInfo: LogInExtraInfo): Promise { try { const result = await this.props.logIn({ ...extraInfo, username: this.usernameInputText, password: this.passwordInputText, source: loginActionSources.logInFromNativeForm, }); this.props.setActiveAlert(false); await setNativeCredentials({ username: result.currentUserInfo.username, password: this.passwordInputText, }); return result; } catch (e) { if (e.message === 'invalid_parameters') { Alert.alert( 'Invalid username', 'User doesn’t exist', [{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }], { cancelable: false }, ); } else if (e.message === 'invalid_credentials') { Alert.alert( 'Incorrect password', 'The password you entered is incorrect', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); } else if (e.message === 'client_version_unsupported') { const app = Platform.select({ ios: 'App Store', android: 'Play Store', }); Alert.alert( 'App out of date', 'Your app version is pretty old, and the server doesn’t know how ' + `to speak to it anymore. Please use the ${app} app to update!`, [{ text: 'OK', onPress: this.onAppOutOfDateAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); } throw e; } } onPasswordAlertAcknowledged: () => void = () => { this.props.setActiveAlert(false); this.props.logInState.setState( { passwordInputText: '', }, this.focusPasswordInput, ); }; onUnknownErrorAlertAcknowledged: () => void = () => { this.props.setActiveAlert(false); this.props.logInState.setState( { usernameInputText: '', passwordInputText: '', }, this.focusUsernameInput, ); }; onAppOutOfDateAlertAcknowledged: () => void = () => { this.props.setActiveAlert(false); }; } export type InnerLogInPanel = LogInPanel; const styles = StyleSheet.create({ footer: { flexDirection: 'row', justifyContent: 'flex-end', }, icon: { bottom: 10, left: 4, position: 'absolute', }, input: { paddingLeft: 35, }, row: { marginHorizontal: 24, }, }); const loadingStatusSelector = createLoadingStatusSelector(logInActionTypes); const ConnectedLogInPanel: React.ComponentType = React.memo( function ConnectedLogInPanel(props: BaseProps) { const loadingStatus = useSelector(loadingStatusSelector); const navContext = React.useContext(NavContext); const logInExtraInfo = useSelector(state => nativeLogInExtraInfoSelector({ redux: state, navContext, }), ); const dispatchActionPromise = useDispatchActionPromise(); const callLogIn = useServerCall(logIn); return ( ); }, ); export default ConnectedLogInPanel; diff --git a/web/account/log-in-form.react.js b/web/account/log-in-form.react.js index 389754b3d..27e070688 100644 --- a/web/account/log-in-form.react.js +++ b/web/account/log-in-form.react.js @@ -1,165 +1,170 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { logInActionTypes, logIn } from 'lib/actions/user-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { oldValidUsernameRegex, validEmailRegex, } from 'lib/shared/account-utils'; import { type LogInExtraInfo, type LogInStartingPayload, loginActionSources, } from 'lib/types/account-types'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils'; import Button from '../components/button.react'; import LoadingIndicator from '../loading-indicator.react'; import Input from '../modals/input.react'; import { useModalContext } from '../modals/modal-provider.react'; import { useSelector } from '../redux/redux-utils'; import { webLogInExtraInfoSelector } from '../selectors/account-selectors'; import css from './log-in-form.css'; const loadingStatusSelector = createLoadingStatusSelector(logInActionTypes); function LoginForm(): React.Node { const inputDisabled = useSelector(loadingStatusSelector) === 'loading'; const loginExtraInfo = useSelector(webLogInExtraInfoSelector); const callLogIn = useServerCall(logIn); const dispatchActionPromise = useDispatchActionPromise(); const modalContext = useModalContext(); const [username, setUsername] = React.useState(''); const [password, setPassword] = React.useState(''); const [errorMessage, setErrorMessage] = React.useState(''); const usernameInputRef = React.useRef(); const passwordInputRef = React.useRef(); React.useEffect(() => { usernameInputRef.current?.focus(); }, []); const onUsernameChange = React.useCallback(e => { invariant(e.target instanceof HTMLInputElement, 'target not input'); setUsername(e.target.value); }, []); + const onUsernameBlur = React.useCallback(() => { + setUsername(untrimmedUsername => untrimmedUsername.trim()); + }, []); + const onPasswordChange = React.useCallback(e => { invariant(e.target instanceof HTMLInputElement, 'target not input'); setPassword(e.target.value); }, []); const logInAction = React.useCallback( async (extraInfo: LogInExtraInfo) => { try { const result = await callLogIn({ ...extraInfo, username, password, source: loginActionSources.logInFromWebForm, }); modalContext.popModal(); return result; } catch (e) { if (e.message === 'invalid_parameters') { setUsername(''); setErrorMessage(`user doesn't exist`); usernameInputRef.current?.focus(); } else if (e.message === 'invalid_credentials') { setPassword(''); setErrorMessage('wrong password'); passwordInputRef.current?.focus(); } else { setUsername(''); setPassword(''); setErrorMessage('unknown error'); usernameInputRef.current?.focus(); } throw e; } }, [callLogIn, modalContext, password, username], ); const onSubmit = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); if (username.search(validEmailRegex) > -1) { setUsername(''); setErrorMessage('usernames only, not emails'); usernameInputRef.current?.focus(); return; } else if (username.search(oldValidUsernameRegex) === -1) { setUsername(''); setErrorMessage('alphanumeric usernames only'); usernameInputRef.current?.focus(); return; } const extraInfo = loginExtraInfo(); dispatchActionPromise( logInActionTypes, logInAction(extraInfo), undefined, ({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload), ); }, [dispatchActionPromise, logInAction, loginExtraInfo, username], ); const loginButtonContent = React.useMemo(() => { if (inputDisabled) { return ; } return 'Log in'; }, [inputDisabled]); return (
Username
Password
{errorMessage}
); } export default LoginForm; diff --git a/web/modals/input.react.js b/web/modals/input.react.js index 0c2dd4f78..de6214c20 100644 --- a/web/modals/input.react.js +++ b/web/modals/input.react.js @@ -1,48 +1,49 @@ // @flow import * as React from 'react'; import css from './input.css'; type Props = { +type: string, +placeholder: string, +value: string, +onChange: (value: SyntheticEvent) => mixed, + +onBlur?: (value: SyntheticEvent) => mixed, +disabled?: boolean, +label?: string, +id?: string, }; function Input(props: Props, ref): React.Node { const { label: labelProp, disabled = false, id, ...rest } = props; let label; if (labelProp) { label = ( ); } return ( <> {label} ); } const ForwardedInput: React.AbstractComponent< Props, HTMLInputElement, > = React.forwardRef(Input); export default ForwardedInput;