diff --git a/res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss b/res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss index f15d43b199..63d0ca555d 100644 --- a/res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss +++ b/res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss @@ -38,11 +38,56 @@ limitations under the License. height: 30px; } -.mx_AccessSecretStorageDialog_passPhraseInput, -.mx_AccessSecretStorageDialog_recoveryKeyInput { +.mx_AccessSecretStorageDialog_passPhraseInput { width: 300px; border: 1px solid $accent-color; border-radius: 5px; padding: 10px; } +.mx_AccessSecretStorageDialog_recoveryKeyEntry { + display: flex; + align-items: center; +} + +.mx_AccessSecretStorageDialog_recoveryKeyEntry_textInput { + flex-grow: 1; +} + +.mx_AccessSecretStorageDialog_recoveryKeyEntry_entryControlSeparatorText { + margin: 16px; +} + +.mx_AccessSecretStorageDialog_recoveryKeyFeedback { + &::before { + content: ""; + display: inline-block; + vertical-align: bottom; + width: 20px; + height: 20px; + mask-repeat: no-repeat; + mask-position: center; + mask-size: 20px; + margin-right: 5px; + } +} + +.mx_AccessSecretStorageDialog_recoveryKeyFeedback_valid { + color: $input-valid-border-color; + &::before { + mask-image: url('$(res)/img/feather-customised/check.svg'); + background-color: $input-valid-border-color; + } +} + +.mx_AccessSecretStorageDialog_recoveryKeyFeedback_invalid { + color: $input-invalid-border-color; + &::before { + mask-image: url('$(res)/img/feather-customised/x.svg'); + background-color: $input-invalid-border-color; + } +} + +.mx_AccessSecretStorageDialog_recoveryKeyEntry_fileInput { + display: none; +} diff --git a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js index 58b5b57354..984158c7a2 100644 --- a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js @@ -491,7 +491,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { label={_t("Password")} value={this.state.accountPassword} onChange={this._onAccountPasswordChange} - flagInvalid={this.state.accountPasswordCorrect === false} + forceValidity={this.state.accountPasswordCorrect === false ? false : null} autoFocus={true} /> ; diff --git a/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js b/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js index bb9937c429..5c01a6907f 100644 --- a/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js +++ b/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js @@ -15,13 +15,25 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { debounce } from 'lodash'; +import classNames from 'classnames'; import React from 'react'; import PropTypes from "prop-types"; import * as sdk from '../../../../index'; import {MatrixClientPeg} from '../../../../MatrixClientPeg'; +import Field from '../../elements/Field'; +import AccessibleButton from '../../elements/AccessibleButton'; import { _t } from '../../../../languageHandler'; +// Maximum acceptable size of a key file. It's 59 characters including the spaces we encode, +// so this should be plenty and allow for people putting extra whitespace in the file because +// maybe that's a thing people would do? +const KEY_FILE_MAX_SIZE = 128; + +// Don't shout at the user that their key is invalid every time they type a key: wait a short time +const VALIDATION_THROTTLE_MS = 200; + /* * Access Secure Secret Storage by requesting the user's passphrase. */ @@ -35,9 +47,14 @@ export default class AccessSecretStorageDialog extends React.PureComponent { constructor(props) { super(props); + + this._fileUpload = React.createRef(); + this.state = { recoveryKey: "", - recoveryKeyValid: false, + recoveryKeyValid: null, + recoveryKeyCorrect: null, + recoveryKeyFileError: null, forceRecoveryKey: false, passPhrase: '', keyMatches: null, @@ -54,12 +71,89 @@ export default class AccessSecretStorageDialog extends React.PureComponent { }); } + _validateRecoveryKeyOnChange = debounce(() => { + this._validateRecoveryKey(); + }, VALIDATION_THROTTLE_MS); + + async _validateRecoveryKey() { + if (this.state.recoveryKey === '') { + this.setState({ + recoveryKeyValid: null, + recoveryKeyCorrect: null, + }); + return; + } + + try { + const cli = MatrixClientPeg.get(); + const decodedKey = cli.keyBackupKeyFromRecoveryKey(this.state.recoveryKey); + const correct = await cli.checkSecretStorageKey( + decodedKey, this.props.keyInfo, + ); + this.setState({ + recoveryKeyValid: true, + recoveryKeyCorrect: correct, + }); + } catch (e) { + this.setState({ + recoveryKeyValid: false, + recoveryKeyCorrect: false, + }); + } + } + _onRecoveryKeyChange = (e) => { this.setState({ recoveryKey: e.target.value, - recoveryKeyValid: MatrixClientPeg.get().isValidRecoveryKey(e.target.value), - keyMatches: null, + recoveryKeyFileError: null, }); + + // also clear the file upload control so that the user can upload the same file + // the did before (otherwise the onchange wouldn't fire) + if (this._fileUpload.current) this._fileUpload.current.value = null; + + // We don't use Field's validation here because a) we want it in a separate place rather + // than in a tooltip and b) we want it to display feedback based on the uploaded file + // as well as the text box. Ideally we would refactor Field's validation logic so we could + // re-use some of it. + this._validateRecoveryKeyOnChange(); + } + + _onRecoveryKeyFileChange = async e => { + if (e.target.files.length === 0) return; + + const f = e.target.files[0]; + + if (f.size > KEY_FILE_MAX_SIZE) { + this.setState({ + recoveryKeyFileError: true, + recoveryKeyCorrect: false, + recoveryKeyValid: false, + }); + } else { + const contents = await f.text(); + // test it's within the base58 alphabet. We could be more strict here, eg. require the + // right number of characters, but it's really just to make sure that what we're reading is + // text because we'll put it in the text field. + if (/^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz\s]+$/.test(contents)) { + this.setState({ + recoveryKeyFileError: null, + recoveryKey: contents.trim(), + }); + this._validateRecoveryKey(); + } else { + this.setState({ + recoveryKeyFileError: true, + recoveryKeyCorrect: false, + recoveryKeyValid: false, + recoveryKey: '', + }); + } + } + } + + _onRecoveryKeyFileUploadClick = () => { + this._fileUpload.current.click(); } _onPassPhraseNext = async (e) => { @@ -99,6 +193,20 @@ export default class AccessSecretStorageDialog extends React.PureComponent { }); } + getKeyValidationText() { + if (this.state.recoveryKeyFileError) { + return _t("Wrong file type"); + } else if (this.state.recoveryKeyCorrect) { + return _t("Looks good!"); + } else if (this.state.recoveryKeyValid) { + return _t("Wrong Recovery Key"); + } else if (this.state.recoveryKeyValid === null) { + return ''; + } else { + return _t("Invalid Recovery Key"); + } + } + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); @@ -169,40 +277,50 @@ export default class AccessSecretStorageDialog extends React.PureComponent { titleClass = ['mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_secureBackupTitle']; const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - let keyStatus; - if (this.state.recoveryKey.length === 0) { - keyStatus =
; - } else if (this.state.keyMatches === false) { - keyStatus ={_t("Use your Security Key to continue.")}
-