You've already forked element-web
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:
@@ -67,8 +67,9 @@ test.describe("Cryptography", function () {
|
|||||||
// Bob has a second, not cross-signed, device
|
// Bob has a second, not cross-signed, device
|
||||||
const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob);
|
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
|
// Dismiss the toasts nagging us, otherwise they get in the way of clicking the room list
|
||||||
await page.getByRole("button", { name: "Not now" }).click();
|
await page.getByRole("button", { name: "Dismiss" }).click();
|
||||||
|
await page.getByRole("button", { name: "Yes, dismiss" }).click();
|
||||||
|
|
||||||
await bob.sendEvent(testRoomId, null, "m.room.encrypted", {
|
await bob.sendEvent(testRoomId, null, "m.room.encrypted", {
|
||||||
algorithm: "m.megolm.v1.aes-sha2",
|
algorithm: "m.megolm.v1.aes-sha2",
|
||||||
|
@@ -8,7 +8,8 @@
|
|||||||
import { type GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
|
import { type GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
|
||||||
|
|
||||||
import { test, expect } from "../../element-web-test";
|
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", () => {
|
test.describe("Key storage out of sync toast", () => {
|
||||||
let recoveryKey: GeneratedSecretStorageKey;
|
let recoveryKey: GeneratedSecretStorageKey;
|
||||||
@@ -53,3 +54,114 @@ test.describe("Key storage out of sync toast", () => {
|
|||||||
).toBeVisible();
|
).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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@@ -316,6 +316,25 @@ export async function enableKeyBackup(app: ElementAppPage): Promise<string> {
|
|||||||
return recoveryKey;
|
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`).
|
* Go through the "Set up Secure Backup" dialog (aka the `CreateSecretStorageDialog`).
|
||||||
*
|
*
|
||||||
|
@@ -593,6 +593,7 @@ legend {
|
|||||||
.mx_Dialog
|
.mx_Dialog
|
||||||
button:not(
|
button:not(
|
||||||
.mx_EncryptionUserSettingsTab button,
|
.mx_EncryptionUserSettingsTab button,
|
||||||
|
.mx_EncryptionCard button,
|
||||||
.mx_UserProfileSettings button,
|
.mx_UserProfileSettings button,
|
||||||
.mx_ShareDialog button,
|
.mx_ShareDialog button,
|
||||||
.mx_UnpinAllDialog button,
|
.mx_UnpinAllDialog button,
|
||||||
|
@@ -131,6 +131,7 @@
|
|||||||
@import "./views/dialogs/_BugReportDialog.pcss";
|
@import "./views/dialogs/_BugReportDialog.pcss";
|
||||||
@import "./views/dialogs/_ChangelogDialog.pcss";
|
@import "./views/dialogs/_ChangelogDialog.pcss";
|
||||||
@import "./views/dialogs/_CompoundDialog.pcss";
|
@import "./views/dialogs/_CompoundDialog.pcss";
|
||||||
|
@import "./views/dialogs/_ConfirmKeyStorageOffDialog.pcss";
|
||||||
@import "./views/dialogs/_ConfirmSpaceUserActionDialog.pcss";
|
@import "./views/dialogs/_ConfirmSpaceUserActionDialog.pcss";
|
||||||
@import "./views/dialogs/_ConfirmUserActionDialog.pcss";
|
@import "./views/dialogs/_ConfirmUserActionDialog.pcss";
|
||||||
@import "./views/dialogs/_CreateRoomDialog.pcss";
|
@import "./views/dialogs/_CreateRoomDialog.pcss";
|
||||||
|
@@ -79,6 +79,11 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
background-color: $primary-content;
|
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 {
|
&.mx_Toast_icon_labs::after {
|
||||||
mask-image: url("$(res)/img/element-icons/flask.svg");
|
mask-image: url("$(res)/img/element-icons/flask.svg");
|
||||||
background-color: $secondary-content;
|
background-color: $secondary-content;
|
||||||
|
16
res/css/views/dialogs/_ConfirmKeyStorageOffDialog.pcss
Normal file
16
res/css/views/dialogs/_ConfirmKeyStorageOffDialog.pcss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@@ -97,6 +97,7 @@ export default class DeviceListener {
|
|||||||
this.client.on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
|
this.client.on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
|
||||||
this.client.on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
|
this.client.on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
|
||||||
this.client.on(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged);
|
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.AccountData, this.onAccountData);
|
||||||
this.client.on(ClientEvent.Sync, this.onSync);
|
this.client.on(ClientEvent.Sync, this.onSync);
|
||||||
this.client.on(RoomStateEvent.Events, this.onRoomStateEvents);
|
this.client.on(RoomStateEvent.Events, this.onRoomStateEvents);
|
||||||
@@ -132,7 +133,7 @@ export default class DeviceListener {
|
|||||||
this.dismissedThisDeviceToast = false;
|
this.dismissedThisDeviceToast = false;
|
||||||
this.keyBackupInfo = null;
|
this.keyBackupInfo = null;
|
||||||
this.keyBackupFetchedAt = null;
|
this.keyBackupFetchedAt = null;
|
||||||
this.cachedKeyBackupStatus = undefined;
|
this.cachedKeyBackupUploadActive = undefined;
|
||||||
this.ourDeviceIdsAtStart = null;
|
this.ourDeviceIdsAtStart = null;
|
||||||
this.displayingToastsForDeviceIds = new Set();
|
this.displayingToastsForDeviceIds = new Set();
|
||||||
this.client = undefined;
|
this.client = undefined;
|
||||||
@@ -157,6 +158,13 @@ export default class DeviceListener {
|
|||||||
this.recheck();
|
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> {
|
private async ensureDeviceIdsAtStartPopulated(): Promise<void> {
|
||||||
if (this.ourDeviceIdsAtStart === null) {
|
if (this.ourDeviceIdsAtStart === null) {
|
||||||
this.ourDeviceIdsAtStart = await this.getDeviceIds();
|
this.ourDeviceIdsAtStart = await this.getDeviceIds();
|
||||||
@@ -192,6 +200,11 @@ export default class DeviceListener {
|
|||||||
this.recheck();
|
this.recheck();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private onKeyBackupStatusChanged = (): void => {
|
||||||
|
this.cachedKeyBackupUploadActive = undefined;
|
||||||
|
this.recheck();
|
||||||
|
};
|
||||||
|
|
||||||
private onCrossSingingKeysChanged = (): void => {
|
private onCrossSingingKeysChanged = (): void => {
|
||||||
this.recheck();
|
this.recheck();
|
||||||
};
|
};
|
||||||
@@ -201,11 +214,13 @@ export default class DeviceListener {
|
|||||||
// * migrated SSSS to symmetric
|
// * migrated SSSS to symmetric
|
||||||
// * uploaded keys to secret storage
|
// * uploaded keys to secret storage
|
||||||
// * completed secret storage creation
|
// * completed secret storage creation
|
||||||
|
// * disabled key backup
|
||||||
// which result in account data changes affecting checks below.
|
// which result in account data changes affecting checks below.
|
||||||
if (
|
if (
|
||||||
ev.getType().startsWith("m.secret_storage.") ||
|
ev.getType().startsWith("m.secret_storage.") ||
|
||||||
ev.getType().startsWith("m.cross_signing.") ||
|
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();
|
this.recheck();
|
||||||
}
|
}
|
||||||
@@ -324,7 +339,16 @@ export default class DeviceListener {
|
|||||||
(await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified,
|
(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);
|
await this.reportCryptoSessionStateToAnalytics(cli);
|
||||||
|
|
||||||
if (this.dismissedThisDeviceToast || allSystemsReady) {
|
if (this.dismissedThisDeviceToast || allSystemsReady) {
|
||||||
@@ -353,14 +377,19 @@ export default class DeviceListener {
|
|||||||
crossSigningStatus.privateKeysCachedLocally,
|
crossSigningStatus.privateKeysCachedLocally,
|
||||||
);
|
);
|
||||||
showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC);
|
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) {
|
} 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)
|
// The user just hasn't set up 4S yet: if they have key
|
||||||
const disabledEvent = cli.getAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY);
|
// backup, prompt them to turn on recovery too. (If not, they
|
||||||
if (!disabledEvent?.getContent().disabled) {
|
// have explicitly opted out, so don't hassle them.)
|
||||||
|
if (keyBackupUploadActive) {
|
||||||
logSpan.info("No default 4S key: showing SET_UP_RECOVERY toast");
|
logSpan.info("No default 4S key: showing SET_UP_RECOVERY toast");
|
||||||
showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY);
|
showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY);
|
||||||
} else {
|
} else {
|
||||||
logSpan.info("No default 4S key but backup disabled: no toast needed");
|
logSpan.info("No default 4S key but backup disabled: no toast needed");
|
||||||
|
hideSetupEncryptionToast();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// some other condition... yikes! Show the 'set up encryption' toast: this is what we previously did
|
// 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;
|
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.
|
* 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).
|
* 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).
|
* trigger an auto-rageshake).
|
||||||
*/
|
*/
|
||||||
private checkKeyBackupStatus = async (): Promise<void> => {
|
private checkKeyBackupStatus = async (): Promise<void> => {
|
||||||
if (!(await this.getKeyBackupStatus())) {
|
if (!(await this.isKeyBackupUploadActive())) {
|
||||||
dis.dispatch({ action: Action.ReportKeyBackupNotEnabled });
|
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.
|
* 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) {
|
if (!this.client) {
|
||||||
// To preserve existing behaviour, if there is no client, we
|
// 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
|
// Someone looking to improve this code could try throwing an error
|
||||||
// here since we don't expect client to be undefined.
|
// here since we don't expect client to be undefined.
|
||||||
return true;
|
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 we've already cached the answer, return it.
|
||||||
if (this.cachedKeyBackupStatus !== undefined) {
|
if (this.cachedKeyBackupUploadActive !== undefined) {
|
||||||
return this.cachedKeyBackupStatus;
|
return this.cachedKeyBackupUploadActive;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the answer and cache it
|
// Fetch the answer and cache it
|
||||||
const activeKeyBackupVersion = await this.client.getCrypto()?.getActiveSessionBackupVersion();
|
const activeKeyBackupVersion = await crypto.getActiveSessionBackupVersion();
|
||||||
this.cachedKeyBackupStatus = !!activeKeyBackupVersion;
|
this.cachedKeyBackupUploadActive = !!activeKeyBackupVersion;
|
||||||
|
|
||||||
return this.cachedKeyBackupStatus;
|
return this.cachedKeyBackupUploadActive;
|
||||||
};
|
};
|
||||||
private cachedKeyBackupStatus: boolean | undefined = undefined;
|
private cachedKeyBackupUploadActive: boolean | undefined = undefined;
|
||||||
|
|
||||||
private onRecordClientInformationSettingChange: CallbackFn = (
|
private onRecordClientInformationSettingChange: CallbackFn = (
|
||||||
_originalSettingName,
|
_originalSettingName,
|
||||||
|
80
src/components/views/dialogs/ConfirmKeyStorageOffDialog.tsx
Normal file
80
src/components/views/dialogs/ConfirmKeyStorageOffDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -157,6 +157,7 @@
|
|||||||
"view_message": "View message",
|
"view_message": "View message",
|
||||||
"view_source": "View Source",
|
"view_source": "View Source",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
|
"yes_dismiss": "Yes, dismiss",
|
||||||
"zoom_in": "Zoom in",
|
"zoom_in": "Zoom in",
|
||||||
"zoom_out": "Zoom out"
|
"zoom_out": "Zoom out"
|
||||||
},
|
},
|
||||||
@@ -981,6 +982,8 @@
|
|||||||
"setup_secure_backup": {
|
"setup_secure_backup": {
|
||||||
"explainer": "Back up your keys before signing out to avoid losing them."
|
"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": {
|
"udd": {
|
||||||
"interactive_verification_button": "Interactively verify by emoji",
|
"interactive_verification_button": "Interactively verify by emoji",
|
||||||
"other_ask_verify_text": "Ask this user to verify their session, or manually verify it below.",
|
"other_ask_verify_text": "Ask this user to verify their session, or manually verify it below.",
|
||||||
@@ -2559,6 +2562,8 @@
|
|||||||
"session_key": "Session key:",
|
"session_key": "Session key:",
|
||||||
"title": "Advanced"
|
"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": {
|
"delete_key_storage": {
|
||||||
"breadcrumb_page": "Delete key storage",
|
"breadcrumb_page": "Delete key storage",
|
||||||
"confirm": "Delete key storage",
|
"confirm": "Delete key storage",
|
||||||
|
@@ -24,6 +24,7 @@ import { type OpenToTabPayload } from "../dispatcher/payloads/OpenToTabPayload";
|
|||||||
import { Action } from "../dispatcher/actions";
|
import { Action } from "../dispatcher/actions";
|
||||||
import { UserTab } from "../components/views/dialogs/UserTab";
|
import { UserTab } from "../components/views/dialogs/UserTab";
|
||||||
import defaultDispatcher from "../dispatcher/dispatcher";
|
import defaultDispatcher from "../dispatcher/dispatcher";
|
||||||
|
import ConfirmKeyStorageOffDialog from "../components/views/dialogs/ConfirmKeyStorageOffDialog";
|
||||||
|
|
||||||
const TOAST_KEY = "setupencryption";
|
const TOAST_KEY = "setupencryption";
|
||||||
|
|
||||||
@@ -37,6 +38,8 @@ const getTitle = (kind: Kind): string => {
|
|||||||
return _t("encryption|verify_toast_title");
|
return _t("encryption|verify_toast_title");
|
||||||
case Kind.KEY_STORAGE_OUT_OF_SYNC:
|
case Kind.KEY_STORAGE_OUT_OF_SYNC:
|
||||||
return _t("encryption|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.VERIFY_THIS_SESSION:
|
||||||
case Kind.KEY_STORAGE_OUT_OF_SYNC:
|
case Kind.KEY_STORAGE_OUT_OF_SYNC:
|
||||||
return "verification_warning";
|
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");
|
return _t("action|verify");
|
||||||
case Kind.KEY_STORAGE_OUT_OF_SYNC:
|
case Kind.KEY_STORAGE_OUT_OF_SYNC:
|
||||||
return _t("encryption|enter_recovery_key");
|
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");
|
return _t("encryption|verification|unverified_sessions_toast_reject");
|
||||||
case Kind.KEY_STORAGE_OUT_OF_SYNC:
|
case Kind.KEY_STORAGE_OUT_OF_SYNC:
|
||||||
return _t("encryption|forgot_recovery_key");
|
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");
|
return _t("encryption|verify_toast_description");
|
||||||
case Kind.KEY_STORAGE_OUT_OF_SYNC:
|
case Kind.KEY_STORAGE_OUT_OF_SYNC:
|
||||||
return _t("encryption|key_storage_out_of_sync_description");
|
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
|
* Prompt the user to enter their recovery key
|
||||||
*/
|
*/
|
||||||
KEY_STORAGE_OUT_OF_SYNC = "key_storage_out_of_sync",
|
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> => {
|
const onPrimaryClick = async (): Promise<void> => {
|
||||||
if (kind === Kind.VERIFY_THIS_SESSION) {
|
if (kind === Kind.VERIFY_THIS_SESSION) {
|
||||||
Modal.createDialog(SetupEncryptionDialog, {}, undefined, /* priority = */ false, /* static = */ true);
|
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 {
|
} else {
|
||||||
const modal = Modal.createDialog(
|
const modal = Modal.createDialog(
|
||||||
Spinner,
|
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) {
|
if (kind === Kind.KEY_STORAGE_OUT_OF_SYNC) {
|
||||||
// Open the user settings dialog to the encryption tab and start the flow to reset encryption
|
// Open the user settings dialog to the encryption tab and start the flow to reset encryption
|
||||||
const payload: OpenToTabPayload = {
|
const payload: OpenToTabPayload = {
|
||||||
@@ -170,6 +192,15 @@ export const showToast = (kind: Kind): void => {
|
|||||||
props: { initialEncryptionState: "reset_identity_forgot" },
|
props: { initialEncryptionState: "reset_identity_forgot" },
|
||||||
};
|
};
|
||||||
defaultDispatcher.dispatch(payload);
|
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 {
|
} else {
|
||||||
DeviceListener.sharedInstance().dismissEncryptionSetup();
|
DeviceListener.sharedInstance().dismissEncryptionSetup();
|
||||||
}
|
}
|
||||||
|
@@ -24,7 +24,7 @@ import {
|
|||||||
} from "matrix-js-sdk/src/crypto-api";
|
} from "matrix-js-sdk/src/crypto-api";
|
||||||
import { type CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange";
|
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 { MatrixClientPeg } from "../../src/MatrixClientPeg";
|
||||||
import * as SetupEncryptionToast from "../../src/toasts/SetupEncryptionToast";
|
import * as SetupEncryptionToast from "../../src/toasts/SetupEncryptionToast";
|
||||||
import * as UnverifiedSessionToast from "../../src/toasts/UnverifiedSessionToast";
|
import * as UnverifiedSessionToast from "../../src/toasts/UnverifiedSessionToast";
|
||||||
@@ -118,6 +118,7 @@ describe("DeviceListener", () => {
|
|||||||
getDeviceId: jest.fn().mockReturnValue(deviceId),
|
getDeviceId: jest.fn().mockReturnValue(deviceId),
|
||||||
setAccountData: jest.fn(),
|
setAccountData: jest.fn(),
|
||||||
getAccountData: jest.fn(),
|
getAccountData: jest.fn(),
|
||||||
|
getAccountDataFromServer: jest.fn(),
|
||||||
deleteAccountData: jest.fn(),
|
deleteAccountData: jest.fn(),
|
||||||
getCrypto: jest.fn().mockReturnValue(mockCrypto),
|
getCrypto: jest.fn().mockReturnValue(mockCrypto),
|
||||||
secretStorage: {
|
secretStorage: {
|
||||||
@@ -309,6 +310,8 @@ describe("DeviceListener", () => {
|
|||||||
it("hides setup encryption toast when cross signing and secret storage are ready", async () => {
|
it("hides setup encryption toast when cross signing and secret storage are ready", async () => {
|
||||||
mockCrypto!.isCrossSigningReady.mockResolvedValue(true);
|
mockCrypto!.isCrossSigningReady.mockResolvedValue(true);
|
||||||
mockCrypto!.isSecretStorageReady.mockResolvedValue(true);
|
mockCrypto!.isSecretStorageReady.mockResolvedValue(true);
|
||||||
|
mockCrypto!.getActiveSessionBackupVersion.mockResolvedValue("1");
|
||||||
|
|
||||||
await createAndStart();
|
await createAndStart();
|
||||||
expect(SetupEncryptionToast.hideToast).toHaveBeenCalled();
|
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 () => {
|
it("hides the out-of-sync toast when one of the secrets is missing", async () => {
|
||||||
mockCrypto!.isSecretStorageReady.mockResolvedValue(true);
|
mockCrypto!.isSecretStorageReady.mockResolvedValue(true);
|
||||||
|
mockCrypto!.getActiveSessionBackupVersion.mockResolvedValue("1");
|
||||||
|
|
||||||
// First show the toast
|
// First show the toast
|
||||||
mockCrypto!.getCrossSigningStatus.mockResolvedValue({
|
mockCrypto!.getCrossSigningStatus.mockResolvedValue({
|
||||||
@@ -414,6 +418,7 @@ describe("DeviceListener", () => {
|
|||||||
it("shows set up recovery toast when user has a key backup available", async () => {
|
it("shows set up recovery toast when user has a key backup available", async () => {
|
||||||
// non falsy response
|
// non falsy response
|
||||||
mockCrypto.getKeyBackupInfo.mockResolvedValue({} as unknown as KeyBackupInfo);
|
mockCrypto.getKeyBackupInfo.mockResolvedValue({} as unknown as KeyBackupInfo);
|
||||||
|
mockCrypto.getActiveSessionBackupVersion.mockResolvedValue("1");
|
||||||
mockClient.secretStorage.getDefaultKeyId.mockResolvedValue(null);
|
mockClient.secretStorage.getDefaultKeyId.mockResolvedValue(null);
|
||||||
|
|
||||||
await createAndStart();
|
await createAndStart();
|
||||||
@@ -444,6 +449,9 @@ describe("DeviceListener", () => {
|
|||||||
|
|
||||||
it("dispatches keybackup event when key backup is not enabled", async () => {
|
it("dispatches keybackup event when key backup is not enabled", async () => {
|
||||||
mockCrypto.getActiveSessionBackupVersion.mockResolvedValue(null);
|
mockCrypto.getActiveSessionBackupVersion.mockResolvedValue(null);
|
||||||
|
mockClient.getAccountDataFromServer.mockImplementation((eventType) =>
|
||||||
|
eventType === BACKUP_DISABLED_ACCOUNT_DATA_KEY ? ({ disabled: true } as any) : null,
|
||||||
|
);
|
||||||
await createAndStart();
|
await createAndStart();
|
||||||
expect(mockDispatcher.dispatch).toHaveBeenCalledWith({
|
expect(mockDispatcher.dispatch).toHaveBeenCalledWith({
|
||||||
action: Action.ReportKeyBackupNotEnabled,
|
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", () => {
|
describe("unverified sessions toasts", () => {
|
||||||
const currentDevice = new Device({ deviceId, userId: userId, algorithms: [], keys: new Map() });
|
const currentDevice = new Device({ deviceId, userId: userId, algorithms: [], keys: new Map() });
|
||||||
const device2 = new Device({ deviceId: "d2", 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 () => {
|
it("shows the 'set up recovery' toast if user has not set up 4S", async () => {
|
||||||
|
mockCrypto!.getActiveSessionBackupVersion.mockResolvedValue("1");
|
||||||
|
|
||||||
await createAndStart();
|
await createAndStart();
|
||||||
|
|
||||||
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(SetupEncryptionToast.Kind.SET_UP_RECOVERY);
|
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(SetupEncryptionToast.Kind.SET_UP_RECOVERY);
|
||||||
|
@@ -87,5 +87,8 @@ describe("KeyStoragePanelViewModel", () => {
|
|||||||
await result.current.setEnabled(false);
|
await result.current.setEnabled(false);
|
||||||
|
|
||||||
expect(mocked(matrixClient.getCrypto()!.disableKeyStorage)).toHaveBeenCalled();
|
expect(mocked(matrixClient.getCrypto()!.disableKeyStorage)).toHaveBeenCalled();
|
||||||
|
expect(mocked(matrixClient.setAccountData)).toHaveBeenCalledWith("m.org.matrix.custom.backup_disabled", {
|
||||||
|
disabled: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
@@ -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>
|
||||||
|
`;
|
@@ -14,6 +14,8 @@ import ToastContainer from "../../../src/components/structures/ToastContainer";
|
|||||||
import { Kind, showToast } from "../../../src/toasts/SetupEncryptionToast";
|
import { Kind, showToast } from "../../../src/toasts/SetupEncryptionToast";
|
||||||
import dis from "../../../src/dispatcher/dispatcher";
|
import dis from "../../../src/dispatcher/dispatcher";
|
||||||
import DeviceListener from "../../../src/DeviceListener";
|
import DeviceListener from "../../../src/DeviceListener";
|
||||||
|
import Modal from "../../../src/Modal";
|
||||||
|
import ConfirmKeyStorageOffDialog from "../../../src/components/views/dialogs/ConfirmKeyStorageOffDialog";
|
||||||
|
|
||||||
jest.mock("../../../src/dispatcher/dispatcher", () => ({
|
jest.mock("../../../src/dispatcher/dispatcher", () => ({
|
||||||
dispatch: jest.fn(),
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
Reference in New Issue
Block a user