Add sign out button to settings profile section (#12666)

* Add sign out button to settings profile section

And move the logic for displaying the dialog out of the user menu
to somewhere it can be re-used.

Also close any open dialog on logout, because otherwise, well... you
can guess.

* Missing import

* Update screenshot

* This button doesn't need to be an anchor

* Use Flex component

* Use new force-close function

* More tests
pull/24763/head
David Baker 2024-07-29 13:53:44 +01:00 committed by GitHub
parent 844da7a656
commit c2c108957e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 86 additions and 28 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View File

@ -27,7 +27,7 @@ import { UserTab } from "../views/dialogs/UserTab";
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
import FeedbackDialog from "../views/dialogs/FeedbackDialog"; import FeedbackDialog from "../views/dialogs/FeedbackDialog";
import Modal from "../../Modal"; import Modal from "../../Modal";
import LogoutDialog from "../views/dialogs/LogoutDialog"; import LogoutDialog, { shouldShowLogoutDialog } from "../views/dialogs/LogoutDialog";
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import { findHighContrastTheme, getCustomTheme, isHighContrastTheme } from "../../theme"; import { findHighContrastTheme, getCustomTheme, isHighContrastTheme } from "../../theme";
import { RovingAccessibleButton } from "../../accessibility/RovingTabIndex"; import { RovingAccessibleButton } from "../../accessibility/RovingTabIndex";
@ -288,7 +288,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
if (await this.shouldShowLogoutDialog()) { if (await shouldShowLogoutDialog(MatrixClientPeg.safeGet())) {
Modal.createDialog(LogoutDialog); Modal.createDialog(LogoutDialog);
} else { } else {
defaultDispatcher.dispatch({ action: "logout" }); defaultDispatcher.dispatch({ action: "logout" });
@ -297,27 +297,6 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.setState({ contextMenuPosition: null }); // also close the menu 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<boolean> {
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 => { private onSignInClick = (): void => {
defaultDispatcher.dispatch({ action: "start_login" }); defaultDispatcher.dispatch({ action: "start_login" });
this.setState({ contextMenuPosition: null }); // also close the menu this.setState({ contextMenuPosition: null }); // also close the menu

View File

@ -17,6 +17,7 @@ limitations under the License.
import React from "react"; import React from "react";
import { logger } from "matrix-js-sdk/src/logger"; 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 CreateKeyBackupDialog from "../../../async-components/views/dialogs/security/CreateKeyBackupDialog";
import type ExportE2eKeysDialog from "../../../async-components/views/dialogs/security/ExportE2eKeysDialog"; import type ExportE2eKeysDialog from "../../../async-components/views/dialogs/security/ExportE2eKeysDialog";
@ -58,6 +59,25 @@ interface IState {
backupStatus: BackupStatus; 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<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;
}
export default class LogoutDialog extends React.Component<IProps, IState> { export default class LogoutDialog extends React.Component<IProps, IState> {
public static defaultProps = { public static defaultProps = {
onFinished: function () {}, onFinished: function () {},

View File

@ -18,6 +18,7 @@ import React, { ChangeEvent, useCallback, useEffect, useMemo, useState } from "r
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { EditInPlace, Alert, ErrorMessage } from "@vector-im/compound-web"; 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 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 { _t } from "../../../languageHandler";
import { OwnProfileStore } from "../../../stores/OwnProfileStore"; import { OwnProfileStore } from "../../../stores/OwnProfileStore";
@ -31,6 +32,10 @@ import { useId } from "../../../utils/useId";
import CopyableText from "../elements/CopyableText"; import CopyableText from "../elements/CopyableText";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import AccessibleButton from "../elements/AccessibleButton"; 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 }) => ( const SpinnerToast: React.FC = ({ children }) => (
<> <>
@ -76,6 +81,25 @@ const ManageAccountButton: React.FC<ManageAccountButtonProps> = ({ externalAccou
</AccessibleButton> </AccessibleButton>
); );
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 (
<AccessibleButton onClick={onClick} kind="danger_outline">
<SignOutIcon className="mx_UserProfileSettings_accountmanageIcon" width="24" height="24" />
{_t("action|sign_out")}
</AccessibleButton>
);
};
interface UserProfileSettingsProps { interface UserProfileSettingsProps {
// The URL to redirect the user to in order to manage their account. // The URL to redirect the user to in order to manage their account.
externalAccountManagementUrl?: string; externalAccountManagementUrl?: string;
@ -219,11 +243,12 @@ const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({
</Alert> </Alert>
)} )}
{userIdentifier && <UsernameBox username={userIdentifier} />} {userIdentifier && <UsernameBox username={userIdentifier} />}
<Flex gap="var(--cpd-space-4x)" className="mx_UserProfileSettings_profile_buttons">
{externalAccountManagementUrl && ( {externalAccountManagementUrl && (
<div className="mx_UserProfileSettings_profile_buttons">
<ManageAccountButton externalAccountManagementUrl={externalAccountManagementUrl} /> <ManageAccountButton externalAccountManagementUrl={externalAccountManagementUrl} />
</div>
)} )}
<SignOutButton />
</Flex>
</div> </div>
); );
}; };

View File

@ -18,12 +18,15 @@ import React, { ChangeEvent } from "react";
import { act, render, screen } from "@testing-library/react"; import { act, render, screen } from "@testing-library/react";
import { MatrixClient, UploadResponse } from "matrix-js-sdk/src/matrix"; import { MatrixClient, UploadResponse } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
import userEvent from "@testing-library/user-event";
import UserProfileSettings from "../../../../src/components/views/settings/UserProfileSettings"; 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 { ToastContext, ToastRack } from "../../../../src/contexts/ToastContext";
import { OwnProfileStore } from "../../../../src/stores/OwnProfileStore"; import { OwnProfileStore } from "../../../../src/stores/OwnProfileStore";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import dis from "../../../../src/dispatcher/dispatcher";
import Modal from "../../../../src/Modal";
interface MockedAvatarSettingProps { interface MockedAvatarSettingProps {
removeAvatar: () => void; removeAvatar: () => void;
@ -43,6 +46,11 @@ jest.mock(
}) as React.FC<MockedAvatarSettingProps>, }) as React.FC<MockedAvatarSettingProps>,
); );
jest.mock("../../../../src/dispatcher/dispatcher", () => ({
dispatch: jest.fn(),
register: jest.fn(),
}));
let editInPlaceOnChange: (e: ChangeEvent<HTMLInputElement>) => void; let editInPlaceOnChange: (e: ChangeEvent<HTMLInputElement>) => void;
let editInPlaceOnSave: () => void; let editInPlaceOnSave: () => void;
let editInPlaceOnCancel: () => void; let editInPlaceOnCancel: () => void;
@ -209,4 +217,30 @@ describe("ProfileSettings", () => {
expect(await screen.findByText("Mocked EditInPlace: Alice")).toBeInTheDocument(); 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();
});
}); });