diff --git a/res/css/components/views/settings/devices/_DeviceDetails.pcss b/res/css/components/views/settings/devices/_DeviceDetails.pcss index d53dcee02b..47766cec45 100644 --- a/res/css/components/views/settings/devices/_DeviceDetails.pcss +++ b/res/css/components/views/settings/devices/_DeviceDetails.pcss @@ -73,3 +73,10 @@ limitations under the License. color: $primary-content; } } + +.mx_DeviceDetails_signOutButtonContent { + display: flex; + flex-direction: row; + align-items: center; + gap: $spacing-4; +} diff --git a/src/components/views/settings/devices/CurrentDeviceSection.tsx b/src/components/views/settings/devices/CurrentDeviceSection.tsx index 8db70aa2b1..e720b47ede 100644 --- a/src/components/views/settings/devices/CurrentDeviceSection.tsx +++ b/src/components/views/settings/devices/CurrentDeviceSection.tsx @@ -28,6 +28,7 @@ import { DeviceWithVerification } from './types'; interface Props { device?: DeviceWithVerification; isLoading: boolean; + isSigningOut: boolean; onVerifyCurrentDevice: () => void; onSignOutCurrentDevice: () => void; } @@ -35,6 +36,7 @@ interface Props { const CurrentDeviceSection: React.FC<Props> = ({ device, isLoading, + isSigningOut, onVerifyCurrentDevice, onSignOutCurrentDevice, }) => { @@ -58,6 +60,7 @@ const CurrentDeviceSection: React.FC<Props> = ({ { isExpanded && <DeviceDetails device={device} + isSigningOut={isSigningOut} onSignOutDevice={onSignOutCurrentDevice} /> } diff --git a/src/components/views/settings/devices/DeviceDetails.tsx b/src/components/views/settings/devices/DeviceDetails.tsx index 48c32ad1a7..c773e2cfdb 100644 --- a/src/components/views/settings/devices/DeviceDetails.tsx +++ b/src/components/views/settings/devices/DeviceDetails.tsx @@ -19,16 +19,16 @@ import React from 'react'; import { formatDate } from '../../../../DateUtils'; import { _t } from '../../../../languageHandler'; import AccessibleButton from '../../elements/AccessibleButton'; +import Spinner from '../../elements/Spinner'; import Heading from '../../typography/Heading'; import { DeviceVerificationStatusCard } from './DeviceVerificationStatusCard'; import { DeviceWithVerification } from './types'; interface Props { device: DeviceWithVerification; + isSigningOut: boolean; onVerifyDevice?: () => void; - // @TODO(kerry) optional while signout only implemented - // for current device (PSG-744) - onSignOutDevice?: () => void; + onSignOutDevice: () => void; } interface MetadataTable { @@ -38,6 +38,7 @@ interface MetadataTable { const DeviceDetails: React.FC<Props> = ({ device, + isSigningOut, onVerifyDevice, onSignOutDevice, }) => { @@ -87,15 +88,19 @@ const DeviceDetails: React.FC<Props> = ({ </table>, ) } </section> - { !!onSignOutDevice && <section className='mx_DeviceDetails_section'> + <section className='mx_DeviceDetails_section'> <AccessibleButton onClick={onSignOutDevice} kind='danger_inline' + disabled={isSigningOut} data-testid='device-detail-sign-out-cta' > - { _t('Sign out of this session') } + <span className='mx_DeviceDetails_signOutButtonContent'> + { _t('Sign out of this session') } + { isSigningOut && <Spinner w={16} h={16} /> } + </span> </AccessibleButton> - </section> } + </section> </div>; }; diff --git a/src/components/views/settings/devices/FilteredDeviceList.tsx b/src/components/views/settings/devices/FilteredDeviceList.tsx index 4ce3e6e7da..74f3f5eebf 100644 --- a/src/components/views/settings/devices/FilteredDeviceList.tsx +++ b/src/components/views/settings/devices/FilteredDeviceList.tsx @@ -36,9 +36,11 @@ import { interface Props { devices: DevicesDictionary; expandedDeviceIds: DeviceWithVerification['device_id'][]; + signingOutDeviceIds: DeviceWithVerification['device_id'][]; filter?: DeviceSecurityVariation; onFilterChange: (filter: DeviceSecurityVariation | undefined) => void; onDeviceExpandToggle: (deviceId: DeviceWithVerification['device_id']) => void; + onSignOutDevices: (deviceIds: DeviceWithVerification['device_id'][]) => void; onRequestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => void; } @@ -132,10 +134,16 @@ const NoResults: React.FC<NoResultsProps> = ({ filter, clearFilter }) => const DeviceListItem: React.FC<{ device: DeviceWithVerification; isExpanded: boolean; + isSigningOut: boolean; onDeviceExpandToggle: () => void; + onSignOutDevice: () => void; onRequestDeviceVerification?: () => void; }> = ({ - device, isExpanded, onDeviceExpandToggle, + device, + isExpanded, + isSigningOut, + onDeviceExpandToggle, + onSignOutDevice, onRequestDeviceVerification, }) => <li className='mx_FilteredDeviceList_listItem'> <DeviceTile @@ -146,7 +154,15 @@ const DeviceListItem: React.FC<{ onClick={onDeviceExpandToggle} /> </DeviceTile> - { isExpanded && <DeviceDetails device={device} onVerifyDevice={onRequestDeviceVerification} /> } + { + isExpanded && + <DeviceDetails + device={device} + isSigningOut={isSigningOut} + onVerifyDevice={onRequestDeviceVerification} + onSignOutDevice={onSignOutDevice} + /> + } </li>; /** @@ -158,8 +174,10 @@ export const FilteredDeviceList = devices, filter, expandedDeviceIds, + signingOutDeviceIds, onFilterChange, onDeviceExpandToggle, + onSignOutDevices, onRequestDeviceVerification, }: Props, ref: ForwardedRef<HTMLDivElement>) => { const sortedDevices = getFilteredSortedDevices(devices, filter); @@ -213,7 +231,9 @@ export const FilteredDeviceList = key={device.device_id} device={device} isExpanded={expandedDeviceIds.includes(device.device_id)} + isSigningOut={signingOutDeviceIds.includes(device.device_id)} onDeviceExpandToggle={() => onDeviceExpandToggle(device.device_id)} + onSignOutDevice={() => onSignOutDevices([device.device_id])} onRequestDeviceVerification={ onRequestDeviceVerification ? () => onRequestDeviceVerification(device.device_id) diff --git a/src/components/views/settings/devices/useOwnDevices.ts b/src/components/views/settings/devices/useOwnDevices.ts index 1a4b1e6bb2..b4e3391860 100644 --- a/src/components/views/settings/devices/useOwnDevices.ts +++ b/src/components/views/settings/devices/useOwnDevices.ts @@ -18,7 +18,6 @@ import { useCallback, useContext, useEffect, useState } from "react"; import { IMyDevice, MatrixClient } from "matrix-js-sdk/src/matrix"; import { CrossSigningInfo } from "matrix-js-sdk/src/crypto/CrossSigning"; import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; -import { User } from "matrix-js-sdk/src/models/user"; import { MatrixError } from "matrix-js-sdk/src/http-api"; import { logger } from "matrix-js-sdk/src/logger"; @@ -74,10 +73,9 @@ export enum OwnDevicesError { Unsupported = 'Unsupported', Default = 'Default', } -type DevicesState = { +export type DevicesState = { devices: DevicesDictionary; currentDeviceId: string; - currentUserMember?: User; isLoading: boolean; // not provided when current session cannot request verification requestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => Promise<VerificationRequest>; @@ -135,7 +133,6 @@ export const useOwnDevices = (): DevicesState => { return { devices, currentDeviceId, - currentUserMember: userId && matrixClient.getUser(userId) || undefined, requestDeviceVerification, refreshDevices, isLoading, diff --git a/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/src/components/views/settings/tabs/user/SessionManagerTab.tsx index 14521f84da..0b2056b63d 100644 --- a/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/src/components/views/settings/tabs/user/SessionManagerTab.tsx @@ -14,10 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { MatrixClient } from 'matrix-js-sdk/src/client'; +import { logger } from 'matrix-js-sdk/src/logger'; import { _t } from "../../../../../languageHandler"; -import { useOwnDevices } from '../../devices/useOwnDevices'; +import { DevicesState, useOwnDevices } from '../../devices/useOwnDevices'; import SettingsSubsection from '../../shared/SettingsSubsection'; import { FilteredDeviceList } from '../../devices/FilteredDeviceList'; import CurrentDeviceSection from '../../devices/CurrentDeviceSection'; @@ -28,12 +30,64 @@ import Modal from '../../../../../Modal'; import SetupEncryptionDialog from '../../../dialogs/security/SetupEncryptionDialog'; import VerificationRequestDialog from '../../../dialogs/VerificationRequestDialog'; import LogoutDialog from '../../../dialogs/LogoutDialog'; +import MatrixClientContext from '../../../../../contexts/MatrixClientContext'; +import { deleteDevicesWithInteractiveAuth } from '../../devices/deleteDevices'; + +const useSignOut = ( + matrixClient: MatrixClient, + refreshDevices: DevicesState['refreshDevices'], +): { + onSignOutCurrentDevice: () => void; + onSignOutOtherDevices: (deviceIds: DeviceWithVerification['device_id'][]) => Promise<void>; + signingOutDeviceIds: DeviceWithVerification['device_id'][]; + } => { + const [signingOutDeviceIds, setSigningOutDeviceIds] = useState<DeviceWithVerification['device_id'][]>([]); + + const onSignOutCurrentDevice = () => { + Modal.createDialog( + LogoutDialog, + {}, // props, + undefined, // className + false, // isPriority + true, // isStatic + ); + }; + + const onSignOutOtherDevices = async (deviceIds: DeviceWithVerification['device_id'][]) => { + if (!deviceIds.length) { + return; + } + try { + setSigningOutDeviceIds([...signingOutDeviceIds, ...deviceIds]); + await deleteDevicesWithInteractiveAuth( + matrixClient, + deviceIds, + async (success) => { + if (success) { + // @TODO(kerrya) clear selection if was bulk deletion + // when added in PSG-659 + await refreshDevices(); + } + setSigningOutDeviceIds(signingOutDeviceIds.filter(deviceId => !deviceIds.includes(deviceId))); + }, + ); + } catch (error) { + logger.error("Error deleting sessions", error); + setSigningOutDeviceIds(signingOutDeviceIds.filter(deviceId => !deviceIds.includes(deviceId))); + } + }; + + return { + onSignOutCurrentDevice, + onSignOutOtherDevices, + signingOutDeviceIds, + }; +}; const SessionManagerTab: React.FC = () => { const { devices, currentDeviceId, - currentUserMember, isLoading, requestDeviceVerification, refreshDevices, @@ -43,6 +97,10 @@ const SessionManagerTab: React.FC = () => { const filteredDeviceListRef = useRef<HTMLDivElement>(null); const scrollIntoViewTimeoutRef = useRef<ReturnType<typeof setTimeout>>(); + const matrixClient = useContext(MatrixClientContext); + const userId = matrixClient.getUserId(); + const currentUserMember = userId && matrixClient.getUser(userId) || undefined; + const onDeviceExpandToggle = (deviceId: DeviceWithVerification['device_id']): void => { if (expandedDeviceIds.includes(deviceId)) { setExpandedDeviceIds(expandedDeviceIds.filter(id => id !== deviceId)); @@ -91,14 +149,11 @@ const SessionManagerTab: React.FC = () => { }); }, [requestDeviceVerification, refreshDevices, currentUserMember]); - const onSignOutCurrentDevice = () => { - if (!currentDevice) { - return; - } - Modal.createDialog(LogoutDialog, - /* props= */{}, /* className= */undefined, - /* isPriority= */false, /* isStatic= */true); - }; + const { + onSignOutCurrentDevice, + onSignOutOtherDevices, + signingOutDeviceIds, + } = useSignOut(matrixClient, refreshDevices); useEffect(() => () => { clearTimeout(scrollIntoViewTimeoutRef.current); @@ -113,6 +168,7 @@ const SessionManagerTab: React.FC = () => { <CurrentDeviceSection device={currentDevice} isLoading={isLoading} + isSigningOut={signingOutDeviceIds.includes(currentDevice?.device_id)} onVerifyCurrentDevice={onVerifyCurrentDevice} onSignOutCurrentDevice={onSignOutCurrentDevice} /> @@ -130,9 +186,11 @@ const SessionManagerTab: React.FC = () => { devices={otherDevices} filter={filter} expandedDeviceIds={expandedDeviceIds} + signingOutDeviceIds={signingOutDeviceIds} onFilterChange={setFilter} onDeviceExpandToggle={onDeviceExpandToggle} onRequestDeviceVerification={requestDeviceVerification ? onTriggerDeviceVerification : undefined} + onSignOutDevices={onSignOutOtherDevices} ref={filteredDeviceListRef} /> </SettingsSubsection> diff --git a/test/components/views/settings/devices/CurrentDeviceSection-test.tsx b/test/components/views/settings/devices/CurrentDeviceSection-test.tsx index 1fd6b2ec43..a63d96fa07 100644 --- a/test/components/views/settings/devices/CurrentDeviceSection-test.tsx +++ b/test/components/views/settings/devices/CurrentDeviceSection-test.tsx @@ -37,6 +37,7 @@ describe('<CurrentDeviceSection />', () => { onVerifyCurrentDevice: jest.fn(), onSignOutCurrentDevice: jest.fn(), isLoading: false, + isSigningOut: false, }; const getComponent = (props = {}): React.ReactElement => (<CurrentDeviceSection {...defaultProps} {...props} />); diff --git a/test/components/views/settings/devices/DeviceDetails-test.tsx b/test/components/views/settings/devices/DeviceDetails-test.tsx index 95897f69a1..272f781758 100644 --- a/test/components/views/settings/devices/DeviceDetails-test.tsx +++ b/test/components/views/settings/devices/DeviceDetails-test.tsx @@ -26,6 +26,8 @@ describe('<DeviceDetails />', () => { }; const defaultProps = { device: baseDevice, + isSigningOut: false, + onSignOutDevice: jest.fn(), }; const getComponent = (props = {}) => <DeviceDetails {...defaultProps} {...props} />; // 14.03.2022 16:15 @@ -60,4 +62,14 @@ describe('<DeviceDetails />', () => { const { container } = render(getComponent({ device })); expect(container).toMatchSnapshot(); }); + + it('disables sign out button while sign out is pending', () => { + const device = { + ...baseDevice, + }; + const { getByTestId } = render(getComponent({ device, isSigningOut: true })); + expect( + getByTestId('device-detail-sign-out-cta').getAttribute('aria-disabled'), + ).toEqual("true"); + }); }); diff --git a/test/components/views/settings/devices/FilteredDeviceList-test.tsx b/test/components/views/settings/devices/FilteredDeviceList-test.tsx index ef5ae5b18b..02cff73222 100644 --- a/test/components/views/settings/devices/FilteredDeviceList-test.tsx +++ b/test/components/views/settings/devices/FilteredDeviceList-test.tsx @@ -43,7 +43,9 @@ describe('<FilteredDeviceList />', () => { const defaultProps = { onFilterChange: jest.fn(), onDeviceExpandToggle: jest.fn(), + onSignOutDevices: jest.fn(), expandedDeviceIds: [], + signingOutDeviceIds: [], devices: { [unverifiedNoMetadata.device_id]: unverifiedNoMetadata, [verifiedNoMetadata.device_id]: verifiedNoMetadata, diff --git a/test/components/views/settings/devices/__snapshots__/CurrentDeviceSection-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/CurrentDeviceSection-test.tsx.snap index 5215e7f208..a3c9d7de2b 100644 --- a/test/components/views/settings/devices/__snapshots__/CurrentDeviceSection-test.tsx.snap +++ b/test/components/views/settings/devices/__snapshots__/CurrentDeviceSection-test.tsx.snap @@ -110,7 +110,11 @@ HTMLCollection [ role="button" tabindex="0" > - Sign out of this session + <span + class="mx_DeviceDetails_signOutButtonContent" + > + Sign out of this session + </span> </div> </section> </div>, diff --git a/test/components/views/settings/devices/__snapshots__/DeviceDetails-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/DeviceDetails-test.tsx.snap index 4a3d7afade..6de93f65af 100644 --- a/test/components/views/settings/devices/__snapshots__/DeviceDetails-test.tsx.snap +++ b/test/components/views/settings/devices/__snapshots__/DeviceDetails-test.tsx.snap @@ -101,6 +101,22 @@ exports[`<DeviceDetails /> renders a verified device 1`] = ` </tbody> </table> </section> + <section + class="mx_DeviceDetails_section" + > + <div + class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger_inline" + data-testid="device-detail-sign-out-cta" + role="button" + tabindex="0" + > + <span + class="mx_DeviceDetails_signOutButtonContent" + > + Sign out of this session + </span> + </div> + </section> </div> </div> `; @@ -210,6 +226,22 @@ exports[`<DeviceDetails /> renders device with metadata 1`] = ` </tbody> </table> </section> + <section + class="mx_DeviceDetails_section" + > + <div + class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger_inline" + data-testid="device-detail-sign-out-cta" + role="button" + tabindex="0" + > + <span + class="mx_DeviceDetails_signOutButtonContent" + > + Sign out of this session + </span> + </div> + </section> </div> </div> `; @@ -315,6 +347,22 @@ exports[`<DeviceDetails /> renders device without metadata 1`] = ` </tbody> </table> </section> + <section + class="mx_DeviceDetails_section" + > + <div + class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger_inline" + data-testid="device-detail-sign-out-cta" + role="button" + tabindex="0" + > + <span + class="mx_DeviceDetails_signOutButtonContent" + > + Sign out of this session + </span> + </div> + </section> </div> </div> `; diff --git a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx index ee538b27dd..4e0a556818 100644 --- a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx +++ b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx @@ -21,6 +21,7 @@ import { DeviceInfo } from 'matrix-js-sdk/src/crypto/deviceinfo'; import { logger } from 'matrix-js-sdk/src/logger'; import { DeviceTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning'; import { VerificationRequest } from 'matrix-js-sdk/src/crypto/verification/request/VerificationRequest'; +import { sleep } from 'matrix-js-sdk/src/utils'; import SessionManagerTab from '../../../../../../src/components/views/settings/tabs/user/SessionManagerTab'; import MatrixClientContext from '../../../../../../src/contexts/MatrixClientContext'; @@ -31,8 +32,7 @@ import { } from '../../../../../test-utils'; import Modal from '../../../../../../src/Modal'; import LogoutDialog from '../../../../../../src/components/views/dialogs/LogoutDialog'; - -jest.useFakeTimers(); +import { DeviceWithVerification } from '../../../../../../src/components/views/settings/devices/types'; describe('<SessionManagerTab />', () => { const aliceId = '@alice:server.org'; @@ -62,6 +62,8 @@ describe('<SessionManagerTab />', () => { getStoredDevice: jest.fn(), getDeviceId: jest.fn().mockReturnValue(deviceId), requestVerification: jest.fn().mockResolvedValue(mockVerificationRequest), + deleteMultipleDevices: jest.fn(), + generateClientSecret: jest.fn(), }); const defaultProps = {}; @@ -72,6 +74,16 @@ describe('<SessionManagerTab />', () => { </MatrixClientContext.Provider> ); + const toggleDeviceDetails = ( + getByTestId: ReturnType<typeof render>['getByTestId'], + deviceId: DeviceWithVerification['device_id'], + ) => { + // open device detail + const tile = getByTestId(`device-tile-${deviceId}`); + const toggle = tile.querySelector('[aria-label="Toggle device details"]') as Element; + fireEvent.click(toggle); + }; + beforeEach(() => { jest.clearAllMocks(); jest.spyOn(logger, 'error').mockRestore(); @@ -83,6 +95,10 @@ describe('<SessionManagerTab />', () => { mockCrossSigningInfo.checkDeviceTrust .mockReset() .mockReturnValue(new DeviceTrustLevel(false, false, false, false)); + + mockClient.getDevices + .mockReset() + .mockResolvedValue({ devices: [alicesMobileDevice] }); }); it('renders spinner while devices load', () => { @@ -257,24 +273,18 @@ describe('<SessionManagerTab />', () => { await flushPromisesWithFakeTimers(); }); - const tile1 = getByTestId(`device-tile-${alicesOlderMobileDevice.device_id}`); - const toggle1 = tile1.querySelector('[aria-label="Toggle device details"]') as Element; - fireEvent.click(toggle1); + toggleDeviceDetails(getByTestId, alicesOlderMobileDevice.device_id); // device details are expanded expect(getByTestId(`device-detail-${alicesOlderMobileDevice.device_id}`)).toBeTruthy(); - const tile2 = getByTestId(`device-tile-${alicesMobileDevice.device_id}`); - const toggle2 = tile2.querySelector('[aria-label="Toggle device details"]') as Element; - fireEvent.click(toggle2); + toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id); // both device details are expanded expect(getByTestId(`device-detail-${alicesOlderMobileDevice.device_id}`)).toBeTruthy(); expect(getByTestId(`device-detail-${alicesMobileDevice.device_id}`)).toBeTruthy(); - const tile3 = getByTestId(`device-tile-${alicesMobileDevice.device_id}`); - const toggle3 = tile3.querySelector('[aria-label="Toggle device details"]') as Element; - fireEvent.click(toggle3); + toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id); // alicesMobileDevice was toggled off expect(queryByTestId(`device-detail-${alicesMobileDevice.device_id}`)).toBeFalsy(); @@ -294,9 +304,7 @@ describe('<SessionManagerTab />', () => { await flushPromisesWithFakeTimers(); }); - const tile1 = getByTestId(`device-tile-${alicesOlderMobileDevice.device_id}`); - const toggle1 = tile1.querySelector('[aria-label="Toggle device details"]') as Element; - fireEvent.click(toggle1); + toggleDeviceDetails(getByTestId, alicesOlderMobileDevice.device_id); // verify device button is not rendered expect(queryByTestId(`verification-status-button-${alicesOlderMobileDevice.device_id}`)).toBeFalsy(); @@ -323,9 +331,7 @@ describe('<SessionManagerTab />', () => { await flushPromisesWithFakeTimers(); }); - const tile1 = getByTestId(`device-tile-${alicesMobileDevice.device_id}`); - const toggle1 = tile1.querySelector('[aria-label="Toggle device details"]') as Element; - fireEvent.click(toggle1); + toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id); // click verify button from current session section fireEvent.click(getByTestId(`verification-status-button-${alicesMobileDevice.device_id}`)); @@ -355,9 +361,7 @@ describe('<SessionManagerTab />', () => { await flushPromisesWithFakeTimers(); }); - const tile1 = getByTestId(`device-tile-${alicesMobileDevice.device_id}`); - const toggle1 = tile1.querySelector('[aria-label="Toggle device details"]') as Element; - fireEvent.click(toggle1); + toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id); // reset mock counter before triggering verification mockClient.getDevices.mockClear(); @@ -387,10 +391,7 @@ describe('<SessionManagerTab />', () => { await flushPromisesWithFakeTimers(); }); - // open device detail - const tile1 = getByTestId(`device-tile-${alicesDevice.device_id}`); - const toggle1 = tile1.querySelector('[aria-label="Toggle device details"]') as Element; - fireEvent.click(toggle1); + toggleDeviceDetails(getByTestId, alicesDevice.device_id); const signOutButton = getByTestId('device-detail-sign-out-cta'); expect(signOutButton).toMatchSnapshot(); @@ -399,5 +400,165 @@ describe('<SessionManagerTab />', () => { // logout dialog opened expect(modalSpy).toHaveBeenCalledWith(LogoutDialog, {}, undefined, false, true); }); + + describe('other devices', () => { + const interactiveAuthError = { httpStatus: 401, data: { flows: [{ stages: ["m.login.password"] }] } }; + + beforeEach(() => { + mockClient.deleteMultipleDevices.mockReset(); + }); + + it('deletes a device when interactive auth is not required', async () => { + mockClient.deleteMultipleDevices.mockResolvedValue({}); + mockClient.getDevices + .mockResolvedValueOnce({ devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice] }) + // pretend it was really deleted on refresh + .mockResolvedValueOnce({ devices: [alicesDevice, alicesOlderMobileDevice] }); + + const { getByTestId } = render(getComponent()); + + await act(async () => { + await flushPromisesWithFakeTimers(); + }); + + toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id); + + const deviceDetails = getByTestId(`device-detail-${alicesMobileDevice.device_id}`); + const signOutButton = deviceDetails.querySelector( + '[data-testid="device-detail-sign-out-cta"]', + ) as Element; + fireEvent.click(signOutButton); + + // sign out button is disabled with spinner + expect((deviceDetails.querySelector( + '[data-testid="device-detail-sign-out-cta"]', + ) as Element).getAttribute('aria-disabled')).toEqual("true"); + // delete called + expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith( + [alicesMobileDevice.device_id], undefined, + ); + + await flushPromisesWithFakeTimers(); + + // devices refreshed + expect(mockClient.getDevices).toHaveBeenCalled(); + }); + + it('deletes a device when interactive auth is required', async () => { + mockClient.deleteMultipleDevices + // require auth + .mockRejectedValueOnce(interactiveAuthError) + // then succeed + .mockResolvedValueOnce({}); + + mockClient.getDevices + .mockResolvedValueOnce({ devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice] }) + // pretend it was really deleted on refresh + .mockResolvedValueOnce({ devices: [alicesDevice, alicesOlderMobileDevice] }); + + const { getByTestId, getByLabelText } = render(getComponent()); + + await act(async () => { + await flushPromisesWithFakeTimers(); + }); + + // reset mock count after initial load + mockClient.getDevices.mockClear(); + + toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id); + + const deviceDetails = getByTestId(`device-detail-${alicesMobileDevice.device_id}`); + const signOutButton = deviceDetails.querySelector( + '[data-testid="device-detail-sign-out-cta"]', + ) as Element; + fireEvent.click(signOutButton); + + await flushPromisesWithFakeTimers(); + // modal rendering has some weird sleeps + await sleep(100); + + expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith( + [alicesMobileDevice.device_id], undefined, + ); + + const modal = document.getElementsByClassName('mx_Dialog'); + expect(modal.length).toBeTruthy(); + + // fill password and submit for interactive auth + act(() => { + fireEvent.change(getByLabelText('Password'), { target: { value: 'topsecret' } }); + fireEvent.submit(getByLabelText('Password')); + }); + + await flushPromisesWithFakeTimers(); + + // called again with auth + expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith([alicesMobileDevice.device_id], + { identifier: { + type: "m.id.user", user: aliceId, + }, password: "", type: "m.login.password", user: aliceId, + }); + // devices refreshed + expect(mockClient.getDevices).toHaveBeenCalled(); + }); + + it('clears loading state when device deletion is cancelled during interactive auth', async () => { + mockClient.deleteMultipleDevices + // require auth + .mockRejectedValueOnce(interactiveAuthError) + // then succeed + .mockResolvedValueOnce({}); + + mockClient.getDevices + .mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice] }); + + const { getByTestId, getByLabelText } = render(getComponent()); + + await act(async () => { + await flushPromisesWithFakeTimers(); + }); + + toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id); + + const deviceDetails = getByTestId(`device-detail-${alicesMobileDevice.device_id}`); + const signOutButton = deviceDetails.querySelector( + '[data-testid="device-detail-sign-out-cta"]', + ) as Element; + fireEvent.click(signOutButton); + + // button is loading + expect((deviceDetails.querySelector( + '[data-testid="device-detail-sign-out-cta"]', + ) as Element).getAttribute('aria-disabled')).toEqual("true"); + + await flushPromisesWithFakeTimers(); + // modal rendering has some weird sleeps + await sleep(100); + + expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith( + [alicesMobileDevice.device_id], undefined, + ); + + const modal = document.getElementsByClassName('mx_Dialog'); + expect(modal.length).toBeTruthy(); + + // cancel iau by closing modal + act(() => { + fireEvent.click(getByLabelText('Close dialog')); + }); + + await flushPromisesWithFakeTimers(); + + // not called again + expect(mockClient.deleteMultipleDevices).toHaveBeenCalledTimes(1); + // devices not refreshed (not called since initial fetch) + expect(mockClient.getDevices).toHaveBeenCalledTimes(1); + + // loading state cleared + expect((deviceDetails.querySelector( + '[data-testid="device-detail-sign-out-cta"]', + ) as Element).getAttribute('aria-disabled')).toEqual(null); + }); + }); }); }); diff --git a/test/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap b/test/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap index 6cec91242d..a0b087ce4f 100644 --- a/test/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap +++ b/test/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap @@ -7,7 +7,11 @@ exports[`<SessionManagerTab /> Sign out Signs out of current device 1`] = ` role="button" tabindex="0" > - Sign out of this session + <span + class="mx_DeviceDetails_signOutButtonContent" + > + Sign out of this session + </span> </div> `;