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:
parent
0c22b15bba
commit
10bb10539b
13 changed files with 371 additions and 49 deletions
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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} />);
|
||||||
|
|
|
@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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>,
|
||||||
|
|
|
@ -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>
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,7 +7,11 @@ exports[`<SessionManagerTab /> Sign out Signs out of current device 1`] = `
|
||||||
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>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue