From 3e77b3d5379b84235fe7d3c681072ca4da0cbb10 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 4 Dec 2024 14:35:24 +0100 Subject: [PATCH 01/16] Refine `SettingsSection` & `SettingsTab` --- res/css/_components.pcss | 2 + res/css/views/settings/_SettingsHeader.pcss | 19 +++++++ .../views/settings/_SettingsSubheader.pcss | 27 ++++++++++ .../views/settings/tabs/_SettingsSection.pcss | 14 ++++++ .../views/settings/SettingsHeader.tsx | 33 ++++++++++++ .../views/settings/SettingsSubheader.tsx | 50 +++++++++++++++++++ .../views/settings/shared/SettingsSection.tsx | 37 +++++++++++--- .../views/settings/tabs/SettingsTab.tsx | 7 +-- src/i18n/strings/en_EN.json | 1 + 9 files changed, 181 insertions(+), 9 deletions(-) create mode 100644 res/css/views/settings/_SettingsHeader.pcss create mode 100644 res/css/views/settings/_SettingsSubheader.pcss create mode 100644 src/components/views/settings/SettingsHeader.tsx create mode 100644 src/components/views/settings/SettingsSubheader.tsx diff --git a/res/css/_components.pcss b/res/css/_components.pcss index e9a53cd43cc..af930ee58ae 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -346,6 +346,8 @@ @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"; diff --git a/res/css/views/settings/_SettingsHeader.pcss b/res/css/views/settings/_SettingsHeader.pcss new file mode 100644 index 00000000000..56b3ccea4eb --- /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 + * 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..b35bad96417 --- /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 + * 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/tabs/_SettingsSection.pcss b/res/css/views/settings/tabs/_SettingsSection.pcss index a00350c082c..d71a32f4842 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/src/components/views/settings/SettingsHeader.tsx b/src/components/views/settings/SettingsHeader.tsx new file mode 100644 index 00000000000..413fb8ffa5e --- /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 + * 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..555f01c46ee --- /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 + * 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/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 }) => ( -
- {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 7472da22e78..bf6545f2e4c 100644 --- a/src/components/views/settings/tabs/SettingsTab.tsx +++ b/src/components/views/settings/tabs/SettingsTab.tsx @@ -6,8 +6,9 @@ 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, { HTMLAttributes } from "react"; +import classNames from "classnames"; -export interface SettingsTabProps extends Omit, "className"> { +export interface SettingsTabProps extends HTMLAttributes { children?: React.ReactNode; } @@ -29,8 +30,8 @@ export interface SettingsTabProps extends Omit, " * * ``` */ -const SettingsTab: React.FC = ({ children, ...rest }) => ( -
+const SettingsTab: React.FC = ({ children, className, ...rest }) => ( +
{children}
); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index e9ac73b48b4..e53d675e2ae 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", From ae623f8319f04b8f4104648c172086786ce4078b Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 4 Dec 2024 16:38:54 +0100 Subject: [PATCH 02/16] Add encryption tab --- .../views/dialogs/UserSettingsDialog.tsx | 8 ++++++++ src/components/views/dialogs/UserTab.ts | 1 + .../tabs/user/EncryptionUserSettingsTab.tsx | 14 ++++++++++++++ src/i18n/strings/en_EN.json | 4 ++++ 4 files changed, 27 insertions(+) create mode 100644 src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx diff --git a/src/components/views/dialogs/UserSettingsDialog.tsx b/src/components/views/dialogs/UserSettingsDialog.tsx index 8ae7a302ac4..2acdca79965 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 467a67cd9f6..99c349ee4b7 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/tabs/user/EncryptionUserSettingsTab.tsx b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx new file mode 100644 index 00000000000..7964f2641ae --- /dev/null +++ b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @@ -0,0 +1,14 @@ +/* + * 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 } from "react"; + +import SettingsTab from "../SettingsTab"; + +export function EncryptionUserSettingsTab(): JSX.Element { + return ; +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index e53d675e2ae..ffdbddb6df8 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2462,6 +2462,10 @@ "emoji_autocomplete": "Enable Emoji suggestions while typing", "enable_markdown": "Enable Markdown", "enable_markdown_description": "Start messages with /plain to send without markdown.", + "encryption": { + "dialog_title": "Settings: Encryption", + "title": "Encryption" + }, "general": { "account_management_section": "Account management", "account_section": "Account", From f9e48b4e3b39d989789b6ac8b3a75f0a69575106 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Fri, 6 Dec 2024 11:51:34 +0100 Subject: [PATCH 03/16] Add recovery section --- res/css/_common.pcss | 22 +- res/css/_components.pcss | 2 + .../encryption/_ChangeRecoveryKey.pcss | 76 ++++ .../settings/encryption/_EncryptionCard.pcss | 33 ++ res/css/views/settings/tabs/_SettingsTab.pcss | 2 +- .../settings/encryption/ChangeRecoveryKey.tsx | 324 ++++++++++++++++++ .../settings/encryption/EncryptionCard.tsx | 51 +++ .../settings/encryption/RecoveryPanel.tsx | 134 ++++++++ .../tabs/user/EncryptionUserSettingsTab.tsx | 40 ++- src/i18n/strings/en_EN.json | 20 ++ 10 files changed, 694 insertions(+), 10 deletions(-) create mode 100644 res/css/views/settings/encryption/_ChangeRecoveryKey.pcss create mode 100644 res/css/views/settings/encryption/_EncryptionCard.pcss create mode 100644 src/components/views/settings/encryption/ChangeRecoveryKey.tsx create mode 100644 src/components/views/settings/encryption/EncryptionCard.tsx create mode 100644 src/components/views/settings/encryption/RecoveryPanel.tsx diff --git a/res/css/_common.pcss b/res/css/_common.pcss index 74328af39b2..9964ec8e508 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 af930ee58ae..805b6056e95 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -352,6 +352,8 @@ @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/encryption/_ChangeRecoveryKey.pcss b/res/css/views/settings/encryption/_ChangeRecoveryKey.pcss new file mode 100644 index 00000000000..824d8b76f75 --- /dev/null +++ b/res/css/views/settings/encryption/_ChangeRecoveryKey.pcss @@ -0,0 +1,76 @@ +/* + * 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. + */ + +.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: InputMono, monospace; + 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..605ab49b43c --- /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 + * 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/_SettingsTab.pcss b/res/css/views/settings/tabs/_SettingsTab.pcss index 43a5a8fd104..d394524dc32 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/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 ( + <> +
+ + {_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 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..b047b528e3e --- /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/tabs/user/EncryptionUserSettingsTab.tsx b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx index 7964f2641ae..fa7db599a53 100644 --- a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @@ -5,10 +5,46 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { JSX } from "react"; +import React, { JSX, useState } from "react"; import SettingsTab from "../SettingsTab"; +import { RecoveryPanel } from "../../encryption/RecoveryPanel"; +import { ChangeRecoveryKey } from "../../encryption/ChangeRecoveryKey"; + +type Panel = "main" | "change_recovery_key" | "set_recovery_key"; export function EncryptionUserSettingsTab(): JSX.Element { - return ; + const [panel, setPanel] = useState("main"); + + let content: JSX.Element; + switch (panel) { + case "main": + content = ( + setPanel("change_recovery_key")} + onSetUpRecoveryClick={() => setPanel("set_recovery_key")} + /> + ); + break; + case "set_recovery_key": + content = ( + setPanel("main")} + onFinish={() => setPanel("main")} + /> + ); + break; + case "change_recovery_key": + content = ( + setPanel("main")} + onFinish={() => setPanel("main")} + /> + ); + break; + } + + return {content}; } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index ffdbddb6df8..8f6ab46b613 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2464,6 +2464,26 @@ "enable_markdown_description": "Start messages with /plain to send without markdown.", "encryption": { "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": { From 0057f57db95f4494f0238d2daa0247f897284908 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Thu, 12 Dec 2024 17:16:49 +0100 Subject: [PATCH 04/16] Add device verification --- .../tabs/user/EncryptionUserSettingsTab.tsx | 87 ++++++++++++++++++- src/i18n/strings/en_EN.json | 3 + 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx index fa7db599a53..c4ed28117e7 100644 --- a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @@ -5,19 +5,42 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { JSX, useState } from "react"; +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"; -type Panel = "main" | "change_recovery_key" | "set_recovery_key"; +/** + * The panel to show 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 Panel = "loading" | "main" | "verification_required" | "change_recovery_key" | "set_recovery_key"; export function EncryptionUserSettingsTab(): JSX.Element { - const [panel, setPanel] = useState("main"); + const [panel, setPanel] = useState("loading"); + const checkVerificationRequired = useVerificationRequired(setPanel); let content: JSX.Element; switch (panel) { + case "loading": + content = ; + break; + case "verification_required": + content = ; + break; case "main": content = ( {content}; } + +/** + * Hook to check if the user needs to verify their session. + * If the user needs to verify their session, the panel will be set to "verification_required". + * If the user doesn't need to verify their session, the panel will be set to "main". + * @param setPanel + */ +function useVerificationRequired(setPanel: (panel: Panel) => void): () => Promise { + const matrixClient = useMatrixClientContext(); + + const checkVerificationRequired = useCallback(async () => { + const crypto = matrixClient.getCrypto(); + if (!crypto) return; + + const isCrossSigningReady = await crypto.isCrossSigningReady(); + if (isCrossSigningReady) setPanel("main"); + else setPanel("verification_required"); + }, [matrixClient, setPanel]); + + 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 8f6ab46b613..a49d391d36b 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2463,6 +2463,9 @@ "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", From bb507b092373c49e1652ef78e9c33f712ed293ad Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Fri, 13 Dec 2024 15:09:39 +0100 Subject: [PATCH 05/16] Rename `Panel` into `State` --- .../tabs/user/EncryptionUserSettingsTab.tsx | 50 +++++++++---------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx index c4ed28117e7..b8cbfa44fd2 100644 --- a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @@ -20,21 +20,21 @@ import { SettingsSection } from "../../shared/SettingsSection"; import { SettingsSubheader } from "../../SettingsSubheader"; /** - * The panel to show in the encryption settings tab. + * 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 Panel = "loading" | "main" | "verification_required" | "change_recovery_key" | "set_recovery_key"; +type State = "loading" | "main" | "verification_required" | "change_recovery_key" | "set_recovery_key"; export function EncryptionUserSettingsTab(): JSX.Element { - const [panel, setPanel] = useState("loading"); - const checkVerificationRequired = useVerificationRequired(setPanel); + const [state, setState] = useState("loading"); + const checkVerificationRequired = useVerificationRequired(setState); let content: JSX.Element; - switch (panel) { + switch (state) { case "loading": content = ; break; @@ -44,41 +44,37 @@ export function EncryptionUserSettingsTab(): JSX.Element { case "main": content = ( setPanel("change_recovery_key")} - onSetUpRecoveryClick={() => setPanel("set_recovery_key")} - /> - ); - break; - case "set_recovery_key": - content = ( - setPanel("main")} - onFinish={() => setPanel("main")} + onChangingRecoveryKeyClick={() => setState("change_recovery_key")} + onSetUpRecoveryClick={() => setState("set_recovery_key")} /> ); break; case "change_recovery_key": + case "set_recovery_key": content = ( setPanel("main")} - onFinish={() => setPanel("main")} + isSetupFlow={state === "set_recovery_key"} + onCancelClick={() => setState("main")} + onFinish={() => setState("main")} /> ); break; } - return {content}; + return ( + + {content} + + ); } /** * Hook to check if the user needs to verify their session. - * If the user needs to verify their session, the panel will be set to "verification_required". - * If the user doesn't need to verify their session, the panel will be set to "main". - * @param setPanel + * 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(setPanel: (panel: Panel) => void): () => Promise { +function useVerificationRequired(setState: (state: State) => void): () => Promise { const matrixClient = useMatrixClientContext(); const checkVerificationRequired = useCallback(async () => { @@ -86,9 +82,9 @@ function useVerificationRequired(setPanel: (panel: Panel) => void): () => Promis if (!crypto) return; const isCrossSigningReady = await crypto.isCrossSigningReady(); - if (isCrossSigningReady) setPanel("main"); - else setPanel("verification_required"); - }, [matrixClient, setPanel]); + if (isCrossSigningReady) setState("main"); + else setState("verification_required"); + }, [matrixClient, setState]); useEffect(() => { checkVerificationRequired(); From 1aace3f87e4de203ad4640c8f78c2cc96951fce1 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Fri, 13 Dec 2024 15:34:19 +0100 Subject: [PATCH 06/16] Update & add tests to user settings common --- .../UserSettingsDialog-test.tsx.snap | 26 +++++++ .../views/settings/SettingsHeader-test.tsx | 23 ++++++ .../views/settings/SettingsSubheader-test.tsx | 28 +++++++ .../SettingsHeader-test.tsx.snap | 24 ++++++ .../SettingsSubheader-test.tsx.snap | 77 +++++++++++++++++++ .../encryption/EncryptionCard-test.tsx | 22 ++++++ .../EncryptionCard-test.tsx.snap | 39 ++++++++++ 7 files changed, 239 insertions(+) create mode 100644 test/unit-tests/components/views/settings/SettingsHeader-test.tsx create mode 100644 test/unit-tests/components/views/settings/SettingsSubheader-test.tsx create mode 100644 test/unit-tests/components/views/settings/__snapshots__/SettingsHeader-test.tsx.snap create mode 100644 test/unit-tests/components/views/settings/__snapshots__/SettingsSubheader-test.tsx.snap create mode 100644 test/unit-tests/components/views/settings/encryption/EncryptionCard-test.tsx create mode 100644 test/unit-tests/components/views/settings/encryption/__snapshots__/EncryptionCard-test.tsx.snap 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 , + ,
  • ", () => { + 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/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/__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 +
    +
    +`; From 70c084e8799263021d313945af5052d82177f7d4 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 18 Dec 2024 10:43:49 +0100 Subject: [PATCH 07/16] Add tests to `RecoveryPanel` --- .../settings/encryption/RecoveryPanel.tsx | 2 +- test/test-utils/test-utils.ts | 16 +- .../encryption/RecoveryPanel-test.tsx | 97 ++++++++++ .../__snapshots__/RecoveryPanel-test.tsx.snap | 179 ++++++++++++++++++ 4 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 test/unit-tests/components/views/settings/encryption/RecoveryPanel-test.tsx create mode 100644 test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanel-test.tsx.snap diff --git a/src/components/views/settings/encryption/RecoveryPanel.tsx b/src/components/views/settings/encryption/RecoveryPanel.tsx index b047b528e3e..df555a583ad 100644 --- a/src/components/views/settings/encryption/RecoveryPanel.tsx +++ b/src/components/views/settings/encryption/RecoveryPanel.tsx @@ -68,7 +68,7 @@ export function RecoveryPanel({ onSetUpRecoveryClick, onChangingRecoveryKeyClick let content: JSX.Element; switch (state) { case "loading": - content = ; + content = ; break; case "missing_backup": content = ( diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index f9aee512a30..902dc32e6ea 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), + getCrossSigningStatus: jest.fn().mockResolvedValue({ + publicKeysOnDevice: false, + privateKeysInSecretStorage: false, + privateKeysCachedLocally: { + masterKey: false, + selfSigningKey: false, + userSigningKey: false, + }, + }), + isCrossSigningReady: jest.fn().mockResolvedValue(false), + checkKeyBackupAndEnable: jest.fn().mockResolvedValue(null), }), getPushActionsForEvent: jest.fn(), 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..ce81b51b6f4 --- /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()!, "checkKeyBackupAndEnable").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__/RecoveryPanel-test.tsx.snap b/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanel-test.tsx.snap new file mode 100644 index 00000000000..864116a9c2f --- /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. +
    + + + +
    +
    +`; From 7193998905f2e83b2938654dbda0c1c74ccd2a69 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 18 Dec 2024 10:43:59 +0100 Subject: [PATCH 08/16] Add tests to `ChangeRecoveryKey` --- .../encryption/ChangeRecoveryKey-test.tsx | 117 +++ .../ChangeRecoveryKey-test.tsx.snap | 725 ++++++++++++++++++ 2 files changed, 842 insertions(+) create mode 100644 test/unit-tests/components/views/settings/encryption/ChangeRecoveryKey-test.tsx create mode 100644 test/unit-tests/components/views/settings/encryption/__snapshots__/ChangeRecoveryKey-test.tsx.snap 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/__snapshots__/ChangeRecoveryKey-test.tsx.snap b/test/unit-tests/components/views/settings/encryption/__snapshots__/ChangeRecoveryKey-test.tsx.snap new file mode 100644 index 00000000000..ee18396e448 --- /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? +

    + + 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. + +
    +
    +
    + + + + + + + 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! + +
    + +
    + +
    +
    +`; From fec324ec10b3232607d0f35f3876faf9cb97f4cc Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Mon, 16 Dec 2024 15:43:24 +0100 Subject: [PATCH 09/16] Update CreateSecretStorageDialog-test snapshot --- .../__snapshots__/CreateSecretStorageDialog-test.tsx.snap | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/unit-tests/components/views/dialogs/security/__snapshots__/CreateSecretStorageDialog-test.tsx.snap b/test/unit-tests/components/views/dialogs/security/__snapshots__/CreateSecretStorageDialog-test.tsx.snap index 5ad35904225..a39ce29439d 100644 --- a/test/unit-tests/components/views/dialogs/security/__snapshots__/CreateSecretStorageDialog-test.tsx.snap +++ b/test/unit-tests/components/views/dialogs/security/__snapshots__/CreateSecretStorageDialog-test.tsx.snap @@ -165,7 +165,9 @@ exports[`CreateSecretStorageDialog handles the happy path 2`] = `
    - + + encoded private key +
    Date: Mon, 16 Dec 2024 17:18:22 +0100 Subject: [PATCH 10/16] Add tests to `EncryptionUserSettingsTab` --- .../tabs/user/EncryptionUserSettingsTab.tsx | 2 +- test/test-utils/test-utils.ts | 1 - .../user/EncryptionUserSettingsTab-test.tsx | 98 +++++++++++++++++++ .../EncryptionUserSettingsTab-test.tsx.snap | 95 ++++++++++++++++++ 4 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 test/unit-tests/components/views/settings/tabs/user/EncryptionUserSettingsTab-test.tsx create mode 100644 test/unit-tests/components/views/settings/tabs/user/__snapshots__/EncryptionUserSettingsTab-test.tsx.snap diff --git a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx index b8cbfa44fd2..97e6ed1ab53 100644 --- a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @@ -36,7 +36,7 @@ export function EncryptionUserSettingsTab(): JSX.Element { let content: JSX.Element; switch (state) { case "loading": - content = ; + content = ; break; case "verification_required": content = ; diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 902dc32e6ea..883d1fa7ebe 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -149,7 +149,6 @@ export function createTestClient(): MatrixClient { }, }), isCrossSigningReady: jest.fn().mockResolvedValue(false), - checkKeyBackupAndEnable: jest.fn().mockResolvedValue(null), }), getPushActionsForEvent: jest.fn(), 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..d0a01fe6035 --- /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 { KeyBackupCheck } from "matrix-js-sdk/src/crypto-api"; + +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()!, "checkKeyBackupAndEnable").mockResolvedValue({} as KeyBackupCheck); + // 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 () => { + 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.getCrypto()!, "checkKeyBackupAndEnable").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..fd63b70c764 --- /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 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 set up recovery key when the user clicks on the set up recovery key button 1`] = ` + +
    +
    +
    + +`; From 075f6dca5714a4e8faf03765882a80246825a20b Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Mon, 16 Dec 2024 17:44:35 +0100 Subject: [PATCH 11/16] Update existing screenshots of e2e tests --- .../account-linux.png | Bin 55908 -> 57590 bytes .../account-smallscreen-linux.png | Bin 30299 -> 30067 bytes .../appearance-tab-linux.png | Bin 60155 -> 61895 bytes .../window-12px-linux.png | Bin 68174 -> 69546 bytes ...ab-should-be-rendered-properly-1-linux.png | Bin 244526 -> 245045 bytes 5 files changed, 0 insertions(+), 0 deletions(-) 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 c641f2a0aafc4bf686317008c14d24aff2789ffa..c800df354f0de3fc21f18a3f4043e741f260578b 100644 GIT binary patch delta 22321 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 UX|eNH0|b+U?(QDkA$V|icX#(4^1SElp8ffL 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 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 b9d81c5d5a8226a0536df24f5454bfe9457369d5..b31592c79585073855cb204a60ea84bb83de5849 100644 GIT binary patch delta 11562 zcmbt)by!s0_wV?kq9Ve8ND1iBjUY%3yyAd>gh;nYNq5IV5Expz1Vp-q?v(DKN4mQi zI`8rOet$Qf`@8kr=b1ld@3YT3Yp=ccTA%er2fh52X_)tf(j&KTfpXD|C zM@=G&>axF%AKO$u-h$P@e%m%}tw_5RzN)cdoymM4JV(8+U-r)$t9kf-71iFYAR$=Y={ZiwHz@pL}+-`#0g?9r

    ev^7RRamBUP=p9&%ZTbGz8Qv@V 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 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 75a4852f9b9dea9954fb1c7902ba6f56fb0b82cc..a8b3ae3ea7784cdda5184e54b4d7b5064c385123 100644 GIT binary patch delta 32637 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