diff --git a/src/components/views/settings/devices/DeviceDetails.tsx b/src/components/views/settings/devices/DeviceDetails.tsx index 5a58efaa887..779e29b8d15 100644 --- a/src/components/views/settings/devices/DeviceDetails.tsx +++ b/src/components/views/settings/devices/DeviceDetails.tsx @@ -24,6 +24,7 @@ import { DeviceWithVerification } from './types'; interface Props { device: DeviceWithVerification; + onVerifyDevice?: () => void; } interface MetadataTable { @@ -31,7 +32,10 @@ interface MetadataTable { values: { label: string, value?: string | React.ReactNode }[]; } -const DeviceDetails: React.FC = ({ device }) => { +const DeviceDetails: React.FC = ({ + device, + onVerifyDevice, +}) => { const metadata: MetadataTable[] = [ { values: [ @@ -52,7 +56,10 @@ const DeviceDetails: React.FC = ({ device }) => { return
{ device.display_name ?? device.device_id } - +

{ _t('Session details') }

diff --git a/src/components/views/settings/devices/FilteredDeviceList.tsx b/src/components/views/settings/devices/FilteredDeviceList.tsx index 0c88a507c6d..4ce3e6e7da1 100644 --- a/src/components/views/settings/devices/FilteredDeviceList.tsx +++ b/src/components/views/settings/devices/FilteredDeviceList.tsx @@ -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 @@ -132,8 +133,10 @@ const DeviceListItem: React.FC<{ device: DeviceWithVerification; isExpanded: boolean; onDeviceExpandToggle: () => void; + onRequestDeviceVerification?: () => void; }> = ({ device, isExpanded, onDeviceExpandToggle, + onRequestDeviceVerification, }) =>
  • - { isExpanded && } + { isExpanded && }
  • ; /** @@ -157,6 +160,7 @@ export const FilteredDeviceList = expandedDeviceIds, onFilterChange, onDeviceExpandToggle, + onRequestDeviceVerification, }: Props, ref: ForwardedRef) => { const sortedDevices = getFilteredSortedDevices(devices, filter); @@ -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 + } />, ) } diff --git a/src/components/views/settings/devices/useOwnDevices.ts b/src/components/views/settings/devices/useOwnDevices.ts index 7252b053b35..3f1beecae78 100644 --- a/src/components/views/settings/devices/useOwnDevices.ts +++ b/src/components/views/settings/devices/useOwnDevices.ts @@ -17,10 +17,13 @@ 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, @@ -28,7 +31,14 @@ const isDeviceVerified = ( 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, @@ -41,9 +51,13 @@ const isDeviceVerified = ( } }; -const fetchDevicesWithVerification = async (matrixClient: MatrixClient): Promise => { +const fetchDevicesWithVerification = async ( + matrixClient: MatrixClient, + userId: string, +): Promise => { const { devices } = await matrixClient.getDevices(); - const crossSigningInfo = matrixClient.getStoredCrossSigningForUser(matrixClient.getUserId()); + + const crossSigningInfo = matrixClient.getStoredCrossSigningForUser(userId); const devicesDict = devices.reduce((acc, device: IMyDevice) => ({ ...acc, @@ -63,7 +77,10 @@ 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; refreshDevices: () => Promise; error?: OwnDevicesError; }; @@ -71,6 +88,7 @@ export const useOwnDevices = (): DevicesState => { const matrixClient = useContext(MatrixClientContext); const currentDeviceId = matrixClient.getDeviceId(); + const userId = matrixClient.getUserId(); const [devices, setDevices] = useState({}); const [isLoading, setIsLoading] = useState(true); @@ -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 { @@ -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, diff --git a/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/src/components/views/settings/tabs/user/SessionManagerTab.tsx index 3b6cefc15b7..024b71c5310 100644 --- a/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/src/components/views/settings/tabs/user/SessionManagerTab.tsx @@ -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'; @@ -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(); @@ -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]); @@ -105,6 +121,7 @@ const SessionManagerTab: React.FC = () => { expandedDeviceIds={expandedDeviceIds} onFilterChange={setFilter} onDeviceExpandToggle={onDeviceExpandToggle} + onRequestDeviceVerification={requestDeviceVerification ? onTriggerDeviceVerification : undefined} ref={filteredDeviceListRef} /> diff --git a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx index 183aba540df..bea02207e24 100644 --- a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx +++ b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx @@ -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'; @@ -52,12 +53,14 @@ describe('', () => { 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 = {}; @@ -278,4 +281,97 @@ describe('', () => { 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(); + }); + }); }); diff --git a/test/test-utils/client.ts b/test/test-utils/client.ts index 453856eb26e..4a5b3184913 100644 --- a/test/test-utils/client.ts +++ b/test/test-utils/client.ts @@ -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"; @@ -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 },