Add Advanced section to the user settings encryption tab (#28804)

* Make the encryption card more configurable:
- Change the icon
- Can set the destructive props

* Update compound

* Add advanced section

* Add the `Never send encrypted messages to unverified devices` settings

* - Add commercial license
- Remove generic type

* Rename EncryptionDetails css classes

* Use same uiAuthCallback

* Use h3 for title

* Add tests to `AdvancedPanel`

* Add tests to `EncryptionUserSettingsTab`

* Add tests to `ResetIdentityPanel`

* Get only the recovery section in recovery tests

* Add e2e test
This commit is contained in:
Florian Duros
2025-01-24 09:33:16 +01:00
committed by GitHub
parent a0044d6b5f
commit ac565dca80
31 changed files with 1296 additions and 71 deletions

View File

@@ -31,49 +31,50 @@ export async function createCrossSigning(cli: MatrixClient): Promise<void> {
throw new Error("No crypto API found!");
}
const doBootstrapUIAuth = async (
makeRequest: (authData: AuthDict) => Promise<UIAResponse<void>>,
): Promise<void> => {
try {
await makeRequest({});
} catch (error) {
if (!(error instanceof MatrixError) || !error.data || !error.data.flows) {
// Not a UIA response
throw error;
}
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("auth|uia|sso_title"),
body: _t("auth|uia|sso_preauth_body"),
continueText: _t("auth|sso"),
continueKind: "primary",
},
[SSOAuthEntry.PHASE_POSTAUTH]: {
title: _t("encryption|confirm_encryption_setup_title"),
body: _t("encryption|confirm_encryption_setup_body"),
continueText: _t("action|confirm"),
continueKind: "primary",
},
};
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
title: _t("encryption|bootstrap_title"),
matrixClient: cli,
makeRequest,
aestheticsForStagePhases: {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
},
});
const [confirmed] = await finished;
if (!confirmed) {
throw new Error("Cross-signing key upload auth canceled");
}
}
};
await cryptoApi.bootstrapCrossSigning({
authUploadDeviceSigningKeys: doBootstrapUIAuth,
authUploadDeviceSigningKeys: (makeRequest) => uiAuthCallback(cli, makeRequest),
});
}
export async function uiAuthCallback(
matrixClient: MatrixClient,
makeRequest: (authData: AuthDict) => Promise<UIAResponse<void>>,
): Promise<void> {
try {
await makeRequest({});
} catch (error) {
if (!(error instanceof MatrixError) || !error.data || !error.data.flows) {
// Not a UIA response
throw error;
}
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("auth|uia|sso_title"),
body: _t("auth|uia|sso_preauth_body"),
continueText: _t("auth|sso"),
continueKind: "primary",
},
[SSOAuthEntry.PHASE_POSTAUTH]: {
title: _t("encryption|confirm_encryption_setup_title"),
body: _t("encryption|confirm_encryption_setup_body"),
continueText: _t("action|confirm"),
continueKind: "primary",
},
};
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
title: _t("encryption|bootstrap_title"),
matrixClient,
makeRequest,
aestheticsForStagePhases: {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
},
});
const [confirmed] = await finished;
if (!confirmed) {
throw new Error("Cross-signing key upload auth canceled");
}
}
}

View File

