diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 695a94254e9..b31ec5e3bf9 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -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
diff --git a/playwright/e2e/crypto/device-verification.spec.ts b/playwright/e2e/crypto/device-verification.spec.ts
index a028bfb70c2..272e4c1db12 100644
--- a/playwright/e2e/crypto/device-verification.spec.ts
+++ b/playwright/e2e/crypto/device-verification.spec.ts
@@ -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.
diff --git a/playwright/e2e/crypto/utils.ts b/playwright/e2e/crypto/utils.ts
index 48da798f1a7..ee652982186 100644
--- a/playwright/e2e/crypto/utils.ts
+++ b/playwright/e2e/crypto/utils.ts
@@ -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
*
diff --git a/playwright/e2e/settings/encryption-user-tab/index.ts b/playwright/e2e/settings/encryption-user-tab/index.ts
new file mode 100644
index 00000000000..c473a4af71e
--- /dev/null
+++ b/playwright/e2e/settings/encryption-user-tab/index.ts
@@ -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 at 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");
+ }
+}
diff --git a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts
new file mode 100644
index 00000000000..4a0aac1c9f9
--- /dev/null
+++ b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts
@@ -0,0 +1,176 @@
+/*
+ * 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, "2", true);
+ });
+
+ // This case shouldn't happen but we have seen cases where the secrets gossiping failed or shared partial secrets when verified with another device.
+ // To simulate this case, we need to delete 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();
+}
diff --git a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png
index 655d45bc4a1..12fd5c79d32 100644
Binary files a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png and b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png differ
diff --git a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png
index e2bd16fb5af..ac6f86e81c4 100644
Binary files a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png and b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png differ
diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png
index 75a4852f9b9..a8b3ae3ea77 100644
Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png differ
diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png
index 41ffca6c93d..5307b7400a3 100644
Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png differ
diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/change-key-1-encryption-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/change-key-1-encryption-tab-linux.png
new file mode 100644
index 00000000000..b2083a5dd47
Binary files /dev/null and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/change-key-1-encryption-tab-linux.png differ
diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/change-key-2-encryption-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/change-key-2-encryption-tab-linux.png
new file mode 100644
index 00000000000..02945494593
Binary files /dev/null and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/change-key-2-encryption-tab-linux.png differ
diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/default-recovery-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/default-recovery-linux.png
new file mode 100644
index 00000000000..971745c4128
Binary files /dev/null and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/default-recovery-linux.png differ
diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/out-of-sync-recovery-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/out-of-sync-recovery-linux.png
new file mode 100644
index 00000000000..e6664a5f79b
Binary files /dev/null and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/out-of-sync-recovery-linux.png differ
diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-1-encryption-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-1-encryption-tab-linux.png
new file mode 100644
index 00000000000..1a413094ae1
Binary files /dev/null and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-1-encryption-tab-linux.png differ
diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-2-encryption-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-2-encryption-tab-linux.png
new file mode 100644
index 00000000000..099c0c549e0
Binary files /dev/null and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-2-encryption-tab-linux.png differ
diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-3-encryption-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-3-encryption-tab-linux.png
new file mode 100644
index 00000000000..6cc32cc4311
Binary files /dev/null and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-3-encryption-tab-linux.png differ
diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png
new file mode 100644
index 00000000000..78dcd14aeab
Binary files /dev/null and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png differ
diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/verify-device-encryption-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/verify-device-encryption-tab-linux.png
new file mode 100644
index 00000000000..643fe46a1df
Binary files /dev/null and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/verify-device-encryption-tab-linux.png differ
diff --git a/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png b/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png
index 7d952052519..62a8c5b8d17 100644
Binary files a/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png and b/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png differ
diff --git a/res/css/_common.pcss b/res/css/_common.pcss
index ac7c36daa53..fe8eff22860 100644
--- a/res/css/_common.pcss
+++ b/res/css/_common.pcss
@@ -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 {
diff --git a/res/css/_components.pcss b/res/css/_components.pcss
index b966d62ddd1..b9e080d94b5 100644
--- a/res/css/_components.pcss
+++ b/res/css/_components.pcss
@@ -350,10 +350,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";
diff --git a/res/css/views/settings/_SettingsHeader.pcss b/res/css/views/settings/_SettingsHeader.pcss
new file mode 100644
index 00000000000..a705deda6cf
--- /dev/null
+++ b/res/css/views/settings/_SettingsHeader.pcss
@@ -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);
+ }
+}
diff --git a/res/css/views/settings/_SettingsSubheader.pcss b/res/css/views/settings/_SettingsSubheader.pcss
new file mode 100644
index 00000000000..276421e5be5
--- /dev/null
+++ b/res/css/views/settings/_SettingsSubheader.pcss
@@ -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);
+ }
+}
diff --git a/res/css/views/settings/encryption/_ChangeRecoveryKey.pcss b/res/css/views/settings/encryption/_ChangeRecoveryKey.pcss
new file mode 100644
index 00000000000..d6577431404
--- /dev/null
+++ b/res/css/views/settings/encryption/_ChangeRecoveryKey.pcss
@@ -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;
+ }
+}
diff --git a/res/css/views/settings/encryption/_EncryptionCard.pcss b/res/css/views/settings/encryption/_EncryptionCard.pcss
new file mode 100644
index 00000000000..f125aea1764
--- /dev/null
+++ b/res/css/views/settings/encryption/_EncryptionCard.pcss
@@ -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;
+ }
+ }
+}
diff --git a/res/css/views/settings/tabs/_SettingsSection.pcss b/res/css/views/settings/tabs/_SettingsSection.pcss
index 1dd11661380..997343190dc 100644
--- a/res/css/views/settings/tabs/_SettingsSection.pcss
+++ b/res/css/views/settings/tabs/_SettingsSection.pcss
@@ -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 {
diff --git a/res/css/views/settings/tabs/_SettingsTab.pcss b/res/css/views/settings/tabs/_SettingsTab.pcss
index 6055c289fcb..e0abf08e83b 100644
--- a/res/css/views/settings/tabs/_SettingsTab.pcss
+++ b/res/css/views/settings/tabs/_SettingsTab.pcss
@@ -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;
diff --git a/src/components/views/dialogs/UserSettingsDialog.tsx b/src/components/views/dialogs/UserSettingsDialog.tsx
index 2c1c87d19fe..75739a7f454 100644
--- a/src/components/views/dialogs/UserSettingsDialog.tsx
+++ b/src/components/views/dialogs/UserSettingsDialog.tsx
@@ -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"),
/plain
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": "Settings: 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 you’ve 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, we’ll generate a recovery key for you.",
+ "title": "Recovery"
+ },
+ "title": "Encryption"
+ },
"general": {
"account_management_section": "Account management",
"account_section": "Account",
diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts
index 1c78ec024c3..1809fa91c8c 100644
--- a/test/test-utils/test-utils.ts
+++ b/test/test-utils/test-utils.ts
@@ -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(),
diff --git a/test/unit-tests/components/views/dialogs/__snapshots__/UserSettingsDialog-test.tsx.snap b/test/unit-tests/components/views/dialogs/__snapshots__/UserSettingsDialog-test.tsx.snap
index 871d9376810..de47330ddfc 100644
--- a/test/unit-tests/components/views/dialogs/__snapshots__/UserSettingsDialog-test.tsx.snap
+++ b/test/unit-tests/components/views/dialogs/__snapshots__/UserSettingsDialog-test.tsx.snap
@@ -225,6 +225,32 @@ NodeList [
Security & Privacy
,
+
+
+ encoded private key
+