Merge remote-tracking branch 'origin/develop' into hs/roomstatusbar-vm

This commit is contained in:
Half-Shot 2025-12-19 17:29:16 +00:00
commit 5fdf04fea2
26 changed files with 810 additions and 390 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View File

@ -27,8 +27,6 @@
padding: var(--cpd-space-4x);
border-top: 1px solid var(--cpd-color-gray-400);
white-space: nowrap;
}
.banner[data-type="success"] {
@ -90,4 +88,6 @@
flex-direction: row;
gap: var(--cpd-space-1x);
align-self: center;
white-space: nowrap;
}

View File

@ -11,7 +11,6 @@ import { type Meta, type StoryObj } from "@storybook/react-vite";
import { Button } from "@vector-im/compound-web";
import { Banner } from "./Banner";
import { _t } from "../../utils/i18n";
const meta = {
title: "room/Banner",
@ -46,17 +45,14 @@ export const WithAction: Story = {
args: {
children: (
<p>
{_t(
"encryption|pinned_identity_changed",
{ displayName: "Alice", userId: "@alice:example.org" },
{
a: (sub) => <a href="https://example.org">{sub}</a>,
b: (sub) => <b>{sub}</b>,
},
)}
Alice's (<b>@alice:example.com</b>) identity was reset. <a href="https://example.org">Learn more</a>
</p>
),
actions: <Button kind="primary">{_t("encryption|withdraw_verification_action")}</Button>,
actions: (
<Button kind="primary" size="sm">
Withdraw verification
</Button>
),
},
};
@ -71,3 +67,19 @@ export const WithoutClose: Story = {
onClose: undefined,
},
};
export const WithLoadsOfContent: Story = {
args: {
type: "info",
children: (
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed quis massa facilisis, venenatis risus
consectetur, sagittis libero. Aenean et scelerisque justo. Nunc luctus, mi sed facilisis suscipit, magna
ante pharetra sem, eu rutrum purus quam quis arcu. Sed eleifend arcu vitae magna sodales, sit amet
fermentum urna dictum. Mauris vel velit pulvinar enim mollis tincidunt. Vivamus egestas rhoncus
sagittis. Curabitur auctor vehicula massa, et cursus lacus laoreet a. Maecenas et sollicitudin lectus,
in ligula.
</p>
),
},
};

View File

