+ );
+}
diff --git a/src/components/views/settings/encryption/ChangeRecoveryKey.tsx b/src/components/views/settings/encryption/ChangeRecoveryKey.tsx
new file mode 100644
index 00000000000..7bbd998183e
--- /dev/null
+++ b/src/components/views/settings/encryption/ChangeRecoveryKey.tsx
@@ -0,0 +1,324 @@
+/*
+ * 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 chang key flow.
+ * - `confirm`: The user is asked to confirm the new recovery key.
+ */
+type State = "inform_user" | "save_key_setup_flow" | "save_key_change_flow" | "confirm";
+
+interface ChangeRecoveryKeyProps {
+ /**
+ * If true, the component will display the flow to set up a new recovery key.
+ * If false, the component will display the flow to change the recovery key.
+ */
+ isSetupFlow: 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({
+ isSetupFlow,
+ onFinish,
+ onCancelClick,
+}: ChangeRecoveryKeyProps): JSX.Element | null {
+ const matrixClient = useMatrixClientContext();
+
+ const [state, setState] = useState(isSetupFlow ? "inform_user" : "save_key_change_flow");
+
+ // We create a new recovery key, the recovery key will be displayed to the user
+ const recoveryKey = useAsyncMemo(() => {
+ const crypto = matrixClient.getCrypto();
+ if (!crypto) return Promise.resolve(undefined);
+
+ return crypto.createRecoveryKeyFromPassphrase();
+ }, []);
+
+ if (!recoveryKey?.encodedPrivateKey) return null;
+
+ let content: JSX.Element;
+ switch (state) {
+ case "inform_user":
+ content = (
+ setState("save_key_setup_flow")}
+ onCancelClick={onCancelClick}
+ />
+ );
+ break;
+ case "save_key_setup_flow":
+ case "save_key_change_flow":
+ content = (
+ setState("confirm")}
+ onCancelClick={onCancelClick}
+ />
+ );
+ break;
+ case "confirm":
+ 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: isSetupFlow,
+ setupNewSecretStorage: true,
+ createSecretStorageKey: async () => recoveryKey,
+ }),
+ );
+ onFinish();
+ } catch (e) {
+ logger.error("Failed to bootstrap secret storage", e);
+ }
+ }}
+ />
+ );
+ }
+
+ const pages = [
+ _t("settings|encryption|title"),
+ isSetupFlow
+ ? _t("settings|encryption|recovery|set_up_recovery")
+ : _t("settings|encryption|recovery|change_recovery_key"),
+ ];
+ 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":
+ return {
+ title: _t("settings|encryption|recovery|confirm_title"),
+ description: _t("settings|encryption|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 (
+ <>
+
+ >
+ );
+}
+
+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 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 }: 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..df555a583ad
--- /dev/null
+++ b/src/components/views/settings/encryption/RecoveryPanel.tsx
@@ -0,0 +1,134 @@
+/*
+ * 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, MouseEventHandler, 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 backup, the recovery and the secrets.
+ * - `missing_backup`: The user has no backup.
+ * - `secrets_not_cached`: The user has a backup but the secrets are not cached.
+ * - `good`: The user has a backup and the secrets are cached.
+ */
+type State = "loading" | "missing_backup" | "secrets_not_cached" | "good";
+
+interface RecoveryPanelProps {
+ /**
+ * Callback for when the user clicks the button to set up their recovery key.
+ */
+ onSetUpRecoveryClick: MouseEventHandler;
+ /**
+ * Callback for when the user clicks the button to change their recovery key.
+ */
+ onChangingRecoveryKeyClick: MouseEventHandler;
+}
+
+/**
+ * This component allows the user to set up or change their recovery key.
+ */
+export function RecoveryPanel({ onSetUpRecoveryClick, onChangingRecoveryKeyClick }: RecoveryPanelProps): JSX.Element {
+ const [state, setState] = useState("loading");
+ const isMissingBackup = state === "missing_backup";
+
+ const matrixClient = useMatrixClientContext();
+
+ const checkEncryption = useCallback(async () => {
+ const crypto = matrixClient.getCrypto();
+ if (!crypto) return;
+
+ // Check if the user has a backup
+ const hasBackup = Boolean(await crypto.checkKeyBackupAndEnable());
+ if (!hasBackup) return setState("missing_backup");
+
+ // 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_backup":
+ content = (
+
+ );
+ break;
+ case "secrets_not_cached":
+ content = (
+
+ );
+ break;
+ default:
+ 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 we 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 a42a2a9b788..8ca2d9aa617 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 }) => (
-
);
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..97e6ed1ab53
--- /dev/null
+++ b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx
@@ -0,0 +1,127 @@
+/*
+ * 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, 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).
+ * - "verification_required": The panel to show when the user needs to verify their session.
+ * - "change_recovery_key": The panel to show when the user is changing their recovery key.
+ * - "set_recovery_key": The panel to show when the user is setting up their recovery key.
+ */
+type State = "loading" | "main" | "verification_required" | "change_recovery_key" | "set_recovery_key";
+
+export function EncryptionUserSettingsTab(): JSX.Element {
+ const [state, setState] = useState("loading");
+ const checkVerificationRequired = useVerificationRequired(setState);
+
+ let content: JSX.Element;
+ switch (state) {
+ case "loading":
+ content = ;
+ break;
+ case "verification_required":
+ content = ;
+ break;
+ case "main":
+ content = (
+ setState("change_recovery_key")}
+ onSetUpRecoveryClick={() => setState("set_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 verify their session.
+ * If the user needs to verify their session, the state will be set to "verification_required".
+ * If the user doesn't need to verify their session, the state will be set to "main".
+ * @param setState
+ */
+function useVerificationRequired(setState: (state: State) => void): () => Promise {
+ const matrixClient = useMatrixClientContext();
+
+ const checkVerificationRequired = useCallback(async () => {
+ const crypto = matrixClient.getCrypto();
+ if (!crypto) return;
+
+ const isCrossSigningReady = await crypto.isCrossSigningReady();
+ if (isCrossSigningReady) setState("main");
+ else setState("verification_required");
+ }, [matrixClient, setState]);
+
+ useEffect(() => {
+ checkVerificationRequired();
+ }, [checkVerificationRequired]);
+
+ return checkVerificationRequired;
+}
+
+interface VerifySessionPanelProps {
+ /**
+ * Callback to call when the user has finished verifying their session.
+ */
+ onFinish: () => void;
+}
+
+/**
+ * Panel to show when the user needs to verify their session.
+ */
+function VerifySessionPanel({ onFinish }: VerifySessionPanelProps): JSX.Element {
+ return (
+
+ }
+ >
+
+
+ );
+}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index e9ac73b48b4..a49d391d36b 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -542,6 +542,7 @@
"qr_code": "QR Code",
"random": "Random",
"reactions": "Reactions",
+ "recommended": "Recommended",
"report_a_bug": "Report a bug",
"room": "Room",
"room_name": "Room name",
@@ -2461,6 +2462,33 @@
"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_key": "Change recovery key",
+ "change_recovery_key_description": "Get a new recovery key if you've lost your existing one. After changing your recovery key, your old one will no longer work.",
+ "change_recovery_key_title": "Change recovery key?",
+ "confirm_description": "Enter the recovery key shown on the previous screen to finish setting up recovery.",
+ "confirm_finish": "Finish set up",
+ "confirm_title": "Enter your recovery key to confirm",
+ "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_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 f9aee512a30..ae4efc1d729 100644
--- a/test/test-utils/test-utils.ts
+++ b/test/test-utils/test-utils.ts
@@ -127,7 +127,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(),
@@ -136,6 +139,17 @@ export function createTestClient(): MatrixClient {
storeSessionBackupPrivateKey: jest.fn(),
getKeyBackupInfo: jest.fn().mockResolvedValue(null),
getEncryptionInfoForEvent: jest.fn().mockResolvedValue(null),
+ getSessionBackupPrivateKey: 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
,
+
+
+
+ Encryption
+
+
,
-
+
+ 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..e80029be230
--- /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
+ * 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..dd76bc8adf7
--- /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..5d67b4e6b3b
--- /dev/null
+++ b/test/unit-tests/components/views/settings/encryption/ChangeRecoveryKey-test.tsx
@@ -0,0 +1,117 @@
+/*
+ * 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, 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(isSetupFlow = false, 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(true, 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(true, 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(true, 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..757985ecb1e
--- /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
+ * 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..324bda060ea
--- /dev/null
+++ b/test/unit-tests/components/views/settings/encryption/RecoveryPanel-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 { 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 { KeyBackupCheck } from "matrix-js-sdk/src/crypto-api";
+
+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(
+ props = {
+ onSetUpRecoveryClick: jest.fn(),
+ onChangingRecoveryKeyClick: jest.fn(),
+ },
+ ) {
+ return render(, withClientContextRenderOptions(matrixClient));
+ }
+
+ it("should be in loading state when checking backup and the cached keys", () => {
+ jest.spyOn(matrixClient.getCrypto()!, "getSessionBackupPrivateKey").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 key backup", async () => {
+ const user = userEvent.setup();
+
+ const onSetUpRecoveryClick = jest.fn();
+ const { asFragment } = renderRecoverPanel({ onSetUpRecoveryClick, onChangingRecoveryKeyClick: jest.fn() });
+
+ await waitFor(() => screen.getByRole("button", { name: "Set up recovery" }));
+ expect(asFragment()).toMatchSnapshot();
+
+ await user.click(screen.getByRole("button", { name: "Set up recovery" }));
+ expect(onSetUpRecoveryClick).toHaveBeenCalled();
+ });
+
+ it("should ask to enter the recovery key when secrets are not cached", async () => {
+ jest.spyOn(matrixClient.getCrypto()!, "checkKeyBackupAndEnable").mockResolvedValue({} as KeyBackupCheck);
+ 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.getCrypto()!, "checkKeyBackupAndEnable").mockResolvedValue({} as KeyBackupCheck);
+ jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({
+ privateKeysInSecretStorage: true,
+ publicKeysOnDevice: true,
+ privateKeysCachedLocally: {
+ masterKey: true,
+ selfSigningKey: true,
+ userSigningKey: true,
+ },
+ });
+ const user = userEvent.setup();
+
+ const onChangingRecoveryKeyClick = jest.fn();
+ const { asFragment } = renderRecoverPanel({ onSetUpRecoveryClick: jest.fn(), onChangingRecoveryKeyClick });
+ await waitFor(() => screen.getByRole("button", { name: "Change recovery key" }));
+ expect(asFragment()).toMatchSnapshot();
+
+ await user.click(screen.getByRole("button", { name: "Change recovery key" }));
+ expect(onChangingRecoveryKeyClick).toHaveBeenCalled();
+ });
+});
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..a5c5ea1c5d4
--- /dev/null
+++ b/test/unit-tests/components/views/settings/encryption/__snapshots__/ChangeRecoveryKey-test.tsx.snap
@@ -0,0 +1,723 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` flow to change the recovery key should display the recovery key 1`] = `
+
+
+
+
+
+
+
+
+ Change recovery key?
+
+
+ Get a new recovery key if you've lost your existing one. After changing your recovery key, your old one will no longer work.
+
+
+
+
+ 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.
+
+
+
+
+
+`;
+
+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__/RecoverPanel-test.tsx.snap b/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoverPanel-test.tsx.snap
new file mode 100644
index 00000000000..1ed911f5208
--- /dev/null
+++ b/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoverPanel-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 key backup 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 backup 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/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..1ed911f5208
--- /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 key backup 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 backup 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..ba16dad1e82
--- /dev/null
+++ b/test/unit-tests/components/views/settings/tabs/user/EncryptionUserSettingsTab-test.tsx
@@ -0,0 +1,98 @@
+/*
+ * 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);
+ // Key backup is enabled
+ jest.spyOn(matrixClient.getCrypto()!, "getSessionBackupPrivateKey").mockResolvedValue(new Uint8Array(32));
+ // 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 verification 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 device is not verified", 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 device is verified", async () => {
+ const { asFragment } = renderComponent();
+ await waitFor(() => expect(screen.getByText("Recovery")).toBeInTheDocument());
+ expect(asFragment()).toMatchSnapshot();
+ });
+
+ 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.getCrypto()!, "getSessionBackupPrivateKey").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..bb2dce31597
--- /dev/null
+++ b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/EncryptionUserSettingsTab-test.tsx.snap
@@ -0,0 +1,135 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` should display a verify button when the device is not verified 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 recovery panel when the device is verified 1`] = `
+
+
+
+
+
+
+ Recovery
+
+ Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices.
+
+
+
+
+
+
+`;
+
+exports[` should display the set up recovery key when the user clicks on the set up recovery key button 1`] = `
+
+