Add Recovery section in the new user settings Encryption tab (#28673)

* Refine `SettingsSection` & `SettingsTab`

* Add encryption tab

* Add recovery section

* Add device verification

* Rename `Panel` into `State`

* Update & add tests to user settings common

* Add tests to `RecoveryPanel`

* Add tests to `ChangeRecoveryKey`

* Update CreateSecretStorageDialog-test snapshot

* Add tests to `EncryptionUserSettingsTab`

* Update existing screenshots of e2e tests

* Add new encryption tab ownership to `@element-hq/element-crypto-web-reviewers`

* Add e2e tests

* Fix monospace font and add figma link to hardcoded value

* Add unit to Icon

* Improve e2e doc

* Assert that the crypto module is defined

* Add classname doc

* Fix typo

* Use `good` state instead of default

* Rename `ChangeRecoveryKey.isSetupFlow` into `ChangeRecoveryKey.userHasKeyBackup`

* Move `deleteCachedSecrets` fixture in `recovery.spec.ts`

* Use one callback instead of two in `RecoveryPanel`

* Fix docs and naming of `utils.createBot`

* Fix typo in `RecoveryPanel`

* Add more doc to the state of the `EncryptionUserSettingsTab`

* Rename `verification_required` into `set_up_encryption`

* Update test

* ADd new license

* Update comments and doc

* Assert that `recoveryKey.encodedPrivateKey` is always defined

* Add comments to explain how the secrets could be uncached

* Use `matrixClient.secretStorage.getDefaultKeyId` instead of `matrixClient.getCrypto().checkKeyBackupAndEnable` to know if we need to set up a recovery key

* Update existing screenshot to add encryption tab.

* Update tests

* Use new labels when changing the recovery key

* Fix docs

* Don't reset key backup when creating a recovery key

* Fix doc
This commit is contained in:
Florian Duros
2025-01-15 13:44:20 +01:00
committed by GitHub
parent 03a1b48e1f
commit 13913ba8b2
53 changed files with 2931 additions and 49 deletions

18
.github/CODEOWNERS vendored
View File

@@ -3,13 +3,17 @@
/package.json @element-hq/element-web-team
/yarn.lock @element-hq/element-web-team
/src/SecurityManager.ts @element-hq/element-crypto-web-reviewers
/test/SecurityManager-test.ts @element-hq/element-crypto-web-reviewers
/src/async-components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
/src/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
/test/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
/src/stores/SetupEncryptionStore.ts @element-hq/element-crypto-web-reviewers
/test/stores/SetupEncryptionStore-test.ts @element-hq/element-crypto-web-reviewers
/src/SecurityManager.ts @element-hq/element-crypto-web-reviewers
/test/SecurityManager-test.ts @element-hq/element-crypto-web-reviewers
/src/async-components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
/src/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
/test/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
/src/stores/SetupEncryptionStore.ts @element-hq/element-crypto-web-reviewers
/test/stores/SetupEncryptionStore-test.ts @element-hq/element-crypto-web-reviewers
/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @element-hq/element-crypto-web-reviewers
/src/src/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
/test/unit-tests/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
/playwright/e2e/settings/encryption-user-tab/ @element-hq/element-crypto-web-reviewers
# Ignore translations as those will be updated by GHA for Localazy download
/src/i18n/strings

View File

@@ -15,6 +15,7 @@ import {
awaitVerifier,
checkDeviceIsConnectedKeyBackup,
checkDeviceIsCrossSigned,
createBot,
doTwoWaySasVerification,
logIntoElement,
waitForVerificationRequest,
@@ -28,29 +29,9 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
let expectedBackupVersion: string;
test.beforeEach(async ({ page, homeserver, credentials }) => {
// Visit the login page of the app, to load the matrix sdk
await page.goto("/#/login");
// wait for the page to load
await page.waitForSelector(".mx_AuthPage", { timeout: 30000 });
// Create a new device for alice
aliceBotClient = new Bot(page, homeserver, {
bootstrapCrossSigning: true,
bootstrapSecretStorage: true,
});
aliceBotClient.setCredentials(credentials);
// Backup is prepared in the background. Poll until it is ready.
const botClientHandle = await aliceBotClient.prepareClient();
await expect
.poll(async () => {
expectedBackupVersion = await botClientHandle.evaluate((cli) =>
cli.getCrypto()!.getActiveSessionBackupVersion(),
);
return expectedBackupVersion;
})
.not.toBe(null);
const res = await createBot(page, homeserver, credentials);
aliceBotClient = res.botClient;
expectedBackupVersion = res.expectedBackupVersion;
});
// Click the "Verify with another device" button, and have the bot client auto-accept it.

View File

@@ -12,6 +12,7 @@ import type { ICreateRoomOpts, MatrixClient } from "matrix-js-sdk/src/matrix";
import type {
CryptoEvent,
EmojiMapping,
GeneratedSecretStorageKey,
ShowSasCallbacks,
VerificationRequest,
Verifier,
@@ -22,6 +23,46 @@ import { Client } from "../../pages/client";
import { ElementAppPage } from "../../pages/ElementAppPage";
import { Bot } from "../../pages/bot";
/**
* Create a bot client using the supplied credentials, and wait for the key backup to be ready.
* @param page - the playwright `page` fixture
* @param homeserver - the homeserver to use
* @param credentials - the credentials to use for the bot client
*/
export async function createBot(
page: Page,
homeserver: HomeserverInstance,
credentials: Credentials,
): Promise<{ botClient: Bot; recoveryKey: GeneratedSecretStorageKey; expectedBackupVersion: string }> {
// Visit the login page of the app, to load the matrix sdk
await page.goto("/#/login");
// wait for the page to load
await page.waitForSelector(".mx_AuthPage", { timeout: 30000 });
// Create a new bot client
const botClient = new Bot(page, homeserver, {
bootstrapCrossSigning: true,
bootstrapSecretStorage: true,
});
botClient.setCredentials(credentials);
// Backup is prepared in the background. Poll until it is ready.
const botClientHandle = await botClient.prepareClient();
let expectedBackupVersion: string;
await expect
.poll(async () => {
expectedBackupVersion = await botClientHandle.evaluate((cli) =>
cli.getCrypto()!.getActiveSessionBackupVersion(),
);
return expectedBackupVersion;
})
.not.toBe(null);
const recoveryKey = await botClient.getRecoveryKey();
return { botClient, recoveryKey, expectedBackupVersion };
}
/**
* wait for the given client to receive an incoming verification request, and automatically accept it
*

View File

@@ -0,0 +1,98 @@
/*
* Copyright 2024 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 { Page } from "@playwright/test";
import { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
import { ElementAppPage } from "../../../pages/ElementAppPage";
import { test as base, expect } from "../../../element-web-test";
export { expect };
/**
* Set up for the encryption tab test
*/
export const test = base.extend<{
util: Helpers;
}>({
util: async ({ page, app, bot }, use) => {
await use(new Helpers(page, app));
},
});
class Helpers {
constructor(
private page: Page,
private app: ElementAppPage,
) {}
/**
* Open the encryption tab
*/
openEncryptionTab() {
return this.app.settings.openUserSettings("Encryption");
}
/**
* Go through the device verification flow using the recovery key.
*/
async verifyDevice(recoveryKey: GeneratedSecretStorageKey) {
// Select the security phrase
await this.page.getByRole("button", { name: "Verify with Security Key or Phrase" }).click();
await this.enterRecoveryKey(recoveryKey);
await this.page.getByRole("button", { name: "Done" }).click();
}
/**
* Fill the recovery key in the dialog
* @param recoveryKey
*/
async enterRecoveryKey(recoveryKey: GeneratedSecretStorageKey) {
// Select to use recovery key
await this.page.getByRole("button", { name: "use your Security Key" }).click();
// Fill the recovery key
const dialog = this.page.locator(".mx_Dialog");
await dialog.getByRole("textbox").fill(recoveryKey.encodedPrivateKey);
await dialog.getByRole("button", { name: "Continue" }).click();
}
/**
* Get the encryption tab content
*/
getEncryptionTabContent() {
return this.page.getByTestId("encryptionTab");
}
/**
* Set the default key id of the secret storage to `null`
*/
async removeSecretStorageDefaultKeyId() {
const client = await this.app.client.prepareClient();
await client.evaluate(async (client) => {
await client.secretStorage.setDefaultKeyId(null);
});
}
/**
* Get the security key from the clipboard and fill in the input field
* Then click on the finish button
* @param title - The title of the dialog
* @param confirmButtonLabel - The label of the confirm button
* @param screenshot
*/
async confirmRecoveryKey(title: string, confirmButtonLabel: string, screenshot: `${string}.png`) {
const dialog = this.getEncryptionTabContent();
await expect(dialog.getByText(title, { exact: true })).toBeVisible();
await expect(dialog).toMatchScreenshot(screenshot);
const handle = await this.page.evaluateHandle(() => navigator.clipboard.readText());
const clipboardContent = await handle.jsonValue();
await dialog.getByRole("textbox").fill(clipboardContent);
await dialog.getByRole("button", { name: confirmButtonLabel }).click();
await expect(dialog).toMatchScreenshot("default-recovery.png");
}
}

View File

@@ -0,0 +1,178 @@
/*
* Copyright 2024 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 { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
import { Page } from "@playwright/test";
import { test, expect } from ".";
import {
checkDeviceIsConnectedKeyBackup,
checkDeviceIsCrossSigned,
createBot,
verifySession,
} from "../../crypto/utils";
test.describe("Recovery section in Encryption tab", () => {
test.use({
displayName: "Alice",
});
let recoveryKey: GeneratedSecretStorageKey;
let expectedBackupVersion: string;
test.beforeEach(async ({ page, homeserver, credentials }) => {
const res = await createBot(page, homeserver, credentials);
recoveryKey = res.recoveryKey;
expectedBackupVersion = res.expectedBackupVersion;
});
test("should verify the device", { tag: "@screenshot" }, async ({ page, app, util }) => {
const dialog = await util.openEncryptionTab();
// The user's device is in an unverified state, therefore the only option available to them here is to verify it
const verifyButton = dialog.getByRole("button", { name: "Verify this device" });
await expect(verifyButton).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("verify-device-encryption-tab.png");
await verifyButton.click();
await util.verifyDevice(recoveryKey);
await expect(util.getEncryptionTabContent()).toMatchScreenshot("default-recovery.png");
// Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app);
// Check that the current device is connected to key backup
// The backup decryption key should be in cache also, as we got it directly from the 4S
await app.closeDialog();
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true);
});
test(
"should change the recovery key",
{ tag: "@screenshot" },
async ({ page, app, homeserver, credentials, util, context }) => {
await verifySession(app, "new passphrase");
const dialog = await util.openEncryptionTab();
// The user can only change the recovery key
const changeButton = dialog.getByRole("button", { name: "Change recovery key" });
await expect(changeButton).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("default-recovery.png");
await changeButton.click();
// Display the new recovery key and click on the copy button
await expect(dialog.getByText("Change recovery key?")).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("change-key-1-encryption-tab.png", {
mask: [dialog.getByTestId("recoveryKey")],
});
await dialog.getByRole("button", { name: "Copy" }).click();
await dialog.getByRole("button", { name: "Continue" }).click();
// Confirm the recovery key
await util.confirmRecoveryKey(
"Enter your new recovery key",
"Confirm new recovery key",
"change-key-2-encryption-tab.png",
);
},
);
test("should setup the recovery key", { tag: "@screenshot" }, async ({ page, app, util }) => {
await verifySession(app, "new passphrase");
await util.removeSecretStorageDefaultKeyId();
// The key backup is deleted and the user needs to set it up
const dialog = await util.openEncryptionTab();
const setupButton = dialog.getByRole("button", { name: "Set up recovery" });
await expect(setupButton).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("set-up-recovery.png");
await setupButton.click();
// Display an informative panel about the recovery key
await expect(dialog.getByRole("heading", { name: "Set up recovery" })).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("set-up-key-1-encryption-tab.png");
await dialog.getByRole("button", { name: "Continue" }).click();
// Display the new recovery key and click on the copy button
await expect(dialog.getByText("Save your recovery key somewhere safe")).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("set-up-key-2-encryption-tab.png", {
mask: [dialog.getByTestId("recoveryKey")],
});
await dialog.getByRole("button", { name: "Copy" }).click();
await dialog.getByRole("button", { name: "Continue" }).click();
// Confirm the recovery key
await util.confirmRecoveryKey(
"Enter your recovery key to confirm",
"Finish set up",
"set-up-key-3-encryption-tab.png",
);
// The recovery key is now set up and the user can change it
await expect(dialog.getByRole("button", { name: "Change recovery key" })).toBeVisible();
await app.closeDialog();
// Check that the current device is connected to key backup and the backup version is the expected one
await checkDeviceIsConnectedKeyBackup(page, "1", true);
});
// Test what happens if the cross-signing secrets are in secret storage but are not cached in the local DB.
//
// This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
// We simulate this case by deleting the cached secrets in the indexedDB.
test(
"should enter the recovery key when the secrets are not cached",
{ tag: "@screenshot" },
async ({ page, app, util }) => {
await verifySession(app, "new passphrase");
// We need to delete the cached secrets
await deleteCachedSecrets(page);
await util.openEncryptionTab();
// We ask the user to enter the recovery key
const dialog = util.getEncryptionTabContent();
const enterKeyButton = dialog.getByRole("button", { name: "Enter recovery key" });
await expect(enterKeyButton).toBeVisible();
await expect(dialog).toMatchScreenshot("out-of-sync-recovery.png");
await enterKeyButton.click();
// Fill the recovery key
await util.enterRecoveryKey(recoveryKey);
await expect(dialog).toMatchScreenshot("default-recovery.png");
// Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app);
// Check that the current device is connected to key backup
// The backup decryption key should be in cache also, as we got it directly from the 4S
await app.closeDialog();
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true);
},
);
});
/**
* Remove the cached secrets from the indexedDB
* This is a workaround to simulate the case where the secrets are not cached.
*/
async function deleteCachedSecrets(page: Page) {
await page.evaluate(async () => {
const removeCachedSecrets = new Promise((resolve) => {
const request = window.indexedDB.open("matrix-js-sdk::matrix-sdk-crypto");
request.onsuccess = async (event: Event & { target: { result: IDBDatabase } }) => {
const db = event.target.result;
const request = db.transaction("core", "readwrite").objectStore("core").delete("private_identity");
request.onsuccess = () => {
db.close();
resolve(undefined);
};
};
});
await removeCachedSecrets;
});
await page.reload();
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 239 KiB

View File

@@ -596,7 +596,9 @@ legend {
.mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button),
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not(
.mx_EncryptionUserSettingsTab button
),
.mx_Dialog input[type="submit"],
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton),
.mx_Dialog_buttons input[type="submit"] {
@@ -616,8 +618,8 @@ legend {
.mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(
.mx_ShareDialog button
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not(
.mx_EncryptionUserSettingsTab button
):last-child {
margin-right: 0px;
}
@@ -625,7 +627,9 @@ legend {
.mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):focus,
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not(
.mx_EncryptionUserSettingsTab button
):focus,
.mx_Dialog input[type="submit"]:focus,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus,
.mx_Dialog_buttons input[type="submit"]:focus {
@@ -637,7 +641,9 @@ legend {
.mx_Dialog_buttons
button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button),
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not(
.mx_EncryptionUserSettingsTab button
),
.mx_Dialog_buttons input[type="submit"].mx_Dialog_primary {
color: var(--cpd-color-text-on-solid-primary);
background-color: var(--cpd-color-bg-action-primary-rest);
@@ -650,7 +656,7 @@ legend {
.mx_Dialog_buttons
button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button):not(
.mx_ThemeChoicePanel_CustomTheme button
):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button),
):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not(.mx_EncryptionUserSettingsTab button),
.mx_Dialog_buttons input[type="submit"].danger {
background-color: var(--cpd-color-bg-critical-primary);
border: solid 1px var(--cpd-color-bg-critical-primary);
@@ -666,7 +672,9 @@ legend {
.mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):disabled,
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not(
.mx_EncryptionUserSettingsTab button
):disabled,
.mx_Dialog input[type="submit"]:disabled,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled,
.mx_Dialog_buttons input[type="submit"]:disabled {

View File

@@ -347,10 +347,14 @@
@import "./views/settings/_SetIdServer.pcss";
@import "./views/settings/_SetIntegrationManager.pcss";
@import "./views/settings/_SettingsFieldset.pcss";
@import "./views/settings/_SettingsHeader.pcss";
@import "./views/settings/_SettingsSubheader.pcss";
@import "./views/settings/_SpellCheckLanguages.pcss";
@import "./views/settings/_ThemeChoicePanel.pcss";
@import "./views/settings/_UpdateCheckButton.pcss";
@import "./views/settings/_UserProfileSettings.pcss";
@import "./views/settings/encryption/_ChangeRecoveryKey.pcss";
@import "./views/settings/encryption/_EncryptionCard.pcss";
@import "./views/settings/tabs/_SettingsBanner.pcss";
@import "./views/settings/tabs/_SettingsIndent.pcss";
@import "./views/settings/tabs/_SettingsSection.pcss";

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2024 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_SettingsHeader {
display: flex;
align-items: center;
gap: var(--cpd-space-2x);
/* Override margin from common.pcss */
margin: 0;
> span {
font: var(--cpd-font-body-sm-medium);
color: var(--cpd-color-text-action-accent);
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright 2024 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_SettingsSubheader {
display: flex;
flex-direction: column;
gap: var(--cpd-space-2x);
> span {
display: flex;
align-items: center;
gap: var(--cpd-space-2x);
font: var(--cpd-font-body-sm-medium);
}
.mx_SettingsSubheader_success {
color: var(--cpd-color-text-success-primary);
}
.mx_SettingsSubheader_error {
color: var(--cpd-color-text-critical-primary);
}
}

View File

@@ -0,0 +1,79 @@
/*
* Copyright 2024 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_ChangeRecoveryKey {
.mx_InformationPanel_description {
text-align: center;
}
.mx_ChangeRecoveryKey_Form {
display: flex;
flex-direction: column;
gap: var(--cpd-space-8x);
.mx_ChangeRecoveryKey_footer {
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
justify-content: center;
}
}
.mx_KeyPanel {
display: grid;
grid-template:
"header button" auto
"content button" auto / 1fr;
column-gap: var(--cpd-space-3x);
row-gap: var(--cpd-space-1x);
align-items: center;
> span {
grid-area: header;
}
> div {
grid-area: content;
display: flex;
flex-direction: column;
gap: var(--cpd-space-2x);
color: var(--cpd-color-text-secondary);
.mx_KeyPanel_key {
font-family: Inconsolata, monospace;
/*
* From figma https://www.figma.com/design/qTWRfItpO3RdCjnTKPu4mL/Settings?node-id=375-77471&t=t7lozYrSI1AVZZ3U-4
*/
height: 70px;
box-sizing: border-box;
border-radius: var(--cpd-space-2x);
padding: var(--cpd-space-3x) var(--cpd-space-4x);
background-color: var(--cpd-color-bg-subtle-secondary);
}
}
> button {
margin: 0 var(--cpd-space-1x);
grid-area: button;
color: var(--cpd-color-icon-secondary-alpha);
}
}
.mx_KeyForm {
display: flex;
flex-direction: column;
gap: var(--cpd-space-8x);
}
.mx_ChangeRecoveryKey_footer {
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
justify-content: center;
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2024 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_EncryptionCard {
display: flex;
flex-direction: column;
gap: var(--cpd-space-8x);
padding: var(--cpd-space-10x);
border-radius: var(--cpd-space-4x);
/* From figma */
box-shadow: 0 1.2px 2.4px 0 rgba(27, 29, 34, 0.15);
border: 1px solid var(--cpd-color-gray-400);
.mx_EncryptionCard_header {
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
align-items: center;
> h2 {
margin: 0;
}
> span {
color: var(--cpd-color-text-secondary);
text-align: center;
}
}
}

View File

@@ -15,6 +15,20 @@ Please see LICENSE files in the repository root for full details.
a {
color: $links;
}
&.mx_SettingsSection_newUi {
display: flex;
flex-direction: column;
gap: var(--cpd-space-6x);
align-items: start;
}
.mx_SettingsSection_header {
display: flex;
flex-direction: column;
gap: var(--cpd-space-3x);
color: var(--cpd-color-text-secondary);
}
}
.mx_SettingsSection_subSections {

View File

@@ -14,7 +14,7 @@ Please see LICENSE files in the repository root for full details.
color: $links;
}
form {
form:not(.mx_EncryptionUserSettingsTab form) {
display: flex;
flex-direction: column;
gap: $spacing-8;

View File

@@ -15,6 +15,7 @@ import VisibilityOnIcon from "@vector-im/compound-design-tokens/assets/web/icons
import NotificationsIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications";
import PreferencesIcon from "@vector-im/compound-design-tokens/assets/web/icons/preferences";
import KeyboardIcon from "@vector-im/compound-design-tokens/assets/web/icons/keyboard";
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key";
import SidebarIcon from "@vector-im/compound-design-tokens/assets/web/icons/sidebar";
import MicOnIcon from "@vector-im/compound-design-tokens/assets/web/icons/mic-on";
import LockIcon from "@vector-im/compound-design-tokens/assets/web/icons/lock";
@@ -44,6 +45,7 @@ import { NonEmptyArray } from "../../../@types/common";
import { SDKContext, SdkContextClass } from "../../../contexts/SDKContext";
import { useSettingValue } from "../../../hooks/useSettings";
import { ToastContext, useActiveToast } from "../../../contexts/ToastContext";
import { EncryptionUserSettingsTab } from "../settings/tabs/user/EncryptionUserSettingsTab";
interface IProps {
initialTabId?: UserTab;
@@ -75,6 +77,8 @@ function titleForTabID(tabId: UserTab): React.ReactNode {
return _t("settings|voip|dialog_title", undefined, subs);
case UserTab.Security:
return _t("settings|security|dialog_title", undefined, subs);
case UserTab.Encryption:
return _t("settings|encryption|dialog_title", undefined, subs);
case UserTab.Labs:
return _t("settings|labs|dialog_title", undefined, subs);
case UserTab.Mjolnir:
@@ -179,6 +183,10 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
),
);
tabs.push(
new Tab(UserTab.Encryption, _td("settings|encryption|title"), <KeyIcon />, <EncryptionUserSettingsTab />),
);
if (showLabsFlags() || SettingsStore.getFeatureSettingNames().some((k) => SettingsStore.getBetaInfo(k))) {
tabs.push(
new Tab(UserTab.Labs, _td("common|labs"), <LabsIcon />, <LabsUserSettingsTab />, "UserSettingsLabs"),

View File

@@ -15,6 +15,7 @@ export enum UserTab {
Sidebar = "USER_SIDEBAR_TAB",
Voice = "USER_VOICE_TAB",
Security = "USER_SECURITY_TAB",
Encryption = "USER_ENCRYPTION_TAB",
Labs = "USER_LABS_TAB",
Mjolnir = "USER_MJOLNIR_TAB",
Help = "USER_HELP_TAB",

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2024 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, { JSX } from "react";
import { Heading } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
/**
* The heading for a settings section.
*/
interface SettingsHeaderProps {
/**
* Whether the user has a recommended tag.
*/
hasRecommendedTag?: boolean;
/**
* The label for the header.
*/
label: string;
}
export function SettingsHeader({ hasRecommendedTag = false, label }: SettingsHeaderProps): JSX.Element {
return (
<Heading className="mx_SettingsHeader" as="h2" size="sm" weight="semibold">
{label} {hasRecommendedTag && <span>{_t("common|recommended")}</span>}
</Heading>
);
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2024 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, { JSX } from "react";
import CheckCircleIcon from "@vector-im/compound-design-tokens/assets/web/icons/check-circle-solid";
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error";
import classNames from "classnames";
interface SettingsSubheaderProps {
/**
* The subheader text.
*/
label?: string;
/**
* The state of the subheader.
*/
state: "success" | "error";
/**
* The message to display next to the state icon.
*/
stateMessage: string;
}
/**
* A styled subheader for settings.
*/
export function SettingsSubheader({ label, state, stateMessage }: SettingsSubheaderProps): JSX.Element {
return (
<div className="mx_SettingsSubheader">
{label}
<span
className={classNames({
mx_SettingsSubheader_success: state === "success",
mx_SettingsSubheader_error: state === "error",
})}
>
{state === "success" ? (
<CheckCircleIcon width="20px" height="20px" />
) : (
<ErrorIcon width="20px" height="20px" />
)}
{stateMessage}
</span>
</div>
);
}

View File

@@ -0,0 +1,352 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import React, { FormEventHandler, JSX, MouseEventHandler, useState } from "react";
import {
Breadcrumb,
Button,
ErrorMessage,
Field,
IconButton,
Label,
Root,
Text,
TextControl,
} from "@vector-im/compound-web";
import CopyIcon from "@vector-im/compound-design-tokens/assets/web/icons/copy";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../../languageHandler";
import { EncryptionCard } from "./EncryptionCard";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
import { copyPlaintext } from "../../../../utils/strings";
import { withSecretStorageKeyCache } from "../../../../SecurityManager";
/**
* The possible states of the component.
* - `inform_user`: The user is informed about the recovery key.
* - `save_key_setup_flow`: The user is asked to save the new recovery key during the setup flow.
* - `save_key_change_flow`: The user is asked to save the new recovery key during the change key flow.
* - `confirm_key_setup_flow`: The user is asked to confirm the new recovery key during the set up flow.
* - `confirm_key_change_flow`: The user is asked to confirm the new recovery key during the change key flow.
*/
type State =
| "inform_user"
| "save_key_setup_flow"
| "save_key_change_flow"
| "confirm_key_setup_flow"
| "confirm_key_change_flow";
interface ChangeRecoveryKeyProps {
/**
* If true, the component will display the flow to change the recovery key.
* If false,the component will display the flow to set up a new recovery key.
*/
userHasRecoveryKey: boolean;
/**
* Called when the recovery key is successfully changed.
*/
onFinish: () => void;
/**
* Called when the cancel button is clicked or when we go back in the breadcrumbs.
*/
onCancelClick: () => void;
}
/**
* A component to set up or change the recovery key.
*/
export function ChangeRecoveryKey({
userHasRecoveryKey,
onFinish,
onCancelClick,
}: ChangeRecoveryKeyProps): JSX.Element | null {
const matrixClient = useMatrixClientContext();
// If the user is setting up recovery for the first time, we first show them a panel explaining what
// "recovery" is about. Otherwise, we jump straight to showing the user the new key.
const [state, setState] = useState<State>(userHasRecoveryKey ? "save_key_change_flow" : "inform_user");
// We create a new recovery key, the recovery key will be displayed to the user
const recoveryKey = useAsyncMemo(() => matrixClient.getCrypto()!.createRecoveryKeyFromPassphrase(), []);
// Waiting for the recovery key to be generated
if (!recoveryKey) return null;
let content: JSX.Element;
switch (state) {
case "inform_user":
// Show a panel explaining what "recovery" is for, and what a recovery key does.
content = (
<InformationPanel
onContinueClick={() => setState("save_key_setup_flow")}
onCancelClick={onCancelClick}
/>
);
break;
case "save_key_setup_flow":
case "save_key_change_flow":
// Show a generated recovery key and ask the user to save it.
content = (
<KeyPanel
// encodedPrivateKey is always defined, the optional typing is incorrect
recoveryKey={recoveryKey.encodedPrivateKey!}
onConfirmClick={() =>
setState((currentState) =>
currentState === "save_key_change_flow"
? "confirm_key_change_flow"
: "confirm_key_setup_flow",
)
}
onCancelClick={onCancelClick}
/>
);
break;
case "confirm_key_setup_flow":
case "confirm_key_change_flow":
// Ask the user to enter the recovery key they just saved to confirm it.
content = (
<KeyForm
// encodedPrivateKey is always defined, the optional typing is incorrect
recoveryKey={recoveryKey.encodedPrivateKey!}
onCancelClick={onCancelClick}
onSubmit={async () => {
const crypto = matrixClient.getCrypto();
if (!crypto) return onFinish();
try {
// We need to enable the cache to avoid to prompt the user to enter the new key
// when we will try to access the secret storage during the bootstrap
await withSecretStorageKeyCache(() =>
crypto.bootstrapSecretStorage({
setupNewSecretStorage: true,
createSecretStorageKey: async () => recoveryKey,
}),
);
onFinish();
} catch (e) {
logger.error("Failed to bootstrap secret storage", e);
}
}}
submitButtonLabel={
state === "confirm_key_setup_flow"
? _t("settings|encryption|recovery|set_up_recovery_confirm_button")
: _t("settings|encryption|recovery|change_recovery_confirm_button")
}
/>
);
}
const pages = [
_t("settings|encryption|title"),
userHasRecoveryKey
? _t("settings|encryption|recovery|change_recovery_key")
: _t("settings|encryption|recovery|set_up_recovery"),
];
const labels = getLabels(state);
return (
<>
<Breadcrumb
backLabel={_t("action|back")}
onBackClick={onCancelClick}
pages={pages}
onPageClick={onCancelClick}
/>
<EncryptionCard title={labels.title} description={labels.description} className="mx_ChangeRecoveryKey">
{content}
</EncryptionCard>
</>
);
}
type Labels = {
/**
* The title of the card.
*/
title: string;
/**
* The description of the card.
*/
description: string;
};
/**
* Get the header title and description for the given state.
* @param state
*/
function getLabels(state: State): Labels {
switch (state) {
case "inform_user":
return {
title: _t("settings|encryption|recovery|set_up_recovery"),
description: _t("settings|encryption|recovery|set_up_recovery_description", {
changeRecoveryKeyButton: _t("settings|encryption|recovery|change_recovery_key"),
}),
};
case "save_key_setup_flow":
return {
title: _t("settings|encryption|recovery|set_up_recovery_save_key_title"),
description: _t("settings|encryption|recovery|set_up_recovery_save_key_description"),
};
case "save_key_change_flow":
return {
title: _t("settings|encryption|recovery|change_recovery_key_title"),
description: _t("settings|encryption|recovery|change_recovery_key_description"),
};
case "confirm_key_setup_flow":
return {
title: _t("settings|encryption|recovery|set_up_recovery_confirm_title"),
description: _t("settings|encryption|recovery|set_up_recovery_confirm_description"),
};
case "confirm_key_change_flow":
return {
title: _t("settings|encryption|recovery|change_recovery_confirm_title"),
description: _t("settings|encryption|recovery|change_recovery_confirm_description"),
};
}
}
interface InformationPanelProps {
/**
* Called when the continue button is clicked.
*/
onContinueClick: MouseEventHandler<HTMLButtonElement>;
/**
* Called when the cancel button is clicked.
*/
onCancelClick: MouseEventHandler<HTMLButtonElement>;
}
/**
* The panel to display information about the recovery key.
*/
function InformationPanel({ onContinueClick, onCancelClick }: InformationPanelProps): JSX.Element {
return (
<>
<Text as="span" weight="medium" className="mx_InformationPanel_description">
{_t("settings|encryption|recovery|set_up_recovery_secondary_description")}
</Text>
<div className="mx_ChangeRecoveryKey_footer">
<Button onClick={onContinueClick}>{_t("action|continue")}</Button>
<Button kind="tertiary" onClick={onCancelClick}>
{_t("action|cancel")}
</Button>
</div>
</>
);
}
interface KeyPanelProps {
/**
* Called when the confirm button is clicked.
*/
onConfirmClick: MouseEventHandler;
/**
* Called when the cancel button is clicked.
*/
onCancelClick: MouseEventHandler;
/**
* The recovery key to display.
*/
recoveryKey: string;
}
/**
* The panel to display the recovery key.
*/
function KeyPanel({ recoveryKey, onConfirmClick, onCancelClick }: KeyPanelProps): JSX.Element {
return (
<>
<div className="mx_KeyPanel">
<Text as="span" weight="medium">
{_t("settings|encryption|recovery|save_key_title")}
</Text>
<div>
<Text as="span" className="mx_KeyPanel_key" data-testid="recoveryKey">
{recoveryKey}
</Text>
<Text as="span" size="sm">
{_t("settings|encryption|recovery|save_key_description")}
</Text>
</div>
<IconButton aria-label={_t("action|copy")} size="28px" onClick={() => copyPlaintext(recoveryKey)}>
<CopyIcon />
</IconButton>
</div>
<div className="mx_ChangeRecoveryKey_footer">
<Button onClick={onConfirmClick}>{_t("action|continue")}</Button>
<Button kind="tertiary" onClick={onCancelClick}>
{_t("action|cancel")}
</Button>
</div>
</>
);
}
interface KeyFormProps {
/**
* Called when the cancel button is clicked.
*/
onCancelClick: MouseEventHandler;
/**
* Called when the form is submitted.
*/
onSubmit: FormEventHandler;
/**
* The recovery key to confirm.
*/
recoveryKey: string;
/**
* The label for the submit button.
*/
submitButtonLabel: string;
}
/**
* The form to confirm the recovery key.
* The finish button is disabled until the key is filled and valid.
* The entered key is valid if it matches the recovery key.
*/
function KeyForm({ onCancelClick, onSubmit, recoveryKey, submitButtonLabel }: KeyFormProps): JSX.Element {
// Undefined by default, as the key is not filled yet
const [isKeyValid, setIsKeyValid] = useState<boolean>();
const isKeyInvalidAndFilled = isKeyValid === false;
return (
<Root
className="mx_KeyForm"
onSubmit={(evt) => {
evt.preventDefault();
onSubmit(evt);
}}
onChange={async (evt) => {
evt.preventDefault();
evt.stopPropagation();
// We don't have any file in the form, we can cast it as string safely
const filledKey = new FormData(evt.currentTarget).get("recoveryKey") as string | "";
setIsKeyValid(filledKey.trim() === recoveryKey);
}}
>
<Field name="recoveryKey" serverInvalid={isKeyInvalidAndFilled}>
<Label>{_t("settings|encryption|recovery|enter_recovery_key")}</Label>
<TextControl required={true} />
{isKeyInvalidAndFilled && (
<ErrorMessage>{_t("settings|encryption|recovery|enter_key_error")}</ErrorMessage>
)}
</Field>
<div className="mx_ChangeRecoveryKey_footer">
<Button disabled={!isKeyValid}>{submitButtonLabel}</Button>
<Button kind="tertiary" onClick={onCancelClick}>
{_t("action|cancel")}
</Button>
</div>
</Root>
);
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import React, { JSX, PropsWithChildren } from "react";
import { BigIcon, Heading } from "@vector-im/compound-web";
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key-solid";
import classNames from "classnames";
interface EncryptionCardProps {
/**
* CSS class name to apply to the card.
*/
className?: string;
/**
* The title of the card.
*/
title: string;
/**
* The description of the card.
*/
description: string;
}
/**
* A styled card for encryption settings.
*/
export function EncryptionCard({
title,
description,
className,
children,
}: PropsWithChildren<EncryptionCardProps>): JSX.Element {
return (
<div className={classNames("mx_EncryptionCard", className)}>
<div className="mx_EncryptionCard_header">
<BigIcon>
<KeyIcon />
</BigIcon>
<Heading as="h2" size="sm" weight="semibold">
{title}
</Heading>
<span>{description}</span>
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,136 @@
/*
* Copyright 2024 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, { JSX, useCallback, useEffect, useState } from "react";
import { Button, InlineSpinner } from "@vector-im/compound-web";
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key";
import { SettingsSection } from "../shared/SettingsSection";
import { _t } from "../../../../languageHandler";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { SettingsHeader } from "../SettingsHeader";
import { accessSecretStorage } from "../../../../SecurityManager";
import { SettingsSubheader } from "../SettingsSubheader";
/**
* The possible states of the recovery panel.
* - `loading`: We are checking the recovery key and the secrets.
* - `missing_recovery_key`: The user has no recovery key.
* - `secrets_not_cached`: The user has a recovery key but the secrets are not cached.
* This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
* - `good`: The user has a recovery key and the secrets are cached.
*/
type State = "loading" | "missing_recovery_key" | "secrets_not_cached" | "good";
interface RecoveryPanelProps {
/**
* Callback for when the user wants to set up or change their recovery key.
*
* @param setupNewKey - set if the user does not already have a recovery key (and has therefore clicked on
* "Set up recovery" rather than "Change recovery key").
*/
onChangeRecoveryKeyClick: (setupNewKey: boolean) => void;
}
/**
* This component allows the user to set up or change their recovery key.
*/
export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps): JSX.Element {
const [state, setState] = useState<State>("loading");
const isMissingRecoveryKey = state === "missing_recovery_key";
const matrixClient = useMatrixClientContext();
const checkEncryption = useCallback(async () => {
const crypto = matrixClient.getCrypto()!;
// Check if the user has a recovery key
const hasRecoveryKey = Boolean(await matrixClient.secretStorage.getDefaultKeyId());
if (!hasRecoveryKey) return setState("missing_recovery_key");
// Check if the secrets are cached
const cachedSecrets = (await crypto.getCrossSigningStatus()).privateKeysCachedLocally;
const secretsOk = cachedSecrets.masterKey && cachedSecrets.selfSigningKey && cachedSecrets.userSigningKey;
if (!secretsOk) return setState("secrets_not_cached");
setState("good");
}, [matrixClient]);
useEffect(() => {
checkEncryption();
}, [checkEncryption]);
let content: JSX.Element;
switch (state) {
case "loading":
content = <InlineSpinner aria-label={_t("common|loading")} />;
break;
case "missing_recovery_key":
content = (
<Button size="sm" kind="primary" Icon={KeyIcon} onClick={() => onChangeRecoveryKeyClick(true)}>
{_t("settings|encryption|recovery|set_up_recovery")}
</Button>
);
break;
case "secrets_not_cached":
content = (
<Button
size="sm"
kind="primary"
Icon={KeyIcon}
onClick={async () => await accessSecretStorage(checkEncryption)}
>
{_t("settings|encryption|recovery|enter_recovery_key")}
</Button>
);
break;
case "good":
content = (
<Button size="sm" kind="secondary" Icon={KeyIcon} onClick={() => onChangeRecoveryKeyClick(false)}>
{_t("settings|encryption|recovery|change_recovery_key")}
</Button>
);
}
return (
<SettingsSection
legacy={false}
heading={
<SettingsHeader
hasRecommendedTag={isMissingRecoveryKey}
label={_t("settings|encryption|recovery|title")}
/>
}
subHeading={<Subheader state={state} />}
>
{content}
</SettingsSection>
);
}
interface SubheaderProps {
/**
* The state of the recovery panel.
*/
state: State;
}
/**
* The subheader for the recovery panel.
*/
function Subheader({ state }: SubheaderProps): JSX.Element {
// If the secrets are not cached, we display a warning message.
if (state !== "secrets_not_cached") return <>{_t("settings|encryption|recovery|description")}</>;
return (
<SettingsSubheader
label={_t("settings|encryption|recovery|description")}
state="error"
stateMessage={_t("settings|encryption|recovery|key_storage_warning")}
/>
);
}

View File

@@ -10,19 +10,24 @@ import classnames from "classnames";
import React, { HTMLAttributes } from "react";
import Heading from "../../typography/Heading";
import { SettingsHeader } from "../SettingsHeader";
export interface SettingsSectionProps extends HTMLAttributes<HTMLDivElement> {
heading?: string | React.ReactNode;
subHeading?: string | React.ReactNode;
children?: React.ReactNode;
legacy?: boolean;
}
function renderHeading(heading: string | React.ReactNode | undefined): React.ReactNode | undefined {
function renderHeading(heading: string | React.ReactNode | undefined, legacy: boolean): React.ReactNode | undefined {
switch (typeof heading) {
case "string":
return (
return legacy ? (
<Heading as="h2" size="3">
{heading}
</Heading>
) : (
<SettingsHeader label={heading} />
);
case "undefined":
return undefined;
@@ -48,9 +53,29 @@ function renderHeading(heading: string | React.ReactNode | undefined): React.Rea
* </SettingsTab>
* ```
*/
export const SettingsSection: React.FC<SettingsSectionProps> = ({ className, heading, children, ...rest }) => (
<div {...rest} className={classnames("mx_SettingsSection", className)}>
{renderHeading(heading)}
<div className="mx_SettingsSection_subSections">{children}</div>
export const SettingsSection: React.FC<SettingsSectionProps> = ({
className,
heading,
subHeading,
legacy = true,
children,
...rest
}) => (
<div
{...rest}
className={classnames("mx_SettingsSection", className, {
mx_SettingsSection_newUi: !legacy,
})}
>
{heading &&
(subHeading ? (
<div className="mx_SettingsSection_header">
{renderHeading(heading, legacy)}
{subHeading}
</div>
) : (
renderHeading(heading, legacy)
))}
{legacy ? <div className="mx_SettingsSection_subSections">{children}</div> : children}
</div>
);

View File

@@ -6,9 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { HTMLAttributes } from "react";
import classNames from "classnames";
export interface SettingsTabProps extends Omit<HTMLAttributes<HTMLDivElement>, "className"> {
export interface SettingsTabProps extends HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
/**
* Added to the classList of the root element
*/
className?: string;
}
/**
@@ -29,8 +34,8 @@ export interface SettingsTabProps extends Omit<HTMLAttributes<HTMLDivElement>, "
* </SettingsTab>
* ```
*/
const SettingsTab: React.FC<SettingsTabProps> = ({ children, ...rest }) => (
<div {...rest} className="mx_SettingsTab">
const SettingsTab: React.FC<SettingsTabProps> = ({ children, className, ...rest }) => (
<div {...rest} className={classNames("mx_SettingsTab", className)}>
<div className="mx_SettingsTab_sections">{children}</div>
</div>
);

View File

@@ -0,0 +1,140 @@
/*
* Copyright 2024 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, { JSX, useCallback, useEffect, useState } from "react";
import { Button, InlineSpinner } from "@vector-im/compound-web";
import ComputerIcon from "@vector-im/compound-design-tokens/assets/web/icons/computer";
import SettingsTab from "../SettingsTab";
import { RecoveryPanel } from "../../encryption/RecoveryPanel";
import { ChangeRecoveryKey } from "../../encryption/ChangeRecoveryKey";
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
import { _t } from "../../../../../languageHandler";
import Modal from "../../../../../Modal";
import SetupEncryptionDialog from "../../../dialogs/security/SetupEncryptionDialog";
import { SettingsSection } from "../../shared/SettingsSection";
import { SettingsSubheader } from "../../SettingsSubheader";
/**
* The state in the encryption settings tab.
* - "loading": We are checking if the device is verified.
* - "main": The main panel with all the sections (Key storage, recovery, advanced).
* - "set_up_encryption": The panel to show when the user is setting up their encryption.
* This happens when the user doesn't have cross-signing enabled, or their current device is not verified.
* - "change_recovery_key": The panel to show when the user is changing their recovery key.
* This happens when the user has a recovery key and the user clicks on "Change recovery key" button of the RecoveryPanel.
* - "set_recovery_key": The panel to show when the user is setting up their recovery key.
* This happens when the user doesn't have a key a recovery key and the user clicks on "Set up recovery key" button of the RecoveryPanel.
*/
type State = "loading" | "main" | "set_up_encryption" | "change_recovery_key" | "set_recovery_key";
export function EncryptionUserSettingsTab(): JSX.Element {
const [state, setState] = useState<State>("loading");
const setUpEncryptionRequired = useSetUpEncryptionRequired(setState);
let content: JSX.Element;
switch (state) {
case "loading":
content = <InlineSpinner aria-label={_t("common|loading")} />;
break;
case "set_up_encryption":
content = <SetUpEncryptionPanel onFinish={setUpEncryptionRequired} />;
break;
case "main":
content = (
<RecoveryPanel
onChangeRecoveryKeyClick={(setupNewKey) =>
setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key")
}
/>
);
break;
case "change_recovery_key":
case "set_recovery_key":
content = (
<ChangeRecoveryKey
userHasRecoveryKey={state === "change_recovery_key"}
onCancelClick={() => setState("main")}
onFinish={() => setState("main")}
/>
);
break;
}
return (
<SettingsTab className="mx_EncryptionUserSettingsTab" data-testid="encryptionTab">
{content}
</SettingsTab>
);
}
/**
* Hook to check if the user needs to go through the SetupEncryption flow.
* If the user needs to set up the encryption, the state will be set to "set_up_encryption".
* Otherwise, the state will be set to "main".
*
* The state is set once when the component is first mounted.
* Also returns a callback function which can be called to re-run the logic.
*
* @param setState - callback passed from the EncryptionUserSettingsTab to set the current `State`.
* @returns a callback function, which will re-run the logic and update the state.
*/
function useSetUpEncryptionRequired(setState: (state: State) => void): () => Promise<void> {
const matrixClient = useMatrixClientContext();
const setUpEncryptionRequired = useCallback(async () => {
const crypto = matrixClient.getCrypto()!;
const isCrossSigningReady = await crypto.isCrossSigningReady();
if (isCrossSigningReady) setState("main");
else setState("set_up_encryption");
}, [matrixClient, setState]);
// Initialise the state when the component is mounted
useEffect(() => {
setUpEncryptionRequired();
}, [setUpEncryptionRequired]);
// Also return the callback so that the component can re-run the logic.
return setUpEncryptionRequired;
}
interface SetUpEncryptionPanelProps {
/**
* Callback to call when the user has finished setting up encryption.
*/
onFinish: () => void;
}
/**
* Panel to show when the user needs to go through the SetupEncryption flow.
*/
function SetUpEncryptionPanel({ onFinish }: SetUpEncryptionPanelProps): JSX.Element {
// Strictly speaking, the SetupEncryptionDialog may make the user do things other than
// verify their device (in particular, if they manage to get here without cross-signing keys existing);
// however the common case is that they will be asked to verify, so we just show buttons and headings
// that talk about verification.
return (
<SettingsSection
legacy={false}
heading={_t("settings|encryption|device_not_verified_title")}
subHeading={
<SettingsSubheader
stateMessage={_t("settings|encryption|device_not_verified_description")}
state="error"
/>
}
>
<Button
size="sm"
Icon={ComputerIcon}
onClick={() => Modal.createDialog(SetupEncryptionDialog, { onFinished: onFinish })}
>
{_t("settings|encryption|device_not_verified_button")}
</Button>
</SettingsSection>
);
}

View File

@@ -539,6 +539,7 @@
"qr_code": "QR Code",
"random": "Random",
"reactions": "Reactions",
"recommended": "Recommended",
"report_a_bug": "Report a bug",
"room": "Room",
"room_name": "Room name",
@@ -2416,6 +2417,36 @@
"emoji_autocomplete": "Enable Emoji suggestions while typing",
"enable_markdown": "Enable Markdown",
"enable_markdown_description": "Start messages with <code>/plain</code> to send without markdown.",
"encryption": {
"device_not_verified_button": "Verify this device",
"device_not_verified_description": "You need to verify this device in order to view your encryption settings.",
"device_not_verified_title": "Device not verified",
"dialog_title": "<strong>Settings:</strong> Encryption",
"recovery": {
"change_recovery_confirm_button": "Confirm new recovery key",
"change_recovery_confirm_description": "Enter your new recovery key below to finish. Your old one will no longer work.",
"change_recovery_confirm_title": "Enter your new recovery key",
"change_recovery_key": "Change recovery key",
"change_recovery_key_description": "Write down this new recovery key somewhere safe. Then click Continue to confirm the change.",
"change_recovery_key_title": "Change recovery key?",
"description": "Recover your cryptographic identity and message history with a recovery key if youve lost all your existing devices.",
"enter_key_error": "The recovery key you entered is not correct.",
"enter_recovery_key": "Enter recovery key",
"key_storage_warning": "Your key storage is out of sync. Click the button below to fix the problem.",
"save_key_description": "Do not share this with anyone!",
"save_key_title": "Recovery key",
"set_up_recovery": "Set up recovery",
"set_up_recovery_confirm_button": "Finish set up",
"set_up_recovery_confirm_description": "Enter the recovery key shown on the previous screen to finish setting up recovery.",
"set_up_recovery_confirm_title": "Enter your recovery key to confirm",
"set_up_recovery_description": "Your key storage is protected by a recovery key. If you need a new recovery key after setup, you can recreate it by selecting %(changeRecoveryKeyButton)s.",
"set_up_recovery_save_key_description": "Write down this recovery key somewhere safe, like a password manager, encrypted note, or a physical safe.",
"set_up_recovery_save_key_title": "Save your recovery key somewhere safe",
"set_up_recovery_secondary_description": "After clicking continue, well generate a recovery key for you.",
"title": "Recovery"
},
"title": "Encryption"
},
"general": {
"account_management_section": "Account management",
"account_section": "Account",

View File

@@ -105,6 +105,7 @@ export function createTestClient(): MatrixClient {
isStored: jest.fn().mockReturnValue(false),
checkKey: jest.fn().mockResolvedValue(false),
hasKey: jest.fn().mockReturnValue(false),
getDefaultKeyId: jest.fn().mockResolvedValue(null),
},
store: {
@@ -127,7 +128,10 @@ export function createTestClient(): MatrixClient {
bootstrapCrossSigning: jest.fn(),
getActiveSessionBackupVersion: jest.fn().mockResolvedValue(null),
isKeyBackupTrusted: jest.fn().mockResolvedValue({}),
createRecoveryKeyFromPassphrase: jest.fn().mockResolvedValue({}),
createRecoveryKeyFromPassphrase: jest.fn().mockResolvedValue({
privateKey: new Uint8Array(32),
encodedPrivateKey: "encoded private key",
}),
bootstrapSecretStorage: jest.fn(),
isDehydrationSupported: jest.fn().mockResolvedValue(false),
restoreKeyBackup: jest.fn(),
@@ -137,6 +141,16 @@ export function createTestClient(): MatrixClient {
checkKeyBackupAndEnable: jest.fn().mockResolvedValue(null),
getKeyBackupInfo: jest.fn().mockResolvedValue(null),
getEncryptionInfoForEvent: jest.fn().mockResolvedValue(null),
getCrossSigningStatus: jest.fn().mockResolvedValue({
publicKeysOnDevice: false,
privateKeysInSecretStorage: false,
privateKeysCachedLocally: {
masterKey: false,
selfSigningKey: false,
userSigningKey: false,
},
}),
isCrossSigningReady: jest.fn().mockResolvedValue(false),
}),
getPushActionsForEvent: jest.fn(),

View File

@@ -225,6 +225,32 @@ NodeList [
Security & Privacy
</span>
</li>,
<li
aria-controls="mx_tabpanel_USER_ENCRYPTION_TAB"
aria-selected="false"
class="mx_AccessibleButton mx_TabbedView_tabLabel"
data-testid="settings-tab-USER_ENCRYPTION_TAB"
role="tab"
tabindex="-1"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 14c-.55 0-1.02-.196-1.412-.588A1.926 1.926 0 0 1 5 12c0-.55.196-1.02.588-1.412A1.926 1.926 0 0 1 7 10c.55 0 1.02.196 1.412.588.392.391.588.862.588 1.412 0 .55-.196 1.02-.588 1.412A1.926 1.926 0 0 1 7 14Zm0 4c-1.667 0-3.083-.583-4.25-1.75C1.583 15.083 1 13.667 1 12c0-1.667.583-3.083 1.75-4.25C3.917 6.583 5.333 6 7 6c1.117 0 2.13.275 3.037.825A6.212 6.212 0 0 1 12.2 9h8.375a1.033 1.033 0 0 1 .725.3l2 2c.1.1.17.208.212.325.042.117.063.242.063.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-3.175 3.175a.946.946 0 0 1-.3.2c-.117.05-.233.083-.35.1a.832.832 0 0 1-.35-.025.884.884 0 0 1-.325-.175L17.5 15l-1.425 1.075a.945.945 0 0 1-.887.15.859.859 0 0 1-.288-.15L13.375 15H12.2a6.212 6.212 0 0 1-2.162 2.175C9.128 17.725 8.117 18 7 18Zm0-2c.933 0 1.754-.283 2.463-.85A4.032 4.032 0 0 0 10.875 13H14l1.45 1.025L17.5 12.5l1.775 1.375L21.15 12l-1-1h-9.275a4.032 4.032 0 0 0-1.412-2.15C8.754 8.283 7.933 8 7 8c-1.1 0-2.042.392-2.825 1.175C3.392 9.958 3 10.9 3 12s.392 2.042 1.175 2.825C4.958 15.608 5.9 16 7 16Z"
/>
</svg>
<span
class="mx_TabbedView_tabLabel_text"
id="mx_tabpanel_USER_ENCRYPTION_TAB_label"
>
Encryption
</span>
</li>,
<li
aria-controls="mx_tabpanel_USER_LABS_TAB"
aria-selected="false"

View File

@@ -165,7 +165,9 @@ exports[`CreateSecretStorageDialog handles the happy path 2`] = `
<div
class="mx_CreateSecretStorageDialog_recoveryKey"
>
<code />
<code>
encoded private key
</code>
</div>
<div
class="mx_CreateSecretStorageDialog_recoveryKeyButtons"

View File

@@ -0,0 +1,23 @@
/*
* Copyright 2024 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 { SettingsHeader } from "../../../../../src/components/views/settings/SettingsHeader";
describe("<SettingsHeader />", () => {
it("should render the component", () => {
const { asFragment } = render(<SettingsHeader label="Settings Header" />);
expect(asFragment()).toMatchSnapshot();
});
it("should render the component with the recommended tag", () => {
const { asFragment } = render(<SettingsHeader label="Settings Header" hasRecommendedTag={true} />);
expect(asFragment()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,28 @@
/*
* Copyright 2024 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 { SettingsSubheader } from "../../../../../src/components/views/settings/SettingsSubheader";
describe("<SettingsSubheader />", () => {
it("should display a check icon when in success", () => {
const { asFragment } = render(<SettingsSubheader state="success" stateMessage="Success!" />);
expect(asFragment()).toMatchSnapshot();
});
it("should display an error icon when in error", () => {
const { asFragment } = render(<SettingsSubheader state="error" stateMessage="Error!" />);
expect(asFragment()).toMatchSnapshot();
});
it("should display a label", () => {
const { asFragment } = render(<SettingsSubheader label="My label" state="success" stateMessage="Success!" />);
expect(asFragment()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,24 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SettingsHeader /> should render the component 1`] = `
<DocumentFragment>
<h2
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102 mx_SettingsHeader"
>
Settings Header
</h2>
</DocumentFragment>
`;
exports[`<SettingsHeader /> should render the component with the recommended tag 1`] = `
<DocumentFragment>
<h2
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102 mx_SettingsHeader"
>
Settings Header
<span>
Recommended
</span>
</h2>
</DocumentFragment>
`;

View File

@@ -0,0 +1,77 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SettingsSubheader /> should display a check icon when in success 1`] = `
<DocumentFragment>
<div
class="mx_SettingsSubheader"
>
<span
class="mx_SettingsSubheader_success"
>
<svg
fill="currentColor"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m10.6 13.8-2.15-2.15a.948.948 0 0 0-.7-.275.948.948 0 0 0-.7.275.948.948 0 0 0-.275.7.95.95 0 0 0 .275.7L9.9 15.9c.2.2.433.3.7.3.267 0 .5-.1.7-.3l5.65-5.65a.948.948 0 0 0 .275-.7.948.948 0 0 0-.275-.7.948.948 0 0 0-.7-.275.948.948 0 0 0-.7.275L10.6 13.8ZM12 22a9.738 9.738 0 0 1-3.9-.788 10.099 10.099 0 0 1-3.175-2.137c-.9-.9-1.612-1.958-2.137-3.175A9.738 9.738 0 0 1 2 12a9.74 9.74 0 0 1 .788-3.9 10.099 10.099 0 0 1 2.137-3.175c.9-.9 1.958-1.612 3.175-2.137A9.738 9.738 0 0 1 12 2a9.74 9.74 0 0 1 3.9.788 10.098 10.098 0 0 1 3.175 2.137c.9.9 1.613 1.958 2.137 3.175A9.738 9.738 0 0 1 22 12a9.738 9.738 0 0 1-.788 3.9 10.098 10.098 0 0 1-2.137 3.175c-.9.9-1.958 1.613-3.175 2.137A9.738 9.738 0 0 1 12 22Z"
/>
</svg>
Success!
</span>
</div>
</DocumentFragment>
`;
exports[`<SettingsSubheader /> should display a label 1`] = `
<DocumentFragment>
<div
class="mx_SettingsSubheader"
>
My label
<span
class="mx_SettingsSubheader_success"
>
<svg
fill="currentColor"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m10.6 13.8-2.15-2.15a.948.948 0 0 0-.7-.275.948.948 0 0 0-.7.275.948.948 0 0 0-.275.7.95.95 0 0 0 .275.7L9.9 15.9c.2.2.433.3.7.3.267 0 .5-.1.7-.3l5.65-5.65a.948.948 0 0 0 .275-.7.948.948 0 0 0-.275-.7.948.948 0 0 0-.7-.275.948.948 0 0 0-.7.275L10.6 13.8ZM12 22a9.738 9.738 0 0 1-3.9-.788 10.099 10.099 0 0 1-3.175-2.137c-.9-.9-1.612-1.958-2.137-3.175A9.738 9.738 0 0 1 2 12a9.74 9.74 0 0 1 .788-3.9 10.099 10.099 0 0 1 2.137-3.175c.9-.9 1.958-1.612 3.175-2.137A9.738 9.738 0 0 1 12 2a9.74 9.74 0 0 1 3.9.788 10.098 10.098 0 0 1 3.175 2.137c.9.9 1.613 1.958 2.137 3.175A9.738 9.738 0 0 1 22 12a9.738 9.738 0 0 1-.788 3.9 10.098 10.098 0 0 1-2.137 3.175c-.9.9-1.958 1.613-3.175 2.137A9.738 9.738 0 0 1 12 22Z"
/>
</svg>
Success!
</span>
</div>
</DocumentFragment>
`;
exports[`<SettingsSubheader /> should display an error icon when in error 1`] = `
<DocumentFragment>
<div
class="mx_SettingsSubheader"
>
<span
class="mx_SettingsSubheader_error"
>
<svg
fill="currentColor"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17a.97.97 0 0 0 .713-.288A.968.968 0 0 0 13 16a.968.968 0 0 0-.287-.713A.968.968 0 0 0 12 15a.968.968 0 0 0-.713.287A.968.968 0 0 0 11 16c0 .283.096.52.287.712.192.192.43.288.713.288Zm0-4c.283 0 .52-.096.713-.287A.968.968 0 0 0 13 12V8a.967.967 0 0 0-.287-.713A.968.968 0 0 0 12 7a.968.968 0 0 0-.713.287A.967.967 0 0 0 11 8v4c0 .283.096.52.287.713.192.191.43.287.713.287Zm0 9a9.738 9.738 0 0 1-3.9-.788 10.099 10.099 0 0 1-3.175-2.137c-.9-.9-1.612-1.958-2.137-3.175A9.738 9.738 0 0 1 2 12a9.74 9.74 0 0 1 .788-3.9 10.099 10.099 0 0 1 2.137-3.175c.9-.9 1.958-1.612 3.175-2.137A9.738 9.738 0 0 1 12 2a9.74 9.74 0 0 1 3.9.788 10.098 10.098 0 0 1 3.175 2.137c.9.9 1.613 1.958 2.137 3.175A9.738 9.738 0 0 1 22 12a9.738 9.738 0 0 1-.788 3.9 10.098 10.098 0 0 1-2.137 3.175c-.9.9-1.958 1.613-3.175 2.137A9.738 9.738 0 0 1 12 22Z"
/>
</svg>
Error!
</span>
</div>
</DocumentFragment>
`;

View File

@@ -0,0 +1,121 @@
/*
* Copyright 2024 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, screen, waitFor } from "jest-matrix-react";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import userEvent from "@testing-library/user-event";
import { ChangeRecoveryKey } from "../../../../../../src/components/views/settings/encryption/ChangeRecoveryKey";
import { createTestClient, withClientContextRenderOptions } from "../../../../../test-utils";
import { copyPlaintext } from "../../../../../../src/utils/strings";
jest.mock("../../../../../../src/utils/strings", () => ({
copyPlaintext: jest.fn(),
}));
describe("<ChangeRecoveryKey />", () => {
let matrixClient: MatrixClient;
beforeEach(() => {
matrixClient = createTestClient();
});
function renderComponent(userHasRecoveryKey = true, onFinish = jest.fn(), onCancelClick = jest.fn()) {
return render(
<ChangeRecoveryKey
userHasRecoveryKey={userHasRecoveryKey}
onFinish={onFinish}
onCancelClick={onCancelClick}
/>,
withClientContextRenderOptions(matrixClient),
);
}
describe("flow to setup a recovery key", () => {
it("should display information about the recovery key", async () => {
const user = userEvent.setup();
const onCancelClick = jest.fn();
const { asFragment } = renderComponent(false, jest.fn(), onCancelClick);
await waitFor(() =>
expect(
screen.getByText(
"Your key storage is protected by a recovery key. If you need a new recovery key after setup, you can recreate it by selecting Change recovery key.",
),
).toBeInTheDocument(),
);
expect(asFragment()).toMatchSnapshot();
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancelClick).toHaveBeenCalled();
});
it("should display the recovery key", async () => {
const user = userEvent.setup();
const onCancelClick = jest.fn();
const { asFragment } = renderComponent(false, jest.fn(), onCancelClick);
await waitFor(() => user.click(screen.getByRole("button", { name: "Continue" })));
expect(screen.getByText("Save your recovery key somewhere safe")).toBeInTheDocument();
expect(screen.getByText("encoded private key")).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
// Test copy button
await user.click(screen.getByRole("button", { name: "Copy" }));
expect(copyPlaintext).toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancelClick).toHaveBeenCalled();
});
it("should ask the user to enter the recovery key", async () => {
const user = userEvent.setup();
const onFinish = jest.fn();
const { asFragment } = renderComponent(false, onFinish);
// Display the recovery key to save
await waitFor(() => user.click(screen.getByRole("button", { name: "Continue" })));
// Display the form to confirm the recovery key
await waitFor(() => user.click(screen.getByRole("button", { name: "Continue" })));
await waitFor(() => expect(screen.getByText("Enter your recovery key to confirm")).toBeInTheDocument());
expect(asFragment()).toMatchSnapshot();
// The finish button should be disabled by default
const finishButton = screen.getByRole("button", { name: "Finish set up" });
expect(finishButton).toHaveAttribute("aria-disabled", "true");
const input = screen.getByRole("textbox");
// If the user enters an incorrect recovery key, the finish button should be disabled
// and we display an error message
await userEvent.type(input, "wrong recovery key");
expect(finishButton).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText("The recovery key you entered is not correct.")).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
await userEvent.clear(input);
// If the user enters the correct recovery key, the finish button should be enabled
await userEvent.type(input, "encoded private key");
await waitFor(() => expect(finishButton).not.toHaveAttribute("aria-disabled", "true"));
await user.click(finishButton);
expect(onFinish).toHaveBeenCalledWith();
});
});
describe("flow to change the recovery key", () => {
it("should display the recovery key", async () => {
const { asFragment } = renderComponent();
await waitFor(() => expect(screen.getByText("Change recovery key?")).toBeInTheDocument());
expect(screen.getByText("encoded private key")).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2024 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 { EncryptionCard } from "../../../../../../src/components/views/settings/encryption/EncryptionCard";
describe("<EncryptionCard />", () => {
it("should render", () => {
const { asFragment } = render(
<EncryptionCard title="My title" description="My description">
Encryption card children
</EncryptionCard>,
);
expect(asFragment()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,92 @@
/*
* Copyright 2024 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 { MatrixClient } from "matrix-js-sdk/src/matrix";
import { render, screen } from "jest-matrix-react";
import { waitFor } from "@testing-library/dom";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";
import { createTestClient, withClientContextRenderOptions } from "../../../../../test-utils";
import { RecoveryPanel } from "../../../../../../src/components/views/settings/encryption/RecoveryPanel";
import { accessSecretStorage } from "../../../../../../src/SecurityManager";
jest.mock("../../../../../../src/SecurityManager", () => ({
accessSecretStorage: jest.fn(),
}));
describe("<RecoveryPanel />", () => {
let matrixClient: MatrixClient;
beforeEach(() => {
matrixClient = createTestClient();
mocked(accessSecretStorage).mockClear().mockResolvedValue();
});
function renderRecoverPanel(onChangeRecoveryKeyClick = jest.fn()) {
return render(
<RecoveryPanel onChangeRecoveryKeyClick={onChangeRecoveryKeyClick} />,
withClientContextRenderOptions(matrixClient),
);
}
it("should be in loading state when checking the recovery key and the cached keys", () => {
jest.spyOn(matrixClient.secretStorage, "getDefaultKeyId").mockImplementation(() => new Promise(() => {}));
const { asFragment } = renderRecoverPanel();
expect(screen.getByLabelText("Loading…")).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
it("should ask to set up a recovery key when there is no recovery key", async () => {
const user = userEvent.setup();
const onChangeRecoveryKeyClick = jest.fn();
const { asFragment } = renderRecoverPanel(onChangeRecoveryKeyClick);
await waitFor(() => screen.getByRole("button", { name: "Set up recovery" }));
expect(asFragment()).toMatchSnapshot();
await user.click(screen.getByRole("button", { name: "Set up recovery" }));
expect(onChangeRecoveryKeyClick).toHaveBeenCalledWith(true);
});
it("should ask to enter the recovery key when secrets are not cached", async () => {
jest.spyOn(matrixClient.secretStorage, "getDefaultKeyId").mockResolvedValue("default key");
const user = userEvent.setup();
const { asFragment } = renderRecoverPanel();
await waitFor(() => screen.getByRole("button", { name: "Enter recovery key" }));
expect(asFragment()).toMatchSnapshot();
await user.click(screen.getByRole("button", { name: "Enter recovery key" }));
expect(accessSecretStorage).toHaveBeenCalled();
});
it("should allow to change the recovery key when everything is good", async () => {
jest.spyOn(matrixClient.secretStorage, "getDefaultKeyId").mockResolvedValue("default key");
jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({
privateKeysInSecretStorage: true,
publicKeysOnDevice: true,
privateKeysCachedLocally: {
masterKey: true,
selfSigningKey: true,
userSigningKey: true,
},
});
const user = userEvent.setup();
const onChangeRecoveryKeyClick = jest.fn();
const { asFragment } = renderRecoverPanel(onChangeRecoveryKeyClick);
await waitFor(() => screen.getByRole("button", { name: "Change recovery key" }));
expect(asFragment()).toMatchSnapshot();
await user.click(screen.getByRole("button", { name: "Change recovery key" }));
expect(onChangeRecoveryKeyClick).toHaveBeenCalledWith(false);
});
});

View File

@@ -0,0 +1,725 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<ChangeRecoveryKey /> flow to change the recovery key should display the recovery key 1`] = `
<DocumentFragment>
<nav
class="_breadcrumb_ikpbb_17"
>
<button
aria-label="Back"
class="_icon-button_bh2qc_17 _subtle-bg_bh2qc_38"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m13.3 17.3-4.6-4.6a.877.877 0 0 1-.213-.325A1.106 1.106 0 0 1 8.425 12c0-.133.02-.258.062-.375A.878.878 0 0 1 8.7 11.3l4.6-4.6a.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275.948.948 0 0 1 .275.7.948.948 0 0 1-.275.7L10.8 12l3.9 3.9a.949.949 0 0 1 .275.7.948.948 0 0 1-.275.7.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
/>
</svg>
</div>
</button>
<ol
class="_pages_ikpbb_26"
>
<li>
<a
class="_link_ue21z_17"
data-kind="primary"
data-size="small"
rel="noreferrer noopener"
role="button"
tabindex="0"
>
Encryption
</a>
</li>
<li>
<span
aria-current="page"
class="_last-page_ikpbb_39"
>
Change recovery key
</span>
</li>
</ol>
</nav>
<div
class="mx_EncryptionCard mx_ChangeRecoveryKey"
>
<div
class="mx_EncryptionCard_header"
>
<div
class="_content_md016_17"
data-size="large"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.475 16.9A5.863 5.863 0 0 1 7 18c-1.667 0-3.083-.583-4.25-1.75C1.583 15.083 1 13.667 1 12c0-1.667.583-3.083 1.75-4.25C3.917 6.583 5.333 6 7 6c1.35 0 2.53.383 3.537 1.15 1.009.767 1.713 1.717 2.113 2.85h7.95a1.033 1.033 0 0 1 .725.3l1.025 1.025a.99.99 0 0 1 .2.288c.05.108.075.229.075.362a1.066 1.066 0 0 1-.25.7l-2.25 2.575a.973.973 0 0 1-1.038.313 1.033 1.033 0 0 1-.337-.188L17 14l-1.3 1.3c-.1.1-.208.17-.325.212a1.106 1.106 0 0 1-.375.063c-.133 0-.258-.02-.375-.063a.877.877 0 0 1-.325-.212L13 14h-.35a5.81 5.81 0 0 1-2.175 2.9Zm-4.887-3.487c.391.39.862.587 1.412.587.55 0 1.02-.196 1.412-.588C8.804 13.021 9 12.55 9 12c0-.55-.196-1.02-.588-1.412A1.926 1.926 0 0 0 7 10c-.55 0-1.02.196-1.412.588A1.926 1.926 0 0 0 5 12c0 .55.196 1.02.588 1.412Z"
/>
</svg>
</div>
<h2
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
>
Change recovery key?
</h2>
<span>
Write down this new recovery key somewhere safe. Then click Continue to confirm the change.
</span>
</div>
<div
class="mx_KeyPanel"
>
<span
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69"
>
Recovery key
</span>
<div>
<span
class="_typography_yh5dq_162 _font-body-md-regular_yh5dq_59 mx_KeyPanel_key"
data-testid="recoveryKey"
>
encoded private key
</span>
<span
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40"
>
Do not share this with anyone!
</span>
</div>
<button
aria-label="Copy"
class="_icon-button_bh2qc_17"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14 5H5v9h1a1 1 0 1 1 0 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1a1 1 0 1 1-2 0V5Z"
/>
<path
d="M8 10a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2v-9Zm2 0v9h9v-9h-9Z"
/>
</svg>
</div>
</button>
</div>
<div
class="mx_ChangeRecoveryKey_footer"
>
<button
class="_button_i91xf_17"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
Continue
</button>
<button
class="_button_i91xf_17"
data-kind="tertiary"
data-size="lg"
role="button"
tabindex="0"
>
Cancel
</button>
</div>
</div>
</DocumentFragment>
`;
exports[`<ChangeRecoveryKey /> flow to setup a recovery key should ask the user to enter the recovery key 1`] = `
<DocumentFragment>
<nav
class="_breadcrumb_ikpbb_17"
>
<button
aria-label="Back"
class="_icon-button_bh2qc_17 _subtle-bg_bh2qc_38"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m13.3 17.3-4.6-4.6a.877.877 0 0 1-.213-.325A1.106 1.106 0 0 1 8.425 12c0-.133.02-.258.062-.375A.878.878 0 0 1 8.7 11.3l4.6-4.6a.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275.948.948 0 0 1 .275.7.948.948 0 0 1-.275.7L10.8 12l3.9 3.9a.949.949 0 0 1 .275.7.948.948 0 0 1-.275.7.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
/>
</svg>
</div>
</button>
<ol
class="_pages_ikpbb_26"
>
<li>
<a
class="_link_ue21z_17"
data-kind="primary"
data-size="small"
rel="noreferrer noopener"
role="button"
tabindex="0"
>
Encryption
</a>
</li>
<li>
<span
aria-current="page"
class="_last-page_ikpbb_39"
>
Set up recovery
</span>
</li>
</ol>
</nav>
<div
class="mx_EncryptionCard mx_ChangeRecoveryKey"
>
<div
class="mx_EncryptionCard_header"
>
<div
class="_content_md016_17"
data-size="large"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.475 16.9A5.863 5.863 0 0 1 7 18c-1.667 0-3.083-.583-4.25-1.75C1.583 15.083 1 13.667 1 12c0-1.667.583-3.083 1.75-4.25C3.917 6.583 5.333 6 7 6c1.35 0 2.53.383 3.537 1.15 1.009.767 1.713 1.717 2.113 2.85h7.95a1.033 1.033 0 0 1 .725.3l1.025 1.025a.99.99 0 0 1 .2.288c.05.108.075.229.075.362a1.066 1.066 0 0 1-.25.7l-2.25 2.575a.973.973 0 0 1-1.038.313 1.033 1.033 0 0 1-.337-.188L17 14l-1.3 1.3c-.1.1-.208.17-.325.212a1.106 1.106 0 0 1-.375.063c-.133 0-.258-.02-.375-.063a.877.877 0 0 1-.325-.212L13 14h-.35a5.81 5.81 0 0 1-2.175 2.9Zm-4.887-3.487c.391.39.862.587 1.412.587.55 0 1.02-.196 1.412-.588C8.804 13.021 9 12.55 9 12c0-.55-.196-1.02-.588-1.412A1.926 1.926 0 0 0 7 10c-.55 0-1.02.196-1.412.588A1.926 1.926 0 0 0 5 12c0 .55.196 1.02.588 1.412Z"
/>
</svg>
</div>
<h2
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
>
Enter your recovery key to confirm
</h2>
<span>
Enter the recovery key shown on the previous screen to finish setting up recovery.
</span>
</div>
<form
class="_root_ssths_24 mx_KeyForm"
>
<div
class="_field_ssths_34"
>
<label
class="_label_ssths_67"
for="radix-:r0:"
>
Enter recovery key
</label>
<input
class="_control_9gon8_18"
id="radix-:r0:"
name="recoveryKey"
required=""
title=""
/>
</div>
<div
class="mx_ChangeRecoveryKey_footer"
>
<button
aria-disabled="true"
class="_button_i91xf_17"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
Finish set up
</button>
<button
class="_button_i91xf_17"
data-kind="tertiary"
data-size="lg"
role="button"
tabindex="0"
>
Cancel
</button>
</div>
</form>
</div>
</DocumentFragment>
`;
exports[`<ChangeRecoveryKey /> flow to setup a recovery key should ask the user to enter the recovery key 2`] = `
<DocumentFragment>
<nav
class="_breadcrumb_ikpbb_17"
>
<button
aria-label="Back"
class="_icon-button_bh2qc_17 _subtle-bg_bh2qc_38"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m13.3 17.3-4.6-4.6a.877.877 0 0 1-.213-.325A1.106 1.106 0 0 1 8.425 12c0-.133.02-.258.062-.375A.878.878 0 0 1 8.7 11.3l4.6-4.6a.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275.948.948 0 0 1 .275.7.948.948 0 0 1-.275.7L10.8 12l3.9 3.9a.949.949 0 0 1 .275.7.948.948 0 0 1-.275.7.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
/>
</svg>
</div>
</button>
<ol
class="_pages_ikpbb_26"
>
<li>
<a
class="_link_ue21z_17"
data-kind="primary"
data-size="small"
rel="noreferrer noopener"
role="button"
tabindex="0"
>
Encryption
</a>
</li>
<li>
<span
aria-current="page"
class="_last-page_ikpbb_39"
>
Set up recovery
</span>
</li>
</ol>
</nav>
<div
class="mx_EncryptionCard mx_ChangeRecoveryKey"
>
<div
class="mx_EncryptionCard_header"
>
<div
class="_content_md016_17"
data-size="large"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.475 16.9A5.863 5.863 0 0 1 7 18c-1.667 0-3.083-.583-4.25-1.75C1.583 15.083 1 13.667 1 12c0-1.667.583-3.083 1.75-4.25C3.917 6.583 5.333 6 7 6c1.35 0 2.53.383 3.537 1.15 1.009.767 1.713 1.717 2.113 2.85h7.95a1.033 1.033 0 0 1 .725.3l1.025 1.025a.99.99 0 0 1 .2.288c.05.108.075.229.075.362a1.066 1.066 0 0 1-.25.7l-2.25 2.575a.973.973 0 0 1-1.038.313 1.033 1.033 0 0 1-.337-.188L17 14l-1.3 1.3c-.1.1-.208.17-.325.212a1.106 1.106 0 0 1-.375.063c-.133 0-.258-.02-.375-.063a.877.877 0 0 1-.325-.212L13 14h-.35a5.81 5.81 0 0 1-2.175 2.9Zm-4.887-3.487c.391.39.862.587 1.412.587.55 0 1.02-.196 1.412-.588C8.804 13.021 9 12.55 9 12c0-.55-.196-1.02-.588-1.412A1.926 1.926 0 0 0 7 10c-.55 0-1.02.196-1.412.588A1.926 1.926 0 0 0 5 12c0 .55.196 1.02.588 1.412Z"
/>
</svg>
</div>
<h2
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
>
Enter your recovery key to confirm
</h2>
<span>
Enter the recovery key shown on the previous screen to finish setting up recovery.
</span>
</div>
<form
class="_root_ssths_24 mx_KeyForm"
>
<div
class="_field_ssths_34"
data-invalid="true"
>
<label
class="_label_ssths_67"
data-invalid="true"
for="radix-:r0:"
>
Enter recovery key
</label>
<input
aria-describedby="radix-:r1:"
aria-invalid="true"
class="_control_9gon8_18"
data-invalid="true"
id="radix-:r0:"
name="recoveryKey"
required=""
title=""
/>
<span
class="_message_ssths_93 _error-message_ssths_103"
id="radix-:r1:"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17a.97.97 0 0 0 .713-.288A.968.968 0 0 0 13 16a.968.968 0 0 0-.287-.713A.968.968 0 0 0 12 15a.968.968 0 0 0-.713.287A.968.968 0 0 0 11 16c0 .283.096.52.287.712.192.192.43.288.713.288Zm0-4c.283 0 .52-.096.713-.287A.968.968 0 0 0 13 12V8a.967.967 0 0 0-.287-.713A.968.968 0 0 0 12 7a.968.968 0 0 0-.713.287A.967.967 0 0 0 11 8v4c0 .283.096.52.287.713.192.191.43.287.713.287Zm0 9a9.738 9.738 0 0 1-3.9-.788 10.099 10.099 0 0 1-3.175-2.137c-.9-.9-1.612-1.958-2.137-3.175A9.738 9.738 0 0 1 2 12a9.74 9.74 0 0 1 .788-3.9 10.099 10.099 0 0 1 2.137-3.175c.9-.9 1.958-1.612 3.175-2.137A9.738 9.738 0 0 1 12 2a9.74 9.74 0 0 1 3.9.788 10.098 10.098 0 0 1 3.175 2.137c.9.9 1.613 1.958 2.137 3.175A9.738 9.738 0 0 1 22 12a9.738 9.738 0 0 1-.788 3.9 10.098 10.098 0 0 1-2.137 3.175c-.9.9-1.958 1.613-3.175 2.137A9.738 9.738 0 0 1 12 22Z"
/>
</svg>
The recovery key you entered is not correct.
</span>
</div>
<div
class="mx_ChangeRecoveryKey_footer"
>
<button
aria-disabled="true"
class="_button_i91xf_17"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
Finish set up
</button>
<button
class="_button_i91xf_17"
data-kind="tertiary"
data-size="lg"
role="button"
tabindex="0"
>
Cancel
</button>
</div>
</form>
</div>
</DocumentFragment>
`;
exports[`<ChangeRecoveryKey /> flow to setup a recovery key should display information about the recovery key 1`] = `
<DocumentFragment>
<nav
class="_breadcrumb_ikpbb_17"
>
<button
aria-label="Back"
class="_icon-button_bh2qc_17 _subtle-bg_bh2qc_38"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m13.3 17.3-4.6-4.6a.877.877 0 0 1-.213-.325A1.106 1.106 0 0 1 8.425 12c0-.133.02-.258.062-.375A.878.878 0 0 1 8.7 11.3l4.6-4.6a.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275.948.948 0 0 1 .275.7.948.948 0 0 1-.275.7L10.8 12l3.9 3.9a.949.949 0 0 1 .275.7.948.948 0 0 1-.275.7.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
/>
</svg>
</div>
</button>
<ol
class="_pages_ikpbb_26"
>
<li>
<a
class="_link_ue21z_17"
data-kind="primary"
data-size="small"
rel="noreferrer noopener"
role="button"
tabindex="0"
>
Encryption
</a>
</li>
<li>
<span
aria-current="page"
class="_last-page_ikpbb_39"
>
Set up recovery
</span>
</li>
</ol>
</nav>
<div
class="mx_EncryptionCard mx_ChangeRecoveryKey"
>
<div
class="mx_EncryptionCard_header"
>
<div
class="_content_md016_17"
data-size="large"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.475 16.9A5.863 5.863 0 0 1 7 18c-1.667 0-3.083-.583-4.25-1.75C1.583 15.083 1 13.667 1 12c0-1.667.583-3.083 1.75-4.25C3.917 6.583 5.333 6 7 6c1.35 0 2.53.383 3.537 1.15 1.009.767 1.713 1.717 2.113 2.85h7.95a1.033 1.033 0 0 1 .725.3l1.025 1.025a.99.99 0 0 1 .2.288c.05.108.075.229.075.362a1.066 1.066 0 0 1-.25.7l-2.25 2.575a.973.973 0 0 1-1.038.313 1.033 1.033 0 0 1-.337-.188L17 14l-1.3 1.3c-.1.1-.208.17-.325.212a1.106 1.106 0 0 1-.375.063c-.133 0-.258-.02-.375-.063a.877.877 0 0 1-.325-.212L13 14h-.35a5.81 5.81 0 0 1-2.175 2.9Zm-4.887-3.487c.391.39.862.587 1.412.587.55 0 1.02-.196 1.412-.588C8.804 13.021 9 12.55 9 12c0-.55-.196-1.02-.588-1.412A1.926 1.926 0 0 0 7 10c-.55 0-1.02.196-1.412.588A1.926 1.926 0 0 0 5 12c0 .55.196 1.02.588 1.412Z"
/>
</svg>
</div>
<h2
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
>
Set up recovery
</h2>
<span>
Your key storage is protected by a recovery key. If you need a new recovery key after setup, you can recreate it by selecting Change recovery key.
</span>
</div>
<span
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 mx_InformationPanel_description"
>
After clicking continue, well generate a recovery key for you.
</span>
<div
class="mx_ChangeRecoveryKey_footer"
>
<button
class="_button_i91xf_17"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
Continue
</button>
<button
class="_button_i91xf_17"
data-kind="tertiary"
data-size="lg"
role="button"
tabindex="0"
>
Cancel
</button>
</div>
</div>
</DocumentFragment>
`;
exports[`<ChangeRecoveryKey /> flow to setup a recovery key should display the recovery key 1`] = `
<DocumentFragment>
<nav
class="_breadcrumb_ikpbb_17"
>
<button
aria-label="Back"
class="_icon-button_bh2qc_17 _subtle-bg_bh2qc_38"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m13.3 17.3-4.6-4.6a.877.877 0 0 1-.213-.325A1.106 1.106 0 0 1 8.425 12c0-.133.02-.258.062-.375A.878.878 0 0 1 8.7 11.3l4.6-4.6a.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275.948.948 0 0 1 .275.7.948.948 0 0 1-.275.7L10.8 12l3.9 3.9a.949.949 0 0 1 .275.7.948.948 0 0 1-.275.7.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
/>
</svg>
</div>
</button>
<ol
class="_pages_ikpbb_26"
>
<li>
<a
class="_link_ue21z_17"
data-kind="primary"
data-size="small"
rel="noreferrer noopener"
role="button"
tabindex="0"
>
Encryption
</a>
</li>
<li>
<span
aria-current="page"
class="_last-page_ikpbb_39"
>
Set up recovery
</span>
</li>
</ol>
</nav>
<div
class="mx_EncryptionCard mx_ChangeRecoveryKey"
>
<div
class="mx_EncryptionCard_header"
>
<div
class="_content_md016_17"
data-size="large"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.475 16.9A5.863 5.863 0 0 1 7 18c-1.667 0-3.083-.583-4.25-1.75C1.583 15.083 1 13.667 1 12c0-1.667.583-3.083 1.75-4.25C3.917 6.583 5.333 6 7 6c1.35 0 2.53.383 3.537 1.15 1.009.767 1.713 1.717 2.113 2.85h7.95a1.033 1.033 0 0 1 .725.3l1.025 1.025a.99.99 0 0 1 .2.288c.05.108.075.229.075.362a1.066 1.066 0 0 1-.25.7l-2.25 2.575a.973.973 0 0 1-1.038.313 1.033 1.033 0 0 1-.337-.188L17 14l-1.3 1.3c-.1.1-.208.17-.325.212a1.106 1.106 0 0 1-.375.063c-.133 0-.258-.02-.375-.063a.877.877 0 0 1-.325-.212L13 14h-.35a5.81 5.81 0 0 1-2.175 2.9Zm-4.887-3.487c.391.39.862.587 1.412.587.55 0 1.02-.196 1.412-.588C8.804 13.021 9 12.55 9 12c0-.55-.196-1.02-.588-1.412A1.926 1.926 0 0 0 7 10c-.55 0-1.02.196-1.412.588A1.926 1.926 0 0 0 5 12c0 .55.196 1.02.588 1.412Z"
/>
</svg>
</div>
<h2
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
>
Save your recovery key somewhere safe
</h2>
<span>
Write down this recovery key somewhere safe, like a password manager, encrypted note, or a physical safe.
</span>
</div>
<div
class="mx_KeyPanel"
>
<span
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69"
>
Recovery key
</span>
<div>
<span
class="_typography_yh5dq_162 _font-body-md-regular_yh5dq_59 mx_KeyPanel_key"
data-testid="recoveryKey"
>
encoded private key
</span>
<span
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40"
>
Do not share this with anyone!
</span>
</div>
<button
aria-label="Copy"
class="_icon-button_bh2qc_17"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14 5H5v9h1a1 1 0 1 1 0 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1a1 1 0 1 1-2 0V5Z"
/>
<path
d="M8 10a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2v-9Zm2 0v9h9v-9h-9Z"
/>
</svg>
</div>
</button>
</div>
<div
class="mx_ChangeRecoveryKey_footer"
>
<button
class="_button_i91xf_17"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
Continue
</button>
<button
class="_button_i91xf_17"
data-kind="tertiary"
data-size="lg"
role="button"
tabindex="0"
>
Cancel
</button>
</div>
</div>
</DocumentFragment>
`;

View File

@@ -0,0 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<EncryptionCard /> should render 1`] = `
<DocumentFragment>
<div
class="mx_EncryptionCard"
>
<div
class="mx_EncryptionCard_header"
>
<div
class="_content_md016_17"
data-size="large"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.475 16.9A5.863 5.863 0 0 1 7 18c-1.667 0-3.083-.583-4.25-1.75C1.583 15.083 1 13.667 1 12c0-1.667.583-3.083 1.75-4.25C3.917 6.583 5.333 6 7 6c1.35 0 2.53.383 3.537 1.15 1.009.767 1.713 1.717 2.113 2.85h7.95a1.033 1.033 0 0 1 .725.3l1.025 1.025a.99.99 0 0 1 .2.288c.05.108.075.229.075.362a1.066 1.066 0 0 1-.25.7l-2.25 2.575a.973.973 0 0 1-1.038.313 1.033 1.033 0 0 1-.337-.188L17 14l-1.3 1.3c-.1.1-.208.17-.325.212a1.106 1.106 0 0 1-.375.063c-.133 0-.258-.02-.375-.063a.877.877 0 0 1-.325-.212L13 14h-.35a5.81 5.81 0 0 1-2.175 2.9Zm-4.887-3.487c.391.39.862.587 1.412.587.55 0 1.02-.196 1.412-.588C8.804 13.021 9 12.55 9 12c0-.55-.196-1.02-.588-1.412A1.926 1.926 0 0 0 7 10c-.55 0-1.02.196-1.412.588A1.926 1.926 0 0 0 5 12c0 .55.196 1.02.588 1.412Z"
/>
</svg>
</div>
<h2
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
>
My title
</h2>
<span>
My description
</span>
</div>
Encryption card children
</div>
</DocumentFragment>
`;

View File

@@ -0,0 +1,179 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<RecoveryPanel /> should allow to change the recovery key when everything is good 1`] = `
<DocumentFragment>
<div
class="mx_SettingsSection mx_SettingsSection_newUi"
>
<div
class="mx_SettingsSection_header"
>
<h2
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102 mx_SettingsHeader"
>
Recovery
</h2>
Recover your cryptographic identity and message history with a recovery key if youve lost all your existing devices.
</div>
<button
class="_button_i91xf_17 _has-icon_i91xf_66"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 14c-.55 0-1.02-.196-1.412-.588A1.926 1.926 0 0 1 5 12c0-.55.196-1.02.588-1.412A1.926 1.926 0 0 1 7 10c.55 0 1.02.196 1.412.588.392.391.588.862.588 1.412 0 .55-.196 1.02-.588 1.412A1.926 1.926 0 0 1 7 14Zm0 4c-1.667 0-3.083-.583-4.25-1.75C1.583 15.083 1 13.667 1 12c0-1.667.583-3.083 1.75-4.25C3.917 6.583 5.333 6 7 6c1.117 0 2.13.275 3.037.825A6.212 6.212 0 0 1 12.2 9h8.375a1.033 1.033 0 0 1 .725.3l2 2c.1.1.17.208.212.325.042.117.063.242.063.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-3.175 3.175a.946.946 0 0 1-.3.2c-.117.05-.233.083-.35.1a.832.832 0 0 1-.35-.025.884.884 0 0 1-.325-.175L17.5 15l-1.425 1.075a.945.945 0 0 1-.887.15.859.859 0 0 1-.288-.15L13.375 15H12.2a6.212 6.212 0 0 1-2.162 2.175C9.128 17.725 8.117 18 7 18Zm0-2c.933 0 1.754-.283 2.463-.85A4.032 4.032 0 0 0 10.875 13H14l1.45 1.025L17.5 12.5l1.775 1.375L21.15 12l-1-1h-9.275a4.032 4.032 0 0 0-1.412-2.15C8.754 8.283 7.933 8 7 8c-1.1 0-2.042.392-2.825 1.175C3.392 9.958 3 10.9 3 12s.392 2.042 1.175 2.825C4.958 15.608 5.9 16 7 16Z"
/>
</svg>
Change recovery key
</button>
</div>
</DocumentFragment>
`;
exports[`<RecoveryPanel /> should ask to enter the recovery key when secrets are not cached 1`] = `
<DocumentFragment>
<div
class="mx_SettingsSection mx_SettingsSection_newUi"
>
<div
class="mx_SettingsSection_header"
>
<h2
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102 mx_SettingsHeader"
>
Recovery
</h2>
<div
class="mx_SettingsSubheader"
>
Recover your cryptographic identity and message history with a recovery key if youve lost all your existing devices.
<span
class="mx_SettingsSubheader_error"
>
<svg
fill="currentColor"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17a.97.97 0 0 0 .713-.288A.968.968 0 0 0 13 16a.968.968 0 0 0-.287-.713A.968.968 0 0 0 12 15a.968.968 0 0 0-.713.287A.968.968 0 0 0 11 16c0 .283.096.52.287.712.192.192.43.288.713.288Zm0-4c.283 0 .52-.096.713-.287A.968.968 0 0 0 13 12V8a.967.967 0 0 0-.287-.713A.968.968 0 0 0 12 7a.968.968 0 0 0-.713.287A.967.967 0 0 0 11 8v4c0 .283.096.52.287.713.192.191.43.287.713.287Zm0 9a9.738 9.738 0 0 1-3.9-.788 10.099 10.099 0 0 1-3.175-2.137c-.9-.9-1.612-1.958-2.137-3.175A9.738 9.738 0 0 1 2 12a9.74 9.74 0 0 1 .788-3.9 10.099 10.099 0 0 1 2.137-3.175c.9-.9 1.958-1.612 3.175-2.137A9.738 9.738 0 0 1 12 2a9.74 9.74 0 0 1 3.9.788 10.098 10.098 0 0 1 3.175 2.137c.9.9 1.613 1.958 2.137 3.175A9.738 9.738 0 0 1 22 12a9.738 9.738 0 0 1-.788 3.9 10.098 10.098 0 0 1-2.137 3.175c-.9.9-1.958 1.613-3.175 2.137A9.738 9.738 0 0 1 12 22Z"
/>
</svg>
Your key storage is out of sync. Click the button below to fix the problem.
</span>
</div>
</div>
<button
class="_button_i91xf_17 _has-icon_i91xf_66"
data-kind="primary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 14c-.55 0-1.02-.196-1.412-.588A1.926 1.926 0 0 1 5 12c0-.55.196-1.02.588-1.412A1.926 1.926 0 0 1 7 10c.55 0 1.02.196 1.412.588.392.391.588.862.588 1.412 0 .55-.196 1.02-.588 1.412A1.926 1.926 0 0 1 7 14Zm0 4c-1.667 0-3.083-.583-4.25-1.75C1.583 15.083 1 13.667 1 12c0-1.667.583-3.083 1.75-4.25C3.917 6.583 5.333 6 7 6c1.117 0 2.13.275 3.037.825A6.212 6.212 0 0 1 12.2 9h8.375a1.033 1.033 0 0 1 .725.3l2 2c.1.1.17.208.212.325.042.117.063.242.063.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-3.175 3.175a.946.946 0 0 1-.3.2c-.117.05-.233.083-.35.1a.832.832 0 0 1-.35-.025.884.884 0 0 1-.325-.175L17.5 15l-1.425 1.075a.945.945 0 0 1-.887.15.859.859 0 0 1-.288-.15L13.375 15H12.2a6.212 6.212 0 0 1-2.162 2.175C9.128 17.725 8.117 18 7 18Zm0-2c.933 0 1.754-.283 2.463-.85A4.032 4.032 0 0 0 10.875 13H14l1.45 1.025L17.5 12.5l1.775 1.375L21.15 12l-1-1h-9.275a4.032 4.032 0 0 0-1.412-2.15C8.754 8.283 7.933 8 7 8c-1.1 0-2.042.392-2.825 1.175C3.392 9.958 3 10.9 3 12s.392 2.042 1.175 2.825C4.958 15.608 5.9 16 7 16Z"
/>
</svg>
Enter recovery key
</button>
</div>
</DocumentFragment>
`;
exports[`<RecoveryPanel /> should ask to set up a recovery key when there is no recovery key 1`] = `
<DocumentFragment>
<div
class="mx_SettingsSection mx_SettingsSection_newUi"
>
<div
class="mx_SettingsSection_header"
>
<h2
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102 mx_SettingsHeader"
>
Recovery
<span>
Recommended
</span>
</h2>
Recover your cryptographic identity and message history with a recovery key if youve lost all your existing devices.
</div>
<button
class="_button_i91xf_17 _has-icon_i91xf_66"
data-kind="primary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 14c-.55 0-1.02-.196-1.412-.588A1.926 1.926 0 0 1 5 12c0-.55.196-1.02.588-1.412A1.926 1.926 0 0 1 7 10c.55 0 1.02.196 1.412.588.392.391.588.862.588 1.412 0 .55-.196 1.02-.588 1.412A1.926 1.926 0 0 1 7 14Zm0 4c-1.667 0-3.083-.583-4.25-1.75C1.583 15.083 1 13.667 1 12c0-1.667.583-3.083 1.75-4.25C3.917 6.583 5.333 6 7 6c1.117 0 2.13.275 3.037.825A6.212 6.212 0 0 1 12.2 9h8.375a1.033 1.033 0 0 1 .725.3l2 2c.1.1.17.208.212.325.042.117.063.242.063.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-3.175 3.175a.946.946 0 0 1-.3.2c-.117.05-.233.083-.35.1a.832.832 0 0 1-.35-.025.884.884 0 0 1-.325-.175L17.5 15l-1.425 1.075a.945.945 0 0 1-.887.15.859.859 0 0 1-.288-.15L13.375 15H12.2a6.212 6.212 0 0 1-2.162 2.175C9.128 17.725 8.117 18 7 18Zm0-2c.933 0 1.754-.283 2.463-.85A4.032 4.032 0 0 0 10.875 13H14l1.45 1.025L17.5 12.5l1.775 1.375L21.15 12l-1-1h-9.275a4.032 4.032 0 0 0-1.412-2.15C8.754 8.283 7.933 8 7 8c-1.1 0-2.042.392-2.825 1.175C3.392 9.958 3 10.9 3 12s.392 2.042 1.175 2.825C4.958 15.608 5.9 16 7 16Z"
/>
</svg>
Set up recovery
</button>
</div>
</DocumentFragment>
`;
exports[`<RecoveryPanel /> should be in loading state when checking the recovery key and the cached keys 1`] = `
<DocumentFragment>
<div
class="mx_SettingsSection mx_SettingsSection_newUi"
>
<div
class="mx_SettingsSection_header"
>
<h2
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102 mx_SettingsHeader"
>
Recovery
</h2>
Recover your cryptographic identity and message history with a recovery key if youve lost all your existing devices.
</div>
<svg
aria-label="Loading…"
class="_icon_1ye7b_27"
fill="currentColor"
height="1em"
style="width: 20px; height: 20px;"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2Z"
fill-rule="evenodd"
/>
</svg>
</div>
</DocumentFragment>
`;

View File

@@ -0,0 +1,97 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen } from "jest-matrix-react";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { waitFor } from "@testing-library/dom";
import userEvent from "@testing-library/user-event";
import { EncryptionUserSettingsTab } from "../../../../../../../src/components/views/settings/tabs/user/EncryptionUserSettingsTab";
import { createTestClient, withClientContextRenderOptions } from "../../../../../../test-utils";
import Modal from "../../../../../../../src/Modal";
describe("<EncryptionUserSettingsTab />", () => {
let matrixClient: MatrixClient;
beforeEach(() => {
matrixClient = createTestClient();
jest.spyOn(matrixClient.getCrypto()!, "isCrossSigningReady").mockResolvedValue(true);
// Recovery key is available
jest.spyOn(matrixClient.secretStorage, "getDefaultKeyId").mockResolvedValue("default key");
// Secrets are cached
jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({
privateKeysInSecretStorage: true,
publicKeysOnDevice: true,
privateKeysCachedLocally: {
masterKey: true,
selfSigningKey: true,
userSigningKey: true,
},
});
});
function renderComponent() {
return render(<EncryptionUserSettingsTab />, withClientContextRenderOptions(matrixClient));
}
it("should display a loading state when the encryption state is computed", () => {
jest.spyOn(matrixClient.getCrypto()!, "isCrossSigningReady").mockImplementation(() => new Promise(() => {}));
renderComponent();
expect(screen.getByLabelText("Loading…")).toBeInTheDocument();
});
it("should display a verify button when the encryption is not set up", async () => {
const user = userEvent.setup();
jest.spyOn(matrixClient.getCrypto()!, "isCrossSigningReady").mockResolvedValue(false);
const { asFragment } = renderComponent();
await waitFor(() =>
expect(
screen.getByText("You need to verify this device in order to view your encryption settings."),
).toBeInTheDocument(),
);
expect(asFragment()).toMatchSnapshot();
const spy = jest.spyOn(Modal, "createDialog").mockReturnValue({} as any);
await user.click(screen.getByText("Verify this device"));
expect(spy).toHaveBeenCalled();
});
it("should display the recovery panel when the encryption is set up", async () => {
renderComponent();
await waitFor(() => expect(screen.getByText("Recovery")).toBeInTheDocument());
});
it("should display the change recovery key panel when the user clicks on the change recovery button", async () => {
const user = userEvent.setup();
const { asFragment } = renderComponent();
await waitFor(() => {
const button = screen.getByRole("button", { name: "Change recovery key" });
expect(button).toBeInTheDocument();
user.click(button);
});
await waitFor(() => expect(screen.getByText("Change recovery key")).toBeInTheDocument());
expect(asFragment()).toMatchSnapshot();
});
it("should display the set up recovery key when the user clicks on the set up recovery key button", async () => {
jest.spyOn(matrixClient.secretStorage, "getDefaultKeyId").mockResolvedValue(null);
const user = userEvent.setup();
const { asFragment } = renderComponent();
await waitFor(() => {
const button = screen.getByRole("button", { name: "Set up recovery" });
expect(button).toBeInTheDocument();
user.click(button);
});
await waitFor(() => expect(screen.getByText("Set up recovery")).toBeInTheDocument());
expect(asFragment()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,95 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<EncryptionUserSettingsTab /> should display a verify button when the encryption is not set up 1`] = `
<DocumentFragment>
<div
class="mx_SettingsTab mx_EncryptionUserSettingsTab"
data-testid="encryptionTab"
>
<div
class="mx_SettingsTab_sections"
>
<div
class="mx_SettingsSection mx_SettingsSection_newUi"
>
<div
class="mx_SettingsSection_header"
>
<h2
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102 mx_SettingsHeader"
>
Device not verified
</h2>
<div
class="mx_SettingsSubheader"
>
<span
class="mx_SettingsSubheader_error"
>
<svg
fill="currentColor"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17a.97.97 0 0 0 .713-.288A.968.968 0 0 0 13 16a.968.968 0 0 0-.287-.713A.968.968 0 0 0 12 15a.968.968 0 0 0-.713.287A.968.968 0 0 0 11 16c0 .283.096.52.287.712.192.192.43.288.713.288Zm0-4c.283 0 .52-.096.713-.287A.968.968 0 0 0 13 12V8a.967.967 0 0 0-.287-.713A.968.968 0 0 0 12 7a.968.968 0 0 0-.713.287A.967.967 0 0 0 11 8v4c0 .283.096.52.287.713.192.191.43.287.713.287Zm0 9a9.738 9.738 0 0 1-3.9-.788 10.099 10.099 0 0 1-3.175-2.137c-.9-.9-1.612-1.958-2.137-3.175A9.738 9.738 0 0 1 2 12a9.74 9.74 0 0 1 .788-3.9 10.099 10.099 0 0 1 2.137-3.175c.9-.9 1.958-1.612 3.175-2.137A9.738 9.738 0 0 1 12 2a9.74 9.74 0 0 1 3.9.788 10.098 10.098 0 0 1 3.175 2.137c.9.9 1.613 1.958 2.137 3.175A9.738 9.738 0 0 1 22 12a9.738 9.738 0 0 1-.788 3.9 10.098 10.098 0 0 1-2.137 3.175c-.9.9-1.958 1.613-3.175 2.137A9.738 9.738 0 0 1 12 22Z"
/>
</svg>
You need to verify this device in order to view your encryption settings.
</span>
</div>
</div>
<button
class="_button_i91xf_17 _has-icon_i91xf_66"
data-kind="primary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 18c-.55 0-1.02-.196-1.413-.587A1.926 1.926 0 0 1 2 16V5c0-.55.196-1.02.587-1.413A1.926 1.926 0 0 1 4 3h16c.55 0 1.02.196 1.413.587.39.393.587.863.587 1.413v11c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 20 18H4Zm0-2h16V5H4v11Zm-2 5a.967.967 0 0 1-.712-.288A.968.968 0 0 1 1 20c0-.283.096-.52.288-.712A.967.967 0 0 1 2 19h20c.283 0 .52.096.712.288.192.191.288.429.288.712s-.096.52-.288.712A.968.968 0 0 1 22 21H2Z"
/>
</svg>
Verify this device
</button>
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`<EncryptionUserSettingsTab /> should display the change recovery key panel when the user clicks on the change recovery button 1`] = `
<DocumentFragment>
<div
class="mx_SettingsTab mx_EncryptionUserSettingsTab"
data-testid="encryptionTab"
>
<div
class="mx_SettingsTab_sections"
/>
</div>
</DocumentFragment>
`;
exports[`<EncryptionUserSettingsTab /> should display the set up recovery key when the user clicks on the set up recovery key button 1`] = `
<DocumentFragment>
<div
class="mx_SettingsTab mx_EncryptionUserSettingsTab"
data-testid="encryptionTab"
>
<div
class="mx_SettingsTab_sections"
/>
</div>
</DocumentFragment>
`;