Skip to content

Commit 2247400

Browse files
authored
Merge pull request matrix-org#4847 from matrix-org/dbkr/recovery_key_upload_2
Add file upload button to recovery key input
2 parents 21c5c74 + 7caf2d5 commit 2247400

File tree

7 files changed

+226
-55
lines changed

7 files changed

+226
-55
lines changed

res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,56 @@ limitations under the License.
3838
height: 30px;
3939
}
4040

41-
.mx_AccessSecretStorageDialog_passPhraseInput,
42-
.mx_AccessSecretStorageDialog_recoveryKeyInput {
41+
.mx_AccessSecretStorageDialog_passPhraseInput {
4342
width: 300px;
4443
border: 1px solid $accent-color;
4544
border-radius: 5px;
4645
padding: 10px;
4746
}
4847

48+
.mx_AccessSecretStorageDialog_recoveryKeyEntry {
49+
display: flex;
50+
align-items: center;
51+
}
52+
53+
.mx_AccessSecretStorageDialog_recoveryKeyEntry_textInput {
54+
flex-grow: 1;
55+
}
56+
57+
.mx_AccessSecretStorageDialog_recoveryKeyEntry_entryControlSeparatorText {
58+
margin: 16px;
59+
}
60+
61+
.mx_AccessSecretStorageDialog_recoveryKeyFeedback {
62+
&::before {
63+
content: "";
64+
display: inline-block;
65+
vertical-align: bottom;
66+
width: 20px;
67+
height: 20px;
68+
mask-repeat: no-repeat;
69+
mask-position: center;
70+
mask-size: 20px;
71+
margin-right: 5px;
72+
}
73+
}
74+
75+
.mx_AccessSecretStorageDialog_recoveryKeyFeedback_valid {
76+
color: $input-valid-border-color;
77+
&::before {
78+
mask-image: url('$(res)/img/feather-customised/check.svg');
79+
background-color: $input-valid-border-color;
80+
}
81+
}
82+
83+
.mx_AccessSecretStorageDialog_recoveryKeyFeedback_invalid {
84+
color: $input-invalid-border-color;
85+
&::before {
86+
mask-image: url('$(res)/img/feather-customised/x.svg');
87+
background-color: $input-invalid-border-color;
88+
}
89+
}
90+
91+
.mx_AccessSecretStorageDialog_recoveryKeyEntry_fileInput {
92+
display: none;
93+
}

src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
491491
label={_t("Password")}
492492
value={this.state.accountPassword}
493493
onChange={this._onAccountPasswordChange}
494-
flagInvalid={this.state.accountPasswordCorrect === false}
494+
forceValidity={this.state.accountPasswordCorrect === false ? false : null}
495495
autoFocus={true}
496496
/></div>
497497
</div>;

src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js

Lines changed: 147 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,25 @@ See the License for the specific language governing permissions and
1515
limitations under the License.
1616
*/
1717

18+
import { debounce } from 'lodash';
19+
import classNames from 'classnames';
1820
import React from 'react';
1921
import PropTypes from "prop-types";
2022
import * as sdk from '../../../../index';
2123
import {MatrixClientPeg} from '../../../../MatrixClientPeg';
24+
import Field from '../../elements/Field';
25+
import AccessibleButton from '../../elements/AccessibleButton';
2226

2327
import { _t } from '../../../../languageHandler';
2428

29+
// Maximum acceptable size of a key file. It's 59 characters including the spaces we encode,
30+
// so this should be plenty and allow for people putting extra whitespace in the file because
31+
// maybe that's a thing people would do?
32+
const KEY_FILE_MAX_SIZE = 128;
33+
34+
// Don't shout at the user that their key is invalid every time they type a key: wait a short time
35+
const VALIDATION_THROTTLE_MS = 200;
36+
2537
/*
2638
* Access Secure Secret Storage by requesting the user's passphrase.
2739
*/
@@ -35,9 +47,14 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
3547

3648
constructor(props) {
3749
super(props);
50+
51+
this._fileUpload = React.createRef();
52+
3853
this.state = {
3954
recoveryKey: "",
40-
recoveryKeyValid: false,
55+
recoveryKeyValid: null,
56+
recoveryKeyCorrect: null,
57+
recoveryKeyFileError: null,
4158
forceRecoveryKey: false,
4259
passPhrase: '',
4360
keyMatches: null,
@@ -54,12 +71,89 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
5471
});
5572
}
5673