@@ -0,0 +1,139 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { JSX, lazy, MouseEventHandler } from "react";
import { Button, HelpMessage, InlineField, InlineSpinner, Label, Root, ToggleControl } from "@vector-im/compound-web";
import DownloadIcon from "@vector-im/compound-design-tokens/assets/web/icons/download";
import ShareIcon from "@vector-im/compound-design-tokens/assets/web/icons/share";
import { _t } from "../../../../languageHandler";
import { SettingsSection } from "../shared/SettingsSection";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
import Modal from "../../../../Modal";
import { SettingLevel } from "../../../../settings/SettingLevel";
import { useSettingValueAt } from "../../../../hooks/useSettings";
import SettingsStore from "../../../../settings/SettingsStore";
interface AdvancedPanelProps {
/**
* Callback for when the user clicks the button to reset their identity.
*/
onResetIdentityClick: MouseEventHandler<HTMLButtonElement>;
}
/**
* The advanced panel of the encryption settings.
*/
export function AdvancedPanel({ onResetIdentityClick }: AdvancedPanelProps): JSX.Element {
return (
<SettingsSection heading={_t("settings|encryption|advanced|title")} legacy={false}>
<EncryptionDetails onResetIdentityClick={onResetIdentityClick} />
<OtherSettings />
</SettingsSection>
);
}
interface EncryptionDetails {
/**
* Callback for when the user clicks the button to reset their identity.
*/
onResetIdentityClick: MouseEventHandler<HTMLButtonElement>;
}
/**
* The encryption details section of the advanced panel.
*/
function EncryptionDetails({ onResetIdentityClick }: EncryptionDetails): JSX.Element {
const matrixClient = useMatrixClientContext();
// Null when the keys are not loaded yet
const keys = useAsyncMemo(() => matrixClient.getCrypto()!.getOwnDeviceKeys(), [matrixClient], null);
return (
<div className="mx_EncryptionDetails" data-testid="encryptionDetails">
<div className="mx_EncryptionDetails_session">
<h3 className="mx_EncryptionDetails_session_title">
{_t("settings|encryption|advanced|details_title")}
</h3>
<div>
<span>{_t("settings|encryption|advanced|session_id")}</span>
<span data-testid="deviceId">{matrixClient.deviceId}</span>
</div>
<div>
<span>{_t("settings|encryption|advanced|session_key")}</span>
<span data-testid="sessionKey">
{keys ? keys.ed25519 : <InlineSpinner aria-label={_t("common|loading")} />}
</span>
</div>
</div>
<div className="mx_EncryptionDetails_buttons">
<Button
size="sm"
kind="secondary"
Icon={ShareIcon}
onClick={() =>
Modal.createDialog(
lazy(
() => import("../../../../async-components/views/dialogs/security/ExportE2eKeysDialog"),
),
{ matrixClient },
)
}
>
{_t("settings|encryption|advanced|export_keys")}
</Button>
<Button
size="sm"
kind="secondary"
Icon={DownloadIcon}
onClick={() =>
Modal.createDialog(
lazy(
() => import("../../../../async-components/views/dialogs/security/ImportE2eKeysDialog"),
),
{ matrixClient },
)
}
>
{_t("settings|encryption|advanced|import_keys")}
</Button>
</div>
<Button size="sm" kind="tertiary" destructive={true} onClick={onResetIdentityClick}>
{_t("settings|encryption|advanced|reset_identity")}
</Button>
</div>
);
}
/**
* Display the never send encrypted message to unverified devices setting.
*/
function OtherSettings(): JSX.Element | null {
const blacklistUnverifiedDevices = useSettingValueAt(SettingLevel.DEVICE, "blacklistUnverifiedDevices");
const canSetValue = SettingsStore.canSetValue("blacklistUnverifiedDevices", null, SettingLevel.DEVICE);
if (!canSetValue) return null;
return (
<Root
data-testid="otherSettings"
className="mx_OtherSettings"
onChange={async (evt) => {
const checked = new FormData(evt.currentTarget).get("neverSendEncrypted") === "on";
await SettingsStore.setValue("blacklistUnverifiedDevices", null, SettingLevel.DEVICE, checked);
}}
>
<h3 className="mx_OtherSettings_title">{_t("settings|encryption|advanced|other_people_device_title")}</h3>
<InlineField
name="neverSendEncrypted"
control={<ToggleControl name="neverSendEncrypted" defaultChecked={blacklistUnverifiedDevices} />}
>
<Label>{_t("settings|encryption|advanced|other_people_device_label")}</Label>
<HelpMessage>{_t("settings|encryption|advanced|other_people_device_description")}</HelpMessage>
</InlineField>
</Root>
);
}

View File

