diff --git a/src/components/views/settings/DevicesPanel.tsx b/src/components/views/settings/DevicesPanel.tsx index 5ae034d9fe..f32f7997fe 100644 --- a/src/components/views/settings/DevicesPanel.tsx +++ b/src/components/views/settings/DevicesPanel.tsx @@ -22,12 +22,10 @@ import { CrossSigningInfo } from "matrix-js-sdk/src/crypto/CrossSigning"; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { _t } from '../../../languageHandler'; -import Modal from '../../../Modal'; -import { SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents"; -import InteractiveAuthDialog from "../dialogs/InteractiveAuthDialog"; import DevicesPanelEntry from "./DevicesPanelEntry"; import Spinner from "../elements/Spinner"; import AccessibleButton from "../elements/AccessibleButton"; +import { deleteDevicesWithInteractiveAuth } from './devices/deleteDevices'; interface IProps { className?: string; @@ -79,7 +77,6 @@ export default class DevicesPanel extends React.Component { crossSigningInfo: crossSigningInfo, }; }); - console.log(this.state); }, (error) => { if (this.unmounted) { return; } @@ -178,76 +175,38 @@ export default class DevicesPanel extends React.Component { }); }; - private onDeleteClick = (): void => { + private onDeleteClick = async (): Promise => { if (this.state.selectedDevices.length === 0) { return; } this.setState({ deleting: true, }); - this.makeDeleteRequest(null).catch((error) => { - if (this.unmounted) { return; } - if (error.httpStatus !== 401 || !error.data || !error.data.flows) { - // doesn't look like an interactive-auth failure - throw error; - } - - // pop up an interactive auth dialog - - const numDevices = this.state.selectedDevices.length; - const dialogAesthetics = { - [SSOAuthEntry.PHASE_PREAUTH]: { - title: _t("Use Single Sign On to continue"), - body: _t("Confirm logging out these devices by using Single Sign On to prove your identity.", { - count: numDevices, - }), - continueText: _t("Single Sign On"), - continueKind: "primary", + try { + await deleteDevicesWithInteractiveAuth( + MatrixClientPeg.get(), + this.state.selectedDevices, + (success) => { + if (success) { + // Reset selection to [], update device list + this.setState({ + selectedDevices: [], + }); + this.loadDevices(); + } + this.setState({ + deleting: false, + }); }, - [SSOAuthEntry.PHASE_POSTAUTH]: { - title: _t("Confirm signing out these devices", { - count: numDevices, - }), - body: _t("Click the button below to confirm signing out these devices.", { - count: numDevices, - }), - continueText: _t("Sign out devices", { count: numDevices }), - continueKind: "danger", - }, - }; - Modal.createDialog(InteractiveAuthDialog, { - title: _t("Authentication"), - matrixClient: MatrixClientPeg.get(), - authData: error.data, - makeRequest: this.makeDeleteRequest.bind(this), - aestheticsForStagePhases: { - [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics, - [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics, - }, - }); - }).catch((e) => { - logger.error("Error deleting sessions", e); - if (this.unmounted) { return; } - }).finally(() => { + ); + } catch (error) { + logger.error("Error deleting sessions", error); this.setState({ deleting: false, }); - }); + } }; - // TODO: proper typing for auth - private makeDeleteRequest(auth?: any): Promise { - return MatrixClientPeg.get().deleteMultipleDevices(this.state.selectedDevices, auth).then( - () => { - // Reset selection to [], update device list - this.setState({ - selectedDevices: [], - }); - this.loadDevices(); - }, - ); - } - private renderDevice = (device: IMyDevice): JSX.Element => { const myDeviceId = MatrixClientPeg.get().getDeviceId(); const myDevice = this.state.devices.find((device) => (device.device_id === myDeviceId)); @@ -289,6 +248,7 @@ export default class DevicesPanel extends React.Component { const myDeviceId = MatrixClientPeg.get().getDeviceId(); const myDevice = devices.find((device) => (device.device_id === myDeviceId)); + if (!myDevice) { return loadError; } @@ -373,6 +333,7 @@ export default class DevicesPanel extends React.Component { onClick={this.onDeleteClick} kind="danger_outline" disabled={this.state.selectedDevices.length === 0} + data-testid='sign-out-devices-btn' > { _t("Sign out %(count)s selected devices", { count: this.state.selectedDevices.length }) } ; diff --git a/src/components/views/settings/devices/deleteDevices.tsx b/src/components/views/settings/devices/deleteDevices.tsx new file mode 100644 index 0000000000..8decacae78 --- /dev/null +++ b/src/components/views/settings/devices/deleteDevices.tsx @@ -0,0 +1,83 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { IAuthData } from "matrix-js-sdk/src/interactive-auth"; + +import { _t } from "../../../../languageHandler"; +import Modal from "../../../../Modal"; +import { InteractiveAuthCallback } from "../../../structures/InteractiveAuth"; +import { SSOAuthEntry } from "../../auth/InteractiveAuthEntryComponents"; +import InteractiveAuthDialog from "../../dialogs/InteractiveAuthDialog"; + +const makeDeleteRequest = ( + matrixClient: MatrixClient, deviceIds: string[], +) => async (auth?: IAuthData): Promise => { + await matrixClient.deleteMultipleDevices(deviceIds, auth); +}; + +export const deleteDevicesWithInteractiveAuth = async ( + matrixClient: MatrixClient, deviceIds: string[], onFinished?: InteractiveAuthCallback, +) => { + if (!deviceIds.length) { + return; + } + try { + await makeDeleteRequest(matrixClient, deviceIds)(); + // no interactive auth needed + onFinished(true, undefined); + } catch (error) { + if (error.httpStatus !== 401 || !error.data?.flows) { + // doesn't look like an interactive-auth failure + throw error; + } + + // pop up an interactive auth dialog + + const numDevices = deviceIds.length; + const dialogAesthetics = { + [SSOAuthEntry.PHASE_PREAUTH]: { + title: _t("Use Single Sign On to continue"), + body: _t("Confirm logging out these devices by using Single Sign On to prove your identity.", { + count: numDevices, + }), + continueText: _t("Single Sign On"), + continueKind: "primary", + }, + [SSOAuthEntry.PHASE_POSTAUTH]: { + title: _t("Confirm signing out these devices", { + count: numDevices, + }), + body: _t("Click the button below to confirm signing out these devices.", { + count: numDevices, + }), + continueText: _t("Sign out devices", { count: numDevices }), + continueKind: "danger", + }, + }; + Modal.createDialog(InteractiveAuthDialog, { + title: _t("Authentication"), + matrixClient: matrixClient, + authData: error.data, + onFinished, + makeRequest: makeDeleteRequest(matrixClient, deviceIds), + aestheticsForStagePhases: { + [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics, + [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics, + }, + }); + } +}; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index c15c81273c..4afa3fabe3 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1284,15 +1284,6 @@ "Session key:": "Session key:", "Your homeserver does not support device management.": "Your homeserver does not support device management.", "Unable to load device list": "Unable to load device list", - "Confirm logging out these devices by using Single Sign On to prove your identity.|other": "Confirm logging out these devices by using Single Sign On to prove your identity.", - "Confirm logging out these devices by using Single Sign On to prove your identity.|one": "Confirm logging out this device by using Single Sign On to prove your identity.", - "Confirm signing out these devices|other": "Confirm signing out these devices", - "Confirm signing out these devices|one": "Confirm signing out this device", - "Click the button below to confirm signing out these devices.|other": "Click the button below to confirm signing out these devices.", - "Click the button below to confirm signing out these devices.|one": "Click the button below to confirm signing out this device.", - "Sign out devices|other": "Sign out devices", - "Sign out devices|one": "Sign out device", - "Authentication": "Authentication", "Deselect all": "Deselect all", "Select all": "Select all", "Verified devices": "Verified devices", @@ -1692,6 +1683,15 @@ "Please enter verification code sent via text.": "Please enter verification code sent via text.", "Verification code": "Verification code", "Discovery options will appear once you have added a phone number above.": "Discovery options will appear once you have added a phone number above.", + "Confirm logging out these devices by using Single Sign On to prove your identity.|other": "Confirm logging out these devices by using Single Sign On to prove your identity.", + "Confirm logging out these devices by using Single Sign On to prove your identity.|one": "Confirm logging out this device by using Single Sign On to prove your identity.", + "Confirm signing out these devices|other": "Confirm signing out these devices", + "Confirm signing out these devices|one": "Confirm signing out this device", + "Click the button below to confirm signing out these devices.|other": "Click the button below to confirm signing out these devices.", + "Click the button below to confirm signing out these devices.|one": "Click the button below to confirm signing out this device.", + "Sign out devices|other": "Sign out devices", + "Sign out devices|one": "Sign out device", + "Authentication": "Authentication", "Last activity": "Last activity", "Verified": "Verified", "Unverified": "Unverified", diff --git a/test/components/views/settings/DevicesPanel-test.tsx b/test/components/views/settings/DevicesPanel-test.tsx new file mode 100644 index 0000000000..e03274c0ae --- /dev/null +++ b/test/components/views/settings/DevicesPanel-test.tsx @@ -0,0 +1,203 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; +import { CrossSigningInfo } from 'matrix-js-sdk/src/crypto/CrossSigning'; +import { DeviceInfo } from 'matrix-js-sdk/src/crypto/deviceinfo'; +import { sleep } from 'matrix-js-sdk/src/utils'; + +import DevicesPanel from "../../../../src/components/views/settings/DevicesPanel"; +import { + flushPromises, + getMockClientWithEventEmitter, + mockClientMethodsUser, +} from "../../../test-utils"; + +describe('', () => { + const userId = '@alice:server.org'; + const device1 = { device_id: 'device_1' }; + const device2 = { device_id: 'device_2' }; + const device3 = { device_id: 'device_3' }; + const mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(userId), + getDevices: jest.fn(), + getDeviceId: jest.fn().mockReturnValue(device1.device_id), + deleteMultipleDevices: jest.fn(), + getStoredCrossSigningForUser: jest.fn().mockReturnValue(new CrossSigningInfo(userId, {}, {})), + getStoredDevice: jest.fn().mockReturnValue(new DeviceInfo('id')), + generateClientSecret: jest.fn(), + }); + + const getComponent = () => ; + + beforeEach(() => { + jest.clearAllMocks(); + + mockClient.getDevices + .mockReset() + .mockResolvedValue({ devices: [device1, device2, device3] }); + }); + + it('renders device panel with devices', async () => { + const { container } = render(getComponent()); + await flushPromises(); + expect(container).toMatchSnapshot(); + }); + + describe('device deletion', () => { + const interactiveAuthError = { httpStatus: 401, data: { flows: [{ stages: ["m.login.password"] }] } }; + + const toggleDeviceSelection = (container: HTMLElement, deviceId: string) => act(() => { + const checkbox = container.querySelector(`#device-tile-checkbox-${deviceId}`); + fireEvent.click(checkbox); + }); + + beforeEach(() => { + mockClient.deleteMultipleDevices.mockReset(); + }); + + it('deletes selected devices when interactive auth is not required', async () => { + mockClient.deleteMultipleDevices.mockResolvedValue({}); + mockClient.getDevices + .mockResolvedValueOnce({ devices: [device1, device2, device3] }) + // pretend it was really deleted on refresh + .mockResolvedValueOnce({ devices: [device1, device3] }); + + const { container, getByTestId } = render(getComponent()); + await flushPromises(); + + expect(container.getElementsByClassName('mx_DevicesPanel_device').length).toEqual(3); + + toggleDeviceSelection(container, device2.device_id); + + mockClient.getDevices.mockClear(); + + act(() => { + fireEvent.click(getByTestId('sign-out-devices-btn')); + }); + + expect(container.getElementsByClassName('mx_Spinner').length).toBeTruthy(); + expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith([device2.device_id], undefined); + + await flushPromises(); + + // devices refreshed + expect(mockClient.getDevices).toHaveBeenCalled(); + // and rerendered + expect(container.getElementsByClassName('mx_DevicesPanel_device').length).toEqual(2); + }); + + it('deletes selected devices when interactive auth is required', async () => { + mockClient.deleteMultipleDevices + // require auth + .mockRejectedValueOnce(interactiveAuthError) + // then succeed + .mockResolvedValueOnce({}); + + mockClient.getDevices + .mockResolvedValueOnce({ devices: [device1, device2, device3] }) + // pretend it was really deleted on refresh + .mockResolvedValueOnce({ devices: [device1, device3] }); + + const { container, getByTestId, getByLabelText } = render(getComponent()); + + await flushPromises(); + + // reset mock count after initial load + mockClient.getDevices.mockClear(); + + toggleDeviceSelection(container, device2.device_id); + + act(() => { + fireEvent.click(getByTestId('sign-out-devices-btn')); + }); + + await flushPromises(); + // modal rendering has some weird sleeps + await sleep(10); + + expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith([device2.device_id], undefined); + + const modal = document.getElementsByClassName('mx_Dialog'); + expect(modal).toMatchSnapshot(); + + // fill password and submit for interactive auth + act(() => { + fireEvent.change(getByLabelText('Password'), { target: { value: 'topsecret' } }); + fireEvent.submit(getByLabelText('Password')); + }); + + await flushPromises(); + + // called again with auth + expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith([device2.device_id], + { identifier: { + type: "m.id.user", user: userId, + }, password: "", type: "m.login.password", user: userId, + }); + // devices refreshed + expect(mockClient.getDevices).toHaveBeenCalled(); + // and rerendered + expect(container.getElementsByClassName('mx_DevicesPanel_device').length).toEqual(2); + }); + + it('clears loading state when interactive auth fail is cancelled', async () => { + mockClient.deleteMultipleDevices + // require auth + .mockRejectedValueOnce(interactiveAuthError) + // then succeed + .mockResolvedValueOnce({}); + + mockClient.getDevices + .mockResolvedValueOnce({ devices: [device1, device2, device3] }) + // pretend it was really deleted on refresh + .mockResolvedValueOnce({ devices: [device1, device3] }); + + const { container, getByTestId } = render(getComponent()); + + await flushPromises(); + + // reset mock count after initial load + mockClient.getDevices.mockClear(); + + toggleDeviceSelection(container, device2.device_id); + + act(() => { + fireEvent.click(getByTestId('sign-out-devices-btn')); + }); + + expect(container.getElementsByClassName('mx_Spinner').length).toBeTruthy(); + + await flushPromises(); + // modal rendering has some weird sleeps + await sleep(10); + + // close the modal without submission + act(() => { + const modalCloseButton = document.querySelector('[aria-label="Close dialog"]'); + fireEvent.click(modalCloseButton); + }); + + await flushPromises(); + + // not refreshed + expect(mockClient.getDevices).not.toHaveBeenCalled(); + // spinner removed + expect(container.getElementsByClassName('mx_Spinner').length).toBeFalsy(); + }); + }); +}); diff --git a/test/components/views/settings/__snapshots__/DevicesPanel-test.tsx.snap b/test/components/views/settings/__snapshots__/DevicesPanel-test.tsx.snap new file mode 100644 index 0000000000..5d967f6d2a --- /dev/null +++ b/test/components/views/settings/__snapshots__/DevicesPanel-test.tsx.snap @@ -0,0 +1,306 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` device deletion deletes selected devices when interactive auth is required 1`] = ` +HTMLCollection [ +
+
+