Prompt users to set up recovery (#30075)
* Show indicator in settings dialog when user doesn't have recovery set up * Update settings headers to use red dot for recommended settings * update recovery setup toast and remember if the user dismisses it * update playwright snapshots * use typed event emitters * reverse logic for the account data flag * fix comment and type
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 272 KiB After Width: | Height: | Size: 272 KiB |
@ -30,4 +30,28 @@ Please see LICENSE files in the repository root for full details.
|
||||
/* colliding harshly with the dialog when scrolled down. */
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.mx_SettingsDialog_tabLabelsAlert::after {
|
||||
display: inline-block;
|
||||
content: "";
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--cpd-color-icon-critical-primary);
|
||||
clip-path: circle(4px);
|
||||
position: absolute;
|
||||
right: var(--cpd-space-4x);
|
||||
}
|
||||
}
|
||||
|
||||
/* On narrow viewports, the tab labels are hidden, so we need to shift the indicator so it isn't over the tab icon. */
|
||||
@media (max-width: 1024px) {
|
||||
.mx_UserSettingsDialog,
|
||||
.mx_RoomSettingsDialog,
|
||||
.mx_SpaceSettingsDialog,
|
||||
.mx_SpacePreferencesDialog {
|
||||
.mx_SettingsDialog_tabLabelsAlert::after {
|
||||
right: var(--cpd-space-1x);
|
||||
top: var(--cpd-space-1x);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,4 +16,13 @@
|
||||
font: var(--cpd-font-body-sm-medium);
|
||||
color: var(--cpd-color-text-action-accent);
|
||||
}
|
||||
|
||||
&.mx_SettingsHeader_recommended::after {
|
||||
display: inline-block;
|
||||
content: "";
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--cpd-color-icon-critical-primary);
|
||||
clip-path: circle(4px);
|
||||
}
|
||||
}
|
||||
|
||||
3
src/@types/matrix-js-sdk.d.ts
vendored
@ -92,6 +92,9 @@ declare module "matrix-js-sdk/src/types" {
|
||||
// MSC4155: Invite filtering
|
||||
[INVITE_RULES_ACCOUNT_DATA_TYPE]: InviteConfigAccountData;
|
||||
"io.element.msc4278.media_preview_config": MediaPreviewConfig;
|
||||
|
||||
// Indicate whether recovery is enabled or disabled
|
||||
"io.element.recovery": { enabled: boolean };
|
||||
}
|
||||
|
||||
export interface AudioContent {
|
||||
|
||||
@ -57,6 +57,11 @@ const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
|
||||
*/
|
||||
export const BACKUP_DISABLED_ACCOUNT_DATA_KEY = "m.org.matrix.custom.backup_disabled";
|
||||
|
||||
/**
|
||||
* Account data key to indicate whether the user has chosen to enable or disable recovery.
|
||||
*/
|
||||
export const RECOVERY_ACCOUNT_DATA_KEY = "io.element.recovery";
|
||||
|
||||
const logger = baseLogger.getChild("DeviceListener:");
|
||||
|
||||
export default class DeviceListener {
|
||||
@ -165,6 +170,13 @@ export default class DeviceListener {
|
||||
await this.client?.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the account data to indicate that recovery is disabled
|
||||
*/
|
||||
public async recordRecoveryDisabled(): Promise<void> {
|
||||
await this.client?.setAccountData(RECOVERY_ACCOUNT_DATA_KEY, { enabled: false });
|
||||
}
|
||||
|
||||
private async ensureDeviceIdsAtStartPopulated(): Promise<void> {
|
||||
if (this.ourDeviceIdsAtStart === null) {
|
||||
this.ourDeviceIdsAtStart = await this.getDeviceIds();
|
||||
@ -220,7 +232,8 @@ export default class DeviceListener {
|
||||
ev.getType().startsWith("m.secret_storage.") ||
|
||||
ev.getType().startsWith("m.cross_signing.") ||
|
||||
ev.getType() === "m.megolm_backup.v1" ||
|
||||
ev.getType() === BACKUP_DISABLED_ACCOUNT_DATA_KEY
|
||||
ev.getType() === BACKUP_DISABLED_ACCOUNT_DATA_KEY ||
|
||||
ev.getType() === RECOVERY_ACCOUNT_DATA_KEY
|
||||
) {
|
||||
this.recheck();
|
||||
}
|
||||
@ -332,6 +345,9 @@ export default class DeviceListener {
|
||||
crossSigningStatus.privateKeysCachedLocally.userSigningKey;
|
||||
|
||||
const defaultKeyId = await cli.secretStorage.getDefaultKeyId();
|
||||
const recoveryDisabled = await this.recheckRecoveryDisabled(cli);
|
||||
|
||||
const recoveryIsOk = secretStorageReady || recoveryDisabled;
|
||||
|
||||
const isCurrentDeviceTrusted =
|
||||
crossSigningReady &&
|
||||
@ -346,8 +362,7 @@ export default class DeviceListener {
|
||||
// said we are OK with that.
|
||||
const keyBackupIsOk = keyBackupUploadActive || backupDisabled;
|
||||
|
||||
const allSystemsReady =
|
||||
crossSigningReady && keyBackupIsOk && secretStorageReady && allCrossSigningSecretsCached;
|
||||
const allSystemsReady = crossSigningReady && keyBackupIsOk && recoveryIsOk && allCrossSigningSecretsCached;
|
||||
|
||||
await this.reportCryptoSessionStateToAnalytics(cli);
|
||||
|
||||
@ -384,7 +399,10 @@ export default class DeviceListener {
|
||||
// The user just hasn't set up 4S yet: if they have key
|
||||
// backup, prompt them to turn on recovery too. (If not, they
|
||||
// have explicitly opted out, so don't hassle them.)
|
||||
if (keyBackupUploadActive) {
|
||||
if (recoveryDisabled) {
|
||||
logSpan.info("Recovery disabled: no toast needed");
|
||||
hideSetupEncryptionToast();
|
||||
} else if (keyBackupUploadActive) {
|
||||
logSpan.info("No default 4S key: showing SET_UP_RECOVERY toast");
|
||||
showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY);
|
||||
} else {
|
||||
@ -482,6 +500,20 @@ export default class DeviceListener {
|
||||
return !!backupDisabled?.disabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the user has disabled recovery. If this is the first time,
|
||||
* fetch it from the server (in case the initial sync has not finished).
|
||||
* Otherwise, fetch it from the store as normal.
|
||||
*/
|
||||
private async recheckRecoveryDisabled(cli: MatrixClient): Promise<boolean> {
|
||||
const recoveryStatus = await cli.getAccountDataFromServer(RECOVERY_ACCOUNT_DATA_KEY);
|
||||
// Recovery is disabled only if the `enabled` flag is set to `false`.
|
||||
// If it is missing, or set to any other value, we consider it as
|
||||
// not-disabled, and will prompt the user to create recovery (if
|
||||
// missing).
|
||||
return recoveryStatus?.enabled === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports current recovery state to analytics.
|
||||
* Checks if the session is verified and if the recovery is correctly set up (i.e all secrets known locally and in 4S).
|
||||
|
||||
@ -29,6 +29,7 @@ export class Tab<T extends string> {
|
||||
* @param {string|JSX.Element} icon An SVG element to use for the tab icon. Can also be a string for legacy icons, in which case it is the class for the tab icon. This should be a simple mask.
|
||||
* @param {JSX.Element} body The JSX for the tab container.
|
||||
* @param {string} screenName The screen name to report to Posthog.
|
||||
* @param {string} labelClassName Additional class to add to the tab label.
|
||||
*/
|
||||
public constructor(
|
||||
public readonly id: T,
|
||||
@ -36,6 +37,7 @@ export class Tab<T extends string> {
|
||||
public readonly icon: string | JSX.Element | null,
|
||||
public readonly body: JSX.Element,
|
||||
public readonly screenName?: ScreenName,
|
||||
public readonly labelClassName?: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
@ -85,7 +87,7 @@ interface ITabLabelProps<T extends string> {
|
||||
}
|
||||
|
||||
function TabLabel<T extends string>({ tab, isActive, showToolip, onClick }: ITabLabelProps<T>): JSX.Element {
|
||||
const classes = classNames("mx_TabbedView_tabLabel", {
|
||||
const classes = classNames("mx_TabbedView_tabLabel", tab.labelClassName, {
|
||||
mx_TabbedView_tabLabel_active: isActive,
|
||||
});
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ 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 { ClientEvent, type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { Toast } from "@vector-im/compound-web";
|
||||
import React, { type JSX, useState } from "react";
|
||||
import UserProfileIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-profile";
|
||||
@ -44,6 +45,7 @@ import { UserTab } from "./UserTab";
|
||||
import { type NonEmptyArray } from "../../../@types/common";
|
||||
import { SDKContext, type SdkContextClass } from "../../../contexts/SDKContext";
|
||||
import { useSettingValue } from "../../../hooks/useSettings";
|
||||
import { NoChange, useEventEmitterAsyncState, type AsyncStateCallbackResult } from "../../../hooks/useEventEmitter";
|
||||
import { ToastContext, useActiveToast } from "../../../contexts/ToastContext";
|
||||
import { EncryptionUserSettingsTab, type State } from "../settings/tabs/user/EncryptionUserSettingsTab";
|
||||
|
||||
@ -100,6 +102,26 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
|
||||
const [showMsc4108QrCode, setShowMsc4108QrCode] = useState(props.showMsc4108QrCode);
|
||||
const [initialEncryptionState, setInitialEncryptionState] = useState(props.initialEncryptionState);
|
||||
|
||||
// If the user doesn't have Recovery set up (no default Secret Storage key),
|
||||
// we show an indicator on the Encryption tab.
|
||||
const showSetupRecoveryIndicator = useEventEmitterAsyncState(
|
||||
props.sdkContext.client,
|
||||
ClientEvent.AccountData,
|
||||
async (event?: MatrixEvent): AsyncStateCallbackResult<boolean> => {
|
||||
if (event === undefined || event.getType() === "m.secret_storage.default_key") {
|
||||
const client = props.sdkContext.client;
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !(await client.secretStorage.getDefaultKeyId());
|
||||
}
|
||||
return new NoChange();
|
||||
},
|
||||
[],
|
||||
false,
|
||||
);
|
||||
|
||||
const getTabs = (): NonEmptyArray<Tab<UserTab>> => {
|
||||
const tabs: Tab<UserTab>[] = [];
|
||||
|
||||
@ -196,6 +218,7 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
|
||||
<KeyIcon />,
|
||||
<EncryptionUserSettingsTab initialState={initialEncryptionState} />,
|
||||
"UserSettingsEncryption",
|
||||
showSetupRecoveryIndicator ? "mx_SettingsDialog_tabLabelsAlert" : undefined,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@ -6,10 +6,9 @@
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import classNames from "classnames";
|
||||
import { Heading } from "@vector-im/compound-web";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
|
||||
/**
|
||||
* The heading for a settings section.
|
||||
*/
|
||||
@ -25,9 +24,12 @@ interface SettingsHeaderProps {
|
||||
}
|
||||
|
||||
export function SettingsHeader({ hasRecommendedTag = false, label }: SettingsHeaderProps): JSX.Element {
|
||||
const classes = classNames("mx_SettingsHeader", {
|
||||
mx_SettingsHeader_recommended: hasRecommendedTag,
|
||||
});
|
||||
return (
|
||||
<Heading className="mx_SettingsHeader" as="h2" size="sm" weight="semibold">
|
||||
{label} {hasRecommendedTag && <span>{_t("common|recommended")}</span>}
|
||||
<Heading className={classes} as="h2" size="sm" weight="semibold">
|
||||
{label}
|
||||
</Heading>
|
||||
);
|
||||
}
|
||||
|
||||
@ -29,6 +29,7 @@ import { initialiseDehydrationIfEnabled } from "../../../../utils/device/dehydra
|
||||
import { withSecretStorageKeyCache } from "../../../../SecurityManager";
|
||||
import { EncryptionCardButtons } from "./EncryptionCardButtons";
|
||||
import { logErrorAndShowErrorDialog } from "../../../../utils/ErrorUtils.tsx";
|
||||
import { RECOVERY_ACCOUNT_DATA_KEY } from "../../../../DeviceListener";
|
||||
|
||||
/**
|
||||
* The possible states of the component.
|
||||
@ -131,6 +132,10 @@ export function ChangeRecoveryKey({
|
||||
});
|
||||
await initialiseDehydrationIfEnabled(matrixClient, { createNewKey: true });
|
||||
});
|
||||
|
||||
// Record the fact that the user explicitly enabled recovery.
|
||||
await matrixClient.setAccountData(RECOVERY_ACCOUNT_DATA_KEY, { enabled: true });
|
||||
|
||||
onFinish();
|
||||
} catch (e) {
|
||||
logErrorAndShowErrorDialog("Failed to set up secret storage", e);
|
||||
|
||||
@ -6,7 +6,7 @@ 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 { useRef, useEffect, useState, useCallback } from "react";
|
||||
import { useRef, useEffect, useState, useCallback, type DependencyList } from "react";
|
||||
import { type ListenerMap, type TypedEventEmitter } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import type { EventEmitter } from "events";
|
||||
@ -93,3 +93,100 @@ export function useEventEmitterState<T>(
|
||||
useEventEmitter(emitter, eventName, handler);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The return value of the callback function for `useEventEmitterAsyncState`.
|
||||
*/
|
||||
export type AsyncStateCallbackResult<T> = Promise<T | NoChange>;
|
||||
|
||||
/**
|
||||
* Creates a state, which is computed asynchronously, and can be updated by events.
|
||||
*
|
||||
* Similar to `useEventEmitterState`, but the callback is `async`.
|
||||
*
|
||||
* If the event is emitted while the callback is running, it will wait until
|
||||
* after the callback completes before calling the callback again. If the event
|
||||
* is emitted multiple times while the callback is running, the callback will be
|
||||
* called once for each time the event was emitted, in the order that the events
|
||||
* were emitted.
|
||||
*
|
||||
* @param emitter The emitter sending the event
|
||||
* @param eventName Event name to listen for
|
||||
* @param fn The callback function, that should return the state value.
|
||||
* It should have the signature of the event callback, except that all
|
||||
* parameters are optional. If the params are not set, a default value
|
||||
* for the state should be returned. If the state value should not
|
||||
* change from its previous value, the function can return a `NoChange`
|
||||
* object.
|
||||
* @param deps The dependencies of the callback function.
|
||||
* @param initialValue The initial value of the state, before the callback finishes its initial run.
|
||||
* @returns State
|
||||
*/
|
||||
export function useEventEmitterAsyncState<T, Events extends string, Arguments extends ListenerMap<Events>>(
|
||||
emitter: TypedEventEmitter<Events, Arguments> | undefined,
|
||||
eventName: string | symbol,
|
||||
fn: Mapper<AsyncStateCallbackResult<T>>,
|
||||
deps: DependencyList,
|
||||
initialValue: T,
|
||||
): T;
|
||||
export function useEventEmitterAsyncState<T, Events extends string, Arguments extends ListenerMap<Events>>(
|
||||
emitter: TypedEventEmitter<Events, Arguments> | undefined,
|
||||
eventName: string | symbol,
|
||||
fn: Mapper<AsyncStateCallbackResult<T>>,
|
||||
deps: DependencyList,
|
||||
initialValue?: T,
|
||||
): T | undefined;
|
||||
export function useEventEmitterAsyncState<T, Events extends string, Arguments extends ListenerMap<Events>>(
|
||||
emitter: TypedEventEmitter<Events, Arguments> | undefined,
|
||||
eventName: string | symbol,
|
||||
fn: Mapper<AsyncStateCallbackResult<T>>,
|
||||
deps: DependencyList,
|
||||
initialValue?: T,
|
||||
): T | undefined {
|
||||
const [value, setValue] = useState<T | undefined>(initialValue);
|
||||
|
||||
let running = false;
|
||||
// If the handler is called while it's already running, we remember the
|
||||
// arguments that it was called with, and call the handler again when the
|
||||
// first call is done.
|
||||
const rerunArgs: any[] = [];
|
||||
|
||||
const handler = useCallback(
|
||||
(...args: any[]) => {
|
||||
if (running) {
|
||||
// We're already running, so remember the arguments we were
|
||||
// called with, so that we can call the handler again when we're
|
||||
// done.
|
||||
rerunArgs.push(args);
|
||||
return;
|
||||
}
|
||||
running = true; // eslint-disable-line react-hooks/exhaustive-deps
|
||||
// Note: We need to use .then notation instead of async/await,
|
||||
// because async/await would cause this function to return a
|
||||
// promise, which `useEffect` doesn't like.
|
||||
fn(...args)
|
||||
.then((v) => {
|
||||
if (!(v instanceof NoChange)) {
|
||||
setValue(v);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
running = false;
|
||||
if (rerunArgs.length != 0) {
|
||||
handler(...rerunArgs.shift());
|
||||
}
|
||||
});
|
||||
},
|
||||
[fn, ...deps], // eslint-disable-line react-compiler/react-compiler
|
||||
);
|
||||
|
||||
// re-run when the emitter changes
|
||||
useEffect(handler, [emitter, handler, ...deps]);
|
||||
useEventEmitter(emitter, eventName, handler);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates that the callback for `useEventEmitterAsyncState` is not changing the value of the state.
|
||||
*/
|
||||
export class NoChange {}
|
||||
|
||||
@ -968,7 +968,6 @@
|
||||
},
|
||||
"reset_all_button": "Forgotten or lost all recovery methods? <a>Reset all</a>",
|
||||
"set_up_recovery": "Set up recovery",
|
||||
"set_up_recovery_later": "Not now",
|
||||
"set_up_recovery_toast_description": "Generate a recovery key that can be used to restore your encrypted message history in case you lose access to your devices.",
|
||||
"set_up_toast_description": "Safeguard against losing access to encrypted messages & data",
|
||||
"set_up_toast_title": "Set up Secure Backup",
|
||||
|
||||
@ -88,7 +88,7 @@ const getPrimaryButtonIcon = (kind: Kind): ComponentType<React.SVGAttributes<SVG
|
||||
const getSecondaryButtonLabel = (kind: Kind): string => {
|
||||
switch (kind) {
|
||||
case Kind.SET_UP_RECOVERY:
|
||||
return _t("encryption|set_up_recovery_later");
|
||||
return _t("action|dismiss");
|
||||
case Kind.SET_UP_ENCRYPTION:
|
||||
case Kind.VERIFY_THIS_SESSION:
|
||||
return _t("encryption|verification|unverified_sessions_toast_reject");
|
||||
@ -201,6 +201,11 @@ export const showToast = (kind: Kind): void => {
|
||||
await deviceListener.recordKeyBackupDisabled();
|
||||
deviceListener.dismissEncryptionSetup();
|
||||
}
|
||||
} else if (kind === Kind.SET_UP_RECOVERY) {
|
||||
// Record that the user doesn't want to set up recovery
|
||||
const deviceListener = DeviceListener.sharedInstance();
|
||||
await deviceListener.recordRecoveryDisabled();
|
||||
deviceListener.dismissEncryptionSetup();
|
||||
} else {
|
||||
DeviceListener.sharedInstance().dismissEncryptionSetup();
|
||||
}
|
||||
|
||||
@ -153,7 +153,11 @@ export const mockClientMethodsCrypto = (): Partial<
|
||||
> => ({
|
||||
isKeyBackupKeyStored: jest.fn(),
|
||||
getCrossSigningCacheCallbacks: jest.fn().mockReturnValue({ getCrossSigningKeyCache: jest.fn() }),
|
||||
secretStorage: { hasKey: jest.fn(), isStored: jest.fn().mockResolvedValue(null) },
|
||||
secretStorage: {
|
||||
hasKey: jest.fn(),
|
||||
isStored: jest.fn().mockResolvedValue(null),
|
||||
getDefaultKeyId: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
getCrypto: jest.fn().mockReturnValue({
|
||||
getUserDeviceInfo: jest.fn(),
|
||||
getCrossSigningStatus: jest.fn().mockResolvedValue({
|
||||
|
||||
@ -480,6 +480,15 @@ describe("DeviceListener", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sets the recovery account data when we call recordRecoveryDisabled", async () => {
|
||||
const instance = await createAndStart();
|
||||
await instance.recordRecoveryDisabled();
|
||||
|
||||
expect(mockClient.setAccountData).toHaveBeenCalledWith("io.element.recovery", {
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("when crypto is in use and set up", () => {
|
||||
beforeEach(() => {
|
||||
// Encryption is in use
|
||||
|
||||
@ -7,9 +7,9 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type ReactElement } from "react";
|
||||
import { render, screen } from "jest-matrix-react";
|
||||
import { render, screen, waitFor } from "jest-matrix-react";
|
||||
import { mocked, type MockedObject } from "jest-mock";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { ClientEvent, MatrixEvent, type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import SettingsStore, { type CallbackFn } from "../../../../../src/settings/SettingsStore";
|
||||
import SdkConfig from "../../../../../src/SdkConfig";
|
||||
@ -250,4 +250,28 @@ describe("<UserSettingsDialog />", () => {
|
||||
// unwatches settings on unmount
|
||||
expect(mockSettingsStore.unwatchSetting).toHaveBeenCalledWith("mock-watcher-id-feature_mjolnir");
|
||||
});
|
||||
|
||||
it("displays an indicator when user needs to set up recovery", async () => {
|
||||
// Initially, the user doesn't have secret storage, so it should display
|
||||
// an indicator.
|
||||
mockClient.secretStorage.getDefaultKeyId.mockResolvedValue(null);
|
||||
|
||||
const { container } = render(getComponent());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(".mx_SettingsDialog_tabLabelsAlert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Test that the handler ignores unknown account data
|
||||
mockClient.emit(ClientEvent.AccountData, new MatrixEvent({ type: "bar" }));
|
||||
|
||||
// The user now has secret storage. Trigger an update and check that
|
||||
// the indicator disappears.
|
||||
mockClient.secretStorage.getDefaultKeyId.mockResolvedValue("foo");
|
||||
mockClient.emit(ClientEvent.AccountData, new MatrixEvent({ type: "m.secret_storage.default_key" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(".mx_SettingsDialog_tabLabelsAlert")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -5,7 +5,7 @@ exports[`<SettingsHeader /> should render the component 1`] = `
|
||||
<h2
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 mx_SettingsHeader"
|
||||
>
|
||||
Settings Header
|
||||
Settings Header
|
||||
</h2>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@ -13,12 +13,9 @@ exports[`<SettingsHeader /> should render the component 1`] = `
|
||||
exports[`<SettingsHeader /> should render the component with the recommended tag 1`] = `
|
||||
<DocumentFragment>
|
||||
<h2
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 mx_SettingsHeader"
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 mx_SettingsHeader mx_SettingsHeader_recommended"
|
||||
>
|
||||
Settings Header
|
||||
<span>
|
||||
Recommended
|
||||
</span>
|
||||
Settings Header
|
||||
</h2>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
@ -104,12 +104,14 @@ describe("<ChangeRecoveryKey />", () => {
|
||||
expect(screen.getByText("The recovery key you entered is not correct.")).toBeInTheDocument();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
|
||||
const setAccountDataSpy = jest.spyOn(matrixClient, "setAccountData");
|
||||
await userEvent.clear(input);
|
||||
// If the user enters the correct recovery key, the finish button should be enabled
|
||||
await userEvent.type(input, "encoded private key");
|
||||
await waitFor(() => expect(finishButton).not.toHaveAttribute("aria-disabled", "true"));
|
||||
|
||||
await user.click(finishButton);
|
||||
expect(setAccountDataSpy).toHaveBeenCalledWith("io.element.recovery", { enabled: true });
|
||||
expect(onFinish).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ exports[`<RecoveryPanel /> should allow to change the recovery key when everythi
|
||||
<h2
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 mx_SettingsHeader"
|
||||
>
|
||||
Recovery
|
||||
Recovery
|
||||
</h2>
|
||||
Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices.
|
||||
</div>
|
||||
@ -51,12 +51,9 @@ exports[`<RecoveryPanel /> should ask to set up a recovery key when there is no
|
||||
class="mx_SettingsSection_header"
|
||||
>
|
||||
<h2
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 mx_SettingsHeader"
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 mx_SettingsHeader mx_SettingsHeader_recommended"
|
||||
>
|
||||
Recovery
|
||||
<span>
|
||||
Recommended
|
||||
</span>
|
||||
Recovery
|
||||
</h2>
|
||||
Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices.
|
||||
</div>
|
||||
@ -97,7 +94,7 @@ exports[`<RecoveryPanel /> should be in loading state when checking the recovery
|
||||
<h2
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 mx_SettingsHeader"
|
||||
>
|
||||
Recovery
|
||||
Recovery
|
||||
</h2>
|
||||
Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices.
|
||||
</div>
|
||||
|
||||
@ -12,7 +12,7 @@ exports[`<RecoveyPanelOutOfSync /> should render 1`] = `
|
||||
<h2
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 mx_SettingsHeader"
|
||||
>
|
||||
Recovery
|
||||
Recovery
|
||||
</h2>
|
||||
<div
|
||||
class="mx_SettingsSubheader"
|
||||
|
||||
@ -18,7 +18,7 @@ exports[`<EncryptionUserSettingsTab /> should display a verify button when the e
|
||||
<h2
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 mx_SettingsHeader"
|
||||
>
|
||||
Device not verified
|
||||
Device not verified
|
||||
</h2>
|
||||
<div
|
||||
class="mx_SettingsSubheader"
|
||||
@ -100,7 +100,7 @@ exports[`<EncryptionUserSettingsTab /> should display the recovery out of sync p
|
||||
<h2
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 mx_SettingsHeader"
|
||||
>
|
||||
Recovery
|
||||
Recovery
|
||||
</h2>
|
||||
<div
|
||||
class="mx_SettingsSubheader"
|
||||
|
||||
@ -36,14 +36,16 @@ describe("SetupEncryptionToast", () => {
|
||||
expect(await screen.findByRole("heading", { name: "Set up recovery" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should dismiss the toast when 'not now' button clicked", async () => {
|
||||
it("should dismiss the toast when 'Dismiss' button clicked, and remember it", async () => {
|
||||
jest.spyOn(DeviceListener.sharedInstance(), "recordRecoveryDisabled");
|
||||
jest.spyOn(DeviceListener.sharedInstance(), "dismissEncryptionSetup");
|
||||
|
||||
showToast(Kind.SET_UP_RECOVERY);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole("button", { name: "Not now" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Dismiss" }));
|
||||
|
||||
expect(DeviceListener.sharedInstance().recordRecoveryDisabled).toHaveBeenCalled();
|
||||
expect(DeviceListener.sharedInstance().dismissEncryptionSetup).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||