Files
element-web/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx
Hubert Chathi 45182172b8 EW: Modernize the recovery key input modal (#29819)
* initial application of recovery key input redesign

* update styling to agree more with design, and fix jest tests

* look for the right element for entering the key

* fix more playwright tests

* use return value of validation function instead of state
2025-05-23 21:06:00 +00:00

220 lines
7.9 KiB
TypeScript

/*
Copyright 2024 New Vector Ltd.
Copyright 2018-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
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 { type SecretStorage } from "matrix-js-sdk/src/matrix";
import Field from "../../elements/Field";
import { _t } from "../../../../languageHandler";
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;
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;
//! Is the recovery key/phrase correct? `null` means no key/phrase has been entered
recoveryKeyCorrect: boolean | null;
}
/*
* Access Secure Secret Storage by requesting the user's passphrase.
*/
export default class AccessSecretStorageDialog extends React.PureComponent<IProps, IState> {
private inputRef = React.createRef<HTMLTextAreaElement>();
public constructor(props: IProps) {
super(props);
this.state = {
recoveryKey: "",
recoveryKeyCorrect: null,
};
}
private onCancel = (): void => {
this.props.onFinished(false);
};
private validateRecoveryKeyOnChange = debounce(async (): Promise<void> => {
await this.validateRecoveryKey(this.state.recoveryKey);
}, VALIDATION_THROTTLE_MS);
/**
* 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({
recoveryKeyCorrect: null,
});
}
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 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<HTMLTextAreaElement>): void => {
this.setState({
recoveryKey: ev.target.value,
});
// 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 onRecoveryKeyNext = async (ev: FormEvent<HTMLFormElement> | React.MouseEvent): Promise<void> => {
ev.preventDefault();
const keyParams = await this.validateRecoveryKey(this.state.recoveryKey);
if (keyParams !== undefined) {
this.props.onFinished(keyParams);
} else {
this.inputRef.current?.focus();
}
};
private getKeyValidationClasses(): string {
return classNames({
"mx_AccessSecretStorageDialog_recoveryKeyFeedback": this.state.recoveryKeyCorrect !== null,
"mx_AccessSecretStorageDialog_recoveryKeyFeedback--invalid": this.state.recoveryKeyCorrect === 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 getRecoveryKeyFeedback(): React.ReactNode | null {
const validationText = this.getKeyValidationText();
if (validationText === null) {
return null;
} else {
return <div className={this.getKeyValidationClasses()}>{validationText}</div>;
}
}
public render(): React.ReactNode {
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}
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}
forceValidity={this.state.recoveryKeyCorrect ?? undefined}
autoComplete="off"
/>
</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 (
<EncryptionCard
Icon={LockSolidIcon}
className="mx_AccessSecretStorageDialog"
title={title}
description={_t("encryption|access_secret_storage_dialog|privacy_warning")}
>
{content}
</EncryptionCard>
);
}
}