Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 4a5ed2f

Browse files
author
Kerry
authored
Device manager - label devices as inactive (PSG-638) (#9175)
* filter devices by security recommendation * display inactive status on device tile * unify DeviceSecurityVariation type, add correct icon to inactive ui * tidy * avoid dead code warning
1 parent d21498d commit 4a5ed2f

File tree

10 files changed

+189
-25
lines changed

10 files changed

+189
-25
lines changed

res/css/components/views/settings/devices/_DeviceTile.pcss

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ limitations under the License.
1818
display: flex;
1919
flex-direction: row;
2020
align-items: center;
21-
2221
width: 100%;
2322
}
2423

@@ -27,15 +26,21 @@ limitations under the License.
2726
}
2827

2928
.mx_DeviceTile_metadata {
30-
margin-top: 2px;
29+
margin-top: $spacing-4;
3130
font-size: $font-12px;
3231
color: $secondary-content;
32+
line-height: $font-14px;
33+
}
34+
35+
.mx_DeviceTile_inactiveIcon {
36+
height: 14px;
37+
margin-right: $spacing-8;
38+
vertical-align: middle;
3339
}
3440

3541
.mx_DeviceTile_actions {
3642
display: grid;
3743
grid-gap: $spacing-8;
3844
grid-auto-flow: column;
39-
4045
margin-left: $spacing-8;
4146
}

