Device manager - logout of other session (PSG-744) (#9280)

* add sign out of current device section in device details

* lint

* add sign out cta for other sessions

* test other device sign out

* add pending sign out loader

* tidy

* fix strict error

* use gap instead of nbsp

* use more specific assertions in tests, tweak formatting

* tweak test
This commit is contained in:
Kerry 2022-09-14 18:18:32 +02:00 committed by GitHub
parent 0c22b15bba
commit 10bb10539b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 371 additions and 49 deletions

View file

@ -73,3 +73,10 @@ limitations under the License.
color: $primary-content; color: $primary-content;
} }
} }
.mx_DeviceDetails_signOutButtonContent {
display: flex;
flex-direction: row;
align-items: center;
gap: $spacing-4;
}

View file

@ -28,6 +28,7 @@ import { DeviceWithVerification } from './types';
interface Props { interface Props {
device?: DeviceWithVerification; device?: DeviceWithVerification;
isLoading: boolean; isLoading: boolean;
isSigningOut: boolean;
onVerifyCurrentDevice: () => void; onVerifyCurrentDevice: () => void;
onSignOutCurrentDevice: () => void; onSignOutCurrentDevice: () => void;
} }
@ -35,6 +36,7 @@ interface Props {
const CurrentDeviceSection: React.FC<Props> = ({ const CurrentDeviceSection: React.FC<Props> = ({
device, device,
isLoading, isLoading,
isSigningOut,
onVerifyCurrentDevice, onVerifyCurrentDevice,
onSignOutCurrentDevice, onSignOutCurrentDevice,
}) => { }) => {
@ -58,6 +60,7 @@ const CurrentDeviceSection: React.FC<Props> = ({
{ isExpanded && { isExpanded &&
<DeviceDetails <DeviceDetails
device={device} device={device}
isSigningOut={isSigningOut}
onSignOutDevice={onSignOutCurrentDevice} onSignOutDevice={onSignOutCurrentDevice}
/> />
} }

View file

@ -19,16 +19,16 @@ import React from 'react';
import { formatDate } from '../../../../DateUtils'; import { formatDate } from '../../../../DateUtils';
import { _t } from '../../../../languageHandler'; import { _t } from '../../../../languageHandler';
import AccessibleButton from '../../elements/AccessibleButton'; import AccessibleButton from '../../elements/AccessibleButton';
import Spinner from '../../elements/Spinner';
import Heading from '../../typography/Heading'; import Heading from '../../typography/Heading';
import { DeviceVerificationStatusCard } from './DeviceVerificationStatusCard'; import { DeviceVerificationStatusCard } from './DeviceVerificationStatusCard';
import { DeviceWithVerification } from './types'; import { DeviceWithVerification } from './types';
interface Props { interface Props {
device: DeviceWithVerification; device: DeviceWithVerification;
isSigningOut: boolean;
onVerifyDevice?: () => void; onVerifyDevice?: () => void;
// @TODO(kerry) optional while signout only implemented onSignOutDevice: () => void;
// for current device (PSG-744)
onSignOutDevice?: () => void;
} }
interface MetadataTable { interface MetadataTable {
@ -38,6 +38,7 @@ interface MetadataTable {
const DeviceDetails: React.FC<Props> = ({ const DeviceDetails: React.FC<Props> = ({
device, device,
isSigningOut,
onVerifyDevice, onVerifyDevice,
onSignOutDevice, onSignOutDevice,
}) => { }) => {
@ -87,15 +88,19 @@ const DeviceDetails: React.FC<Props> = ({
</table>, </table>,
) } ) }
</section> </section>
{ !!onSignOutDevice && <section className='mx_DeviceDetails_section'> <section className='mx_DeviceDetails_section'>
<AccessibleButton <AccessibleButton
onClick={onSignOutDevice} onClick={onSignOutDevice}
kind='danger_inline' kind='danger_inline'
disabled={isSigningOut}
data-testid='device-detail-sign-out-cta' data-testid='device-detail-sign-out-cta'
> >
<span className='mx_DeviceDetails_signOutButtonContent'>
{ _t('Sign out of this session') } { _t('Sign out of this session') }
{ isSigningOut && <Spinner w={16} h={16} /> }
</span>
</AccessibleButton> </AccessibleButton>
</section> } </section>
</div>; </div>;
}; };

View file

@ -36,9 +36,11 @@ import {
interface Props { interface Props {
devices: DevicesDictionary; devices: DevicesDictionary;
expandedDeviceIds: DeviceWithVerification['device_id'][]; expandedDeviceIds: DeviceWithVerification['device_id'][];
signingOutDeviceIds: DeviceWithVerification['device_id'][];
filter?: DeviceSecurityVariation; filter?: DeviceSecurityVariation;
onFilterChange: (filter: DeviceSecurityVariation | undefined) => void; onFilterChange: (filter: DeviceSecurityVariation | undefined) => void;
onDeviceExpandToggle: (deviceId: DeviceWithVerification['device_id']) => void; onDeviceExpandToggle: (deviceId: DeviceWithVerification['device_id']) => void;
onSignOutDevices: (deviceIds: DeviceWithVerification['device_id'][]) => void;
onRequestDeviceVerification?: (deviceId: 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<{ const DeviceListItem: React.FC<{
device: DeviceWithVerification; device: DeviceWithVerification;
isExpanded: boolean; isExpanded: boolean;
isSigningOut: boolean;
onDeviceExpandToggle: () => void; onDeviceExpandToggle: () => void;
onSignOutDevice: () => void;
onRequestDeviceVerification?: () => void; onRequestDeviceVerification?: () => void;
}> = ({ }> = ({
device, isExpanded, onDeviceExpandToggle, device,
isExpanded,
isSigningOut,
onDeviceExpandToggle,
onSignOutDevice,
onRequestDeviceVerification, onRequestDeviceVerification,
}) => <li className='mx_FilteredDeviceList_listItem'> }) => <li className='mx_FilteredDeviceList_listItem'>
<DeviceTile <DeviceTile
@ -146,7 +154,15 @@ const DeviceListItem: React.FC<{
onClick={onDeviceExpandToggle} onClick={onDeviceExpandToggle}
/> />
</DeviceTile> </DeviceTile>
{ isExpanded && <DeviceDetails device={device} onVerifyDevice={onRequestDeviceVerification} /> } {
isExpanded &&
<DeviceDetails
device={device}
isSigningOut={isSigningOut}
onVerifyDevice={onRequestDeviceVerification}
onSignOutDevice={onSignOutDevice}
/>
}
</li>; </li>;
/** /**
@ -158,8 +174,10 @@ export const FilteredDeviceList =
devices, devices,
filter, filter,
expandedDeviceIds, expandedDeviceIds,
signingOutDeviceIds,
onFilterChange, onFilterChange,
onDeviceExpandToggle, onDeviceExpandToggle,
onSignOutDevices,
onRequestDeviceVerification, onRequestDeviceVerification,
}: Props, ref: ForwardedRef<HTMLDivElement>) => { }: Props, ref: ForwardedRef<HTMLDivElement>) => {
const sortedDevices = getFilteredSortedDevices(devices, filter); const sortedDevices = getFilteredSortedDevices(devices, filter);
@ -213,7 +231,9 @@ export const FilteredDeviceList =
key={device.device_id} key={device.device_id}
device={device} device={device}
isExpanded={expandedDeviceIds.includes(device.device_id)} isExpanded={expandedDeviceIds.includes(device.device_id)}
isSigningOut={signingOutDeviceIds.includes(device.device_id)}
onDeviceExpandToggle={() => onDeviceExpandToggle(device.device_id)} onDeviceExpandToggle={() => onDeviceExpandToggle(device.device_id)}
onSignOutDevice={() => onSignOutDevices([device.device_id])}
onRequestDeviceVerification={ onRequestDeviceVerification={
onRequestDeviceVerification onRequestDeviceVerification
? () => onRequestDeviceVerification(device.device_id) ? () => onRequestDeviceVerification(device.device_id)

View file

@ -18,7 +18,6 @@ import { useCallback, useContext, useEffect, useState } from "react";
import { IMyDevice, MatrixClient } from "matrix-js-sdk/src/matrix"; import { IMyDevice, MatrixClient } from "matrix-js-sdk/src/matrix";
import { CrossSigningInfo } from "matrix-js-sdk/src/crypto/CrossSigning"; import { CrossSigningInfo } from "matrix-js-sdk/src/crypto/CrossSigning";
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; 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 { MatrixError } from "matrix-js-sdk/src/http-api";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
@ -74,10 +73,9 @@ export enum OwnDevicesError {
Unsupported = 'Unsupported', Unsupported = 'Unsupported',
Default = 'Default', Default = 'Default',
} }
type DevicesState = { export type DevicesState = {
devices: DevicesDictionary; devices: DevicesDictionary;
currentDeviceId: string; currentDeviceId: string;
currentUserMember?: User;
isLoading: boolean; isLoading: boolean;
// not provided when current session cannot request verification // not provided when current session cannot request verification
requestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => Promise<VerificationRequest>; requestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => Promise<VerificationRequest>;
@ -135,7 +133,6 @@ export const useOwnDevices = (): DevicesState => {
return { return {
devices, devices,
currentDeviceId, currentDeviceId,
currentUserMember: userId && matrixClient.getUser(userId) || undefined,
requestDeviceVerification, requestDeviceVerification,
refreshDevices, refreshDevices,
isLoading, isLoading,

View file

@ -14,10 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License. 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 { _t } from "../../../../../languageHandler";
import { useOwnDevices } from '../../devices/useOwnDevices'; import { DevicesState, useOwnDevices } from '../../devices/useOwnDevices';
import SettingsSubsection from '../../shared/SettingsSubsection'; import SettingsSubsection from '../../shared/SettingsSubsection';
import { FilteredDeviceList } from '../../devices/FilteredDeviceList'; import { FilteredDeviceList } from '../../devices/FilteredDeviceList';
import CurrentDeviceSection from '../../devices/CurrentDeviceSection'; import CurrentDeviceSection from '../../devices/CurrentDeviceSection';
@ -28,12 +30,64 @@ import Modal from '../../../../../Modal';
import SetupEncryptionDialog from '../../../dialogs/security/SetupEncryptionDialog'; import SetupEncryptionDialog from '../../../dialogs/security/SetupEncryptionDialog';
import VerificationRequestDialog from '../../../dialogs/VerificationRequestDialog'; import VerificationRequestDialog from '../../../dialogs/VerificationRequestDialog';
import LogoutDialog from '../../../dialogs/LogoutDialog'; 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 SessionManagerTab: React.FC = () => {
const { const {
devices, devices,
currentDeviceId, currentDeviceId,
currentUserMember,
isLoading, isLoading,
requestDeviceVerification, requestDeviceVerification,
refreshDevices, refreshDevices,
@ -43,6 +97,10 @@ const SessionManagerTab: React.FC = () => {
const filteredDeviceListRef = useRef<HTMLDivElement>(null); const filteredDeviceListRef = useRef<HTMLDivElement>(null);
const scrollIntoViewTimeoutRef = useRef<ReturnType<typeof setTimeout>>(); 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 => { const onDeviceExpandToggle = (deviceId: DeviceWithVerification['device_id']): void => {
if (expandedDeviceIds.includes(deviceId)) { if (expandedDeviceIds.includes(deviceId)) {
setExpandedDeviceIds(expandedDeviceIds.filter(id => id !== deviceId)); setExpandedDeviceIds(expandedDeviceIds.filter(id => id !== deviceId));
@ -91,14 +149,11 @@ const SessionManagerTab: React.FC = () => {
}); });
}, [requestDeviceVerification, refreshDevices, currentUserMember]); }, [requestDeviceVerification, refreshDevices, currentUserMember]);
const onSignOutCurrentDevice = () => { const {
if (!currentDevice) { onSignOutCurrentDevice,
return; onSignOutOtherDevices,
} signingOutDeviceIds,
Modal.createDialog(LogoutDialog, } = useSignOut(matrixClient, refreshDevices);
/* props= */{}, /* className= */undefined,
/* isPriority= */false, /* isStatic= */true);
};
useEffect(() => () => { useEffect(() => () => {
clearTimeout(scrollIntoViewTimeoutRef.current); clearTimeout(scrollIntoViewTimeoutRef.current);
@ -113,6 +168,7 @@ const SessionManagerTab: React.FC = () => {
<CurrentDeviceSection <CurrentDeviceSection
device={currentDevice} device={currentDevice}
isLoading={isLoading} isLoading={isLoading}
isSigningOut={signingOutDeviceIds.includes(currentDevice?.device_id)}
onVerifyCurrentDevice={onVerifyCurrentDevice} onVerifyCurrentDevice={onVerifyCurrentDevice}
onSignOutCurrentDevice={onSignOutCurrentDevice} onSignOutCurrentDevice={onSignOutCurrentDevice}
/> />
@ -130,9 +186,11 @@ const SessionManagerTab: React.FC = () => {
devices={otherDevices} devices={otherDevices}
filter={filter} filter={filter}
expandedDeviceIds={expandedDeviceIds} expandedDeviceIds={expandedDeviceIds}
signingOutDeviceIds={signingOutDeviceIds}
onFilterChange={setFilter} onFilterChange={setFilter}
onDeviceExpandToggle={onDeviceExpandToggle} onDeviceExpandToggle={onDeviceExpandToggle}
onRequestDeviceVerification={requestDeviceVerification ? onTriggerDeviceVerification : undefined} onRequestDeviceVerification={requestDeviceVerification ? onTriggerDeviceVerification : undefined}
onSignOutDevices={onSignOutOtherDevices}
ref={filteredDeviceListRef} ref={filteredDeviceListRef}
/> />
</SettingsSubsection> </SettingsSubsection>

View file

@ -37,6 +37,7 @@ describe('<CurrentDeviceSection />', () => {
onVerifyCurrentDevice: jest.fn(), onVerifyCurrentDevice: jest.fn(),
onSignOutCurrentDevice: jest.fn(), onSignOutCurrentDevice: jest.fn(),
isLoading: false, isLoading: false,
isSigningOut: false,
}; };
const getComponent = (props = {}): React.ReactElement => const getComponent = (props = {}): React.ReactElement =>
(<CurrentDeviceSection {...defaultProps} {...props} />); (<CurrentDeviceSection {...defaultProps} {...props} />);

View file

@ -26,6 +26,8 @@ describe('<DeviceDetails />', () => {
}; };
const defaultProps = { const defaultProps = {
device: baseDevice, device: baseDevice,
isSigningOut: false,
onSignOutDevice: jest.fn(),
}; };
const getComponent = (props = {}) => <DeviceDetails {...defaultProps} {...props} />; const getComponent = (props = {}) => <DeviceDetails {...defaultProps} {...props} />;
// 14.03.2022 16:15 // 14.03.2022 16:15
@ -60,4 +62,14 @@ describe('<DeviceDetails />', () => {
const { container } = render(getComponent({ device })); const { container } = render(getComponent({ device }));
expect(container).toMatchSnapshot(); 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");
});
}); });

View file

@ -43,7 +43,9 @@ describe('<FilteredDeviceList />', () => {
const defaultProps = { const defaultProps = {
onFilterChange: jest.fn(), onFilterChange: jest.fn(),
onDeviceExpandToggle: jest.fn(), onDeviceExpandToggle: jest.fn(),
onSignOutDevices: jest.fn(),
expandedDeviceIds: [], expandedDeviceIds: [],
signingOutDeviceIds: [],
devices: { devices: {
[unverifiedNoMetadata.device_id]: unverifiedNoMetadata, [unverifiedNoMetadata.device_id]: unverifiedNoMetadata,
[verifiedNoMetadata.device_id]: verifiedNoMetadata, [verifiedNoMetadata.device_id]: verifiedNoMetadata,

View file

@ -109,8 +109,12 @@ HTMLCollection [
data-testid="device-detail-sign-out-cta" data-testid="device-detail-sign-out-cta"
role="button" role="button"
tabindex="0" tabindex="0"
>
<span
class="mx_DeviceDetails_signOutButtonContent"
> >
Sign out of this session Sign out of this session
</span>
</div> </div>
</section> </section>
</div>, </div>,

View file

@ -101,6 +101,22 @@ exports[`<DeviceDetails /> renders a verified device 1`] = `
</tbody> </tbody>
</table> </table>
</section> </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>
</div> </div>
`; `;
@ -210,6 +226,22 @@ exports[`<DeviceDetails /> renders device with metadata 1`] = `
</tbody> </tbody>
</table> </table>
</section> </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>
</div> </div>
`; `;
@ -315,6 +347,22 @@ exports[`<DeviceDetails /> renders device without metadata 1`] = `
</tbody> </tbody>
</table> </table>
</section> </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>
</div> </div>
`; `;

View file

@ -21,6 +21,7 @@ import { DeviceInfo } from 'matrix-js-sdk/src/crypto/deviceinfo';
import { logger } from 'matrix-js-sdk/src/logger'; import { logger } from 'matrix-js-sdk/src/logger';
import { DeviceTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning'; import { DeviceTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning';
import { VerificationRequest } from 'matrix-js-sdk/src/crypto/verification/request/VerificationRequest'; 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 SessionManagerTab from '../../../../../../src/components/views/settings/tabs/user/SessionManagerTab';
import MatrixClientContext from '../../../../../../src/contexts/MatrixClientContext'; import MatrixClientContext from '../../../../../../src/contexts/MatrixClientContext';
@ -31,8 +32,7 @@ import {
} from '../../../../../test-utils'; } from '../../../../../test-utils';
import Modal from '../../../../../../src/Modal'; import Modal from '../../../../../../src/Modal';
import LogoutDialog from '../../../../../../src/components/views/dialogs/LogoutDialog'; import LogoutDialog from '../../../../../../src/components/views/dialogs/LogoutDialog';
import { DeviceWithVerification } from '../../../../../../src/components/views/settings/devices/types';
jest.useFakeTimers();
describe('<SessionManagerTab />', () => { describe('<SessionManagerTab />', () => {
const aliceId = '@alice:server.org'; const aliceId = '@alice:server.org';
@ -62,6 +62,8 @@ describe('<SessionManagerTab />', () => {
getStoredDevice: jest.fn(), getStoredDevice: jest.fn(),
getDeviceId: jest.fn().mockReturnValue(deviceId), getDeviceId: jest.fn().mockReturnValue(deviceId),
requestVerification: jest.fn().mockResolvedValue(mockVerificationRequest), requestVerification: jest.fn().mockResolvedValue(mockVerificationRequest),
deleteMultipleDevices: jest.fn(),
generateClientSecret: jest.fn(),
}); });
const defaultProps = {}; const defaultProps = {};
@ -72,6 +74,16 @@ describe('<SessionManagerTab />', () => {
</MatrixClientContext.Provider> </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(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
jest.spyOn(logger, 'error').mockRestore(); jest.spyOn(logger, 'error').mockRestore();
@ -83,6 +95,10 @@ describe('<SessionManagerTab />', () => {
mockCrossSigningInfo.checkDeviceTrust mockCrossSigningInfo.checkDeviceTrust
.mockReset() .mockReset()
.mockReturnValue(new DeviceTrustLevel(false, false, false, false)); .mockReturnValue(new DeviceTrustLevel(false, false, false, false));
mockClient.getDevices
.mockReset()
.mockResolvedValue({ devices: [alicesMobileDevice] });
}); });
it('renders spinner while devices load', () => { it('renders spinner while devices load', () => {
@ -257,24 +273,18 @@ describe('<SessionManagerTab />', () => {
await flushPromisesWithFakeTimers(); await flushPromisesWithFakeTimers();
}); });
const tile1 = getByTestId(`device-tile-${alicesOlderMobileDevice.device_id}`); toggleDeviceDetails(getByTestId, alicesOlderMobileDevice.device_id);
const toggle1 = tile1.querySelector('[aria-label="Toggle device details"]') as Element;
fireEvent.click(toggle1);
// device details are expanded // device details are expanded
expect(getByTestId(`device-detail-${alicesOlderMobileDevice.device_id}`)).toBeTruthy(); expect(getByTestId(`device-detail-${alicesOlderMobileDevice.device_id}`)).toBeTruthy();
const tile2 = getByTestId(`device-tile-${alicesMobileDevice.device_id}`); toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id);
const toggle2 = tile2.querySelector('[aria-label="Toggle device details"]') as Element;
fireEvent.click(toggle2);
// both device details are expanded // both device details are expanded
expect(getByTestId(`device-detail-${alicesOlderMobileDevice.device_id}`)).toBeTruthy(); expect(getByTestId(`device-detail-${alicesOlderMobileDevice.device_id}`)).toBeTruthy();
expect(getByTestId(`device-detail-${alicesMobileDevice.device_id}`)).toBeTruthy(); expect(getByTestId(`device-detail-${alicesMobileDevice.device_id}`)).toBeTruthy();
const tile3 = getByTestId(`device-tile-${alicesMobileDevice.device_id}`); toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id);
const toggle3 = tile3.querySelector('[aria-label="Toggle device details"]') as Element;
fireEvent.click(toggle3);
// alicesMobileDevice was toggled off // alicesMobileDevice was toggled off
expect(queryByTestId(`device-detail-${alicesMobileDevice.device_id}`)).toBeFalsy(); expect(queryByTestId(`device-detail-${alicesMobileDevice.device_id}`)).toBeFalsy();
@ -294,9 +304,7 @@ describe('<SessionManagerTab />', () => {
await flushPromisesWithFakeTimers(); await flushPromisesWithFakeTimers();
}); });
const tile1 = getByTestId(`device-tile-${alicesOlderMobileDevice.device_id}`); toggleDeviceDetails(getByTestId, alicesOlderMobileDevice.device_id);
const toggle1 = tile1.querySelector('[aria-label="Toggle device details"]') as Element;
fireEvent.click(toggle1);
// verify device button is not rendered // verify device button is not rendered
expect(queryByTestId(`verification-status-button-${alicesOlderMobileDevice.device_id}`)).toBeFalsy(); expect(queryByTestId(`verification-status-button-${alicesOlderMobileDevice.device_id}`)).toBeFalsy();
@ -323,9 +331,7 @@ describe('<SessionManagerTab />', () => {
await flushPromisesWithFakeTimers(); await flushPromisesWithFakeTimers();
}); });
const tile1 = getByTestId(`device-tile-${alicesMobileDevice.device_id}`); toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id);
const toggle1 = tile1.querySelector('[aria-label="Toggle device details"]') as Element;
fireEvent.click(toggle1);
// click verify button from current session section // click verify button from current session section
fireEvent.click(getByTestId(`verification-status-button-${alicesMobileDevice.device_id}`)); fireEvent.click(getByTestId(`verification-status-button-${alicesMobileDevice.device_id}`));
@ -355,9 +361,7 @@ describe('<SessionManagerTab />', () => {
await flushPromisesWithFakeTimers(); await flushPromisesWithFakeTimers();
}); });
const tile1 = getByTestId(`device-tile-${alicesMobileDevice.device_id}`); toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id);
const toggle1 = tile1.querySelector('[aria-label="Toggle device details"]') as Element;
fireEvent.click(toggle1);
// reset mock counter before triggering verification // reset mock counter before triggering verification
mockClient.getDevices.mockClear(); mockClient.getDevices.mockClear();
@ -387,10 +391,7 @@ describe('<SessionManagerTab />', () => {
await flushPromisesWithFakeTimers(); await flushPromisesWithFakeTimers();
}); });
// open device detail toggleDeviceDetails(getByTestId, alicesDevice.device_id);
const tile1 = getByTestId(`device-tile-${alicesDevice.device_id}`);
const toggle1 = tile1.querySelector('[aria-label="Toggle device details"]') as Element;
fireEvent.click(toggle1);
const signOutButton = getByTestId('device-detail-sign-out-cta'); const signOutButton = getByTestId('device-detail-sign-out-cta');
expect(signOutButton).toMatchSnapshot(); expect(signOutButton).toMatchSnapshot();
@ -399,5 +400,165 @@ describe('<SessionManagerTab />', () => {
// logout dialog opened // logout dialog opened
expect(modalSpy).toHaveBeenCalledWith(LogoutDialog, {}, undefined, false, true); 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);
});
});
}); });
}); });

View file

@ -6,8 +6,12 @@ exports[`<SessionManagerTab /> Sign out Signs out of current device 1`] = `
data-testid="device-detail-sign-out-cta" data-testid="device-detail-sign-out-cta"
role="button" role="button"
tabindex="0" tabindex="0"
>
<span
class="mx_DeviceDetails_signOutButtonContent"
> >
Sign out of this session Sign out of this session
</span>
</div> </div>
`; `;