1
0
mirror of https://github.com/element-hq/element-web.git synced 2025-08-08 03:42:14 +03:00

Prompt the user when key storage is unexpectedly off (#29912)

* Assert that we set backup_disabled when turning off key storage

* Prompt the user when key storage is unexpectedly off

* Playwright tests for the Turn on key storage toast
This commit is contained in:
Andy Balaam
2025-05-20 13:28:22 +01:00
committed by GitHub
parent 22c7bf346c
commit b539eda4fe
16 changed files with 655 additions and 20 deletions

View File

@@ -67,8 +67,9 @@ test.describe("Cryptography", function () {
// Bob has a second, not cross-signed, device
const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob);
// Dismiss the toast nagging us to set up recovery otherwise it gets in the way of clicking the room list
await page.getByRole("button", { name: "Not now" }).click();
// Dismiss the toasts nagging us, otherwise they get in the way of clicking the room list
await page.getByRole("button", { name: "Dismiss" }).click();
await page.getByRole("button", { name: "Yes, dismiss" }).click();
await bob.sendEvent(testRoomId, null, "m.room.encrypted", {
algorithm: "m.megolm.v1.aes-sha2",

View File

@@ -8,7 +8,8 @@
import { type GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
import { test, expect } from "../../element-web-test";
import { createBot, deleteCachedSecrets, logIntoElement } from "./utils";
import { createBot, deleteCachedSecrets, disableKeyBackup, logIntoElement } from "./utils";
import { type Bot } from "../../pages/bot";
test.describe("Key storage out of sync toast", () => {
let recoveryKey: GeneratedSecretStorageKey;
@@ -53,3 +54,114 @@ test.describe("Key storage out of sync toast", () => {
).toBeVisible();
});
});
test.describe("'Turn on key storage' toast", () => {
let botClient: Bot | undefined;
test.beforeEach(async ({ page, homeserver, credentials, toasts }) => {
// Set up all crypto stuff. Key storage defaults to on.
const res = await createBot(page, homeserver, credentials);
const recoveryKey = res.recoveryKey;
botClient = res.botClient;
await logIntoElement(page, credentials, recoveryKey.encodedPrivateKey);
// We won't be prompted for crypto setup unless we have an e2e room, so make one
await page.getByRole("button", { name: "Add room" }).click();
await page.getByRole("menuitem", { name: "New room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("Test room");
await page.getByRole("button", { name: "Create room" }).click();
await toasts.rejectToast("Notifications");
});
test("should not show toast if key storage is on", async ({ page, toasts }) => {
// Given the default situation after signing in
// Then no toast is shown (because key storage is on)
await toasts.assertNoToasts();
// When we reload
await page.reload();
// Give the toasts time to appear
await new Promise((resolve) => setTimeout(resolve, 2000));
// Then still no toast is shown
await toasts.assertNoToasts();
});
test("should not show toast if key storage is off because we turned it off", async ({ app, page, toasts }) => {
// Given the backup is disabled because we disabled it
await disableKeyBackup(app);
// Then no toast is shown
await toasts.assertNoToasts();
// When we reload
await page.reload();
// Give the toasts time to appear
await new Promise((resolve) => setTimeout(resolve, 2000));
// Then still no toast is shown
await toasts.assertNoToasts();
});
test("should show toast if key storage is off but account data is missing", async ({ app, page, toasts }) => {
// Given the backup is disabled but we didn't set account data saying that is expected
await disableKeyBackup(app);
await botClient.setAccountData("m.org.matrix.custom.backup_disabled", { disabled: false });
// Wait for the account data setting to stick
await new Promise((resolve) => setTimeout(resolve, 2000));
// When we enter the app
await page.reload();
// Then the toast is displayed
let toast = await toasts.getToast("Turn on key storage");
// And when we click "Continue"
await toast.getByRole("button", { name: "Continue" }).click();
// Then we see the Encryption settings dialog with an option to turn on key storage
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).toBeVisible();
// And when we close that
await page.getByRole("button", { name: "Close dialog" }).click();
// Then we see the toast again
toast = await toasts.getToast("Turn on key storage");
// And when we click "Dismiss"
await toast.getByRole("button", { name: "Dismiss" }).click();
// Then we see the "are you sure?" dialog
await expect(
page.getByRole("heading", { name: "Are you sure you want to keep key storage turned off?" }),
).toBeVisible();
// And when we close it by clicking away
await page.getByTestId("dialog-background").click({ force: true, position: { x: 10, y: 10 } });
// Then we see the toast again
toast = await toasts.getToast("Turn on key storage");
// And when we click Dismiss and then "Go to Settings"
await toast.getByRole("button", { name: "Dismiss" }).click();
await page.getByRole("button", { name: "Go to Settings" }).click();
// Then we see Encryption settings again
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).toBeVisible();
// And when we close that, see the toast, click Dismiss, and Yes, Dismiss
await page.getByRole("button", { name: "Close dialog" }).click();
toast = await toasts.getToast("Turn on key storage");
await toast.getByRole("button", { name: "Dismiss" }).click();
await page.getByRole("button", { name: "Yes, dismiss" }).click();
// Then the toast is gone
await toasts.assertNoToasts();
});
});

View File

@@ -316,6 +316,25 @@ export async function enableKeyBackup(app: ElementAppPage): Promise<string> {
return recoveryKey;
}
/**
* Open the encryption settings and disable key storage (and recovery)
* Assumes that the current device has been verified
*/
export async function disableKeyBackup(app: ElementAppPage): Promise<void> {
const encryptionTab = await app.settings.openUserSettings("Encryption");
const keyStorageToggle = encryptionTab.getByRole("checkbox", { name: "Allow key storage" });
if (await keyStorageToggle.isChecked()) {
await encryptionTab.getByRole("checkbox", { name: "Allow key storage" }).click();
await encryptionTab.getByRole("button", { name: "Delete key storage" }).click();
await encryptionTab.getByRole("checkbox", { name: "Allow key storage" }).isVisible();
// Wait for the update to account data to stick
await new Promise((resolve) => setTimeout(resolve, 2000));
}
await app.settings.closeDialog();
}
/**
* Go through the "Set up Secure Backup" dialog (aka the `CreateSecretStorageDialog`).
*

View File

@@ -593,6 +593,7 @@ legend {
.mx_Dialog
button:not(
.mx_EncryptionUserSettingsTab button,
.mx_EncryptionCard button,
.mx_UserProfileSettings button,
.mx_ShareDialog button,
.mx_UnpinAllDialog button,

View File

@@ -131,6 +131,7 @@
@import "./views/dialogs/_BugReportDialog.pcss";
@import "./views/dialogs/_ChangelogDialog.pcss";
@import "./views/dialogs/_CompoundDialog.pcss";
@import "./views/dialogs/_ConfirmKeyStorageOffDialog.pcss";
@import "./views/dialogs/_ConfirmSpaceUserActionDialog.pcss";
@import "./views/dialogs/_ConfirmUserActionDialog.pcss";
@import "./views/dialogs/_CreateRoomDialog.pcss";

View File

@@ -79,6 +79,11 @@ Please see LICENSE files in the repository root for full details.
background-color: $primary-content;
}
&.mx_Toast_icon_key_storage::after {
mask-image: url("@vector-im/compound-design-tokens/icons/settings-solid.svg");
background-color: $primary-content;
}
&.mx_Toast_icon_labs::after {
mask-image: url("$(res)/img/element-icons/flask.svg");
background-color: $secondary-content;

View File

@@ -0,0 +1,16 @@
/*
Copyright 2025 New Vector Ltd.
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.
*/
.mx_ConfirmKeyStorageOffDialog {
.mx_Dialog_border {
width: 600px;
}
.mx_EncryptionCard {
text-align: center;
}
}

View File

@@ -97,6 +97,7 @@ export default class DeviceListener {
this.client.on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
this.client.on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
this.client.on(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged);
this.client.on(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatusChanged);
this.client.on(ClientEvent.AccountData, this.onAccountData);
this.client.on(ClientEvent.Sync, this.onSync);
this.client.on(RoomStateEvent.Events, this.onRoomStateEvents);
@@ -132,7 +133,7 @@ export default class DeviceListener {
this.dismissedThisDeviceToast = false;
this.keyBackupInfo = null;
this.keyBackupFetchedAt = null;
this.cachedKeyBackupStatus = undefined;
this.cachedKeyBackupUploadActive = undefined;
this.ourDeviceIdsAtStart = null;
this.displayingToastsForDeviceIds = new Set();
this.client = undefined;
@@ -157,6 +158,13 @@ export default class DeviceListener {
this.recheck();
}
/**
* Set the account data "m.org.matrix.custom.backup_disabled" to { "disabled": true }.
*/
public async recordKeyBackupDisabled(): Promise<void> {
await this.client?.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true });
}
private async ensureDeviceIdsAtStartPopulated(): Promise<void> {
if (this.ourDeviceIdsAtStart === null) {
this.ourDeviceIdsAtStart = await this.getDeviceIds();
@@ -192,6 +200,11 @@ export default class DeviceListener {
this.recheck();
};
private onKeyBackupStatusChanged = (): void => {
this.cachedKeyBackupUploadActive = undefined;
this.recheck();
};
private onCrossSingingKeysChanged = (): void => {
this.recheck();
};
@@ -201,11 +214,13 @@ export default class DeviceListener {
// * migrated SSSS to symmetric
// * uploaded keys to secret storage
// * completed secret storage creation
// * disabled key backup
// which result in account data changes affecting checks below.
if (
ev.getType().startsWith("m.secret_storage.") ||
ev.getType().startsWith("m.cross_signing.") ||
ev.getType() === "m.megolm_backup.v1"
ev.getType() === "m.megolm_backup.v1" ||
ev.getType() === BACKUP_DISABLED_ACCOUNT_DATA_KEY
) {
this.recheck();
}
@@ -324,7 +339,16 @@ export default class DeviceListener {
(await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified,
);
const allSystemsReady = crossSigningReady && secretStorageReady && allCrossSigningSecretsCached;
const keyBackupUploadActive = await this.isKeyBackupUploadActive();
const backupDisabled = await this.recheckBackupDisabled(cli);
// We warn if key backup upload is turned off and we have not explicitly
// said we are OK with that.
const keyBackupIsOk = keyBackupUploadActive || backupDisabled;
const allSystemsReady =
crossSigningReady && keyBackupIsOk && secretStorageReady && allCrossSigningSecretsCached;
await this.reportCryptoSessionStateToAnalytics(cli);
if (this.dismissedThisDeviceToast || allSystemsReady) {
@@ -353,14 +377,19 @@ export default class DeviceListener {
crossSigningStatus.privateKeysCachedLocally,
);
showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC);
} else if (!keyBackupIsOk) {
logSpan.info("Key backup upload is unexpectedly turned off: showing TURN_ON_KEY_STORAGE toast");
showSetupEncryptionToast(SetupKind.TURN_ON_KEY_STORAGE);
} else if (defaultKeyId === null) {
// the user just hasn't set up 4S yet: prompt them to do so (unless they've explicitly said no to key storage)
const disabledEvent = cli.getAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY);
if (!disabledEvent?.getContent().disabled) {
// The user just hasn't set up 4S yet: if they have key
// backup, prompt them to turn on recovery too. (If not, they
// have explicitly opted out, so don't hassle them.)
if (keyBackupUploadActive) {
logSpan.info("No default 4S key: showing SET_UP_RECOVERY toast");
showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY);
} else {
logSpan.info("No default 4S key but backup disabled: no toast needed");
hideSetupEncryptionToast();
}
} else {
// some other condition... yikes! Show the 'set up encryption' toast: this is what we previously did
@@ -443,6 +472,16 @@ export default class DeviceListener {
this.displayingToastsForDeviceIds = newUnverifiedDeviceIds;
}
/**
* Fetch the account data for `backup_disabled`. If this is the first time,
* fetch it from the server (in case the initial sync has not finished).
* Otherwise, fetch it from the store as normal.
*/
private async recheckBackupDisabled(cli: MatrixClient): Promise<boolean> {
const backupDisabled = await cli.getAccountDataFromServer(BACKUP_DISABLED_ACCOUNT_DATA_KEY);
return !!backupDisabled?.disabled;
}
/**
* Reports current recovery state to analytics.
* Checks if the session is verified and if the recovery is correctly set up (i.e all secrets known locally and in 4S).
@@ -512,7 +551,7 @@ export default class DeviceListener {
* trigger an auto-rageshake).
*/
private checkKeyBackupStatus = async (): Promise<void> => {
if (!(await this.getKeyBackupStatus())) {
if (!(await this.isKeyBackupUploadActive())) {
dis.dispatch({ action: Action.ReportKeyBackupNotEnabled });
}
};
@@ -520,28 +559,34 @@ export default class DeviceListener {
/**
* Is key backup enabled? Use a cached answer if we have one.
*/
private getKeyBackupStatus = async (): Promise<boolean> => {
private isKeyBackupUploadActive = async (): Promise<boolean> => {
if (!this.client) {
// To preserve existing behaviour, if there is no client, we
// pretend key storage is on.
// pretend key backup upload is on.
//
// Someone looking to improve this code could try throwing an error
// here since we don't expect client to be undefined.
return true;
}
const crypto = this.client.getCrypto();
if (!crypto) {
// If there is no crypto, there is no key backup
return false;
}
// If we've already cached the answer, return it.
if (this.cachedKeyBackupStatus !== undefined) {
return this.cachedKeyBackupStatus;
if (this.cachedKeyBackupUploadActive !== undefined) {
return this.cachedKeyBackupUploadActive;
}
// Fetch the answer and cache it
const activeKeyBackupVersion = await this.client.getCrypto()?.getActiveSessionBackupVersion();
this.cachedKeyBackupStatus = !!activeKeyBackupVersion;
const activeKeyBackupVersion = await crypto.getActiveSessionBackupVersion();
this.cachedKeyBackupUploadActive = !!activeKeyBackupVersion;
return this.cachedKeyBackupStatus;
return this.cachedKeyBackupUploadActive;
};
private cachedKeyBackupStatus: boolean | undefined = undefined;
private cachedKeyBackupUploadActive: boolean | undefined = undefined;
private onRecordClientInformationSettingChange: CallbackFn = (
_originalSettingName,

View File

@@ -0,0 +1,80 @@
/*
Copyright 2025 New Vector Ltd.
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 React from "react";
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error";
import { Button } from "@vector-im/compound-web";
import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../../languageHandler";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { EncryptionCard } from "../settings/encryption/EncryptionCard";
import { EncryptionCardButtons } from "../settings/encryption/EncryptionCardButtons";
import { type OpenToTabPayload } from "../../../dispatcher/payloads/OpenToTabPayload";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "./UserTab";
interface Props {
onFinished: (dismissed: boolean) => void;
}
/**
* Ask the user whether they really want to dismiss the toast about key storage.
*
* Launched from the {@link SetupEncryptionToast} in mode `TURN_ON_KEY_STORAGE`,
* when the user clicks "Dismiss". The caller handles any action via the
* `onFinished` prop which takes a boolean that is true if the user clicked
* "Yes, dismiss".
*/
export default class ConfirmKeyStorageOffDialog extends React.Component<Props> {
public constructor(props: Props) {
super(props);
}
private onGoToSettingsClick = (): void => {
// Open Settings at the Encryption tab
const payload: OpenToTabPayload = {
action: Action.ViewUserSettings,
initialTabId: UserTab.Encryption,
};
defaultDispatcher.dispatch(payload);
this.props.onFinished(false);
};
private onDismissClick = (): void => {
this.props.onFinished(true);
};
public render(): React.ReactNode {
return (
<EncryptionCard
Icon={ErrorIcon}
destructive={true}
title={_t("settings|encryption|confirm_key_storage_off")}
>
{_t("settings|encryption|confirm_key_storage_off_description", undefined, {
a: (sub) => (
<>
<br />
<a href="https://element.io/help#encryption5" target="_blank" rel="noreferrer noopener">
{sub} <PopOutIcon />
</a>
</>
),
})}
<EncryptionCardButtons>
<Button onClick={this.onGoToSettingsClick} autoFocus kind="primary" className="">
{_t("common|go_to_settings")}
</Button>
<Button onClick={this.onDismissClick} kind="secondary">
{_t("action|yes_dismiss")}
</Button>
</EncryptionCardButtons>
</EncryptionCard>
);
}
}

View File

@@ -157,6 +157,7 @@
"view_message": "View message",
"view_source": "View Source",
"yes": "Yes",
"yes_dismiss": "Yes, dismiss",
"zoom_in": "Zoom in",
"zoom_out": "Zoom out"
},
@@ -981,6 +982,8 @@
"setup_secure_backup": {
"explainer": "Back up your keys before signing out to avoid losing them."
},
"turn_on_key_storage": "Turn on key storage",
"turn_on_key_storage_description": "Store your cryptographic identity and message keys securely on the server. This will allow you to view your message history on any new devices.",
"udd": {
"interactive_verification_button": "Interactively verify by emoji",
"other_ask_verify_text": "Ask this user to verify their session, or manually verify it below.",
@@ -2559,6 +2562,8 @@
"session_key": "Session key:",
"title": "Advanced"
},
"confirm_key_storage_off": "Are you sure you want to keep key storage turned off?",
"confirm_key_storage_off_description": "If you sign out of all your devices you will lose your message history and will need to verify all your existing contacts again. <a>Learn more</a>",
"delete_key_storage": {
"breadcrumb_page": "Delete key storage",
"confirm": "Delete key storage",

View File

@@ -24,6 +24,7 @@ import { type OpenToTabPayload } from "../dispatcher/payloads/OpenToTabPayload";
import { Action } from "../dispatcher/actions";
import { UserTab } from "../components/views/dialogs/UserTab";
import defaultDispatcher from "../dispatcher/dispatcher";
import ConfirmKeyStorageOffDialog from "../components/views/dialogs/ConfirmKeyStorageOffDialog";
const TOAST_KEY = "setupencryption";
@@ -37,6 +38,8 @@ const getTitle = (kind: Kind): string => {
return _t("encryption|verify_toast_title");
case Kind.KEY_STORAGE_OUT_OF_SYNC:
return _t("encryption|key_storage_out_of_sync");
case Kind.TURN_ON_KEY_STORAGE:
return _t("encryption|turn_on_key_storage");
}
};
@@ -49,6 +52,8 @@ const getIcon = (kind: Kind): string | undefined => {
case Kind.VERIFY_THIS_SESSION:
case Kind.KEY_STORAGE_OUT_OF_SYNC:
return "verification_warning";
case Kind.TURN_ON_KEY_STORAGE:
return "key_storage";
}
};
@@ -62,6 +67,8 @@ const getSetupCaption = (kind: Kind): string => {
return _t("action|verify");
case Kind.KEY_STORAGE_OUT_OF_SYNC:
return _t("encryption|enter_recovery_key");
case Kind.TURN_ON_KEY_STORAGE:
return _t("action|continue");
}
};
@@ -87,6 +94,8 @@ const getSecondaryButtonLabel = (kind: Kind): string => {
return _t("encryption|verification|unverified_sessions_toast_reject");
case Kind.KEY_STORAGE_OUT_OF_SYNC:
return _t("encryption|forgot_recovery_key");
case Kind.TURN_ON_KEY_STORAGE:
return _t("action|dismiss");
}
};
@@ -100,6 +109,8 @@ const getDescription = (kind: Kind): string => {
return _t("encryption|verify_toast_description");
case Kind.KEY_STORAGE_OUT_OF_SYNC:
return _t("encryption|key_storage_out_of_sync_description");
case Kind.TURN_ON_KEY_STORAGE:
return _t("encryption|turn_on_key_storage_description");
}
};
@@ -123,6 +134,10 @@ export enum Kind {
* Prompt the user to enter their recovery key
*/
KEY_STORAGE_OUT_OF_SYNC = "key_storage_out_of_sync",
/**
* Prompt the user to turn on key storage
*/
TURN_ON_KEY_STORAGE = "turn_on_key_storage",
}
/**
@@ -143,6 +158,13 @@ export const showToast = (kind: Kind): void => {
const onPrimaryClick = async (): Promise<void> => {
if (kind === Kind.VERIFY_THIS_SESSION) {
Modal.createDialog(SetupEncryptionDialog, {}, undefined, /* priority = */ false, /* static = */ true);
} else if (kind == Kind.TURN_ON_KEY_STORAGE) {
// Open the user settings dialog to the encryption tab
const payload: OpenToTabPayload = {
action: Action.ViewUserSettings,
initialTabId: UserTab.Encryption,
};
defaultDispatcher.dispatch(payload);
} else {
const modal = Modal.createDialog(
Spinner,
@@ -161,7 +183,7 @@ export const showToast = (kind: Kind): void => {
}
};
const onSecondaryClick = (): void => {
const onSecondaryClick = async (): Promise<void> => {
if (kind === Kind.KEY_STORAGE_OUT_OF_SYNC) {
// Open the user settings dialog to the encryption tab and start the flow to reset encryption
const payload: OpenToTabPayload = {
@@ -170,6 +192,15 @@ export const showToast = (kind: Kind): void => {
props: { initialEncryptionState: "reset_identity_forgot" },
};
defaultDispatcher.dispatch(payload);
} else if (kind === Kind.TURN_ON_KEY_STORAGE) {
// The user clicked "Dismiss": offer them "Are you sure?"
const modal = Modal.createDialog(ConfirmKeyStorageOffDialog, undefined, "mx_ConfirmKeyStorageOffDialog");
const [dismissed] = await modal.finished;
if (dismissed) {
const deviceListener = DeviceListener.sharedInstance();
await deviceListener.recordKeyBackupDisabled();
deviceListener.dismissEncryptionSetup();
}
} else {
DeviceListener.sharedInstance().dismissEncryptionSetup();
}

View File

@@ -24,7 +24,7 @@ import {
} from "matrix-js-sdk/src/crypto-api";
import { type CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange";
import DeviceListener from "../../src/DeviceListener";
import DeviceListener, { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../src/DeviceListener";
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
import * as SetupEncryptionToast from "../../src/toasts/SetupEncryptionToast";
import * as UnverifiedSessionToast from "../../src/toasts/UnverifiedSessionToast";
@@ -118,6 +118,7 @@ describe("DeviceListener", () => {
getDeviceId: jest.fn().mockReturnValue(deviceId),
setAccountData: jest.fn(),
getAccountData: jest.fn(),
getAccountDataFromServer: jest.fn(),
deleteAccountData: jest.fn(),
getCrypto: jest.fn().mockReturnValue(mockCrypto),
secretStorage: {
@@ -309,6 +310,8 @@ describe("DeviceListener", () => {
it("hides setup encryption toast when cross signing and secret storage are ready", async () => {
mockCrypto!.isCrossSigningReady.mockResolvedValue(true);
mockCrypto!.isSecretStorageReady.mockResolvedValue(true);
mockCrypto!.getActiveSessionBackupVersion.mockResolvedValue("1");
await createAndStart();
expect(SetupEncryptionToast.hideToast).toHaveBeenCalled();
});
@@ -377,6 +380,7 @@ describe("DeviceListener", () => {
it("hides the out-of-sync toast when one of the secrets is missing", async () => {
mockCrypto!.isSecretStorageReady.mockResolvedValue(true);
mockCrypto!.getActiveSessionBackupVersion.mockResolvedValue("1");
// First show the toast
mockCrypto!.getCrossSigningStatus.mockResolvedValue({
@@ -414,6 +418,7 @@ describe("DeviceListener", () => {
it("shows set up recovery toast when user has a key backup available", async () => {
// non falsy response
mockCrypto.getKeyBackupInfo.mockResolvedValue({} as unknown as KeyBackupInfo);
mockCrypto.getActiveSessionBackupVersion.mockResolvedValue("1");
mockClient.secretStorage.getDefaultKeyId.mockResolvedValue(null);
await createAndStart();
@@ -444,6 +449,9 @@ describe("DeviceListener", () => {
it("dispatches keybackup event when key backup is not enabled", async () => {
mockCrypto.getActiveSessionBackupVersion.mockResolvedValue(null);
mockClient.getAccountDataFromServer.mockImplementation((eventType) =>
eventType === BACKUP_DISABLED_ACCOUNT_DATA_KEY ? ({ disabled: true } as any) : null,
);
await createAndStart();
expect(mockDispatcher.dispatch).toHaveBeenCalledWith({
action: Action.ReportKeyBackupNotEnabled,
@@ -463,6 +471,137 @@ describe("DeviceListener", () => {
});
});
it("sets backup_disabled account data when we call recordKeyBackupDisabled", async () => {
const instance = await createAndStart();
await instance.recordKeyBackupDisabled();
expect(mockClient.setAccountData).toHaveBeenCalledWith("m.org.matrix.custom.backup_disabled", {
disabled: true,
});
});
describe("when crypto is in use and set up", () => {
beforeEach(() => {
// Encryption is in use
mockClient.getRooms.mockReturnValue([{ roomId: "!room1" }, { roomId: "!room2" }] as unknown as Room[]);
jest.spyOn(mockClient.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true);
// The device is verified
mockCrypto.getDeviceVerificationStatus.mockResolvedValue(
new DeviceVerificationStatus({ crossSigningVerified: true }),
);
});
describe("but key storage is off", () => {
beforeEach(() => {
// There is no active key backup/storage
mockCrypto.getActiveSessionBackupVersion.mockResolvedValue(null);
});
it("shows the 'Turn on key storage' toast if we never explicitly turned off key storage", async () => {
// Given key backup is off but the account data saying we turned it off is not set
// (m.org.matrix.custom.backup_disabled)
mockClient.getAccountData.mockReturnValue(undefined);
// When we launch the DeviceListener
await createAndStart();
// Then the toast is displayed
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(
SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE,
);
});
it("shows the 'Turn on key storage' toast if we turned on key storage", async () => {
// Given key backup is off but the account data says we turned it on (this should not happen - the
// account data should only be updated if we turn on key storage)
mockClient.getAccountData.mockImplementation((eventType) =>
eventType === BACKUP_DISABLED_ACCOUNT_DATA_KEY
? new MatrixEvent({ content: { disabled: false } })
: undefined,
);
// When we launch the DeviceListener
await createAndStart();
// Then the toast is displayed
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(
SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE,
);
});
it("does not show the 'Turn on key storage' toast if we turned off key storage", async () => {
// Given key backup is off but the account data saying we turned it off is set
mockClient.getAccountDataFromServer.mockImplementation((eventType) =>
eventType === BACKUP_DISABLED_ACCOUNT_DATA_KEY ? ({ disabled: true } as any) : null,
);
// When we launch the DeviceListener
await createAndStart();
// Then the toast is not displayed
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith(
SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE,
);
});
});
describe("and key storage is on", () => {
beforeEach(() => {
// There is an active key backup/storage
mockCrypto.getActiveSessionBackupVersion.mockResolvedValue("1");
});
it("does not show the 'Turn on key storage' toast if we never explicitly turned off key storage", async () => {
// Given key backup is on and the account data saying we turned it off is not set
mockClient.getAccountData.mockReturnValue(undefined);
// When we launch the DeviceListener
await createAndStart();
// Then the toast is not displayed
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith(
SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE,
);
});
it("does not show the 'Turn on key storage' toast if we turned on key storage", async () => {
// Given key backup is on and the account data says we turned it on
mockClient.getAccountData.mockImplementation((eventType) =>
eventType === BACKUP_DISABLED_ACCOUNT_DATA_KEY
? new MatrixEvent({ content: { disabled: false } })
: undefined,
);
// When we launch the DeviceListener
await createAndStart();
// Then the toast is not displayed
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith(
SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE,
);
});
it("does not show the 'Turn on key storage' toast if we turned off key storage", async () => {
// Given key backup is on but the account data saying we turned it off is set (this should never
// happen - it should only be set when we turn off key storage or dismiss the toast)
mockClient.getAccountData.mockImplementation((eventType) =>
eventType === BACKUP_DISABLED_ACCOUNT_DATA_KEY
? new MatrixEvent({ content: { disabled: true } })
: undefined,
);
// When we launch the DeviceListener
await createAndStart();
// Then the toast is not displayed
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith(
SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE,
);
});
});
});
describe("unverified sessions toasts", () => {
const currentDevice = new Device({ deviceId, userId: userId, algorithms: [], keys: new Map() });
const device2 = new Device({ deviceId: "d2", userId: userId, algorithms: [], keys: new Map() });
@@ -997,6 +1136,8 @@ describe("DeviceListener", () => {
});
it("shows the 'set up recovery' toast if user has not set up 4S", async () => {
mockCrypto!.getActiveSessionBackupVersion.mockResolvedValue("1");
await createAndStart();
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(SetupEncryptionToast.Kind.SET_UP_RECOVERY);

View File

@@ -87,5 +87,8 @@ describe("KeyStoragePanelViewModel", () => {
await result.current.setEnabled(false);
expect(mocked(matrixClient.getCrypto()!.disableKeyStorage)).toHaveBeenCalled();
expect(mocked(matrixClient.setAccountData)).toHaveBeenCalledWith("m.org.matrix.custom.backup_disabled", {
disabled: true,
});
});
});

View File

@@ -0,0 +1,40 @@
/*
Copyright 2025 New Vector Ltd.
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 React from "react";
import { render } from "jest-matrix-react";
import ConfirmKeyStorageOffDialog from "../../../../../src/components/views/dialogs/ConfirmKeyStorageOffDialog";
describe("ConfirmKeyStorageOffDialog", () => {
beforeEach(() => {
jest.resetAllMocks();
});
it("renders", () => {
const dialog = render(<ConfirmKeyStorageOffDialog onFinished={jest.fn()} />);
expect(dialog.asFragment()).toMatchSnapshot();
});
it("calls onFinished with dismissed=true if we dismiss", () => {
const onFinished = jest.fn();
const dialog = render(<ConfirmKeyStorageOffDialog onFinished={onFinished} />);
dialog.getByRole("button", { name: "Yes, dismiss" }).click();
expect(onFinished).toHaveBeenCalledWith(true);
});
it("calls onFinished with dismissed=true if we continue", () => {
const onFinished = jest.fn();
const dialog = render(<ConfirmKeyStorageOffDialog onFinished={onFinished} />);
dialog.getByRole("button", { name: "Go to Settings" }).click();
expect(onFinished).toHaveBeenCalledWith(false);
});
});

View File

@@ -0,0 +1,82 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ConfirmKeyStorageOffDialog renders 1`] = `
<DocumentFragment>
<div
class="mx_EncryptionCard"
>
<div
class="mx_EncryptionCard_header"
>
<div
class="_content_o77nw_8 _destructive_o77nw_34"
data-size="large"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17q.424 0 .713-.288A.97.97 0 0 0 13 16a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 15a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 16q0 .424.287.712.288.288.713.288m0-4q.424 0 .713-.287A.97.97 0 0 0 13 12V8a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 7a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 8v4q0 .424.287.713.288.287.713.287m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12t2.325 5.675T12 20"
/>
</svg>
</div>
<h2
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
>
Are you sure you want to keep key storage turned off?
</h2>
</div>
<span>
If you sign out of all your devices you will lose your message history and will need to verify all your existing contacts again.
<br />
<a
href="https://element.io/help#encryption5"
rel="noreferrer noopener"
target="_blank"
>
Learn more
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 3h6a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2"
/>
<path
d="M15 3h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V6.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L17.586 5H15a1 1 0 1 1 0-2"
/>
</svg>
</a>
</span>
<div
class="mx_EncryptionCard_buttons"
>
<button
class="_button_vczzf_8"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
Go to Settings
</button>
<button
class="_button_vczzf_8"
data-kind="secondary"
data-size="lg"
role="button"
tabindex="0"
>
Yes, dismiss
</button>
</div>
</div>
</DocumentFragment>
`;

View File

@@ -14,6 +14,8 @@ import ToastContainer from "../../../src/components/structures/ToastContainer";
import { Kind, showToast } from "../../../src/toasts/SetupEncryptionToast";
import dis from "../../../src/dispatcher/dispatcher";
import DeviceListener from "../../../src/DeviceListener";
import Modal from "../../../src/Modal";
import ConfirmKeyStorageOffDialog from "../../../src/components/views/dialogs/ConfirmKeyStorageOffDialog";
jest.mock("../../../src/dispatcher/dispatcher", () => ({
dispatch: jest.fn(),
@@ -83,4 +85,55 @@ describe("SetupEncryptionToast", () => {
});
});
});
describe("Turn on key storage", () => {
it("should render the toast", async () => {
showToast(Kind.TURN_ON_KEY_STORAGE);
await expect(screen.findByText("Turn on key storage")).resolves.toBeInTheDocument();
await expect(screen.findByRole("button", { name: "Dismiss" })).resolves.toBeInTheDocument();
await expect(screen.findByRole("button", { name: "Continue" })).resolves.toBeInTheDocument();
});
it("should open settings to the Encryption tab when 'Continue' clicked", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "recordKeyBackupDisabled");
showToast(Kind.TURN_ON_KEY_STORAGE);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Continue" }));
expect(dis.dispatch).toHaveBeenCalledWith({
action: "view_user_settings",
initialTabId: "USER_ENCRYPTION_TAB",
});
expect(DeviceListener.sharedInstance().recordKeyBackupDisabled).not.toHaveBeenCalled();
});
it("should open the confirm key storage off dialog when 'Dismiss' clicked", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "recordKeyBackupDisabled");
// Given that as soon as the dialog opens, it closes and says "yes they clicked dismiss"
jest.spyOn(Modal, "createDialog").mockImplementation(() => {
return { finished: Promise.resolve([true]) } as any;
});
// When we show the toast, and click Dismiss
showToast(Kind.TURN_ON_KEY_STORAGE);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Dismiss" }));
// Then the dialog was opened
expect(Modal.createDialog).toHaveBeenCalledWith(
ConfirmKeyStorageOffDialog,
undefined,
"mx_ConfirmKeyStorageOffDialog",
);
// And the backup was disabled when the dialog's onFinished was called
expect(DeviceListener.sharedInstance().recordKeyBackupDisabled).toHaveBeenCalledTimes(1);
});
});
});