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>
@@ -17,9 +17,7 @@ import {
|
|||||||
} from "../../crypto/utils";
|
} from "../../crypto/utils";
|
||||||
|
|
||||||
test.describe("Encryption tab", () => {
|
test.describe("Encryption tab", () => {
|
||||||
test.use({
|
test.use({ displayName: "Alice" });
|
||||||
displayName: "Alice",
|
|
||||||
});
|
|
||||||
|
|
||||||
let recoveryKey: GeneratedSecretStorageKey;
|
let recoveryKey: GeneratedSecretStorageKey;
|
||||||
let expectedBackupVersion: string;
|
let expectedBackupVersion: string;
|
||||||
@@ -111,4 +109,36 @@ test.describe("Encryption tab", () => {
|
|||||||
// The user is prompted to reset their identity
|
// The user is prompted to reset their identity
|
||||||
await expect(dialog.getByText("Forgot your recovery key? You’ll need to reset your identity.")).toBeVisible();
|
await expect(dialog.getByText("Forgot your recovery key? You’ll need to reset your identity.")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should warn before turning off key storage", { tag: "@screenshot" }, async ({ page, app, util }) => {
|
||||||
|
await verifySession(app, recoveryKey.encodedPrivateKey);
|
||||||
|
await util.openEncryptionTab();
|
||||||
|
|
||||||
|
await page.getByRole("checkbox", { name: "Allow key storage" }).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Are you sure you want to turn off key storage and delete it?" }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await expect(util.getEncryptionTabContent()).toMatchScreenshot("delete-key-storage-confirm.png");
|
||||||
|
|
||||||
|
const deleteRequestPromises = [
|
||||||
|
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.master")),
|
||||||
|
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.self_signing")),
|
||||||
|
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.user_signing")),
|
||||||
|
page.waitForRequest((req) => req.url().endsWith("/account_data/m.megolm_backup.v1")),
|
||||||
|
page.waitForRequest((req) => req.url().endsWith("/account_data/m.secret_storage.default_key")),
|
||||||
|
page.waitForRequest((req) => req.url().includes("/account_data/m.secret_storage.key.")),
|
||||||
|
];
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Delete key storage" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).not.toBeChecked();
|
||||||
|
|
||||||
|
for (const prom of deleteRequestPromises) {
|
||||||
|
const request = await prom;
|
||||||
|
expect(request.method()).toBe("PUT");
|
||||||
|
expect(request.postData()).toBe(JSON.stringify({}));
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 51 KiB |
After Width: | Height: | Size: 54 KiB |
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
@@ -48,6 +48,7 @@
|
|||||||
@import "./components/views/settings/devices/_FilteredDeviceListHeader.pcss";
|
@import "./components/views/settings/devices/_FilteredDeviceListHeader.pcss";
|
||||||
@import "./components/views/settings/devices/_SecurityRecommendations.pcss";
|
@import "./components/views/settings/devices/_SecurityRecommendations.pcss";
|
||||||
@import "./components/views/settings/devices/_SelectableDeviceTile.pcss";
|
@import "./components/views/settings/devices/_SelectableDeviceTile.pcss";
|
||||||
|
@import "./components/views/settings/encryption/_KeyStoragePanel.pcss";
|
||||||
@import "./components/views/settings/shared/_SettingsSubsection.pcss";
|
@import "./components/views/settings/shared/_SettingsSubsection.pcss";
|
||||||
@import "./components/views/settings/shared/_SettingsSubsectionHeading.pcss";
|
@import "./components/views/settings/shared/_SettingsSubsectionHeading.pcss";
|
||||||
@import "./components/views/spaces/_QuickThemeSwitcher.pcss";
|
@import "./components/views/spaces/_QuickThemeSwitcher.pcss";
|
||||||
|
@@ -0,0 +1,10 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.mx_KeyStoragePanel_toggleRow {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
@@ -49,10 +49,13 @@ import { asyncSomeParallel } from "./utils/arrays.ts";
|
|||||||
|
|
||||||
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
|
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
|
||||||
|
|
||||||
// Unfortunately named account data key used by Element X to indicate that the user
|
/**
|
||||||
// has chosen to disable server side key backups. We need to set and honour this
|
* Unfortunately-named account data key used by Element X to indicate that the user
|
||||||
// to prevent Element X from automatically turning key backup back on.
|
* has chosen to disable server side key backups.
|
||||||
const BACKUP_DISABLED_ACCOUNT_DATA_KEY = "m.org.matrix.custom.backup_disabled";
|
*
|
||||||
|
* We need to set and honour this to prevent Element X from automatically turning key backup back on.
|
||||||
|
*/
|
||||||
|
export const BACKUP_DISABLED_ACCOUNT_DATA_KEY = "m.org.matrix.custom.backup_disabled";
|
||||||
|
|
||||||
const logger = baseLogger.getChild("DeviceListener:");
|
const logger = baseLogger.getChild("DeviceListener:");
|
||||||
|
|
||||||
|
@@ -0,0 +1,116 @@
|
|||||||
|
/*
|
||||||
|
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 { useCallback, useEffect, useState } from "react";
|
||||||
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
|
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
|
||||||
|
import DeviceListener, { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../../../DeviceListener";
|
||||||
|
|
||||||
|
interface KeyStoragePanelState {
|
||||||
|
/**
|
||||||
|
* Whether the app's "key storage" option should show as enabled to the user,
|
||||||
|
* or 'undefined' if the state is still loading.
|
||||||
|
*/
|
||||||
|
isEnabled: boolean | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A function that can be called to enable or disable key storage.
|
||||||
|
* @param enable True to turn key storage on or false to turn it off
|
||||||
|
*/
|
||||||
|
setEnabled: (enable: boolean) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the state is still loading for the first time
|
||||||
|
*/
|
||||||
|
loading: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the status is in the process of being changed
|
||||||
|
*/
|
||||||
|
busy: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a ViewModel for use in {@link KeyStoragePanel} and {@link DeleteKeyStoragePanel}. */
|
||||||
|
export function useKeyStoragePanelViewModel(): KeyStoragePanelState {
|
||||||
|
const [isEnabled, setIsEnabled] = useState<boolean | undefined>(undefined);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
// Whilst the change is being made, the toggle will reflect the pending value rather than the actual state
|
||||||
|
const [pendingValue, setPendingValue] = useState<boolean | undefined>(undefined);
|
||||||
|
|
||||||
|
const matrixClient = useMatrixClientContext();
|
||||||
|
|
||||||
|
const checkStatus = useCallback(async () => {
|
||||||
|
const crypto = matrixClient.getCrypto();
|
||||||
|
if (!crypto) {
|
||||||
|
logger.error("Can't check key backup status: no crypto module available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The toggle is enabled only if this device will upload megolm keys to the backup.
|
||||||
|
// This is consistent with EX.
|
||||||
|
const activeBackupVersion = await crypto.getActiveSessionBackupVersion();
|
||||||
|
setIsEnabled(activeBackupVersion !== null);
|
||||||
|
}, [matrixClient]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
await checkStatus();
|
||||||
|
setLoading(false);
|
||||||
|
})();
|
||||||
|
}, [checkStatus]);
|
||||||
|
|
||||||
|
const setEnabled = useCallback(
|
||||||
|
async (enable: boolean) => {
|
||||||
|
setPendingValue(enable);
|
||||||
|
try {
|
||||||
|
// stop the device listener since enabling or (especially) disabling key storage must be
|
||||||
|
// done with a sequence of API calls that will put the account in a slightly different
|
||||||
|
// state each time, so suppress any warning toasts until the process is finished (when
|
||||||
|
// we'll turn it back on again.)
|
||||||
|
DeviceListener.sharedInstance().stop();
|
||||||
|
|
||||||
|
const crypto = matrixClient.getCrypto();
|
||||||
|
if (!crypto) {
|
||||||
|
logger.error("Can't change key backup status: no crypto module available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (enable) {
|
||||||
|
// If there is no existing key backup on the server, create one.
|
||||||
|
// `resetKeyBackup` will delete any existing backup, so we only do this if there is no existing backup.
|
||||||
|
const currentKeyBackup = await crypto.checkKeyBackupAndEnable();
|
||||||
|
if (currentKeyBackup === null) {
|
||||||
|
await crypto.resetKeyBackup();
|
||||||
|
|
||||||
|
// resetKeyBackup fires this off in the background without waiting, so we need to do it
|
||||||
|
// explicitly and wait for it, otherwise it won't be enabled yet when we check again.
|
||||||
|
await crypto.checkKeyBackupAndEnable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the flag so that EX no longer thinks the user wants backup disabled
|
||||||
|
await matrixClient.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: false });
|
||||||
|
} else {
|
||||||
|
// This method will delete the key backup as well as server side recovery keys and other
|
||||||
|
// server-side crypto data.
|
||||||
|
await crypto.disableKeyStorage();
|
||||||
|
|
||||||
|
// Set a flag to say that the user doesn't want key backup.
|
||||||
|
// Element X uses this to determine whether to set up automatically,
|
||||||
|
// so this will stop EX turning it back on spontaneously.
|
||||||
|
await matrixClient.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkStatus();
|
||||||
|
} finally {
|
||||||
|
setPendingValue(undefined);
|
||||||
|
DeviceListener.sharedInstance().start(matrixClient);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setPendingValue, checkStatus, matrixClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { isEnabled: pendingValue ?? isEnabled, setEnabled, loading, busy: pendingValue !== undefined };
|
||||||
|
}
|
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
75
src/components/views/settings/encryption/KeyStoragePanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@@ -8,6 +8,7 @@
|
|||||||
import React, { type JSX, useCallback, useEffect, useState } from "react";
|
import React, { type JSX, useCallback, useEffect, useState } from "react";
|
||||||
import { Button, InlineSpinner, Separator } 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 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 SettingsTab from "../SettingsTab";
|
||||||
import { RecoveryPanel } from "../../encryption/RecoveryPanel";
|
import { RecoveryPanel } from "../../encryption/RecoveryPanel";
|
||||||
@@ -21,11 +22,15 @@ import { SettingsSubheader } from "../../SettingsSubheader";
|
|||||||
import { AdvancedPanel } from "../../encryption/AdvancedPanel";
|
import { AdvancedPanel } from "../../encryption/AdvancedPanel";
|
||||||
import { ResetIdentityPanel } from "../../encryption/ResetIdentityPanel";
|
import { ResetIdentityPanel } from "../../encryption/ResetIdentityPanel";
|
||||||
import { RecoveryPanelOutOfSync } from "../../encryption/RecoveryPanelOutOfSync";
|
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.
|
* The state in the encryption settings tab.
|
||||||
* - "loading": We are checking if the device is verified.
|
* - "loading": We are checking if the device is verified.
|
||||||
* - "main": The main panel with all the sections (Key storage, recovery, advanced).
|
* - "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.
|
* - "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.
|
* 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.
|
* - "change_recovery_key": The panel to show when the user is changing their recovery key.
|
||||||
@@ -34,18 +39,21 @@ import { RecoveryPanelOutOfSync } from "../../encryption/RecoveryPanelOutOfSync"
|
|||||||
* 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.
|
* 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_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.
|
* - "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.
|
* - "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.
|
* 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 =
|
export type State =
|
||||||
| "loading"
|
| "loading"
|
||||||
| "main"
|
| "main"
|
||||||
|
| "key_storage_disabled"
|
||||||
| "set_up_encryption"
|
| "set_up_encryption"
|
||||||
| "change_recovery_key"
|
| "change_recovery_key"
|
||||||
| "set_recovery_key"
|
| "set_recovery_key"
|
||||||
| "reset_identity_compromised"
|
| "reset_identity_compromised"
|
||||||
| "reset_identity_forgot"
|
| "reset_identity_forgot"
|
||||||
| "secrets_not_cached";
|
| "secrets_not_cached"
|
||||||
|
| "key_storage_delete";
|
||||||
|
|
||||||
interface EncryptionUserSettingsTabProps {
|
interface EncryptionUserSettingsTabProps {
|
||||||
/**
|
/**
|
||||||
@@ -63,6 +71,7 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Encrypti
|
|||||||
const checkEncryptionState = useCheckEncryptionState(state, setState);
|
const checkEncryptionState = useCheckEncryptionState(state, setState);
|
||||||
|
|
||||||
let content: JSX.Element;
|
let content: JSX.Element;
|
||||||
|
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "loading":
|
case "loading":
|
||||||
content = <InlineSpinner aria-label={_t("common|loading")} />;
|
content = <InlineSpinner aria-label={_t("common|loading")} />;
|
||||||
@@ -78,8 +87,14 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Encrypti
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case "key_storage_disabled":
|
||||||
case "main":
|
case "main":
|
||||||
content = (
|
content = (
|
||||||
|
<>
|
||||||
|
<KeyStoragePanel onKeyStorageDisableClick={() => setState("key_storage_delete")} />
|
||||||
|
<Separator kind="section" />
|
||||||
|
{/* We only show the "Recovery" panel if key storage is enabled.*/}
|
||||||
|
{state === "main" && (
|
||||||
<>
|
<>
|
||||||
<RecoveryPanel
|
<RecoveryPanel
|
||||||
onChangeRecoveryKeyClick={(setupNewKey) =>
|
onChangeRecoveryKeyClick={(setupNewKey) =>
|
||||||
@@ -87,6 +102,8 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Encrypti
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Separator kind="section" />
|
<Separator kind="section" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<AdvancedPanel onResetIdentityClick={() => setState("reset_identity_compromised")} />
|
<AdvancedPanel onResetIdentityClick={() => setState("reset_identity_compromised")} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -111,6 +128,9 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Encrypti
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case "key_storage_delete":
|
||||||
|
content = <DeleteKeyStoragePanel onFinish={checkEncryptionState} />;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -124,10 +144,12 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Encrypti
|
|||||||
* Hook to check if the user needs:
|
* Hook to check if the user needs:
|
||||||
* - to go through the SetupEncryption flow.
|
* - to go through the SetupEncryption flow.
|
||||||
* - to enter their recovery key, if the secrets are not cached locally.
|
* - 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 cross signing is set up, key backup is enabled and the secrets are cached, the state will be set to "main".
|
||||||
* If the user secrets are not cached, the state will be set to "secrets_not_cached".
|
* If cross signing is not set up, the state will be set to "set_up_encryption".
|
||||||
* Otherwise, the state will be set to "main".
|
* 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.
|
* 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.
|
* 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 cachedSecrets = (await crypto.getCrossSigningStatus()).privateKeysCachedLocally;
|
||||||
const secretsOk = cachedSecrets.masterKey && cachedSecrets.selfSigningKey && cachedSecrets.userSigningKey;
|
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 (!isCrossSigningReady) setState("set_up_encryption");
|
||||||
|
else if (!keyStorageEnabled) setState("key_storage_disabled");
|
||||||
else setState("secrets_not_cached");
|
else setState("secrets_not_cached");
|
||||||
}, [matrixClient, setState]);
|
}, [matrixClient, setState]);
|
||||||
|
|
||||||
@@ -156,6 +184,15 @@ function useCheckEncryptionState(state: State, setState: (state: State) => void)
|
|||||||
if (state === "loading") checkEncryptionState();
|
if (state === "loading") checkEncryptionState();
|
||||||
}, [checkEncryptionState, state]);
|
}, [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.
|
// Also return the callback so that the component can re-run the logic.
|
||||||
return checkEncryptionState;
|
return checkEncryptionState;
|
||||||
}
|
}
|
||||||
|
@@ -2510,10 +2510,23 @@
|
|||||||
"session_key": "Session key:",
|
"session_key": "Session key:",
|
||||||
"title": "Advanced"
|
"title": "Advanced"
|
||||||
},
|
},
|
||||||
|
"delete_key_storage": {
|
||||||
|
"breadcrumb_page": "Delete key storage",
|
||||||
|
"confirm": "Delete key storage",
|
||||||
|
"description": "Deleting key storage will remove your cryptographic identity and message keys from the server and turn off the following security features:",
|
||||||
|
"list_first": "You will not have encrypted message history on new devices",
|
||||||
|
"list_second": "You will lose access to your encrypted messages if you are signed out of %(brand)s everywhere",
|
||||||
|
"title": "Are you sure you want to turn off key storage and delete it?"
|
||||||
|
},
|
||||||
"device_not_verified_button": "Verify this device",
|
"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_description": "You need to verify this device in order to view your encryption settings.",
|
||||||
"device_not_verified_title": "Device not verified",
|
"device_not_verified_title": "Device not verified",
|
||||||
"dialog_title": "<strong>Settings:</strong> Encryption",
|
"dialog_title": "<strong>Settings:</strong> Encryption",
|
||||||
|
"key_storage": {
|
||||||
|
"allow_key_storage": "Allow key storage",
|
||||||
|
"description": "Store your cryptographic identity and message keys securely on the server. This will allow you to view your message history on any new devices. <a>Learn more</a>",
|
||||||
|
"title": "Key storage"
|
||||||
|
},
|
||||||
"recovery": {
|
"recovery": {
|
||||||
"change_recovery_confirm_button": "Confirm new recovery key",
|
"change_recovery_confirm_button": "Confirm new recovery key",
|
||||||
"change_recovery_confirm_description": "Enter your new recovery key below to finish. Your old one will no longer work.",
|
"change_recovery_confirm_description": "Enter your new recovery key below to finish. Your old one will no longer work.",
|
||||||
|
@@ -151,9 +151,11 @@ export function createTestClient(): MatrixClient {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
isCrossSigningReady: jest.fn().mockResolvedValue(false),
|
isCrossSigningReady: jest.fn().mockResolvedValue(false),
|
||||||
|
disableKeyStorage: jest.fn(),
|
||||||
resetEncryption: jest.fn(),
|
resetEncryption: jest.fn(),
|
||||||
getSessionBackupPrivateKey: jest.fn().mockResolvedValue(null),
|
getSessionBackupPrivateKey: jest.fn().mockResolvedValue(null),
|
||||||
isSecretStorageReady: jest.fn().mockResolvedValue(false),
|
isSecretStorageReady: jest.fn().mockResolvedValue(false),
|
||||||
|
deleteKeyBackupVersion: jest.fn(),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getPushActionsForEvent: jest.fn(),
|
getPushActionsForEvent: jest.fn(),
|
||||||
@@ -192,6 +194,7 @@ export function createTestClient(): MatrixClient {
|
|||||||
}),
|
}),
|
||||||
mxcUrlToHttp: jest.fn().mockImplementation((mxc: string) => `http://this.is.a.url/${mxc.substring(6)}`),
|
mxcUrlToHttp: jest.fn().mockImplementation((mxc: string) => `http://this.is.a.url/${mxc.substring(6)}`),
|
||||||
setAccountData: jest.fn(),
|
setAccountData: jest.fn(),
|
||||||
|
deleteAccountData: jest.fn(),
|
||||||
setRoomAccountData: jest.fn(),
|
setRoomAccountData: jest.fn(),
|
||||||
setRoomTopic: jest.fn(),
|
setRoomTopic: jest.fn(),
|
||||||
setRoomReadMarkers: jest.fn().mockResolvedValue({}),
|
setRoomReadMarkers: jest.fn().mockResolvedValue({}),
|
||||||
|
@@ -0,0 +1,91 @@
|
|||||||
|
/*
|
||||||
|
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 { renderHook } from "jest-matrix-react";
|
||||||
|
import { act } from "react";
|
||||||
|
import { mocked } from "jest-mock";
|
||||||
|
|
||||||
|
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
|
import type { KeyBackupCheck, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
|
||||||
|
import { useKeyStoragePanelViewModel } from "../../../../../../src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel";
|
||||||
|
import { createTestClient, withClientContextRenderOptions } from "../../../../../test-utils";
|
||||||
|
|
||||||
|
describe("KeyStoragePanelViewModel", () => {
|
||||||
|
let matrixClient: MatrixClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
matrixClient = createTestClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update the pending value immediately", async () => {
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useKeyStoragePanelViewModel(),
|
||||||
|
withClientContextRenderOptions(matrixClient),
|
||||||
|
);
|
||||||
|
act(() => {
|
||||||
|
result.current.setEnabled(true);
|
||||||
|
});
|
||||||
|
expect(result.current.isEnabled).toBe(true);
|
||||||
|
expect(result.current.busy).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call resetKeyBackup if there is no backup currently", async () => {
|
||||||
|
mocked(matrixClient.getCrypto()!.checkKeyBackupAndEnable).mockResolvedValue(null);
|
||||||
|
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useKeyStoragePanelViewModel(),
|
||||||
|
withClientContextRenderOptions(matrixClient),
|
||||||
|
);
|
||||||
|
|
||||||
|
await result.current.setEnabled(true);
|
||||||
|
expect(mocked(matrixClient.getCrypto()!.resetKeyBackup)).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not call resetKeyBackup if there is a backup currently", async () => {
|
||||||
|
mocked(matrixClient.getCrypto()!.checkKeyBackupAndEnable).mockResolvedValue({} as KeyBackupCheck);
|
||||||
|
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useKeyStoragePanelViewModel(),
|
||||||
|
withClientContextRenderOptions(matrixClient),
|
||||||
|
);
|
||||||
|
|
||||||
|
await result.current.setEnabled(true);
|
||||||
|
expect(mocked(matrixClient.getCrypto()!.resetKeyBackup)).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set account data flag when enabling", async () => {
|
||||||
|
mocked(matrixClient.getCrypto()!.checkKeyBackupAndEnable).mockResolvedValue(null);
|
||||||
|
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useKeyStoragePanelViewModel(),
|
||||||
|
withClientContextRenderOptions(matrixClient),
|
||||||
|
);
|
||||||
|
|
||||||
|
await result.current.setEnabled(true);
|
||||||
|
expect(mocked(matrixClient.setAccountData)).toHaveBeenCalledWith("m.org.matrix.custom.backup_disabled", {
|
||||||
|
disabled: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should delete key storage when disabling", async () => {
|
||||||
|
mocked(matrixClient.getCrypto()!.checkKeyBackupAndEnable).mockResolvedValue({} as KeyBackupCheck);
|
||||||
|
mocked(matrixClient.getCrypto()!.getKeyBackupInfo).mockResolvedValue({ version: "99" } as KeyBackupInfo);
|
||||||
|
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useKeyStoragePanelViewModel(),
|
||||||
|
withClientContextRenderOptions(matrixClient),
|
||||||
|
);
|
||||||
|
|
||||||
|
await result.current.setEnabled(false);
|
||||||
|
|
||||||
|
expect(mocked(matrixClient.getCrypto()!.disableKeyStorage)).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,96 @@
|
|||||||
|
/*
|
||||||
|
* 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 from "react";
|
||||||
|
import { render, screen, waitFor } from "jest-matrix-react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { mocked } from "jest-mock";
|
||||||
|
import { defer } from "matrix-js-sdk/src/utils";
|
||||||
|
|
||||||
|
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { createTestClient, withClientContextRenderOptions } from "../../../../../test-utils";
|
||||||
|
import { DeleteKeyStoragePanel } from "../../../../../../src/components/views/settings/encryption/DeleteKeyStoragePanel";
|
||||||
|
import { useKeyStoragePanelViewModel } from "../../../../../../src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel";
|
||||||
|
|
||||||
|
jest.mock("../../../../../../src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel", () => ({
|
||||||
|
useKeyStoragePanelViewModel: jest
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue({ setEnabled: jest.fn(), isEnabled: true, loading: false, busy: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("<DeleteKeyStoragePanel />", () => {
|
||||||
|
let matrixClient: MatrixClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
matrixClient = createTestClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should match snapshot", async () => {
|
||||||
|
const { asFragment } = render(
|
||||||
|
<DeleteKeyStoragePanel onFinish={() => {}} />,
|
||||||
|
withClientContextRenderOptions(matrixClient),
|
||||||
|
);
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onFinished when cancel pressed", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const onFinish = jest.fn();
|
||||||
|
render(<DeleteKeyStoragePanel onFinish={onFinish} />, withClientContextRenderOptions(matrixClient));
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onFinish).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call disable key storage when confirm pressed", async () => {
|
||||||
|
const setEnabled = jest.fn();
|
||||||
|
|
||||||
|
mocked(useKeyStoragePanelViewModel).mockReturnValue({
|
||||||
|
setEnabled,
|
||||||
|
isEnabled: true,
|
||||||
|
loading: false,
|
||||||
|
busy: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const onFinish = jest.fn();
|
||||||
|
render(<DeleteKeyStoragePanel onFinish={onFinish} />, withClientContextRenderOptions(matrixClient));
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Delete key storage" }));
|
||||||
|
|
||||||
|
expect(setEnabled).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should wait with button disabled while setEnabled runs", async () => {
|
||||||
|
const setEnabledDefer = defer();
|
||||||
|
|
||||||
|
mocked(useKeyStoragePanelViewModel).mockReturnValue({
|
||||||
|
setEnabled: jest.fn().mockReturnValue(setEnabledDefer.promise),
|
||||||
|
isEnabled: true,
|
||||||
|
loading: false,
|
||||||
|
busy: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const onFinish = jest.fn();
|
||||||
|
render(<DeleteKeyStoragePanel onFinish={onFinish} />, withClientContextRenderOptions(matrixClient));
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Delete key storage" }));
|
||||||
|
|
||||||
|
expect(onFinish).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole("button", { name: "Delete key storage" })).toHaveAttribute("aria-disabled", "true");
|
||||||
|
setEnabledDefer.resolve();
|
||||||
|
await waitFor(() => expect(onFinish).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,156 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`<DeleteKeyStoragePanel /> should match snapshot 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<nav
|
||||||
|
class="_breadcrumb_1xygz_8"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="Back"
|
||||||
|
class="_icon-button_m2erp_8 _subtle-bg_m2erp_29"
|
||||||
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 28px;"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_indicator-icon_zr2a0_17"
|
||||||
|
style="--cpd-icon-button-size: 100%;"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m13.3 17.3-4.6-4.6a.9.9 0 0 1-.213-.325A1.1 1.1 0 0 1 8.425 12q0-.2.062-.375A.9.9 0 0 1 8.7 11.3l4.6-4.6a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7L10.8 12l3.9 3.9a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<ol
|
||||||
|
class="_pages_1xygz_17"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="_link_1v5rz_8"
|
||||||
|
data-kind="primary"
|
||||||
|
data-size="small"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
Encryption
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span
|
||||||
|
aria-current="page"
|
||||||
|
class="_last-page_1xygz_30"
|
||||||
|
>
|
||||||
|
Delete key storage
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<div
|
||||||
|
class="mx_EncryptionCard"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_EncryptionCard_header"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_content_o77nw_8 _destructive_o77nw_34"
|
||||||
|
data-size="large"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 17q.424 0 .713-.288A.97.97 0 0 0 13 16a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 15a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 16q0 .424.287.712.288.288.713.288m0-4q.424 0 .713-.287A.97.97 0 0 0 13 12V8a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 7a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 8v4q0 .424.287.713.288.287.713.287m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
|
||||||
|
>
|
||||||
|
Are you sure you want to turn off key storage and delete it?
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_Flex mx_EncryptionCard_emphasisedContent"
|
||||||
|
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: normal; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
|
||||||
|
>
|
||||||
|
Deleting key storage will remove your cryptographic identity and message keys from the server and turn off the following security features:
|
||||||
|
<ul
|
||||||
|
class="_visual-list_15wzx_8"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
class="_visual-list-item_1ma3e_8"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="_visual-list-item-icon_1ma3e_17 _visual-list-item-icon-destructive_1ma3e_26"
|
||||||
|
fill="currentColor"
|
||||||
|
height="24px"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24px"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
You will not have encrypted message history on new devices
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
class="_visual-list-item_1ma3e_8"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="_visual-list-item-icon_1ma3e_17 _visual-list-item-icon-destructive_1ma3e_26"
|
||||||
|
fill="currentColor"
|
||||||
|
height="24px"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24px"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
You will lose access to your encrypted messages if you are signed out of Element everywhere
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_EncryptionCard_buttons"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-disabled="false"
|
||||||
|
class="_button_vczzf_8 _destructive_vczzf_107"
|
||||||
|
data-kind="primary"
|
||||||
|
data-size="lg"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
Delete key storage
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="_button_vczzf_8"
|
||||||
|
data-kind="tertiary"
|
||||||
|
data-size="lg"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
@@ -6,10 +6,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { render, screen } from "jest-matrix-react";
|
import { act, render, screen } from "jest-matrix-react";
|
||||||
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
import { waitFor } from "@testing-library/dom";
|
import { waitFor } from "@testing-library/dom";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EncryptionUserSettingsTab,
|
EncryptionUserSettingsTab,
|
||||||
@@ -66,12 +67,21 @@ describe("<EncryptionUserSettingsTab />", () => {
|
|||||||
expect(spy).toHaveBeenCalled();
|
expect(spy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should display the recovery panel when the encryption is set up", async () => {
|
it("should display the recovery panel when key storage is enabled", async () => {
|
||||||
|
jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1");
|
||||||
renderComponent();
|
renderComponent();
|
||||||
await waitFor(() => expect(screen.getByText("Recovery")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("Recovery")).toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should not display the recovery panel when key storage is not enabled", async () => {
|
||||||
|
jest.spyOn(matrixClient.getCrypto()!, "getKeyBackupInfo").mockResolvedValue(null);
|
||||||
|
jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue(null);
|
||||||
|
renderComponent();
|
||||||
|
await expect(screen.queryByText("Recovery")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("should display the recovery out of sync panel when secrets are not cached", async () => {
|
it("should display the recovery out of sync panel when secrets are not cached", async () => {
|
||||||
|
jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1");
|
||||||
// Secrets are not cached
|
// Secrets are not cached
|
||||||
jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({
|
jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({
|
||||||
privateKeysInSecretStorage: true,
|
privateKeysInSecretStorage: true,
|
||||||
@@ -96,6 +106,7 @@ describe("<EncryptionUserSettingsTab />", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should display the change recovery key panel when the user clicks on the change recovery button", async () => {
|
it("should display the change recovery key panel when the user clicks on the change recovery button", async () => {
|
||||||
|
jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1");
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
const { asFragment } = renderComponent();
|
const { asFragment } = renderComponent();
|
||||||
@@ -109,6 +120,7 @@ describe("<EncryptionUserSettingsTab />", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should display the set up recovery key when the user clicks on the set up recovery key button", async () => {
|
it("should display the set up recovery key when the user clicks on the set up recovery key button", async () => {
|
||||||
|
jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1");
|
||||||
jest.spyOn(matrixClient.secretStorage, "getDefaultKeyId").mockResolvedValue(null);
|
jest.spyOn(matrixClient.secretStorage, "getDefaultKeyId").mockResolvedValue(null);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
@@ -123,6 +135,8 @@ describe("<EncryptionUserSettingsTab />", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should display the reset identity panel when the user clicks on the reset cryptographic identity panel", async () => {
|
it("should display the reset identity panel when the user clicks on the reset cryptographic identity panel", async () => {
|
||||||
|
jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1");
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
const { asFragment } = renderComponent();
|
const { asFragment } = renderComponent();
|
||||||
@@ -137,17 +151,41 @@ describe("<EncryptionUserSettingsTab />", () => {
|
|||||||
expect(asFragment()).toMatchSnapshot();
|
expect(asFragment()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should enter reset flow when showResetIdentity is set", () => {
|
it("should enter reset flow when showResetIdentity is set", async () => {
|
||||||
|
jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1");
|
||||||
|
|
||||||
renderComponent({ initialState: "reset_identity_forgot" });
|
renderComponent({ initialState: "reset_identity_forgot" });
|
||||||
|
|
||||||
expect(
|
await expect(
|
||||||
screen.getByRole("heading", { name: "Forgot your recovery key? You’ll need to reset your identity." }),
|
await screen.findByRole("heading", {
|
||||||
|
name: "Forgot your recovery key? You’ll need to reset your identity.",
|
||||||
|
}),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should update when key backup status event is fired", async () => {
|
||||||
|
jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1");
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
await expect(await screen.findByRole("heading", { name: "Recovery" })).toBeVisible();
|
||||||
|
|
||||||
|
jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue(null);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
matrixClient.emit(CryptoEvent.KeyBackupStatus, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("heading", { name: "Recovery" })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("should re-check the encryption state and displays the correct panel when the user clicks cancel the reset identity flow", async () => {
|
it("should re-check the encryption state and displays the correct panel when the user clicks cancel the reset identity flow", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1");
|
||||||
|
|
||||||
// Secrets are not cached
|
// Secrets are not cached
|
||||||
jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({
|
jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({
|
||||||
privateKeysInSecretStorage: true,
|
privateKeysInSecretStorage: true,
|
||||||
|