diff --git a/playwright/e2e/crypto/device-verification.spec.ts b/playwright/e2e/crypto/device-verification.spec.ts index 9be79452c4571d0c861cb009caa3d342f8b34abd..64846ac86dc90d7ed3b9a8055ed76b6fcf1c906d 100644 --- a/playwright/e2e/crypto/device-verification.spec.ts +++ b/playwright/e2e/crypto/device-verification.spec.ts @@ -169,8 +169,8 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { // Fill the passphrase const dialog = page.locator(".mx_Dialog"); - await dialog.locator("input").fill("new passphrase"); - await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click(); + await dialog.locator("textarea").fill("new passphrase"); + await dialog.getByRole("button", { name: "Continue", disabled: false }).click(); await page.locator(".mx_AuthPage").getByRole("button", { name: "Done" }).click(); @@ -190,10 +190,9 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { // Fill the recovery key const dialog = page.locator(".mx_Dialog"); - await dialog.getByRole("button", { name: "use your Recovery Key" }).click(); const aliceRecoveryKey = await aliceBotClient.getRecoveryKey(); - await dialog.locator("#mx_securityKey").fill(aliceRecoveryKey.encodedPrivateKey); - await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click(); + await dialog.locator("textarea").fill(aliceRecoveryKey.encodedPrivateKey); + await dialog.getByRole("button", { name: "Continue", disabled: false }).click(); await page.locator(".mx_AuthPage").getByRole("button", { name: "Done" }).click(); diff --git a/playwright/e2e/crypto/utils.ts b/playwright/e2e/crypto/utils.ts index c31ccb130d609878e039a0b02b9b47c71627f66a..289b123e86e7c38abad0bf32c40c9b32ca2eb515 100644 --- a/playwright/e2e/crypto/utils.ts +++ b/playwright/e2e/crypto/utils.ts @@ -228,8 +228,8 @@ export async function logIntoElement(page: Page, credentials: Credentials, secur await useSecurityKey.click(); } // Fill in the recovery key - await page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey); - await page.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click(); + await page.locator(".mx_Dialog").locator("textarea").fill(securityKey); + await page.getByRole("button", { name: "Continue", disabled: false }).click(); await page.getByRole("button", { name: "Done" }).click(); } } @@ -263,7 +263,7 @@ export async function verifySession(app: ElementAppPage, securityKey: string) { const settings = await app.settings.openUserSettings("Encryption"); await settings.getByRole("button", { name: "Verify this device" }).click(); await app.page.getByRole("button", { name: "Verify with Recovery Key" }).click(); - await app.page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey); + await app.page.locator(".mx_Dialog").locator("textarea").fill(securityKey); await app.page.getByRole("button", { name: "Continue", disabled: false }).click(); await app.page.getByRole("button", { name: "Done" }).click(); await app.settings.closeDialog(); diff --git a/res/css/_common.pcss b/res/css/_common.pcss index 98b66aec2b4bba2a6315e75185188b88630f7e5e..3eed8c93c603e12f670c16f68a6327b436aad12d 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -601,6 +601,7 @@ legend { .mx_Dialog_nonDialogButton, .mx_AccessibleButton, .mx_IdentityServerPicker button, + .mx_AccessSecretStorageDialog button, [class|="maplibregl"] ), .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton), diff --git a/res/css/views/dialogs/security/_AccessSecretStorageDialog.pcss b/res/css/views/dialogs/security/_AccessSecretStorageDialog.pcss index 83b9fe96b45825114d2955c248aa55c6d49ae935..943ec3a41fe73449d125374180d203de82282e69 100644 --- a/res/css/views/dialogs/security/_AccessSecretStorageDialog.pcss +++ b/res/css/views/dialogs/security/_AccessSecretStorageDialog.pcss @@ -7,62 +7,14 @@ Please see LICENSE files in the repository root for full details. */ .mx_AccessSecretStorageDialog { - .mx_AccessSecretStorageDialog_titleWithIcon { - &::before { - content: ""; - display: inline-block; - width: 24px; - height: 24px; - margin-inline-end: $spacing-8; - position: relative; - top: 5px; - background-color: $primary-content; - } - - &.mx_AccessSecretStorageDialog_resetBadge::before { - /* The image isn't capable of masking, so we use a background instead. */ - background-image: url("@vector-im/compound-design-tokens/icons/error-solid.svg"); - background-size: 24px; - background-color: transparent; - } - - &.mx_AccessSecretStorageDialog_secureBackupTitle::before { - mask-image: url("$(res)/img/feather-customised/secure-backup.svg"); - } - - &.mx_AccessSecretStorageDialog_securePhraseTitle::before { - mask-image: url("$(res)/img/feather-customised/secure-phrase.svg"); - } + &.mx_EncryptionCard { + /* override some styles that we don't need */ + border: 0px none; + box-shadow: none; + padding: 0px; } .mx_AccessSecretStorageDialog_primaryContainer { - .mx_AccessSecretStorageDialog_passPhraseInput { - width: 300px; - border: 1px solid $accent; - border-radius: 5px; - } - - .mx_AccessSecretStorageDialog_keyStatus { - height: 30px; - } - - .mx_AccessSecretStorageDialog_recoveryKeyEntry { - display: flex; - align-items: center; - - .mx_AccessSecretStorageDialog_recoveryKeyEntry_textInput { - flex-grow: 1; - } - - .mx_AccessSecretStorageDialog_recoveryKeyEntry_entryControlSeparatorText { - margin: $spacing-16; - } - - .mx_AccessSecretStorageDialog_recoveryKeyEntry_fileInput { - display: none; - } - } - .mx_AccessSecretStorageDialog_recoveryKeyFeedback { &::before { content: ""; @@ -76,15 +28,6 @@ Please see LICENSE files in the repository root for full details. margin-inline-end: 5px; } - &.mx_AccessSecretStorageDialog_recoveryKeyFeedback--valid { - color: $accent; - - &::before { - mask-image: url("@vector-im/compound-design-tokens/icons/check.svg"); - background-color: $accent; - } - } - &.mx_AccessSecretStorageDialog_recoveryKeyFeedback--invalid { color: $alert; @@ -94,46 +37,9 @@ Please see LICENSE files in the repository root for full details. } } } + } - .mx_Dialog_buttons { - $spacingStart: $spacing-24; /* 16px icon + 8px padding */ - - text-align: initial; - display: flex; - flex-flow: column; - gap: 14px; - - .mx_Dialog_buttons_additive { - float: none; - - .mx_AccessSecretStorageDialog_reset { - position: relative; - padding-inline-start: $spacingStart; - /* To avoid bold styling inherent with <strong> elements */ - font-weight: inherit; - - &::before { - content: ""; - display: inline-block; - position: absolute; - height: 16px; - width: 16px; - left: 0; - top: 2px; /* alignment */ - background-image: url("@vector-im/compound-design-tokens/icons/error-solid.svg"); - background-size: contain; - } - - .mx_AccessSecretStorageDialog_reset_link { - color: $alert; - } - } - } - - .mx_Dialog_buttons_row { - gap: $spacing-16; /* TODO: needs normalization */ - padding-inline-start: $spacingStart; - } - } + .mx_EncryptionCard_buttons { + margin-top: var(--cpd-space-20x); } } diff --git a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx index 01d7203b68c5c934a6eea4a41b66abfb2905160c..5b1fb3e142de5bfec6b86151f9c6794dc23cc29a 100644 --- a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx +++ b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx @@ -6,27 +6,17 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ +import { Button } from "@vector-im/compound-web"; +import LockSolidIcon from "@vector-im/compound-design-tokens/assets/web/icons/lock-solid"; import { debounce } from "lodash"; import classNames from "classnames"; import React, { type ChangeEvent, type FormEvent } from "react"; -import { logger } from "matrix-js-sdk/src/logger"; -import { decodeRecoveryKey } from "matrix-js-sdk/src/crypto-api"; import { type SecretStorage } from "matrix-js-sdk/src/matrix"; -import { MatrixClientPeg } from "../../../../MatrixClientPeg"; import Field from "../../elements/Field"; -import AccessibleButton, { type ButtonEvent } from "../../elements/AccessibleButton"; import { _t } from "../../../../languageHandler"; -import { accessSecretStorage } from "../../../../SecurityManager"; -import Modal from "../../../../Modal"; -import DialogButtons from "../../elements/DialogButtons"; -import BaseDialog from "../BaseDialog"; -import { chromeFileInputFix } from "../../../../utils/BrowserWorkarounds"; - -// 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; +import { EncryptionCard } from "../../settings/encryption/EncryptionCard"; +import { EncryptionCardButtons } from "../../settings/encryption/EncryptionCardButtons"; // 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; @@ -34,401 +24,196 @@ const VALIDATION_THROTTLE_MS = 200; export type KeyParams = { passphrase?: string; recoveryKey?: string }; interface IProps { + /** + * Information about the Secret Storage key that we want to get. + */ keyInfo: SecretStorage.SecretStorageKeyDescription; + /** + * Callback to check whether the given key is correct. + */ checkPrivateKey: (k: KeyParams) => Promise<boolean>; + /** + * Callback for when the user is done with this dialog. `result` will + * contain information about the key that was entered, or will be `false` if + * the user cancelled. + */ onFinished(result?: false | KeyParams): void; } interface IState { + //! The recovery key/phrase that the user entered recoveryKey: string; - recoveryKeyValid: boolean | null; + //! Is the recovery key/phrase correct? `null` means no key/phrase has been entered recoveryKeyCorrect: boolean | null; - recoveryKeyFileError: boolean | null; - forceRecoveryKey: boolean; - passPhrase: string; - keyMatches: boolean | null; - resetting: boolean; } /* * Access Secure Secret Storage by requesting the user's passphrase. */ export default class AccessSecretStorageDialog extends React.PureComponent<IProps, IState> { - private fileUpload = React.createRef<HTMLInputElement>(); - private inputRef = React.createRef<HTMLInputElement>(); + private inputRef = React.createRef<HTMLTextAreaElement>(); public constructor(props: IProps) { super(props); this.state = { recoveryKey: "", - recoveryKeyValid: null, recoveryKeyCorrect: null, - recoveryKeyFileError: null, - forceRecoveryKey: false, - passPhrase: "", - keyMatches: null, - resetting: false, }; } private onCancel = (): void => { - if (this.state.resetting) { - this.setState({ resetting: false }); - } this.props.onFinished(false); }; - private onUseRecoveryKeyClick = (): void => { - this.setState({ - forceRecoveryKey: true, - }); - }; - private validateRecoveryKeyOnChange = debounce(async (): Promise<void> => { await this.validateRecoveryKey(this.state.recoveryKey); }, VALIDATION_THROTTLE_MS); - private async validateRecoveryKey(recoveryKey: string): Promise<void> { + /** + * Checks whether the security key/phrase is correct. + * + * Sets `state.recoveryKeyCorrect` accordingly, and if the key/phrase is + * correct, returns a `KeyParams` structure. + */ + private async validateRecoveryKey(recoveryKey: string): Promise<KeyParams | undefined> { + recoveryKey = recoveryKey.trim(); + if (recoveryKey === "") { this.setState({ - recoveryKeyValid: null, recoveryKeyCorrect: null, }); - return; } + const hasPassphrase = this.props.keyInfo?.passphrase?.salt && this.props.keyInfo?.passphrase?.iterations; + + // If the user has a passphrase, we want to try validating it both as a + // key and as a passphrase. We first try to validate it as a key, since + // that check is faster. + try { - const cli = MatrixClientPeg.safeGet(); - const decodedKey = decodeRecoveryKey(recoveryKey); - const correct = await cli.secretStorage.checkKey(decodedKey, this.props.keyInfo); - this.setState({ - recoveryKeyValid: true, - recoveryKeyCorrect: correct, - }); - } catch { - this.setState({ - recoveryKeyValid: false, - recoveryKeyCorrect: false, - }); + const input = { recoveryKey }; + const recoveryKeyCorrect = await this.props.checkPrivateKey(input); + if (recoveryKeyCorrect) { + this.setState({ recoveryKeyCorrect }); + return input; + } + } catch {} + + if (hasPassphrase) { + try { + const input = { passphrase: recoveryKey }; + const recoveryKeyCorrect = await this.props.checkPrivateKey(input); + if (recoveryKeyCorrect) { + this.setState({ recoveryKeyCorrect }); + return input; + } + } catch {} } + + this.setState({ + recoveryKeyCorrect: false, + }); } - private onRecoveryKeyChange = (ev: ChangeEvent<HTMLInputElement>): void => { + private onRecoveryKeyChange = (ev: ChangeEvent<HTMLTextAreaElement>): void => { this.setState({ recoveryKey: ev.target.value, - 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 = ""; - - // 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 + // We don't use Field's validation here because we want it in a separate place rather + // than in a tooltip. Ideally we would refactor Field's validation logic so we could // re-use some of it. this.validateRecoveryKeyOnChange(); }; - private onRecoveryKeyFileChange = async (ev: ChangeEvent<HTMLInputElement>): Promise<void> => { - if (!ev.target.files?.length) return; - - const f = ev.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)) { - const recoveryKey = contents.trim(); - this.setState({ - recoveryKeyFileError: null, - recoveryKey, - }); - await this.validateRecoveryKey(recoveryKey); - } else { - this.setState({ - recoveryKeyFileError: true, - recoveryKeyCorrect: false, - recoveryKeyValid: false, - recoveryKey: "", - }); - } - } - }; - - private onRecoveryKeyFileUploadClick = (): void => { - this.fileUpload.current?.click(); - }; - - private onPassPhraseNext = async (ev: FormEvent<HTMLFormElement> | React.MouseEvent): Promise<void> => { - ev.preventDefault(); - - if (this.state.passPhrase.length <= 0) { - this.inputRef.current?.focus(); - return; - } - - this.setState({ keyMatches: null }); - const input = { passphrase: this.state.passPhrase }; - const keyMatches = await this.props.checkPrivateKey(input); - if (keyMatches) { - this.props.onFinished(input); - } else { - this.setState({ keyMatches }); - this.inputRef.current?.focus(); - } - }; - private onRecoveryKeyNext = async (ev: FormEvent<HTMLFormElement> | React.MouseEvent): Promise<void> => { ev.preventDefault(); - if (!this.state.recoveryKeyValid) return; + const keyParams = await this.validateRecoveryKey(this.state.recoveryKey); - this.setState({ keyMatches: null }); - const input = { recoveryKey: this.state.recoveryKey }; - const keyMatches = await this.props.checkPrivateKey(input); - if (keyMatches) { - this.props.onFinished(input); + if (keyParams !== undefined) { + this.props.onFinished(keyParams); } else { - this.setState({ keyMatches }); + this.inputRef.current?.focus(); } }; - private onPassPhraseChange = (ev: ChangeEvent<HTMLInputElement>): void => { - this.setState({ - passPhrase: ev.target.value, - keyMatches: null, + private getKeyValidationClasses(): string { + return classNames({ + "mx_AccessSecretStorageDialog_recoveryKeyFeedback": this.state.recoveryKeyCorrect !== null, + "mx_AccessSecretStorageDialog_recoveryKeyFeedback--invalid": this.state.recoveryKeyCorrect === false, }); - }; - - private onResetAllClick = (ev: ButtonEvent): void => { - ev.preventDefault(); - this.setState({ resetting: true }); - }; - - private onConfirmResetAllClick = async (): Promise<void> => { - // Hide ourselves so the user can interact with the reset dialogs. - // We don't conclude the promise chain (onFinished) yet to avoid confusing - // any upstream code flows. - // - // Note: this will unmount us, so don't call `setState` or anything in the - // rest of this function. - Modal.toggleCurrentDialogVisibility(); + } - try { - // Force reset secret storage (which resets the key backup) - await accessSecretStorage( - async (): Promise<void> => { - // Now we can indicate that the user is done pressing buttons, finally. - // Upstream flows will detect the new secret storage, key backup, etc and use it. - this.props.onFinished({}); - }, - { forceReset: true, resetCrossSigning: true }, - ); - } catch (e) { - logger.error(e); - this.props.onFinished(false); + private getKeyValidationText(): string | null { + if (this.state.recoveryKeyCorrect) { + return null; + } else if (this.state.recoveryKeyCorrect === null) { + return _t("encryption|access_secret_storage_dialog|alternatives"); + } else { + return _t("encryption|access_secret_storage_dialog|key_validation_text|wrong_security_key"); } - }; + } - private getKeyValidationText(): string { - if (this.state.recoveryKeyFileError) { - return _t("encryption|access_secret_storage_dialog|key_validation_text|wrong_file_type"); - } else if (this.state.recoveryKeyCorrect) { - return _t("encryption|access_secret_storage_dialog|key_validation_text|recovery_key_is_correct"); - } else if (this.state.recoveryKeyValid) { - return _t("encryption|access_secret_storage_dialog|key_validation_text|wrong_security_key"); - } else if (this.state.recoveryKeyValid === null) { - return ""; + private getRecoveryKeyFeedback(): React.ReactNode | null { + const validationText = this.getKeyValidationText(); + if (validationText === null) { + return null; } else { - return _t("encryption|access_secret_storage_dialog|key_validation_text|invalid_security_key"); + return <div className={this.getKeyValidationClasses()}>{validationText}</div>; } } public render(): React.ReactNode { - const hasPassphrase = this.props.keyInfo?.passphrase?.salt && this.props.keyInfo?.passphrase?.iterations; - - const resetLine = ( - <strong className="mx_AccessSecretStorageDialog_reset"> - {_t("encryption|reset_all_button", undefined, { - a: (sub) => ( - <AccessibleButton - kind="link_inline" - onClick={this.onResetAllClick} - className="mx_AccessSecretStorageDialog_reset_link" - > - {sub} - </AccessibleButton> - ), - })} - </strong> - ); - - let content; - let title; - let titleClass; - if (this.state.resetting) { - title = _t("encryption|access_secret_storage_dialog|reset_title"); - titleClass = ["mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_resetBadge"]; - content = ( - <div> - <p>{_t("encryption|access_secret_storage_dialog|reset_warning_1")}</p> - <p>{_t("encryption|access_secret_storage_dialog|reset_warning_2")}</p> - <DialogButtons - primaryButton={_t("action|reset")} - onPrimaryButtonClick={this.onConfirmResetAllClick} - hasCancel={true} - onCancel={this.onCancel} - focus={false} - primaryButtonClass="danger" - /> - </div> - ); - } else if (hasPassphrase && !this.state.forceRecoveryKey) { - title = _t("encryption|access_secret_storage_dialog|security_phrase_title"); - titleClass = ["mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_securePhraseTitle"]; - - let keyStatus; - if (this.state.keyMatches === false) { - keyStatus = ( - <div className="mx_AccessSecretStorageDialog_keyStatus"> - {"\uD83D\uDC4E "} - {_t("encryption|access_secret_storage_dialog|security_phrase_incorrect_error")} - </div> - ); - } else { - keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus" />; - } - - content = ( - <div> - <p> - {_t( - "encryption|access_secret_storage_dialog|enter_phrase_or_key_prompt", - {}, - { - button: (s) => ( - <AccessibleButton kind="link_inline" onClick={this.onUseRecoveryKeyClick}> - {s} - </AccessibleButton> - ), - }, - )} - </p> - - <form className="mx_AccessSecretStorageDialog_primaryContainer" onSubmit={this.onPassPhraseNext}> + const title = _t("encryption|access_secret_storage_dialog|security_key_title"); + + const recoveryKeyFeedback = this.getRecoveryKeyFeedback(); + const content = ( + <div> + <form + className="mx_AccessSecretStorageDialog_primaryContainer" + onSubmit={this.onRecoveryKeyNext} + spellCheck={false} + autoComplete="off" + > + <div className="mx_AccessSecretStorageDialog_recoveryKeyEntry"> <Field inputRef={this.inputRef} - id="mx_passPhraseInput" - className="mx_AccessSecretStorageDialog_passPhraseInput" - type="password" - label={_t("encryption|access_secret_storage_dialog|security_phrase_title")} - value={this.state.passPhrase} - onChange={this.onPassPhraseChange} + element="textarea" + rows={2} + cols={45} + id="mx_securityKey" + label={_t("encryption|access_secret_storage_dialog|security_key_title")} + value={this.state.recoveryKey} + onChange={this.onRecoveryKeyChange} autoFocus={true} - autoComplete="new-password" - /> - {keyStatus} - <DialogButtons - primaryButton={_t("action|continue")} - onPrimaryButtonClick={this.onPassPhraseNext} - hasCancel={true} - onCancel={this.onCancel} - focus={false} - primaryDisabled={this.state.passPhrase.length === 0} - additive={resetLine} + forceValidity={this.state.recoveryKeyCorrect ?? undefined} + autoComplete="off" /> - </form> - </div> - ); - } else { - title = _t("encryption|access_secret_storage_dialog|security_key_title"); - titleClass = ["mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_secureBackupTitle"]; - - 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>; - - content = ( - <div> - <p>{_t("encryption|access_secret_storage_dialog|use_security_key_prompt")}</p> - - <form - className="mx_AccessSecretStorageDialog_primaryContainer" - onSubmit={this.onRecoveryKeyNext} - spellCheck={false} - autoComplete="off" - > - <div className="mx_AccessSecretStorageDialog_recoveryKeyEntry"> - <div className="mx_AccessSecretStorageDialog_recoveryKeyEntry_textInput"> - <Field - type="password" - id="mx_securityKey" - label={_t("encryption|access_secret_storage_dialog|security_key_title")} - value={this.state.recoveryKey} - onChange={this.onRecoveryKeyChange} - autoFocus={true} - forceValidity={this.state.recoveryKeyCorrect ?? undefined} - autoComplete="off" - /> - </div> - <span className="mx_AccessSecretStorageDialog_recoveryKeyEntry_entryControlSeparatorText"> - {_t("encryption|access_secret_storage_dialog|separator", { - recoveryFile: "", - securityKey: "", - })} - </span> - <div> - <input - type="file" - className="mx_AccessSecretStorageDialog_recoveryKeyEntry_fileInput" - ref={this.fileUpload} - onClick={chromeFileInputFix} - onChange={this.onRecoveryKeyFileChange} - /> - <AccessibleButton kind="primary" onClick={this.onRecoveryKeyFileUploadClick}> - {_t("action|upload")} - </AccessibleButton> - </div> - </div> - {recoveryKeyFeedback} - <DialogButtons - primaryButton={_t("action|continue")} - onPrimaryButtonClick={this.onRecoveryKeyNext} - hasCancel={true} - cancelButton={_t("action|go_back")} - cancelButtonClass="warning" - onCancel={this.onCancel} - focus={false} - primaryDisabled={!this.state.recoveryKeyValid} - additive={resetLine} - /> - </form> - </div> - ); - } + </div> + {recoveryKeyFeedback} + <EncryptionCardButtons> + <Button disabled={!this.state.recoveryKeyCorrect} onClick={this.onRecoveryKeyNext}> + {_t("action|continue")} + </Button> + <Button kind="tertiary" onClick={this.onCancel}> + {_t("action|cancel")} + </Button> + </EncryptionCardButtons> + </form> + </div> + ); return ( - <BaseDialog + <EncryptionCard + Icon={LockSolidIcon} className="mx_AccessSecretStorageDialog" - onFinished={this.props.onFinished} title={title} - titleClass={titleClass} + description={_t("encryption|access_secret_storage_dialog|privacy_warning")} > - <div>{content}</div> - </BaseDialog> + {content} + </EncryptionCard> ); } } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 9adb5bc14a354feb038dff277ce0262740437178..d848f839a53ea063bc57d958ca6a352ffa3afdfc 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -911,22 +911,13 @@ "empty_room_was_name": "Empty room (was %(oldName)s)", "encryption": { "access_secret_storage_dialog": { - "enter_phrase_or_key_prompt": "Enter your Security Phrase or <button>use your Recovery Key</button> to continue.", + "alternatives": "If you have a security key or security phrase, this will work too.", "key_validation_text": { - "invalid_security_key": "Invalid Recovery Key", - "recovery_key_is_correct": "Looks good!", - "wrong_file_type": "Wrong file type", - "wrong_security_key": "Wrong Recovery Key" + "wrong_security_key": "The recovery key you entered is not correct." }, - "reset_title": "Reset everything", - "reset_warning_1": "Only do this if you have no other device to complete verification with.", - "reset_warning_2": "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.", + "privacy_warning": "Make sure nobody can see this screen!", "restoring": "Restoring keys from backup", - "security_key_title": "Recovery Key", - "security_phrase_incorrect_error": "Unable to access secret storage. Please verify that you entered the correct Security Phrase.", - "security_phrase_title": "Security Phrase", - "separator": "%(securityKey)s or %(recoveryFile)s", - "use_security_key_prompt": "Use your Recovery Key to continue." + "security_key_title": "Recovery key" }, "bootstrap_title": "Setting up keys", "cancel_entering_passphrase_description": "Are you sure you want to cancel entering passphrase?", diff --git a/test/unit-tests/components/views/dialogs/AccessSecretStorageDialog-test.tsx b/test/unit-tests/components/views/dialogs/AccessSecretStorageDialog-test.tsx index e0320b290861fb07863decc1502e8842999c3d3c..2d597f8e18f7f869f7fec8bc951fbe34aabcbed8 100644 --- a/test/unit-tests/components/views/dialogs/AccessSecretStorageDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/AccessSecretStorageDialog-test.tsx @@ -29,9 +29,9 @@ describe("AccessSecretStorageDialog", () => { render(<AccessSecretStorageDialog {...defaultProps} {...props} />); }; - const enterRecoveryKey = (placeholder = "Recovery Key"): void => { + const enterRecoveryKey = (): void => { act(() => { - fireEvent.change(screen.getByPlaceholderText(placeholder), { + fireEvent.change(screen.getByRole("textbox"), { target: { value: recoveryKey, }, @@ -67,19 +67,19 @@ describe("AccessSecretStorageDialog", () => { renderComponent({ onFinished, checkPrivateKey }); // check that the input field is focused - expect(screen.getByPlaceholderText("Recovery Key")).toHaveFocus(); + expect(screen.getByRole("textbox")).toHaveFocus(); await enterRecoveryKey(); await submitDialog(); - expect(screen.getByText("Looks good!")).toBeInTheDocument(); + expect(screen.getByText("Continue")).not.toHaveAttribute("aria-disabled", "true"); expect(checkPrivateKey).toHaveBeenCalledWith({ recoveryKey }); expect(onFinished).toHaveBeenCalledWith({ recoveryKey }); }); it("Notifies the user if they input an invalid Recovery Key", async () => { const onFinished = jest.fn(); - const checkPrivateKey = jest.fn().mockResolvedValue(true); + const checkPrivateKey = jest.fn().mockResolvedValue(false); renderComponent({ onFinished, checkPrivateKey }); jest.spyOn(mockClient.secretStorage, "checkKey").mockImplementation(() => { @@ -89,8 +89,8 @@ describe("AccessSecretStorageDialog", () => { await enterRecoveryKey(); await submitDialog(); - expect(screen.getByText("Continue")).toBeDisabled(); - expect(screen.getByText("Invalid Recovery Key")).toBeInTheDocument(); + expect(screen.getByText("The recovery key you entered is not correct.")).toBeInTheDocument(); + expect(screen.getByText("Continue")).toHaveAttribute("aria-disabled", "true"); }); it("Notifies the user if they input an invalid passphrase", async function () { @@ -110,46 +110,10 @@ describe("AccessSecretStorageDialog", () => { const checkPrivateKey = jest.fn().mockResolvedValue(false); renderComponent({ checkPrivateKey, keyInfo }); - await enterRecoveryKey("Security Phrase"); - expect(screen.getByPlaceholderText("Security Phrase")).toHaveValue(recoveryKey); - await submitDialog(); - - await expect( - screen.findByText( - "👎 Unable to access secret storage. Please verify that you entered the correct Security Phrase.", - ), - ).resolves.toBeInTheDocument(); - - expect(screen.getByPlaceholderText("Security Phrase")).toHaveFocus(); - }); - - it("Can reset secret storage", async () => { - jest.spyOn(mockClient.secretStorage, "checkKey").mockResolvedValue(true); - - const onFinished = jest.fn(); - const checkPrivateKey = jest.fn().mockResolvedValue(true); - renderComponent({ onFinished, checkPrivateKey }); - - await userEvent.click(screen.getByText("Reset all"), { delay: null }); - - // It will prompt the user to confirm resetting - expect(screen.getByText("Reset everything")).toBeInTheDocument(); - await userEvent.click(screen.getByText("Reset"), { delay: null }); - - // Then it will prompt the user to create a key/passphrase - await screen.findByText("Set up Secure Backup"); - document.execCommand = jest.fn().mockReturnValue(true); - jest.spyOn(mockClient.getCrypto()!, "createRecoveryKeyFromPassphrase").mockResolvedValue({ - privateKey: new Uint8Array(), - encodedPrivateKey: recoveryKey, - }); - screen.getByRole("button", { name: "Continue" }).click(); - - await screen.findByText(/Save your Recovery Key/); - screen.getByRole("button", { name: "Copy" }).click(); - await screen.findByText("Copied!"); - screen.getByRole("button", { name: "Continue" }).click(); + await enterRecoveryKey(); + expect(screen.getByRole("textbox")).toHaveValue(recoveryKey); - await screen.findByText("Secure Backup successful"); + await expect(screen.findByText("The recovery key you entered is not correct.")).resolves.toBeInTheDocument(); + expect(screen.getByText("Continue")).toHaveAttribute("aria-disabled", "true"); }); });