Add key storage toggle to Encryption settings (#29310)

* Add key storage toggle to Encryption settings

* Keys in the acceptable order

* Fix some tests

* Fix import

* Fix toast showing condition

* Fix import order

* Fix playwright tests

* Fix bits lost in merge

* Add key storage delete confirm screen

* Fix hardcoded Element string

* Fix type imports

* Fix tests

* Tests for key storage delete panel

* Fix test

* Type import

* Test for the view model

* Fix type import

* Actually fix type imports

* Test updating

* Add playwright test & clarify slightly confusing comment

* Show the advnced section whatever the state of key storage

* Update screenshots

* Copy css to its own file

* Add missing doc & merge loading states

* Add tsdoc & loading alt text to spinner

* Turn comments into proper tsdoc

* Switch to TypedEventEmitter and remove unnecessary loading state

* Add screenshot

* Use higher level interface

* Merge the two hooks in EncryptionUserSettingsTab

* Remove unused import

* Don't check key backup enabled state separately

as we don't need it for all the screens

* Update snapshot

* Use fixed recovery key function

* Amalgamate duplicated CSS files

* Have "key storage disabled" as a separate state

* Update snapshot

* Fix... bad merge?

* Add backup enabled mock to more tests

* More snapshots

* Use defer util

* Update to use EncryptionCardButtons

* Update snapshots

* Use EncryptionCardEmphasisedContent

* Update snapshots

* Update snapshot

* Try screenshot from CI playwright

* Try playwright screenshots again

* More screenshots

* Rename to match files

* Test that 4S secrets are deleted

* Make description clearer

* Fix typo & move related states together

* Add comment

* More comments

* Fix hook docs

* restoreAllMocks

* Update snapshot

because pulling in upstream has caused IDs to shift

* Switch icon

as apparenty the error icon has changed

* Update snapshot

* Missing copyright

* Re-order states

and also sort out indenting

* Remove phantom space

* Clarify 'button'

* Clarify docs more

* Explain thinking behind updating

* Switch to getActiveBackupVersion

which checks that key backup is happining on this device, which is
consistent with EX.

* Add use of Key Storage Panel

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Change key storage panel to be consistent

ie. using getActiveBackupVersion(), and add comment

* Add tsdoc

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Use BACKUP_DISABLED_ACCOUNT_DATA_KEY in more places

* Expand doc

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Undo random yarn lock change

* Use aggregate method for disabling key storage

in https://github.com/matrix-org/matrix-js-sdk/pull/4742

* Fix tests

* Use key backup status event to update

* Comment formatting

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Fix comment & put check inside if statement

* Add comment

* Prettier

* Fix comment

* Update snapshot

Which has gained nowrap due to 917d53a56f

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
This commit is contained in:
David Baker
2025-03-14 08:52:41 +00:00
committed by GitHub
parent 973d639d01
commit be3778bef0
20 changed files with 772 additions and 24 deletions

View File

@@ -0,0 +1,79 @@
/*
* Copyright 2025 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 CrossIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid";
import React, { useCallback, useState } from "react";
import { _t } from "../../../../languageHandler";
import { EncryptionCard } from "./EncryptionCard";
import { useKeyStoragePanelViewModel } from "../../../viewmodels/settings/encryption/KeyStoragePanelViewModel";
import SdkConfig from "../../../../SdkConfig";
import { EncryptionCardButtons } from "./EncryptionCardButtons";
import { EncryptionCardEmphasisedContent } from "./EncryptionCardEmphasisedContent";
interface Props {
/**
* Called when the user either cancels the operation or key storage has been disabled
*/
onFinish: () => void;
}
/**
* Confirms that the user really wants to turn off and delete their key storage. Part of the "Encryption" settings tab.
*/
export function DeleteKeyStoragePanel({ onFinish }: Props): JSX.Element {
const { setEnabled } = useKeyStoragePanelViewModel();
const [busy, setBusy] = useState(false);
const onDeleteClick = useCallback(async () => {
setBusy(true);
try {
await setEnabled(false);
} finally {
setBusy(false);
}
onFinish();
}, [setEnabled, onFinish]);
return (
<>
<Breadcrumb
backLabel={_t("action|back")}
onBackClick={onFinish}
pages={[_t("settings|encryption|title"), _t("settings|encryption|delete_key_storage|breadcrumb_page")]}
onPageClick={onFinish}
/>
<EncryptionCard
Icon={ErrorIcon}
destructive={true}
title={_t("settings|encryption|delete_key_storage|title")}
>
<EncryptionCardEmphasisedContent>
{_t("settings|encryption|delete_key_storage|description")}
<VisualList>
<VisualListItem Icon={CrossIcon} destructive={true}>
{_t("settings|encryption|delete_key_storage|list_first")}
</VisualListItem>
<VisualListItem Icon={CrossIcon} destructive={true}>
{_t("settings|encryption|delete_key_storage|list_second", { brand: SdkConfig.get().brand })}
</VisualListItem>
</VisualList>
</EncryptionCardEmphasisedContent>
<EncryptionCardButtons>
<Button destructive={true} onClick={onDeleteClick} disabled={busy}>
{_t("settings|encryption|delete_key_storage|confirm")}
</Button>
<Button kind="tertiary" onClick={onFinish}>
{_t("action|cancel")}
</Button>
</EncryptionCardButtons>
</EncryptionCard>
</>
);
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright 2025 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, { useCallback } from "react";
import { InlineField, InlineSpinner, Label, Root, ToggleControl } from "@vector-im/compound-web";
import type { FormEvent } from "react";
import { SettingsSection } from "../shared/SettingsSection";
import { _t } from "../../../../languageHandler";
import { SettingsHeader } from "../SettingsHeader";
import { useKeyStoragePanelViewModel } from "../../../viewmodels/settings/encryption/KeyStoragePanelViewModel";
interface Props {
/**
* Called when the user turns off the "allow key storage" toggle
*/
onKeyStorageDisableClick: () => void;
}
/**
* This component allows the user to set up or change their recovery key.
*
* It is used within the "Encryption" settings tab.
*/
export const KeyStoragePanel: React.FC<Props> = ({ onKeyStorageDisableClick }) => {
const { isEnabled, setEnabled, loading, busy } = useKeyStoragePanelViewModel();
const onKeyBackupChange = useCallback(
(e: FormEvent<HTMLInputElement>) => {
if (e.currentTarget.checked) {
setEnabled(true);
} else {
onKeyStorageDisableClick();
}
},
[setEnabled, onKeyStorageDisableClick],
);
if (loading) {
return <InlineSpinner aria-label={_t("common|loading")} />;
}
return (
<SettingsSection
legacy={false}
heading={
<SettingsHeader
hasRecommendedTag={isEnabled === false}
label={_t("settings|encryption|key_storage|title")}
/>
}
subHeading={_t("settings|encryption|key_storage|description", undefined, {
a: (sub) => (
<a href="https://element.io/help#encryption5" target="_blank" rel="noreferrer noopener">
{sub}
</a>
),
})}
>
<Root className="mx_KeyStoragePanel_toggleRow">
<InlineField
name="keyStorage"
control={<ToggleControl name="keyStorage" checked={isEnabled} onChange={onKeyBackupChange} />}
>
<Label>{_t("settings|encryption|key_storage|allow_key_storage")}</Label>
</InlineField>
{busy && <InlineSpinner />}
</Root>
</SettingsSection>
);
};

View File

@@ -8,6 +8,7 @@
import React, { type JSX, useCallback, useEffect, useState } from "react";
import { Button, InlineSpinner, Separator } from "@vector-im/compound-web";
import ComputerIcon from "@vector-im/compound-design-tokens/assets/web/icons/computer";
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
import SettingsTab from "../SettingsTab";
import { RecoveryPanel } from "../../encryption/RecoveryPanel";
@@ -21,11 +22,15 @@ import { SettingsSubheader } from "../../SettingsSubheader";
import { AdvancedPanel } from "../../encryption/AdvancedPanel";
import { ResetIdentityPanel } from "../../encryption/ResetIdentityPanel";
import { RecoveryPanelOutOfSync } from "../../encryption/RecoveryPanelOutOfSync";
import { useTypedEventEmitter } from "../../../../../hooks/useEventEmitter";
import { KeyStoragePanel } from "../../encryption/KeyStoragePanel";
import { DeleteKeyStoragePanel } from "../../encryption/DeleteKeyStoragePanel";
/**
* 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).
* - "key_storage_disabled": The user has chosen to disable key storage and options are unavailable as a result.
* - "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.
@@ -33,19 +38,22 @@ import { RecoveryPanelOutOfSync } from "../../encryption/RecoveryPanelOutOfSync"
* - "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_compromised": The panel to show when the user is resetting their identity, in te case where their key is compromised.
* - "reset_identity_forgot": The panel to show when the user is resetting their identity, in the case where they forgot their recovery key.
* - `secrets_not_cached`: The secrets are not cached locally. This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
* - "reset_identity_forgot": The panel to show when the user is resetting their identity, in the case where they forgot their recovery key.
* - "secrets_not_cached": The secrets are not cached locally. This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
* If the "set_up_encryption" and "secrets_not_cached" conditions are both filled, "set_up_encryption" prevails.
* - "key_storage_delete": The confirmation page asking if the user really wants to turn off key storage.
*/
export type State =
| "loading"
| "main"
| "key_storage_disabled"
| "set_up_encryption"
| "change_recovery_key"
| "set_recovery_key"
| "reset_identity_compromised"
| "reset_identity_forgot"
| "secrets_not_cached";
| "secrets_not_cached"
| "key_storage_delete";
interface EncryptionUserSettingsTabProps {
/**
@@ -63,6 +71,7 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Encrypti
const checkEncryptionState = useCheckEncryptionState(state, setState);
let content: JSX.Element;
switch (state) {
case "loading":
content = <InlineSpinner aria-label={_t("common|loading")} />;
@@ -78,15 +87,23 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Encrypti
/>
);
break;
case "key_storage_disabled":
case "main":
content = (
<>
<RecoveryPanel
onChangeRecoveryKeyClick={(setupNewKey) =>
setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key")
}
/>
<KeyStoragePanel onKeyStorageDisableClick={() => setState("key_storage_delete")} />
<Separator kind="section" />
{/* We only show the "Recovery" panel if key storage is enabled.*/}
{state === "main" && (
<>
<RecoveryPanel
onChangeRecoveryKeyClick={(setupNewKey) =>
setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key")
}
/>
<Separator kind="section" />
</>
)}
<AdvancedPanel onResetIdentityClick={() => setState("reset_identity_compromised")} />
</>
);
@@ -111,6 +128,9 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Encrypti
/>
);
break;
case "key_storage_delete":
content = <DeleteKeyStoragePanel onFinish={checkEncryptionState} />;
break;
}
return (
@@ -124,10 +144,12 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Encrypti
* Hook to check if the user needs:
* - to go through the SetupEncryption flow.
* - to enter their recovery key, if the secrets are not cached locally.
* ...and also whether megolm key backup is enabled on this device (which we use to set the state of the 'allow key storage' toggle)
*
* If the user needs to set up the encryption, the state will be set to "set_up_encryption".
* If the user secrets are not cached, the state will be set to "secrets_not_cached".
* Otherwise, the state will be set to "main".
* If cross signing is set up, key backup is enabled and the secrets are cached, the state will be set to "main".
* If cross signing is not set up, the state will be set to "set_up_encryption".
* If key backup is not enabled, the state will be set to "key_storage_disabled".
* If secrets are missing, the state will be set to "secrets_not_cached".
*
* 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.
@@ -146,8 +168,14 @@ function useCheckEncryptionState(state: State, setState: (state: State) => void)
const cachedSecrets = (await crypto.getCrossSigningStatus()).privateKeysCachedLocally;
const secretsOk = cachedSecrets.masterKey && cachedSecrets.selfSigningKey && cachedSecrets.userSigningKey;
if (isCrossSigningReady && secretsOk) setState("main");
// Also check the key backup status
const activeBackupVersion = await crypto.getActiveSessionBackupVersion();
const keyStorageEnabled = activeBackupVersion !== null;
if (isCrossSigningReady && keyStorageEnabled && secretsOk) setState("main");
else if (!isCrossSigningReady) setState("set_up_encryption");
else if (!keyStorageEnabled) setState("key_storage_disabled");
else setState("secrets_not_cached");
}, [matrixClient, setState]);
@@ -156,6 +184,15 @@ function useCheckEncryptionState(state: State, setState: (state: State) => void)
if (state === "loading") checkEncryptionState();
}, [checkEncryptionState, state]);
useTypedEventEmitter(matrixClient, CryptoEvent.KeyBackupStatus, (): void => {
// Recheck the status if the key backup status has changed so we can keep the page up to date.
// Note that this could potentially update the UI while the user is trying to do something, although
// if their key backup status is changing then they're changing encryption related things
// on another device. This code is written with the assumption that it's better for the UI to refresh
// and be up to date with whatever changes they've made.
checkEncryptionState();
});
// Also return the callback so that the component can re-run the logic.
return checkEncryptionState;
}