diff --git a/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-linux.png b/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-linux.png index 4cf8df1c326..4924832093a 100644 Binary files a/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-linux.png and b/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-linux.png differ diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 0e6b17ccc07..6921c262645 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -27,7 +27,7 @@ import { UserTab } from "../views/dialogs/UserTab"; import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; import FeedbackDialog from "../views/dialogs/FeedbackDialog"; import Modal from "../../Modal"; -import LogoutDialog from "../views/dialogs/LogoutDialog"; +import LogoutDialog, { shouldShowLogoutDialog } from "../views/dialogs/LogoutDialog"; import SettingsStore from "../../settings/SettingsStore"; import { findHighContrastTheme, getCustomTheme, isHighContrastTheme } from "../../theme"; import { RovingAccessibleButton } from "../../accessibility/RovingTabIndex"; @@ -288,7 +288,7 @@ export default class UserMenu extends React.Component { ev.preventDefault(); ev.stopPropagation(); - if (await this.shouldShowLogoutDialog()) { + if (await shouldShowLogoutDialog(MatrixClientPeg.safeGet())) { Modal.createDialog(LogoutDialog); } else { defaultDispatcher.dispatch({ action: "logout" }); @@ -297,27 +297,6 @@ export default class UserMenu extends React.Component { this.setState({ contextMenuPosition: null }); // also close the menu }; - /** - * 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 - * help the user setup recovery properly if needed. - * @private - */ - private async shouldShowLogoutDialog(): Promise { - const cli = MatrixClientPeg.get(); - 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; - } - private onSignInClick = (): void => { defaultDispatcher.dispatch({ action: "start_login" }); this.setState({ contextMenuPosition: null }); // also close the menu diff --git a/src/components/views/dialogs/LogoutDialog.tsx b/src/components/views/dialogs/LogoutDialog.tsx index 253a43b0f05..3714a71e8cc 100644 --- a/src/components/views/dialogs/LogoutDialog.tsx +++ b/src/components/views/dialogs/LogoutDialog.tsx @@ -17,6 +17,7 @@ limitations under the License. import React from "react"; import { logger } from "matrix-js-sdk/src/logger"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; import type CreateKeyBackupDialog from "../../../async-components/views/dialogs/security/CreateKeyBackupDialog"; import type ExportE2eKeysDialog from "../../../async-components/views/dialogs/security/ExportE2eKeysDialog"; @@ -58,6 +59,25 @@ 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 + * help the user setup recovery properly if needed. + */ +export async function shouldShowLogoutDialog(cli: MatrixClient): Promise { + 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; +} + export default class LogoutDialog extends React.Component { public static defaultProps = { onFinished: function () {}, diff --git a/src/components/views/settings/UserProfileSettings.tsx b/src/components/views/settings/UserProfileSettings.tsx index a5ff4356767..a104aabb1d7 100644 --- a/src/components/views/settings/UserProfileSettings.tsx +++ b/src/components/views/settings/UserProfileSettings.tsx @@ -18,6 +18,7 @@ import React, { ChangeEvent, useCallback, useEffect, useMemo, useState } from "r import { logger } from "matrix-js-sdk/src/logger"; import { EditInPlace, Alert, ErrorMessage } from "@vector-im/compound-web"; import { Icon as PopOutIcon } from "@vector-im/compound-design-tokens/icons/pop-out.svg"; +import { Icon as SignOutIcon } from "@vector-im/compound-design-tokens/icons/sign-out.svg"; import { _t } from "../../../languageHandler"; import { OwnProfileStore } from "../../../stores/OwnProfileStore"; @@ -31,6 +32,10 @@ import { useId } from "../../../utils/useId"; import CopyableText from "../elements/CopyableText"; import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import AccessibleButton from "../elements/AccessibleButton"; +import LogoutDialog, { shouldShowLogoutDialog } from "../dialogs/LogoutDialog"; +import Modal from "../../../Modal"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import { Flex } from "../../utils/Flex"; const SpinnerToast: React.FC = ({ children }) => ( <> @@ -76,6 +81,25 @@ const ManageAccountButton: React.FC = ({ externalAccou ); +const SignOutButton: React.FC = () => { + const client = useMatrixClientContext(); + + const onClick = useCallback(async () => { + if (await shouldShowLogoutDialog(client)) { + Modal.createDialog(LogoutDialog); + } else { + defaultDispatcher.dispatch({ action: "logout" }); + } + }, [client]); + + return ( + + + {_t("action|sign_out")} + + ); +}; + interface UserProfileSettingsProps { // The URL to redirect the user to in order to manage their account. externalAccountManagementUrl?: string; @@ -219,11 +243,12 @@ const UserProfileSettings: React.FC = ({ )} {userIdentifier && } - {externalAccountManagementUrl && ( -
+ + {externalAccountManagementUrl && ( -
- )} + )} + + ); }; diff --git a/test/components/views/settings/UserProfileSettings-test.tsx b/test/components/views/settings/UserProfileSettings-test.tsx index 6b7f278e5a2..6aba71d81b0 100644 --- a/test/components/views/settings/UserProfileSettings-test.tsx +++ b/test/components/views/settings/UserProfileSettings-test.tsx @@ -18,12 +18,15 @@ import React, { ChangeEvent } from "react"; import { act, render, screen } from "@testing-library/react"; import { MatrixClient, UploadResponse } from "matrix-js-sdk/src/matrix"; import { mocked } from "jest-mock"; +import userEvent from "@testing-library/user-event"; import UserProfileSettings from "../../../../src/components/views/settings/UserProfileSettings"; -import { stubClient } from "../../../test-utils"; +import { mkStubRoom, stubClient } from "../../../test-utils"; import { ToastContext, ToastRack } from "../../../../src/contexts/ToastContext"; import { OwnProfileStore } from "../../../../src/stores/OwnProfileStore"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; +import dis from "../../../../src/dispatcher/dispatcher"; +import Modal from "../../../../src/Modal"; interface MockedAvatarSettingProps { removeAvatar: () => void; @@ -43,6 +46,11 @@ jest.mock( }) as React.FC, ); +jest.mock("../../../../src/dispatcher/dispatcher", () => ({ + dispatch: jest.fn(), + register: jest.fn(), +})); + let editInPlaceOnChange: (e: ChangeEvent) => void; let editInPlaceOnSave: () => void; let editInPlaceOnCancel: () => void; @@ -209,4 +217,30 @@ describe("ProfileSettings", () => { expect(await screen.findByText("Mocked EditInPlace: Alice")).toBeInTheDocument(); }); + + it("signs out directly if no rooms are encrypted", async () => { + renderProfileSettings(toastRack, client); + + const signOutButton = await screen.findByText("Sign out"); + await userEvent.click(signOutButton); + + expect(dis.dispatch).toHaveBeenCalledWith({ action: "logout" }); + }); + + it("displays confirmation dialog if rooms are encrypted", async () => { + jest.spyOn(Modal, "createDialog"); + + const mockRoom = mkStubRoom("!test:room", "Test Room", client); + client.getRooms = jest.fn().mockReturnValue([mockRoom]); + client.getCrypto = jest.fn().mockReturnValue({ + isEncryptionEnabledInRoom: jest.fn().mockReturnValue(true), + }); + + renderProfileSettings(toastRack, client); + + const signOutButton = await screen.findByText("Sign out"); + await userEvent.click(signOutButton); + + expect(Modal.createDialog).toHaveBeenCalled(); + }); });