"Verify this device" redesign (#30596)

* add variant of ResetIdentityBody for when the user has no verif. methods

* no longer distinguish between the using having a passphrase or not

* use vertical stack of buttons via EncryptionCard

and update wording

* swap logic order to match rendering order

* use the same dialog when no verification options available

* make it agree with the design more

* allow signing out on initial login

* apply styling changes and remove duplicate elements

* fix and add tests

* add missing snapshot

* Apply suggestions from code review

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* use a boolean property to disable blurring instead of adding a class

* change string identifiers

* apply changes from review -- simplify logic

* change class name to avoid confusion

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
This commit is contained in:
Hubert Chathi 2025-09-12 14:37:14 -04:00 committed by GitHub
parent 1e0cdf7b14
commit 9ad239f87f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 583 additions and 208 deletions

View File

@ -38,7 +38,7 @@ test.describe("Dehydration", () => {
// Reset the identity key
const settings = await app.settings.openUserSettings("Encryption");
await settings.getByRole("button", { name: "Verify this device" }).click();
await page.getByRole("button", { name: "Proceed with reset" }).click();
await page.getByRole("button", { name: "Can't confirm?" }).click();
await page.getByRole("button", { name: "Continue" }).click();
// Set up recovery
@ -106,7 +106,7 @@ test.describe("Dehydration", () => {
await logIntoElement(page, credentials);
// Oh no, we forgot our recovery key - reset our identity
await page.locator(".mx_AuthPage").getByRole("button", { name: "Reset all" }).click();
await page.locator(".mx_AuthPage").getByRole("button", { name: "Can't confirm" }).click();
await expect(
page.getByRole("heading", { name: "Are you sure you want to reset your identity?" }),
).toBeVisible();

View File

@ -36,13 +36,13 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
expectedBackupVersion = res.expectedBackupVersion;
});
// Click the "Verify with another device" button, and have the bot client auto-accept it.
// Click the "Use another device" button, and have the bot client auto-accept it.
async function initiateAliceVerificationRequest(page: Page): Promise<JSHandle<VerificationRequest>> {
// alice bot waits for verification request
const promiseVerificationRequest = waitForVerificationRequest(aliceBotClient);
// Click on "Verify with another device"
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with another device" }).click();
// Click on "Use another device"
await page.locator(".mx_AuthPage").getByRole("button", { name: "Use another device" }).click();
// alice bot responds yes to verification request from alice
return promiseVerificationRequest;
@ -203,7 +203,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
/** Helper for the three tests above which verify by recovery key */
async function enterRecoveryKeyAndCheckVerified(page: Page, app: ElementAppPage, recoveryKey: string) {
await page.getByRole("button", { name: "Verify with Recovery Key or Phrase" }).click();
await page.getByRole("button", { name: "Use recovery key" }).click();
// Enter the recovery key
const dialog = page.locator(".mx_Dialog");

View File

@ -224,9 +224,9 @@ export async function logIntoElement(page: Page, credentials: Credentials) {
export async function logIntoElementAndVerify(page: Page, credentials: Credentials, recoveryKey: string) {
await logIntoElement(page, credentials);
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Recovery Key" }).click();
await page.locator(".mx_AuthPage").getByRole("button", { name: "Use recovery key" }).click();
const useSecurityKey = page.locator(".mx_Dialog").getByRole("button", { name: "use your Recovery Key" });
const useSecurityKey = page.locator(".mx_Dialog").getByRole("button", { name: "Use recovery key" });
// If the user has set a recovery *passphrase*, they'll be prompted for that first and have to click
// through to enter the recovery key which is what we have here. If they haven't, they'll be prompted
// for a recovery key straight away. We click the button if it's there so this works in both cases.
@ -272,7 +272,7 @@ export async function logOutOfElement(page: Page, discardKeys: boolean = false)
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.getByRole("button", { name: "Use recovery key" }).click();
await app.page.locator(".mx_Dialog").getByTitle("Recovery key").fill(securityKey);
await app.page.getByRole("button", { name: "Continue", disabled: false }).click();
await app.page.getByRole("button", { name: "Done" }).click();

View File

@ -186,7 +186,7 @@ test.describe("Login", () => {
await page.goto("/");
await login(page, homeserver, credentials);
await expect(page.getByRole("heading", { name: "Verify this device", level: 1 })).toBeVisible();
await expect(page.getByRole("heading", { name: "Confirm your identity", level: 2 })).toBeVisible();
await expect(page.getByRole("button", { name: "Skip verification for now" })).toBeVisible();
});
@ -219,7 +219,7 @@ test.describe("Login", () => {
await page.goto("/");
await login(page, homeserver, credentials);
await expect(page.getByRole("heading", { name: "Verify this device", level: 1 })).toBeVisible();
await expect(page.getByRole("heading", { name: "Confirm your identity", level: 2 })).toBeVisible();
await expect(page.getByRole("button", { name: "Skip verification for now" })).toBeVisible();
});
@ -254,10 +254,10 @@ test.describe("Login", () => {
await page.goto("/");
await login(page, homeserver, credentials);
const h1 = page.getByRole("heading", { name: "Verify this device", level: 1 });
await expect(h1).toBeVisible();
const h2 = page.getByRole("heading", { name: "Confirm your identity", level: 2 });
await expect(h2).toBeVisible();
await expect(h1.locator(".mx_CompleteSecurity_skip")).toHaveCount(0);
await expect(h2.locator(".mx_CompleteSecurity_skip")).toHaveCount(0);
});
test("Continues to show verification prompt after cancelling device verification", async ({
@ -274,18 +274,18 @@ test.describe("Login", () => {
// Load the page and see that we are asked to verify
await page.goto("/#/welcome");
await login(page, homeserver, credentials);
let h1 = page.getByRole("heading", { name: "Verify this device", level: 1 });
await expect(h1).toBeVisible();
let h2 = page.getByRole("heading", { name: "Confirm your identity", level: 2 });
await expect(h2).toBeVisible();
// Click "Verify with another device"
await page.getByRole("button", { name: "Verify with another device" }).click();
// Click "Use another device"
await page.getByRole("button", { name: "Use another device" }).click();
// Cancel the new dialog
await page.getByRole("button", { name: "Close dialog" }).click();
// Check that we are still being asked to verify
h1 = page.getByRole("heading", { name: "Verify this device", level: 1 });
await expect(h1).toBeVisible();
h2 = page.getByRole("heading", { name: "Confirm your identity", level: 2 });
await expect(h2).toBeVisible();
});
});
@ -303,18 +303,18 @@ test.describe("Login", () => {
await page.goto("/");
await login(page, homeserver, credentials);
await expect(page.getByRole("heading", { name: "Verify this device", level: 1 })).toBeVisible();
await expect(page.getByRole("heading", { name: "Confirm your identity", level: 2 })).toBeVisible();
// Start the reset process
await page.getByRole("button", { name: "Proceed with reset" }).click();
await page.getByRole("button", { name: "Can't confirm?" }).click();
// First try cancelling and restarting
await page.getByRole("button", { name: "Cancel" }).click();
await page.getByRole("button", { name: "Proceed with reset" }).click();
await page.getByRole("button", { name: "Can't confirm?" }).click();
// Then click outside the dialog and restart
await page.getByRole("link", { name: "Powered by Matrix" }).click({ force: true });
await page.getByRole("button", { name: "Proceed with reset" }).click();
await page.getByRole("button", { name: "Can't confirm?" }).click();
// Finally we actually continue
await page.getByRole("button", { name: "Continue" }).click();

View File

@ -129,8 +129,8 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Continue" }).click();
// We should be in (we see an error because we have no recovery key).
await expect(page.getByText("Unable to verify this device")).toBeVisible();
// We should be in
await expect(page.getByText("Confirm your identity")).toBeVisible();
});
test.describe("with force_verification on", () => {
@ -162,7 +162,7 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
await page.getByRole("button", { name: "Continue" }).click();
// We should be being warned that we need to verify (but we can't)
await expect(page.getByText("Unable to verify this device")).toBeVisible();
await expect(page.getByText("Confirm your identity")).toBeVisible();
// And there should be no way to close this prompt
await expect(page.getByRole("button", { name: "Skip verification for now" })).not.toBeVisible();
@ -210,7 +210,7 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
await expect(page.getByRole("button", { name: "Skip verification for now" })).not.toBeVisible();
// When we start verifying with another device
await page.getByRole("button", { name: "Verify with another device" }).click();
await page.getByRole("button", { name: "Use another device" }).click();
// And then cancel it
await page.getByRole("button", { name: "Close dialog" }).click();
@ -227,7 +227,7 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
* Perform interactive emoji verification for a new device.
*/
async function verifyUsingOtherDevice(deviceToVerifyPage: Page, alreadyVerifiedDevicePage: Page) {
await deviceToVerifyPage.getByRole("button", { name: "Verify with another device" }).click();
await deviceToVerifyPage.getByRole("button", { name: "Use another device" }).click();
await alreadyVerifiedDevicePage.getByRole("button", { name: "Verify session" }).click();
await alreadyVerifiedDevicePage.getByRole("button", { name: "Start" }).click();
await alreadyVerifiedDevicePage.getByRole("button", { name: "They match" }).click();

View File

@ -160,15 +160,15 @@ test.describe("Encryption tab", () => {
// We will reset our identity
await settings.getByRole("button", { name: "Verify this device" }).click();
await page.getByRole("button", { name: "Proceed with reset" }).click();
await page.getByRole("button", { name: "Can't confirm?" }).click();
// First try cancelling and restarting
await page.getByRole("button", { name: "Cancel" }).click();
await page.getByRole("button", { name: "Proceed with reset" }).click();
await page.getByRole("button", { name: "Can't confirm?" }).click();
// Then click outside the dialog and restart
await page.locator("li").filter({ hasText: "Encryption" }).click({ force: true });
await page.getByRole("button", { name: "Proceed with reset" }).click();
await page.getByRole("button", { name: "Can't confirm?" }).click();
// Finally we actually continue
await page.getByRole("button", { name: "Continue" }).click();

View File

@ -43,7 +43,7 @@ class Helpers {
*/
async verifyDevice(recoveryKey: GeneratedSecretStorageKey) {
// Select the security phrase
await this.page.getByRole("button", { name: "Verify with Recovery Key" }).click();
await this.page.getByRole("button", { name: "Use recovery key" }).click();
await this.enterRecoveryKey(recoveryKey);
await this.page.getByRole("button", { name: "Done" }).click();
}

View File

@ -6,13 +6,6 @@ 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.
*/
.mx_SetupEncryptionBody_reset {
color: $light-fg-color;
margin-top: $font-14px;
.mx_SetupEncryptionBody_reset_link {
&.mx_AccessibleButton_kind_link_inline {
color: $alert;
}
}
.mx_SetupEncryptionBody {
width: 600px;
}

View File

@ -19,7 +19,6 @@ Please see LICENSE files in the repository root for full details.
display: flex;
margin: 100px auto auto;
border-radius: 4px;
box-shadow: 0 2px 4px 0 rgb(0, 0, 0, 0.33);
background-color: $authpage-modal-bg-color;
@media only screen and (max-height: 768px) {
@ -29,4 +28,9 @@ Please see LICENSE files in the repository root for full details.
@media only screen and (max-width: 480px) {
margin-top: 0;
}
/* Apply a blurred shadow around the modal */
&.mx_AuthPage_modal_withBlur {
box-shadow: 0 2px 4px 0 rgb(0, 0, 0, 0.33);
}
}

View File

@ -8,11 +8,10 @@ Please see LICENSE files in the repository root for full details.
*/
.mx_CompleteSecurityBody {
width: 600px;
color: $authpage-primary-color;
background-color: $background;
border-radius: 4px;
padding: 20px;
padding: 20px 20px 60px 20px;
box-sizing: border-box;
h2 {

View File

@ -30,6 +30,13 @@
text-align: center;
}
}
/* extra class for specifying that we don't need a border */
&.mx_EncryptionCard_noBorder {
border: 0px none;
box-shadow: none;
padding: 0px;
}
}
.mx_EncryptionCard_buttons {

View File

@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { Glass } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
import { SetupEncryptionStore, Phase } from "../../../stores/SetupEncryptionStore";
@ -22,15 +23,17 @@ interface IProps {
interface IState {
phase?: Phase;
lostKeys: boolean;
}
/**
* Prompts the user to verify their device when they first log in.
*/
export default class CompleteSecurity extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
const store = SetupEncryptionStore.sharedInstance();
store.start();
this.state = { phase: store.phase, lostKeys: store.lostKeys() };
this.state = { phase: store.phase };
}
public componentDidMount(): void {
@ -40,7 +43,7 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
private onStoreUpdate = (): void => {
const store = SetupEncryptionStore.sharedInstance();
this.setState({ phase: store.phase, lostKeys: store.lostKeys() });
this.setState({ phase: store.phase });
};
private onSkipClick = (): void => {
@ -55,20 +58,14 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
}
public render(): React.ReactNode {
const { phase, lostKeys } = this.state;
const { phase } = this.state;
let icon;
let title;
if (phase === Phase.Loading) {
return null;
} else if (phase === Phase.Intro) {
if (lostKeys) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("encryption|verification|after_new_login|unable_to_verify");
} else {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("encryption|verification|after_new_login|verify_this_device");
}
// We don't specify an icon nor title since `SetupEncryptionBody` provides its own
} else if (phase === Phase.Done) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_verified" />;
title = _t("encryption|verification|after_new_login|device_verified");
@ -98,7 +95,8 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
}
return (
<AuthPage>
<AuthPage addBlur={false}>
<Glass className="mx_Dialog_border">
<CompleteSecurityBody>
<h1 className="mx_CompleteSecurity_header">
{icon}
@ -106,9 +104,10 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
{skipButton}
</h1>
<div className="mx_CompleteSecurity_body">
<SetupEncryptionBody onFinished={this.props.onFinished} />
<SetupEncryptionBody onFinished={this.props.onFinished} allowLogout={true} />
</div>
</CompleteSecurityBody>
</Glass>
</AuthPage>
);
}

View File

@ -9,7 +9,9 @@ Please see LICENSE files in the repository root for full details.
import React, { type JSX } from "react";
import { type KeyBackupInfo, type VerificationRequest } from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger";
import { type SecretStorageKeyDescription } from "matrix-js-sdk/src/secret-storage";
import DevicesIcon from "@vector-im/compound-design-tokens/assets/web/icons/devices";
import LockIcon from "@vector-im/compound-design-tokens/assets/web/icons/lock-solid";
import { Button } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
@ -17,25 +19,38 @@ import Modal from "../../../Modal";
import VerificationRequestDialog from "../../views/dialogs/VerificationRequestDialog";
import { SetupEncryptionStore, Phase } from "../../../stores/SetupEncryptionStore";
import EncryptionPanel from "../../views/right_panel/EncryptionPanel";
import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton";
import AccessibleButton from "../../views/elements/AccessibleButton";
import Spinner from "../../views/elements/Spinner";
import { ResetIdentityDialog } from "../../views/dialogs/ResetIdentityDialog";
function keyHasPassphrase(keyInfo: SecretStorageKeyDescription): boolean {
return Boolean(keyInfo.passphrase && keyInfo.passphrase.salt && keyInfo.passphrase.iterations);
}
import { EncryptionCard } from "../../views/settings/encryption/EncryptionCard";
import { EncryptionCardButtons } from "../../views/settings/encryption/EncryptionCardButtons";
import { EncryptionCardEmphasisedContent } from "../../views/settings/encryption/EncryptionCardEmphasisedContent";
import ExternalLink from "../../views/elements/ExternalLink";
import dispatcher from "../../../dispatcher/dispatcher";
interface IProps {
onFinished: () => void;
/**
* Offer the user an option to log out, instead of setting up encryption.
*
* This is used when this component is shown when the user is initially
* prompted to set up encryption, before the user is shown the main chat
* interface.
*
* Defaults to `false` if omitted.
*/
allowLogout?: boolean;
}
interface IState {
phase?: Phase;
verificationRequest: VerificationRequest | null;
backupInfo: KeyBackupInfo | null;
lostKeys: boolean;
}
/**
* Component to set up encryption by verifying the current device.
*/
export default class SetupEncryptionBody extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
@ -48,7 +63,6 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
// Because of the latter, it lives in the state.
verificationRequest: store.verificationRequest,
backupInfo: store.backupInfo,
lostKeys: store.lostKeys(),
};
}
@ -67,7 +81,6 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
phase: store.phase,
verificationRequest: store.verificationRequest,
backupInfo: store.backupInfo,
lostKeys: store.lostKeys(),
});
};
@ -112,8 +125,8 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
store.returnAfterSkip();
};
private onResetClick = (ev: ButtonEvent): void => {
ev.preventDefault();
private onCantConfirmClick = (): void => {
const store = SetupEncryptionStore.sharedInstance();
Modal.createDialog(ResetIdentityDialog, {
onReset: () => {
// The user completed the reset process - close this dialog
@ -121,10 +134,14 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
const store = SetupEncryptionStore.sharedInstance();
store.done();
},
variant: "confirm",
variant: store.lostKeys() ? "no_verification_method" : "confirm",
});
};
private onSignOutClick = (): void => {
dispatcher.dispatch({ action: "logout" });
};
private onDoneClick = (): void => {
const store = SetupEncryptionStore.sharedInstance();
store.done();
@ -136,7 +153,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
public render(): React.ReactNode {
const cli = MatrixClientPeg.safeGet();
const { phase, lostKeys } = this.state;
const { phase } = this.state;
if (this.state.verificationRequest && cli.getUser(this.state.verificationRequest.otherUserId)) {
return (
@ -149,69 +166,59 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
/>
);
} else if (phase === Phase.Intro) {
if (lostKeys) {
return (
<div>
<p>{_t("encryption|verification|no_key_or_device")}</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton kind="primary" onClick={this.onResetClick}>
{_t("encryption|verification|reset_proceed_prompt")}
</AccessibleButton>
</div>
</div>
);
} else {
const store = SetupEncryptionStore.sharedInstance();
let recoveryKeyPrompt;
if (store.keyInfo && keyHasPassphrase(store.keyInfo)) {
recoveryKeyPrompt = _t("encryption|verification|verify_using_key_or_phrase");
} else if (store.keyInfo) {
recoveryKeyPrompt = _t("encryption|verification|verify_using_key");
}
let useRecoveryKeyButton;
if (recoveryKeyPrompt) {
useRecoveryKeyButton = (
<AccessibleButton kind="primary" onClick={this.onUsePassphraseClick}>
{recoveryKeyPrompt}
</AccessibleButton>
);
}
let verifyButton;
if (store.hasDevicesToVerifyAgainst) {
verifyButton = (
<AccessibleButton kind="primary" onClick={this.onVerifyClick}>
{_t("encryption|verification|verify_using_device")}
</AccessibleButton>
<Button kind="primary" onClick={this.onVerifyClick}>
<DevicesIcon /> {_t("encryption|verification|use_another_device")}
</Button>
);
}
let useRecoveryKeyButton;
if (store.keyInfo) {
useRecoveryKeyButton = (
<Button kind="primary" onClick={this.onUsePassphraseClick}>
{_t("encryption|verification|use_recovery_key")}
</Button>
);
}
let signOutButton;
if (this.props.allowLogout) {
signOutButton = (
<Button kind="tertiary" onClick={this.onSignOutClick}>
{_t("action|sign_out")}
</Button>
);
}
return (
<div>
<p>{_t("encryption|verification|verification_description")}</p>
<div className="mx_CompleteSecurity_actionRow">
<EncryptionCard
title={_t("encryption|verification|confirm_identity_title")}
Icon={LockIcon}
className="mx_EncryptionCard_noBorder mx_SetupEncryptionBody"
>
<EncryptionCardEmphasisedContent>
<span>{_t("encryption|verification|confirm_identity_description")}</span>
<span>
<ExternalLink href="https://element.io/help#encryption-device-verification">
{_t("action|learn_more")}
</ExternalLink>
</span>
</EncryptionCardEmphasisedContent>
<EncryptionCardButtons>
{verifyButton}
{useRecoveryKeyButton}
</div>
<div className="mx_SetupEncryptionBody_reset">
{_t("encryption|reset_all_button", undefined, {
a: (sub) => (
<AccessibleButton
kind="link_inline"
className="mx_SetupEncryptionBody_reset_link"
onClick={this.onResetClick}
>
{sub}
</AccessibleButton>
),
})}
</div>
</div>
<Button kind="secondary" onClick={this.onCantConfirmClick}>
{_t("encryption|verification|cant_confirm")}
</Button>
{signOutButton}
</EncryptionCardButtons>
</EncryptionCard>
);
}
} else if (phase === Phase.Done) {
let message: JSX.Element;
if (this.state.backupInfo) {

View File

@ -8,11 +8,22 @@ Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import classNames from "classnames";
import SdkConfig from "../../../SdkConfig";
import AuthFooter from "./AuthFooter";
export default class AuthPage extends React.PureComponent<React.PropsWithChildren> {
interface IProps {
/**
* Whether to add a blurred shadow around the modal.
*
* If the modal component provides its own shadow or blurring, this can be
* disabled. Defaults to `true`.
*/
addBlur?: boolean;
}
export default class AuthPage extends React.PureComponent<React.PropsWithChildren<IProps>> {
private static welcomeBackgroundUrl?: string;
// cache the url as a static to prevent it changing without refreshing
@ -58,14 +69,26 @@ export default class AuthPage extends React.PureComponent<React.PropsWithChildre
const modalContentStyle: React.CSSProperties = {
display: "flex",
zIndex: 1,
background: "rgba(255, 255, 255, 0.59)",
borderRadius: "8px",
};
let modalBlur;
if (this.props.addBlur !== false) {
// Blur out the background: add a `div` which covers the content behind the modal,
// and blurs it out, and make the modal's background semitransparent.
modalBlur = <div className="mx_AuthPage_modalBlur" style={blurStyle} />;
modalContentStyle.background = "rgba(255, 255, 255, 0.59)";
}
const modalClasses = classNames({
mx_AuthPage_modal: true,
mx_AuthPage_modal_withBlur: this.props.addBlur !== false,
});
return (
<div className="mx_AuthPage" style={pageStyle}>
<div className="mx_AuthPage_modal" style={modalStyle}>
<div className="mx_AuthPage_modalBlur" style={blurStyle} />
<div className={modalClasses} style={modalStyle}>
{modalBlur}
<div className="mx_AuthPage_modalContent" style={modalContentStyle}>
{this.props.children}
</div>

View File

@ -10,55 +10,19 @@ import React from "react";
import SetupEncryptionBody from "../../../structures/auth/SetupEncryptionBody";
import BaseDialog from "../BaseDialog";
import { _t } from "../../../../languageHandler";
import { SetupEncryptionStore, Phase } from "../../../../stores/SetupEncryptionStore";
function iconFromPhase(phase?: Phase): string {
if (phase === Phase.Done) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require("../../../../../res/img/e2e/verified-deprecated.svg").default;
} else {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require("../../../../../res/img/e2e/warning-deprecated.svg").default;
}
}
interface IProps {
onFinished(): void;
}
interface IState {
icon: string;
}
export default class SetupEncryptionDialog extends React.Component<IProps, IState> {
private store: SetupEncryptionStore;
export default class SetupEncryptionDialog extends React.Component<IProps> {
public constructor(props: IProps) {
super(props);
this.store = SetupEncryptionStore.sharedInstance();
this.state = { icon: iconFromPhase(this.store.phase) };
}
public componentDidMount(): void {
this.store.on("update", this.onStoreUpdate);
}
public componentWillUnmount(): void {
this.store.removeListener("update", this.onStoreUpdate);
}
private onStoreUpdate = (): void => {
this.setState({ icon: iconFromPhase(this.store.phase) });
};
public render(): React.ReactNode {
return (
<BaseDialog
headerImage={this.state.icon}
onFinished={this.props.onFinished}
title={_t("encryption|verify_toast_title")}
>
<BaseDialog onFinished={this.props.onFinished} fixedWidth={false}>
<SetupEncryptionBody onFinished={this.props.onFinished} />
</BaseDialog>
);

View File

@ -48,8 +48,12 @@ interface ResetIdentityBodyProps {
* "forgot" is shown when the user chose 'Forgot recovery key?' during `SetupEncryptionToast`.
*
* "confirm" is shown when the user chose 'Reset all' during `SetupEncryptionBody`.
*
* "no_verification_method" is shown when the device is unverified and has no way of
* obtaining the existing keys, and hence the identity needs to be reset to have
* a cross-signed device.
*/
export type ResetIdentityBodyVariant = "compromised" | "forgot" | "sync_failed" | "confirm";
export type ResetIdentityBodyVariant = "compromised" | "forgot" | "sync_failed" | "confirm" | "no_verification_method";
/**
* User interface component allowing the user to reset their cryptographic identity.
@ -124,5 +128,7 @@ function titleForVariant(variant: ResetIdentityBodyVariant): string {
return _t("settings|encryption|advanced|breadcrumb_title_sync_failed");
case "forgot":
return _t("settings|encryption|advanced|breadcrumb_title_forgot");
case "no_verification_method":
return _t("settings|encryption|advanced|breadcrumb_title_cant_confirm");
}
}

View File

@ -969,7 +969,6 @@
"title": "Recovery Method Removed",
"warning": "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings."
},
"reset_all_button": "Forgotten or lost all recovery methods? <a>Reset all</a>",
"set_up_recovery": "Set up recovery",
"set_up_recovery_toast_description": "Generate a recovery key that can be used to restore your encrypted message history in case you lose access to your devices.",
"set_up_toast_title": "Set up Secure Backup",
@ -992,16 +991,18 @@
"after_new_login": {
"device_verified": "Device verified",
"skip_verification": "Skip verification for now",
"unable_to_verify": "Unable to verify this device",
"verify_this_device": "Verify this device"
},
"cancelled": "You cancelled verification.",
"cancelled_self": "You cancelled verification on your other device.",
"cancelled_user": "%(displayName)s cancelled verification.",
"cancelling": "Cancelling…",
"cant_confirm": "Can't confirm?",
"complete_action": "Got It",
"complete_description": "You've successfully verified this user.",
"complete_title": "Verified!",
"confirm_identity_description": "Verify this device to set up secure messaging",
"confirm_identity_title": "Confirm your identity",
"error_starting_description": "We were unable to start a chat with the other user.",
"error_starting_title": "Error starting verification",
"explainer": "Secure messages with this user are end-to-end encrypted and not able to be read by third parties.",
@ -1027,7 +1028,6 @@
"text": "Supply the ID and fingerprint of one of your own devices to verify it. NOTE this allows the other device to send and receive messages as you. IF SOMEONE TOLD YOU TO PASTE SOMETHING HERE, IT IS LIKELY YOU ARE BEING SCAMMED!",
"wrong_fingerprint": "Unable to verify device '%(deviceId)s' - the supplied fingerprint '%(fingerprint)s' does not match the device fingerprint, '%(fprint)s'"
},
"no_key_or_device": "It looks like you don't have a Recovery Key or any other devices you can verify against. This device will not be able to access old encrypted messages. In order to verify your identity on this device, you'll need to reset your cryptographic identity.",
"no_support_qr_emoji": "The device you are trying to verify doesn't support scanning a QR code or emoji verification, which is what %(brand)s supports. Try with a different client.",
"other_party_cancelled": "The other party cancelled the verification.",
"prompt_encrypted": "Verify all users in a room to ensure it's secure.",
@ -1043,7 +1043,6 @@
"request_toast_accept_user": "Verify User",
"request_toast_decline_counter": "Ignore (%(counter)s)",
"request_toast_detail": "%(deviceId)s from %(ip)s",
"reset_proceed_prompt": "Proceed with reset",
"sas_caption_self": "Verify this device by confirming the following number appears on its screen.",
"sas_caption_user": "Verify this user by confirming the following number appears on their screen.",
"sas_description": "Compare a unique set of emoji if you don't have a camera on either device",
@ -1066,7 +1065,8 @@
"unverified_sessions_toast_description": "Review to ensure your account is safe",
"unverified_sessions_toast_reject": "Later",
"unverified_sessions_toast_title": "You have unverified sessions",
"verification_description": "Verify your identity to access encrypted messages and prove your identity to others. If you also use a mobile device, please open the app there before you proceed.",
"use_another_device": "Use another device",
"use_recovery_key": "Use recovery key",
"verification_dialog_title_device": "Verify other device",
"verification_dialog_title_user": "Verification Request",
"verification_skip_warning": "Without verifying, you won't have access to all your messages and may appear as untrusted to others.",
@ -1076,9 +1076,6 @@
"verify_emoji_prompt": "Verify by comparing unique emoji.",
"verify_emoji_prompt_qr": "If you can't scan the code above, verify by comparing unique emoji.",
"verify_later": "I'll verify later",
"verify_using_device": "Verify with another device",
"verify_using_key": "Verify with Recovery Key",
"verify_using_key_or_phrase": "Verify with Recovery Key or Phrase",
"waiting_for_user_accept": "Waiting for %(displayName)s to accept…",
"waiting_other_device": "Waiting for you to verify on your other device…",
"waiting_other_device_details": "Waiting for you to verify on your other device, %(deviceName)s (%(deviceId)s)…",
@ -2589,6 +2586,7 @@
"breadcrumb_second_description": "You will lose any message history thats stored only on the server",
"breadcrumb_third_description": "You will need to verify all your existing devices and contacts again",
"breadcrumb_title": "Are you sure you want to reset your identity?",
"breadcrumb_title_cant_confirm": "You need to reset your identity",
"breadcrumb_title_forgot": "Forgot your recovery key? Youll need to reset your identity.",
"breadcrumb_title_sync_failed": "Failed to sync key storage. You need to reset your identity.",
"breadcrumb_warning": "Only do this if you believe your account has been compromised.",

View File

@ -1081,10 +1081,10 @@ describe("<MatrixChat />", () => {
getComponent();
// Then we are asked to verify our device
await screen.findByRole("heading", { name: "Verify this device", level: 1 });
await screen.findByRole("heading", { name: "Confirm your identity", level: 2 });
// Sanity: we are not racing with another screen update, so this heading stays visible
await screen.findByRole("heading", { name: "Verify this device", level: 1 });
await screen.findByRole("heading", { name: "Confirm your identity", level: 2 });
});
it("should not open app after cancelling device verify if unskippable verification is on", async () => {
// See https://github.com/element-hq/element-web/issues/29230
@ -1100,9 +1100,9 @@ describe("<MatrixChat />", () => {
// And MatrixChat is rendered
getComponent();
// When we click "Verify with another device"
await screen.findByRole("heading", { name: "Verify this device", level: 1 });
const verify = screen.getByRole("button", { name: "Verify with another device" });
// When we click "Use another device"
await screen.findByRole("heading", { name: "Confirm your identity", level: 2 });
const verify = screen.getByRole("button", { name: "Use another device" });
act(() => verify.click());
// And close the device verification dialog
@ -1110,7 +1110,7 @@ describe("<MatrixChat />", () => {
act(() => closeButton.click());
// Then we are not allowed in - we are still being asked to verify
await screen.findByRole("heading", { name: "Verify this device", level: 1 });
await screen.findByRole("heading", { name: "Confirm your identity", level: 2 });
});
describe("when query params have a loginToken", () => {
@ -1153,7 +1153,7 @@ describe("<MatrixChat />", () => {
);
// Then we are not allowed in - we are being asked to verify
await screen.findByRole("heading", { name: "Verify this device", level: 1 });
await screen.findByRole("heading", { name: "Confirm your identity", level: 2 });
});
});
@ -1397,7 +1397,7 @@ describe("<MatrixChat />", () => {
await flushPromises();
// Complete security begin screen is rendered
expect(screen.getByText("Unable to verify this device")).toBeInTheDocument();
expect(screen.getByText("Confirm your identity")).toBeInTheDocument();
});
it("should setup e2e when server supports cross signing", async () => {

View File

@ -113,7 +113,7 @@ exports[`<MatrixChat /> Multi-tab lockout waits for other tab to stop during sta
class="mx_AuthPage"
>
<div
class="mx_AuthPage_modal"
class="mx_AuthPage_modal mx_AuthPage_modal_withBlur"
style="position: relative;"
>
<div
@ -122,7 +122,7 @@ exports[`<MatrixChat /> Multi-tab lockout waits for other tab to stop during sta
/>
<div
class="mx_AuthPage_modalContent"
style="display: flex; z-index: 1; background: rgba(255, 255, 255, 0.59); border-radius: 8px;"
style="display: flex; z-index: 1; border-radius: 8px; background: rgba(255, 255, 255, 0.59);"
>
<div
class="mx_Welcome"
@ -231,7 +231,7 @@ exports[`<MatrixChat /> with a soft-logged-out session should show the soft-logo
class="mx_AuthPage"
>
<div
class="mx_AuthPage_modal"
class="mx_AuthPage_modal mx_AuthPage_modal_withBlur"
style="position: relative;"
>
<div
@ -240,7 +240,7 @@ exports[`<MatrixChat /> with a soft-logged-out session should show the soft-logo
/>
<div
class="mx_AuthPage_modalContent"
style="display: flex; z-index: 1; background: rgba(255, 255, 255, 0.59); border-radius: 8px;"
style="display: flex; z-index: 1; border-radius: 8px; background: rgba(255, 255, 255, 0.59);"
>
<div
class="mx_AuthHeader"

View File

@ -83,13 +83,66 @@ describe("CompleteSecurity", () => {
jest.spyOn(SetupEncryptionStore, "sharedInstance").mockReturnValue(store);
const panel = await act(() => render(<CompleteSecurity onFinished={() => {}} />));
// No recovery methods are available, so only the "Can't confirm?" button should be visible
expect(screen.queryByRole("button", { name: "Can't confirm?" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Use another device" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Use recovery key" })).not.toBeInTheDocument();
// When we hit reset
await act(async () => panel.getByRole("button", { name: "Proceed with reset" }).click());
await act(async () => panel.getByRole("button", { name: "Can't confirm?" }).click());
// Then the reset identity dialog appears
expect(screen.getByRole("heading", { name: "You need to reset your identity" })).toBeInTheDocument();
expect(panel.getByRole("button", { name: "Continue" })).toBeInTheDocument();
});
it("Allows verifying with another device if one is available", async () => {
// Given a store and a dialog based on it
const store = new SetupEncryptionStore();
jest.spyOn(store, "fetchKeyInfo").mockImplementation(async () => {
store.hasDevicesToVerifyAgainst = true;
store.phase = Phase.Intro;
store.emit("update");
});
jest.spyOn(SetupEncryptionStore, "sharedInstance").mockReturnValue(store);
const panel = await act(() => render(<CompleteSecurity onFinished={() => {}} />));
// The snapshot should have "Use another device" and "Can't confirm?"
// buttons, but no "Use recovery key".
expect(panel.asFragment()).toMatchSnapshot();
// When we hit reset
await act(async () => panel.getByRole("button", { name: "Can't confirm?" }).click());
// Then the reset identity dialog appears, and should have a different
// title from when there were no verification methods available.
expect(
screen.getByRole("heading", { name: "Are you sure you want to reset your identity?" }),
).toBeInTheDocument();
});
it("Allows verifying with recovery key if one is available", async () => {
// Given a store and a dialog based on it
const store = new SetupEncryptionStore();
jest.spyOn(store, "fetchKeyInfo").mockImplementation(async () => {
store.keyInfo = {} as any;
store.phase = Phase.Intro;
store.emit("update");
});
jest.spyOn(SetupEncryptionStore, "sharedInstance").mockReturnValue(store);
const panel = await act(() => render(<CompleteSecurity onFinished={() => {}} />));
// The snapshot should have "Use recovery key" and "Can't confirm?"
// buttons, but no "Use another device".
expect(panel.asFragment()).toMatchSnapshot();
// When we hit reset
await act(async () => panel.getByRole("button", { name: "Can't confirm?" }).click());
// Then the reset identity dialog appears, and should have a different
// title from when there were no verification methods available.
expect(
screen.getByRole("heading", { name: "Are you sure you want to reset your identity?" }),
).toBeInTheDocument();
expect(panel.getByRole("button", { name: "Continue" })).toBeInTheDocument();
});
});

View File

@ -0,0 +1,322 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CompleteSecurity Allows verifying with another device if one is available 1`] = `
<DocumentFragment>
<div
class="mx_AuthPage"
>
<div
class="mx_AuthPage_modal"
style="position: relative;"
>
<div
class="mx_AuthPage_modalContent"
style="display: flex; z-index: 1; border-radius: 8px;"
>
<div
class="mx_Dialog_border _glass_sepwu_8"
>
<div
class="mx_CompleteSecurityBody"
>
<h1
class="mx_CompleteSecurity_header"
>
<div
aria-label="Skip verification for now"
class="mx_AccessibleButton mx_CompleteSecurity_skip"
role="button"
tabindex="0"
/>
</h1>
<div
class="mx_CompleteSecurity_body"
>
<div
class="mx_EncryptionCard mx_EncryptionCard_noBorder mx_SetupEncryptionBody"
>
<div
class="mx_EncryptionCard_header"
>
<div
class="_content_o77nw_8"
data-size="large"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 22q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412A1.93 1.93 0 0 1 6 8h1V6q0-2.075 1.463-3.537Q9.926 1 12 1q2.075 0 3.537 1.463Q17 3.925 17 6v2h1q.824 0 1.413.588Q20 9.175 20 10v10q0 .824-.587 1.413A1.93 1.93 0 0 1 18 22zM9 8h6V6q0-1.25-.875-2.125A2.9 2.9 0 0 0 12 3q-1.25 0-2.125.875A2.9 2.9 0 0 0 9 6z"
/>
</svg>
</div>
<h2
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
>
Confirm your identity
</h2>
</div>
<div
class="flex mx_EncryptionCard_emphasisedContent"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: normal; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<span>
Verify this device to set up secure messaging
</span>
<span>
<a
class="mx_ExternalLink"
href="https://element.io/help#encryption-device-verification"
rel="noreferrer noopener"
target="_blank"
>
Learn more
<i
class="mx_ExternalLink_icon"
/>
</a>
</span>
</div>
<div
class="mx_EncryptionCard_buttons"
>
<button
class="_button_vczzf_8"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3.5 20q-.625 0-1.062-.437A1.45 1.45 0 0 1 2 18.5q0-.625.438-1.062A1.45 1.45 0 0 1 3.5 17H4V6q0-.824.588-1.412A1.93 1.93 0 0 1 6 4h14q.424 0 .712.287Q21 4.576 21 5t-.288.713A.97.97 0 0 1 20 6H6v11h4.5q.624 0 1.063.438.437.437.437 1.062t-.437 1.063A1.45 1.45 0 0 1 10.5 20zM15 20a.97.97 0 0 1-.713-.288A.97.97 0 0 1 14 19V9q0-.424.287-.713A.97.97 0 0 1 15 8h6q.424 0 .712.287Q22 8.576 22 9v10q0 .424-.288.712A.97.97 0 0 1 21 20zm1-3h4v-7h-4z"
/>
</svg>
Use another device
</button>
<button
class="_button_vczzf_8"
data-kind="secondary"
data-size="lg"
role="button"
tabindex="0"
>
Can't confirm?
</button>
<button
class="_button_vczzf_8"
data-kind="tertiary"
data-size="lg"
role="button"
tabindex="0"
>
Sign out
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<footer
class="mx_AuthFooter"
role="contentinfo"
>
<a
href="https://element.io/blog"
rel="noreferrer noopener"
target="_blank"
>
Blog
</a>
<a
href="https://mastodon.matrix.org/@Element"
rel="noreferrer noopener"
target="_blank"
>
Mastodon
</a>
<a
href="https://github.com/element-hq/element-web"
rel="noreferrer noopener"
target="_blank"
>
GitHub
</a>
<a
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
Powered by Matrix
</a>
</footer>
</div>
</DocumentFragment>
`;
exports[`CompleteSecurity Allows verifying with recovery key if one is available 1`] = `
<DocumentFragment>
<div
class="mx_AuthPage"
>
<div
class="mx_AuthPage_modal"
style="position: relative;"
>
<div
class="mx_AuthPage_modalContent"
style="display: flex; z-index: 1; border-radius: 8px;"
>
<div
class="mx_Dialog_border _glass_sepwu_8"
>
<div
class="mx_CompleteSecurityBody"
>
<h1
class="mx_CompleteSecurity_header"
>
<div
aria-label="Skip verification for now"
class="mx_AccessibleButton mx_CompleteSecurity_skip"
role="button"
tabindex="0"
/>
</h1>
<div
class="mx_CompleteSecurity_body"
>
<div
class="mx_EncryptionCard mx_EncryptionCard_noBorder mx_SetupEncryptionBody"
>
<div
class="mx_EncryptionCard_header"
>
<div
class="_content_o77nw_8"
data-size="large"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 22q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412A1.93 1.93 0 0 1 6 8h1V6q0-2.075 1.463-3.537Q9.926 1 12 1q2.075 0 3.537 1.463Q17 3.925 17 6v2h1q.824 0 1.413.588Q20 9.175 20 10v10q0 .824-.587 1.413A1.93 1.93 0 0 1 18 22zM9 8h6V6q0-1.25-.875-2.125A2.9 2.9 0 0 0 12 3q-1.25 0-2.125.875A2.9 2.9 0 0 0 9 6z"
/>
</svg>
</div>
<h2
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
>
Confirm your identity
</h2>
</div>
<div
class="flex mx_EncryptionCard_emphasisedContent"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: normal; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<span>
Verify this device to set up secure messaging
</span>
<span>
<a
class="mx_ExternalLink"
href="https://element.io/help#encryption-device-verification"
rel="noreferrer noopener"
target="_blank"
>
Learn more
<i
class="mx_ExternalLink_icon"
/>
</a>
</span>
</div>
<div
class="mx_EncryptionCard_buttons"
>
<button
class="_button_vczzf_8"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
Use recovery key
</button>
<button
class="_button_vczzf_8"
data-kind="secondary"
data-size="lg"
role="button"
tabindex="0"
>
Can't confirm?
</button>
<button
class="_button_vczzf_8"
data-kind="tertiary"
data-size="lg"
role="button"
tabindex="0"
>
Sign out
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<footer
class="mx_AuthFooter"
role="contentinfo"
>
<a
href="https://element.io/blog"
rel="noreferrer noopener"
target="_blank"
>
Blog
</a>
<a
href="https://mastodon.matrix.org/@Element"
rel="noreferrer noopener"
target="_blank"
>
Mastodon
</a>
<a
href="https://github.com/element-hq/element-web"
rel="noreferrer noopener"
target="_blank"
>
GitHub
</a>
<a
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
Powered by Matrix
</a>
</footer>
</div>
</DocumentFragment>
`;

View File

@ -6,7 +6,7 @@ exports[`<AuthPage /> should match snapshot 1`] = `
class="mx_AuthPage"
>
<div
class="mx_AuthPage_modal"
class="mx_AuthPage_modal mx_AuthPage_modal_withBlur"
style="position: relative;"
>
<div
@ -15,7 +15,7 @@ exports[`<AuthPage /> should match snapshot 1`] = `
/>
<div
class="mx_AuthPage_modalContent"
style="display: flex; z-index: 1; background: rgba(255, 255, 255, 0.59); border-radius: 8px;"
style="display: flex; z-index: 1; border-radius: 8px; background: rgba(255, 255, 255, 0.59);"
/>
</div>
<footer

View File

@ -46,7 +46,7 @@ describe("SetupEncryptionDialog", () => {
// And we hit the Proceed with reset button.
// (The createDialog mock above simulates the user doing the reset)
await act(async () => screen.getByRole("button", { name: "Proceed with reset" }).click());
await act(async () => screen.getByRole("button", { name: "Can't confirm?" }).click());
// Then the phase has been set to Finished
expect(store.phase).toBe(Phase.Finished);