74+
_validateRecoveryKeyOnChange = debounce(() => {
75+
this._validateRecoveryKey();
76+
}, VALIDATION_THROTTLE_MS);
77+
78+
async _validateRecoveryKey() {
79+
if (this.state.recoveryKey === '') {
80+
this.setState({
81+
recoveryKeyValid: null,
82+
recoveryKeyCorrect: null,
83+
});
84+
return;
85+
}
86+
87+
try {
88+
const cli = MatrixClientPeg.get();
89+
const decodedKey = cli.keyBackupKeyFromRecoveryKey(this.state.recoveryKey);
90+
const correct = await cli.checkSecretStorageKey(
91+
decodedKey, this.props.keyInfo,
92+
);
93+
this.setState({
94+
recoveryKeyValid: true,
95+
recoveryKeyCorrect: correct,
96+
});
97+
} catch (e) {
98+
this.setState({
99+
recoveryKeyValid: false,
100+
recoveryKeyCorrect: false,
101+
});
102+
}
103+
}
104+
57105
_onRecoveryKeyChange = (e) => {
58106
this.setState({
59107
recoveryKey: e.target.value,
60-
recoveryKeyValid: MatrixClientPeg.get().isValidRecoveryKey(e.target.value),
61-
keyMatches: null,
108+
recoveryKeyFileError: null,
62109
});
110+
111+
// also clear the file upload control so that the user can upload the same file
112+
// the did before (otherwise the onchange wouldn't fire)
113+
if (this._fileUpload.current) this._fileUpload.current.value = null;
114+
115+
// We don't use Field's validation here because a) we want it in a separate place rather
116+
// than in a tooltip and b) we want it to display feedback based on the uploaded file
117+
// as well as the text box. Ideally we would refactor Field's validation logic so we could
118+
// re-use some of it.
119+
this._validateRecoveryKeyOnChange();
120+
}
121+
122+
_onRecoveryKeyFileChange = async e => {
123+
if (e.target.files.length === 0) return;
124+
125+
const f = e.target.files[0];
126+
127+
if (f.size > KEY_FILE_MAX_SIZE) {
128+
this.setState({
129+
recoveryKeyFileError: true,
130+
recoveryKeyCorrect: false,
131+
recoveryKeyValid: false,
132+
});
133+
} else {
134+
const contents = await f.text();
135+
// test it's within the base58 alphabet. We could be more strict here, eg. require the
136+
// right number of characters, but it's really just to make sure that what we're reading is
137+
// text because we'll put it in the text field.
138+
if (/^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz\s]+$/.test(contents)) {
139+
this.setState({
140+
recoveryKeyFileError: null,
141+
recoveryKey: contents.trim(),
142+
});
143+
this._validateRecoveryKey();
144+
} else {
145+
this.setState({
146+
recoveryKeyFileError: true,
147+
recoveryKeyCorrect: false,
148+
recoveryKeyValid: false,
149+
recoveryKey: '',
150+
});
151+
}
152+
}
153+
}
154+
155+
_onRecoveryKeyFileUploadClick = () => {
156+
this._fileUpload.current.click();
63157
}
64158

65159
_onPassPhraseNext = async (e) => {
@@ -99,6 +193,20 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
99193
});
100194
}
101195

