Apply new design and display logic to logout confirmation dialog (#33426)

* apply new design to logout dialog

* factor out check for other verified devices

* only show recovery warning when user has no other verified devices

* fix playwright tests

* tweak style to better match design

* another playwright test fix

* fix playwright

* Look for the remove button within the dialog

* Use testid to locate 'Remove this device' button

* move rendering to sub-components, rather than embedded functions

* use <Type> element

* use <Text> for the <a> element

---------

Co-authored-by: Andy Balaam <andy.balaam@matrix.org>
This commit is contained in:
Hubert Chathi
2026-06-01 23:08:18 -04:00
committed by GitHub
parent 178e909dea
commit 2bd5224dbe
17 changed files with 771 additions and 354 deletions
@@ -7,11 +7,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { lazy } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import React, { type JSX } from "react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { Button, Text } from "@vector-im/compound-web";
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid";
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key";
import PopOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/pop-out";
import SignOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/sign-out";
import Modal from "../../../Modal";
import dis from "../../../dispatcher/dispatcher";
import { type OpenToTabPayload } from "../../../dispatcher/payloads/OpenToTabPayload";
import { Action } from "../../../dispatcher/actions";
@@ -21,39 +24,16 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg";
import QuestionDialog from "./QuestionDialog";
import BaseDialog from "./BaseDialog";
import Spinner from "../elements/Spinner";
import DialogButtons from "../elements/DialogButtons";
import { BackupStatus, useKeyBackupStatus } from "../../../hooks/useKeyBackupStatus";
import { useHasOtherVerifiedDevices } from "../../../hooks/useHasOtherVerifiedDevices";
import { EncryptionCard } from "../settings/encryption/EncryptionCard";
import { EncryptionCardButtons } from "../settings/encryption/EncryptionCardButtons";
import { EncryptionCardEmphasisedContent } from "../settings/encryption/EncryptionCardEmphasisedContent";
interface IProps {
onFinished: (success: boolean) => void;
}
enum BackupStatus {
/** we're trying to figure out if there is an active backup */
LOADING,
/** crypto is disabled in this client (so no need to back up) */
NO_CRYPTO,
/** Key backup is active and working */
BACKUP_ACTIVE,
/** there is a backup on the server but we are not backing up to it */
SERVER_BACKUP_BUT_DISABLED,
/** Key backup is set up but recovery (4s) is not */
BACKUP_NO_RECOVERY,
/** backup is not set up locally and there is no backup on the server */
NO_BACKUP,
/** there was an error fetching the state */
ERROR,
}
interface IState {
backupStatus: BackupStatus;
}
/**
* Checks if the `LogoutDialog` should be shown instead of the simple logout flow.
* The `LogoutDialog` will check the crypto recovery status of the account and
@@ -61,191 +41,122 @@ interface IState {
*/
export async function shouldShowLogoutDialog(cli: MatrixClient): Promise<boolean> {
const crypto = cli?.getCrypto();
if (!crypto) return false;
// If any room is encrypted, we need to show the advanced logout flow
const allRooms = cli!.getRooms();
for (const room of allRooms) {
const isE2e = await crypto.isEncryptionEnabledInRoom(room.roomId);
if (isE2e) return true;
}
return false;
return !!crypto;
}
export default class LogoutDialog extends React.Component<IProps, IState> {
public static defaultProps = {
onFinished: function () {},
};
export default function LogoutDialog(props: IProps): JSX.Element {
const client = MatrixClientPeg.safeGet();
const backupStatus = useKeyBackupStatus(client);
const hasOtherVerifiedDevices = useHasOtherVerifiedDevices(client);
public constructor(props: IProps) {
super(props);
this.state = {
backupStatus: BackupStatus.LOADING,
};
}
public componentDidMount(): void {
this.startLoadBackupStatus();
}
/** kick off the asynchronous calls to populate `state.backupStatus` in the background */
private startLoadBackupStatus(): void {
this.loadBackupStatus().catch((e) => {
logger.log("Unable to fetch key backup status", e);
this.setState({
backupStatus: BackupStatus.ERROR,
});
});
}
private async loadBackupStatus(): Promise<void> {
const client = MatrixClientPeg.safeGet();
const crypto = client.getCrypto();
if (!crypto) {
this.setState({ backupStatus: BackupStatus.NO_CRYPTO });
return;
}
if ((await crypto.getActiveSessionBackupVersion()) !== null) {
if (await crypto.isSecretStorageReady()) {
this.setState({ backupStatus: BackupStatus.BACKUP_ACTIVE });
} else {
this.setState({ backupStatus: BackupStatus.BACKUP_NO_RECOVERY });
}
return;
}
// backup is not active. see if there is a backup version on the server we ought to back up to.
const backupInfo = await crypto.getKeyBackupInfo();
this.setState({ backupStatus: backupInfo ? BackupStatus.SERVER_BACKUP_BUT_DISABLED : BackupStatus.NO_BACKUP });
}
private onExportE2eKeysClicked = (): void => {
Modal.createDialog(
lazy(() => import("../../../async-components/views/dialogs/security/ExportE2eKeysDialog")),
{
matrixClient: MatrixClientPeg.safeGet(),
},
);
};
private onFinished = (confirmed?: boolean): void => {
const onFinished = (confirmed?: boolean): void => {
if (confirmed) {
dis.dispatch({ action: "logout" });
}
// close dialog
this.props.onFinished(!!confirmed);
props.onFinished(!!confirmed);
};
private onSetRecoveryMethodClick = (): void => {
// Open the user settings dialog to the encryption tab and start the flow to reset encryption
const payload: OpenToTabPayload = {
action: Action.ViewUserSettings,
initialTabId: UserTab.Encryption,
};
dis.dispatch(payload);
// close dialog
this.props.onFinished(true);
};
private onLogoutConfirm = (): void => {
const onLogoutConfirm = (): void => {
dis.dispatch({ action: "logout" });
// close dialog
this.props.onFinished(true);
props.onFinished(true);
};
/**
* Show a dialog prompting the user to set up their recovery method.
*
* Either:
* * There is no backup at all ({@link BackupStatus.NO_BACKUP})
* * There is a backup set up but recovery (4s) is not ({@link BackupStatus.BACKUP_NO_RECOVERY})
* * There is a backup on the server but we are not connected to it ({@link BackupStatus.SERVER_BACKUP_BUT_DISABLED})
* * We were unable to pull the backup data ({@link BackupStatus.ERROR}).
*
* In all four cases, we should prompt the user to set up a method of recovery.
*/
private renderSetupRecoveryMethod(): React.ReactNode {
const description = (
<div>
<p>{_t("auth|logout_dialog|setup_secure_backup_description_1")}</p>
<p>{_t("auth|logout_dialog|setup_secure_backup_description_2")}</p>
<p>{_t("encryption|setup_secure_backup|explainer")}</p>
</div>
);
const onGoToSettings = (): void => {
// Open the user settings dialog to the encryption tab and start the flow to get recovery key
const payload: OpenToTabPayload = {
action: Action.ViewUserSettings,
initialTabId: UserTab.Encryption,
props: {
initialEncryptionState: "set_recovery_key",
},
};
dis.dispatch(payload);
const dialogContent = (
<div>
<div className="mx_Dialog_content" id="mx_Dialog_content">
{description}
</div>
<DialogButtons
primaryButton={_t("common|go_to_settings")}
hasCancel={false}
onPrimaryButtonClick={this.onSetRecoveryMethodClick}
focus={true}
>
<button onClick={this.onLogoutConfirm}>{_t("auth|logout_dialog|skip_key_backup")}</button>
</DialogButtons>
<details>
<summary className="mx_LogoutDialog_ExportKeyAdvanced">{_t("common|advanced")}</summary>
<p>
<button onClick={this.onExportE2eKeysClicked}>{_t("auth|logout_dialog|megolm_export")}</button>
</p>
</details>
</div>
);
// Not quite a standard question dialog as the primary button cancels
// the action and does something else instead, whilst non-default button
// confirms the action.
return (
<BaseDialog
title={_t("auth|logout_dialog|setup_key_backup_title")}
contentId="mx_Dialog_content"
hasCancel={true}
onFinished={this.onFinished}
>
{dialogContent}
</BaseDialog>
);
props.onFinished(false);
};
if (hasOtherVerifiedDevices === undefined) {
return <Loading onFinished={onFinished} />;
} else if (hasOtherVerifiedDevices) {
return <ConfirmLogout onFinished={onFinished} />;
}
switch (backupStatus) {
case BackupStatus.LOADING:
return <Loading onFinished={onFinished} />;
public render(): React.ReactNode {
switch (this.state.backupStatus) {
case BackupStatus.LOADING:
// while we're deciding if we have backups, show a spinner
return (
<BaseDialog
title={_t("action|sign_out")}
contentId="mx_Dialog_content"
hasCancel={true}
onFinished={this.onFinished}
case BackupStatus.NO_CRYPTO:
case BackupStatus.BACKUP_ACTIVE:
return <ConfirmLogout onFinished={onFinished} />;
case BackupStatus.NO_BACKUP:
case BackupStatus.SERVER_BACKUP_BUT_DISABLED:
case BackupStatus.ERROR:
case BackupStatus.BACKUP_NO_RECOVERY: {
return (
<BaseDialog
contentId="mx_Dialog_content"
hasCancel={true}
onFinished={onFinished}
className="mx_LogoutDialog"
>
<EncryptionCard
Icon={ErrorIcon}
destructive={true}
title={_t("auth|logout_dialog|setup_key_backup_title")}
className="mx_EncryptionCard_noBorder"
>
<Spinner />
</BaseDialog>
);
case BackupStatus.NO_CRYPTO:
case BackupStatus.BACKUP_ACTIVE:
return (
<QuestionDialog
hasCancelButton={true}
title={_t("action|sign_out")}
description={_t("auth|logout_dialog|description")}
button={_t("action|sign_out")}
onFinished={this.onFinished}
/>
);
case BackupStatus.NO_BACKUP:
case BackupStatus.SERVER_BACKUP_BUT_DISABLED:
case BackupStatus.ERROR:
case BackupStatus.BACKUP_NO_RECOVERY:
return this.renderSetupRecoveryMethod();
<EncryptionCardEmphasisedContent>
<Text>{_t("auth|logout_dialog|setup_secure_backup_description")}</Text>
<Text as="a" target="_blank" href="https://element.io/en/help#encryption16">
{_t("action|learn_more")} <PopOutIcon />
</Text>
</EncryptionCardEmphasisedContent>
<EncryptionCardButtons>
<Button onClick={onGoToSettings} Icon={KeyIcon}>
{_t("settings|encryption|recovery|set_up_recovery")}
</Button>
<Button kind="tertiary" destructive={true} onClick={onLogoutConfirm} Icon={SignOutIcon}>
{_t("auth|logout_dialog|skip_key_backup")}
</Button>
</EncryptionCardButtons>
</EncryptionCard>
</BaseDialog>
);
}
}
}
interface SubComponentProps {
onFinished: (confirmed?: boolean) => void;
}
// Dialog contents to show a spinner while deciding whether to prompt the
// user to set up recovery
function Loading(props: SubComponentProps): JSX.Element {
return (
<BaseDialog
title={_t("action|sign_out")}
contentId="mx_Dialog_content"
hasCancel={true}
onFinished={props.onFinished}
>
<Spinner />
</BaseDialog>
);
}
// Dialog contents to confirm whether the user is sure if they want to log
// out.
function ConfirmLogout(props: SubComponentProps): JSX.Element {
return (
<QuestionDialog
hasCancelButton={true}
title={_t("action|sign_out")}
description={_t("auth|logout_dialog|description")}
button={_t("action|sign_out")}
onFinished={props.onFinished}
/>
);
}