Device manager - verify other devices (PSG-724) (#9274)

* trigger verification of other devices

* tests

* fix strict errors

* add types
This commit is contained in:
Kerry 2022-09-14 12:40:26 +02:00 committed by GitHub
parent 236ca2e494
commit 4623d84dd0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 181 additions and 15 deletions

View file

@ -24,6 +24,7 @@ import { DeviceWithVerification } from './types';
interface Props { interface Props {
device: DeviceWithVerification; device: DeviceWithVerification;
onVerifyDevice?: () => void;
} }
interface MetadataTable { interface MetadataTable {
@ -31,7 +32,10 @@ interface MetadataTable {
values: { label: string, value?: string | React.ReactNode }[]; values: { label: string, value?: string | React.ReactNode }[];
} }
const DeviceDetails: React.FC<Props> = ({ device }) => { const DeviceDetails: React.FC<Props> = ({
device,
onVerifyDevice,
}) => {
const metadata: MetadataTable[] = [ const metadata: MetadataTable[] = [
{ {
values: [ values: [
@ -52,7 +56,10 @@ const DeviceDetails: React.FC<Props> = ({ device }) => {
return <div className='mx_DeviceDetails' data-testid={`device-detail-${device.device_id}`}> return <div className='mx_DeviceDetails' data-testid={`device-detail-${device.device_id}`}>
<section className='mx_DeviceDetails_section'> <section className='mx_DeviceDetails_section'>
<Heading size='h3'>{ device.display_name ?? device.device_id }</Heading> <Heading size='h3'>{ device.display_name ?? device.device_id }</Heading>
<DeviceVerificationStatusCard device={device} /> <DeviceVerificationStatusCard
device={device}
onVerifyDevice={onVerifyDevice}
/>
</section> </section>
<section className='mx_DeviceDetails_section'> <section className='mx_DeviceDetails_section'>
<p className='mx_DeviceDetails_sectionHeading'>{ _t('Session details') }</p> <p className='mx_DeviceDetails_sectionHeading'>{ _t('Session details') }</p>

View file

@ -39,6 +39,7 @@ interface Props {
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;
onRequestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => void;
} }
// devices without timestamp metadata should be sorted last // devices without timestamp metadata should be sorted last
@ -132,8 +133,10 @@ const DeviceListItem: React.FC<{
device: DeviceWithVerification; device: DeviceWithVerification;
isExpanded: boolean; isExpanded: boolean;
onDeviceExpandToggle: () => void; onDeviceExpandToggle: () => void;
onRequestDeviceVerification?: () => void;
}> = ({ }> = ({
device, isExpanded, onDeviceExpandToggle, device, isExpanded, onDeviceExpandToggle,
onRequestDeviceVerification,
}) => <li className='mx_FilteredDeviceList_listItem'> }) => <li className='mx_FilteredDeviceList_listItem'>
<DeviceTile <DeviceTile
device={device} device={device}
@ -143,7 +146,7 @@ const DeviceListItem: React.FC<{
onClick={onDeviceExpandToggle} onClick={onDeviceExpandToggle}
/> />
</DeviceTile> </DeviceTile>
{ isExpanded && <DeviceDetails device={device} /> } { isExpanded && <DeviceDetails device={device} onVerifyDevice={onRequestDeviceVerification} /> }
</li>; </li>;
/** /**
@ -157,6 +160,7 @@ export const FilteredDeviceList =
expandedDeviceIds, expandedDeviceIds,
onFilterChange, onFilterChange,
onDeviceExpandToggle, onDeviceExpandToggle,
onRequestDeviceVerification,
}: Props, ref: ForwardedRef<HTMLDivElement>) => { }: Props, ref: ForwardedRef<HTMLDivElement>) => {
const sortedDevices = getFilteredSortedDevices(devices, filter); const sortedDevices = getFilteredSortedDevices(devices, filter);
@ -210,6 +214,11 @@ export const FilteredDeviceList =
device={device} device={device}
isExpanded={expandedDeviceIds.includes(device.device_id)} isExpanded={expandedDeviceIds.includes(device.device_id)}
onDeviceExpandToggle={() => onDeviceExpandToggle(device.device_id)} onDeviceExpandToggle={() => onDeviceExpandToggle(device.device_id)}
onRequestDeviceVerification={
onRequestDeviceVerification
? () => onRequestDeviceVerification(device.device_id)
: undefined
}
/>, />,
) } ) }
</ol> </ol>

View file

@ -17,10 +17,13 @@ limitations under the License.
import { useCallback, useContext, useEffect, useState } from "react"; 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 { User } from "matrix-js-sdk/src/models/user";
import { MatrixError } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import MatrixClientContext from "../../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import { DevicesDictionary } from "./types"; import { DevicesDictionary, DeviceWithVerification } from "./types";
const isDeviceVerified = ( const isDeviceVerified = (
matrixClient: MatrixClient, matrixClient: MatrixClient,
@ -28,7 +31,14 @@ const isDeviceVerified = (
device: IMyDevice, device: IMyDevice,
): boolean | null => { ): boolean | null => {
try { try {
const deviceInfo = matrixClient.getStoredDevice(matrixClient.getUserId(), device.device_id); const userId = matrixClient.getUserId();
if (!userId) {
throw new Error('No user id');
}
const deviceInfo = matrixClient.getStoredDevice(userId, device.device_id);
if (!deviceInfo) {
throw new Error('No device info available');
}
return crossSigningInfo.checkDeviceTrust( return crossSigningInfo.checkDeviceTrust(
crossSigningInfo, crossSigningInfo,
deviceInfo, deviceInfo,
@ -41,9 +51,13 @@ const isDeviceVerified = (
} }
}; };
const fetchDevicesWithVerification = async (matrixClient: MatrixClient): Promise<DevicesState['devices']> => { const fetchDevicesWithVerification = async (
matrixClient: MatrixClient,
userId: string,
): Promise<DevicesState['devices']> => {
const { devices } = await matrixClient.getDevices(); const { devices } = await matrixClient.getDevices();
const crossSigningInfo = matrixClient.getStoredCrossSigningForUser(matrixClient.getUserId());
const crossSigningInfo = matrixClient.getStoredCrossSigningForUser(userId);
const devicesDict = devices.reduce((acc, device: IMyDevice) => ({ const devicesDict = devices.reduce((acc, device: IMyDevice) => ({
...acc, ...acc,
@ -63,7 +77,10 @@ export enum OwnDevicesError {
type DevicesState = { type DevicesState = {
devices: DevicesDictionary; devices: DevicesDictionary;
currentDeviceId: string; currentDeviceId: string;
currentUserMember?: User;
isLoading: boolean; isLoading: boolean;
// not provided when current session cannot request verification
requestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => Promise<VerificationRequest>;
refreshDevices: () => Promise<void>; refreshDevices: () => Promise<void>;
error?: OwnDevicesError; error?: OwnDevicesError;
}; };
@ -71,6 +88,7 @@ export const useOwnDevices = (): DevicesState => {
const matrixClient = useContext(MatrixClientContext); const matrixClient = useContext(MatrixClientContext);
const currentDeviceId = matrixClient.getDeviceId(); const currentDeviceId = matrixClient.getDeviceId();
const userId = matrixClient.getUserId();
const [devices, setDevices] = useState<DevicesState['devices']>({}); const [devices, setDevices] = useState<DevicesState['devices']>({});
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@ -79,11 +97,16 @@ export const useOwnDevices = (): DevicesState => {
const refreshDevices = useCallback(async () => { const refreshDevices = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const devices = await fetchDevicesWithVerification(matrixClient); // realistically we should never hit this
// but it satisfies types
if (!userId) {
throw new Error('Cannot fetch devices without user id');
}
const devices = await fetchDevicesWithVerification(matrixClient, userId);
setDevices(devices); setDevices(devices);
setIsLoading(false); setIsLoading(false);
} catch (error) { } catch (error) {
if (error.httpStatus == 404) { if ((error as MatrixError).httpStatus == 404) {
// 404 probably means the HS doesn't yet support the API. // 404 probably means the HS doesn't yet support the API.
setError(OwnDevicesError.Unsupported); setError(OwnDevicesError.Unsupported);
} else { } else {
@ -92,15 +115,28 @@ export const useOwnDevices = (): DevicesState => {
} }
setIsLoading(false); setIsLoading(false);
} }
}, [matrixClient]); }, [matrixClient, userId]);
useEffect(() => { useEffect(() => {
refreshDevices(); refreshDevices();
}, [refreshDevices]); }, [refreshDevices]);
const isCurrentDeviceVerified = !!devices[currentDeviceId]?.isVerified;
const requestDeviceVerification = isCurrentDeviceVerified && userId
? async (deviceId: DeviceWithVerification['device_id']) => {
return await matrixClient.requestVerification(
userId,
[deviceId],
);
}
: undefined;
return { return {
devices, devices,
currentDeviceId, currentDeviceId,
currentUserMember: userId && matrixClient.getUser(userId) || undefined,
requestDeviceVerification,
refreshDevices, refreshDevices,
isLoading, isLoading,
error, error,

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { _t } from "../../../../../languageHandler"; import { _t } from "../../../../../languageHandler";
import { useOwnDevices } from '../../devices/useOwnDevices'; import { useOwnDevices } from '../../devices/useOwnDevices';
@ -26,12 +26,15 @@ import { DeviceSecurityVariation, DeviceWithVerification } from '../../devices/t
import SettingsTab from '../SettingsTab'; import SettingsTab from '../SettingsTab';
import Modal from '../../../../../Modal'; import Modal from '../../../../../Modal';
import SetupEncryptionDialog from '../../../dialogs/security/SetupEncryptionDialog'; import SetupEncryptionDialog from '../../../dialogs/security/SetupEncryptionDialog';
import VerificationRequestDialog from '../../../dialogs/VerificationRequestDialog';
const SessionManagerTab: React.FC = () => { const SessionManagerTab: React.FC = () => {
const { const {
devices, devices,
currentDeviceId, currentDeviceId,
currentUserMember,
isLoading, isLoading,
requestDeviceVerification,
refreshDevices, refreshDevices,
} = useOwnDevices(); } = useOwnDevices();
const [filter, setFilter] = useState<DeviceSecurityVariation>(); const [filter, setFilter] = useState<DeviceSecurityVariation>();
@ -65,15 +68,28 @@ const SessionManagerTab: React.FC = () => {
const shouldShowOtherSessions = Object.keys(otherDevices).length > 0; const shouldShowOtherSessions = Object.keys(otherDevices).length > 0;
const onVerifyCurrentDevice = () => { const onVerifyCurrentDevice = () => {
if (!currentDevice) {
return;
}
Modal.createDialog( Modal.createDialog(
SetupEncryptionDialog as unknown as React.ComponentType, SetupEncryptionDialog as unknown as React.ComponentType,
{ onFinished: refreshDevices }, { onFinished: refreshDevices },
); );
}; };
const onTriggerDeviceVerification = useCallback((deviceId: DeviceWithVerification['device_id']) => {
if (!requestDeviceVerification) {
return;
}
const verificationRequestPromise = requestDeviceVerification(deviceId);
Modal.createDialog(VerificationRequestDialog, {
verificationRequestPromise,
member: currentUserMember,
onFinished: async () => {
const request = await verificationRequestPromise;
request.cancel();
await refreshDevices();
},
});
}, [requestDeviceVerification, refreshDevices, currentUserMember]);
useEffect(() => () => { useEffect(() => () => {
clearTimeout(scrollIntoViewTimeoutRef.current); clearTimeout(scrollIntoViewTimeoutRef.current);
}, [scrollIntoViewTimeoutRef]); }, [scrollIntoViewTimeoutRef]);
@ -105,6 +121,7 @@ const SessionManagerTab: React.FC = () => {
expandedDeviceIds={expandedDeviceIds} expandedDeviceIds={expandedDeviceIds}
onFilterChange={setFilter} onFilterChange={setFilter}
onDeviceExpandToggle={onDeviceExpandToggle} onDeviceExpandToggle={onDeviceExpandToggle}
onRequestDeviceVerification={requestDeviceVerification ? onTriggerDeviceVerification : undefined}
ref={filteredDeviceListRef} ref={filteredDeviceListRef}
/> />
</SettingsSubsection> </SettingsSubsection>

View file

@ -20,6 +20,7 @@ import { act } from 'react-dom/test-utils';
import { DeviceInfo } from 'matrix-js-sdk/src/crypto/deviceinfo'; 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 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';
@ -52,12 +53,14 @@ describe('<SessionManagerTab />', () => {
const mockCrossSigningInfo = { const mockCrossSigningInfo = {
checkDeviceTrust: jest.fn(), checkDeviceTrust: jest.fn(),
}; };
const mockVerificationRequest = { cancel: jest.fn() } as unknown as VerificationRequest;
const mockClient = getMockClientWithEventEmitter({ const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(aliceId), ...mockClientMethodsUser(aliceId),
getStoredCrossSigningForUser: jest.fn().mockReturnValue(mockCrossSigningInfo), getStoredCrossSigningForUser: jest.fn().mockReturnValue(mockCrossSigningInfo),
getDevices: jest.fn(), getDevices: jest.fn(),
getStoredDevice: jest.fn(), getStoredDevice: jest.fn(),
getDeviceId: jest.fn().mockReturnValue(deviceId), getDeviceId: jest.fn().mockReturnValue(deviceId),
requestVerification: jest.fn().mockResolvedValue(mockVerificationRequest),
}); });
const defaultProps = {}; const defaultProps = {};
@ -278,4 +281,97 @@ describe('<SessionManagerTab />', () => {
expect(getByTestId(`device-detail-${alicesOlderMobileDevice.device_id}`)).toBeTruthy(); expect(getByTestId(`device-detail-${alicesOlderMobileDevice.device_id}`)).toBeTruthy();
}); });
}); });
describe('Device verification', () => {
it('does not render device verification cta when current session is not verified', async () => {
mockClient.getDevices.mockResolvedValue({
devices: [alicesDevice, alicesOlderMobileDevice, alicesMobileDevice],
});
const { getByTestId, queryByTestId } = render(getComponent());
await act(async () => {
await flushPromisesWithFakeTimers();
});
const tile1 = getByTestId(`device-tile-${alicesOlderMobileDevice.device_id}`);
const toggle1 = tile1.querySelector('[aria-label="Toggle device details"]') as Element;
fireEvent.click(toggle1);
// verify device button is not rendered
expect(queryByTestId(`verification-status-button-${alicesOlderMobileDevice.device_id}`)).toBeFalsy();
});
it('renders device verification cta on other sessions when current session is verified', async () => {
const modalSpy = jest.spyOn(Modal, 'createDialog');
// make the current device verified
mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice] });
mockClient.getStoredDevice.mockImplementation((_userId, deviceId) => new DeviceInfo(deviceId));
mockCrossSigningInfo.checkDeviceTrust
.mockImplementation((_userId, { deviceId }) => {
console.log('hhh', deviceId);
if (deviceId === alicesDevice.device_id) {
return new DeviceTrustLevel(true, true, false, false);
}
throw new Error('everything else unverified');
});
const { getByTestId } = render(getComponent());
await act(async () => {
await flushPromisesWithFakeTimers();
});
const tile1 = getByTestId(`device-tile-${alicesMobileDevice.device_id}`);
const toggle1 = tile1.querySelector('[aria-label="Toggle device details"]') as Element;
fireEvent.click(toggle1);
// click verify button from current session section
fireEvent.click(getByTestId(`verification-status-button-${alicesMobileDevice.device_id}`));
expect(mockClient.requestVerification).toHaveBeenCalledWith(aliceId, [alicesMobileDevice.device_id]);
expect(modalSpy).toHaveBeenCalled();
});
it('refreshes devices after verifying other device', async () => {
const modalSpy = jest.spyOn(Modal, 'createDialog');
// make the current device verified
mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice] });
mockClient.getStoredDevice.mockImplementation((_userId, deviceId) => new DeviceInfo(deviceId));
mockCrossSigningInfo.checkDeviceTrust
.mockImplementation((_userId, { deviceId }) => {
console.log('hhh', deviceId);
if (deviceId === alicesDevice.device_id) {
return new DeviceTrustLevel(true, true, false, false);
}
throw new Error('everything else unverified');
});
const { getByTestId } = render(getComponent());
await act(async () => {
await flushPromisesWithFakeTimers();
});
const tile1 = getByTestId(`device-tile-${alicesMobileDevice.device_id}`);
const toggle1 = tile1.querySelector('[aria-label="Toggle device details"]') as Element;
fireEvent.click(toggle1);
// reset mock counter before triggering verification
mockClient.getDevices.mockClear();
// click verify button from current session section
fireEvent.click(getByTestId(`verification-status-button-${alicesMobileDevice.device_id}`));
const { onFinished: modalOnFinished } = modalSpy.mock.calls[0][1] as any;
// simulate modal completing process
await modalOnFinished();
// cancelled in case it was a failure exit from modal
expect(mockVerificationRequest.cancel).toHaveBeenCalled();
// devices refreshed
expect(mockClient.getDevices).toHaveBeenCalled();
});
});
}); });

View file

@ -16,7 +16,7 @@ limitations under the License.
import EventEmitter from "events"; import EventEmitter from "events";
import { MethodKeysOf, mocked, MockedObject } from "jest-mock"; import { MethodKeysOf, mocked, MockedObject } from "jest-mock";
import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { MatrixClient, User } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../src/MatrixClientPeg";
@ -65,6 +65,7 @@ export const unmockClientPeg = () => jest.spyOn(MatrixClientPeg, 'get').mockRest
*/ */
export const mockClientMethodsUser = (userId = '@alice:domain') => ({ export const mockClientMethodsUser = (userId = '@alice:domain') => ({
getUserId: jest.fn().mockReturnValue(userId), getUserId: jest.fn().mockReturnValue(userId),
getUser: jest.fn().mockReturnValue(new User(userId)),
isGuest: jest.fn().mockReturnValue(false), isGuest: jest.fn().mockReturnValue(false),
mxcUrlToHttp: jest.fn().mockReturnValue('mock-mxcUrlToHttp'), mxcUrlToHttp: jest.fn().mockReturnValue('mock-mxcUrlToHttp'),
credentials: { userId }, credentials: { userId },