@ -78,7 +78,7 @@ export function Banner({
return (
<div {...props} className={classes} data-type={type}>
<div className={styles.icon}>{avatar ?? icon}</div>
<span className={styles.content}>{children}</span>
<div className={styles.content}>{children}</div>
<div className={styles.actions}>
{actions}
{onClose && (

View File

@ -26,24 +26,33 @@ exports[`AvatarWithDetails renders a banner with an action 1`] = `
/>
</svg>
</div>
<span
<div
class="content"
>
<p>
encryption|pinned_identity_changed
Alice's (
<b>
@alice:example.com
</b>
) identity was reset.
<a
href="https://example.org"
>
Learn more
</a>
</p>
</span>
</div>
<div
class="actions"
>
<button
class="_button_187yx_8"
data-kind="primary"
data-size="lg"
data-size="sm"
role="button"
tabindex="0"
>
encryption|withdraw_verification_action
Withdraw verification
</button>
<button
class="_button_187yx_8"
@ -72,13 +81,13 @@ exports[`AvatarWithDetails renders a banner with an avatar iamge 1`] = `
src="https://picsum.photos/32/32"
/>
</div>
<span
<div
class="content"
>
<p>
Hello! This is a status banner.
</p>
</span>
</div>
<div
class="actions"
>
@ -118,13 +127,13 @@ exports[`AvatarWithDetails renders a critical banner 1`] = `
/>
</svg>
</div>
<span
<div
class="content"
>
<p>
Hello! This is a status banner.
</p>
</span>
</div>
<div
class="actions"
>
@ -168,13 +177,13 @@ exports[`AvatarWithDetails renders a default banner 1`] = `
/>
</svg>
</div>
<span
<div
class="content"
>
<p>
Hello! This is a status banner.
</p>
</span>
</div>
<div
class="actions"
>
@ -219,13 +228,13 @@ exports[`AvatarWithDetails renders a info banner 1`] = `
/>
</svg>
</div>
<span
<div
class="content"
>
<p>
Hello! This is a status banner.
</p>
</span>
</div>
<div
class="actions"
>
@ -265,13 +274,13 @@ exports[`AvatarWithDetails renders a success banner 1`] = `
/>
</svg>
</div>
<span
<div
class="content"
>
<p>
Hello! This is a status banner.
</p>
</span>
</div>
<div
class="actions"
>

View File

@ -27,7 +27,7 @@ exports[`HistoryVisibleBannerView renders a history visible banner 1`] = `
/>
</svg>
</div>
<span
<div
class="content"
>
<span>
@ -43,7 +43,7 @@ exports[`HistoryVisibleBannerView renders a history visible banner 1`] = `
Learn More
</a>
</span>
</span>
</div>
<div
class="actions"
>

View File

@ -25,7 +25,9 @@ test.describe("Encryption tab", () => {
test.beforeEach(async ({ page, homeserver, credentials }) => {
// The bot bootstraps cross-signing, creates a key backup and sets up a recovery key
const res = await createBot(page, homeserver, credentials);
const botCredentials = { ...credentials };
delete botCredentials.accessToken; // use a new login for the bot
const res = await createBot(page, homeserver, botCredentials);
recoveryKey = res.recoveryKey;
expectedBackupVersion = res.expectedBackupVersion;
});

View File

@ -17,7 +17,9 @@ test.describe("Recovery section in Encryption tab", () => {
let recoveryKey: GeneratedSecretStorageKey;
test.beforeEach(async ({ page, homeserver, credentials }) => {
// The bot bootstraps cross-signing, creates a key backup and sets up a recovery key
const res = await createBot(page, homeserver, credentials);
const botCredentials = { ...credentials };
delete botCredentials.accessToken; // use a new login for the bot
const res = await createBot(page, homeserver, botCredentials);
recoveryKey = res.recoveryKey;
});

View File

@ -16,6 +16,10 @@ import type { Credentials, HomeserverInstance } from "../plugins/homeserver";
import type { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
import { bootstrapCrossSigningForClient, Client } from "./client";
export interface CredentialsOptionalAccessToken extends Omit<Credentials, "accessToken"> {
accessToken?: string;
}
export interface CreateBotOpts {
/**
* A prefix to use for the userid. If unspecified, "bot_" will be used.
@ -58,7 +62,7 @@ const defaultCreateBotOptions = {
type ExtendedMatrixClient = MatrixClient & { __playwright_recovery_key: GeneratedSecretStorageKey };
export class Bot extends Client {
public credentials?: Credentials;
public credentials?: CredentialsOptionalAccessToken;
private handlePromise: Promise<JSHandle<ExtendedMatrixClient>>;
constructor(
@ -70,7 +74,16 @@ export class Bot extends Client {
this.opts = Object.assign({}, defaultCreateBotOptions, opts);
}
public setCredentials(credentials: Credentials): void {
/**
* Set the credentials used by the bot.
*
* If `credentials.accessToken` is unset, then `buildClient` will log in a
* new session. Note that `getCredentials` will return the credentials
* passed to this function, rather than the updated credentials from the new
* login. In particular, the `accessToken` and `deviceId` will not be
* updated.
*/
public setCredentials(credentials: CredentialsOptionalAccessToken): void {
if (this.credentials) throw new Error("Bot has already started");
this.credentials = credentials;
}
@ -80,7 +93,7 @@ export class Bot extends Client {
return client.evaluate((cli) => cli.__playwright_recovery_key);
}
private async getCredentials(): Promise<Credentials> {
private async getCredentials(): Promise<CredentialsOptionalAccessToken> {
if (this.credentials) return this.credentials;
// We want to pad the uniqueId but not the prefix
const username =
@ -161,6 +174,30 @@ export class Bot extends Client {
getSecretStorageKey,
};
if (!("accessToken" in credentials)) {
const loginCli = new window.matrixcs.MatrixClient({
baseUrl,
store: new window.matrixcs.MemoryStore(),
scheduler: new window.matrixcs.MatrixScheduler(),
cryptoStore: new window.matrixcs.MemoryCryptoStore(),
cryptoCallbacks,
logger,
});
const loginResponse = await loginCli.loginRequest({
type: "m.login.password",
identifier: {
type: "m.id.user",
user: credentials.userId,
},
password: credentials.password,
});
credentials.accessToken = loginResponse.access_token;
credentials.userId = loginResponse.user_id;
credentials.deviceId = loginResponse.device_id;
}
const cli = new window.matrixcs.MatrixClient({
baseUrl,
userId: credentials.userId,

View File

@ -28,7 +28,7 @@ import type {
EmptyObject,
} from "matrix-js-sdk/src/matrix";
import type { RoomMessageEventContent } from "matrix-js-sdk/src/types";
import { type Credentials } from "../plugins/homeserver";
import { type CredentialsOptionalAccessToken } from "./bot";
export class Client {
public network: Network;
@ -424,7 +424,7 @@ export class Client {
/**
* Bootstraps cross-signing.
*/
public async bootstrapCrossSigning(credentials: Credentials): Promise<void> {
public async bootstrapCrossSigning(credentials: CredentialsOptionalAccessToken): Promise<void> {
const client = await this.prepareClient();
return bootstrapCrossSigningForClient(client, credentials);
}
@ -522,7 +522,7 @@ export class Client {
*/
export function bootstrapCrossSigningForClient(
client: JSHandle<MatrixClient>,
credentials: Credentials,
credentials: CredentialsOptionalAccessToken,
resetKeys: boolean = false,
) {
return client.evaluate(

View File

@ -15,6 +15,7 @@ import {
RoomStateEvent,
type SyncState,
ClientStoppedError,
TypedEventEmitter,
} from "matrix-js-sdk/src/matrix";
import { logger as baseLogger, type BaseLogger, LogSpan } from "matrix-js-sdk/src/logger";
import { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
@ -29,7 +30,6 @@ import {
} from "./toasts/BulkUnverifiedSessionsToast";
import {
hideToast as hideSetupEncryptionToast,
Kind as SetupKind,
showToast as showSetupEncryptionToast,
} from "./toasts/SetupEncryptionToast";
import {
@ -65,7 +65,47 @@ export const RECOVERY_ACCOUNT_DATA_KEY = "io.element.recovery";
const logger = baseLogger.getChild("DeviceListener:");
export default class DeviceListener {
/**
* The state of the device and the user's account.
*/
export type DeviceState =
/**
* The device is in a good state.
*/
| "ok"
/**
* The user needs to set up recovery.
*/
| "set_up_recovery"
/**
* The device is not verified.
*/
| "verify_this_session"
/**
* Key storage is out of sync (keys are missing locally, from recovery, or both).
*/
| "key_storage_out_of_sync"
/**
* Key storage is not enabled, and has not been marked as purposely disabled.
*/
| "turn_on_key_storage"
/**
* The user's identity needs resetting, due to missing keys.
*/
| "identity_needs_reset";
/**
* The events emitted by {@link DeviceListener}
*/
export enum DeviceListenerEvents {
DeviceState = "device_state",
}
type EventHandlerMap = {
[DeviceListenerEvents.DeviceState]: (state: DeviceState) => void;
};
export default class DeviceListener extends TypedEventEmitter<DeviceListenerEvents, EventHandlerMap> {
private dispatcherRef?: string;
// device IDs for which the user has dismissed the verify toast ('Later')
private dismissed = new Set<string>();
@ -87,6 +127,7 @@ export default class DeviceListener {
private shouldRecordClientInformation = false;
private enableBulkUnverifiedSessionsReminder = true;
private deviceClientInformationSettingWatcherRef: string | undefined;
private deviceState: DeviceState = "ok";
// Remember the current analytics state to avoid sending the same event multiple times.
private analyticsVerificationState?: string;
@ -198,8 +239,8 @@ export default class DeviceListener {
}
/**
* If a `Kind.KEY_STORAGE_OUT_OF_SYNC` condition from {@link doRecheck}
* requires a reset of cross-signing keys.
* If the device is in a `key_storage_out_of_sync` state, check if
* it requires a reset of cross-signing keys.
*
* We will reset cross-signing keys if both our local cache and 4S don't
* have all cross-signing keys.
@ -227,16 +268,15 @@ export default class DeviceListener {
}
/**
* If a `Kind.KEY_STORAGE_OUT_OF_SYNC` condition from {@link doRecheck}
* requires a reset of key backup.
* If the device is in a `"key_storage_out_of_sync"` state, check if
* it requires a reset of key backup.
*
* If the user has their recovery key, we need to reset backup if:
* - the user hasn't disabled backup,
* - we don't have the backup key cached locally, *and*
* - we don't have the backup key stored in 4S.
* (The user should already have a key backup created at this point,
* otherwise `doRecheck` would have triggered a `Kind.TURN_ON_KEY_STORAGE`
* condition.)
* (The user should already have a key backup created at this point, the
* device state would be `turn_on_key_storage`.)
*
* If the user has forgotten their recovery key, we need to reset backup if:
* - the user hasn't disabled backup, and
@ -425,88 +465,93 @@ export default class DeviceListener {
const recoveryIsOk = secretStorageStatus.ready || recoveryDisabled;
const isCurrentDeviceTrusted =
crossSigningReady &&
Boolean(
(await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified,
);
const isCurrentDeviceTrusted = Boolean(
(await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified,
);
const keyBackupUploadActive = await this.isKeyBackupUploadActive(logSpan);
const backupDisabled = await this.recheckBackupDisabled(cli);
// We warn if key backup upload is turned off and we have not explicitly
// said we are OK with that.
const keyBackupIsOk = keyBackupUploadActive || backupDisabled;
const keyBackupUploadIsOk = keyBackupUploadActive || backupDisabled;
// If key backup is active and not disabled: do we have the backup key
// cached locally?
const backupKeyCached =
// We warn if key backup is set up, but we don't have the decryption
// key, so can't fetch keys from backup.
const keyBackupDownloadIsOk =
!keyBackupUploadActive || backupDisabled || (await crypto.getSessionBackupPrivateKey()) !== null;
const allSystemsReady =
isCurrentDeviceTrusted && allCrossSigningSecretsCached && keyBackupIsOk && recoveryIsOk && backupKeyCached;
isCurrentDeviceTrusted &&
allCrossSigningSecretsCached &&
keyBackupUploadIsOk &&
recoveryIsOk &&
keyBackupDownloadIsOk;
await this.reportCryptoSessionStateToAnalytics(cli);
if (this.dismissedThisDeviceToast || allSystemsReady) {
if (allSystemsReady) {
logSpan.info("No toast needed");
hideSetupEncryptionToast();
await this.setDeviceState("ok", logSpan);
this.checkKeyBackupStatus();
} else if (await this.shouldShowSetupEncryptionToast()) {
} else {
// make sure our keys are finished downloading
await crypto.getUserDeviceInfo([cli.getSafeUserId()]);
if (!isCurrentDeviceTrusted) {
// the current device is not trusted: prompt the user to verify
logSpan.info("Current device not verified: showing VERIFY_THIS_SESSION toast");
showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION);
logSpan.info("Current device not verified: setting state to VERIFY_THIS_SESSION");
await this.setDeviceState("verify_this_session", logSpan);
} else if (!allCrossSigningSecretsCached) {
// cross signing ready & device trusted, but we are missing secrets from our local cache.
// prompt the user to enter their recovery key.
logSpan.info(
"Some secrets not cached: showing KEY_STORAGE_OUT_OF_SYNC toast",
"Some secrets not cached: setting state to KEY_STORAGE_OUT_OF_SYNC",
crossSigningStatus.privateKeysCachedLocally,
crossSigningStatus.privateKeysInSecretStorage,
);
showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC);
} else if (!keyBackupIsOk) {
logSpan.info("Key backup upload is unexpectedly turned off: showing TURN_ON_KEY_STORAGE toast");
showSetupEncryptionToast(SetupKind.TURN_ON_KEY_STORAGE);
await this.setDeviceState(
crossSigningStatus.privateKeysInSecretStorage ? "key_storage_out_of_sync" : "identity_needs_reset",
logSpan,
);
} else if (!keyBackupUploadIsOk) {
logSpan.info("Key backup upload is unexpectedly turned off: setting state to TURN_ON_KEY_STORAGE");
await this.setDeviceState("turn_on_key_storage", logSpan);
} else if (secretStorageStatus.defaultKeyId === null) {
// 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 (recoveryDisabled) {
logSpan.info("Recovery disabled: no toast needed");
hideSetupEncryptionToast();
await this.setDeviceState("ok", logSpan);
} else if (keyBackupUploadActive) {
logSpan.info("No default 4S key: showing SET_UP_RECOVERY toast");
showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY);
logSpan.info("No default 4S key: setting state to SET_UP_RECOVERY");
await this.setDeviceState("set_up_recovery", logSpan);
} else {
logSpan.info("No default 4S key but backup disabled: no toast needed");
hideSetupEncryptionToast();
await this.setDeviceState("ok", logSpan);
}
} else {
// If we get here, then we are verified, have key backup, and
// 4S, but allSystemsReady is false, which means that either
// secretStorageStatus.ready is false (which means that 4S
// doesn't have all the secrets), or we don't have the backup
// key cached locally.
// key cached locally. If any of the cross-signing keys are
// missing locally, that is handled by the
// `!allCrossSigningSecretsCached` branch above.
logSpan.warn("4S is missing secrets or backup key not cached", {
crossSigningReady,
secretStorageStatus,
allCrossSigningSecretsCached,
isCurrentDeviceTrusted,
backupKeyCached,
keyBackupDownloadIsOk,
});
// We use the right toast variant based on whether the backup
// key is missing locally. If any of the cross-signing keys are
// missing locally, that is handled by the
// `!allCrossSigningSecretsCached` branch above.
showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC);
await this.setDeviceState("key_storage_out_of_sync", logSpan);
}
if (this.dismissedThisDeviceToast) {
this.checkKeyBackupStatus();
}
} else {
logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==false");
}
// This needs to be done after awaiting on getUserDeviceInfo() above, so
@ -598,6 +643,31 @@ export default class DeviceListener {
return recoveryStatus?.enabled === false;
}
/**
* Get the state of the device and the user's account. The device/account
* state indicates what action the user must take in order to get a
* self-verified device that is using key backup and recovery.
*/
public getDeviceState(): DeviceState {
return this.deviceState;
}
/**
* Set the state of the device, and perform any actions necessary in
* response to the state changing.
*/
private async setDeviceState(newState: DeviceState, logSpan: LogSpan): Promise<void> {
this.deviceState = newState;
this.emit(DeviceListenerEvents.DeviceState, newState);
if (newState === "ok" || this.dismissedThisDeviceToast) {
hideSetupEncryptionToast();
} else if (await this.shouldShowSetupEncryptionToast()) {
showSetupEncryptionToast(newState);
} else {
logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==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).

View File

@ -16,6 +16,9 @@ export const HistoryVisibleBanner: React.FC<{
/** The room instance associated with this banner view model. */
room: Room;
/** Whether the current user can send messages in the room. */
canSendMessages: boolean;
/**
* If not null, specifies the ID of the thread currently being viewed in the thread timeline side view,
* where the banner view is displayed as a child of the message composer.

View File

@ -675,7 +675,11 @@ export class MessageComposer extends React.Component<IProps, IState> {
return (
<div className={classes} ref={this.ref} role="region" aria-label={_t("a11y|message_composer")}>
<HistoryVisibleBanner room={this.props.room} threadId={threadId ?? null} />
<HistoryVisibleBanner
room={this.props.room}
canSendMessages={canSendMessages}
threadId={threadId ?? null}
/>
<div className="mx_MessageComposer_wrapper">
<UserIdentityWarning room={this.props.room} key={this.props.room.roomId} />
<ReplyPreview

View File

@ -12,13 +12,20 @@ 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";
import { AccessCancelledError, accessSecretStorage } from "../../../../SecurityManager";
import DeviceListener from "../../../../DeviceListener";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { resetKeyBackupAndWait } from "../../../../utils/crypto/resetKeyBackup";
interface RecoveryPanelOutOfSyncProps {
/**
* Callback for when the user has finished entering their recovery key.
*/
onFinish: () => void;
/**
* Callback for when accessing secret storage fails.
*/
onAccessSecretStorageFailed: () => void;
/**
* Callback for when the user clicks on the "Forgot recovery key?" button.
*/
@ -32,7 +39,13 @@ interface RecoveryPanelOutOfSyncProps {
* It prompts the user to enter their recovery key so that the secrets can be loaded from 4S into
* the client.
*/
export function RecoveryPanelOutOfSync({ onForgotRecoveryKey, onFinish }: RecoveryPanelOutOfSyncProps): JSX.Element {
export function RecoveryPanelOutOfSync({
onForgotRecoveryKey,
onAccessSecretStorageFailed,
onFinish,
}: RecoveryPanelOutOfSyncProps): JSX.Element {
const matrixClient = useMatrixClientContext();
return (
<SettingsSection
legacy={false}
@ -55,7 +68,39 @@ export function RecoveryPanelOutOfSync({ onForgotRecoveryKey, onFinish }: Recove
kind="primary"
Icon={KeyIcon}
onClick={async () => {
await accessSecretStorage();
const crypto = matrixClient.getCrypto()!;
const deviceListener = DeviceListener.sharedInstance();
// we need to call keyStorageOutOfSyncNeedsBackupReset here because
// deviceListener.whilePaused() sets its client to undefined, so
// keyStorageOutOfSyncNeedsBackupReset won't be able to check
// the backup state.
const needsBackupReset = await deviceListener.keyStorageOutOfSyncNeedsBackupReset(false);
try {
// pause the device listener because we could be making lots
// of changes, and don't want toasts to pop up and disappear
// while we're doing it
await deviceListener.whilePaused(async () => {
await accessSecretStorage(async () => {
// Reset backup if needed.
if (needsBackupReset) {
await resetKeyBackupAndWait(crypto);
} else if (await matrixClient.isKeyBackupKeyStored()) {
await crypto.loadSessionBackupPrivateKeyFromSecretStorage();
}
});
});
} catch (error) {
if (error instanceof AccessCancelledError) {
// The user cancelled the dialog - just allow it to
// close, and return to this panel
} else {
onAccessSecretStorageFailed();
}
return;
}
onFinish();
}}
>

View File

@ -5,15 +5,13 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, useCallback, useEffect, useState } from "react";
import { Button, InlineSpinner, Separator } from "@vector-im/compound-web";
import React, { type JSX, useState } from "react";
import { Button, Separator } from "@vector-im/compound-web";
import ComputerIcon from "@vector-im/compound-design-tokens/assets/web/icons/computer";
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
import SettingsTab from "../SettingsTab";
import { RecoveryPanel } from "../../encryption/RecoveryPanel";
import { ChangeRecoveryKey } from "../../encryption/ChangeRecoveryKey";
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
import { _t } from "../../../../../languageHandler";
import Modal from "../../../../../Modal";
import SetupEncryptionDialog from "../../../dialogs/security/SetupEncryptionDialog";
@ -23,17 +21,15 @@ import { AdvancedPanel } from "../../encryption/AdvancedPanel";
import { ResetIdentityPanel } from "../../encryption/ResetIdentityPanel";
import { type ResetIdentityBodyVariant } from "../../encryption/ResetIdentityBody";
import { RecoveryPanelOutOfSync } from "../../encryption/RecoveryPanelOutOfSync";
import { useTypedEventEmitter } from "../../../../../hooks/useEventEmitter";
import { useTypedEventEmitterState } from "../../../../../hooks/useEventEmitter";
import { KeyStoragePanel } from "../../encryption/KeyStoragePanel";
import { DeleteKeyStoragePanel } from "../../encryption/DeleteKeyStoragePanel";
import DeviceListener, { DeviceListenerEvents, type DeviceState } from "../../../../../DeviceListener";
import { useKeyStoragePanelViewModel } from "../../../../viewmodels/settings/encryption/KeyStoragePanelViewModel";
/**
* The state in the encryption settings tab.
* - "loading": We are checking if the device is verified.
* - "main": The main panel with all the sections (Key storage, recovery, advanced).
* - "key_storage_disabled": The user has chosen to disable key storage and options are unavailable as a result.
* - "set_up_encryption": The panel to show when the user is setting up their encryption.
* This happens when the user doesn't have cross-signing enabled, or their current device is not verified.
* - "change_recovery_key": The panel to show when the user is changing their recovery key.
* This happens when the user has a recovery key and the user clicks on "Change recovery key" button of the RecoveryPanel.
* - "set_recovery_key": The panel to show when the user is setting up their recovery key.
@ -41,21 +37,17 @@ import { DeleteKeyStoragePanel } from "../../encryption/DeleteKeyStoragePanel";
* - "reset_identity_compromised": The panel to show when the user is resetting their identity, in the 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_sync_failed": The panel to show when the user us resetting their identity, in the case where recovery failed.
* - "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.
* - "reset_identity_cant_recover": The panel to show when the user is resetting their identity, in the case where they can't use recovery.
* - "key_storage_delete": The confirmation page asking if the user really wants to turn off key storage.
*/
export type State =
| "loading"
| "main"
| "key_storage_disabled"
| "set_up_encryption"
| "change_recovery_key"
| "set_recovery_key"
| "reset_identity_compromised"
| "reset_identity_forgot"
| "reset_identity_sync_failed"
| "secrets_not_cached"
| "reset_identity_cant_recover"
| "key_storage_delete";
interface Props {
@ -68,48 +60,69 @@ interface Props {
/**
* The encryption settings tab.
*/
export function EncryptionUserSettingsTab({ initialState = "loading" }: Props): JSX.Element {
export function EncryptionUserSettingsTab({ initialState = "main" }: Readonly<Props>): JSX.Element {
const [state, setState] = useState<State>(initialState);
const checkEncryptionState = useCheckEncryptionState(state, setState);
const deviceState = useTypedEventEmitterState(
DeviceListener.sharedInstance(),
DeviceListenerEvents.DeviceState,
(state?: DeviceState): DeviceState => {
return state ?? DeviceListener.sharedInstance().getDeviceState();
},
);
const { isEnabled: isBackupEnabled } = useKeyStoragePanelViewModel();
let content: JSX.Element;
switch (state) {
case "loading":
content = <InlineSpinner aria-label={_t("common|loading")} />;
break;
case "set_up_encryption":
content = <SetUpEncryptionPanel onFinish={checkEncryptionState} />;
break;
case "secrets_not_cached":
content = (
<RecoveryPanelOutOfSync
onFinish={checkEncryptionState}
onForgotRecoveryKey={() => setState("reset_identity_forgot")}
/>
);
break;
case "key_storage_disabled":
case "main":
content = (
<>
<KeyStoragePanel onKeyStorageDisableClick={() => setState("key_storage_delete")} />
<Separator kind="section" />
{/* We only show the "Recovery" panel if key storage is enabled.*/}
{state === "main" && (
switch (deviceState) {
// some device states require action from the user rather than showing the main settings screen
case "verify_this_session":
content = <SetUpEncryptionPanel onFinish={() => setState("main")} />;
break;
case "key_storage_out_of_sync":
content = (
<RecoveryPanelOutOfSync
onFinish={() => setState("main")}
onForgotRecoveryKey={() => setState("reset_identity_forgot")}
onAccessSecretStorageFailed={async () => {
const needsCrossSigningReset =
await DeviceListener.sharedInstance().keyStorageOutOfSyncNeedsCrossSigningReset(
true,
);
setState(needsCrossSigningReset ? "reset_identity_sync_failed" : "change_recovery_key");
}}
/>
);
break;
case "identity_needs_reset":
content = (
<IdentityNeedsResetNoticePanel onContinue={() => setState("reset_identity_cant_recover")} />
);
break;
default:
content = (
<>
<RecoveryPanel
onChangeRecoveryKeyClick={(setupNewKey) =>
setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key")
}
/>
<KeyStoragePanel onKeyStorageDisableClick={() => setState("key_storage_delete")} />
<Separator kind="section" />
{/* We only show the "Recovery" panel if key storage is enabled.*/}
{isBackupEnabled && (
<>
<RecoveryPanel
onChangeRecoveryKeyClick={(setupNewKey) =>
setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key")
}
/>
<Separator kind="section" />
</>
)}
<AdvancedPanel onResetIdentityClick={() => setState("reset_identity_compromised")} />
</>
)}
<AdvancedPanel onResetIdentityClick={() => setState("reset_identity_compromised")} />
</>
);
);
break;
}
break;
case "change_recovery_key":
case "set_recovery_key":
@ -124,16 +137,17 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Props):
case "reset_identity_compromised":
case "reset_identity_forgot":
case "reset_identity_sync_failed":
case "reset_identity_cant_recover":
content = (
<ResetIdentityPanel
variant={findResetVariant(state)}
onCancelClick={checkEncryptionState}
onReset={checkEncryptionState}
onCancelClick={() => setState("main")}
onReset={() => setState("main")}
/>
);
break;
case "key_storage_delete":
content = <DeleteKeyStoragePanel onFinish={checkEncryptionState} />;
content = <DeleteKeyStoragePanel onFinish={() => setState("main")} />;
break;
}
@ -154,6 +168,8 @@ function findResetVariant(state: State): ResetIdentityBodyVariant {
return "compromised";
case "reset_identity_sync_failed":
return "sync_failed";
case "reset_identity_cant_recover":
return "no_verification_method";
default:
case "reset_identity_forgot":
@ -161,63 +177,6 @@ function findResetVariant(state: State): ResetIdentityBodyVariant {
}
}
/**
* Hook to check if the user needs:
* - to go through the SetupEncryption flow.
* - to enter their recovery key, if the secrets are not cached locally.
* ...and also whether megolm key backup is enabled on this device (which we use to set the state of the 'allow key storage' toggle)
*
* If cross signing is set up, key backup is enabled and the secrets are cached, the state will be set to "main".
* If cross signing is not set up, the state will be set to "set_up_encryption".
* If key backup is not enabled, the state will be set to "key_storage_disabled".
* If secrets are missing, the state will be set to "secrets_not_cached".
*
* The state is set once when the component is first mounted.
* Also returns a callback function which can be called to re-run the logic.
*
* @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 useCheckEncryptionState(state: State, setState: (state: State) => void): () => Promise<void> {
const matrixClient = useMatrixClientContext();
const checkEncryptionState = useCallback(async () => {
const crypto = matrixClient.getCrypto()!;
const isCrossSigningReady = await crypto.isCrossSigningReady();
// Check if the secrets are cached
const cachedSecrets = (await crypto.getCrossSigningStatus()).privateKeysCachedLocally;
const secretsOk = cachedSecrets.masterKey && cachedSecrets.selfSigningKey && cachedSecrets.userSigningKey;
// Also check the key backup status
const activeBackupVersion = await crypto.getActiveSessionBackupVersion();
const keyStorageEnabled = activeBackupVersion !== null;
if (isCrossSigningReady && keyStorageEnabled && secretsOk) setState("main");
else if (!isCrossSigningReady) setState("set_up_encryption");
else if (!keyStorageEnabled) setState("key_storage_disabled");
else setState("secrets_not_cached");
}, [matrixClient, setState]);
// Initialise the state when the component is mounted
useEffect(() => {
if (state === "loading") checkEncryptionState();
}, [checkEncryptionState, state]);
useTypedEventEmitter(matrixClient, CryptoEvent.KeyBackupStatus, (): void => {
// Recheck the status if the key backup status has changed so we can keep the page up to date.
// Note that this could potentially update the UI while the user is trying to do something, although
// if their key backup status is changing then they're changing encryption related things
// on another device. This code is written with the assumption that it's better for the UI to refresh
// and be up to date with whatever changes they've made.
checkEncryptionState();
});
// Also return the callback so that the component can re-run the logic.
return checkEncryptionState;
}
interface SetUpEncryptionPanelProps {
/**
* Callback to call when the user has finished setting up encryption.
@ -257,3 +216,31 @@ function SetUpEncryptionPanel({ onFinish }: SetUpEncryptionPanelProps): JSX.Elem
</SettingsSection>
);
}
interface IdentityNeedsResetNoticePanelProps {
/**
* Callback to call when the user has finished setting up encryption.
*/
onContinue: () => void;
}
/**
* Panel to tell the user that they need to reset their identity.
*/
function IdentityNeedsResetNoticePanel({ onContinue }: Readonly<IdentityNeedsResetNoticePanelProps>): JSX.Element {
return (
<SettingsSection
legacy={false}
heading={_t("encryption|key_storage_out_of_sync")}
subHeading={
<SettingsSubheader state="error" stateMessage={_t("encryption|identity_needs_reset_description")} />
}
>
<div>
<Button size="sm" kind="primary" onClick={onContinue}>
{_t("encryption|continue_with_reset")}
</Button>
</div>
</SettingsSection>
);
}

View File

@ -959,6 +959,7 @@
"bootstrap_title": "Setting up keys",
"confirm_encryption_setup_body": "Click the button below to confirm setting up encryption.",
"confirm_encryption_setup_title": "Confirm encryption setup",
"continue_with_reset": "Continue with reset",
"cross_signing_room_normal": "This room is end-to-end encrypted",
"cross_signing_room_verified": "Everyone in this room is verified",
"cross_signing_room_warning": "Someone is using an unknown session",
@ -974,6 +975,7 @@
"event_shield_reason_unverified_identity": "Encrypted by an unverified user.",
"export_unsupported": "Your browser does not support the required cryptography extensions",
"forgot_recovery_key": "Forgot recovery key?",
"identity_needs_reset_description": "You have to reset your cryptographic identity in order to ensure access to your message history",
"import_invalid_keyfile": "Not a valid %(brand)s keyfile",
"import_invalid_passphrase": "Authentication check failed: incorrect password?",
"key_storage_out_of_sync": "Your key storage is out of sync.",

View File

@ -14,7 +14,7 @@ import { type Interaction as InteractionEvent } from "@matrix-org/analytics-even
import Modal from "../Modal";
import { _t } from "../languageHandler";
import DeviceListener from "../DeviceListener";
import DeviceListener, { type DeviceState } from "../DeviceListener";
import SetupEncryptionDialog from "../components/views/dialogs/security/SetupEncryptionDialog";
import { AccessCancelledError, accessSecretStorage } from "../SecurityManager";
import ToastStore, { type IToast } from "../stores/ToastStore";
@ -33,114 +33,107 @@ import { PosthogAnalytics } from "../PosthogAnalytics";
const TOAST_KEY = "setupencryption";
const getTitle = (kind: Kind): string => {
switch (kind) {
case Kind.SET_UP_RECOVERY:
/**
* The device states that we show a toast for (everything except for "ok").
*/
type DeviceStateForToast = Exclude<DeviceState, "ok">;
const getTitle = (state: DeviceStateForToast): string => {
switch (state) {
case "set_up_recovery":
return _t("encryption|set_up_recovery");
case Kind.VERIFY_THIS_SESSION:
case "verify_this_session":
return _t("encryption|verify_toast_title");
case Kind.KEY_STORAGE_OUT_OF_SYNC:
case "key_storage_out_of_sync":
case "identity_needs_reset":
return _t("encryption|key_storage_out_of_sync");
case Kind.TURN_ON_KEY_STORAGE:
case "turn_on_key_storage":
return _t("encryption|turn_on_key_storage");
}
};
const getIcon = (kind: Kind): IToast<any>["icon"] => {
switch (kind) {
case Kind.SET_UP_RECOVERY:
const getIcon = (state: DeviceStateForToast): IToast<any>["icon"] => {
switch (state) {
case "set_up_recovery":
return undefined;
case Kind.VERIFY_THIS_SESSION:
case Kind.KEY_STORAGE_OUT_OF_SYNC:
case "verify_this_session":
case "key_storage_out_of_sync":
case "identity_needs_reset":
return <ErrorSolidIcon color="var(--cpd-color-icon-critical-primary)" />;
case Kind.TURN_ON_KEY_STORAGE:
case "turn_on_key_storage":
return <SettingsSolidIcon color="var(--cpd-color-text-primary)" />;
}
};
const getSetupCaption = (kind: Kind): string => {
switch (kind) {
case Kind.SET_UP_RECOVERY:
const getSetupCaption = (state: DeviceStateForToast): string => {
switch (state) {
case "set_up_recovery":
return _t("action|continue");
case Kind.VERIFY_THIS_SESSION:
case "verify_this_session":
return _t("action|verify");
case Kind.KEY_STORAGE_OUT_OF_SYNC:
case "key_storage_out_of_sync":
return _t("encryption|enter_recovery_key");
case Kind.TURN_ON_KEY_STORAGE:
case "turn_on_key_storage":
return _t("action|continue");
case "identity_needs_reset":
return _t("encryption|continue_with_reset");
}
};
/**
* Get the icon to show on the primary button.
* @param kind
* @param state
*/
const getPrimaryButtonIcon = (kind: Kind): ComponentType<React.SVGAttributes<SVGElement>> | undefined => {
switch (kind) {
case Kind.KEY_STORAGE_OUT_OF_SYNC:
const getPrimaryButtonIcon = (
state: DeviceStateForToast,
): ComponentType<React.SVGAttributes<SVGElement>> | undefined => {
switch (state) {
case "key_storage_out_of_sync":
return KeyIcon;
default:
return;
}
};
const getSecondaryButtonLabel = (kind: Kind): string => {
switch (kind) {
case Kind.SET_UP_RECOVERY:
const getSecondaryButtonLabel = (state: DeviceStateForToast): string => {
switch (state) {
case "set_up_recovery":
return _t("action|dismiss");
case Kind.VERIFY_THIS_SESSION:
case "verify_this_session":
return _t("encryption|verification|unverified_sessions_toast_reject");
case Kind.KEY_STORAGE_OUT_OF_SYNC:
case "key_storage_out_of_sync":
return _t("encryption|forgot_recovery_key");
case Kind.TURN_ON_KEY_STORAGE:
case "turn_on_key_storage":
return _t("action|dismiss");
case "identity_needs_reset":
return "";
}
};
const getDescription = (kind: Kind): string => {
switch (kind) {
case Kind.SET_UP_RECOVERY:
const getDescription = (state: DeviceStateForToast): string => {
switch (state) {
case "set_up_recovery":
return _t("encryption|set_up_recovery_toast_description");
case Kind.VERIFY_THIS_SESSION:
case "verify_this_session":
return _t("encryption|verify_toast_description");
case Kind.KEY_STORAGE_OUT_OF_SYNC:
case "key_storage_out_of_sync":
return _t("encryption|key_storage_out_of_sync_description");
case Kind.TURN_ON_KEY_STORAGE:
case "turn_on_key_storage":
return _t("encryption|turn_on_key_storage_description");
case "identity_needs_reset":
return _t("encryption|identity_needs_reset_description");
}
};
/**
* The kind of toast to show.
*/
export enum Kind {
/**
* Prompt the user to set up a recovery key
*/
SET_UP_RECOVERY = "set_up_recovery",
/**
* Prompt the user to verify this session
*/
VERIFY_THIS_SESSION = "verify_this_session",
/**
* Prompt the user to enter their recovery key
*/
KEY_STORAGE_OUT_OF_SYNC = "key_storage_out_of_sync",
/**
* Prompt the user to turn on key storage
*/
TURN_ON_KEY_STORAGE = "turn_on_key_storage",
}
/**
* Show a toast prompting the user for some action related to setting up their encryption.
*
* @param kind The kind of toast to show
* @param state The state of the device
*/
export const showToast = (kind: Kind): void => {
export const showToast = (state: DeviceStateForToast): void => {
if (
ModuleRunner.instance.extensions.cryptoSetup.setupEncryptionNeeded({
kind: kind as any,
kind: state as any,
storeProvider: { getInstance: () => SetupEncryptionStore.sharedInstance() },
})
) {
@ -148,13 +141,13 @@ export const showToast = (kind: Kind): void => {
}
const onPrimaryClick = async (): Promise<void> => {
switch (kind) {
case Kind.SET_UP_RECOVERY:
case Kind.TURN_ON_KEY_STORAGE: {
switch (state) {
case "set_up_recovery":
case "turn_on_key_storage": {
PosthogAnalytics.instance.trackEvent<InteractionEvent>({
eventName: "Interaction",
interactionType: "Pointer",
name: kind === Kind.SET_UP_RECOVERY ? "ToastSetUpRecoveryClick" : "ToastTurnOnKeyStorageClick",
name: state === "set_up_recovery" ? "ToastSetUpRecoveryClick" : "ToastTurnOnKeyStorageClick",
});
// Open the user settings dialog to the encryption tab
const payload: OpenToTabPayload = {
@ -164,10 +157,10 @@ export const showToast = (kind: Kind): void => {
defaultDispatcher.dispatch(payload);
break;
}
case Kind.VERIFY_THIS_SESSION:
case "verify_this_session":
Modal.createDialog(SetupEncryptionDialog, {}, undefined, /* priority = */ false, /* static = */ true);
break;
case Kind.KEY_STORAGE_OUT_OF_SYNC: {
case "key_storage_out_of_sync": {
const modal = Modal.createDialog(
Spinner,
undefined,
@ -208,12 +201,24 @@ export const showToast = (kind: Kind): void => {
}
break;
}
case "identity_needs_reset": {
// Open the user settings dialog to reset identity
const payload: OpenToTabPayload = {
action: Action.ViewUserSettings,
initialTabId: UserTab.Encryption,
props: {
initialEncryptionState: "reset_identity_cant_recover",
},
};
defaultDispatcher.dispatch(payload);
break;
}
}
};
const onSecondaryClick = async (): Promise<void> => {
switch (kind) {
case Kind.SET_UP_RECOVERY: {
switch (state) {
case "set_up_recovery": {
PosthogAnalytics.instance.trackEvent<InteractionEvent>({
eventName: "Interaction",
interactionType: "Pointer",
@ -225,7 +230,7 @@ export const showToast = (kind: Kind): void => {
deviceListener.dismissEncryptionSetup();
break;
}
case Kind.KEY_STORAGE_OUT_OF_SYNC: {
case "key_storage_out_of_sync": {
// Open the user settings dialog to the encryption tab and start the flow to reset encryption or change the recovery key
const deviceListener = DeviceListener.sharedInstance();
const needsCrossSigningReset = await deviceListener.keyStorageOutOfSyncNeedsCrossSigningReset(true);
@ -241,7 +246,7 @@ export const showToast = (kind: Kind): void => {
defaultDispatcher.dispatch(payload);
break;
}
case Kind.TURN_ON_KEY_STORAGE: {
case "turn_on_key_storage": {
PosthogAnalytics.instance.trackEvent<InteractionEvent>({
eventName: "Interaction",
interactionType: "Pointer",
@ -296,19 +301,19 @@ export const showToast = (kind: Kind): void => {
ToastStore.sharedInstance().addOrReplaceToast({
key: TOAST_KEY,
title: getTitle(kind),
icon: getIcon(kind),
title: getTitle(state),
icon: getIcon(state),
props: {
description: getDescription(kind),
primaryLabel: getSetupCaption(kind),
PrimaryIcon: getPrimaryButtonIcon(kind),
description: getDescription(state),
primaryLabel: getSetupCaption(state),
PrimaryIcon: getPrimaryButtonIcon(state),
onPrimaryClick,
secondaryLabel: getSecondaryButtonLabel(kind),
secondaryLabel: getSecondaryButtonLabel(state),
onSecondaryClick,
overrideWidth: kind === Kind.KEY_STORAGE_OUT_OF_SYNC ? "366px" : undefined,
overrideWidth: state === "key_storage_out_of_sync" ? "366px" : undefined,
},
component: GenericToast,
priority: kind === Kind.VERIFY_THIS_SESSION ? 95 : 40,
priority: state === "verify_this_session" ? 95 : 40,
});
};

View File

@ -15,12 +15,22 @@ import { HistoryVisibility, RoomStateEvent, type Room } from "matrix-js-sdk/src/
import SettingsStore from "../../settings/SettingsStore";
import { SettingLevel } from "../../settings/SettingLevel";
/**
* A collection of {@link HistoryVisibility} levels that trigger the display of the history visible banner.
*/
const BANNER_VISIBLE_LEVELS = [HistoryVisibility.Shared, HistoryVisibility.WorldReadable];
interface Props {
/**
* The room instance associated with this banner view model.
*/
room: Room;
/**
* Whether or not the current user is able to send messages in this room.
*/
canSendMessages: boolean;
/**
* If not null, indicates the ID of the thread currently being viewed in the thread
* timeline side view, where the banner view is displayed as a child of the message
@ -66,23 +76,33 @@ export class HistoryVisibleBannerViewModel
/**
* Computes the latest banner snapshot given the VM's props.
* @param room - The room the banner will be shown in.
* @param threadId - The thread ID passed in from the parent {@link MessageComposer}.
* @param props - See {@link Props}.
* @returns The latest snapshot. See {@link HistoryVisibleBannerViewSnapshot}.
*/
private static readonly computeSnapshot = (
room: Room,
threadId?: string | null,
): HistoryVisibleBannerViewSnapshot => {
private static readonly computeSnapshot = ({
room,
canSendMessages,
threadId,
}: Props): HistoryVisibleBannerViewSnapshot => {
const featureEnabled = SettingsStore.getValue("feature_share_history_on_invite");
const acknowledged = SettingsStore.getValue("acknowledgedHistoryVisibility", room.roomId);
const isHistoryVisible = BANNER_VISIBLE_LEVELS.includes(room.getHistoryVisibility());
// This implements point 1. of the algorithm described above. In the order below, all
// of the following must be true for the banner to display:
// - The room history sharing feature must be enabled.
// - The room must be encrypted.
// - The user must be able to send messages.
// - The history must be visible.
// - The view should not be part of a thread timeline.
// - The user must not have acknowledged the banner.
return {
visible:
featureEnabled &&
!threadId &&
room.hasEncryptionStateEvent() &&
room.getHistoryVisibility() !== HistoryVisibility.Joined &&
canSendMessages &&
isHistoryVisible &&
!threadId &&
!acknowledged,
};
};
@ -92,7 +112,7 @@ export class HistoryVisibleBannerViewModel
* @param props - Properties for this view model. See {@link Props}.
*/
public constructor(props: Props) {
super(props, HistoryVisibleBannerViewModel.computeSnapshot(props.room, props.threadId));
super(props, HistoryVisibleBannerViewModel.computeSnapshot(props));
this.disposables.trackListener(props.room, RoomStateEvent.Update, () => this.setSnapshot());
@ -126,7 +146,7 @@ export class HistoryVisibleBannerViewModel
);
}
this.snapshot.set(HistoryVisibleBannerViewModel.computeSnapshot(this.props.room, this.props.threadId));
this.snapshot.set(HistoryVisibleBannerViewModel.computeSnapshot(this.props));
}
/**

View File

@ -683,6 +683,7 @@ export function mkStubRoom(
getCanonicalAlias: jest.fn(),
getDMInviter: jest.fn(),
getEventReadUpTo: jest.fn(() => null),
getHistoryVisibility: jest.fn().mockReturnValue(HistoryVisibility.Joined),
getInvitedAndJoinedMemberCount: jest.fn().mockReturnValue(1),
getJoinRule: jest.fn().mockReturnValue("invite"),
getJoinedMemberCount: jest.fn().mockReturnValue(1),

View File

@ -341,9 +341,7 @@ describe("DeviceListener", () => {
await createAndStart();
expect(mockCrypto!.getUserDeviceInfo).toHaveBeenCalled();
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(
SetupEncryptionToast.Kind.VERIFY_THIS_SESSION,
);
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("verify_this_session");
});
describe("when current device is verified", () => {
@ -380,9 +378,23 @@ describe("DeviceListener", () => {
await createAndStart();
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(
SetupEncryptionToast.Kind.KEY_STORAGE_OUT_OF_SYNC,
);
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("key_storage_out_of_sync");
});
it("shows an identity reset toast when one of the cross-signing secrets is missing locally and in 4S", async () => {
mockCrypto!.getCrossSigningStatus.mockResolvedValue({
publicKeysOnDevice: true,
privateKeysInSecretStorage: false,
privateKeysCachedLocally: {
masterKey: false,
selfSigningKey: true,
userSigningKey: true,
},
});
await createAndStart();
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("identity_needs_reset");
});
it("shows an out-of-sync toast when the backup key is missing locally", async () => {
@ -392,9 +404,7 @@ describe("DeviceListener", () => {
await createAndStart();
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(
SetupEncryptionToast.Kind.KEY_STORAGE_OUT_OF_SYNC,
);
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("key_storage_out_of_sync");
});
it("does not show an out-of-sync toast when the backup key is missing locally but backup is purposely disabled", async () => {
@ -426,9 +436,7 @@ describe("DeviceListener", () => {
await createAndStart();
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(
SetupEncryptionToast.Kind.KEY_STORAGE_OUT_OF_SYNC,
);
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("key_storage_out_of_sync");
// Then, when we receive the secret, it should be hidden.
mockCrypto!.getCrossSigningStatus.mockResolvedValue({
@ -454,9 +462,7 @@ describe("DeviceListener", () => {
await createAndStart();
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(
SetupEncryptionToast.Kind.SET_UP_RECOVERY,
);
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("set_up_recovery");
});
it("shows an out-of-sync toast when one of the secrets is missing from 4S", async () => {
@ -470,9 +476,7 @@ describe("DeviceListener", () => {
await createAndStart();
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(
SetupEncryptionToast.Kind.KEY_STORAGE_OUT_OF_SYNC,
);
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("key_storage_out_of_sync");
});
});
});
@ -573,9 +577,7 @@ describe("DeviceListener", () => {
await createAndStart();
// Then the toast is displayed
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(
SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE,
);
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("turn_on_key_storage");
});
it("shows the 'Turn on key storage' toast if we turned on key storage", async () => {
@ -591,9 +593,7 @@ describe("DeviceListener", () => {
await createAndStart();
// Then the toast is displayed
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(
SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE,
);
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("turn_on_key_storage");
});
it("does not show the 'Turn on key storage' toast if we turned off key storage", async () => {
@ -606,9 +606,7 @@ describe("DeviceListener", () => {
await createAndStart();
// Then the toast is not displayed
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith(
SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE,
);
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("turn_on_key_storage");
});
});
@ -626,9 +624,7 @@ describe("DeviceListener", () => {
await createAndStart();
// Then the toast is not displayed
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith(
SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE,
);
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("turn_on_key_storage");
});
it("does not show the 'Turn on key storage' toast if we turned on key storage", async () => {
@ -643,9 +639,7 @@ describe("DeviceListener", () => {
await createAndStart();
// Then the toast is not displayed
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith(
SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE,
);
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("turn_on_key_storage");
});
it("does not show the 'Turn on key storage' toast if we turned off key storage", async () => {
@ -661,9 +655,7 @@ describe("DeviceListener", () => {
await createAndStart();
// Then the toast is not displayed
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith(
SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE,
);
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("turn_on_key_storage");
});
});
});
@ -1206,25 +1198,21 @@ describe("DeviceListener", () => {
await createAndStart();
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(SetupEncryptionToast.Kind.SET_UP_RECOVERY);
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("set_up_recovery");
});
it("does not show the 'set up recovery' toast if secret storage is set up", async () => {
mockCrypto!.getSecretStorageStatus.mockResolvedValue(readySecretStorageStatus);
await createAndStart();
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith(
SetupEncryptionToast.Kind.SET_UP_RECOVERY,
);
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("set_up_recovery");
});
it("does not show the 'set up recovery' toast if user has no encrypted rooms", async () => {
jest.spyOn(mockClient.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(false);
await createAndStart();
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith(
SetupEncryptionToast.Kind.SET_UP_RECOVERY,
);
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("set_up_recovery");
});
it("does not show the 'set up recovery' toast if the user has chosen to disable key storage", async () => {
@ -1236,9 +1224,7 @@ describe("DeviceListener", () => {
});
await createAndStart();
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith(
SetupEncryptionToast.Kind.SET_UP_RECOVERY,
);
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("set_up_recovery");
});
});
});

View File

@ -54,7 +54,7 @@ describe("HistoryVisibleBannerViewModel", () => {
});
it("should not show the banner in unencrypted rooms", () => {
const vm = new HistoryVisibleBannerViewModel({ room, threadId: null });
const vm = new HistoryVisibleBannerViewModel({ room, canSendMessages: true, threadId: null });
expect(vm.getSnapshot().visible).toBe(false);
});
@ -76,7 +76,7 @@ describe("HistoryVisibleBannerViewModel", () => {
}),
]);
const vm = new HistoryVisibleBannerViewModel({ room, threadId: null });
const vm = new HistoryVisibleBannerViewModel({ room, canSendMessages: true, threadId: null });
expect(vm.getSnapshot().visible).toBe(false);
});
@ -99,7 +99,7 @@ describe("HistoryVisibleBannerViewModel", () => {
}),
]);
const vm = new HistoryVisibleBannerViewModel({ room, threadId: null });
const vm = new HistoryVisibleBannerViewModel({ room, canSendMessages: true, threadId: null });
expect(vm.getSnapshot().visible).toBe(false);
vm.dispose();
});
@ -122,12 +122,12 @@ describe("HistoryVisibleBannerViewModel", () => {
}),
]);
const vm = new HistoryVisibleBannerViewModel({ room, threadId: "some thread ID" });
const vm = new HistoryVisibleBannerViewModel({ room, canSendMessages: true, threadId: "some thread ID" });
expect(vm.getSnapshot().visible).toBe(false);
vm.dispose();
});
it("should show the banner in encrypted rooms with non-joined history visibility", async () => {
it("should not show the banner if the user cannot send messages", () => {
upsertRoomStateEvents(room, [
mkEvent({
event: true,
@ -145,7 +145,53 @@ describe("HistoryVisibleBannerViewModel", () => {
}),
]);
const vm = new HistoryVisibleBannerViewModel({ room, threadId: null });
const vm = new HistoryVisibleBannerViewModel({ room, canSendMessages: false, threadId: null });
expect(vm.getSnapshot().visible).toBe(false);
vm.dispose();
});
it("should not show the banner if history visibility is `invited`", () => {
upsertRoomStateEvents(room, [
mkEvent({
event: true,
type: "m.room.encryption",
user: "@user1:server",
content: {},
}),
mkEvent({
event: true,
type: "m.room.history_visibility",
user: "@user1:server",
content: {
history_visibility: "invited",
},
}),
]);
const vm = new HistoryVisibleBannerViewModel({ room, canSendMessages: true, threadId: null });
expect(vm.getSnapshot().visible).toBe(false);
vm.dispose();
});
it("should show the banner in encrypted rooms with shared history visibility", async () => {
upsertRoomStateEvents(room, [
mkEvent({
event: true,
type: "m.room.encryption",
user: "@user1:server",
content: {},
}),
mkEvent({
event: true,
type: "m.room.history_visibility",
user: "@user1:server",
content: {
history_visibility: "shared",
},
}),
]);
const vm = new HistoryVisibleBannerViewModel({ room, canSendMessages: true, threadId: null });
expect(vm.getSnapshot().visible).toBe(true);
await vm.onClose();
expect(vm.getSnapshot().visible).toBe(false);

View File

@ -9,19 +9,45 @@ import React from "react";
import { render, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { RecoveryPanelOutOfSync } from "../../../../../../src/components/views/settings/encryption/RecoveryPanelOutOfSync";
import { accessSecretStorage } from "../../../../../../src/SecurityManager";
import { AccessCancelledError, accessSecretStorage } from "../../../../../../src/SecurityManager";
import DeviceListener from "../../../../../../src/DeviceListener";
import { createTestClient, withClientContextRenderOptions } from "../../../../../test-utils";
jest.mock("../../../../../../src/SecurityManager", () => ({
accessSecretStorage: jest.fn(),
}));
jest.mock("../../../../../../src/SecurityManager", () => {
const originalModule = jest.requireActual("../../../../../../src/SecurityManager");
return {
...originalModule,
accessSecretStorage: jest.fn(),
};
});
describe("<RecoveyPanelOutOfSync />", () => {
function renderComponent(onFinish = jest.fn(), onForgotRecoveryKey = jest.fn()) {
return render(<RecoveryPanelOutOfSync onFinish={onFinish} onForgotRecoveryKey={onForgotRecoveryKey} />);
let matrixClient: MatrixClient;
function renderComponent(
onFinish = jest.fn(),
onForgotRecoveryKey = jest.fn(),
onAccessSecretStorageFailed = jest.fn(),
) {
matrixClient = createTestClient();
return render(
<RecoveryPanelOutOfSync
onFinish={onFinish}
onForgotRecoveryKey={onForgotRecoveryKey}
onAccessSecretStorageFailed={onAccessSecretStorageFailed}
/>,
withClientContextRenderOptions(matrixClient),
);
}
afterEach(() => {
jest.clearAllMocks();
});
it("should render", () => {
const { asFragment } = renderComponent();
expect(asFragment()).toMatchSnapshot();
@ -38,8 +64,12 @@ describe("<RecoveyPanelOutOfSync />", () => {
});
it("should access to 4S and call onFinish when 'Enter recovery key' is clicked", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(false);
const user = userEvent.setup();
mocked(accessSecretStorage).mockClear().mockResolvedValue();
mocked(accessSecretStorage).mockImplementation(async (func = async (): Promise<void> => {}) => {
return await func();
});
const onFinish = jest.fn();
renderComponent(onFinish);
@ -47,5 +77,59 @@ describe("<RecoveyPanelOutOfSync />", () => {
await user.click(screen.getByRole("button", { name: "Enter recovery key" }));
expect(accessSecretStorage).toHaveBeenCalled();
expect(onFinish).toHaveBeenCalled();
expect(matrixClient.getCrypto()!.resetKeyBackup).not.toHaveBeenCalled();
});
it("should reset key backup if needed", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(true);
const user = userEvent.setup();
mocked(accessSecretStorage).mockImplementation(async (func = async (): Promise<void> => {}) => {
return await func();
});
const onFinish = jest.fn();
renderComponent(onFinish);
await user.click(screen.getByRole("button", { name: "Enter recovery key" }));
expect(accessSecretStorage).toHaveBeenCalled();
expect(onFinish).toHaveBeenCalled();
expect(matrixClient.getCrypto()!.resetKeyBackup).toHaveBeenCalled();
});
it("should call onAccessSecretStorageFailed on failure", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(true);
const user = userEvent.setup();
mocked(accessSecretStorage).mockImplementation(async (func = async (): Promise<void> => {}) => {
throw new Error("Error");
});
const onAccessSecretStorageFailed = jest.fn();
renderComponent(jest.fn(), jest.fn(), onAccessSecretStorageFailed);
await user.click(screen.getByRole("button", { name: "Enter recovery key" }));
expect(accessSecretStorage).toHaveBeenCalled();
expect(onAccessSecretStorageFailed).toHaveBeenCalled();
});
it("should not call onAccessSecretStorageFailed when cancelled", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(true);
const user = userEvent.setup();
mocked(accessSecretStorage).mockImplementation(async (func = async (): Promise<void> => {}) => {
throw new AccessCancelledError();
});
const onFinish = jest.fn();
const onAccessSecretStorageFailed = jest.fn();
renderComponent(onFinish, jest.fn(), onAccessSecretStorageFailed);
await user.click(screen.getByRole("button", { name: "Enter recovery key" }));
expect(accessSecretStorage).toHaveBeenCalled();
expect(onFinish).not.toHaveBeenCalled();
expect(onAccessSecretStorageFailed).not.toHaveBeenCalled();
});
});

View File

@ -5,6 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import React from "react";
import { act, render, screen } from "jest-matrix-react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
@ -18,6 +19,7 @@ import {
} from "../../../../../../../src/components/views/settings/tabs/user/EncryptionUserSettingsTab";
import { createTestClient, withClientContextRenderOptions } from "../../../../../../test-utils";
import Modal from "../../../../../../../src/Modal";
import DeviceListener from "../../../../../../../src/DeviceListener";
describe("<EncryptionUserSettingsTab />", () => {
let matrixClient: MatrixClient;
@ -37,22 +39,21 @@ describe("<EncryptionUserSettingsTab />", () => {
userSigningKey: true,
},
});
jest.spyOn(DeviceListener.sharedInstance(), "getDeviceState").mockReturnValue("ok");
});
afterEach(() => {
jest.resetAllMocks();
});
function renderComponent(props: { initialState?: State } = {}) {
return render(<EncryptionUserSettingsTab {...props} />, withClientContextRenderOptions(matrixClient));
}
it("should display a loading state when the encryption state is computed", () => {
jest.spyOn(matrixClient.getCrypto()!, "isCrossSigningReady").mockImplementation(() => new Promise(() => {}));
renderComponent();
expect(screen.getByLabelText("Loading…")).toBeInTheDocument();
});
it("should display a verify button when the encryption is not set up", async () => {
const user = userEvent.setup();
jest.spyOn(matrixClient.getCrypto()!, "isCrossSigningReady").mockResolvedValue(false);
mocked(DeviceListener.sharedInstance().getDeviceState).mockReturnValue("verify_this_session");
const { asFragment } = renderComponent();
await waitFor(() =>
@ -81,17 +82,7 @@ describe("<EncryptionUserSettingsTab />", () => {
});
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
jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({
privateKeysInSecretStorage: true,
publicKeysOnDevice: true,
privateKeysCachedLocally: {
masterKey: false,
selfSigningKey: true,
userSigningKey: true,
},
});
mocked(DeviceListener.sharedInstance().getDeviceState).mockReturnValue("key_storage_out_of_sync");
const user = userEvent.setup();
const { asFragment } = renderComponent();
@ -196,18 +187,7 @@ describe("<EncryptionUserSettingsTab />", () => {
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();
jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1");
// Secrets are not cached
jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({
privateKeysInSecretStorage: true,
publicKeysOnDevice: true,
privateKeysCachedLocally: {
masterKey: false,
selfSigningKey: true,
userSigningKey: true,
},
});
mocked(DeviceListener.sharedInstance().getDeviceState).mockReturnValue("key_storage_out_of_sync");
renderComponent({ initialState: "reset_identity_forgot" });
@ -220,4 +200,17 @@ describe("<EncryptionUserSettingsTab />", () => {
screen.getByText("Your key storage is out of sync. Click one of the buttons below to fix the problem."),
);
});
it("should display the identity needs reset panel when the user's identity needs resetting", async () => {
mocked(DeviceListener.sharedInstance().getDeviceState).mockReturnValue("identity_needs_reset");
const user = userEvent.setup();
const { asFragment } = renderComponent();
await waitFor(() => screen.getByRole("button", { name: "Continue with reset" }));
expect(asFragment()).toMatchSnapshot();
await user.click(screen.getByRole("button", { name: "Continue with reset" }));
expect(screen.getByRole("heading", { name: "You need to reset your identity" })).toBeVisible();
});
});

View File

@ -81,6 +81,64 @@ exports[`<EncryptionUserSettingsTab /> should display the change recovery key pa
</DocumentFragment>
`;
exports[`<EncryptionUserSettingsTab /> should display the identity needs reset panel when the user's identity needs resetting 1`] = `
<DocumentFragment>
<div
class="mx_SettingsTab mx_EncryptionUserSettingsTab"
data-testid="encryptionTab"
>
<div
class="mx_SettingsTab_sections"
>
<div
class="mx_SettingsSection mx_SettingsSection_newUi"
>
<div
class="mx_SettingsSection_header"
>
<h2
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 mx_SettingsHeader"
>
Your key storage is out of sync.
</h2>
<div
class="mx_SettingsSubheader"
>
<span
class="mx_SettingsSubheader_error"
>
<svg
fill="currentColor"
height="20px"
viewBox="0 0 24 24"
width="20px"
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>
You have to reset your cryptographic identity in order to ensure access to your message history
</span>
</div>
</div>
<div>
<button
class="_button_187yx_8"
data-kind="primary"
data-size="sm"
role="button"
tabindex="0"
>
Continue with reset
</button>
</div>
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`<EncryptionUserSettingsTab /> should display the recovery out of sync panel when secrets are not cached 1`] = `
<DocumentFragment>
<div

View File

@ -15,11 +15,12 @@ import { type CryptoApi } from "matrix-js-sdk/src/crypto-api";
import * as SecurityManager from "../../../src/SecurityManager";
import ToastContainer from "../../../src/components/structures/ToastContainer";
import { Kind, showToast } from "../../../src/toasts/SetupEncryptionToast";
import { showToast } from "../../../src/toasts/SetupEncryptionToast";
import dis from "../../../src/dispatcher/dispatcher";
import DeviceListener from "../../../src/DeviceListener";
import Modal from "../../../src/Modal";
import ConfirmKeyStorageOffDialog from "../../../src/components/views/dialogs/ConfirmKeyStorageOffDialog";
import SetupEncryptionDialog from "../../../src/components/views/dialogs/security/SetupEncryptionDialog";
import { stubClient } from "../../test-utils";
jest.mock("../../../src/dispatcher/dispatcher", () => ({
@ -36,7 +37,7 @@ describe("SetupEncryptionToast", () => {
describe("Set up recovery", () => {
it("should render the toast", async () => {
act(() => showToast(Kind.SET_UP_RECOVERY));
act(() => showToast("set_up_recovery"));
expect(await screen.findByRole("heading", { name: "Set up recovery" })).toBeInTheDocument();
});
@ -45,7 +46,7 @@ describe("SetupEncryptionToast", () => {
jest.spyOn(DeviceListener.sharedInstance(), "recordRecoveryDisabled");
jest.spyOn(DeviceListener.sharedInstance(), "dismissEncryptionSetup");
act(() => showToast(Kind.SET_UP_RECOVERY));
act(() => showToast("set_up_recovery"));
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Dismiss" }));
@ -55,14 +56,6 @@ describe("SetupEncryptionToast", () => {
});
});
describe("Verify this session", () => {
it("should render the toast", async () => {
act(() => showToast(Kind.VERIFY_THIS_SESSION));
expect(await screen.findByRole("heading", { name: "Verify this session" })).toBeInTheDocument();
});
});
describe("Key storage out of sync", () => {
let client: Mocked<MatrixClient>;
@ -77,13 +70,13 @@ describe("SetupEncryptionToast", () => {
});
it("should render the toast", async () => {
act(() => showToast(Kind.KEY_STORAGE_OUT_OF_SYNC));
act(() => showToast("key_storage_out_of_sync"));
await expect(screen.findByText("Your key storage is out of sync.")).resolves.toBeInTheDocument();
});
it("should reset key backup if needed", async () => {
showToast(Kind.KEY_STORAGE_OUT_OF_SYNC);
showToast("key_storage_out_of_sync");
jest.spyOn(SecurityManager, "accessSecretStorage").mockImplementation(
async (func = async (): Promise<void> => {}) => {
@ -100,7 +93,7 @@ describe("SetupEncryptionToast", () => {
});
it("should not reset key backup if not needed", async () => {
showToast(Kind.KEY_STORAGE_OUT_OF_SYNC);
showToast("key_storage_out_of_sync");
jest.spyOn(SecurityManager, "accessSecretStorage").mockImplementation(
async (func = async (): Promise<void> => {}) => {
@ -122,7 +115,7 @@ describe("SetupEncryptionToast", () => {
});
it("should open settings to the reset flow when 'forgot recovery key' clicked and identity reset needed", async () => {
act(() => showToast(Kind.KEY_STORAGE_OUT_OF_SYNC));
act(() => showToast("key_storage_out_of_sync"));
jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsCrossSigningReset").mockResolvedValue(
true,
@ -139,7 +132,7 @@ describe("SetupEncryptionToast", () => {
});
it("should open settings to the change recovery key flow when 'forgot recovery key' clicked and identity reset not needed", async () => {
act(() => showToast(Kind.KEY_STORAGE_OUT_OF_SYNC));
act(() => showToast("key_storage_out_of_sync"));
jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsCrossSigningReset").mockResolvedValue(
false,
@ -164,7 +157,7 @@ describe("SetupEncryptionToast", () => {
true,
);
act(() => showToast(Kind.KEY_STORAGE_OUT_OF_SYNC));
act(() => showToast("key_storage_out_of_sync"));
const user = userEvent.setup();
await user.click(await screen.findByText("Enter recovery key"));
@ -185,7 +178,7 @@ describe("SetupEncryptionToast", () => {
false,
);
act(() => showToast(Kind.KEY_STORAGE_OUT_OF_SYNC));
act(() => showToast("key_storage_out_of_sync"));
const user = userEvent.setup();
await user.click(await screen.findByText("Enter recovery key"));
@ -200,7 +193,7 @@ describe("SetupEncryptionToast", () => {
describe("Turn on key storage", () => {
it("should render the toast", async () => {
act(() => showToast(Kind.TURN_ON_KEY_STORAGE));
act(() => showToast("turn_on_key_storage"));
await expect(screen.findByText("Turn on key storage")).resolves.toBeInTheDocument();
await expect(screen.findByRole("button", { name: "Dismiss" })).resolves.toBeInTheDocument();
@ -210,7 +203,7 @@ describe("SetupEncryptionToast", () => {
it("should open settings to the Encryption tab when 'Continue' clicked", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "recordKeyBackupDisabled");
act(() => showToast(Kind.TURN_ON_KEY_STORAGE));
act(() => showToast("turn_on_key_storage"));
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Continue" }));
@ -232,7 +225,7 @@ describe("SetupEncryptionToast", () => {
});
// When we show the toast, and click Dismiss
act(() => showToast(Kind.TURN_ON_KEY_STORAGE));
act(() => showToast("turn_on_key_storage"));
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Dismiss" }));
@ -248,4 +241,65 @@ describe("SetupEncryptionToast", () => {
expect(DeviceListener.sharedInstance().recordKeyBackupDisabled).toHaveBeenCalledTimes(1);
});
});
describe("Verify this session", () => {
it("should render the toast", async () => {
act(() => showToast("verify_this_session"));
await expect(screen.findByText("Verify this session")).resolves.toBeInTheDocument();
await expect(screen.findByRole("button", { name: "Later" })).resolves.toBeInTheDocument();
await expect(screen.findByRole("button", { name: "Verify" })).resolves.toBeInTheDocument();
});
it("should dismiss the toast when 'Later' button clicked, and remember it", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "dismissEncryptionSetup");
act(() => showToast("verify_this_session"));
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Later" }));
expect(DeviceListener.sharedInstance().dismissEncryptionSetup).toHaveBeenCalled();
});
it("should open the verification dialog when 'Verify' clicked", async () => {
jest.spyOn(Modal, "createDialog");
// When we show the toast, and click Verify
act(() => showToast("verify_this_session"));
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Verify" }));
// Then the dialog was opened
expect(Modal.createDialog).toHaveBeenCalledWith(SetupEncryptionDialog, {}, undefined, false, true);
});
});
describe("Identity needs reset", () => {
it("should render the toast", async () => {
act(() => showToast("identity_needs_reset"));
await expect(screen.findByText("Your key storage is out of sync.")).resolves.toBeInTheDocument();
await expect(
screen.findByText(
"You have to reset your cryptographic identity in order to ensure access to your message history",
),
).resolves.toBeInTheDocument();
await expect(screen.findByRole("button", { name: "Continue with reset" })).resolves.toBeInTheDocument();
});
it("should open settings to the reset flow when 'Continue with reset' clicked", async () => {
act(() => showToast("identity_needs_reset"));
const user = userEvent.setup();
await user.click(await screen.findByText("Continue with reset"));
expect(dis.dispatch).toHaveBeenCalledWith({
action: "view_user_settings",
initialTabId: "USER_ENCRYPTION_TAB",
props: { initialEncryptionState: "reset_identity_cant_recover" },
});
});
});
});