Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 7 commits into from
Sep 14, 2022
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
48 changes: 41 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,26 @@ export const useOwnDevices = (): DevicesState => {
}
setIsLoading(false);
}
}, [matrixClient]);
}, [matrixClient, userId]);

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

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

const requestDeviceVerification = isCurrentDeviceVerified && userId ? async (deviceId) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing type

return await matrixClient.requestVerification(
userId,
[deviceId],
);
} : undefined;

return {
devices,
currentDeviceId,
currentUserMember: userId && matrixClient.getUser(userId) || undefined,
requestDeviceVerification,
refreshDevices,
isLoading,
error,
Expand Down
22 changes: 21 additions & 1 deletion 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 @@ -74,6 +77,22 @@ const SessionManagerTab: React.FC = () => {
);
};

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 +124,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,56 @@ 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('does not render device verification cta when current session is not 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();
});
});
});
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