From 5328f6e5fe4d5645ecd39e43c846f0076736ac88 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 26 Apr 2023 12:23:32 +0200 Subject: [PATCH] Element-R: Populate device list for right-panel (#10671) * Use `getUserDeviceInfo` instead of `downloadKeys` and `getStoredDevicesForUser` * Use new `getUserDeviceInfo` api in `UserInfo.tsx` and `UserInfo-test.tsx` * Fix missing fields * Use `getUserDeviceInfo` instead of `downloadKeys` * Move `ManualDeviceKeyVerificationDialog.tsx` from class to functional component and add tests * Fix strict errors * Update snapshot * Add snapshot test to `UserInfo-test.tsx` * Add test for * Remove useless TODO comment * Add test for ambiguous device * Rework `` test --- .../ManualDeviceKeyVerificationDialog.tsx | 122 ++++----- .../views/dialogs/UntrustedDeviceDialog.tsx | 2 +- src/components/views/right_panel/UserInfo.tsx | 37 ++- src/verification.ts | 2 +- ...ManualDeviceKeyVerificationDialog-test.tsx | 113 +++++++++ ...lDeviceKeyVerificationDialog-test.tsx.snap | 231 +++++++++++++++++ .../views/right_panel/UserInfo-test.tsx | 98 +++++--- .../__snapshots__/UserInfo-test.tsx.snap | 233 ++++++++++++++++++ test/test-utils/test-utils.ts | 1 + 9 files changed, 739 insertions(+), 100 deletions(-) create mode 100644 test/components/views/dialogs/ManualDeviceKeyVerificationDialog-test.tsx create mode 100644 test/components/views/dialogs/__snapshots__/ManualDeviceKeyVerificationDialog-test.tsx.snap create mode 100644 test/components/views/right_panel/__snapshots__/UserInfo-test.tsx.snap diff --git a/src/components/views/dialogs/ManualDeviceKeyVerificationDialog.tsx b/src/components/views/dialogs/ManualDeviceKeyVerificationDialog.tsx index e3bc1dc469..8f1da8a253 100644 --- a/src/components/views/dialogs/ManualDeviceKeyVerificationDialog.tsx +++ b/src/components/views/dialogs/ManualDeviceKeyVerificationDialog.tsx @@ -18,72 +18,80 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; -import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo"; +import React, { useCallback } from "react"; +import { Device } from "matrix-js-sdk/src/models/device"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; import * as FormattingUtils from "../../../utils/FormattingUtils"; import { _t } from "../../../languageHandler"; import QuestionDialog from "./QuestionDialog"; +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; -interface IProps { +interface IManualDeviceKeyVerificationDialogProps { userId: string; - device: DeviceInfo; - onFinished(confirm?: boolean): void; + device: Device; + onFinished?(confirm?: boolean): void; } -export default class ManualDeviceKeyVerificationDialog extends React.Component { - private onLegacyFinished = (confirm: boolean): void => { - if (confirm) { - MatrixClientPeg.get().setDeviceVerified(this.props.userId, this.props.device.deviceId, true); - } - this.props.onFinished(confirm); - }; +export function ManualDeviceKeyVerificationDialog({ + userId, + device, + onFinished, +}: IManualDeviceKeyVerificationDialogProps): JSX.Element { + const mxClient = useMatrixClientContext(); - public render(): React.ReactNode { - let text; - if (MatrixClientPeg.get().getUserId() === this.props.userId) { - text = _t("Confirm by comparing the following with the User Settings in your other session:"); - } else { - text = _t("Confirm this user's session by comparing the following with their User Settings:"); - } + const onLegacyFinished = useCallback( + (confirm: boolean) => { + if (confirm && mxClient) { + mxClient.setDeviceVerified(userId, device.deviceId, true); + } + onFinished?.(confirm); + }, + [mxClient, userId, device, onFinished], + ); - const key = FormattingUtils.formatCryptoKey(this.props.device.getFingerprint()); - const body = ( -
-

{text}

-
-
    -
  • - {this.props.device.getDisplayName()} -
  • -
  • - {" "} - - {this.props.device.deviceId} - -
  • -
  • - {" "} - - - {key} - - -
  • -
-
-

{_t("If they don't match, the security of your communication may be compromised.")}

-
- ); - - return ( - - ); + let text; + if (mxClient?.getUserId() === userId) { + text = _t("Confirm by comparing the following with the User Settings in your other session:"); + } else { + text = _t("Confirm this user's session by comparing the following with their User Settings:"); } + + const fingerprint = device.getFingerprint(); + const key = fingerprint && FormattingUtils.formatCryptoKey(fingerprint); + const body = ( +
+

{text}

+
+
    +
  • + {device.displayName} +
  • +
  • + {" "} + + {device.deviceId} + +
  • +
  • + {" "} + + + {key} + + +
  • +
+
+

{_t("If they don't match, the security of your communication may be compromised.")}

+
+ ); + + return ( + + ); } diff --git a/src/components/views/dialogs/UntrustedDeviceDialog.tsx b/src/components/views/dialogs/UntrustedDeviceDialog.tsx index 0d70e3b71f..9fa6b075cd 100644 --- a/src/components/views/dialogs/UntrustedDeviceDialog.tsx +++ b/src/components/views/dialogs/UntrustedDeviceDialog.tsx @@ -59,7 +59,7 @@ const UntrustedDeviceDialog: React.FC = ({ device, user, onFinished }) =

