Device manager - add foundation for extended device info (#9344)

* record device client inforamtion events on app start

* matrix-client-information -> matrix_client_information

* fix types

* remove another unused export

* add docs link

* display device client information in device details

* update snapshots

* integration-ish test client information in metadata

* tests

* fix tests

* export helper

* DeviceClientInformation type

* Device manager - select all devices (#9330)

* add device selection that does nothing

* multi select and sign out of sessions

* test multiple selection

* fix type after rebase

* select all sessions

* rename type

* use ExtendedDevice type everywhere

* rename clientName to appName for less collision with UA parser

* fix bad find and replace

* rename ExtendedDeviceInfo to ExtendedDeviceAppInfo

* rename DeviceType comp to DeviceTypeIcon

* update tests for new required property deviceType

* add stubbed user agent parsing
This commit is contained in:
Kerry 2022-10-05 13:41:01 +02:00 committed by GitHub
parent 1032334b20
commit bd270b08df
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 319 additions and 187 deletions

View file

@ -29,6 +29,7 @@ import VerificationRequestDialog from '../../views/dialogs/VerificationRequestDi
import LogoutDialog from '../dialogs/LogoutDialog';
import DeviceTile from './devices/DeviceTile';
import SelectableDeviceTile from './devices/SelectableDeviceTile';
import { DeviceType } from '../../../utils/device/parseUserAgent';
interface IProps {
device: IMyDevice;
@ -153,9 +154,10 @@ export default class DevicesPanelEntry extends React.Component<IProps, IState> {
</AccessibleButton>
</React.Fragment>;
const deviceWithVerification = {
const extendedDevice = {
...this.props.device,
isVerified: this.props.verified,
deviceType: DeviceType.Unknown,
};
if (this.props.isOwnDevice) {
@ -163,7 +165,7 @@ export default class DevicesPanelEntry extends React.Component<IProps, IState> {
<div className="mx_DevicesPanel_deviceTrust">
<span className={"mx_DevicesPanel_icon mx_E2EIcon " + iconClass} />
</div>
<DeviceTile device={deviceWithVerification}>
<DeviceTile device={extendedDevice}>
{ buttons }
</DeviceTile>
</div>;
@ -171,7 +173,7 @@ export default class DevicesPanelEntry extends React.Component<IProps, IState> {
return (
<div className="mx_DevicesPanel_device">
<SelectableDeviceTile device={deviceWithVerification} onClick={this.onDeviceToggled} isSelected={this.props.selected}>
<SelectableDeviceTile device={extendedDevice} onClick={this.onDeviceToggled} isSelected={this.props.selected}>
{ buttons }
</SelectableDeviceTile>
</div>

View file

@ -24,10 +24,10 @@ import DeviceDetails from './DeviceDetails';
import DeviceExpandDetailsButton from './DeviceExpandDetailsButton';
import DeviceTile from './DeviceTile';
import { DeviceVerificationStatusCard } from './DeviceVerificationStatusCard';
import { DeviceWithVerification } from './types';
import { ExtendedDevice } from './types';
interface Props {
device?: DeviceWithVerification;
device?: ExtendedDevice;
isLoading: boolean;
isSigningOut: boolean;
localNotificationSettings?: LocalNotificationSettings | undefined;

View file

@ -22,10 +22,10 @@ import Field from '../../elements/Field';
import Spinner from '../../elements/Spinner';
import { Caption } from '../../typography/Caption';
import Heading from '../../typography/Heading';
import { DeviceWithVerification } from './types';
import { ExtendedDevice } from './types';
interface Props {
device: DeviceWithVerification;
device: ExtendedDevice;
saveDeviceName: (deviceName: string) => Promise<void>;
}

View file

@ -72,8 +72,8 @@ const DeviceDetails: React.FC<Props> = ({
id: 'application',
heading: _t('Application'),
values: [
{ label: _t('Name'), value: device.clientName },
{ label: _t('Version'), value: device.clientVersion },
{ label: _t('Name'), value: device.appName },
{ label: _t('Version'), value: device.appVersion },
{ label: _t('URL'), value: device.url },
],
},

View file

@ -21,16 +21,16 @@ import { _t } from "../../../../languageHandler";
import { formatDate, formatRelativeTime } from "../../../../DateUtils";
import Heading from "../../typography/Heading";
import { INACTIVE_DEVICE_AGE_DAYS, isDeviceInactive } from "./filter";
import { DeviceWithVerification } from "./types";
import { DeviceType } from "./DeviceType";
import { ExtendedDevice } from "./types";
import { DeviceTypeIcon } from "./DeviceTypeIcon";
export interface DeviceTileProps {
device: DeviceWithVerification;
device: ExtendedDevice;
isSelected?: boolean;
children?: React.ReactNode;
onClick?: () => void;
}
const DeviceTileName: React.FC<{ device: DeviceWithVerification }> = ({ device }) => {
const DeviceTileName: React.FC<{ device: ExtendedDevice }> = ({ device }) => {
return <Heading size='h4'>
{ device.display_name || device.device_id }
</Heading>;
@ -48,7 +48,7 @@ const formatLastActivity = (timestamp: number, now = new Date().getTime()): stri
return formatRelativeTime(new Date(timestamp));
};
const getInactiveMetadata = (device: DeviceWithVerification): { id: string, value: React.ReactNode } | undefined => {
const getInactiveMetadata = (device: ExtendedDevice): { id: string, value: React.ReactNode } | undefined => {
const isInactive = isDeviceInactive(device);
if (!isInactive) {
@ -89,7 +89,11 @@ const DeviceTile: React.FC<DeviceTileProps> = ({
];
return <div className="mx_DeviceTile" data-testid={`device-tile-${device.device_id}`}>
<DeviceType isVerified={device.isVerified} isSelected={isSelected} />
<DeviceTypeIcon
isVerified={device.isVerified}
isSelected={isSelected}
deviceType={device.deviceType}
/>
<div className="mx_DeviceTile_info" onClick={onClick}>
<DeviceTileName device={device} />
<div className="mx_DeviceTile_metadata">

View file

@ -21,33 +21,39 @@ import { Icon as UnknownDeviceIcon } from '../../../../../res/img/element-icons/
import { Icon as VerifiedIcon } from '../../../../../res/img/e2e/verified.svg';
import { Icon as UnverifiedIcon } from '../../../../../res/img/e2e/warning.svg';
import { _t } from '../../../../languageHandler';
import { DeviceWithVerification } from './types';
import { ExtendedDevice } from './types';
import { DeviceType } from '../../../../utils/device/parseUserAgent';
interface Props {
isVerified?: DeviceWithVerification['isVerified'];
isVerified?: ExtendedDevice['isVerified'];
isSelected?: boolean;
deviceType?: DeviceType;
}
export const DeviceType: React.FC<Props> = ({ isVerified, isSelected }) => (
<div className={classNames('mx_DeviceType', {
mx_DeviceType_selected: isSelected,
export const DeviceTypeIcon: React.FC<Props> = ({
isVerified,
isSelected,
deviceType,
}) => (
<div className={classNames('mx_DeviceTypeIcon', {
mx_DeviceTypeIcon_selected: isSelected,
})}
>
{ /* TODO(kerrya) all devices have an unknown type until PSG-650 */ }
<UnknownDeviceIcon
className='mx_DeviceType_deviceIcon'
className='mx_DeviceTypeIcon_deviceIcon'
role='img'
aria-label={_t('Unknown device type')}
/>
{
isVerified
? <VerifiedIcon
className={classNames('mx_DeviceType_verificationIcon', 'verified')}
className={classNames('mx_DeviceTypeIcon_verificationIcon', 'verified')}
role='img'
aria-label={_t('Verified')}
/>
: <UnverifiedIcon
className={classNames('mx_DeviceType_verificationIcon', 'unverified')}
className={classNames('mx_DeviceTypeIcon_verificationIcon', 'unverified')}
role='img'
aria-label={_t('Unverified')}
/>

View file

@ -21,11 +21,11 @@ import AccessibleButton from '../../elements/AccessibleButton';
import DeviceSecurityCard from './DeviceSecurityCard';
import {
DeviceSecurityVariation,
DeviceWithVerification,
ExtendedDevice,
} from './types';
interface Props {
device: DeviceWithVerification;
device: ExtendedDevice;
onVerifyDevice?: () => void;
}

View file

@ -33,7 +33,7 @@ import SelectableDeviceTile from './SelectableDeviceTile';
import {
DevicesDictionary,
DeviceSecurityVariation,
DeviceWithVerification,
ExtendedDevice,
} from './types';
import { DevicesState } from './useOwnDevices';
import FilteredDeviceListHeader from './FilteredDeviceListHeader';
@ -42,27 +42,27 @@ interface Props {
devices: DevicesDictionary;
pushers: IPusher[];
localNotificationSettings: Map<string, LocalNotificationSettings>;
expandedDeviceIds: DeviceWithVerification['device_id'][];
signingOutDeviceIds: DeviceWithVerification['device_id'][];
selectedDeviceIds: DeviceWithVerification['device_id'][];
expandedDeviceIds: ExtendedDevice['device_id'][];
signingOutDeviceIds: ExtendedDevice['device_id'][];
selectedDeviceIds: ExtendedDevice['device_id'][];
filter?: DeviceSecurityVariation;
onFilterChange: (filter: DeviceSecurityVariation | undefined) => void;
onDeviceExpandToggle: (deviceId: DeviceWithVerification['device_id']) => void;
onSignOutDevices: (deviceIds: DeviceWithVerification['device_id'][]) => void;
onDeviceExpandToggle: (deviceId: ExtendedDevice['device_id']) => void;
onSignOutDevices: (deviceIds: ExtendedDevice['device_id'][]) => void;
saveDeviceName: DevicesState['saveDeviceName'];
onRequestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => void;
onRequestDeviceVerification?: (deviceId: ExtendedDevice['device_id']) => void;
setPushNotifications: (deviceId: string, enabled: boolean) => Promise<void>;
setSelectedDeviceIds: (deviceIds: DeviceWithVerification['device_id'][]) => void;
setSelectedDeviceIds: (deviceIds: ExtendedDevice['device_id'][]) => void;
supportsMSC3881?: boolean | undefined;
}
const isDeviceSelected = (
deviceId: DeviceWithVerification['device_id'],
selectedDeviceIds: DeviceWithVerification['device_id'][],
deviceId: ExtendedDevice['device_id'],
selectedDeviceIds: ExtendedDevice['device_id'][],
) => selectedDeviceIds.includes(deviceId);
// devices without timestamp metadata should be sorted last
const sortDevicesByLatestActivity = (left: DeviceWithVerification, right: DeviceWithVerification) =>
const sortDevicesByLatestActivity = (left: ExtendedDevice, right: ExtendedDevice) =>
(right.last_seen_ts || 0) - (left.last_seen_ts || 0);
const getFilteredSortedDevices = (devices: DevicesDictionary, filter?: DeviceSecurityVariation) =>
@ -149,7 +149,7 @@ const NoResults: React.FC<NoResultsProps> = ({ filter, clearFilter }) =>
</div>;
const DeviceListItem: React.FC<{
device: DeviceWithVerification;
device: ExtendedDevice;
pusher?: IPusher | undefined;
localNotificationSettings?: LocalNotificationSettings | undefined;
isExpanded: boolean;
@ -227,11 +227,11 @@ export const FilteredDeviceList =
}: Props, ref: ForwardedRef<HTMLDivElement>) => {
const sortedDevices = getFilteredSortedDevices(devices, filter);
function getPusherForDevice(device: DeviceWithVerification): IPusher | undefined {
function getPusherForDevice(device: ExtendedDevice): IPusher | undefined {
return pushers.find(pusher => pusher[PUSHER_DEVICE_ID.name] === device.device_id);
}
const toggleSelection = (deviceId: DeviceWithVerification['device_id']): void => {
const toggleSelection = (deviceId: ExtendedDevice['device_id']): void => {
if (isDeviceSelected(deviceId, selectedDeviceIds)) {
// remove from selection
setSelectedDeviceIds(selectedDeviceIds.filter(id => id !== deviceId));

View file

@ -23,13 +23,13 @@ import DeviceSecurityCard from './DeviceSecurityCard';
import { filterDevicesBySecurityRecommendation, INACTIVE_DEVICE_AGE_DAYS } from './filter';
import {
DeviceSecurityVariation,
DeviceWithVerification,
ExtendedDevice,
DevicesDictionary,
} from './types';
interface Props {
devices: DevicesDictionary;
currentDeviceId: DeviceWithVerification['device_id'];
currentDeviceId: ExtendedDevice['device_id'];
goToFilteredList: (filter: DeviceSecurityVariation) => void;
}
@ -38,7 +38,7 @@ const SecurityRecommendations: React.FC<Props> = ({
currentDeviceId,
goToFilteredList,
}) => {
const devicesArray = Object.values<DeviceWithVerification>(devices);
const devicesArray = Object.values<ExtendedDevice>(devices);
const unverifiedDevicesCount = filterDevicesBySecurityRecommendation(
devicesArray,

View file

@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { DeviceWithVerification, DeviceSecurityVariation } from "./types";
import { ExtendedDevice, DeviceSecurityVariation } from "./types";
type DeviceFilterCondition = (device: DeviceWithVerification) => boolean;
type DeviceFilterCondition = (device: ExtendedDevice) => boolean;
const MS_DAY = 24 * 60 * 60 * 1000;
export const INACTIVE_DEVICE_AGE_MS = 7.776e+9; // 90 days
@ -32,7 +32,7 @@ const filters: Record<DeviceSecurityVariation, DeviceFilterCondition> = {
};
export const filterDevicesBySecurityRecommendation = (
devices: DeviceWithVerification[],
devices: ExtendedDevice[],
securityVariations: DeviceSecurityVariation[],
) => {
const activeFilters = securityVariations.map(variation => filters[variation]);

View file

@ -16,14 +16,17 @@ limitations under the License.
import { IMyDevice } from "matrix-js-sdk/src/matrix";
import { ExtendedDeviceInformation } from "../../../../utils/device/parseUserAgent";
export type DeviceWithVerification = IMyDevice & { isVerified: boolean | null };
export type ExtendedDeviceInfo = {
clientName?: string;
clientVersion?: string;
export type ExtendedDeviceAppInfo = {
// eg Element Web
appName?: string;
appVersion?: string;
url?: string;
};
export type ExtendedDevice = DeviceWithVerification & ExtendedDeviceInfo;
export type DevicesDictionary = Record<DeviceWithVerification['device_id'], ExtendedDevice>;
export type ExtendedDevice = DeviceWithVerification & ExtendedDeviceAppInfo & ExtendedDeviceInformation;
export type DevicesDictionary = Record<ExtendedDevice['device_id'], ExtendedDevice>;
export enum DeviceSecurityVariation {
Verified = 'Verified',

View file

@ -24,6 +24,7 @@ import {
MatrixEvent,
PUSHER_DEVICE_ID,
PUSHER_ENABLED,
UNSTABLE_MSC3852_LAST_SEEN_UA,
} 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";
@ -34,8 +35,9 @@ import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifi
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import { _t } from "../../../../languageHandler";
import { getDeviceClientInformation } from "../../../../utils/device/clientInformation";
import { DevicesDictionary, DeviceWithVerification, ExtendedDeviceInfo } from "./types";
import { DevicesDictionary, ExtendedDevice, ExtendedDeviceAppInfo } from "./types";
import { useEventEmitter } from "../../../../hooks/useEventEmitter";
import { parseUserAgent } from "../../../../utils/device/parseUserAgent";
const isDeviceVerified = (
matrixClient: MatrixClient,
@ -63,12 +65,12 @@ const isDeviceVerified = (
}
};
const parseDeviceExtendedInformation = (matrixClient: MatrixClient, device: IMyDevice): ExtendedDeviceInfo => {
const parseDeviceExtendedInformation = (matrixClient: MatrixClient, device: IMyDevice): ExtendedDeviceAppInfo => {
const { name, version, url } = getDeviceClientInformation(matrixClient, device.device_id);
return {
clientName: name,
clientVersion: version,
appName: name,
appVersion: version,
url,
};
};
@ -87,6 +89,7 @@ const fetchDevicesWithVerification = async (
...device,
isVerified: isDeviceVerified(matrixClient, crossSigningInfo, device),
...parseDeviceExtendedInformation(matrixClient, device),
...parseUserAgent(device[UNSTABLE_MSC3852_LAST_SEEN_UA.name]),
},
}), {});
@ -104,10 +107,10 @@ export type DevicesState = {
currentDeviceId: string;
isLoadingDeviceList: boolean;
// not provided when current session cannot request verification
requestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => Promise<VerificationRequest>;
requestDeviceVerification?: (deviceId: ExtendedDevice['device_id']) => Promise<VerificationRequest>;
refreshDevices: () => Promise<void>;
saveDeviceName: (deviceId: DeviceWithVerification['device_id'], deviceName: string) => Promise<void>;
setPushNotifications: (deviceId: DeviceWithVerification['device_id'], enabled: boolean) => Promise<void>;
saveDeviceName: (deviceId: ExtendedDevice['device_id'], deviceName: string) => Promise<void>;
setPushNotifications: (deviceId: ExtendedDevice['device_id'], enabled: boolean) => Promise<void>;
error?: OwnDevicesError;
supportsMSC3881?: boolean | undefined;
};
@ -189,7 +192,7 @@ export const useOwnDevices = (): DevicesState => {
const isCurrentDeviceVerified = !!devices[currentDeviceId]?.isVerified;
const requestDeviceVerification = isCurrentDeviceVerified && userId
? async (deviceId: DeviceWithVerification['device_id']) => {
? async (deviceId: ExtendedDevice['device_id']) => {
return await matrixClient.requestVerification(
userId,
[deviceId],
@ -198,7 +201,7 @@ export const useOwnDevices = (): DevicesState => {
: undefined;
const saveDeviceName = useCallback(
async (deviceId: DeviceWithVerification['device_id'], deviceName: string): Promise<void> => {
async (deviceId: ExtendedDevice['device_id'], deviceName: string): Promise<void> => {
const device = devices[deviceId];
// no change
@ -219,7 +222,7 @@ export const useOwnDevices = (): DevicesState => {
}, [matrixClient, devices, refreshDevices]);
const setPushNotifications = useCallback(
async (deviceId: DeviceWithVerification['device_id'], enabled: boolean): Promise<void> => {
async (deviceId: ExtendedDevice['device_id'], enabled: boolean): Promise<void> => {
try {
const pusher = pushers.find(pusher => pusher[PUSHER_DEVICE_ID.name] === deviceId);
if (pusher) {

View file

@ -29,7 +29,7 @@ import { useOwnDevices } from '../../devices/useOwnDevices';
import { FilteredDeviceList } from '../../devices/FilteredDeviceList';
import CurrentDeviceSection from '../../devices/CurrentDeviceSection';
import SecurityRecommendations from '../../devices/SecurityRecommendations';
import { DeviceSecurityVariation, DeviceWithVerification } from '../../devices/types';
import { DeviceSecurityVariation, ExtendedDevice } from '../../devices/types';
import { deleteDevicesWithInteractiveAuth } from '../../devices/deleteDevices';
import SettingsTab from '../SettingsTab';
@ -38,10 +38,10 @@ const useSignOut = (
onSignoutResolvedCallback: () => Promise<void>,
): {
onSignOutCurrentDevice: () => void;
onSignOutOtherDevices: (deviceIds: DeviceWithVerification['device_id'][]) => Promise<void>;
signingOutDeviceIds: DeviceWithVerification['device_id'][];
onSignOutOtherDevices: (deviceIds: ExtendedDevice['device_id'][]) => Promise<void>;
signingOutDeviceIds: ExtendedDevice['device_id'][];
} => {
const [signingOutDeviceIds, setSigningOutDeviceIds] = useState<DeviceWithVerification['device_id'][]>([]);
const [signingOutDeviceIds, setSigningOutDeviceIds] = useState<ExtendedDevice['device_id'][]>([]);
const onSignOutCurrentDevice = () => {
Modal.createDialog(
@ -53,7 +53,7 @@ const useSignOut = (
);
};
const onSignOutOtherDevices = async (deviceIds: DeviceWithVerification['device_id'][]) => {
const onSignOutOtherDevices = async (deviceIds: ExtendedDevice['device_id'][]) => {
if (!deviceIds.length) {
return;
}
@ -96,8 +96,8 @@ const SessionManagerTab: React.FC = () => {
supportsMSC3881,
} = useOwnDevices();
const [filter, setFilter] = useState<DeviceSecurityVariation>();
const [expandedDeviceIds, setExpandedDeviceIds] = useState<DeviceWithVerification['device_id'][]>([]);
const [selectedDeviceIds, setSelectedDeviceIds] = useState<DeviceWithVerification['device_id'][]>([]);
const [expandedDeviceIds, setExpandedDeviceIds] = useState<ExtendedDevice['device_id'][]>([]);
const [selectedDeviceIds, setSelectedDeviceIds] = useState<ExtendedDevice['device_id'][]>([]);
const filteredDeviceListRef = useRef<HTMLDivElement>(null);
const scrollIntoViewTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
@ -105,7 +105,7 @@ const SessionManagerTab: React.FC = () => {
const userId = matrixClient.getUserId();
const currentUserMember = userId && matrixClient.getUser(userId) || undefined;
const onDeviceExpandToggle = (deviceId: DeviceWithVerification['device_id']): void => {
const onDeviceExpandToggle = (deviceId: ExtendedDevice['device_id']): void => {
if (expandedDeviceIds.includes(deviceId)) {
setExpandedDeviceIds(expandedDeviceIds.filter(id => id !== deviceId));
} else {
@ -136,7 +136,7 @@ const SessionManagerTab: React.FC = () => {
);
};
const onTriggerDeviceVerification = useCallback((deviceId: DeviceWithVerification['device_id']) => {
const onTriggerDeviceVerification = useCallback((deviceId: ExtendedDevice['device_id']) => {
if (!requestDeviceVerification) {
return;
}

View file

@ -0,0 +1,45 @@
/*
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.
*/
export enum DeviceType {
Desktop = 'Desktop',
Mobile = 'Mobile',
Web = 'Web',
Unknown = 'Unknown',
}
export type ExtendedDeviceInformation = {
deviceType: DeviceType;
// eg Google Pixel 6
deviceModel?: string;
// eg Android 11
deviceOperatingSystem?: string;
// eg Firefox
clientName?: string;
// eg 1.1.0
clientVersion?: string;
};
export const parseUserAgent = (userAgent?: string): ExtendedDeviceInformation => {
if (!userAgent) {
return {
deviceType: DeviceType.Unknown,
};
}
// @TODO(kerrya) not yet implemented
return {
deviceType: DeviceType.Unknown,
};
};