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;
}