src/components/views/settings/devices/DeviceSecurityCard.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,7 @@ import React from 'react';
2020
import { Icon as VerifiedIcon } from '../../../../../res/img/e2e/verified.svg';
2121
import { Icon as UnverifiedIcon } from '../../../../../res/img/e2e/warning.svg';
2222
import { Icon as InactiveIcon } from '../../../../../res/img/element-icons/settings/inactive.svg';
23-
24-
export enum DeviceSecurityVariation {
25-
Verified = 'Verified',
26-
Unverified = 'Unverified',
27-
Inactive = 'Inactive',
28-
}
23+
import { DeviceSecurityVariation } from './filter';
2924
interface Props {
3025
variation: DeviceSecurityVariation;
3126
heading: string;

src/components/views/settings/devices/DeviceTile.tsx

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,14 @@ limitations under the License.
1616

1717
import React, { Fragment } from "react";
1818

19+
import { Icon as InactiveIcon } from '../../../../../res/img/element-icons/settings/inactive.svg';
1920
import { _t } from "../../../../languageHandler";
2021
import { formatDate, formatRelativeTime } from "../../../../DateUtils";
2122
import TooltipTarget from "../../elements/TooltipTarget";
2223
import { Alignment } from "../../elements/Tooltip";
2324
import Heading from "../../typography/Heading";
25+
import { INACTIVE_DEVICE_AGE_MS, isDeviceInactive } from "./filter";
2426
import { DeviceWithVerification } from "./useOwnDevices";
25-
2627
export interface DeviceTileProps {
2728
device: DeviceWithVerification;
2829
children?: React.ReactNode;
@@ -45,7 +46,8 @@ const DeviceTileName: React.FC<{ device: DeviceWithVerification }> = ({ device }
4546
</Heading>;
4647
};
4748

48-
const MS_6_DAYS = 6 * 24 * 60 * 60 * 1000;
49+
const MS_DAY = 24 * 60 * 60 * 1000;
50+
const MS_6_DAYS = 6 * MS_DAY;
4951
const formatLastActivity = (timestamp: number, now = new Date().getTime()): string => {
5052
// less than a week ago
5153
if (timestamp + MS_6_DAYS >= now) {
@@ -56,18 +58,40 @@ const formatLastActivity = (timestamp: number, now = new Date().getTime()): stri
5658
return formatRelativeTime(new Date(timestamp));
5759
};
5860

59-
const DeviceMetadata: React.FC<{ value: string, id: string }> = ({ value, id }) => (
61+
const getInactiveMetadata = (device: DeviceWithVerification): { id: string, value: React.ReactNode } | undefined => {
62+
const isInactive = isDeviceInactive(device);
63+
64+
if (!isInactive) {
65+
return undefined;
66+
}
67+
const inactiveAgeDays = Math.round(INACTIVE_DEVICE_AGE_MS / MS_DAY);
68+
return { id: 'inactive', value: (
69+
<>
70+
<InactiveIcon className="mx_DeviceTile_inactiveIcon" />
71+
{
72+
_t('Inactive for %(inactiveAgeDays)s+ days', { inactiveAgeDays }) +
73+
` (${formatLastActivity(device.last_seen_ts)})`
74+
}
75+
</>),
76+
};
77+
};
78+
79+
const DeviceMetadata: React.FC<{ value: string | React.ReactNode, id: string }> = ({ value, id }) => (
6080
value ? <span data-testid={`device-metadata-${id}`}>{ value }</span> : null
6181
);
6282

6383
const DeviceTile: React.FC<DeviceTileProps> = ({ device, children, onClick }) => {
84+
const inactive = getInactiveMetadata(device);
6485
const lastActivity = device.last_seen_ts && `${_t('Last activity')} ${formatLastActivity(device.last_seen_ts)}`;
6586
const verificationStatus = device.isVerified ? _t('Verified') : _t('Unverified');
66-
const metadata = [
67-
{ id: 'isVerified', value: verificationStatus },
68-
{ id: 'lastActivity', value: lastActivity },
69-
{ id: 'lastSeenIp', value: device.last_seen_ip },
70-
];
87+
// if device is inactive, don't display last activity or verificationStatus
88+
const metadata = inactive
89+
? [inactive, { id: 'lastSeenIp', value: device.last_seen_ip }]
90+
: [
91+
{ id: 'isVerified', value: verificationStatus },
92+
{ id: 'lastActivity', value: lastActivity },
93+
{ id: 'lastSeenIp', value: device.last_seen_ip },
94+
];
7195

7296
return <div className="mx_DeviceTile" data-testid={`device-tile-${device.device_id}`}>
7397
<div className="mx_DeviceTile_info" onClick={onClick}>

src/components/views/settings/devices/FilteredDeviceList.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
import React from 'react';
1818

1919
import DeviceTile from './DeviceTile';
20+
import { filterDevicesBySecurityRecommendation } from './filter';
2021
import { DevicesDictionary, DeviceWithVerification } from './useOwnDevices';
2122

2223
interface Props {
@@ -27,16 +28,17 @@ interface Props {
2728
const sortDevicesByLatestActivity = (left: DeviceWithVerification, right: DeviceWithVerification) =>
2829
(right.last_seen_ts || 0) - (left.last_seen_ts || 0);
2930

30-
const getSortedDevices = (devices: DevicesDictionary) =>
31-
Object.values(devices).sort(sortDevicesByLatestActivity);
31+
const getFilteredSortedDevices = (devices: DevicesDictionary) =>
32+
filterDevicesBySecurityRecommendation(Object.values(devices), [])
33+
.sort(sortDevicesByLatestActivity);
3234

3335
/**
3436
* Filtered list of devices
3537
* Sorted by latest activity descending
3638
* TODO(kerrya) Filtering to added as part of PSG-648
3739
*/
3840
const FilteredDeviceList: React.FC<Props> = ({ devices }) => {
39-
const sortedDevices = getSortedDevices(devices);
41+
const sortedDevices = getFilteredSortedDevices(devices);
4042

4143
return <ol className='mx_FilteredDeviceList'>
4244
{ sortedDevices.map((device) =>
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { DeviceWithVerification } from "./useOwnDevices";
18+
19+
export enum DeviceSecurityVariation {
20+
Verified = 'Verified',
21+
Unverified = 'Unverified',
22+
Inactive = 'Inactive',
23+
}
24+
25+
type DeviceFilterCondition = (device: DeviceWithVerification) => boolean;
26+
27+
export const INACTIVE_DEVICE_AGE_MS = 7.776e+9; // 90 days
28+
29+
export const isDeviceInactive: DeviceFilterCondition = device =>
30+
!!device.last_seen_ts && device.last_seen_ts < Date.now() - INACTIVE_DEVICE_AGE_MS;
31+
32+
const filters: Record<DeviceSecurityVariation, DeviceFilterCondition> = {
33+
[DeviceSecurityVariation.Verified]: device => !!device.isVerified,
34+
[DeviceSecurityVariation.Unverified]: device => !device.isVerified,
35+
[DeviceSecurityVariation.Inactive]: isDeviceInactive,
36+
};
37+
38+
export const filterDevicesBySecurityRecommendation = (
39+
devices: DeviceWithVerification[],
40+
securityVariations: DeviceSecurityVariation[],
41+
) => {
42+
const activeFilters = securityVariations.map(variation => filters[variation]);
43+
if (!activeFilters.length) {
44+
return devices;
45+
}
46+
return devices.filter(device => activeFilters.every(filter => filter(device)));
47+
};

src/components/views/settings/tabs/user/SessionManagerTab.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@ import { _t } from "../../../../../languageHandler";
2020
import Spinner from '../../../elements/Spinner';
2121
import { useOwnDevices } from '../../devices/useOwnDevices';
2222
import DeviceTile from '../../devices/DeviceTile';
23-
import DeviceSecurityCard, { DeviceSecurityVariation } from '../../devices/DeviceSecurityCard';
23+
import DeviceSecurityCard from '../../devices/DeviceSecurityCard';
2424
import SettingsSubsection from '../../shared/SettingsSubsection';
25-
import SettingsTab from '../SettingsTab';
2625
import FilteredDeviceList from '../../devices/FilteredDeviceList';
26+
import { DeviceSecurityVariation } from '../../devices/filter';
27+
import SettingsTab from '../SettingsTab';
2728

2829
const SessionManagerTab: React.FC = () => {
2930
const { devices, currentDeviceId, isLoading } = useOwnDevices();

src/i18n/strings/en_EN.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1703,6 +1703,7 @@
17031703
"Device": "Device",
17041704
"IP address": "IP address",
17051705
"Session details": "Session details",
1706+
"Inactive for %(inactiveAgeDays)s+ days": "Inactive for %(inactiveAgeDays)s+ days",
17061707
"Verified": "Verified",
17071708
"Unverified": "Unverified",
17081709
"Unable to remove contact information": "Unable to remove contact information",

test/components/views/settings/devices/DeviceSecurityCard-test.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,8 @@ limitations under the License.
1717
import { render } from '@testing-library/react';
1818
import React from 'react';
1919

20-
import DeviceSecurityCard, {
21-
DeviceSecurityVariation,
22-
} from '../../../../../src/components/views/settings/devices/DeviceSecurityCard';
20+
import DeviceSecurityCard from '../../../../../src/components/views/settings/devices/DeviceSecurityCard';
21+
import { DeviceSecurityVariation } from '../../../../../src/components/views/settings/devices/filter';
2322

2423
describe('<DeviceSecurityCard />', () => {
2524
const defaultProps = {

test/components/views/settings/devices/DeviceTile-test.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,5 +109,18 @@ describe('<DeviceTile />', () => {
109109
const { getByTestId } = render(getComponent({ device }));
110110
expect(getByTestId('device-metadata-lastActivity').textContent).toEqual('Last activity Dec 29, 2021');
111111
});
112+
113+
it('renders with inactive notice when last activity was more than 90 days ago', () => {
114+
const device: IMyDevice = {
115+
device_id: '123',
116+
last_seen_ip: '1.2.3.4',
117+
last_seen_ts: now - (MS_DAY * 100),
118+
};
119+
const { getByTestId, queryByTestId } = render(getComponent({ device }));
120+
expect(getByTestId('device-metadata-inactive').textContent).toEqual('Inactive for 90+ days (Dec 4, 2021)');
121+
// last activity and verification not shown when inactive
122+
expect(queryByTestId('device-metadata-lastActivity')).toBeFalsy();
123+
expect(queryByTestId('device-metadata-verificationStatus')).toBeFalsy();
124+
});
112125
});
113126
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import {
18+
DeviceSecurityVariation,
19+
filterDevicesBySecurityRecommendation,
20+
} from "../../../../../src/components/views/settings/devices/filter";
21+
22+
const MS_DAY = 86400000;
23+
describe('filterDevicesBySecurityRecommendation()', () => {
24+
const unverifiedNoMetadata = { device_id: 'unverified-no-metadata', isVerified: false };
25+
const verifiedNoMetadata = { device_id: 'verified-no-metadata', isVerified: true };
26+
const hundredDaysOld = { device_id: '100-days-old', isVerified: true, last_seen_ts: Date.now() - (MS_DAY * 100) };
27+
const hundredDaysOldUnverified = {
28+
device_id: 'unverified-100-days-old',
29+
isVerified: false,
30+
last_seen_ts: Date.now() - (MS_DAY * 100),
31+
};
32+
const fiftyDaysOld = { device_id: '50-days-old', isVerified: true, last_seen_ts: Date.now() - (MS_DAY * 50) };
33+
34+
const devices = [
35+
unverifiedNoMetadata,
36+
verifiedNoMetadata,
37+
hundredDaysOld,
38+
hundredDaysOldUnverified,
39+
fiftyDaysOld,
40+
];
41+
42+
it('returns all devices when no securityRecommendations are passed', () => {
43+
expect(filterDevicesBySecurityRecommendation(devices, [])).toBe(devices);
44+
});
45+
46+
it('returns devices older than 90 days as inactive', () => {
47+
expect(filterDevicesBySecurityRecommendation(devices, [DeviceSecurityVariation.Inactive])).toEqual([
48+
// devices without ts metadata are not filtered as inactive
49+
hundredDaysOld,
50+
hundredDaysOldUnverified,
51+
]);
52+
});
53+
54+
it('returns correct devices for verified filter', () => {
55+
expect(filterDevicesBySecurityRecommendation(devices, [DeviceSecurityVariation.Verified])).toEqual([
56+
verifiedNoMetadata,
57+
hundredDaysOld,
58+
fiftyDaysOld,
59+
]);
60+
});
61+
62+
it('returns correct devices for unverified filter', () => {
63+
expect(filterDevicesBySecurityRecommendation(devices, [DeviceSecurityVariation.Unverified])).toEqual([
64+
unverifiedNoMetadata,
65+
hundredDaysOldUnverified,
66+
]);
67+
});
68+
69+
it('returns correct devices for combined verified and inactive filters', () => {
70+
expect(filterDevicesBySecurityRecommendation(
71+
devices,
72+
[DeviceSecurityVariation.Unverified, DeviceSecurityVariation.Inactive],
73+
)).toEqual([
74+
hundredDaysOldUnverified,
75+
]);
76+
});
77+
});

0 commit comments

Comments
 (0)