diff --git a/src/components/views/settings/DevicesPanelEntry.tsx b/src/components/views/settings/DevicesPanelEntry.tsx index b0301214b9b..beb7a8e86e6 100644 --- a/src/components/views/settings/DevicesPanelEntry.tsx +++ b/src/components/views/settings/DevicesPanelEntry.tsx @@ -154,12 +154,17 @@ export default class DevicesPanelEntry extends React.Component { ; + const deviceWithVerification = { + ...this.props.device, + isVerified: this.props.verified, + }; + if (this.props.isOwnDevice) { return
- + { buttons }
; @@ -167,7 +172,7 @@ export default class DevicesPanelEntry extends React.Component { return (
- + { buttons }
diff --git a/src/components/views/settings/devices/DeviceTile.tsx b/src/components/views/settings/devices/DeviceTile.tsx index 33f9fc40a85..9e9a520fcea 100644 --- a/src/components/views/settings/devices/DeviceTile.tsx +++ b/src/components/views/settings/devices/DeviceTile.tsx @@ -15,21 +15,21 @@ limitations under the License. */ import React, { Fragment } from "react"; -import { IMyDevice } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../../languageHandler"; import { formatDate, formatRelativeTime } from "../../../../DateUtils"; import TooltipTarget from "../../elements/TooltipTarget"; import { Alignment } from "../../elements/Tooltip"; import Heading from "../../typography/Heading"; +import { DeviceWithVerification } from "./useOwnDevices"; export interface DeviceTileProps { - device: IMyDevice; + device: DeviceWithVerification; children?: React.ReactNode; onClick?: () => void; } -const DeviceTileName: React.FC<{ device: IMyDevice }> = ({ device }) => { +const DeviceTileName: React.FC<{ device: DeviceWithVerification }> = ({ device }) => { if (device.display_name) { return = ({ value, id }) const DeviceTile: React.FC = ({ device, children, onClick }) => { const lastActivity = device.last_seen_ts && `${_t('Last activity')} ${formatLastActivity(device.last_seen_ts)}`; + const verificationStatus = device.isVerified ? _t('Verified') : _t('Unverified'); const metadata = [ + { id: 'isVerified', value: verificationStatus }, { id: 'lastActivity', value: lastActivity }, { id: 'lastSeenIp', value: device.last_seen_ip }, ]; - return
+ return
diff --git a/src/components/views/settings/devices/useOwnDevices.ts b/src/components/views/settings/devices/useOwnDevices.ts new file mode 100644 index 00000000000..ad9523cc14f --- /dev/null +++ b/src/components/views/settings/devices/useOwnDevices.ts @@ -0,0 +1,105 @@ +/* +Copyright 2022 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 { useContext, useEffect, useState } from "react"; +import { IMyDevice, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { CrossSigningInfo } from "matrix-js-sdk/src/crypto/CrossSigning"; +import { logger } from "matrix-js-sdk/src/logger"; + +import MatrixClientContext from "../../../../contexts/MatrixClientContext"; + +export type DeviceWithVerification = IMyDevice & { isVerified: boolean | null }; + +const isDeviceVerified = ( + matrixClient: MatrixClient, + crossSigningInfo: CrossSigningInfo, + device: IMyDevice, +): boolean | null => { + try { + const deviceInfo = matrixClient.getStoredDevice(matrixClient.getUserId(), device.device_id); + return crossSigningInfo.checkDeviceTrust( + crossSigningInfo, + deviceInfo, + false, + true, + ).isCrossSigningVerified(); + } catch (error) { + logger.error("Error getting device cross-signing info", error); + return null; + } +}; + +const fetchDevicesWithVerification = async (matrixClient: MatrixClient): Promise => { + const { devices } = await matrixClient.getDevices(); + const crossSigningInfo = matrixClient.getStoredCrossSigningForUser(matrixClient.getUserId()); + + const devicesDict = devices.reduce((acc, device: IMyDevice) => ({ + ...acc, + [device.device_id]: { + ...device, + isVerified: isDeviceVerified(matrixClient, crossSigningInfo, device), + }, + }), {}); + + return devicesDict; +}; +export enum OwnDevicesError { + Unsupported = 'Unsupported', + Default = 'Default', +} +type DevicesState = { + devices: Record; + currentDeviceId: string; + isLoading: boolean; + error?: OwnDevicesError; +}; +export const useOwnDevices = (): DevicesState => { + const matrixClient = useContext(MatrixClientContext); + + const currentDeviceId = matrixClient.getDeviceId(); + + const [devices, setDevices] = useState({}); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(); + + useEffect(() => { + const getDevicesAsync = async () => { + setIsLoading(true); + try { + const devices = await fetchDevicesWithVerification(matrixClient); + setDevices(devices); + setIsLoading(false); + } catch (error) { + if (error.httpStatus == 404) { + // 404 probably means the HS doesn't yet support the API. + setError(OwnDevicesError.Unsupported); + } else { + logger.error("Error loading sessions:", error); + setError(OwnDevicesError.Default); + } + setIsLoading(false); + } + }; + getDevicesAsync(); + }, [matrixClient]); + + return { + devices, + currentDeviceId, + isLoading, + error, + }; +}; diff --git a/src/components/views/settings/shared/SettingsSubsection.tsx b/src/components/views/settings/shared/SettingsSubsection.tsx index 5dcdc9dad6f..6d23a080caa 100644 --- a/src/components/views/settings/shared/SettingsSubsection.tsx +++ b/src/components/views/settings/shared/SettingsSubsection.tsx @@ -14,18 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { HTMLAttributes } from "react"; import Heading from "../../typography/Heading"; -export interface SettingsSubsectionProps { +export interface SettingsSubsectionProps extends HTMLAttributes { heading: string; description?: string | React.ReactNode; children?: React.ReactNode; } -const SettingsSubsection: React.FC = ({ heading, description, children }) => ( -
+const SettingsSubsection: React.FC = ({ heading, description, children, ...rest }) => ( +
{ heading } { !!description &&
{ description }
}
diff --git a/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/src/components/views/settings/tabs/user/SessionManagerTab.tsx index 17c09aeb7a3..7d65ce83da0 100644 --- a/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/src/components/views/settings/tabs/user/SessionManagerTab.tsx @@ -17,16 +17,26 @@ limitations under the License. import React from 'react'; import { _t } from "../../../../../languageHandler"; +import Spinner from '../../../elements/Spinner'; +import { useOwnDevices } from '../../devices/useOwnDevices'; +import DeviceTile from '../../devices/DeviceTile'; import SettingsSubsection from '../../shared/SettingsSubsection'; import SettingsTab from '../SettingsTab'; const SessionManagerTab: React.FC = () => { + const { devices, currentDeviceId, isLoading } = useOwnDevices(); + + const currentDevice = devices[currentDeviceId]; return + data-testid='current-session-section' + > + { isLoading && } + { !!currentDevice && } + ; }; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index e601003ecb4..c15c81273cf 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1693,6 +1693,8 @@ "Verification code": "Verification code", "Discovery options will appear once you have added a phone number above.": "Discovery options will appear once you have added a phone number above.", "Last activity": "Last activity", + "Verified": "Verified", + "Unverified": "Unverified", "Unable to remove contact information": "Unable to remove contact information", "Remove %(email)s?": "Remove %(email)s?", "Invalid Email Address": "Invalid Email Address", diff --git a/test/components/views/settings/devices/DeviceTile-test.tsx b/test/components/views/settings/devices/DeviceTile-test.tsx index d688eca9135..4083945fd61 100644 --- a/test/components/views/settings/devices/DeviceTile-test.tsx +++ b/test/components/views/settings/devices/DeviceTile-test.tsx @@ -24,6 +24,7 @@ describe('', () => { const defaultProps = { device: { device_id: '123', + isVerified: false, }, }; const getComponent = (props = {}) => ( @@ -43,6 +44,11 @@ describe('', () => { expect(container).toMatchSnapshot(); }); + it('renders a verified device with no metadata', () => { + const { container } = render(getComponent()); + expect(container).toMatchSnapshot(); + }); + it('renders display name with a tooltip', () => { const device: IMyDevice = { device_id: '123', diff --git a/test/components/views/settings/devices/SelectableDeviceTile-test.tsx b/test/components/views/settings/devices/SelectableDeviceTile-test.tsx index 77dad3e1383..5c0fe47828b 100644 --- a/test/components/views/settings/devices/SelectableDeviceTile-test.tsx +++ b/test/components/views/settings/devices/SelectableDeviceTile-test.tsx @@ -25,6 +25,7 @@ describe('', () => { display_name: 'My Device', device_id: 'my-device', last_seen_ip: '123.456.789', + isVerified: false, }; const defaultProps = { onClick: jest.fn(), diff --git a/test/components/views/settings/devices/__snapshots__/DeviceTile-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/DeviceTile-test.tsx.snap index 299d72348c8..cafd47a8a74 100644 --- a/test/components/views/settings/devices/__snapshots__/DeviceTile-test.tsx.snap +++ b/test/components/views/settings/devices/__snapshots__/DeviceTile-test.tsx.snap @@ -4,6 +4,7 @@ exports[` renders a device with no metadata 1`] = `
renders a device with no metadata 1`] = ` +
+
+
+
+`; + +exports[` renders a verified device with no metadata 1`] = ` +
+
+
+

+ 123 +

+
@@ -30,6 +70,7 @@ exports[` renders display name with a tooltip 1`] = `
renders display name with a tooltip 1`] = `
@@ -60,6 +107,7 @@ exports[` separates metadata with a dot 1`] = `
separates metadata with a dot 1`] = `