mirror of
https://github.com/element-hq/element-web
synced 2024-11-23 09:46:09 +03:00
Merge pull request #4847 from matrix-org/dbkr/recovery_key_upload_2
Add file upload button to recovery key input
This commit is contained in:
commit
2247400010
7 changed files with 226 additions and 55 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/></div>
|
||||
</div>;
|
||||
|
|
|
@ -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 = <div className="mx_AccessSecretStorageDialog_keyStatus" />;
|
||||
} else if (this.state.keyMatches === false) {
|
||||
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus">
|
||||
{"\uD83D\uDC4E "}{_t(
|
||||
"Unable to access secret storage. " +
|
||||
"Please verify that you entered the correct recovery key.",
|
||||
)}
|
||||
const feedbackClasses = classNames({
|
||||
'mx_AccessSecretStorageDialog_recoveryKeyFeedback': true,
|
||||
'mx_AccessSecretStorageDialog_recoveryKeyFeedback_valid': this.state.recoveryKeyCorrect === true,
|
||||
'mx_AccessSecretStorageDialog_recoveryKeyFeedback_invalid': this.state.recoveryKeyCorrect === false,
|
||||
});
|
||||
const recoveryKeyFeedback = <div className={feedbackClasses}>
|
||||
{this.getKeyValidationText()}
|
||||
</div>;
|
||||
} else if (this.state.recoveryKeyValid) {
|
||||
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus">
|
||||
{"\uD83D\uDC4D "}{_t("This looks like a valid recovery key!")}
|
||||
</div>;
|
||||
} else {
|
||||
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus">
|
||||
{"\uD83D\uDC4E "}{_t("Not a valid recovery key")}
|
||||
</div>;
|
||||
}
|
||||
|
||||
content = <div>
|
||||
<p>{_t("Use your Security Key to continue.")}</p>
|
||||
|
||||
<form className="mx_AccessSecretStorageDialog_primaryContainer" onSubmit={this._onRecoveryKeyNext}>
|
||||
<input className="mx_AccessSecretStorageDialog_recoveryKeyInput"
|
||||
onChange={this._onRecoveryKeyChange}
|
||||
<form className="mx_AccessSecretStorageDialog_primaryContainer" onSubmit={this._onRecoveryKeyNext} spellCheck={false}>
|
||||
<div className="mx_AccessSecretStorageDialog_recoveryKeyEntry">
|
||||
<div className="mx_AccessSecretStorageDialog_recoveryKeyEntry_textInput">
|
||||
<Field
|
||||
type="text"
|
||||
label={_t('Security Key')}
|
||||
value={this.state.recoveryKey}
|
||||
autoFocus={true}
|
||||
onChange={this._onRecoveryKeyChange}
|
||||
forceValidity={this.state.recoveryKeyCorrect}
|
||||
/>
|
||||
{keyStatus}
|
||||
</div>
|
||||
<span className="mx_AccessSecretStorageDialog_recoveryKeyEntry_entryControlSeparatorText">
|
||||
{_t("or")}
|
||||
</span>
|
||||
<div>
|
||||
<input type="file"
|
||||
className="mx_AccessSecretStorageDialog_recoveryKeyEntry_fileInput"
|
||||
ref={this._fileUpload}
|
||||
onChange={this._onRecoveryKeyFileChange}
|
||||
/>
|
||||
<AccessibleButton kind="primary" onClick={this._onRecoveryKeyFileUploadClick}>
|
||||
{_t("Upload")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
{recoveryKeyFeedback}
|
||||
<DialogButtons
|
||||
primaryButton={_t('Continue')}
|
||||
onPrimaryButtonClick={this._onRecoveryKeyNext}
|
||||
hasCancel={true}
|
||||
cancelButton={_t("Go Back")}
|
||||
cancelButtonClass='danger'
|
||||
onCancel={this._onCancel}
|
||||
focus={false}
|
||||
primaryDisabled={!this.state.recoveryKeyValid}
|
||||
|
|
|
@ -50,7 +50,7 @@ interface IProps {
|
|||
// to the user.
|
||||
onValidate?: (input: IFieldState) => Promise<IValidationResult>;
|
||||
// If specified, overrides the value returned by onValidate.
|
||||
flagInvalid?: boolean;
|
||||
forceValidity?: boolean;
|
||||
// If specified, contents will appear as a tooltip on the element and
|
||||
// validation feedback tooltips will be suppressed.
|
||||
tooltipContent?: React.ReactNode;
|
||||
|
@ -203,7 +203,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
public render() {
|
||||
const {
|
||||
element, prefixComponent, postfixComponent, className, onValidate, children,
|
||||
tooltipContent, flagInvalid, tooltipClassName, list, ...inputProps} = this.props;
|
||||
tooltipContent, forceValidity, tooltipClassName, list, ...inputProps} = this.props;
|
||||
|
||||
// Set some defaults for the <input> element
|
||||
const ref = input => this.input = input;
|
||||
|
@ -228,15 +228,15 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
postfixContainer = <span className="mx_Field_postfix">{postfixComponent}</span>;
|
||||
}
|
||||
|
||||
const hasValidationFlag = flagInvalid !== null && flagInvalid !== undefined;
|
||||
const hasValidationFlag = forceValidity !== null && forceValidity !== undefined;
|
||||
const fieldClasses = classNames("mx_Field", `mx_Field_${this.props.element}`, className, {
|
||||
// If we have a prefix element, leave the label always at the top left and
|
||||
// don't animate it, as it looks a bit clunky and would add complexity to do
|
||||
// properly.
|
||||
mx_Field_labelAlwaysTopLeft: prefixComponent,
|
||||
mx_Field_valid: onValidate && this.state.valid === true,
|
||||
mx_Field_valid: hasValidationFlag ? forceValidity : onValidate && this.state.valid === true,
|
||||
mx_Field_invalid: hasValidationFlag
|
||||
? flagInvalid
|
||||
? !forceValidity
|
||||
: onValidate && this.state.valid === false,
|
||||
});
|
||||
|
||||
|
|
|
@ -413,7 +413,7 @@ export default class SetIdServer extends React.Component {
|
|||
tooltipContent={this._getTooltip()}
|
||||
tooltipClassName="mx_SetIdServer_tooltip"
|
||||
disabled={this.state.busy}
|
||||
flagInvalid={!!this.state.error}
|
||||
forceValidity={this.state.error ? false : null}
|
||||
/>
|
||||
<AccessibleButton type="submit" kind="primary_sm"
|
||||
onClick={this._checkIdServer}
|
||||
|
|
|
@ -1839,14 +1839,16 @@
|
|||
"Remember my selection for this widget": "Remember my selection for this widget",
|
||||
"Allow": "Allow",
|
||||
"Deny": "Deny",
|
||||
"Wrong file type": "Wrong file type",
|
||||
"Looks good!": "Looks good!",
|
||||
"Wrong Recovery Key": "Wrong Recovery Key",
|
||||
"Invalid Recovery Key": "Invalid Recovery Key",
|
||||
"Security Phrase": "Security Phrase",
|
||||
"Unable to access secret storage. Please verify that you entered the correct recovery passphrase.": "Unable to access secret storage. Please verify that you entered the correct recovery passphrase.",
|
||||
"Enter your Security Phrase or <button>Use your Security Key</button> to continue.": "Enter your Security Phrase or <button>Use your Security Key</button> to continue.",
|
||||
"Security Key": "Security Key",
|
||||
"Unable to access secret storage. Please verify that you entered the correct recovery key.": "Unable to access secret storage. Please verify that you entered the correct recovery key.",
|
||||
"This looks like a valid recovery key!": "This looks like a valid recovery key!",
|
||||
"Not a valid recovery key": "Not a valid recovery key",
|
||||
"Use your Security Key to continue.": "Use your Security Key to continue.",
|
||||
"Go Back": "Go Back",
|
||||
"Restoring keys from backup": "Restoring keys from backup",
|
||||
"Fetching keys from server...": "Fetching keys from server...",
|
||||
"%(completed)s of %(total)s keys restored": "%(completed)s of %(total)s keys restored",
|
||||
|
@ -1865,6 +1867,8 @@
|
|||
"Access your secure message history and set up secure messaging by entering your recovery passphrase.": "Access your secure message history and set up secure messaging by entering your recovery passphrase.",
|
||||
"If you've forgotten your recovery passphrase you can <button1>use your recovery key</button1> or <button2>set up new recovery options</button2>": "If you've forgotten your recovery passphrase you can <button1>use your recovery key</button1> or <button2>set up new recovery options</button2>",
|
||||
"Enter recovery key": "Enter recovery key",
|
||||
"This looks like a valid recovery key!": "This looks like a valid recovery key!",
|
||||
"Not a valid recovery key": "Not a valid recovery key",
|
||||
"<b>Warning</b>: You should only set up key backup from a trusted computer.": "<b>Warning</b>: You should only set up key backup from a trusted computer.",
|
||||
"Access your secure message history and set up secure messaging by entering your recovery key.": "Access your secure message history and set up secure messaging by entering your recovery key.",
|
||||
"If you've forgotten your recovery key you can <button>set up new recovery options</button>": "If you've forgotten your recovery key you can <button>set up new recovery options</button>",
|
||||
|
@ -2188,7 +2192,6 @@
|
|||
"Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.",
|
||||
"Your new session is now verified. Other users will see it as trusted.": "Your new session is now verified. Other users will see it as trusted.",
|
||||
"Without completing security on this session, it won’t have access to encrypted messages.": "Without completing security on this session, it won’t have access to encrypted messages.",
|
||||
"Go Back": "Go Back",
|
||||
"Failed to re-authenticate due to a homeserver problem": "Failed to re-authenticate due to a homeserver problem",
|
||||
"Incorrect password": "Incorrect password",
|
||||
"Failed to re-authenticate": "Failed to re-authenticate",
|
||||
|
|
|
@ -40,19 +40,20 @@ describe("AccessSecretStorageDialog", function() {
|
|||
testInstance.getInstance()._onRecoveryKeyNext(e);
|
||||
});
|
||||
|
||||
it("Considers a valid key to be valid", function() {
|
||||
it("Considers a valid key to be valid", async function() {
|
||||
const testInstance = TestRenderer.create(
|
||||
<AccessSecretStorageDialog
|
||||
checkPrivateKey={() => true}
|
||||
/>,
|
||||
);
|
||||
const v = "asfd";
|
||||
const v = "asdf";
|
||||
const e = { target: { value: v } };
|
||||
stubClient();
|
||||
MatrixClientPeg.get().isValidRecoveryKey = function(k) {
|
||||
return k == v;
|
||||
};
|
||||
MatrixClientPeg.get().keyBackupKeyFromRecoveryKey = () => 'a raw key';
|
||||
MatrixClientPeg.get().checkSecretStorageKey = () => true;
|
||||
testInstance.getInstance()._onRecoveryKeyChange(e);
|
||||
// force a validation now because it debounces
|
||||
await testInstance.getInstance()._validateRecoveryKey();
|
||||
const { recoveryKeyValid } = testInstance.getInstance().state;
|
||||
expect(recoveryKeyValid).toBe(true);
|
||||
});
|
||||
|
@ -65,17 +66,21 @@ describe("AccessSecretStorageDialog", function() {
|
|||
);
|
||||
const e = { target: { value: "a" } };
|
||||
stubClient();
|
||||
MatrixClientPeg.get().isValidRecoveryKey = () => true;
|
||||
MatrixClientPeg.get().keyBackupKeyFromRecoveryKey = () => {
|
||||
throw new Error("that's no key");
|
||||
};
|
||||
testInstance.getInstance()._onRecoveryKeyChange(e);
|
||||
await testInstance.getInstance()._onRecoveryKeyNext({ preventDefault: () => {} });
|
||||
const { keyMatches } = testInstance.getInstance().state;
|
||||
expect(keyMatches).toBe(false);
|
||||
// force a validation now because it debounces
|
||||
await testInstance.getInstance()._validateRecoveryKey();
|
||||
|
||||
const { recoveryKeyValid, recoveryKeyCorrect } = testInstance.getInstance().state;
|
||||
expect(recoveryKeyValid).toBe(false);
|
||||
expect(recoveryKeyCorrect).toBe(false);
|
||||
const notification = testInstance.root.findByProps({
|
||||
className: "mx_AccessSecretStorageDialog_keyStatus",
|
||||
className: "mx_AccessSecretStorageDialog_recoveryKeyFeedback " +
|
||||
"mx_AccessSecretStorageDialog_recoveryKeyFeedback_invalid",
|
||||
});
|
||||
expect(notification.props.children).toEqual(
|
||||
["\uD83D\uDC4E ", "Unable to access secret storage. Please verify that you " +
|
||||
"entered the correct recovery key."]);
|
||||
expect(notification.props.children).toEqual("Invalid Recovery Key");
|
||||
done();
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in a new issue