Skip to content

Commit

Permalink
Device manager - verify other devices (PSG-724) (#9274)
Browse files Browse the repository at this point in the history
* trigger verification of other devices

* tests

* fix strict errors

* add types
  • Loading branch information
Kerry committed Sep 14, 2022
1 parent 236ca2e commit 4623d84
Show file tree
Hide file tree
Showing 6 changed files with 181 additions and 15 deletions.
11 changes: 9 additions & 2 deletions src/components/views/settings/devices/DeviceDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,18 @@ import { DeviceWithVerification } from './types';

interface Props {
device: DeviceWithVerification;
onVerifyDevice?: () => void;
}

interface MetadataTable {
heading?: string;
values: { label: string, value?: string | React.ReactNode }[];
}

const DeviceDetails: React.FC<Props> = ({ device }) => {
const DeviceDetails: React.FC<Props> = ({
device,
onVerifyDevice,
}) => {
const metadata: MetadataTable[] = [
{
values: [
Expand All @@ -52,7 +56,10 @@ const DeviceDetails: React.FC<Props> = ({ device }) => {
return <div className='mx_DeviceDetails' data-testid={`device-detail-${device.device_id}`}>
<section className='mx_DeviceDetails_section'>
<Heading size='h3'>{ device.display_name ?? device.device_id }</Heading>
<DeviceVerificationStatusCard device={device} />
<DeviceVerificationStatusCard
device={device}
onVerifyDevice={onVerifyDevice}
/>
</section>
<section className='mx_DeviceDetails_section'>
<p className='mx_DeviceDetails_sectionHeading'>{ _t('Session details') }</p>
Expand Down
11 changes: 10 additions & 1 deletion src/components/views/settings/devices/FilteredDeviceList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ interface Props {
filter?: DeviceSecurityVariation;
onFilterChange: (filter: DeviceSecurityVariation | undefined) => void;
onDeviceExpandToggle: (deviceId: DeviceWithVerification['device_id']) => void;
onRequestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => void;
}

// devices without timestamp metadata should be sorted last
Expand Down Expand Up @@ -132,8 +133,10 @@ const DeviceListItem: React.FC<{
device: DeviceWithVerification;
isExpanded: boolean;
onDeviceExpandToggle: () => void;
onRequestDeviceVerification?: () => void;
}> = ({
device, isExpanded, onDeviceExpandToggle,
onRequestDeviceVerification,
}) => <li className='mx_FilteredDeviceList_listItem'>
<DeviceTile
device={device}
Expand All @@ -143,7 +146,7 @@ const DeviceListItem: React.FC<{
onClick={onDeviceExpandToggle}
/>
</DeviceTile>
{ isExpanded && <DeviceDetails device={device} /> }
{ isExpanded && <DeviceDetails device={device} onVerifyDevice={onRequestDeviceVerification} /> }
</li>;

/**
Expand All @@ -157,6 +160,7 @@ export const FilteredDeviceList =
expandedDeviceIds,
onFilterChange,
onDeviceExpandToggle,
onRequestDeviceVerification,
}: Props, ref: ForwardedRef<HTMLDivElement>) => {
const sortedDevices = getFilteredSortedDevices(devices, filter);

Expand Down Expand Up @@ -210,6 +214,11 @@ export const FilteredDeviceList =
device={device}
isExpanded={expandedDeviceIds.includes(device.device_id)}
onDeviceExpandToggle={() => onDeviceExpandToggle(device.device_id)}
onRequestDeviceVerification={
onRequestDeviceVerification
? () => onRequestDeviceVerification(device.device_id)
: undefined
}
/>,
) }
</ol>
Expand Down
50 changes: 43 additions & 7 deletions src/components/views/settings/devices/useOwnDevices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,28 @@ limitations under the License.
import { useCallback, useContext, useEffect, useState } from "react";
import { IMyDevice, MatrixClient } from "matrix-js-sdk/src/matrix";
import { CrossSigningInfo } from "matrix-js-sdk/src/crypto/CrossSigning";
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import { User } from "matrix-js-sdk/src/models/user";
import { MatrixError } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";

import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import { DevicesDictionary } from "./types";
import { DevicesDictionary, DeviceWithVerification } from "./types";

const isDeviceVerified = (
matrixClient: MatrixClient,
crossSigningInfo: CrossSigningInfo,
device: IMyDevice,
): boolean | null => {
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(
crossSigningInfo,
deviceInfo,
Expand All @@ -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 crossSigningInfo = matrixClient.getStoredCrossSigningForUser(matrixClient.getUserId());

const crossSigningInfo = matrixClient.getStoredCrossSigningForUser(userId);

const devicesDict = devices.reduce((acc, device: IMyDevice) => ({
...acc,
Expand All @@ -63,14 +77,18 @@ export enum OwnDevicesError {
type DevicesState = {
devices: DevicesDictionary;
currentDeviceId: string;
currentUserMember?: User;
isLoading: boolean;
// not provided when current session cannot request verification
requestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => Promise<VerificationRequest>;
refreshDevices: () => Promise<void>;
error?: OwnDevicesError;
};
export const useOwnDevices = (): DevicesState => {
const matrixClient = useContext(MatrixClientContext);

const currentDeviceId = matrixClient.getDeviceId();
const userId = matrixClient.getUserId();

const [devices, setDevices] = useState<DevicesState['devices']>({});
const [isLoading, setIsLoading] = useState(true);
Expand All @@ -79,11 +97,16 @@ export const useOwnDevices = (): DevicesState => {
const refreshDevices = useCallback(async () => {
setIsLoading(true);
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);
setIsLoading(false);
} catch (error) {
if (error.httpStatus == 404) {
if ((error as MatrixError).httpStatus == 404) {
// 404 probably means the HS doesn't yet support the API.
setError(OwnDevicesError.Unsupported);
} else {
Expand All @@ -92,15 +115,28 @@ export const useOwnDevices = (): DevicesState => {
}
setIsLoading(false);
}
}, [matrixClient]);
}, [matrixClient, userId]);

useEffect(() => {
refreshDevices();
}, [refreshDevices]);

const isCurrentDeviceVerified = !!devices[currentDeviceId]?.isVerified;

const requestDeviceVerification = isCurrentDeviceVerified && userId
? async (deviceId: DeviceWithVerification['device_id']) => {
return await matrixClient.requestVerification(
userId,
[deviceId],
);
}
: undefined;

return {
devices,
currentDeviceId,
currentUserMember: userId && matrixClient.getUser(userId) || undefined,
requestDeviceVerification,
refreshDevices,
isLoading,
error,
Expand Down
25 changes: 21 additions & 4 deletions src/components/views/settings/tabs/user/SessionManagerTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';

import { _t } from "../../../../../languageHandler";
import { useOwnDevices } from '../../devices/useOwnDevices';
Expand All @@ -26,12 +26,15 @@ import { DeviceSecurityVariation, DeviceWithVerification } from '../../devices/t
import SettingsTab from '../SettingsTab';
import Modal from '../../../../../Modal';
import SetupEncryptionDialog from '../../../dialogs/security/SetupEncryptionDialog';
import VerificationRequestDialog from '../../../dialogs/VerificationRequestDialog';

const SessionManagerTab: React.FC = () => {
const {
devices,
currentDeviceId,
currentUserMember,
isLoading,
requestDeviceVerification,
refreshDevices,
} = useOwnDevices();
const [filter, setFilter] = useState<DeviceSecurityVariation>();
Expand Down Expand Up @@ -65,15 +68,28 @@ const SessionManagerTab: React.FC = () => {
const shouldShowOtherSessions = Object.keys(otherDevices).length > 0;

const onVerifyCurrentDevice = () => {
if (!currentDevice) {
return;
}
Modal.createDialog(
SetupEncryptionDialog as unknown as React.ComponentType,
{ 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(() => () => {
clearTimeout(scrollIntoViewTimeoutRef.current);
}, [scrollIntoViewTimeoutRef]);
Expand Down Expand Up @@ -105,6 +121,7 @@ const SessionManagerTab: React.FC = () => {
expandedDeviceIds={expandedDeviceIds}
onFilterChange={setFilter}
onDeviceExpandToggle={onDeviceExpandToggle}
onRequestDeviceVerification={requestDeviceVerification ? onTriggerDeviceVerification : undefined}
ref={filteredDeviceListRef}
/>
</SettingsSubsection>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { act } from 'react-dom/test-utils';
import { DeviceInfo } from 'matrix-js-sdk/src/crypto/deviceinfo';
import { logger } from 'matrix-js-sdk/src/logger';
import { DeviceTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning';
import { VerificationRequest } from 'matrix-js-sdk/src/crypto/verification/request/VerificationRequest';

import SessionManagerTab from '../../../../../../src/components/views/settings/tabs/user/SessionManagerTab';
import MatrixClientContext from '../../../../../../src/contexts/MatrixClientContext';
Expand Down Expand Up @@ -52,12 +53,14 @@ describe('<SessionManagerTab />', () => {
const mockCrossSigningInfo = {
checkDeviceTrust: jest.fn(),
};
const mockVerificationRequest = { cancel: jest.fn() } as unknown as VerificationRequest;
const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(aliceId),
getStoredCrossSigningForUser: jest.fn().mockReturnValue(mockCrossSigningInfo),
getDevices: jest.fn(),
getStoredDevice: jest.fn(),
getDeviceId: jest.fn().mockReturnValue(deviceId),
requestVerification: jest.fn().mockResolvedValue(mockVerificationRequest),
});

const defaultProps = {};
Expand Down Expand Up @@ -278,4 +281,97 @@ describe('<SessionManagerTab />', () => {
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();
});
});
});
3 changes: 2 additions & 1 deletion test/test-utils/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ limitations under the License.

import EventEmitter from "events";
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";

Expand Down Expand Up @@ -65,6 +65,7 @@ export const unmockClientPeg = () => jest.spyOn(MatrixClientPeg, 'get').mockRest
*/
export const mockClientMethodsUser = (userId = '@alice:domain') => ({
getUserId: jest.fn().mockReturnValue(userId),
getUser: jest.fn().mockReturnValue(new User(userId)),
isGuest: jest.fn().mockReturnValue(false),
mxcUrlToHttp: jest.fn().mockReturnValue('mock-mxcUrlToHttp'),
credentials: { userId },
Expand Down

0 comments on commit 4623d84

Please sign in to comment.