Add Recovery section in the new user settings Encryption tab (#28673)
* Refine `SettingsSection` & `SettingsTab` * Add encryption tab * Add recovery section * Add device verification * Rename `Panel` into `State` * Update & add tests to user settings common * Add tests to `RecoveryPanel` * Add tests to `ChangeRecoveryKey` * Update CreateSecretStorageDialog-test snapshot * Add tests to `EncryptionUserSettingsTab` * Update existing screenshots of e2e tests * Add new encryption tab ownership to `@element-hq/element-crypto-web-reviewers` * Add e2e tests * Fix monospace font and add figma link to hardcoded value * Add unit to Icon * Improve e2e doc * Assert that the crypto module is defined * Add classname doc * Fix typo * Use `good` state instead of default * Rename `ChangeRecoveryKey.isSetupFlow` into `ChangeRecoveryKey.userHasKeyBackup` * Move `deleteCachedSecrets` fixture in `recovery.spec.ts` * Use one callback instead of two in `RecoveryPanel` * Fix docs and naming of `utils.createBot` * Fix typo in `RecoveryPanel` * Add more doc to the state of the `EncryptionUserSettingsTab` * Rename `verification_required` into `set_up_encryption` * Update test * ADd new license * Update comments and doc * Assert that `recoveryKey.encodedPrivateKey` is always defined * Add comments to explain how the secrets could be uncached * Use `matrixClient.secretStorage.getDefaultKeyId` instead of `matrixClient.getCrypto().checkKeyBackupAndEnable` to know if we need to set up a recovery key * Update existing screenshot to add encryption tab. * Update tests * Use new labels when changing the recovery key * Fix docs * Don't reset key backup when creating a recovery key * Fix doc
This commit is contained in:
@@ -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"), <KeyIcon />, <EncryptionUserSettingsTab />),
|
||||
);
|
||||
|
||||
if (showLabsFlags() || SettingsStore.getFeatureSettingNames().some((k) => SettingsStore.getBetaInfo(k))) {
|
||||
tabs.push(
|
||||
new Tab(UserTab.Labs, _td("common|labs"), <LabsIcon />, <LabsUserSettingsTab />, "UserSettingsLabs"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
33
src/components/views/settings/SettingsHeader.tsx
Normal file
33
src/components/views/settings/SettingsHeader.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 } 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 (
|
||||
<Heading className="mx_SettingsHeader" as="h2" size="sm" weight="semibold">
|
||||
{label} {hasRecommendedTag && <span>{_t("common|recommended")}</span>}
|
||||
</Heading>
|
||||
);
|
||||
}
|
||||
50
src/components/views/settings/SettingsSubheader.tsx
Normal file
50
src/components/views/settings/SettingsSubheader.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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 } 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 (
|
||||
<div className="mx_SettingsSubheader">
|
||||
{label}
|
||||
<span
|
||||
className={classNames({
|
||||
mx_SettingsSubheader_success: state === "success",
|
||||
mx_SettingsSubheader_error: state === "error",
|
||||
})}
|
||||
>
|
||||
{state === "success" ? (
|
||||
<CheckCircleIcon width="20px" height="20px" />
|
||||
) : (
|
||||
<ErrorIcon width="20px" height="20px" />
|
||||
)}
|
||||
{stateMessage}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
352
src/components/views/settings/encryption/ChangeRecoveryKey.tsx
Normal file
352
src/components/views/settings/encryption/ChangeRecoveryKey.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
/*
|
||||
* 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 change key flow.
|
||||
* - `confirm_key_setup_flow`: The user is asked to confirm the new recovery key during the set up flow.
|
||||
* - `confirm_key_change_flow`: The user is asked to confirm the new recovery key during the change key flow.
|
||||
*/
|
||||
type State =
|
||||
| "inform_user"
|
||||
| "save_key_setup_flow"
|
||||
| "save_key_change_flow"
|
||||
| "confirm_key_setup_flow"
|
||||
| "confirm_key_change_flow";
|
||||
|
||||
interface ChangeRecoveryKeyProps {
|
||||
/**
|
||||
* If true, the component will display the flow to change the recovery key.
|
||||
* If false,the component will display the flow to set up a new recovery key.
|
||||
*/
|
||||
userHasRecoveryKey: 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({
|
||||
userHasRecoveryKey,
|
||||
onFinish,
|
||||
onCancelClick,
|
||||
}: ChangeRecoveryKeyProps): JSX.Element | null {
|
||||
const matrixClient = useMatrixClientContext();
|
||||
|
||||
// If the user is setting up recovery for the first time, we first show them a panel explaining what
|
||||
// "recovery" is about. Otherwise, we jump straight to showing the user the new key.
|
||||
const [state, setState] = useState<State>(userHasRecoveryKey ? "save_key_change_flow" : "inform_user");
|
||||
|
||||
// We create a new recovery key, the recovery key will be displayed to the user
|
||||
const recoveryKey = useAsyncMemo(() => matrixClient.getCrypto()!.createRecoveryKeyFromPassphrase(), []);
|
||||
// Waiting for the recovery key to be generated
|
||||
if (!recoveryKey) return null;
|
||||
|
||||
let content: JSX.Element;
|
||||
switch (state) {
|
||||
case "inform_user":
|
||||
// Show a panel explaining what "recovery" is for, and what a recovery key does.
|
||||
content = (
|
||||
<InformationPanel
|
||||
onContinueClick={() => setState("save_key_setup_flow")}
|
||||
onCancelClick={onCancelClick}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "save_key_setup_flow":
|
||||
case "save_key_change_flow":
|
||||
// Show a generated recovery key and ask the user to save it.
|
||||
content = (
|
||||
<KeyPanel
|
||||
// encodedPrivateKey is always defined, the optional typing is incorrect
|
||||
recoveryKey={recoveryKey.encodedPrivateKey!}
|
||||
onConfirmClick={() =>
|
||||
setState((currentState) =>
|
||||
currentState === "save_key_change_flow"
|
||||
? "confirm_key_change_flow"
|
||||
: "confirm_key_setup_flow",
|
||||
)
|
||||
}
|
||||
onCancelClick={onCancelClick}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "confirm_key_setup_flow":
|
||||
case "confirm_key_change_flow":
|
||||
// Ask the user to enter the recovery key they just saved to confirm it.
|
||||
content = (
|
||||
<KeyForm
|
||||
// encodedPrivateKey is always defined, the optional typing is incorrect
|
||||
recoveryKey={recoveryKey.encodedPrivateKey!}
|
||||
onCancelClick={onCancelClick}
|
||||
onSubmit={async () => {
|
||||
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({
|
||||
setupNewSecretStorage: true,
|
||||
createSecretStorageKey: async () => recoveryKey,
|
||||
}),
|
||||
);
|
||||
onFinish();
|
||||
} catch (e) {
|
||||
logger.error("Failed to bootstrap secret storage", e);
|
||||
}
|
||||
}}
|
||||
submitButtonLabel={
|
||||
state === "confirm_key_setup_flow"
|
||||
? _t("settings|encryption|recovery|set_up_recovery_confirm_button")
|
||||
: _t("settings|encryption|recovery|change_recovery_confirm_button")
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const pages = [
|
||||
_t("settings|encryption|title"),
|
||||
userHasRecoveryKey
|
||||
? _t("settings|encryption|recovery|change_recovery_key")
|
||||
: _t("settings|encryption|recovery|set_up_recovery"),
|
||||
];
|
||||
const labels = getLabels(state);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb
|
||||
backLabel={_t("action|back")}
|
||||
onBackClick={onCancelClick}
|
||||
pages={pages}
|
||||
onPageClick={onCancelClick}
|
||||
/>
|
||||
<EncryptionCard title={labels.title} description={labels.description} className="mx_ChangeRecoveryKey">
|
||||
{content}
|
||||
</EncryptionCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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_key_setup_flow":
|
||||
return {
|
||||
title: _t("settings|encryption|recovery|set_up_recovery_confirm_title"),
|
||||
description: _t("settings|encryption|recovery|set_up_recovery_confirm_description"),
|
||||
};
|
||||
case "confirm_key_change_flow":
|
||||
return {
|
||||
title: _t("settings|encryption|recovery|change_recovery_confirm_title"),
|
||||
description: _t("settings|encryption|recovery|change_recovery_confirm_description"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface InformationPanelProps {
|
||||
/**
|
||||
* Called when the continue button is clicked.
|
||||
*/
|
||||
onContinueClick: MouseEventHandler<HTMLButtonElement>;
|
||||
/**
|
||||
* Called when the cancel button is clicked.
|
||||
*/
|
||||
onCancelClick: MouseEventHandler<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel to display information about the recovery key.
|
||||
*/
|
||||
function InformationPanel({ onContinueClick, onCancelClick }: InformationPanelProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Text as="span" weight="medium" className="mx_InformationPanel_description">
|
||||
{_t("settings|encryption|recovery|set_up_recovery_secondary_description")}
|
||||
</Text>
|
||||
<div className="mx_ChangeRecoveryKey_footer">
|
||||
<Button onClick={onContinueClick}>{_t("action|continue")}</Button>
|
||||
<Button kind="tertiary" onClick={onCancelClick}>
|
||||
{_t("action|cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className="mx_KeyPanel">
|
||||
<Text as="span" weight="medium">
|
||||
{_t("settings|encryption|recovery|save_key_title")}
|
||||
</Text>
|
||||
<div>
|
||||
<Text as="span" className="mx_KeyPanel_key" data-testid="recoveryKey">
|
||||
{recoveryKey}
|
||||
</Text>
|
||||
<Text as="span" size="sm">
|
||||
{_t("settings|encryption|recovery|save_key_description")}
|
||||
</Text>
|
||||
</div>
|
||||
<IconButton aria-label={_t("action|copy")} size="28px" onClick={() => copyPlaintext(recoveryKey)}>
|
||||
<CopyIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="mx_ChangeRecoveryKey_footer">
|
||||
<Button onClick={onConfirmClick}>{_t("action|continue")}</Button>
|
||||
<Button kind="tertiary" onClick={onCancelClick}>
|
||||
{_t("action|cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 label for the submit button.
|
||||
*/
|
||||
submitButtonLabel: 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, submitButtonLabel }: KeyFormProps): JSX.Element {
|
||||
// Undefined by default, as the key is not filled yet
|
||||
const [isKeyValid, setIsKeyValid] = useState<boolean>();
|
||||
const isKeyInvalidAndFilled = isKeyValid === false;
|
||||
|
||||
return (
|
||||
<Root
|
||||
className="mx_KeyForm"
|
||||
onSubmit={(evt) => {
|
||||
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);
|
||||
}}
|
||||
>
|
||||
<Field name="recoveryKey" serverInvalid={isKeyInvalidAndFilled}>
|
||||
<Label>{_t("settings|encryption|recovery|enter_recovery_key")}</Label>
|
||||
|
||||
<TextControl required={true} />
|
||||
{isKeyInvalidAndFilled && (
|
||||
<ErrorMessage>{_t("settings|encryption|recovery|enter_key_error")}</ErrorMessage>
|
||||
)}
|
||||
</Field>
|
||||
<div className="mx_ChangeRecoveryKey_footer">
|
||||
<Button disabled={!isKeyValid}>{submitButtonLabel}</Button>
|
||||
<Button kind="tertiary" onClick={onCancelClick}>
|
||||
{_t("action|cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
51
src/components/views/settings/encryption/EncryptionCard.tsx
Normal file
51
src/components/views/settings/encryption/EncryptionCard.tsx
Normal file
@@ -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<EncryptionCardProps>): JSX.Element {
|
||||
return (
|
||||
<div className={classNames("mx_EncryptionCard", className)}>
|
||||
<div className="mx_EncryptionCard_header">
|
||||
<BigIcon>
|
||||
<KeyIcon />
|
||||
</BigIcon>
|
||||
<Heading as="h2" size="sm" weight="semibold">
|
||||
{title}
|
||||
</Heading>
|
||||
<span>{description}</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
src/components/views/settings/encryption/RecoveryPanel.tsx
Normal file
136
src/components/views/settings/encryption/RecoveryPanel.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* 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, 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 recovery key and the secrets.
|
||||
* - `missing_recovery_key`: The user has no recovery key.
|
||||
* - `secrets_not_cached`: The user has a recovery key but the secrets are not cached.
|
||||
* This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
|
||||
* - `good`: The user has a recovery key and the secrets are cached.
|
||||
*/
|
||||
type State = "loading" | "missing_recovery_key" | "secrets_not_cached" | "good";
|
||||
|
||||
interface RecoveryPanelProps {
|
||||
/**
|
||||
* Callback for when the user wants to set up or change their recovery key.
|
||||
*
|
||||
* @param setupNewKey - set if the user does not already have a recovery key (and has therefore clicked on
|
||||
* "Set up recovery" rather than "Change recovery key").
|
||||
*/
|
||||
onChangeRecoveryKeyClick: (setupNewKey: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* This component allows the user to set up or change their recovery key.
|
||||
*/
|
||||
export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps): JSX.Element {
|
||||
const [state, setState] = useState<State>("loading");
|
||||
const isMissingRecoveryKey = state === "missing_recovery_key";
|
||||
|
||||
const matrixClient = useMatrixClientContext();
|
||||
|
||||
const checkEncryption = useCallback(async () => {
|
||||
const crypto = matrixClient.getCrypto()!;
|
||||
|
||||
// Check if the user has a recovery key
|
||||
const hasRecoveryKey = Boolean(await matrixClient.secretStorage.getDefaultKeyId());
|
||||
if (!hasRecoveryKey) return setState("missing_recovery_key");
|
||||
|
||||
// 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 = <InlineSpinner aria-label={_t("common|loading")} />;
|
||||
break;
|
||||
case "missing_recovery_key":
|
||||
content = (
|
||||
<Button size="sm" kind="primary" Icon={KeyIcon} onClick={() => onChangeRecoveryKeyClick(true)}>
|
||||
{_t("settings|encryption|recovery|set_up_recovery")}
|
||||
</Button>
|
||||
);
|
||||
break;
|
||||
case "secrets_not_cached":
|
||||
content = (
|
||||
<Button
|
||||
size="sm"
|
||||
kind="primary"
|
||||
Icon={KeyIcon}
|
||||
onClick={async () => await accessSecretStorage(checkEncryption)}
|
||||
>
|
||||
{_t("settings|encryption|recovery|enter_recovery_key")}
|
||||
</Button>
|
||||
);
|
||||
break;
|
||||
case "good":
|
||||
content = (
|
||||
<Button size="sm" kind="secondary" Icon={KeyIcon} onClick={() => onChangeRecoveryKeyClick(false)}>
|
||||
{_t("settings|encryption|recovery|change_recovery_key")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
legacy={false}
|
||||
heading={
|
||||
<SettingsHeader
|
||||
hasRecommendedTag={isMissingRecoveryKey}
|
||||
label={_t("settings|encryption|recovery|title")}
|
||||
/>
|
||||
}
|
||||
subHeading={<Subheader state={state} />}
|
||||
>
|
||||
{content}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
interface SubheaderProps {
|
||||
/**
|
||||
* The state of the recovery panel.
|
||||
*/
|
||||
state: State;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subheader for the recovery panel.
|
||||
*/
|
||||
function Subheader({ state }: SubheaderProps): JSX.Element {
|
||||
// If the secrets are not cached, we display a warning message.
|
||||
if (state !== "secrets_not_cached") return <>{_t("settings|encryption|recovery|description")}</>;
|
||||
|
||||
return (
|
||||
<SettingsSubheader
|
||||
label={_t("settings|encryption|recovery|description")}
|
||||
state="error"
|
||||
stateMessage={_t("settings|encryption|recovery|key_storage_warning")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement> {
|
||||
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 as="h2" size="3">
|
||||
{heading}
|
||||
</Heading>
|
||||
) : (
|
||||
<SettingsHeader label={heading} />
|
||||
);
|
||||
case "undefined":
|
||||
return undefined;
|
||||
@@ -48,9 +53,29 @@ function renderHeading(heading: string | React.ReactNode | undefined): React.Rea
|
||||
* </SettingsTab>
|
||||
* ```
|
||||
*/
|
||||
export const SettingsSection: React.FC<SettingsSectionProps> = ({ className, heading, children, ...rest }) => (
|
||||
<div {...rest} className={classnames("mx_SettingsSection", className)}>
|
||||
{renderHeading(heading)}
|
||||
<div className="mx_SettingsSection_subSections">{children}</div>
|
||||
export const SettingsSection: React.FC<SettingsSectionProps> = ({
|
||||
className,
|
||||
heading,
|
||||
subHeading,
|
||||
legacy = true,
|
||||
children,
|
||||
...rest
|
||||
}) => (
|
||||
<div
|
||||
{...rest}
|
||||
className={classnames("mx_SettingsSection", className, {
|
||||
mx_SettingsSection_newUi: !legacy,
|
||||
})}
|
||||
>
|
||||
{heading &&
|
||||
(subHeading ? (
|
||||
<div className="mx_SettingsSection_header">
|
||||
{renderHeading(heading, legacy)}
|
||||
{subHeading}
|
||||
</div>
|
||||
) : (
|
||||
renderHeading(heading, legacy)
|
||||
))}
|
||||
{legacy ? <div className="mx_SettingsSection_subSections">{children}</div> : children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,9 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
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<HTMLAttributes<HTMLDivElement>, "className"> {
|
||||
export interface SettingsTabProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children?: React.ReactNode;
|
||||
/**
|
||||
* Added to the classList of the root element
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,8 +34,8 @@ export interface SettingsTabProps extends Omit<HTMLAttributes<HTMLDivElement>, "
|
||||
* </SettingsTab>
|
||||
* ```
|
||||
*/
|
||||
const SettingsTab: React.FC<SettingsTabProps> = ({ children, ...rest }) => (
|
||||
<div {...rest} className="mx_SettingsTab">
|
||||
const SettingsTab: React.FC<SettingsTabProps> = ({ children, className, ...rest }) => (
|
||||
<div {...rest} className={classNames("mx_SettingsTab", className)}>
|
||||
<div className="mx_SettingsTab_sections">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* 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, useCallback, useEffect, useState } from "react";
|
||||
import { Button, InlineSpinner } from "@vector-im/compound-web";
|
||||
import ComputerIcon from "@vector-im/compound-design-tokens/assets/web/icons/computer";
|
||||
|
||||
import SettingsTab from "../SettingsTab";
|
||||
import { RecoveryPanel } from "../../encryption/RecoveryPanel";
|
||||
import { ChangeRecoveryKey } from "../../encryption/ChangeRecoveryKey";
|
||||
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
|
||||
import { _t } from "../../../../../languageHandler";
|
||||
import Modal from "../../../../../Modal";
|
||||
import SetupEncryptionDialog from "../../../dialogs/security/SetupEncryptionDialog";
|
||||
import { SettingsSection } from "../../shared/SettingsSection";
|
||||
import { SettingsSubheader } from "../../SettingsSubheader";
|
||||
|
||||
/**
|
||||
* The state in the encryption settings tab.
|
||||
* - "loading": We are checking if the device is verified.
|
||||
* - "main": The main panel with all the sections (Key storage, recovery, advanced).
|
||||
* - "set_up_encryption": The panel to show when the user is setting up their encryption.
|
||||
* This happens when the user doesn't have cross-signing enabled, or their current device is not verified.
|
||||
* - "change_recovery_key": The panel to show when the user is changing their recovery key.
|
||||
* 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.
|
||||
*/
|
||||
type State = "loading" | "main" | "set_up_encryption" | "change_recovery_key" | "set_recovery_key";
|
||||
|
||||
export function EncryptionUserSettingsTab(): JSX.Element {
|
||||
const [state, setState] = useState<State>("loading");
|
||||
const setUpEncryptionRequired = useSetUpEncryptionRequired(setState);
|
||||
|
||||
let content: JSX.Element;
|
||||
switch (state) {
|
||||
case "loading":
|
||||
content = <InlineSpinner aria-label={_t("common|loading")} />;
|
||||
break;
|
||||
case "set_up_encryption":
|
||||
content = <SetUpEncryptionPanel onFinish={setUpEncryptionRequired} />;
|
||||
break;
|
||||
case "main":
|
||||
content = (
|
||||
<RecoveryPanel
|
||||
onChangeRecoveryKeyClick={(setupNewKey) =>
|
||||
setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key")
|
||||
}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "change_recovery_key":
|
||||
case "set_recovery_key":
|
||||
content = (
|
||||
<ChangeRecoveryKey
|
||||
userHasRecoveryKey={state === "change_recovery_key"}
|
||||
onCancelClick={() => setState("main")}
|
||||
onFinish={() => setState("main")}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsTab className="mx_EncryptionUserSettingsTab" data-testid="encryptionTab">
|
||||
{content}
|
||||
</SettingsTab>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if the user needs to go through the SetupEncryption flow.
|
||||
* If the user needs to set up the encryption, the state will be set to "set_up_encryption".
|
||||
* Otherwise, the state will be set to "main".
|
||||
*
|
||||
* The state is set once when the component is first mounted.
|
||||
* Also returns a callback function which can be called to re-run the logic.
|
||||
*
|
||||
* @param setState - callback passed from the EncryptionUserSettingsTab to set the current `State`.
|
||||
* @returns a callback function, which will re-run the logic and update the state.
|
||||
*/
|
||||
function useSetUpEncryptionRequired(setState: (state: State) => void): () => Promise<void> {
|
||||
const matrixClient = useMatrixClientContext();
|
||||
|
||||
const setUpEncryptionRequired = useCallback(async () => {
|
||||
const crypto = matrixClient.getCrypto()!;
|
||||
const isCrossSigningReady = await crypto.isCrossSigningReady();
|
||||
if (isCrossSigningReady) setState("main");
|
||||
else setState("set_up_encryption");
|
||||
}, [matrixClient, setState]);
|
||||
|
||||
// Initialise the state when the component is mounted
|
||||
useEffect(() => {
|
||||
setUpEncryptionRequired();
|
||||
}, [setUpEncryptionRequired]);
|
||||
|
||||
// Also return the callback so that the component can re-run the logic.
|
||||
return setUpEncryptionRequired;
|
||||
}
|
||||
|
||||
interface SetUpEncryptionPanelProps {
|
||||
/**
|
||||
* Callback to call when the user has finished setting up encryption.
|
||||
*/
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel to show when the user needs to go through the SetupEncryption flow.
|
||||
*/
|
||||
function SetUpEncryptionPanel({ onFinish }: SetUpEncryptionPanelProps): JSX.Element {
|
||||
// Strictly speaking, the SetupEncryptionDialog may make the user do things other than
|
||||
// verify their device (in particular, if they manage to get here without cross-signing keys existing);
|
||||
// however the common case is that they will be asked to verify, so we just show buttons and headings
|
||||
// that talk about verification.
|
||||
return (
|
||||
<SettingsSection
|
||||
legacy={false}
|
||||
heading={_t("settings|encryption|device_not_verified_title")}
|
||||
subHeading={
|
||||
<SettingsSubheader
|
||||
stateMessage={_t("settings|encryption|device_not_verified_description")}
|
||||
state="error"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
Icon={ComputerIcon}
|
||||
onClick={() => Modal.createDialog(SetupEncryptionDialog, { onFinished: onFinish })}
|
||||
>
|
||||
{_t("settings|encryption|device_not_verified_button")}
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user