{newSessionText}

- {device.getDisplayName()} ({device.deviceId}) + {device.displayName} ({device.deviceId})

{askToVerifyText}

diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index f6f9031319..b87c8ab76c 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -30,7 +30,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { UserTrustLevel } from "matrix-js-sdk/src/crypto/CrossSigning"; -import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo"; +import { Device } from "matrix-js-sdk/src/models/device"; import dis from "../../../dispatcher/dispatcher"; import Modal from "../../../Modal"; @@ -81,14 +81,14 @@ import { DirectoryMember, startDmOnFirstMessage } from "../../../utils/direct-me import { SdkContextClass } from "../../../contexts/SDKContext"; import { asyncSome } from "../../../utils/arrays"; -export interface IDevice extends DeviceInfo { +export interface IDevice extends Device { ambiguous?: boolean; } export const disambiguateDevices = (devices: IDevice[]): void => { const names = Object.create(null); for (let i = 0; i < devices.length; i++) { - const name = devices[i].getDisplayName() ?? ""; + const name = devices[i].displayName ?? ""; const indexList = names[name] || []; indexList.push(i); names[name] = indexList; @@ -149,7 +149,8 @@ function useHasCrossSigningKeys( } setUpdating(true); try { - await cli.downloadKeys([member.userId]); + // We call it to populate the user keys and devices + await cli.getCrypto()?.getUserDeviceInfo([member.userId], true); const xsi = cli.getStoredCrossSigningForUser(member.userId); const key = xsi && xsi.getId(); return !!key; @@ -195,12 +196,10 @@ export function DeviceItem({ userId, device }: { userId: string; device: IDevice }; let deviceName; - if (!device.getDisplayName()?.trim()) { + if (!device.displayName?.trim()) { deviceName = device.deviceId; } else { - deviceName = device.ambiguous - ? device.getDisplayName() + " (" + device.deviceId + ")" - : device.getDisplayName(); + deviceName = device.ambiguous ? device.displayName + " (" + device.deviceId + ")" : device.displayName; } let trustedLabel: string | undefined; @@ -1190,6 +1189,19 @@ export const PowerLevelEditor: React.FC<{ ); }; +async function getUserDeviceInfo( + userId: string, + cli: MatrixClient, + downloadUncached = false, +): Promise { + const userDeviceMap = await cli.getCrypto()?.getUserDeviceInfo([userId], downloadUncached); + const devicesMap = userDeviceMap?.get(userId); + + if (!devicesMap) return; + + return Array.from(devicesMap.values()); +} + export const useDevices = (userId: string): IDevice[] | undefined | null => { const cli = useContext(MatrixClientContext); @@ -1203,10 +1215,9 @@ export const useDevices = (userId: string): IDevice[] | undefined | null => { async function downloadDeviceList(): Promise { try { - await cli.downloadKeys([userId], true); - const devices = cli.getStoredDevicesForUser(userId); + const devices = await getUserDeviceInfo(userId, cli, true); - if (cancelled) { + if (cancelled || !devices) { // we got cancelled - presumably a different user now return; } @@ -1229,8 +1240,8 @@ export const useDevices = (userId: string): IDevice[] | undefined | null => { useEffect(() => { let cancel = false; const updateDevices = async (): Promise => { - const newDevices = cli.getStoredDevicesForUser(userId); - if (cancel) return; + const newDevices = await getUserDeviceInfo(userId, cli); + if (cancel || !newDevices) return; setDevices(newDevices); }; const onDevicesUpdated = (users: string[]): void => { diff --git a/src/verification.ts b/src/verification.ts index c7cdd8073a..83411d5650 100644 --- a/src/verification.ts +++ b/src/verification.ts @@ -26,7 +26,7 @@ import { RightPanelPhases } from "./stores/right-panel/RightPanelStorePhases"; import { accessSecretStorage } from "./SecurityManager"; import UntrustedDeviceDialog from "./components/views/dialogs/UntrustedDeviceDialog"; import { IDevice } from "./components/views/right_panel/UserInfo"; -import ManualDeviceKeyVerificationDialog from "./components/views/dialogs/ManualDeviceKeyVerificationDialog"; +import { ManualDeviceKeyVerificationDialog } from "./components/views/dialogs/ManualDeviceKeyVerificationDialog"; import RightPanelStore from "./stores/right-panel/RightPanelStore"; import { IRightPanelCardState } from "./stores/right-panel/RightPanelStoreIPanelState"; import { findDMForUser } from "./utils/dm/findDMForUser"; diff --git a/test/components/views/dialogs/ManualDeviceKeyVerificationDialog-test.tsx b/test/components/views/dialogs/ManualDeviceKeyVerificationDialog-test.tsx new file mode 100644 index 0000000000..43912b2bc6 --- /dev/null +++ b/test/components/views/dialogs/ManualDeviceKeyVerificationDialog-test.tsx @@ -0,0 +1,113 @@ +/* + * Copyright 2023 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { Device } from "matrix-js-sdk/src/models/device"; +import { MatrixClient } from "matrix-js-sdk/src/client"; + +import { stubClient } from "../../../test-utils"; +import { ManualDeviceKeyVerificationDialog } from "../../../../src/components/views/dialogs/ManualDeviceKeyVerificationDialog"; +import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; + +describe("ManualDeviceKeyVerificationDialog", () => { + let mockClient: MatrixClient; + + function renderDialog(userId: string, device: Device, onLegacyFinished: (confirm: boolean) => void) { + return render( + + + , + ); + } + + beforeEach(() => { + mockClient = stubClient(); + }); + + it("should display the device", () => { + // When + const deviceId = "XYZ"; + const device = new Device({ + userId: mockClient.getUserId()!, + deviceId, + displayName: "my device", + algorithms: [], + keys: new Map([[`ed25519:${deviceId}`, "ABCDEFGH"]]), + }); + const { container } = renderDialog(mockClient.getUserId()!, device, jest.fn()); + + // Then + expect(container).toMatchSnapshot(); + }); + + it("should display the device of another user", () => { + // When + const userId = "@alice:example.com"; + const deviceId = "XYZ"; + const device = new Device({ + userId, + deviceId, + displayName: "my device", + algorithms: [], + keys: new Map([[`ed25519:${deviceId}`, "ABCDEFGH"]]), + }); + const { container } = renderDialog(userId, device, jest.fn()); + + // Then + expect(container).toMatchSnapshot(); + }); + + it("should call onFinished and matrixClient.setDeviceVerified", () => { + // When + const deviceId = "XYZ"; + const device = new Device({ + userId: mockClient.getUserId()!, + deviceId, + displayName: "my device", + algorithms: [], + keys: new Map([[`ed25519:${deviceId}`, "ABCDEFGH"]]), + }); + const onFinished = jest.fn(); + renderDialog(mockClient.getUserId()!, device, onFinished); + + screen.getByRole("button", { name: "Verify session" }).click(); + + // Then + expect(onFinished).toHaveBeenCalledWith(true); + expect(mockClient.setDeviceVerified).toHaveBeenCalledWith(mockClient.getUserId(), deviceId, true); + }); + + it("should call onFinished and not matrixClient.setDeviceVerified", () => { + // When + const deviceId = "XYZ"; + const device = new Device({ + userId: mockClient.getUserId()!, + deviceId, + displayName: "my device", + algorithms: [], + keys: new Map([[`ed25519:${deviceId}`, "ABCDEFGH"]]), + }); + const onFinished = jest.fn(); + renderDialog(mockClient.getUserId()!, device, onFinished); + + screen.getByRole("button", { name: "Cancel" }).click(); + + // Then + expect(onFinished).toHaveBeenCalledWith(false); + expect(mockClient.setDeviceVerified).not.toHaveBeenCalled(); + }); +}); diff --git a/test/components/views/dialogs/__snapshots__/ManualDeviceKeyVerificationDialog-test.tsx.snap b/test/components/views/dialogs/__snapshots__/ManualDeviceKeyVerificationDialog-test.tsx.snap new file mode 100644 index 0000000000..f51e881e2a --- /dev/null +++ b/test/components/views/dialogs/__snapshots__/ManualDeviceKeyVerificationDialog-test.tsx.snap @@ -0,0 +1,231 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ManualDeviceKeyVerificationDialog should display the device 1`] = ` +
+
+