diff --git a/lib/actions/user-actions.js b/lib/actions/user-actions.js index 01542ce9f..a10685926 100644 --- a/lib/actions/user-actions.js +++ b/lib/actions/user-actions.js @@ -1,378 +1,375 @@ // @flow import threadWatcher from '../shared/thread-watcher.js'; import type { LogOutResult, LogInInfo, LogInResult, RegisterResult, RegisterInfo, UpdateUserSettingsRequest, PolicyAcknowledgmentRequest, ClaimUsernameResponse, } from '../types/account-types.js'; import type { UpdateUserAvatarRequest, UpdateUserAvatarResponse, } from '../types/avatar-types.js'; import type { GetSessionPublicKeysArgs, GetOlmSessionInitializationDataResponse, } from '../types/request-types.js'; import type { UserSearchResult, ExactUserSearchResult, } from '../types/search-types.js'; import type { SessionPublicKeys, PreRequestUserState, } from '../types/session-types.js'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResult, } from '../types/subscription-types.js'; import type { UserInfo, PasswordUpdate } from '../types/user-types.js'; import type { CallServerEndpoint, CallServerEndpointOptions, } from '../utils/call-server-endpoint.js'; import { getConfig } from '../utils/config.js'; import sleep from '../utils/sleep.js'; const logOutActionTypes = Object.freeze({ started: 'LOG_OUT_STARTED', success: 'LOG_OUT_SUCCESS', failed: 'LOG_OUT_FAILED', }); const logOut = ( callServerEndpoint: CallServerEndpoint, ): ((preRequestUserState: PreRequestUserState) => Promise) => async preRequestUserState => { let response = null; try { response = await Promise.race([ callServerEndpoint('log_out', {}), (async () => { await sleep(500); throw new Error('log_out took more than 500ms'); })(), ]); } catch {} const currentUserInfo = response ? response.currentUserInfo : null; return { currentUserInfo, preRequestUserState }; }; const claimUsernameActionTypes = Object.freeze({ started: 'CLAIM_USERNAME_STARTED', success: 'CLAIM_USERNAME_SUCCESS', failed: 'CLAIM_USERNAME_FAILED', }); const claimUsernameCallServerEndpointOptions = { timeout: 500 }; const claimUsername = ( callServerEndpoint: CallServerEndpoint, ): (() => Promise) => async () => { const response = await callServerEndpoint( 'claim_username', {}, { ...claimUsernameCallServerEndpointOptions }, ); return response; }; const deleteAccountActionTypes = Object.freeze({ started: 'DELETE_ACCOUNT_STARTED', success: 'DELETE_ACCOUNT_SUCCESS', failed: 'DELETE_ACCOUNT_FAILED', }); const deleteAccount = ( callServerEndpoint: CallServerEndpoint, - ): (( - password: ?string, - preRequestUserState: PreRequestUserState, - ) => Promise) => - async (password, preRequestUserState) => { - const response = await callServerEndpoint('delete_account', { password }); + ): ((preRequestUserState: PreRequestUserState) => Promise) => + async preRequestUserState => { + const response = await callServerEndpoint('delete_account'); return { currentUserInfo: response.currentUserInfo, preRequestUserState }; }; const registerActionTypes = Object.freeze({ started: 'REGISTER_STARTED', success: 'REGISTER_SUCCESS', failed: 'REGISTER_FAILED', }); const registerCallServerEndpointOptions = { timeout: 60000 }; const register = ( callServerEndpoint: CallServerEndpoint, ): (( registerInfo: RegisterInfo, options?: CallServerEndpointOptions, ) => Promise) => async (registerInfo, options) => { const response = await callServerEndpoint( 'create_account', { ...registerInfo, platformDetails: getConfig().platformDetails, }, { ...registerCallServerEndpointOptions, ...options, }, ); return { currentUserInfo: response.currentUserInfo, rawMessageInfos: response.rawMessageInfos, threadInfos: response.cookieChange.threadInfos, userInfos: response.cookieChange.userInfos, calendarQuery: registerInfo.calendarQuery, }; }; function mergeUserInfos(...userInfoArrays: UserInfo[][]): UserInfo[] { const merged = {}; for (const userInfoArray of userInfoArrays) { for (const userInfo of userInfoArray) { merged[userInfo.id] = userInfo; } } const flattened = []; for (const id in merged) { flattened.push(merged[id]); } return flattened; } const logInActionTypes = Object.freeze({ started: 'LOG_IN_STARTED', success: 'LOG_IN_SUCCESS', failed: 'LOG_IN_FAILED', }); const logInCallServerEndpointOptions = { timeout: 60000 }; const logIn = ( callServerEndpoint: CallServerEndpoint, ): ((logInInfo: LogInInfo) => Promise) => async logInInfo => { const watchedIDs = threadWatcher.getWatchedIDs(); const { logInActionSource, ...restLogInInfo } = logInInfo; const response = await callServerEndpoint( 'log_in', { ...restLogInInfo, source: logInActionSource, watchedIDs, platformDetails: getConfig().platformDetails, }, logInCallServerEndpointOptions, ); const userInfos = mergeUserInfos( response.userInfos, response.cookieChange.userInfos, ); return { threadInfos: response.cookieChange.threadInfos, currentUserInfo: response.currentUserInfo, calendarResult: { calendarQuery: logInInfo.calendarQuery, rawEntryInfos: response.rawEntryInfos, }, messagesResult: { messageInfos: response.rawMessageInfos, truncationStatus: response.truncationStatuses, watchedIDsAtRequestTime: watchedIDs, currentAsOf: response.serverTime, }, userInfos, updatesCurrentAsOf: response.serverTime, logInActionSource: logInInfo.logInActionSource, notAcknowledgedPolicies: response.notAcknowledgedPolicies, }; }; const changeUserPasswordActionTypes = Object.freeze({ started: 'CHANGE_USER_PASSWORD_STARTED', success: 'CHANGE_USER_PASSWORD_SUCCESS', failed: 'CHANGE_USER_PASSWORD_FAILED', }); const changeUserPassword = ( callServerEndpoint: CallServerEndpoint, ): ((passwordUpdate: PasswordUpdate) => Promise) => async passwordUpdate => { await callServerEndpoint('update_account', passwordUpdate); }; const searchUsersActionTypes = Object.freeze({ started: 'SEARCH_USERS_STARTED', success: 'SEARCH_USERS_SUCCESS', failed: 'SEARCH_USERS_FAILED', }); const searchUsers = ( callServerEndpoint: CallServerEndpoint, ): ((usernamePrefix: string) => Promise) => async usernamePrefix => { const response = await callServerEndpoint('search_users', { prefix: usernamePrefix, }); return { userInfos: response.userInfos, }; }; const exactSearchUserActionTypes = Object.freeze({ started: 'EXACT_SEARCH_USER_STARTED', success: 'EXACT_SEARCH_USER_SUCCESS', failed: 'EXACT_SEARCH_USER_FAILED', }); const exactSearchUser = ( callServerEndpoint: CallServerEndpoint, ): ((username: string) => Promise) => async username => { const response = await callServerEndpoint('exact_search_user', { username, }); return { userInfo: response.userInfo, }; }; const updateSubscriptionActionTypes = Object.freeze({ started: 'UPDATE_SUBSCRIPTION_STARTED', success: 'UPDATE_SUBSCRIPTION_SUCCESS', failed: 'UPDATE_SUBSCRIPTION_FAILED', }); const updateSubscription = ( callServerEndpoint: CallServerEndpoint, ): (( subscriptionUpdate: SubscriptionUpdateRequest, ) => Promise) => async subscriptionUpdate => { const response = await callServerEndpoint( 'update_user_subscription', subscriptionUpdate, ); return { threadID: subscriptionUpdate.threadID, subscription: response.threadSubscription, }; }; const setUserSettingsActionTypes = Object.freeze({ started: 'SET_USER_SETTINGS_STARTED', success: 'SET_USER_SETTINGS_SUCCESS', failed: 'SET_USER_SETTINGS_FAILED', }); const setUserSettings = ( callServerEndpoint: CallServerEndpoint, ): ((userSettingsRequest: UpdateUserSettingsRequest) => Promise) => async userSettingsRequest => { await callServerEndpoint('update_user_settings', userSettingsRequest); }; const getSessionPublicKeys = ( callServerEndpoint: CallServerEndpoint, ): ((data: GetSessionPublicKeysArgs) => Promise) => async data => { return await callServerEndpoint('get_session_public_keys', data); }; const getOlmSessionInitializationDataActionTypes = Object.freeze({ started: 'GET_OLM_SESSION_INITIALIZATION_DATA_STARTED', success: 'GET_OLM_SESSION_INITIALIZATION_DATA_SUCCESS', failed: 'GET_OLM_SESSION_INITIALIZATION_DATA_FAILED', }); const getOlmSessionInitializationData = ( callServerEndpoint: CallServerEndpoint, ): (( options?: ?CallServerEndpointOptions, ) => Promise) => async options => { return await callServerEndpoint( 'get_olm_session_initialization_data', {}, options, ); }; const policyAcknowledgmentActionTypes = Object.freeze({ started: 'POLICY_ACKNOWLEDGMENT_STARTED', success: 'POLICY_ACKNOWLEDGMENT_SUCCESS', failed: 'POLICY_ACKNOWLEDGMENT_FAILED', }); const policyAcknowledgment = ( callServerEndpoint: CallServerEndpoint, ): ((policyRequest: PolicyAcknowledgmentRequest) => Promise) => async policyRequest => { await callServerEndpoint('policy_acknowledgment', policyRequest); }; const updateUserAvatarActionTypes = Object.freeze({ started: 'UPDATE_USER_AVATAR_STARTED', success: 'UPDATE_USER_AVATAR_SUCCESS', failed: 'UPDATE_USER_AVATAR_FAILED', }); const updateUserAvatar = ( callServerEndpoint: CallServerEndpoint, ): (( avatarDBContent: UpdateUserAvatarRequest, ) => Promise) => async avatarDBContent => { const { updates }: UpdateUserAvatarResponse = await callServerEndpoint( 'update_user_avatar', avatarDBContent, ); return { updates }; }; const resetUserStateActionType = 'RESET_USER_STATE'; const setAccessTokenActionType = 'SET_ACCESS_TOKEN'; export { changeUserPasswordActionTypes, changeUserPassword, claimUsernameActionTypes, claimUsername, deleteAccount, deleteAccountActionTypes, getSessionPublicKeys, getOlmSessionInitializationDataActionTypes, getOlmSessionInitializationData, mergeUserInfos, logIn, logInActionTypes, logOut, logOutActionTypes, register, registerActionTypes, searchUsers, searchUsersActionTypes, exactSearchUser, exactSearchUserActionTypes, setUserSettings, setUserSettingsActionTypes, updateSubscription, updateSubscriptionActionTypes, policyAcknowledgment, policyAcknowledgmentActionTypes, updateUserAvatarActionTypes, updateUserAvatar, resetUserStateActionType, setAccessTokenActionType, }; diff --git a/native/profile/delete-account.react.js b/native/profile/delete-account.react.js index 9bd8adeb9..f8f348f12 100644 --- a/native/profile/delete-account.react.js +++ b/native/profile/delete-account.react.js @@ -1,280 +1,278 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, View, TextInput as BaseTextInput, ActivityIndicator, } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { deleteAccountActionTypes, deleteAccount, } from 'lib/actions/user-actions.js'; import { preRequestUserStateSelector } from 'lib/selectors/account-selectors.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { accountHasPassword } from 'lib/shared/account-utils.js'; import type { LogOutResult } from 'lib/types/account-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { PreRequestUserState } from 'lib/types/session-types.js'; import type { DispatchActionPromise } from 'lib/utils/action-utils.js'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import { deleteNativeCredentialsFor } from '../account/native-credentials.js'; import Button from '../components/button.react.js'; import TextInput from '../components/text-input.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../themes/colors.js'; import type { GlobalTheme } from '../types/themes.js'; import Alert from '../utils/alert.js'; type Props = { // Redux state +isAccountWithPassword: boolean, +loadingStatus: LoadingStatus, +preRequestUserState: PreRequestUserState, +activeTheme: ?GlobalTheme, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +deleteAccount: ( - password: ?string, preRequestUserState: PreRequestUserState, ) => Promise, }; type State = { +password: ?string, }; class DeleteAccount extends React.PureComponent { state: State = { password: null, }; mounted = false; passwordInput: ?React.ElementRef; componentDidMount() { this.mounted = true; } componentWillUnmount() { this.mounted = false; } render() { const buttonContent = this.props.loadingStatus === 'loading' ? ( ) : ( Delete account ); const { panelForegroundTertiaryLabel } = this.props.colors; let inputPasswordPrompt; if (this.props.isAccountWithPassword) { inputPasswordPrompt = ( <> PASSWORD ); } return ( Your account will be permanently deleted. There is no way to reverse this. {inputPasswordPrompt} ); } onChangePasswordText = (newPassword: string) => { this.setState({ password: newPassword }); }; passwordInputRef = ( passwordInput: ?React.ElementRef, ) => { this.passwordInput = passwordInput; }; focusPasswordInput = () => { invariant(this.passwordInput, 'passwordInput should be set'); this.passwordInput.focus(); }; submitDeletion = () => { this.props.dispatchActionPromise( deleteAccountActionTypes, this.deleteAccount(), ); }; async deleteAccount() { try { await deleteNativeCredentialsFor(); const result = await this.props.deleteAccount( - this.state.password, this.props.preRequestUserState, ); return result; } catch (e) { if (e.message === 'invalid_credentials') { Alert.alert( 'Incorrect password', 'The password you entered is incorrect', [{ text: 'OK', onPress: this.onErrorAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onErrorAlertAcknowledged }], { cancelable: false }, ); } throw e; } } onErrorAlertAcknowledged = () => { this.setState({ password: '' }, this.focusPasswordInput); }; } const unboundStyles = { deleteButton: { backgroundColor: 'vibrantRedButton', borderRadius: 5, flex: 1, marginHorizontal: 24, marginVertical: 12, padding: 12, }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, input: { color: 'panelForegroundLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, paddingVertical: 0, borderBottomColor: 'transparent', }, lastWarningText: { marginBottom: 24, }, saveText: { color: 'white', fontSize: 18, textAlign: 'center', }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, flexDirection: 'row', justifyContent: 'space-between', marginBottom: 24, paddingHorizontal: 24, paddingVertical: 12, }, warningText: { color: 'panelForegroundLabel', fontSize: 16, marginHorizontal: 24, textAlign: 'center', }, }; const loadingStatusSelector = createLoadingStatusSelector( deleteAccountActionTypes, ); const ConnectedDeleteAccount: React.ComponentType<{ ... }> = React.memo<{ ... }>(function ConnectedDeleteAccount() { const isAccountWithPassword = useSelector(state => accountHasPassword(state.currentUserInfo), ); const loadingStatus = useSelector(loadingStatusSelector); const preRequestUserState = useSelector(preRequestUserStateSelector); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callDeleteAccount = useServerCall(deleteAccount); return ( ); }); export default ConnectedDeleteAccount; diff --git a/web/settings/account-delete-modal.react.js b/web/settings/account-delete-modal.react.js index 7fffc8568..d91269d05 100644 --- a/web/settings/account-delete-modal.react.js +++ b/web/settings/account-delete-modal.react.js @@ -1,213 +1,211 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { deleteAccount, deleteAccountActionTypes, } from 'lib/actions/user-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import { preRequestUserStateSelector } from 'lib/selectors/account-selectors.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { accountHasPassword } from 'lib/shared/account-utils.js'; import type { LogOutResult } from 'lib/types/account-types.js'; import type { PreRequestUserState } from 'lib/types/session-types.js'; import type { DispatchActionPromise } from 'lib/utils/action-utils.js'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils.js'; import css from './account-delete-modal.css'; import Button, { buttonThemes } from '../components/button.react.js'; import Input from '../modals/input.react.js'; import Modal from '../modals/modal.react.js'; import { useSelector } from '../redux/redux-utils.js'; type Props = { +isAccountWithPassword: boolean, +preRequestUserState: PreRequestUserState, +inputDisabled: boolean, +dispatchActionPromise: DispatchActionPromise, +deleteAccount: ( - password: ?string, preRequestUserState: PreRequestUserState, ) => Promise, +popModal: () => void, }; type State = { +currentPassword: ?string, +errorMessage: string, }; class AccountDeleteModal extends React.PureComponent { currentPasswordInput: ?HTMLInputElement; constructor(props: Props) { super(props); this.state = { currentPassword: props.isAccountWithPassword ? '' : null, errorMessage: '', }; } componentDidMount() { invariant( !this.props.isAccountWithPassword || this.currentPasswordInput, 'newPasswordInput ref unset', ); this.currentPasswordInput?.focus(); } render() { const { inputDisabled } = this.props; let errorMsg; if (this.state.errorMessage) { errorMsg = (
{this.state.errorMessage}
); } let passwordConfirmation; if (this.props.isAccountWithPassword) { invariant( this.state.currentPassword !== null && this.state.currentPassword !== undefined, 'currentPassword must be set if isAccountWithPassword', ); passwordConfirmation = ( <>

Please enter your account password to confirm your identity.

Account password

); } return (

Your account will be permanently deleted. There is no way to reverse this.

{passwordConfirmation}
{errorMsg}
); } currentPasswordInputRef = (currentPasswordInput: ?HTMLInputElement) => { this.currentPasswordInput = currentPasswordInput; }; onChangeCurrentPassword = (event: SyntheticEvent) => { const target = event.target; invariant(target instanceof HTMLInputElement, 'target not input'); this.setState({ currentPassword: target.value }); }; onDelete = (event: SyntheticEvent) => { event.preventDefault(); this.props.dispatchActionPromise( deleteAccountActionTypes, this.deleteAction(), ); }; async deleteAction() { try { const response = await this.props.deleteAccount( - this.state.currentPassword, this.props.preRequestUserState, ); this.props.popModal(); return response; } catch (e) { const errorMessage = e.message === 'invalid_credentials' ? 'wrong password' : 'unknown error'; this.setState( { currentPassword: this.props.isAccountWithPassword ? '' : null, errorMessage: errorMessage, }, () => { invariant( !this.props.isAccountWithPassword || this.currentPasswordInput, 'currentPasswordInput ref unset', ); this.currentPasswordInput?.focus(); }, ); throw e; } } } const deleteAccountLoadingStatusSelector = createLoadingStatusSelector( deleteAccountActionTypes, ); const ConnectedAccountDeleteModal: React.ComponentType<{}> = React.memo<{}>( function ConnectedAccountDeleteModal(): React.Node { const isAccountWithPassword = useSelector(state => accountHasPassword(state.currentUserInfo), ); const preRequestUserState = useSelector(preRequestUserStateSelector); const inputDisabled = useSelector( state => deleteAccountLoadingStatusSelector(state) === 'loading', ); const callDeleteAccount = useServerCall(deleteAccount); const dispatchActionPromise = useDispatchActionPromise(); const modalContext = useModalContext(); return ( ); }, ); export default ConnectedAccountDeleteModal;