@@ -18,6 +18,7 @@ import {
TextControl,
} from "@vector-im/compound-web";
import CopyIcon from "@vector-im/compound-design-tokens/assets/web/icons/copy";
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key-solid";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../../languageHandler";
@@ -157,7 +158,12 @@ export function ChangeRecoveryKey({
pages={pages}
onPageClick={onCancelClick}
/>
<EncryptionCard title={labels.title} description={labels.description} className="mx_ChangeRecoveryKey">
<EncryptionCard
Icon={KeyIcon}
title={labels.title}
description={labels.description}
className="mx_ChangeRecoveryKey"
>
{content}
</EncryptionCard>
</>

View File

@@ -5,9 +5,8 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { JSX, PropsWithChildren } from "react";
import React, { JSX, PropsWithChildren, ComponentType, SVGAttributes } from "react";
import { BigIcon, Heading } from "@vector-im/compound-web";
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key-solid";
import classNames from "classnames";
interface EncryptionCardProps {
@@ -22,7 +21,15 @@ interface EncryptionCardProps {
/**
* The description of the card.
*/
description: string;
description?: string;
/**
* Whether this icon shows a destructive action.
*/
destructive?: boolean;
/**
* The icon to display.
*/
Icon: ComponentType<SVGAttributes<SVGElement>>;
}
/**
@@ -32,18 +39,20 @@ export function EncryptionCard({
title,
description,
className,
destructive = false,
Icon,
children,
}: PropsWithChildren<EncryptionCardProps>): JSX.Element {
return (
<div className={classNames("mx_EncryptionCard", className)}>
<div className="mx_EncryptionCard_header">
<BigIcon>
<KeyIcon />
<BigIcon destructive={destructive}>
<Icon />
</BigIcon>
<Heading as="h2" size="sm" weight="semibold">
{title}
</Heading>
<span>{description}</span>
{description && <span>{description}</span>}
</div>
{children}
</div>

View File

@@ -106,6 +106,7 @@ export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps):
/>
}
subHeading={<Subheader state={state} />}
data-testid="recoveryPanel"
>
{content}
</SettingsSection>

View File

@@ -0,0 +1,83 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { Breadcrumb, Button, VisualList, VisualListItem } from "@vector-im/compound-web";
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
import InfoIcon from "@vector-im/compound-design-tokens/assets/web/icons/info";
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error";
import React, { MouseEventHandler } from "react";
import { _t } from "../../../../languageHandler";
import { EncryptionCard } from "./EncryptionCard";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { uiAuthCallback } from "../../../../CreateCrossSigning";
interface ResetIdentityPanelProps {
/**
* Called when the identity is reset.
*/
onFinish: MouseEventHandler<HTMLButtonElement>;
/**
* Called when the cancel button is clicked or when we go back in the breadcrumbs.
*/
onCancelClick: () => void;
}
/**
* The panel for resetting the identity of the current user.
*/
export function ResetIdentityPanel({ onCancelClick, onFinish }: ResetIdentityPanelProps): JSX.Element {
const matrixClient = useMatrixClientContext();
return (
<>
<Breadcrumb
backLabel={_t("action|back")}
onBackClick={onCancelClick}
pages={[_t("settings|encryption|title"), _t("settings|encryption|advanced|breadcrumb_page")]}
onPageClick={onCancelClick}
/>
<EncryptionCard
Icon={ErrorIcon}
destructive={true}
title={_t("settings|encryption|advanced|breadcrumb_title")}
className="mx_ResetIdentityPanel"
>
<div className="mx_ResetIdentityPanel_content">
<VisualList>
<VisualListItem Icon={CheckIcon} success={true}>
{_t("settings|encryption|advanced|breadcrumb_first_description")}
</VisualListItem>
<VisualListItem Icon={InfoIcon}>
{_t("settings|encryption|advanced|breadcrumb_second_description")}
</VisualListItem>
<VisualListItem Icon={InfoIcon}>
{_t("settings|encryption|advanced|breadcrumb_third_description")}
</VisualListItem>
</VisualList>
<span>{_t("settings|encryption|advanced|breadcrumb_warning")}</span>
</div>
<div className="mx_ResetIdentityPanel_footer">
<Button
destructive={true}
onClick={async (evt) => {
await matrixClient
.getCrypto()
?.resetEncryption((makeRequest) => uiAuthCallback(matrixClient, makeRequest));
onFinish(evt);
}}
>
{_t("action|continue")}
</Button>
<Button kind="tertiary" onClick={onCancelClick}>
{_t("action|cancel")}
</Button>
</div>
</EncryptionCard>
</>
);
}

View File

@@ -6,7 +6,7 @@
*/
import React, { JSX, useCallback, useEffect, useState } from "react";
import { Button, InlineSpinner } from "@vector-im/compound-web";
import { Button, InlineSpinner, Separator } from "@vector-im/compound-web";
import ComputerIcon from "@vector-im/compound-design-tokens/assets/web/icons/computer";
import SettingsTab from "../SettingsTab";
@@ -18,6 +18,8 @@ import Modal from "../../../../../Modal";
import SetupEncryptionDialog from "../../../dialogs/security/SetupEncryptionDialog";
import { SettingsSection } from "../../shared/SettingsSection";
import { SettingsSubheader } from "../../SettingsSubheader";
import { AdvancedPanel } from "../../encryption/AdvancedPanel";
import { ResetIdentityPanel } from "../../encryption/ResetIdentityPanel";
/**
* The state in the encryption settings tab.
@@ -29,8 +31,9 @@ import { SettingsSubheader } from "../../SettingsSubheader";
* This happens when the user has a recovery key and the user clicks on "Change recovery key" button of the RecoveryPanel.
* - "set_recovery_key": The panel to show when the user is setting up their recovery key.
* This happens when the user doesn't have a key a recovery key and the user clicks on "Set up recovery key" button of the RecoveryPanel.
* - "reset_identity": The panel to show when the user is resetting their identity.
*/
type State = "loading" | "main" | "set_up_encryption" | "change_recovery_key" | "set_recovery_key";
type State = "loading" | "main" | "set_up_encryption" | "change_recovery_key" | "set_recovery_key" | "reset_identity";
export function EncryptionUserSettingsTab(): JSX.Element {
const [state, setState] = useState<State>("loading");
@@ -46,11 +49,15 @@ export function EncryptionUserSettingsTab(): JSX.Element {
break;
case "main":
content = (
<RecoveryPanel
onChangeRecoveryKeyClick={(setupNewKey) =>
setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key")
}
/>
<>
<RecoveryPanel
onChangeRecoveryKeyClick={(setupNewKey) =>
setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key")
}
/>
<Separator kind="section" />
<AdvancedPanel onResetIdentityClick={() => setState("reset_identity")} />
</>
);
break;
case "change_recovery_key":
@@ -63,6 +70,9 @@ export function EncryptionUserSettingsTab(): JSX.Element {
/>
);
break;
case "reset_identity":
content = <ResetIdentityPanel onCancelClick={() => setState("main")} onFinish={() => setState("main")} />;
break;
}
return (

View File

@@ -2422,6 +2422,24 @@
"enable_markdown": "Enable Markdown",
"enable_markdown_description": "Start messages with <code>/plain</code> to send without markdown.",
"encryption": {
"advanced": {
"breadcrumb_first_description": "Your account details, contacts, preferences, and chat list will be kept",
"breadcrumb_page": "Reset encryption",
"breadcrumb_second_description": "You will lose any message history thats stored only on the server",
"breadcrumb_third_description": "You will need to verify all your existing devices and contacts again",
"breadcrumb_title": "Are you sure you want to reset your identity?",
"breadcrumb_warning": "Only do this if you believe your account has been compromised.",
"details_title": "Encryption details",
"export_keys": "Export keys",
"import_keys": "Import keys",
"other_people_device_description": "By default in encrypted rooms, do not send encrypted messages to anyone until youve verified them",
"other_people_device_label": "Never send encrypted messages to unverified devices",
"other_people_device_title": "Other peoples devices",
"reset_identity": "Reset cryptographic identity",
"session_id": "Session ID:",
"session_key": "Session key:",
"title": "Advanced"
},
"device_not_verified_button": "Verify this device",
"device_not_verified_description": "You need to verify this device in order to view your encryption settings.",
"device_not_verified_title": "Device not verified",