Encryption tab: hide Advanced section when the key storage is out of sync (#29129)
* fix(encryption tab): hide the advanced section when the secrets are not cached locally The secret verification is now made at the level of `EncryptionUserSettingsTab` instead at the `RecoveryPanel` level. In the `EncryptionUserSettingsTab`, we decide to only display `RecoveryPanelOutOfSync` in case of uncached secrets. `RecoveryPanelOutOfSync` is simplified version of `RecoveryPanel` handling only the `secrets_not_cached` case. * refactor(encryption tab): simplify the `RecoveryPanel` without having to handle the missing secrets * test(encryption tab): move test about cached secrets in `EncryptionUserSettingsTab-test.tsx` * test(encryption tab): move e2e test which are testing all the encryption tab in `encryption-tab.spec.ts * refactor(encryption tab): move `RecoveryPanelOutOfSync` in its own file - fix typos - call onFinish after accessSecretStorage - onFinish doesn't need to be asynchronous * doc(encryption tab): improve documentation when the secrets are not cached locally * test(encryption tab): improve test documentation and naming * doc(encryption tab): improve `RecoveryPanelOutOfSync` documentation
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { JSX, useCallback, useEffect, useState } from "react";
|
||||
import React, { JSX } from "react";
|
||||
import { Button, InlineSpinner } from "@vector-im/compound-web";
|
||||
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key";
|
||||
|
||||
@@ -13,18 +13,15 @@ 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";
|
||||
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
|
||||
|
||||
/**
|
||||
* 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";
|
||||
type State = "loading" | "missing_recovery_key" | "good";
|
||||
|
||||
interface RecoveryPanelProps {
|
||||
/**
|
||||
@@ -40,29 +37,18 @@ interface RecoveryPanelProps {
|
||||
* 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]);
|
||||
const state = useAsyncMemo<State>(
|
||||
async () => {
|
||||
// Check if the user has a recovery key
|
||||
const hasRecoveryKey = Boolean(await matrixClient.secretStorage.getDefaultKeyId());
|
||||
if (hasRecoveryKey) return "good";
|
||||
else return "missing_recovery_key";
|
||||
},
|
||||
[matrixClient],
|
||||
"loading",
|
||||
);
|
||||
const isMissingRecoveryKey = state === "missing_recovery_key";
|
||||
|
||||
let content: JSX.Element;
|
||||
switch (state) {
|
||||
@@ -76,18 +62,6 @@ export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps):
|
||||
</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)}>
|
||||
@@ -105,33 +79,10 @@ export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps):
|
||||
label={_t("settings|encryption|recovery|title")}
|
||||
/>
|
||||
}
|
||||
subHeading={<Subheader state={state} />}
|
||||
subHeading={_t("settings|encryption|recovery|description")}
|
||||
data-testid="recoveryPanel"
|
||||
>
|
||||
{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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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, { JSX } from "react";
|
||||
import { Button } 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 { SettingsSubheader } from "../SettingsSubheader";
|
||||
import { accessSecretStorage } from "../../../../SecurityManager";
|
||||
|
||||
interface RecoveryPanelOutOfSyncProps {
|
||||
/**
|
||||
* Callback for when the user has finished entering their recovery key.
|
||||
*/
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* This component is shown as part of the {@link EncryptionUserSettingsTab}, instead of the
|
||||
* {@link RecoveryPanel}, when some of the user secrets are not cached in the local client.
|
||||
*
|
||||
* It prompts the user to enter their recovery key so that the secrets can be loaded from 4S into
|
||||
* the client.
|
||||
*/
|
||||
export function RecoveryPanelOutOfSync({ onFinish }: RecoveryPanelOutOfSyncProps): JSX.Element {
|
||||
return (
|
||||
<SettingsSection
|
||||
legacy={false}
|
||||
heading={_t("settings|encryption|recovery|title")}
|
||||
subHeading={
|
||||
<SettingsSubheader
|
||||
label={_t("settings|encryption|recovery|description")}
|
||||
state="error"
|
||||
stateMessage={_t("settings|encryption|recovery|key_storage_warning")}
|
||||
/>
|
||||
}
|
||||
data-testid="recoveryPanel"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
kind="primary"
|
||||
Icon={KeyIcon}
|
||||
onClick={async () => {
|
||||
await accessSecretStorage();
|
||||
onFinish();
|
||||
}}
|
||||
>
|
||||
{_t("settings|encryption|recovery|enter_recovery_key")}
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { SettingsSection } from "../../shared/SettingsSection";
|
||||
import { SettingsSubheader } from "../../SettingsSubheader";
|
||||
import { AdvancedPanel } from "../../encryption/AdvancedPanel";
|
||||
import { ResetIdentityPanel } from "../../encryption/ResetIdentityPanel";
|
||||
import { RecoveryPanelOutOfSync } from "../../encryption/RecoveryPanelOutOfSync";
|
||||
|
||||
/**
|
||||
* The state in the encryption settings tab.
|
||||
@@ -32,12 +33,22 @@ import { ResetIdentityPanel } from "../../encryption/ResetIdentityPanel";
|
||||
* - "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.
|
||||
* - `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.
|
||||
*
|
||||
*/
|
||||
type State = "loading" | "main" | "set_up_encryption" | "change_recovery_key" | "set_recovery_key" | "reset_identity";
|
||||
type State =
|
||||
| "loading"
|
||||
| "main"
|
||||
| "set_up_encryption"
|
||||
| "change_recovery_key"
|
||||
| "set_recovery_key"
|
||||
| "reset_identity"
|
||||
| "secrets_not_cached";
|
||||
|
||||
export function EncryptionUserSettingsTab(): JSX.Element {
|
||||
const [state, setState] = useState<State>("loading");
|
||||
const setUpEncryptionRequired = useSetUpEncryptionRequired(setState);
|
||||
const checkEncryptionState = useCheckEncryptionState(setState);
|
||||
|
||||
let content: JSX.Element;
|
||||
switch (state) {
|
||||
@@ -45,7 +56,10 @@ export function EncryptionUserSettingsTab(): JSX.Element {
|
||||
content = <InlineSpinner aria-label={_t("common|loading")} />;
|
||||
break;
|
||||
case "set_up_encryption":
|
||||
content = <SetUpEncryptionPanel onFinish={setUpEncryptionRequired} />;
|
||||
content = <SetUpEncryptionPanel onFinish={checkEncryptionState} />;
|
||||
break;
|
||||
case "secrets_not_cached":
|
||||
content = <RecoveryPanelOutOfSync onFinish={checkEncryptionState} />;
|
||||
break;
|
||||
case "main":
|
||||
content = (
|
||||
@@ -83,8 +97,12 @@ export function EncryptionUserSettingsTab(): JSX.Element {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if the user needs to go through the SetupEncryption flow.
|
||||
* 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.
|
||||
*
|
||||
* 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".
|
||||
*
|
||||
* The state is set once when the component is first mounted.
|
||||
@@ -93,23 +111,29 @@ export function EncryptionUserSettingsTab(): JSX.Element {
|
||||
* @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> {
|
||||
function useCheckEncryptionState(setState: (state: State) => void): () => Promise<void> {
|
||||
const matrixClient = useMatrixClientContext();
|
||||
|
||||
const setUpEncryptionRequired = useCallback(async () => {
|
||||
const checkEncryptionState = useCallback(async () => {
|
||||
const crypto = matrixClient.getCrypto()!;
|
||||
const isCrossSigningReady = await crypto.isCrossSigningReady();
|
||||
if (isCrossSigningReady) setState("main");
|
||||
else setState("set_up_encryption");
|
||||
|
||||
// Check if the secrets are cached
|
||||
const cachedSecrets = (await crypto.getCrossSigningStatus()).privateKeysCachedLocally;
|
||||
const secretsOk = cachedSecrets.masterKey && cachedSecrets.selfSigningKey && cachedSecrets.userSigningKey;
|
||||
|
||||
if (isCrossSigningReady && secretsOk) setState("main");
|
||||
else if (!isCrossSigningReady) setState("set_up_encryption");
|
||||
else setState("secrets_not_cached");
|
||||
}, [matrixClient, setState]);
|
||||
|
||||
// Initialise the state when the component is mounted
|
||||
useEffect(() => {
|
||||
setUpEncryptionRequired();
|
||||
}, [setUpEncryptionRequired]);
|
||||
checkEncryptionState();
|
||||
}, [checkEncryptionState]);
|
||||
|
||||
// Also return the callback so that the component can re-run the logic.
|
||||
return setUpEncryptionRequired;
|
||||
return checkEncryptionState;
|
||||
}
|
||||
|
||||
interface SetUpEncryptionPanelProps {
|
||||
|
||||
Reference in New Issue
Block a user