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"), , ), + ); + if (showLabsFlags() || SettingsStore.getFeatureSettingNames().some((k) => SettingsStore.getBetaInfo(k))) { tabs.push( new Tab(UserTab.Labs, _td("common|labs"), , , "UserSettingsLabs"), diff --git a/src/components/views/dialogs/UserTab.ts b/src/components/views/dialogs/UserTab.ts index 1984f99604e..323fc1e330c 100644 --- a/src/components/views/dialogs/UserTab.ts +++ b/src/components/views/dialogs/UserTab.ts @@ -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", diff --git a/src/components/views/settings/SettingsHeader.tsx b/src/components/views/settings/SettingsHeader.tsx new file mode 100644 index 00000000000..9a83ba1d929 --- /dev/null +++ b/src/components/views/settings/SettingsHeader.tsx @@ -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 ( + + {label} {hasRecommendedTag && {_t("common|recommended")}} + + ); +} diff --git a/src/components/views/settings/SettingsSubheader.tsx b/src/components/views/settings/SettingsSubheader.tsx new file mode 100644 index 00000000000..90b8a41b7ae --- /dev/null +++ b/src/components/views/settings/SettingsSubheader.tsx @@ -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 ( +
+ {label} + + {state === "success" ? ( + + ) : ( + + )} + {stateMessage} + +
+ ); +} diff --git a/src/components/views/settings/encryption/ChangeRecoveryKey.tsx b/src/components/views/settings/encryption/ChangeRecoveryKey.tsx new file mode 100644 index 00000000000..511acc32233 --- /dev/null +++ b/src/components/views/settings/encryption/ChangeRecoveryKey.tsx @@ -0,0 +1,353 @@ +/* + * 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(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 = ( + 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 = ( + + 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 save to confirm it. + content = ( + { + 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({ + setupNewKeyBackup: !userHasRecoveryKey, + 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 ( + <> + + + {content} + + + ); +} + +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; + /** + * Called when the cancel button is clicked. + */ + onCancelClick: MouseEventHandler; +} + +/** + * The panel to display information about the recovery key. + */ +function InformationPanel({ onContinueClick, onCancelClick }: InformationPanelProps): JSX.Element { + return ( + <> + + {_t("settings|encryption|recovery|set_up_recovery_secondary_description")} + +
+ + +
+ + ); +} + +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 ( + <> +
+ + {_t("settings|encryption|recovery|save_key_title")} + +
+ + {recoveryKey} + + + {_t("settings|encryption|recovery|save_key_description")} + +
+ copyPlaintext(recoveryKey)}> + + +
+
+ + +
+ + ); +} + +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(); + const isKeyInvalidAndFilled = isKeyValid === false; + + return ( + { + 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); + }} + > + + + + + {isKeyInvalidAndFilled && ( + {_t("settings|encryption|recovery|enter_key_error")} + )} + +
+ + +
+
+ ); +} diff --git a/src/components/views/settings/encryption/EncryptionCard.tsx b/src/components/views/settings/encryption/EncryptionCard.tsx new file mode 100644 index 00000000000..8a10802cc3e --- /dev/null +++ b/src/components/views/settings/encryption/EncryptionCard.tsx @@ -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): JSX.Element { + return ( +
+
+ + + + + {title} + + {description} +
+ {children} +
+ ); +} diff --git a/src/components/views/settings/encryption/RecoveryPanel.tsx b/src/components/views/settings/encryption/RecoveryPanel.tsx new file mode 100644 index 00000000000..0a14ec9465a --- /dev/null +++ b/src/components/views/settings/encryption/RecoveryPanel.tsx @@ -0,0 +1,133 @@ +/* + * 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 backup but the secrets are not cached. + * This shouldn't happen but we have seen cases where the secrets gossiping failed or shared partial secrets when verified with another device. + * - `good`: The user has a backup 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. + */ + 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("loading"); + const isMissingRecoveryKey = state === "missing_recovery_key"; + + const matrixClient = useMatrixClientContext(); + + const checkEncryption = useCallback(async () => { + const crypto = matrixClient.getCrypto()!; + + // Check if the user has a backup + 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 = ; + break; + case "missing_recovery_key": + content = ( + + ); + break; + case "secrets_not_cached": + content = ( + + ); + break; + case "good": + content = ( + + ); + } + + return ( + + } + subHeading={} + > + {content} + + ); +} + +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 ( + + ); +} diff --git a/src/components/views/settings/shared/SettingsSection.tsx b/src/components/views/settings/shared/SettingsSection.tsx index fbc502e7332..4763c51e564 100644 --- a/src/components/views/settings/shared/SettingsSection.tsx +++ b/src/components/views/settings/shared/SettingsSection.tsx @@ -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 { 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} + ) : ( + ); case "undefined": return undefined; @@ -48,9 +53,29 @@ function renderHeading(heading: string | React.ReactNode | undefined): React.Rea * * ``` */ -export const SettingsSection: React.FC = ({ className, heading, children, ...rest }) => ( -
- {renderHeading(heading)} -
{children}
+export const SettingsSection: React.FC = ({ + className, + heading, + subHeading, + legacy = true, + children, + ...rest +}) => ( +
+ {heading && + (subHeading ? ( +
+ {renderHeading(heading, legacy)} + {subHeading} +
+ ) : ( + renderHeading(heading, legacy) + ))} + {legacy ?
{children}
: children}
); diff --git a/src/components/views/settings/tabs/SettingsTab.tsx b/src/components/views/settings/tabs/SettingsTab.tsx index 169a373db74..ed0b1367f6f 100644 --- a/src/components/views/settings/tabs/SettingsTab.tsx +++ b/src/components/views/settings/tabs/SettingsTab.tsx @@ -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, "className"> { +export interface SettingsTabProps extends HTMLAttributes { children?: React.ReactNode; + /** + * Added to the classList of the root element + */ + className?: string; } /** @@ -29,8 +34,8 @@ export interface SettingsTabProps extends Omit, " * * ``` */ -const SettingsTab: React.FC = ({ children, ...rest }) => ( -
+const SettingsTab: React.FC = ({ children, className, ...rest }) => ( +
{children}
); diff --git a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx new file mode 100644 index 00000000000..ccbbd11c08a --- /dev/null +++ b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @@ -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 key backup 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 backup 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("loading"); + const setUpEncryptionRequired = useSetUpEncryptionRequired(setState); + + let content: JSX.Element; + switch (state) { + case "loading": + content = ; + break; + case "set_up_encryption": + content = ; + break; + case "main": + content = ( + + setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key") + } + /> + ); + break; + case "change_recovery_key": + case "set_recovery_key": + content = ( + setState("main")} + onFinish={() => setState("main")} + /> + ); + break; + } + + return ( + + {content} + + ); +} + +/** + * 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 { + 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 ( + + } + > + + + ); +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a12a7c27f47..81241c771f0 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -541,6 +541,7 @@ "qr_code": "QR Code", "random": "Random", "reactions": "Reactions", + "recommended": "Recommended", "report_a_bug": "Report a bug", "room": "Room", "room_name": "Room name", @@ -2465,6 +2466,36 @@ "emoji_autocomplete": "Enable Emoji suggestions while typing", "enable_markdown": "Enable Markdown", "enable_markdown_description": "Start messages with /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 +
  • ", () => { + it("should render the component", () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should render the component with the recommended tag", () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/test/unit-tests/components/views/settings/SettingsSubheader-test.tsx b/test/unit-tests/components/views/settings/SettingsSubheader-test.tsx new file mode 100644 index 00000000000..6a0bf21defd --- /dev/null +++ b/test/unit-tests/components/views/settings/SettingsSubheader-test.tsx @@ -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("", () => { + it("should display a check icon when in success", () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should display an error icon when in error", () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should display a label", () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/test/unit-tests/components/views/settings/__snapshots__/SettingsHeader-test.tsx.snap b/test/unit-tests/components/views/settings/__snapshots__/SettingsHeader-test.tsx.snap new file mode 100644 index 00000000000..4098a55ed41 --- /dev/null +++ b/test/unit-tests/components/views/settings/__snapshots__/SettingsHeader-test.tsx.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render the component 1`] = ` + +

    + Settings Header +

    +
    +`; + +exports[` should render the component with the recommended tag 1`] = ` + +

    + Settings Header + + Recommended + +

    +
    +`; diff --git a/test/unit-tests/components/views/settings/__snapshots__/SettingsSubheader-test.tsx.snap b/test/unit-tests/components/views/settings/__snapshots__/SettingsSubheader-test.tsx.snap new file mode 100644 index 00000000000..23c9ce087d7 --- /dev/null +++ b/test/unit-tests/components/views/settings/__snapshots__/SettingsSubheader-test.tsx.snap @@ -0,0 +1,77 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should display a check icon when in success 1`] = ` + +
    + + + + + Success! + +
    +
    +`; + +exports[` should display a label 1`] = ` + +
    + My label + + + + + Success! + +
    +
    +`; + +exports[` should display an error icon when in error 1`] = ` + +
    + + + + + Error! + +
    +
    +`; diff --git a/test/unit-tests/components/views/settings/encryption/ChangeRecoveryKey-test.tsx b/test/unit-tests/components/views/settings/encryption/ChangeRecoveryKey-test.tsx new file mode 100644 index 00000000000..8929916a0eb --- /dev/null +++ b/test/unit-tests/components/views/settings/encryption/ChangeRecoveryKey-test.tsx @@ -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("", () => { + let matrixClient: MatrixClient; + + beforeEach(() => { + matrixClient = createTestClient(); + }); + + function renderComponent(userHasRecoveryKey = true, onFinish = jest.fn(), onCancelClick = jest.fn()) { + return render( + , + 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(); + }); + }); +}); diff --git a/test/unit-tests/components/views/settings/encryption/EncryptionCard-test.tsx b/test/unit-tests/components/views/settings/encryption/EncryptionCard-test.tsx new file mode 100644 index 00000000000..d51fcb840bf --- /dev/null +++ b/test/unit-tests/components/views/settings/encryption/EncryptionCard-test.tsx @@ -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("", () => { + it("should render", () => { + const { asFragment } = render( + + Encryption card children + , + ); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/test/unit-tests/components/views/settings/encryption/RecoveryPanel-test.tsx b/test/unit-tests/components/views/settings/encryption/RecoveryPanel-test.tsx new file mode 100644 index 00000000000..6ef79876c7a --- /dev/null +++ b/test/unit-tests/components/views/settings/encryption/RecoveryPanel-test.tsx @@ -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("", () => { + let matrixClient: MatrixClient; + + beforeEach(() => { + matrixClient = createTestClient(); + mocked(accessSecretStorage).mockClear().mockResolvedValue(); + }); + + function renderRecoverPanel(onChangeRecoveryKeyClick = jest.fn()) { + return render( + , + 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); + }); +}); diff --git a/test/unit-tests/components/views/settings/encryption/__snapshots__/ChangeRecoveryKey-test.tsx.snap b/test/unit-tests/components/views/settings/encryption/__snapshots__/ChangeRecoveryKey-test.tsx.snap new file mode 100644 index 00000000000..0719c6cc531 --- /dev/null +++ b/test/unit-tests/components/views/settings/encryption/__snapshots__/ChangeRecoveryKey-test.tsx.snap @@ -0,0 +1,725 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` flow to change the recovery key should display the recovery key 1`] = ` + + +
    +
    +
    + + + +
    +

    + Change recovery key? +

    + + Write down this new recovery key somewhere safe. Then click Continue to confirm the change. + +
    +
    + + Recovery key + +
    + + encoded private key + + + Do not share this with anyone! + +
    + +
    + +
    +
    +`; + +exports[` flow to setup a recovery key should ask the user to enter the recovery key 1`] = ` + + +
    +
    +
    + + + +
    +

    + Enter your recovery key to confirm +

    + + Enter the recovery key shown on the previous screen to finish setting up recovery. + +
    +
    +
    + + +
    + +
    +
    +
    +`; + +exports[` flow to setup a recovery key should ask the user to enter the recovery key 2`] = ` + + +
    +
    +
    + + + +
    +

    + Enter your recovery key to confirm +

    + + Enter the recovery key shown on the previous screen to finish setting up recovery. + +
    +
    +
    + + + + + + + The recovery key you entered is not correct. + +
    + +
    +
    +
    +`; + +exports[` flow to setup a recovery key should display information about the recovery key 1`] = ` + + +
    +
    +
    + + + +
    +

    + Set up recovery +

    + + 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’. + +
    + + After clicking continue, we’ll generate a recovery key for you. + + +
    +
    +`; + +exports[` flow to setup a recovery key should display the recovery key 1`] = ` + + +
    +
    +
    + + + +
    +

    + Save your recovery key somewhere safe +

    + + Write down this recovery key somewhere safe, like a password manager, encrypted note, or a physical safe. + +
    +
    + + Recovery key + +
    + + encoded private key + + + Do not share this with anyone! + +
    + +
    + +
    +
    +`; diff --git a/test/unit-tests/components/views/settings/encryption/__snapshots__/EncryptionCard-test.tsx.snap b/test/unit-tests/components/views/settings/encryption/__snapshots__/EncryptionCard-test.tsx.snap new file mode 100644 index 00000000000..e523e57c090 --- /dev/null +++ b/test/unit-tests/components/views/settings/encryption/__snapshots__/EncryptionCard-test.tsx.snap @@ -0,0 +1,39 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render 1`] = ` + +
    +
    +
    + + + +
    +

    + My title +

    + + My description + +
    + Encryption card children +
    +
    +`; diff --git a/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanel-test.tsx.snap b/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanel-test.tsx.snap new file mode 100644 index 00000000000..5030eb02cc9 --- /dev/null +++ b/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanel-test.tsx.snap @@ -0,0 +1,179 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should allow to change the recovery key when everything is good 1`] = ` + +
    +
    +

    + Recovery +

    + Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices. +
    + +
    +
    +`; + +exports[` should ask to enter the recovery key when secrets are not cached 1`] = ` + +
    +
    +

    + Recovery +

    +
    + Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices. + + + + + Your key storage is out of sync. Click the button below to fix the problem. + +
    +
    + +
    +
    +`; + +exports[` should ask to set up a recovery key when there is no recovery key 1`] = ` + +
    +
    +

    + Recovery + + Recommended + +

    + Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices. +
    + +
    +
    +`; + +exports[` should be in loading state when checking the recovery key and the cached keys 1`] = ` + +
    +
    +

    + Recovery +

    + Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices. +
    + + + +
    +
    +`; diff --git a/test/unit-tests/components/views/settings/tabs/user/EncryptionUserSettingsTab-test.tsx b/test/unit-tests/components/views/settings/tabs/user/EncryptionUserSettingsTab-test.tsx new file mode 100644 index 00000000000..49ce1404216 --- /dev/null +++ b/test/unit-tests/components/views/settings/tabs/user/EncryptionUserSettingsTab-test.tsx @@ -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("", () => { + 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(, 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(); + }); +}); diff --git a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/EncryptionUserSettingsTab-test.tsx.snap b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/EncryptionUserSettingsTab-test.tsx.snap new file mode 100644 index 00000000000..71ec4deb592 --- /dev/null +++ b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/EncryptionUserSettingsTab-test.tsx.snap @@ -0,0 +1,95 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should display a verify button when the encryption is not set up 1`] = ` + +
    +
    +
    +
    +

    + Device not verified +

    +
    + + + + + You need to verify this device in order to view your encryption settings. + +
    +
    + +
    +
    +
    +
    +`; + +exports[` should display the change recovery key panel when the user clicks on the change recovery button 1`] = ` + +
    +
    +
    + +`; + +exports[` should display the set up recovery key when the user clicks on the set up recovery key button 1`] = ` + +
    +
    +
    + +`;