From 4958dad672907f17d47968a2ed52b3e9ca1cf4b3 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 4 Dec 2024 14:35:24 +0100 Subject: [PATCH 1/3] Refine `SettingsSection` & `SettingsTab` --- res/css/_components.pcss | 1 + res/css/views/settings/_SettingsHeader.pcss | 19 ++++++++ .../views/settings/tabs/_SettingsSection.pcss | 14 ++++++ .../views/settings/SettingsHeader.tsx | 33 +++++++++++++ .../views/settings/shared/SettingsSection.tsx | 46 ++++++++++++++++--- .../views/settings/tabs/SettingsTab.tsx | 7 +-- src/i18n/strings/en_EN.json | 1 + 7 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 res/css/views/settings/_SettingsHeader.pcss create mode 100644 src/components/views/settings/SettingsHeader.tsx diff --git a/res/css/_components.pcss b/res/css/_components.pcss index e9a53cd43c..7426f40799 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -346,6 +346,7 @@ @import "./views/settings/_SetIdServer.pcss"; @import "./views/settings/_SetIntegrationManager.pcss"; @import "./views/settings/_SettingsFieldset.pcss"; +@import "./views/settings/_SettingsHeader.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 0000000000..56b3ccea4e --- /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/tabs/_SettingsSection.pcss b/res/css/views/settings/tabs/_SettingsSection.pcss index a00350c082..d71a32f484 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 0000000000..413fb8ffa5 --- /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/shared/SettingsSection.tsx b/src/components/views/settings/shared/SettingsSection.tsx index a42a2a9b78..2ffc14c459 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; @@ -31,6 +36,15 @@ function renderHeading(heading: string | React.ReactNode | undefined): React.Rea } } +function renderSubHeading(subHeading: string | React.ReactNode | undefined): React.ReactNode | undefined { + switch (typeof subHeading) { + case "undefined": + return undefined; + default: + return subHeading; + } +} + /** * A section of settings content * A SettingsTab may contain one or more SettingsSections @@ -48,9 +62,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)} + {renderSubHeading(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 7472da22e7..bf6545f2e4 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 e9ac73b48b..e53d675e2a 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 8747e62103e7fd18d0c8bfe4e0bdaa708d2cb30a Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 4 Dec 2024 16:38:54 +0100 Subject: [PATCH 2/3] 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 8ae7a302ac..2acdca7996 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 467a67cd9f..99c349ee4b 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 0000000000..7964f2641a --- /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 e53d675e2a..ffdbddb6df 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 1811aa6c9805f0d3d1f43dc99f6419a35dc2e68e Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Fri, 6 Dec 2024 11:51:34 +0100 Subject: [PATCH 3/3] Add recovery section --- res/css/_common.pcss | 22 +- res/css/_components.pcss | 3 + .../encryption/_ChangeRecoveryKey.pcss | 73 ++++ .../settings/encryption/_EncryptionCard.pcss | 33 ++ .../settings/encryption/_RecoveryPanel.pcss | 22 ++ res/css/views/settings/tabs/_SettingsTab.pcss | 2 +- .../settings/encryption/ChangeRecoveryKey.tsx | 312 ++++++++++++++++++ .../settings/encryption/EncryptionCard.tsx | 51 +++ .../settings/encryption/RecoveryPanel.tsx | 124 +++++++ .../tabs/user/EncryptionUserSettingsTab.tsx | 40 ++- src/i18n/strings/en_EN.json | 21 ++ 11 files changed, 693 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 res/css/views/settings/encryption/_RecoveryPanel.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 74328af39b..9964ec8e50 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 7426f40799..b424c1e3c1 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -351,6 +351,9 @@ @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/_RecoveryPanel.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 0000000000..dccc50c684 --- /dev/null +++ b/res/css/views/settings/encryption/_ChangeRecoveryKey.pcss @@ -0,0 +1,73 @@ +/* + * 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_WarningPanel_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; + + 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 { + 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 0000000000..605ab49b43 --- /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/encryption/_RecoveryPanel.pcss b/res/css/views/settings/encryption/_RecoveryPanel.pcss new file mode 100644 index 0000000000..0ecc51187d --- /dev/null +++ b/res/css/views/settings/encryption/_RecoveryPanel.pcss @@ -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. + */ + +.mx_RecoveryPanel { + .mx_RecoveryPanel_Subheader { + 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); + color: var(--cpd-color-text-success-primary); + } + } +} diff --git a/res/css/views/settings/tabs/_SettingsTab.pcss b/res/css/views/settings/tabs/_SettingsTab.pcss index 43a5a8fd10..d394524dc3 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 0000000000..c2d8f9def7 --- /dev/null +++ b/src/components/views/settings/encryption/ChangeRecoveryKey.tsx @@ -0,0 +1,312 @@ +/* + * 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, + IconButton, + Button, + Root, + TextControl, + Field, + Label, + ErrorMessage, + Text, +} 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.tsx"; +import { EncryptionCard } from "./EncryptionCard.tsx"; +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext.tsx"; +import { useAsyncMemo } from "../../../../hooks/useAsyncMemo.ts"; +import { copyPlaintext } from "../../../../utils/strings.ts"; +import { withSecretStorageKeyCache } from "../../../../SecurityManager.ts"; + +/** + * The possible states of the component. + * - `warn_user`: The user is warned about the consequences of changing 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 = "warn_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; +} + +export function ChangeRecoveryKey({ + isSetupFlow, + onFinish, + onCancelClick, +}: ChangeRecoveryKeyProps): JSX.Element | null { + console.log("ChangeRecoveryKey.tsx: ChangeRecoveryKey"); + const matrixClient = useMatrixClientContext(); + + const [state, setState] = useState(isSetupFlow ? "warn_user" : "save_key_change_flow"); + const labels = getLabels(state); + + const recoveryKey = useAsyncMemo(() => { + const crypto = matrixClient.getCrypto(); + if (!crypto) return Promise.resolve(undefined); + + return crypto.createRecoveryKeyFromPassphrase(); + }, []); + + console.log(recoveryKey); + + if (!recoveryKey?.encodedPrivateKey) return null; + + console.log("ChangeRecoveryKey.tsx: ChangeRecoveryKey"); + + let content: JSX.Element; + switch (state) { + case "warn_user": + content = ( + setState("save_key_setup_flow")} onCancelClick={onCancelClick} /> + ); + break; + case "save_key_setup_flow": + content = ( + setState("confirm")} + onCancelClick={onCancelClick} + /> + ); + break; + 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"), + ]; + + return ( + <> + + + {content} + + + ); +} + +type Labels = { + title: string; + description: string; +}; + +function getLabels(state: State): Labels { + switch (state) { + case "warn_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 WarningPanelProps { + /** + * Called when the continue button is clicked. + */ + onContinueClick: MouseEventHandler; + /** + * Called when the cancel button is clicked. + */ + onCancelClick: MouseEventHandler; +} + +function WarningPanel({ onContinueClick, onCancelClick }: WarningPanelProps): 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; +} + +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 0000000000..8a10802cc3 --- /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 0000000000..1d20831d3b --- /dev/null +++ b/src/components/views/settings/encryption/RecoveryPanel.tsx @@ -0,0 +1,124 @@ +/* + * 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, 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 CheckCircleIcon from "@vector-im/compound-design-tokens/assets/web/icons/check-circle-solid"; + +import { SettingsSection } from "../shared/SettingsSection"; +import { _t } from "../../../../languageHandler"; +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import { SettingsHeader } from "../SettingsHeader"; + +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 isGood = state === "good"; + const isMissingBackup = state === "missing_backup"; + const areSecretsNotCached = state === "secrets_not_cached"; + const hasError = isMissingBackup || areSecretsNotCached; + + const matrixClient = useMatrixClientContext(); + + useEffect(() => { + const check = async (): Promise => { + const crypto = matrixClient.getCrypto(); + if (!crypto) return; + + console.log("Recovery Panel: Checking recovery key status"); + + const hasBackup = (await crypto.getKeyBackupInfo()) && (await crypto.getSessionBackupPrivateKey()); + if (!hasBackup) return setState("missing_backup"); + + const cachedSecrets = (await crypto.getCrossSigningStatus()).privateKeysCachedLocally; + const secretsOk = cachedSecrets.masterKey && cachedSecrets.selfSigningKey && cachedSecrets.userSigningKey; + if (!secretsOk) return setState("secrets_not_cached"); + + setState("good"); + }; + check(); + }, [matrixClient]); + + 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={} + className="mx_RecoveryPanel" + > + {content} + + ); +} + +/** + * The subheader for the recovery panel. + */ +interface SubheaderProps { + /** + * Whether the user has a recovery key. + * If null, the recovery key is still fetching. + */ + hasRecoveryKey: boolean | null; +} + +function Subheader({ hasRecoveryKey }: SubheaderProps): JSX.Element { + if (!hasRecoveryKey) return <>{_t("settings|encryption|recovery|description")}; + + return ( +
+ {_t("settings|encryption|recovery|description")} + + + {_t("settings|encryption|recovery|key_active")} + +
+ ); +} diff --git a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx index 7964f2641a..b7d0fc0fc1 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.tsx"; + +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 ffdbddb6df..acbe262b45 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2464,6 +2464,27 @@ "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_recovery_key": "Confirm recovery key", + "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_key_title": "Enter recovery key", + "key_active": "Recovery key active", + "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": {