diff --git a/src/PasswordReset.ts b/src/PasswordReset.ts index f5d9ab77dc..df812bafb2 100644 --- a/src/PasswordReset.ts +++ b/src/PasswordReset.ts @@ -31,6 +31,7 @@ export default class PasswordReset { private clientSecret: string; private password: string; private sessionId: string; + private logoutDevices: boolean; /** * Configure the endpoints for password resetting. @@ -50,10 +51,16 @@ export default class PasswordReset { * sending an email to the provided email address. * @param {string} emailAddress The email address * @param {string} newPassword The new password for the account. + * @param {boolean} logoutDevices Should all devices be signed out after the reset? Defaults to `true`. * @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked(). */ - public resetPassword(emailAddress: string, newPassword: string): Promise { + public resetPassword( + emailAddress: string, + newPassword: string, + logoutDevices = true, + ): Promise { this.password = newPassword; + this.logoutDevices = logoutDevices; return this.client.requestPasswordEmailToken(emailAddress, this.clientSecret, 1).then((res) => { this.sessionId = res.sid; return res; @@ -90,7 +97,7 @@ export default class PasswordReset { // See https://github.com/matrix-org/matrix-doc/issues/2220 threepid_creds: creds, threepidCreds: creds, - }, this.password); + }, this.password, this.logoutDevices); } catch (err) { if (err.httpStatus === 401) { err.message = _t('Failed to verify email address: make sure you clicked the link in the email'); diff --git a/src/components/structures/auth/ForgotPassword.tsx b/src/components/structures/auth/ForgotPassword.tsx index 911ee5d1b9..df39f0aa8f 100644 --- a/src/components/structures/auth/ForgotPassword.tsx +++ b/src/components/structures/auth/ForgotPassword.tsx @@ -19,6 +19,7 @@ limitations under the License. import React from 'react'; import classNames from 'classnames'; import { logger } from "matrix-js-sdk/src/logger"; +import { createClient } from "matrix-js-sdk/src/matrix"; import { _t, _td } from '../../../languageHandler'; import Modal from "../../../Modal"; @@ -37,6 +38,7 @@ import AuthHeader from "../../views/auth/AuthHeader"; import AuthBody from "../../views/auth/AuthBody"; import PassphraseConfirmField from "../../views/auth/PassphraseConfirmField"; import AccessibleButton from '../../views/elements/AccessibleButton'; +import StyledCheckbox from '../../views/elements/StyledCheckbox'; enum Phase { // Show the forgot password inputs @@ -72,6 +74,9 @@ interface IState { serverDeadError: string; currentHttpRequest?: Promise; + + serverSupportsControlOfDevicesLogout: boolean; + logoutDevices: boolean; } enum ForgotPasswordField { @@ -97,11 +102,14 @@ export default class ForgotPassword extends React.Component { serverIsAlive: true, serverErrorIsFatal: false, serverDeadError: "", + serverSupportsControlOfDevicesLogout: false, + logoutDevices: false, }; public componentDidMount() { this.reset = null; this.checkServerLiveliness(this.props.serverConfig); + this.checkServerCapabilities(this.props.serverConfig); } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event @@ -112,6 +120,9 @@ export default class ForgotPassword extends React.Component { // Do a liveliness check on the new URLs this.checkServerLiveliness(newProps.serverConfig); + + // Do capabilities check on new URLs + this.checkServerCapabilities(newProps.serverConfig); } private async checkServerLiveliness(serverConfig): Promise { @@ -129,12 +140,25 @@ export default class ForgotPassword extends React.Component { } } - public submitPasswordReset(email: string, password: string): void { + private async checkServerCapabilities(serverConfig: ValidatedServerConfig): Promise { + const tempClient = createClient({ + baseUrl: serverConfig.hsUrl, + }); + + const serverSupportsControlOfDevicesLogout = await tempClient.doesServerSupportLogoutDevices(); + + this.setState({ + logoutDevices: !serverSupportsControlOfDevicesLogout, + serverSupportsControlOfDevicesLogout, + }); + } + + public submitPasswordReset(email: string, password: string, logoutDevices = true): void { this.setState({ phase: Phase.SendingEmail, }); this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl); - this.reset.resetPassword(email, password).then(() => { + this.reset.resetPassword(email, password, logoutDevices).then(() => { this.setState({ phase: Phase.EmailSent, }); @@ -174,24 +198,35 @@ export default class ForgotPassword extends React.Component { return; } - Modal.createTrackedDialog('Forgot Password Warning', '', QuestionDialog, { - title: _t('Warning!'), - description: -
- { _t( - "Changing your password will reset any end-to-end encryption keys " + - "on all of your sessions, making encrypted chat history unreadable. Set up " + - "Key Backup or export your room keys from another session before resetting your " + - "password.", - ) } -
, - button: _t('Continue'), - onFinished: (confirmed) => { - if (confirmed) { - this.submitPasswordReset(this.state.email, this.state.password); - } - }, - }); + if (this.state.logoutDevices) { + const { finished } = Modal.createTrackedDialog<[boolean]>('Forgot Password Warning', '', QuestionDialog, { + title: _t('Warning!'), + description: +
+

{ !this.state.serverSupportsControlOfDevicesLogout ? + _t( + "Resetting your password on this homeserver will cause all of your devices to be " + + "signed out. This will delete the message encryption keys stored on them, " + + "making encrypted chat history unreadable.", + ) : + _t( + "Signing out your devices will delete the message encryption keys stored on them, " + + "making encrypted chat history unreadable.", + ) + }

+

{ _t( + "If you want to retain access to your chat history in encrypted rooms, set up Key Backup " + + "or export your message keys from one of your other devices before proceeding.", + ) }

+
, + button: _t('Continue'), + }); + const [confirmed] = await finished; + + if (!confirmed) return; + } + + this.submitPasswordReset(this.state.email, this.state.password, this.state.logoutDevices); }; private async verifyFieldsBeforeSubmit() { @@ -316,6 +351,13 @@ export default class ForgotPassword extends React.Component { autoComplete="new-password" /> + { this.state.serverSupportsControlOfDevicesLogout ? +
+ this.setState({ logoutDevices: !this.state.logoutDevices })} checked={this.state.logoutDevices}> + { _t("Sign out all devices") } + +
: null + } { _t( 'A verification email will be sent to your inbox to confirm ' + 'setting your new password.', @@ -355,11 +397,14 @@ export default class ForgotPassword extends React.Component { renderDone() { return

{ _t("Your password has been reset.") }

-

{ _t( - "You have been logged out of all sessions and will no longer receive " + - "push notifications. To re-enable notifications, sign in again on each " + - "device.", - ) }

+ { this.state.logoutDevices ? +

{ _t( + "You have been logged out of all devices and will no longer receive " + + "push notifications. To re-enable notifications, sign in again on each " + + "device.", + ) }

+ : null + } void; + onFinished?: (outcome: { + didSetEmail?: boolean; + /** Was one or more other devices logged out whilst changing the password */ + didLogoutOutOtherDevices: boolean; + }) => void; onError?: (error: {error: string}) => void; rowClassName?: string; buttonClassName?: string; @@ -82,48 +86,58 @@ export default class ChangePassword extends React.Component { }; } - private onChangePassword(oldPassword: string, newPassword: string): void { + private async onChangePassword(oldPassword: string, newPassword: string): Promise { const cli = MatrixClientPeg.get(); - if (!this.props.confirm) { - this.changePassword(cli, oldPassword, newPassword); - return; + // if the server supports it then don't sign user out of all devices + const serverSupportsControlOfDevicesLogout = await cli.doesServerSupportLogoutDevices(); + const userHasOtherDevices = (await cli.getDevices()).devices.length > 1; + + if (userHasOtherDevices && !serverSupportsControlOfDevicesLogout && this.props.confirm) { + // warn about logging out all devices + const { finished } = Modal.createTrackedDialog<[boolean]>('Change Password', '', QuestionDialog, { + title: _t("Warning!"), + description: +
+

{ _t( + 'Changing your password on this homeserver will cause all of your other devices to be ' + + 'signed out. This will delete the message encryption keys stored on them, and may make ' + + 'encrypted chat history unreadable.', + ) }

+

{ _t( + 'If you want to retain access to your chat history in encrypted rooms you should first ' + + 'export your room keys and re-import them afterwards.', + ) }

+

{ _t( + 'You can also ask your homeserver admin to upgrade the server to change this behaviour.', + ) }

+
, + button: _t("Continue"), + extraButtons: [ + , + ], + }); + + const [confirmed] = await finished; + if (!confirmed) return; } - Modal.createTrackedDialog('Change Password', '', QuestionDialog, { - title: _t("Warning!"), - description: -
- { _t( - 'Changing password will currently reset any end-to-end encryption keys on all sessions, ' + - 'making encrypted chat history unreadable, unless you first export your room keys ' + - 'and re-import them afterwards. ' + - 'In future this will be improved.', - ) } - { ' ' } - - https://github.com/vector-im/element-web/issues/2671 - -
, - button: _t("Continue"), - extraButtons: [ - , - ], - onFinished: (confirmed) => { - if (confirmed) { - this.changePassword(cli, oldPassword, newPassword); - } - }, - }); + this.changePassword(cli, oldPassword, newPassword, serverSupportsControlOfDevicesLogout, userHasOtherDevices); } - private changePassword(cli: MatrixClient, oldPassword: string, newPassword: string): void { + private changePassword( + cli: MatrixClient, + oldPassword: string, + newPassword: string, + serverSupportsControlOfDevicesLogout: boolean, + userHasOtherDevices: boolean, + ): void { const authDict = { type: 'm.login.password', identifier: { @@ -140,15 +154,21 @@ export default class ChangePassword extends React.Component { phase: Phase.Uploading, }); - cli.setPassword(authDict, newPassword).then(() => { + const logoutDevices = serverSupportsControlOfDevicesLogout ? false : undefined; + + // undefined or true mean all devices signed out + const didLogoutOutOtherDevices = !serverSupportsControlOfDevicesLogout && userHasOtherDevices; + + cli.setPassword(authDict, newPassword, logoutDevices).then(() => { if (this.props.shouldAskForEmail) { return this.optionallySetEmail().then((confirmed) => { this.props.onFinished({ didSetEmail: confirmed, + didLogoutOutOtherDevices, }); }); } else { - this.props.onFinished(); + this.props.onFinished({ didLogoutOutOtherDevices }); } }, (err) => { this.props.onError(err); @@ -279,7 +299,7 @@ export default class ChangePassword extends React.Component { if (err) { this.props.onError(err); } else { - this.onChangePassword(oldPassword, newPassword); + return this.onChangePassword(oldPassword, newPassword); } }; diff --git a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx index 7db27b009e..36d3e55d85 100644 --- a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx @@ -260,14 +260,17 @@ export default class GeneralUserSettingsTab extends React.Component { + private onPasswordChanged = ({ didLogoutOutOtherDevices }: { didLogoutOutOtherDevices: boolean }): void => { + let description = _t("Your password was successfully changed."); + if (didLogoutOutOtherDevices) { + description += " " + _t( + "You will not receive push notifications on other devices until you sign back in to them.", + ); + } // TODO: Figure out a design that doesn't involve replacing the current dialog Modal.createTrackedDialog('Password changed', '', ErrorDialog, { title: _t("Success"), - description: _t( - "Your password was successfully changed. You will not receive " + - "push notifications on other sessions until you log back in to them", - ) + ".", + description, }); }; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index bf666f0719..bf101a8fd3 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1213,7 +1213,9 @@ "Upload new:": "Upload new:", "No display name": "No display name", "Warning!": "Warning!", - "Changing password will currently reset any end-to-end encryption keys on all sessions, making encrypted chat history unreadable, unless you first export your room keys and re-import them afterwards. In future this will be improved.": "Changing password will currently reset any end-to-end encryption keys on all sessions, making encrypted chat history unreadable, unless you first export your room keys and re-import them afterwards. In future this will be improved.", + "Changing your password on this homeserver will cause all of your other devices to be signed out. This will delete the message encryption keys stored on them, and may make encrypted chat history unreadable.": "Changing your password on this homeserver will cause all of your other devices to be signed out. This will delete the message encryption keys stored on them, and may make encrypted chat history unreadable.", + "If you want to retain access to your chat history in encrypted rooms you should first export your room keys and re-import them afterwards.": "If you want to retain access to your chat history in encrypted rooms you should first export your room keys and re-import them afterwards.", + "You can also ask your homeserver admin to upgrade the server to change this behaviour.": "You can also ask your homeserver admin to upgrade the server to change this behaviour.", "Export E2E room keys": "Export E2E room keys", "New passwords don't match": "New passwords don't match", "Passwords can't be empty": "Passwords can't be empty", @@ -1426,8 +1428,9 @@ "Customise your appearance": "Customise your appearance", "Appearance Settings only affect this %(brand)s session.": "Appearance Settings only affect this %(brand)s session.", "Failed to change password. Is your password correct?": "Failed to change password. Is your password correct?", + "Your password was successfully changed.": "Your password was successfully changed.", + "You will not receive push notifications on other devices until you sign back in to them.": "You will not receive push notifications on other devices until you sign back in to them.", "Success": "Success", - "Your password was successfully changed. You will not receive push notifications on other sessions until you log back in to them": "Your password was successfully changed. You will not receive push notifications on other sessions until you log back in to them", "Email addresses": "Email addresses", "Phone numbers": "Phone numbers", "Set a new account password...": "Set a new account password...", @@ -3177,18 +3180,21 @@ "Really reset verification keys?": "Really reset verification keys?", "Skip verification for now": "Skip verification for now", "Failed to send email": "Failed to send email", - "Changing your password will reset any end-to-end encryption keys on all of your sessions, making encrypted chat history unreadable. Set up Key Backup or export your room keys from another session before resetting your password.": "Changing your password will reset any end-to-end encryption keys on all of your sessions, making encrypted chat history unreadable. Set up Key Backup or export your room keys from another session before resetting your password.", + "Resetting your password on this homeserver will cause all of your devices to be signed out. This will delete the message encryption keys stored on them, making encrypted chat history unreadable.": "Resetting your password on this homeserver will cause all of your devices to be signed out. This will delete the message encryption keys stored on them, making encrypted chat history unreadable.", + "Signing out your devices will delete the message encryption keys stored on them, making encrypted chat history unreadable.": "Signing out your devices will delete the message encryption keys stored on them, making encrypted chat history unreadable.", + "If you want to retain access to your chat history in encrypted rooms, set up Key Backup or export your message keys from one of your other devices before proceeding.": "If you want to retain access to your chat history in encrypted rooms, set up Key Backup or export your message keys from one of your other devices before proceeding.", "The email address linked to your account must be entered.": "The email address linked to your account must be entered.", "The email address doesn't appear to be valid.": "The email address doesn't appear to be valid.", "A new password must be entered.": "A new password must be entered.", "New passwords must match each other.": "New passwords must match each other.", + "Sign out all devices": "Sign out all devices", "A verification email will be sent to your inbox to confirm setting your new password.": "A verification email will be sent to your inbox to confirm setting your new password.", "Send Reset Email": "Send Reset Email", "Sign in instead": "Sign in instead", "An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.": "An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.", "I have verified my email address": "I have verified my email address", "Your password has been reset.": "Your password has been reset.", - "You have been logged out of all sessions and will no longer receive push notifications. To re-enable notifications, sign in again on each device.": "You have been logged out of all sessions and will no longer receive push notifications. To re-enable notifications, sign in again on each device.", + "You have been logged out of all devices and will no longer receive push notifications. To re-enable notifications, sign in again on each device.": "You have been logged out of all devices and will no longer receive push notifications. To re-enable notifications, sign in again on each device.", "Return to login screen": "Return to login screen", "Set a new password": "Set a new password", "Invalid homeserver discovery response": "Invalid homeserver discovery response",