196+
getKeyValidationText() {
197+
if (this.state.recoveryKeyFileError) {
198+
return _t("Wrong file type");
199+
} else if (this.state.recoveryKeyCorrect) {
200+
return _t("Looks good!");
201+
} else if (this.state.recoveryKeyValid) {
202+
return _t("Wrong Recovery Key");
203+
} else if (this.state.recoveryKeyValid === null) {
204+
return '';
205+
} else {
206+
return _t("Invalid Recovery Key");
207+
}
208+
}
209+
102210
render() {
103211
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
104212

@@ -169,40 +277,50 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
169277
titleClass = ['mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_secureBackupTitle'];
170278
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
171279

172-
let keyStatus;
173-
if (this.state.recoveryKey.length === 0) {
174-
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus" />;
175-
} else if (this.state.keyMatches === false) {
176-
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus">
177-
{"\uD83D\uDC4E "}{_t(
178-
"Unable to access secret storage. " +
179-
"Please verify that you entered the correct recovery key.",
180-
)}
181-
</div>;
182-
} else if (this.state.recoveryKeyValid) {
183-
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus">
184-
{"\uD83D\uDC4D "}{_t("This looks like a valid recovery key!")}
185-
</div>;
186-
} else {
187-
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus">
188-
{"\uD83D\uDC4E "}{_t("Not a valid recovery key")}
189-
</div>;
190-
}
280+
const feedbackClasses = classNames({
281+
'mx_AccessSecretStorageDialog_recoveryKeyFeedback': true,
282+
'mx_AccessSecretStorageDialog_recoveryKeyFeedback_valid': this.state.recoveryKeyCorrect === true,
283+
'mx_AccessSecretStorageDialog_recoveryKeyFeedback_invalid': this.state.recoveryKeyCorrect === false,
284+
});
285+
const recoveryKeyFeedback = <div className={feedbackClasses}>
286+
{this.getKeyValidationText()}
287+
</div>;
191288

192289
content = <div>
193290
<p>{_t("Use your Security Key to continue.")}</p>
194291

195-
<form className="mx_AccessSecretStorageDialog_primaryContainer" onSubmit={this._onRecoveryKeyNext}>
196-
<input className="mx_AccessSecretStorageDialog_recoveryKeyInput"
197-
onChange={this._onRecoveryKeyChange}
198-
value={this.state.recoveryKey}
199-
autoFocus={true}
200-
/>
201-
{keyStatus}
292+
<form className="mx_AccessSecretStorageDialog_primaryContainer" onSubmit={this._onRecoveryKeyNext} spellCheck={false}>
293+
<div className="mx_AccessSecretStorageDialog_recoveryKeyEntry">
294+
<div className="mx_AccessSecretStorageDialog_recoveryKeyEntry_textInput">
295+
<Field
296+
type="text"
297+
label={_t('Security Key')}
298+
value={this.state.recoveryKey}
299+
onChange={this._onRecoveryKeyChange}
300+
forceValidity={this.state.recoveryKeyCorrect}
301+
/>
302+
</div>
303+
<span className="mx_AccessSecretStorageDialog_recoveryKeyEntry_entryControlSeparatorText">
304+
{_t("or")}
305+
</span>
306+
<div>
307+
<input type="file"
308+
className="mx_AccessSecretStorageDialog_recoveryKeyEntry_fileInput"
309+
ref={this._fileUpload}
310+
onChange={this._onRecoveryKeyFileChange}
311+
/>
312+
<AccessibleButton kind="primary" onClick={this._onRecoveryKeyFileUploadClick}>
313+
{_t("Upload")}
314+
</AccessibleButton>
315+
</div>
316+
</div>
317+
{recoveryKeyFeedback}
202318
<DialogButtons
203319
primaryButton={_t('Continue')}
204320
onPrimaryButtonClick={this._onRecoveryKeyNext}
205321
hasCancel={true}
322+
cancelButton={_t("Go Back")}
323+
cancelButtonClass='danger'
206324
onCancel={this._onCancel}
207325
focus={false}
208326
primaryDisabled={!this.state.recoveryKeyValid}

src/components/views/elements/Field.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ interface IProps {
5050
// to the user.
5151
onValidate?: (input: IFieldState) => Promise<IValidationResult>;
5252
// If specified, overrides the value returned by onValidate.
53-
flagInvalid?: boolean;
53+
forceValidity?: boolean;
5454
// If specified, contents will appear as a tooltip on the element and
5555
// validation feedback tooltips will be suppressed.
5656
tooltipContent?: React.ReactNode;
@@ -203,7 +203,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
203203
public render() {
204204
const {
205205
element, prefixComponent, postfixComponent, className, onValidate, children,
206-
tooltipContent, flagInvalid, tooltipClassName, list, ...inputProps} = this.props;
206+
tooltipContent, forceValidity, tooltipClassName, list, ...inputProps} = this.props;
207207

208208
// Set some defaults for the <input> element
209209
const ref = input => this.input = input;
@@ -228,15 +228,15 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
228228
postfixContainer = <span className="mx_Field_postfix">{postfixComponent}</span>;
229229
}
230230

231-
const hasValidationFlag = flagInvalid !== null && flagInvalid !== undefined;
231+
const hasValidationFlag = forceValidity !== null && forceValidity !== undefined;
232232
const fieldClasses = classNames("mx_Field", `mx_Field_${this.props.element}`, className, {
233233
// If we have a prefix element, leave the label always at the top left and
234234
// don't animate it, as it looks a bit clunky and would add complexity to do
235235
// properly.
236236
mx_Field_labelAlwaysTopLeft: prefixComponent,
237-
mx_Field_valid: onValidate && this.state.valid === true,
237+
mx_Field_valid: hasValidationFlag ? forceValidity : onValidate && this.state.valid === true,
238238
mx_Field_invalid: hasValidationFlag
239-
? flagInvalid
239+
? !forceValidity
240240
: onValidate && this.state.valid === false,
241241
});
242242

src/components/views/settings/SetIdServer.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,7 @@ export default class SetIdServer extends React.Component {
413413
tooltipContent={this._getTooltip()}
414414
tooltipClassName="mx_SetIdServer_tooltip"
415415
disabled={this.state.busy}
416-
flagInvalid={!!this.state.error}
416+
forceValidity={this.state.error ? false : null}
417417
/>
418418
<AccessibleButton type="submit" kind="primary_sm"
419419
onClick={this._checkIdServer}

src/i18n/strings/en_EN.json

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1839,14 +1839,16 @@
18391839
"Remember my selection for this widget": "Remember my selection for this widget",
18401840
"Allow": "Allow",
18411841
"Deny": "Deny",
1842+
"Wrong file type": "Wrong file type",
1843+
"Looks good!": "Looks good!",
1844+
"Wrong Recovery Key": "Wrong Recovery Key",
1845+
"Invalid Recovery Key": "Invalid Recovery Key",
18421846
"Security Phrase": "Security Phrase",
18431847
"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.",
18441848
"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.",
18451849
"Security Key": "Security Key",
1846-
"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.",
1847-
"This looks like a valid recovery key!": "This looks like a valid recovery key!",
1848-
"Not a valid recovery key": "Not a valid recovery key",
18491850
"Use your Security Key to continue.": "Use your Security Key to continue.",
1851+
"Go Back": "Go Back",
18501852
"Restoring keys from backup": "Restoring keys from backup",
18511853
"Fetching keys from server...": "Fetching keys from server...",
18521854
"%(completed)s of %(total)s keys restored": "%(completed)s of %(total)s keys restored",
@@ -1865,6 +1867,8 @@
18651867
"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.",
18661868
"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>",
18671869
"Enter recovery key": "Enter recovery key",
1870+
"This looks like a valid recovery key!": "This looks like a valid recovery key!",
1871+
"Not a valid recovery key": "Not a valid recovery key",
18681872
"<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.",
18691873
"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.",
18701874
"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 @@
21882192
"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.",
21892193
"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.",
21902194
"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.",
2191-
"Go Back": "Go Back",
21922195
"Failed to re-authenticate due to a homeserver problem": "Failed to re-authenticate due to a homeserver problem",
21932196
"Incorrect password": "Incorrect password",
21942197
"Failed to re-authenticate": "Failed to re-authenticate",

0 commit comments

Comments
 (0)