diff --git a/packages/shared-components/playwright/snapshots/room-banner--with-action-linux.png b/packages/shared-components/playwright/snapshots/room-banner--with-action-linux.png index 563cadf027..b3968f9db7 100644 Binary files a/packages/shared-components/playwright/snapshots/room-banner--with-action-linux.png and b/packages/shared-components/playwright/snapshots/room-banner--with-action-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/room-banner--with-loads-of-content-linux.png b/packages/shared-components/playwright/snapshots/room-banner--with-loads-of-content-linux.png new file mode 100644 index 0000000000..85372e48fa Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-banner--with-loads-of-content-linux.png differ diff --git a/packages/shared-components/src/composer/Banner/Banner.module.css b/packages/shared-components/src/composer/Banner/Banner.module.css index 077212354e..7bb48a9cc2 100644 --- a/packages/shared-components/src/composer/Banner/Banner.module.css +++ b/packages/shared-components/src/composer/Banner/Banner.module.css @@ -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; } diff --git a/packages/shared-components/src/composer/Banner/Banner.stories.tsx b/packages/shared-components/src/composer/Banner/Banner.stories.tsx index 53d3941621..e1e3e110fb 100644 --- a/packages/shared-components/src/composer/Banner/Banner.stories.tsx +++ b/packages/shared-components/src/composer/Banner/Banner.stories.tsx @@ -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: (

- {_t( - "encryption|pinned_identity_changed", - { displayName: "Alice", userId: "@alice:example.org" }, - { - a: (sub) => {sub}, - b: (sub) => {sub}, - }, - )} + Alice's (@alice:example.com) identity was reset. Learn more

), - actions: , + actions: ( + + ), }, }; @@ -71,3 +67,19 @@ export const WithoutClose: Story = { onClose: undefined, }, }; + +export const WithLoadsOfContent: Story = { + args: { + type: "info", + children: ( +

+ 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. +

+ ), + }, +}; diff --git a/packages/shared-components/src/composer/Banner/Banner.tsx b/packages/shared-components/src/composer/Banner/Banner.tsx index add618ee91..8f052c97e6 100644 --- a/packages/shared-components/src/composer/Banner/Banner.tsx +++ b/packages/shared-components/src/composer/Banner/Banner.tsx @@ -78,7 +78,7 @@ export function Banner({ return (
{avatar ?? icon}
- {children} +
{children}
{actions} {onClose && ( diff --git a/packages/shared-components/src/composer/Banner/__snapshots__/Banner.test.tsx.snap b/packages/shared-components/src/composer/Banner/__snapshots__/Banner.test.tsx.snap index e790e79831..ebb8df0a3d 100644 --- a/packages/shared-components/src/composer/Banner/__snapshots__/Banner.test.tsx.snap +++ b/packages/shared-components/src/composer/Banner/__snapshots__/Banner.test.tsx.snap @@ -26,24 +26,33 @@ exports[`AvatarWithDetails renders a banner with an action 1`] = ` />
-

- encryption|pinned_identity_changed + Alice's ( + + @alice:example.com + + ) identity was reset. + + Learn more +

-
+
-

Hello! This is a status banner.

-
+
@@ -118,13 +127,13 @@ exports[`AvatarWithDetails renders a critical banner 1`] = ` />
-

Hello! This is a status banner.

-
+
@@ -168,13 +177,13 @@ exports[`AvatarWithDetails renders a default banner 1`] = ` />
-

Hello! This is a status banner.

-
+
@@ -219,13 +228,13 @@ exports[`AvatarWithDetails renders a info banner 1`] = ` />
-

Hello! This is a status banner.

-
+
@@ -265,13 +274,13 @@ exports[`AvatarWithDetails renders a success banner 1`] = ` />
-

Hello! This is a status banner.

-
+
diff --git a/packages/shared-components/src/composer/HistoryVisibleBannerView/__snapshots__/HistoryVisibleBannerView.test.tsx.snap b/packages/shared-components/src/composer/HistoryVisibleBannerView/__snapshots__/HistoryVisibleBannerView.test.tsx.snap index ca4674cb58..1ee67f9a37 100644 --- a/packages/shared-components/src/composer/HistoryVisibleBannerView/__snapshots__/HistoryVisibleBannerView.test.tsx.snap +++ b/packages/shared-components/src/composer/HistoryVisibleBannerView/__snapshots__/HistoryVisibleBannerView.test.tsx.snap @@ -27,7 +27,7 @@ exports[`HistoryVisibleBannerView renders a history visible banner 1`] = ` />
- @@ -43,7 +43,7 @@ exports[`HistoryVisibleBannerView renders a history visible banner 1`] = ` Learn More - +
diff --git a/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts b/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts index 291be4442a..c944e92307 100644 --- a/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts +++ b/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts @@ -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; }); diff --git a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts index 8895e4a7ee..db558a43da 100644 --- a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts +++ b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts @@ -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; }); diff --git a/playwright/pages/bot.ts b/playwright/pages/bot.ts index 05a8948a65..c3168a89ac 100644 --- a/playwright/pages/bot.ts +++ b/playwright/pages/bot.ts @@ -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 { + 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>; 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 { + private async getCredentials(): Promise { 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, diff --git a/playwright/pages/client.ts b/playwright/pages/client.ts index 86cb581397..76f2733820 100644 --- a/playwright/pages/client.ts +++ b/playwright/pages/client.ts @@ -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 { + public async bootstrapCrossSigning(credentials: CredentialsOptionalAccessToken): Promise { const client = await this.prepareClient(); return bootstrapCrossSigningForClient(client, credentials); } @@ -522,7 +522,7 @@ export class Client { */ export function bootstrapCrossSigningForClient( client: JSHandle, - credentials: Credentials, + credentials: CredentialsOptionalAccessToken, resetKeys: boolean = false, ) { return client.evaluate( diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index 3591db8d82..f9904eaef1 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -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 { private dispatcherRef?: string; // device IDs for which the user has dismissed the verify toast ('Later') private dismissed = new Set(); @@ -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 { + 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). diff --git a/src/components/views/composer/HistoryVisibleBanner.tsx b/src/components/views/composer/HistoryVisibleBanner.tsx index 09286ae8e1..85a6bd7fb9 100644 --- a/src/components/views/composer/HistoryVisibleBanner.tsx +++ b/src/components/views/composer/HistoryVisibleBanner.tsx @@ -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. diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index b2546b02a3..c23bc7917d 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -675,7 +675,11 @@ export class MessageComposer extends React.Component { return (
- +
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 ( { - 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(); }} > diff --git a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx index dea28628fb..ddc594a0b0 100644 --- a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @@ -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): JSX.Element { const [state, setState] = useState(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 = ; - break; - case "set_up_encryption": - content = ; - break; - case "secrets_not_cached": - content = ( - setState("reset_identity_forgot")} - /> - ); - break; - case "key_storage_disabled": case "main": - content = ( - <> - setState("key_storage_delete")} /> - - {/* 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 = setState("main")} />; + break; + case "key_storage_out_of_sync": + content = ( + 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 = ( + setState("reset_identity_cant_recover")} /> + ); + break; + default: + content = ( <> - - setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key") - } - /> + setState("key_storage_delete")} /> + {/* We only show the "Recovery" panel if key storage is enabled.*/} + {isBackupEnabled && ( + <> + + setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key") + } + /> + + + )} + setState("reset_identity_compromised")} /> - )} - 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 = ( setState("main")} + onReset={() => setState("main")} /> ); break; case "key_storage_delete": - content = ; + content = 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 { - 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 ); } + +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): JSX.Element { + return ( + + } + > +
+ +
+
+ ); +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index ee49c92cb4..e43c199962 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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.", diff --git a/src/toasts/SetupEncryptionToast.tsx b/src/toasts/SetupEncryptionToast.tsx index ddf9dd69a7..1b5f8a4a49 100644 --- a/src/toasts/SetupEncryptionToast.tsx +++ b/src/toasts/SetupEncryptionToast.tsx @@ -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; + +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["icon"] => { - switch (kind) { - case Kind.SET_UP_RECOVERY: +const getIcon = (state: DeviceStateForToast): IToast["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 ; - case Kind.TURN_ON_KEY_STORAGE: + case "turn_on_key_storage": return ; } }; -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> | undefined => { - switch (kind) { - case Kind.KEY_STORAGE_OUT_OF_SYNC: +const getPrimaryButtonIcon = ( + state: DeviceStateForToast, +): ComponentType> | 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 => { - 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({ 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 => { - switch (kind) { - case Kind.SET_UP_RECOVERY: { + switch (state) { + case "set_up_recovery": { PosthogAnalytics.instance.trackEvent({ 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({ 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, }); }; diff --git a/src/viewmodels/composer/HistoryVisibleBannerViewModel.tsx b/src/viewmodels/composer/HistoryVisibleBannerViewModel.tsx index 7135999021..7d1ce9ec81 100644 --- a/src/viewmodels/composer/HistoryVisibleBannerViewModel.tsx +++ b/src/viewmodels/composer/HistoryVisibleBannerViewModel.tsx @@ -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)); } /** diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index a9db37f5df..f3e1e70ff1 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -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), diff --git a/test/unit-tests/DeviceListener-test.ts b/test/unit-tests/DeviceListener-test.ts index ef3b01b68c..153739dea6 100644 --- a/test/unit-tests/DeviceListener-test.ts +++ b/test/unit-tests/DeviceListener-test.ts @@ -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"); }); }); }); diff --git a/test/unit-tests/components/viewmodels/composer/HistoryVisibleBannerViewModel-test.tsx b/test/unit-tests/components/viewmodels/composer/HistoryVisibleBannerViewModel-test.tsx index 764376aba3..b1ab6ef7ff 100644 --- a/test/unit-tests/components/viewmodels/composer/HistoryVisibleBannerViewModel-test.tsx +++ b/test/unit-tests/components/viewmodels/composer/HistoryVisibleBannerViewModel-test.tsx @@ -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); diff --git a/test/unit-tests/components/views/settings/encryption/RecoveryPanelOutOfSync-test.tsx b/test/unit-tests/components/views/settings/encryption/RecoveryPanelOutOfSync-test.tsx index 36e35dbe83..7f1d37b329 100644 --- a/test/unit-tests/components/views/settings/encryption/RecoveryPanelOutOfSync-test.tsx +++ b/test/unit-tests/components/views/settings/encryption/RecoveryPanelOutOfSync-test.tsx @@ -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("", () => { - function renderComponent(onFinish = jest.fn(), onForgotRecoveryKey = jest.fn()) { - return render(); + let matrixClient: MatrixClient; + + function renderComponent( + onFinish = jest.fn(), + onForgotRecoveryKey = jest.fn(), + onAccessSecretStorageFailed = jest.fn(), + ) { + matrixClient = createTestClient(); + return render( + , + withClientContextRenderOptions(matrixClient), + ); } + afterEach(() => { + jest.clearAllMocks(); + }); + it("should render", () => { const { asFragment } = renderComponent(); expect(asFragment()).toMatchSnapshot(); @@ -38,8 +64,12 @@ describe("", () => { }); 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 => {}) => { + return await func(); + }); const onFinish = jest.fn(); renderComponent(onFinish); @@ -47,5 +77,59 @@ describe("", () => { 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 => {}) => { + 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 => {}) => { + 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 => {}) => { + 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(); }); }); diff --git a/test/unit-tests/components/views/settings/tabs/user/EncryptionUserSettingsTab-test.tsx b/test/unit-tests/components/views/settings/tabs/user/EncryptionUserSettingsTab-test.tsx index 018ec25ef3..d86f8fbdec 100644 --- a/test/unit-tests/components/views/settings/tabs/user/EncryptionUserSettingsTab-test.tsx +++ b/test/unit-tests/components/views/settings/tabs/user/EncryptionUserSettingsTab-test.tsx @@ -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("", () => { let matrixClient: MatrixClient; @@ -37,22 +39,21 @@ describe("", () => { userSigningKey: true, }, }); + + jest.spyOn(DeviceListener.sharedInstance(), "getDeviceState").mockReturnValue("ok"); + }); + + afterEach(() => { + jest.resetAllMocks(); }); function renderComponent(props: { initialState?: State } = {}) { return render(, 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("", () => { }); 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("", () => { 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("", () => { 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(); + }); }); diff --git a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/EncryptionUserSettingsTab-test.tsx.snap b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/EncryptionUserSettingsTab-test.tsx.snap index a3dc5dfecf..d82653a180 100644 --- a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/EncryptionUserSettingsTab-test.tsx.snap +++ b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/EncryptionUserSettingsTab-test.tsx.snap @@ -81,6 +81,64 @@ exports[` should display the change recovery key pa `; +exports[` should display the identity needs reset panel when the user's identity needs resetting 1`] = ` + +
+
+
+
+

+ Your key storage is out of sync. +

+
+ + + + + You have to reset your cryptographic identity in order to ensure access to your message history + +
+
+
+ +
+
+
+
+
+`; + exports[` should display the recovery out of sync panel when secrets are not cached 1`] = `
({ @@ -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; @@ -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 => {}) => { @@ -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 => {}) => { @@ -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" }, + }); + }); + }); });