diff --git a/package.json b/package.json index d79de6e081a..a188ab4adbe 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "@types/png-chunks-extract": "^1.0.2", "@types/react-virtualized": "^9.21.30", "@vector-im/compound-design-tokens": "^2.1.0", - "@vector-im/compound-web": "^7.5.0", + "@vector-im/compound-web": "^7.6.1", "@vector-im/matrix-wysiwyg": "2.38.0", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", diff --git a/playwright/e2e/settings/encryption-user-tab/advanced.spec.ts b/playwright/e2e/settings/encryption-user-tab/advanced.spec.ts new file mode 100644 index 00000000000..8caafad2194 --- /dev/null +++ b/playwright/e2e/settings/encryption-user-tab/advanced.spec.ts @@ -0,0 +1,79 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { test, expect } from "./index"; +import { + checkDeviceIsConnectedKeyBackup, + checkDeviceIsCrossSigned, + createBot, + verifySession, +} from "../../crypto/utils"; + +test.describe("Advanced section in Encryption tab", () => { + let expectedBackupVersion: string; + + test.beforeEach(async ({ page, app, homeserver, credentials }) => { + const res = await createBot(page, homeserver, credentials); + expectedBackupVersion = res.expectedBackupVersion; + }); + + test("should show the encryption details", { tag: "@screenshot" }, async ({ page, app, util }) => { + await verifySession(app, "new passphrase"); + await util.openEncryptionTab(); + const section = util.getEncryptionDetailsSection(); + + const deviceId = await page.evaluate(() => window.mxMatrixClientPeg.get().getDeviceId()); + await expect(section.getByText(deviceId)).toBeVisible(); + + await expect(section).toMatchScreenshot("encryption-details.png", { + mask: [section.getByTestId("deviceId"), section.getByTestId("sessionKey")], + }); + }); + + test("should show the import room keys dialog", async ({ page, app, util }) => { + await verifySession(app, "new passphrase"); + await util.openEncryptionTab(); + const section = util.getEncryptionDetailsSection(); + + await section.getByRole("button", { name: "Import keys" }).click(); + await expect(page.getByRole("heading", { name: "Import room keys" })).toBeVisible(); + }); + + test("should show the export room keys dialog", async ({ page, app, util }) => { + await verifySession(app, "new passphrase"); + await util.openEncryptionTab(); + const section = util.getEncryptionDetailsSection(); + + await section.getByRole("button", { name: "Export keys" }).click(); + await expect(page.getByRole("heading", { name: "Export room keys" })).toBeVisible(); + }); + + test("should reset the cryptographic identity", { tag: "@screenshot" }, async ({ page, app, util }) => { + test.slow(); + + await verifySession(app, "new passphrase"); + const tab = await util.openEncryptionTab(); + const section = util.getEncryptionDetailsSection(); + + await section.getByRole("button", { name: "Reset cryptographic identity" }).click(); + await expect(util.getEncryptionTabContent()).toMatchScreenshot("reset-cryptographic-identity.png"); + await tab.getByRole("button", { name: "Continue" }).click(); + + await expect(section.getByRole("button", { name: "Reset cryptographic identity" })).toBeVisible(); + // After resetting the identity, the user should set up a new recovery key + await expect( + util.getEncryptionRecoverySection().getByRole("button", { name: "Set up recovery" }), + ).toBeVisible(); + + await checkDeviceIsCrossSigned(app); + + await app.closeDialog(); + // The key backup was enabled before resetting the identity + // We create a new one after the reset + await checkDeviceIsConnectedKeyBackup(page, `${parseInt(expectedBackupVersion) + 1}`, true); + }); +}); diff --git a/playwright/e2e/settings/encryption-user-tab/index.ts b/playwright/e2e/settings/encryption-user-tab/index.ts index f8adbb2e336..99db6217edc 100644 --- a/playwright/e2e/settings/encryption-user-tab/index.ts +++ b/playwright/e2e/settings/encryption-user-tab/index.ts @@ -18,6 +18,8 @@ export { expect }; export const test = base.extend<{ util: Helpers; }>({ + displayName: "Alice", + util: async ({ page, app, bot }, use) => { await use(new Helpers(page, app)); }, @@ -67,6 +69,20 @@ class Helpers { return this.page.getByTestId("encryptionTab"); } + /** + * Get the recovery section + */ + getEncryptionRecoverySection() { + return this.page.getByTestId("recoveryPanel"); + } + + /** + * Get the encryption details section + */ + getEncryptionDetailsSection() { + return this.page.getByTestId("encryptionDetails"); + } + /** * Set the default key id of the secret storage to `null` */ diff --git a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts index e6812cd450b..c039030a3f6 100644 --- a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts +++ b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts @@ -32,15 +32,19 @@ test.describe("Recovery section in Encryption tab", () => { test("should verify the device", { tag: "@screenshot" }, async ({ page, app, util }) => { const dialog = await util.openEncryptionTab(); + const content = util.getEncryptionTabContent(); // 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 expect(content).toMatchScreenshot("verify-device-encryption-tab.png"); await verifyButton.click(); await util.verifyDevice(recoveryKey); - await expect(util.getEncryptionTabContent()).toMatchScreenshot("default-recovery.png"); + + await expect(content).toMatchScreenshot("default-tab.png", { + mask: [content.getByTestId("deviceId"), content.getByTestId("sessionKey")], + }); // Check that our device is now cross-signed await checkDeviceIsCrossSigned(app); @@ -61,7 +65,7 @@ test.describe("Recovery section in Encryption tab", () => { // 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 expect(util.getEncryptionRecoverySection()).toMatchScreenshot("default-recovery.png"); await changeButton.click(); // Display the new recovery key and click on the copy button @@ -89,7 +93,7 @@ test.describe("Recovery section in Encryption tab", () => { 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 expect(util.getEncryptionRecoverySection()).toMatchScreenshot("set-up-recovery.png"); await setupButton.click(); // Display an informative panel about the recovery key @@ -137,12 +141,12 @@ test.describe("Recovery section in Encryption tab", () => { 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 expect(util.getEncryptionRecoverySection()).toMatchScreenshot("out-of-sync-recovery.png"); await enterKeyButton.click(); // Fill the recovery key await util.enterRecoveryKey(recoveryKey); - await expect(dialog).toMatchScreenshot("default-recovery.png"); + await expect(util.getEncryptionRecoverySection()).toMatchScreenshot("default-recovery.png"); // Check that our device is now cross-signed await checkDeviceIsCrossSigned(app); 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 index 971745c4128..8a0f80fcea8 100644 Binary files a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/default-recovery-linux.png 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/default-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/default-tab-linux.png new file mode 100644 index 00000000000..d14cff80714 Binary files /dev/null and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/default-tab-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 index e6664a5f79b..d799e944c30 100644 Binary files a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/out-of-sync-recovery-linux.png 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-recovery-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png index 78dcd14aeab..a3f014d0667 100644 Binary files a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png differ diff --git a/res/css/_components.pcss b/res/css/_components.pcss index e0f9a5788de..bfab15625f2 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -353,8 +353,10 @@ @import "./views/settings/_ThemeChoicePanel.pcss"; @import "./views/settings/_UpdateCheckButton.pcss"; @import "./views/settings/_UserProfileSettings.pcss"; +@import "./views/settings/encryption/_AdvancedPanel.pcss"; @import "./views/settings/encryption/_ChangeRecoveryKey.pcss"; @import "./views/settings/encryption/_EncryptionCard.pcss"; +@import "./views/settings/encryption/_ResetIdentityPanel.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/encryption/_AdvancedPanel.pcss b/res/css/views/settings/encryption/_AdvancedPanel.pcss new file mode 100644 index 00000000000..fed8fca7ead --- /dev/null +++ b/res/css/views/settings/encryption/_AdvancedPanel.pcss @@ -0,0 +1,51 @@ +/* + * 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_EncryptionDetails, +.mx_OtherSettings { + display: flex; + flex-direction: column; + gap: var(--cpd-space-6x); + width: 100%; + align-items: start; + + .mx_EncryptionDetails_session_title, + .mx_OtherSettings_title { + font: var(--cpd-font-body-lg-semibold); + padding-bottom: var(--cpd-space-2x); + border-bottom: 1px solid var(--cpd-color-gray-400); + width: 100%; + margin: 0; + } +} + +.mx_EncryptionDetails { + .mx_EncryptionDetails_session { + display: flex; + flex-direction: column; + gap: var(--cpd-space-4x); + width: 100%; + + > div { + display: flex; + + > span { + width: 50%; + word-wrap: break-word; + } + } + + > div:nth-child(odd) { + background-color: var(--cpd-color-gray-200); + } + } + + .mx_EncryptionDetails_buttons { + display: flex; + gap: var(--cpd-space-4x); + } +} diff --git a/res/css/views/settings/encryption/_ResetIdentityPanel.pcss b/res/css/views/settings/encryption/_ResetIdentityPanel.pcss new file mode 100644 index 00000000000..3c382990a27 --- /dev/null +++ b/res/css/views/settings/encryption/_ResetIdentityPanel.pcss @@ -0,0 +1,38 @@ +/* + * 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_ResetIdentityPanel { + .mx_ResetIdentityPanel_content { + display: flex; + flex-direction: column; + gap: var(--cpd-space-3x); + + > ul { + margin: 0; + list-style-type: none; + display: flex; + flex-direction: column; + gap: var(--cpd-space-1x); + + > li { + padding: var(--cpd-space-2x) var(--cpd-space-3x); + } + } + + > span { + font: var(--cpd-font-body-md-medium); + text-align: center; + } + } + + .mx_ResetIdentityPanel_footer { + display: flex; + flex-direction: column; + gap: var(--cpd-space-4x); + justify-content: center; + } +} diff --git a/src/CreateCrossSigning.ts b/src/CreateCrossSigning.ts index 0c043d9d2bb..d3834bd260a 100644 --- a/src/CreateCrossSigning.ts +++ b/src/CreateCrossSigning.ts @@ -31,49 +31,50 @@ export async function createCrossSigning(cli: MatrixClient): Promise { throw new Error("No crypto API found!"); } - const doBootstrapUIAuth = async ( - makeRequest: (authData: AuthDict) => Promise>, - ): Promise => { - try { - await makeRequest({}); - } catch (error) { - if (!(error instanceof MatrixError) || !error.data || !error.data.flows) { - // Not a UIA response - throw error; - } - - const dialogAesthetics = { - [SSOAuthEntry.PHASE_PREAUTH]: { - title: _t("auth|uia|sso_title"), - body: _t("auth|uia|sso_preauth_body"), - continueText: _t("auth|sso"), - continueKind: "primary", - }, - [SSOAuthEntry.PHASE_POSTAUTH]: { - title: _t("encryption|confirm_encryption_setup_title"), - body: _t("encryption|confirm_encryption_setup_body"), - continueText: _t("action|confirm"), - continueKind: "primary", - }, - }; + await cryptoApi.bootstrapCrossSigning({ + authUploadDeviceSigningKeys: (makeRequest) => uiAuthCallback(cli, makeRequest), + }); +} - const { finished } = Modal.createDialog(InteractiveAuthDialog, { - title: _t("encryption|bootstrap_title"), - matrixClient: cli, - makeRequest, - aestheticsForStagePhases: { - [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics, - [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics, - }, - }); - const [confirmed] = await finished; - if (!confirmed) { - throw new Error("Cross-signing key upload auth canceled"); - } +export async function uiAuthCallback( + matrixClient: MatrixClient, + makeRequest: (authData: AuthDict) => Promise>, +): Promise { + try { + await makeRequest({}); + } catch (error) { + if (!(error instanceof MatrixError) || !error.data || !error.data.flows) { + // Not a UIA response + throw error; } - }; - await cryptoApi.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: doBootstrapUIAuth, - }); + const dialogAesthetics = { + [SSOAuthEntry.PHASE_PREAUTH]: { + title: _t("auth|uia|sso_title"), + body: _t("auth|uia|sso_preauth_body"), + continueText: _t("auth|sso"), + continueKind: "primary", + }, + [SSOAuthEntry.PHASE_POSTAUTH]: { + title: _t("encryption|confirm_encryption_setup_title"), + body: _t("encryption|confirm_encryption_setup_body"), + continueText: _t("action|confirm"), + continueKind: "primary", + }, + }; + + const { finished } = Modal.createDialog(InteractiveAuthDialog, { + title: _t("encryption|bootstrap_title"), + matrixClient, + makeRequest, + aestheticsForStagePhases: { + [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics, + [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics, + }, + }); + const [confirmed] = await finished; + if (!confirmed) { + throw new Error("Cross-signing key upload auth canceled"); + } + } } diff --git a/src/components/views/settings/encryption/AdvancedPanel.tsx b/src/components/views/settings/encryption/AdvancedPanel.tsx new file mode 100644 index 00000000000..6bbc48dbcbb --- /dev/null +++ b/src/components/views/settings/encryption/AdvancedPanel.tsx @@ -0,0 +1,146 @@ +/* + * 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, lazy, MouseEventHandler } from "react"; +import { Button, HelpMessage, InlineField, InlineSpinner, Label, Root, ToggleControl } from "@vector-im/compound-web"; +import DownloadIcon from "@vector-im/compound-design-tokens/assets/web/icons/download"; +import ShareIcon from "@vector-im/compound-design-tokens/assets/web/icons/share"; + +import { _t } from "../../../../languageHandler"; +import { SettingsSection } from "../shared/SettingsSection"; +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import { useAsyncMemo } from "../../../../hooks/useAsyncMemo"; +import Modal from "../../../../Modal"; +import { SettingLevel } from "../../../../settings/SettingLevel"; +import { useSettingValueAt } from "../../../../hooks/useSettings"; +import SettingsStore from "../../../../settings/SettingsStore"; + +interface AdvancedPanelProps { + /** + * Callback for when the user clicks the button to reset their identity. + */ + onResetIdentityClick: MouseEventHandler; +} + +/** + * The advanced panel of the encryption settings. + */ +export function AdvancedPanel({ onResetIdentityClick }: AdvancedPanelProps): JSX.Element { + return ( + + + + + ); +} + +interface EncryptionDetails { + /** + * Callback for when the user clicks the button to reset their identity. + */ + onResetIdentityClick: MouseEventHandler; +} + +/** + * The encryption details section of the advanced panel. + */ +function EncryptionDetails({ onResetIdentityClick }: EncryptionDetails): JSX.Element { + const matrixClient = useMatrixClientContext(); + // Null when the keys are not loaded yet + const keys = useAsyncMemo( + () => { + const crypto = matrixClient.getCrypto(); + return crypto ? crypto.getOwnDeviceKeys() : Promise.resolve(null); + }, + [matrixClient], + null, + ); + + return ( +
+
+

+ {_t("settings|encryption|advanced|details_title")} +

+
+ {_t("settings|encryption|advanced|session_id")} + {matrixClient.deviceId} +
+
+ {_t("settings|encryption|advanced|session_key")} + + {keys ? keys.ed25519 : } + +
+
+
+ + +
+ +
+ ); +} + +/** + * Display the never send encrypted message to unverified devices setting. + */ +function OtherSettings(): JSX.Element | null { + const blacklistUnverifiedDevices = useSettingValueAt(SettingLevel.DEVICE, "blacklistUnverifiedDevices"); + const canSetValue = SettingsStore.canSetValue("blacklistUnverifiedDevices", null, SettingLevel.DEVICE); + if (!canSetValue) return null; + + return ( + { + const checked = new FormData(evt.currentTarget).get("neverSendEncrypted") === "on"; + await SettingsStore.setValue("blacklistUnverifiedDevices", null, SettingLevel.DEVICE, checked); + }} + > +

{_t("settings|encryption|advanced|other_people_device_title")}

+ } + > + + {_t("settings|encryption|advanced|other_people_device_description")} + +
+ ); +} diff --git a/src/components/views/settings/encryption/ChangeRecoveryKey.tsx b/src/components/views/settings/encryption/ChangeRecoveryKey.tsx index 7e02d7debde..0c2c9485f21 100644 --- a/src/components/views/settings/encryption/ChangeRecoveryKey.tsx +++ b/src/components/views/settings/encryption/ChangeRecoveryKey.tsx @@ -18,6 +18,7 @@ import { TextControl, } from "@vector-im/compound-web"; import CopyIcon from "@vector-im/compound-design-tokens/assets/web/icons/copy"; +import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key-solid"; import { logger } from "matrix-js-sdk/src/logger"; import { _t } from "../../../../languageHandler"; @@ -157,7 +158,12 @@ export function ChangeRecoveryKey({ pages={pages} onPageClick={onCancelClick} /> - + {content} diff --git a/src/components/views/settings/encryption/EncryptionCard.tsx b/src/components/views/settings/encryption/EncryptionCard.tsx index 8a10802cc3e..6f9ddd651a6 100644 --- a/src/components/views/settings/encryption/EncryptionCard.tsx +++ b/src/components/views/settings/encryption/EncryptionCard.tsx @@ -5,9 +5,8 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { JSX, PropsWithChildren } from "react"; +import React, { JSX, PropsWithChildren, ComponentType, SVGAttributes } 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 { @@ -22,7 +21,15 @@ interface EncryptionCardProps { /** * The description of the card. */ - description: string; + description?: string; + /** + * Whether this icon shows a destructive action. + */ + destructive?: boolean; + /** + * The icon to display. + */ + Icon: ComponentType>; } /** @@ -32,18 +39,20 @@ export function EncryptionCard({ title, description, className, + destructive = false, + Icon, children, }: PropsWithChildren): JSX.Element { return (
- - + + {title} - {description} + {description && {description}}
{children}
diff --git a/src/components/views/settings/encryption/RecoveryPanel.tsx b/src/components/views/settings/encryption/RecoveryPanel.tsx index 19d81668eb8..cd89ba7617e 100644 --- a/src/components/views/settings/encryption/RecoveryPanel.tsx +++ b/src/components/views/settings/encryption/RecoveryPanel.tsx @@ -106,6 +106,7 @@ export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps): /> } subHeading={} + data-testid="recoveryPanel" > {content} diff --git a/src/components/views/settings/encryption/ResetIdentityPanel.tsx b/src/components/views/settings/encryption/ResetIdentityPanel.tsx new file mode 100644 index 00000000000..c9113e9fe70 --- /dev/null +++ b/src/components/views/settings/encryption/ResetIdentityPanel.tsx @@ -0,0 +1,83 @@ +/* + * 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 { Breadcrumb, Button, VisualList, VisualListItem } from "@vector-im/compound-web"; +import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check"; +import InfoIcon from "@vector-im/compound-design-tokens/assets/web/icons/info"; +import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error"; +import React, { MouseEventHandler } from "react"; + +import { _t } from "../../../../languageHandler"; +import { EncryptionCard } from "./EncryptionCard"; +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import { uiAuthCallback } from "../../../../CreateCrossSigning"; + +interface ResetIdentityPanelProps { + /** + * Called when the identity is reset. + */ + onFinish: MouseEventHandler; + /** + * Called when the cancel button is clicked or when we go back in the breadcrumbs. + */ + onCancelClick: () => void; +} + +/** + * The panel for resetting the identity of the current user. + */ +export function ResetIdentityPanel({ onCancelClick, onFinish }: ResetIdentityPanelProps): JSX.Element { + const matrixClient = useMatrixClientContext(); + + return ( + <> + + +
+ + + {_t("settings|encryption|advanced|breadcrumb_first_description")} + + + {_t("settings|encryption|advanced|breadcrumb_second_description")} + + + {_t("settings|encryption|advanced|breadcrumb_third_description")} + + + {_t("settings|encryption|advanced|breadcrumb_warning")} +
+
+ + +
+
+ + ); +} diff --git a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx index d4304eb4d41..4c5030cb58e 100644 --- a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @@ -6,7 +6,7 @@ */ import React, { JSX, useCallback, useEffect, useState } from "react"; -import { Button, InlineSpinner } from "@vector-im/compound-web"; +import { Button, InlineSpinner, Separator } from "@vector-im/compound-web"; import ComputerIcon from "@vector-im/compound-design-tokens/assets/web/icons/computer"; import SettingsTab from "../SettingsTab"; @@ -18,6 +18,8 @@ import Modal from "../../../../../Modal"; import SetupEncryptionDialog from "../../../dialogs/security/SetupEncryptionDialog"; import { SettingsSection } from "../../shared/SettingsSection"; import { SettingsSubheader } from "../../SettingsSubheader"; +import { AdvancedPanel } from "../../encryption/AdvancedPanel"; +import { ResetIdentityPanel } from "../../encryption/ResetIdentityPanel"; /** * The state in the encryption settings tab. @@ -29,8 +31,9 @@ import { SettingsSubheader } from "../../SettingsSubheader"; * This happens when the user has a recovery key and the user clicks on "Change recovery key" button of the RecoveryPanel. * - "set_recovery_key": The panel to show when the user is setting up their recovery key. * This happens when the user doesn't have a key a recovery key and the user clicks on "Set up recovery key" button of the RecoveryPanel. + * - "reset_identity": The panel to show when the user is resetting their identity. */ -type State = "loading" | "main" | "set_up_encryption" | "change_recovery_key" | "set_recovery_key"; +type State = "loading" | "main" | "set_up_encryption" | "change_recovery_key" | "set_recovery_key" | "reset_identity"; export function EncryptionUserSettingsTab(): JSX.Element { const [state, setState] = useState("loading"); @@ -46,11 +49,15 @@ export function EncryptionUserSettingsTab(): JSX.Element { break; case "main": content = ( - - setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key") - } - /> + <> + + setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key") + } + /> + + setState("reset_identity")} /> + ); break; case "change_recovery_key": @@ -63,6 +70,9 @@ export function EncryptionUserSettingsTab(): JSX.Element { /> ); break; + case "reset_identity": + content = setState("main")} onFinish={() => setState("main")} />; + break; } return ( diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 37a739a62e9..25735eceaee 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2418,6 +2418,24 @@ "enable_markdown": "Enable Markdown", "enable_markdown_description": "Start messages with /plain to send without markdown.", "encryption": { + "advanced": { + "breadcrumb_first_description": "Your account details, contacts, preferences, and chat list will be kept", + "breadcrumb_page": "Reset encryption", + "breadcrumb_second_description": "You will lose any message history that’s stored only on the server", + "breadcrumb_third_description": "You will need to verify all your existing devices and contacts again", + "breadcrumb_title": "Are you sure you want to reset your identity?", + "breadcrumb_warning": "Only do this if you believe your account has been compromised.", + "details_title": "Encryption details", + "export_keys": "Export keys", + "import_keys": "Import keys", + "other_people_device_description": "By default in encrypted rooms, do not send encrypted messages to anyone until you’ve verified them", + "other_people_device_label": "Never send encrypted messages to unverified devices", + "other_people_device_title": "Other people’s devices", + "reset_identity": "Reset cryptographic identity", + "session_id": "Session ID:", + "session_key": "Session key:", + "title": "Advanced" + }, "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", diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 1809fa91c8c..b474a24aa81 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -116,7 +116,7 @@ export function createTestClient(): MatrixClient { }, getCrypto: jest.fn().mockReturnValue({ - getOwnDeviceKeys: jest.fn(), + getOwnDeviceKeys: jest.fn().mockResolvedValue({ ed25519: "ed25519", curve25519: "curve25519" }), getUserDeviceInfo: jest.fn().mockResolvedValue(new Map()), getUserVerificationStatus: jest.fn(), getDeviceVerificationStatus: jest.fn(), @@ -151,6 +151,7 @@ export function createTestClient(): MatrixClient { }, }), isCrossSigningReady: jest.fn().mockResolvedValue(false), + resetEncryption: jest.fn(), }), getPushActionsForEvent: jest.fn(), diff --git a/test/unit-tests/components/views/settings/encryption/AdvancedPanel-test.tsx b/test/unit-tests/components/views/settings/encryption/AdvancedPanel-test.tsx new file mode 100644 index 00000000000..2ec51b53637 --- /dev/null +++ b/test/unit-tests/components/views/settings/encryption/AdvancedPanel-test.tsx @@ -0,0 +1,99 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { render, screen, waitFor } from "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; + +import { createTestClient, withClientContextRenderOptions } from "../../../../../test-utils"; +import { AdvancedPanel } from "../../../../../../src/components/views/settings/encryption/AdvancedPanel"; +import SettingsStore from "../../../../../../src/settings/SettingsStore"; +import { SettingLevel } from "../../../../../../src/settings/SettingLevel"; + +describe("", () => { + let matrixClient: MatrixClient; + + beforeEach(() => { + matrixClient = createTestClient(); + }); + + async function renderAdvancedPanel(onResetIdentityClick = jest.fn()) { + const renderResult = render( + , + withClientContextRenderOptions(matrixClient), + ); + // Wait for the device keys to be displayed + await waitFor(() => expect(screen.getByText("ed25519")).toBeInTheDocument()); + return renderResult; + } + + describe("", () => { + it("should display a spinner when loading the device keys", async () => { + jest.spyOn(matrixClient.getCrypto()!, "getOwnDeviceKeys").mockImplementation(() => new Promise(() => {})); + render(, withClientContextRenderOptions(matrixClient)); + + expect(screen.getByTestId("encryptionDetails")).toMatchSnapshot(); + }); + + it("should display the device keys", async () => { + await renderAdvancedPanel(); + + // session id + expect(screen.getByText("ABCDEFGHI")).toBeInTheDocument(); + // session key + expect(screen.getByText("ed25519")).toBeInTheDocument(); + expect(screen.getByTestId("encryptionDetails")).toMatchSnapshot(); + }); + + it("should call the onResetIdentityClick callback when the reset cryptographic identity button is clicked", async () => { + const user = userEvent.setup(); + + const onResetIdentityClick = jest.fn(); + await renderAdvancedPanel(onResetIdentityClick); + + const resetIdentityButton = screen.getByRole("button", { name: "Reset cryptographic identity" }); + await user.click(resetIdentityButton); + + expect(onResetIdentityClick).toHaveBeenCalled(); + }); + }); + + describe("", () => { + it("should display the blacklist of unverified devices settings", async () => { + const user = userEvent.setup(); + + jest.spyOn(SettingsStore, "getValueAt").mockReturnValue(true); + jest.spyOn(SettingsStore, "canSetValue").mockReturnValue(true); + jest.spyOn(SettingsStore, "setValue"); + + await renderAdvancedPanel(); + + expect(screen.getByTestId("otherSettings")).toMatchSnapshot(); + const checkbox = screen.getByRole("checkbox", { + name: "Never send encrypted messages to unverified devices", + }); + expect(checkbox).toBeChecked(); + + await user.click(checkbox); + expect(SettingsStore.setValue).toHaveBeenCalledWith( + "blacklistUnverifiedDevices", + null, + SettingLevel.DEVICE, + false, + ); + }); + + it("should not display the section when the user can not set the value", async () => { + jest.spyOn(SettingsStore, "canSetValue").mockReturnValue(false); + jest.spyOn(SettingsStore, "setValue"); + + await renderAdvancedPanel(); + expect(screen.queryByTestId("otherSettings")).toBeNull(); + }); + }); +}); diff --git a/test/unit-tests/components/views/settings/encryption/EncryptionCard-test.tsx b/test/unit-tests/components/views/settings/encryption/EncryptionCard-test.tsx index d51fcb840bf..e6d9618a433 100644 --- a/test/unit-tests/components/views/settings/encryption/EncryptionCard-test.tsx +++ b/test/unit-tests/components/views/settings/encryption/EncryptionCard-test.tsx @@ -7,13 +7,14 @@ import React from "react"; import { render } from "jest-matrix-react"; +import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key-solid"; import { EncryptionCard } from "../../../../../../src/components/views/settings/encryption/EncryptionCard"; describe("", () => { it("should render", () => { const { asFragment } = render( - + Encryption card children , ); diff --git a/test/unit-tests/components/views/settings/encryption/ResetIdentityPanel-test.tsx b/test/unit-tests/components/views/settings/encryption/ResetIdentityPanel-test.tsx new file mode 100644 index 00000000000..dc791a6a35c --- /dev/null +++ b/test/unit-tests/components/views/settings/encryption/ResetIdentityPanel-test.tsx @@ -0,0 +1,37 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { render, screen } from "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; + +import { ResetIdentityPanel } from "../../../../../../src/components/views/settings/encryption/ResetIdentityPanel"; +import { createTestClient, withClientContextRenderOptions } from "../../../../../test-utils"; + +describe("", () => { + let matrixClient: MatrixClient; + + beforeEach(() => { + matrixClient = createTestClient(); + }); + + it("should reset the encryption when the continue button is clicked", async () => { + const user = userEvent.setup(); + + const onFinish = jest.fn(); + const { asFragment } = render( + , + withClientContextRenderOptions(matrixClient), + ); + expect(asFragment()).toMatchSnapshot(); + + await user.click(screen.getByRole("button", { name: "Continue" })); + expect(matrixClient.getCrypto()!.resetEncryption).toHaveBeenCalled(); + expect(onFinish).toHaveBeenCalled(); + }); +}); diff --git a/test/unit-tests/components/views/settings/encryption/__snapshots__/AdvancedPanel-test.tsx.snap b/test/unit-tests/components/views/settings/encryption/__snapshots__/AdvancedPanel-test.tsx.snap new file mode 100644 index 00000000000..227e37e033d --- /dev/null +++ b/test/unit-tests/components/views/settings/encryption/__snapshots__/AdvancedPanel-test.tsx.snap @@ -0,0 +1,253 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should display a spinner when loading the device keys 1`] = ` +
+
+

+ Encryption details +

+
+ + Session ID: + + + ABCDEFGHI + +
+
+ + Session key: + + + + + + +
+
+
+ + +
+ +
+`; + +exports[` should display the device keys 1`] = ` +
+
+

+ Encryption details +

+
+ + Session ID: + + + ABCDEFGHI + +
+
+ + Session key: + + + ed25519 + +
+
+
+ + +
+ +
+`; + +exports[` should display the blacklist of unverified devices settings 1`] = ` +
+

+ Other people’s devices +

+
+
+
+ +
+
+
+
+ + + By default in encrypted rooms, do not send encrypted messages to anyone until you’ve verified them + +
+
+ +`; diff --git a/test/unit-tests/components/views/settings/encryption/__snapshots__/ResetIdentityPanel-test.tsx.snap b/test/unit-tests/components/views/settings/encryption/__snapshots__/ResetIdentityPanel-test.tsx.snap new file mode 100644 index 00000000000..7635ad06121 --- /dev/null +++ b/test/unit-tests/components/views/settings/encryption/__snapshots__/ResetIdentityPanel-test.tsx.snap @@ -0,0 +1,184 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should reset the encryption when the continue button is clicked 1`] = ` + + +
+
+
+ + + +
+

+ Are you sure you want to reset your identity? +

+
+
+
    +
  • + + Your account details, contacts, preferences, and chat list will be kept +
  • +
  • + + You will lose any message history that’s stored only on the server +
  • +
  • + + You will need to verify all your existing devices and contacts again +
  • +
+ + Only do this if you believe your account has been compromised. + +
+ +
+
+`; 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 index 49ce1404216..a1de62a32a4 100644 --- a/test/unit-tests/components/views/settings/tabs/user/EncryptionUserSettingsTab-test.tsx +++ b/test/unit-tests/components/views/settings/tabs/user/EncryptionUserSettingsTab-test.tsx @@ -94,4 +94,19 @@ describe("", () => { await waitFor(() => expect(screen.getByText("Set up recovery")).toBeInTheDocument()); expect(asFragment()).toMatchSnapshot(); }); + + it("should display the reset identity panel when the user clicks on the reset cryptographic identity panel", async () => { + const user = userEvent.setup(); + + const { asFragment } = renderComponent(); + await waitFor(() => { + const button = screen.getByRole("button", { name: "Reset cryptographic identity" }); + expect(button).toBeInTheDocument(); + user.click(button); + }); + await waitFor(() => + expect(screen.getByText("Are you sure you want to reset your identity?")).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 index 71ec4deb592..b460b91e512 100644 --- 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 @@ -81,6 +81,198 @@ exports[` should display the change recovery key pa `; +exports[` should display the reset identity panel when the user clicks on the reset cryptographic identity panel 1`] = ` + +
+
+ +
+
+
+ + + +
+

+ Are you sure you want to reset your identity? +

+
+
+
    +
  • + + Your account details, contacts, preferences, and chat list will be kept +
  • +
  • + + You will lose any message history that’s stored only on the server +
  • +
  • + + You will need to verify all your existing devices and contacts again +
  • +
+ + Only do this if you believe your account has been compromised. + +
+ +
+
+
+
+`; + exports[` should display the set up recovery key when the user clicks on the set up recovery key button 1`] = `