Prompt the user when key storage is unexpectedly off (#29912)
* Assert that we set backup_disabled when turning off key storage * Prompt the user when key storage is unexpectedly off * Playwright tests for the Turn on key storage toast
This commit is contained in:
@@ -97,6 +97,7 @@ export default class DeviceListener {
|
||||
this.client.on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
|
||||
this.client.on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
|
||||
this.client.on(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged);
|
||||
this.client.on(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatusChanged);
|
||||
this.client.on(ClientEvent.AccountData, this.onAccountData);
|
||||
this.client.on(ClientEvent.Sync, this.onSync);
|
||||
this.client.on(RoomStateEvent.Events, this.onRoomStateEvents);
|
||||
@@ -132,7 +133,7 @@ export default class DeviceListener {
|
||||
this.dismissedThisDeviceToast = false;
|
||||
this.keyBackupInfo = null;
|
||||
this.keyBackupFetchedAt = null;
|
||||
this.cachedKeyBackupStatus = undefined;
|
||||
this.cachedKeyBackupUploadActive = undefined;
|
||||
this.ourDeviceIdsAtStart = null;
|
||||
this.displayingToastsForDeviceIds = new Set();
|
||||
this.client = undefined;
|
||||
@@ -157,6 +158,13 @@ export default class DeviceListener {
|
||||
this.recheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the account data "m.org.matrix.custom.backup_disabled" to { "disabled": true }.
|
||||
*/
|
||||
public async recordKeyBackupDisabled(): Promise<void> {
|
||||
await this.client?.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true });
|
||||
}
|
||||
|
||||
private async ensureDeviceIdsAtStartPopulated(): Promise<void> {
|
||||
if (this.ourDeviceIdsAtStart === null) {
|
||||
this.ourDeviceIdsAtStart = await this.getDeviceIds();
|
||||
@@ -192,6 +200,11 @@ export default class DeviceListener {
|
||||
this.recheck();
|
||||
};
|
||||
|
||||
private onKeyBackupStatusChanged = (): void => {
|
||||
this.cachedKeyBackupUploadActive = undefined;
|
||||
this.recheck();
|
||||
};
|
||||
|
||||
private onCrossSingingKeysChanged = (): void => {
|
||||
this.recheck();
|
||||
};
|
||||
@@ -201,11 +214,13 @@ export default class DeviceListener {
|
||||
// * migrated SSSS to symmetric
|
||||
// * uploaded keys to secret storage
|
||||
// * completed secret storage creation
|
||||
// * disabled key backup
|
||||
// which result in account data changes affecting checks below.
|
||||
if (
|
||||
ev.getType().startsWith("m.secret_storage.") ||
|
||||
ev.getType().startsWith("m.cross_signing.") ||
|
||||
ev.getType() === "m.megolm_backup.v1"
|
||||
ev.getType() === "m.megolm_backup.v1" ||
|
||||
ev.getType() === BACKUP_DISABLED_ACCOUNT_DATA_KEY
|
||||
) {
|
||||
this.recheck();
|
||||
}
|
||||
@@ -324,7 +339,16 @@ export default class DeviceListener {
|
||||
(await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified,
|
||||
);
|
||||
|
||||
const allSystemsReady = crossSigningReady && secretStorageReady && allCrossSigningSecretsCached;
|
||||
const keyBackupUploadActive = await this.isKeyBackupUploadActive();
|
||||
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 allSystemsReady =
|
||||
crossSigningReady && keyBackupIsOk && secretStorageReady && allCrossSigningSecretsCached;
|
||||
|
||||
await this.reportCryptoSessionStateToAnalytics(cli);
|
||||
|
||||
if (this.dismissedThisDeviceToast || allSystemsReady) {
|
||||
@@ -353,14 +377,19 @@ export default class DeviceListener {
|
||||
crossSigningStatus.privateKeysCachedLocally,
|
||||
);
|
||||
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);
|
||||
} else if (defaultKeyId === null) {
|
||||
// the user just hasn't set up 4S yet: prompt them to do so (unless they've explicitly said no to key storage)
|
||||
const disabledEvent = cli.getAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY);
|
||||
if (!disabledEvent?.getContent().disabled) {
|
||||
// The user just hasn't set up 4S yet: if they have key
|
||||
// backup, prompt them to turn on recovery too. (If not, they
|
||||
// have explicitly opted out, so don't hassle them.)
|
||||
if (keyBackupUploadActive) {
|
||||
logSpan.info("No default 4S key: showing SET_UP_RECOVERY toast");
|
||||
showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY);
|
||||
} else {
|
||||
logSpan.info("No default 4S key but backup disabled: no toast needed");
|
||||
hideSetupEncryptionToast();
|
||||
}
|
||||
} else {
|
||||
// some other condition... yikes! Show the 'set up encryption' toast: this is what we previously did
|
||||
@@ -443,6 +472,16 @@ export default class DeviceListener {
|
||||
this.displayingToastsForDeviceIds = newUnverifiedDeviceIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the account data for `backup_disabled`. If this is the first time,
|
||||
* fetch it from the server (in case the initial sync has not finished).
|
||||
* Otherwise, fetch it from the store as normal.
|
||||
*/
|
||||
private async recheckBackupDisabled(cli: MatrixClient): Promise<boolean> {
|
||||
const backupDisabled = await cli.getAccountDataFromServer(BACKUP_DISABLED_ACCOUNT_DATA_KEY);
|
||||
return !!backupDisabled?.disabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
@@ -512,7 +551,7 @@ export default class DeviceListener {
|
||||
* trigger an auto-rageshake).
|
||||
*/
|
||||
private checkKeyBackupStatus = async (): Promise<void> => {
|
||||
if (!(await this.getKeyBackupStatus())) {
|
||||
if (!(await this.isKeyBackupUploadActive())) {
|
||||
dis.dispatch({ action: Action.ReportKeyBackupNotEnabled });
|
||||
}
|
||||
};
|
||||
@@ -520,28 +559,34 @@ export default class DeviceListener {
|
||||
/**
|
||||
* Is key backup enabled? Use a cached answer if we have one.
|
||||
*/
|
||||
private getKeyBackupStatus = async (): Promise<boolean> => {
|
||||
private isKeyBackupUploadActive = async (): Promise<boolean> => {
|
||||
if (!this.client) {
|
||||
// To preserve existing behaviour, if there is no client, we
|
||||
// pretend key storage is on.
|
||||
// pretend key backup upload is on.
|
||||
//
|
||||
// Someone looking to improve this code could try throwing an error
|
||||
// here since we don't expect client to be undefined.
|
||||
return true;
|
||||
}
|
||||
|
||||
const crypto = this.client.getCrypto();
|
||||
if (!crypto) {
|
||||
// If there is no crypto, there is no key backup
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we've already cached the answer, return it.
|
||||
if (this.cachedKeyBackupStatus !== undefined) {
|
||||
return this.cachedKeyBackupStatus;
|
||||
if (this.cachedKeyBackupUploadActive !== undefined) {
|
||||
return this.cachedKeyBackupUploadActive;
|
||||
}
|
||||
|
||||
// Fetch the answer and cache it
|
||||
const activeKeyBackupVersion = await this.client.getCrypto()?.getActiveSessionBackupVersion();
|
||||
this.cachedKeyBackupStatus = !!activeKeyBackupVersion;
|
||||
const activeKeyBackupVersion = await crypto.getActiveSessionBackupVersion();
|
||||
this.cachedKeyBackupUploadActive = !!activeKeyBackupVersion;
|
||||
|
||||
return this.cachedKeyBackupStatus;
|
||||
return this.cachedKeyBackupUploadActive;
|
||||
};
|
||||
private cachedKeyBackupStatus: boolean | undefined = undefined;
|
||||
private cachedKeyBackupUploadActive: boolean | undefined = undefined;
|
||||
|
||||
private onRecordClientInformationSettingChange: CallbackFn = (
|
||||
_originalSettingName,
|
||||
|
||||
80
src/components/views/dialogs/ConfirmKeyStorageOffDialog.tsx
Normal file
80
src/components/views/dialogs/ConfirmKeyStorageOffDialog.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import { EncryptionCard } from "../settings/encryption/EncryptionCard";
|
||||
import { EncryptionCardButtons } from "../settings/encryption/EncryptionCardButtons";
|
||||
import { type OpenToTabPayload } from "../../../dispatcher/payloads/OpenToTabPayload";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { UserTab } from "./UserTab";
|
||||
|
||||
interface Props {
|
||||
onFinished: (dismissed: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the user whether they really want to dismiss the toast about key storage.
|
||||
*
|
||||
* Launched from the {@link SetupEncryptionToast} in mode `TURN_ON_KEY_STORAGE`,
|
||||
* when the user clicks "Dismiss". The caller handles any action via the
|
||||
* `onFinished` prop which takes a boolean that is true if the user clicked
|
||||
* "Yes, dismiss".
|
||||
*/
|
||||
export default class ConfirmKeyStorageOffDialog extends React.Component<Props> {
|
||||
public constructor(props: Props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
private onGoToSettingsClick = (): void => {
|
||||
// Open Settings at the Encryption tab
|
||||
const payload: OpenToTabPayload = {
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.Encryption,
|
||||
};
|
||||
defaultDispatcher.dispatch(payload);
|
||||
this.props.onFinished(false);
|
||||
};
|
||||
|
||||
private onDismissClick = (): void => {
|
||||
this.props.onFinished(true);
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
return (
|
||||
<EncryptionCard
|
||||
Icon={ErrorIcon}
|
||||
destructive={true}
|
||||
title={_t("settings|encryption|confirm_key_storage_off")}
|
||||
>
|
||||
{_t("settings|encryption|confirm_key_storage_off_description", undefined, {
|
||||
a: (sub) => (
|
||||
<>
|
||||
<br />
|
||||
<a href="https://element.io/help#encryption5" target="_blank" rel="noreferrer noopener">
|
||||
{sub} <PopOutIcon />
|
||||
</a>
|
||||
</>
|
||||
),
|
||||
})}
|
||||
<EncryptionCardButtons>
|
||||
<Button onClick={this.onGoToSettingsClick} autoFocus kind="primary" className="">
|
||||
{_t("common|go_to_settings")}
|
||||
</Button>
|
||||
<Button onClick={this.onDismissClick} kind="secondary">
|
||||
{_t("action|yes_dismiss")}
|
||||
</Button>
|
||||
</EncryptionCardButtons>
|
||||
</EncryptionCard>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,7 @@
|
||||
"view_message": "View message",
|
||||
"view_source": "View Source",
|
||||
"yes": "Yes",
|
||||
"yes_dismiss": "Yes, dismiss",
|
||||
"zoom_in": "Zoom in",
|
||||
"zoom_out": "Zoom out"
|
||||
},
|
||||
@@ -981,6 +982,8 @@
|
||||
"setup_secure_backup": {
|
||||
"explainer": "Back up your keys before signing out to avoid losing them."
|
||||
},
|
||||
"turn_on_key_storage": "Turn on key storage",
|
||||
"turn_on_key_storage_description": "Store your cryptographic identity and message keys securely on the server. This will allow you to view your message history on any new devices.",
|
||||
"udd": {
|
||||
"interactive_verification_button": "Interactively verify by emoji",
|
||||
"other_ask_verify_text": "Ask this user to verify their session, or manually verify it below.",
|
||||
@@ -2559,6 +2562,8 @@
|
||||
"session_key": "Session key:",
|
||||
"title": "Advanced"
|
||||
},
|
||||
"confirm_key_storage_off": "Are you sure you want to keep key storage turned off?",
|
||||
"confirm_key_storage_off_description": "If you sign out of all your devices you will lose your message history and will need to verify all your existing contacts again. <a>Learn more</a>",
|
||||
"delete_key_storage": {
|
||||
"breadcrumb_page": "Delete key storage",
|
||||
"confirm": "Delete key storage",
|
||||
|
||||
@@ -24,6 +24,7 @@ import { type OpenToTabPayload } from "../dispatcher/payloads/OpenToTabPayload";
|
||||
import { Action } from "../dispatcher/actions";
|
||||
import { UserTab } from "../components/views/dialogs/UserTab";
|
||||
import defaultDispatcher from "../dispatcher/dispatcher";
|
||||
import ConfirmKeyStorageOffDialog from "../components/views/dialogs/ConfirmKeyStorageOffDialog";
|
||||
|
||||
const TOAST_KEY = "setupencryption";
|
||||
|
||||
@@ -37,6 +38,8 @@ const getTitle = (kind: Kind): string => {
|
||||
return _t("encryption|verify_toast_title");
|
||||
case Kind.KEY_STORAGE_OUT_OF_SYNC:
|
||||
return _t("encryption|key_storage_out_of_sync");
|
||||
case Kind.TURN_ON_KEY_STORAGE:
|
||||
return _t("encryption|turn_on_key_storage");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -49,6 +52,8 @@ const getIcon = (kind: Kind): string | undefined => {
|
||||
case Kind.VERIFY_THIS_SESSION:
|
||||
case Kind.KEY_STORAGE_OUT_OF_SYNC:
|
||||
return "verification_warning";
|
||||
case Kind.TURN_ON_KEY_STORAGE:
|
||||
return "key_storage";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -62,6 +67,8 @@ const getSetupCaption = (kind: Kind): string => {
|
||||
return _t("action|verify");
|
||||
case Kind.KEY_STORAGE_OUT_OF_SYNC:
|
||||
return _t("encryption|enter_recovery_key");
|
||||
case Kind.TURN_ON_KEY_STORAGE:
|
||||
return _t("action|continue");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -87,6 +94,8 @@ const getSecondaryButtonLabel = (kind: Kind): string => {
|
||||
return _t("encryption|verification|unverified_sessions_toast_reject");
|
||||
case Kind.KEY_STORAGE_OUT_OF_SYNC:
|
||||
return _t("encryption|forgot_recovery_key");
|
||||
case Kind.TURN_ON_KEY_STORAGE:
|
||||
return _t("action|dismiss");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -100,6 +109,8 @@ const getDescription = (kind: Kind): string => {
|
||||
return _t("encryption|verify_toast_description");
|
||||
case Kind.KEY_STORAGE_OUT_OF_SYNC:
|
||||
return _t("encryption|key_storage_out_of_sync_description");
|
||||
case Kind.TURN_ON_KEY_STORAGE:
|
||||
return _t("encryption|turn_on_key_storage_description");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -123,6 +134,10 @@ export enum Kind {
|
||||
* 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",
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,6 +158,13 @@ export const showToast = (kind: Kind): void => {
|
||||
const onPrimaryClick = async (): Promise<void> => {
|
||||
if (kind === Kind.VERIFY_THIS_SESSION) {
|
||||
Modal.createDialog(SetupEncryptionDialog, {}, undefined, /* priority = */ false, /* static = */ true);
|
||||
} else if (kind == Kind.TURN_ON_KEY_STORAGE) {
|
||||
// Open the user settings dialog to the encryption tab
|
||||
const payload: OpenToTabPayload = {
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.Encryption,
|
||||
};
|
||||
defaultDispatcher.dispatch(payload);
|
||||
} else {
|
||||
const modal = Modal.createDialog(
|
||||
Spinner,
|
||||
@@ -161,7 +183,7 @@ export const showToast = (kind: Kind): void => {
|
||||
}
|
||||
};
|
||||
|
||||
const onSecondaryClick = (): void => {
|
||||
const onSecondaryClick = async (): Promise<void> => {
|
||||
if (kind === Kind.KEY_STORAGE_OUT_OF_SYNC) {
|
||||
// Open the user settings dialog to the encryption tab and start the flow to reset encryption
|
||||
const payload: OpenToTabPayload = {
|
||||
@@ -170,6 +192,15 @@ export const showToast = (kind: Kind): void => {
|
||||
props: { initialEncryptionState: "reset_identity_forgot" },
|
||||
};
|
||||
defaultDispatcher.dispatch(payload);
|
||||
} else if (kind === Kind.TURN_ON_KEY_STORAGE) {
|
||||
// The user clicked "Dismiss": offer them "Are you sure?"
|
||||
const modal = Modal.createDialog(ConfirmKeyStorageOffDialog, undefined, "mx_ConfirmKeyStorageOffDialog");
|
||||
const [dismissed] = await modal.finished;
|
||||
if (dismissed) {
|
||||
const deviceListener = DeviceListener.sharedInstance();
|
||||
await deviceListener.recordKeyBackupDisabled();
|
||||
deviceListener.dismissEncryptionSetup();
|
||||
}
|
||||
} else {
|
||||
DeviceListener.sharedInstance().dismissEncryptionSetup();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user