Device manager - device list filtering (PSG-648) (#9181)
* add device filtering * improve dropdown styling * test device filtering * update type imports * fix types * security card margin * more specific type for onFilterOptionChange
This commit is contained in:
parent
aa9191bc34
commit
6f2c761fb4
11 changed files with 529 additions and 43 deletions
|
@ -64,7 +64,7 @@ limitations under the License.
|
||||||
margin: 0 0 $spacing-4 0;
|
margin: 0 0 $spacing-4 0;
|
||||||
}
|
}
|
||||||
.mx_DeviceSecurityCard_description {
|
.mx_DeviceSecurityCard_description {
|
||||||
margin: 0 0 $spacing-8 0;
|
margin: 0;
|
||||||
font-size: $font-12px;
|
font-size: $font-12px;
|
||||||
color: $secondary-content;
|
color: $secondary-content;
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,9 +15,45 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.mx_FilteredDeviceList {
|
.mx_FilteredDeviceList {
|
||||||
|
.mx_Dropdown {
|
||||||
|
flex: 1 0 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_FilteredDeviceList_header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 $spacing-16;
|
||||||
|
margin-bottom: $spacing-32;
|
||||||
|
|
||||||
|
background-color: $system;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: $secondary-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_FilteredDeviceList_headerLabel {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_FilteredDeviceList_list {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-gap: $spacing-16;
|
grid-gap: $spacing-16;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 $spacing-8;
|
padding: 0 $spacing-8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_FilteredDeviceList_securityCard {
|
||||||
|
margin-bottom: $spacing-32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_FilteredDeviceList_noResults {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: $spacing-32;
|
||||||
|
}
|
||||||
|
|
|
@ -22,7 +22,7 @@ import { formatDate, formatRelativeTime } from "../../../../DateUtils";
|
||||||
import TooltipTarget from "../../elements/TooltipTarget";
|
import TooltipTarget from "../../elements/TooltipTarget";
|
||||||
import { Alignment } from "../../elements/Tooltip";
|
import { Alignment } from "../../elements/Tooltip";
|
||||||
import Heading from "../../typography/Heading";
|
import Heading from "../../typography/Heading";
|
||||||
import { INACTIVE_DEVICE_AGE_MS, isDeviceInactive } from "./filter";
|
import { INACTIVE_DEVICE_AGE_DAYS, isDeviceInactive } from "./filter";
|
||||||
import { DeviceWithVerification } from "./types";
|
import { DeviceWithVerification } from "./types";
|
||||||
export interface DeviceTileProps {
|
export interface DeviceTileProps {
|
||||||
device: DeviceWithVerification;
|
device: DeviceWithVerification;
|
||||||
|
@ -64,12 +64,11 @@ const getInactiveMetadata = (device: DeviceWithVerification): { id: string, valu
|
||||||
if (!isInactive) {
|
if (!isInactive) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const inactiveAgeDays = Math.round(INACTIVE_DEVICE_AGE_MS / MS_DAY);
|
|
||||||
return { id: 'inactive', value: (
|
return { id: 'inactive', value: (
|
||||||
<>
|
<>
|
||||||
<InactiveIcon className="mx_DeviceTile_inactiveIcon" />
|
<InactiveIcon className="mx_DeviceTile_inactiveIcon" />
|
||||||
{
|
{
|
||||||
_t('Inactive for %(inactiveAgeDays)s+ days', { inactiveAgeDays }) +
|
_t('Inactive for %(inactiveAgeDays)s+ days', { inactiveAgeDays: INACTIVE_DEVICE_AGE_DAYS }) +
|
||||||
` (${formatLastActivity(device.last_seen_ts)})`
|
` (${formatLastActivity(device.last_seen_ts)})`
|
||||||
}
|
}
|
||||||
</>),
|
</>),
|
||||||
|
|
|
@ -16,31 +16,167 @@ limitations under the License.
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { _t } from '../../../../languageHandler';
|
||||||
|
import AccessibleButton from '../../elements/AccessibleButton';
|
||||||
|
import Dropdown from '../../elements/Dropdown';
|
||||||
|
import DeviceSecurityCard from './DeviceSecurityCard';
|
||||||
import DeviceTile from './DeviceTile';
|
import DeviceTile from './DeviceTile';
|
||||||
import { filterDevicesBySecurityRecommendation } from './filter';
|
import {
|
||||||
import { DevicesDictionary, DeviceWithVerification } from './types';
|
filterDevicesBySecurityRecommendation,
|
||||||
|
INACTIVE_DEVICE_AGE_DAYS,
|
||||||
|
} from './filter';
|
||||||
|
import {
|
||||||
|
DevicesDictionary,
|
||||||
|
DeviceSecurityVariation,
|
||||||
|
DeviceWithVerification,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
devices: DevicesDictionary;
|
devices: DevicesDictionary;
|
||||||
|
filter?: DeviceSecurityVariation;
|
||||||
|
onFilterChange: (filter: DeviceSecurityVariation | undefined) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// devices without timestamp metadata should be sorted last
|
// devices without timestamp metadata should be sorted last
|
||||||
const sortDevicesByLatestActivity = (left: DeviceWithVerification, right: DeviceWithVerification) =>
|
const sortDevicesByLatestActivity = (left: DeviceWithVerification, right: DeviceWithVerification) =>
|
||||||
(right.last_seen_ts || 0) - (left.last_seen_ts || 0);
|
(right.last_seen_ts || 0) - (left.last_seen_ts || 0);
|
||||||
|
|
||||||
const getFilteredSortedDevices = (devices: DevicesDictionary) =>
|
const getFilteredSortedDevices = (devices: DevicesDictionary, filter: DeviceSecurityVariation) =>
|
||||||
filterDevicesBySecurityRecommendation(Object.values(devices), [])
|
filterDevicesBySecurityRecommendation(Object.values(devices), filter ? [filter] : [])
|
||||||
.sort(sortDevicesByLatestActivity);
|
.sort(sortDevicesByLatestActivity);
|
||||||
|
|
||||||
|
const ALL_FILTER_ID = 'ALL';
|
||||||
|
|
||||||
|
const FilterSecurityCard: React.FC<{ filter?: DeviceSecurityVariation | string }> = ({ filter }) => {
|
||||||
|
switch (filter) {
|
||||||
|
case DeviceSecurityVariation.Verified:
|
||||||
|
return <div className='mx_FilteredDeviceList_securityCard'>
|
||||||
|
<DeviceSecurityCard
|
||||||
|
variation={DeviceSecurityVariation.Verified}
|
||||||
|
heading={_t('Verified sessions')}
|
||||||
|
description={_t(
|
||||||
|
`For best security, sign out from any session` +
|
||||||
|
` that you don't recognize or use anymore.`,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
;
|
||||||
|
case DeviceSecurityVariation.Unverified:
|
||||||
|
return <div className='mx_FilteredDeviceList_securityCard'>
|
||||||
|
<DeviceSecurityCard
|
||||||
|
variation={DeviceSecurityVariation.Unverified}
|
||||||
|
heading={_t('Unverified sessions')}
|
||||||
|
description={_t(
|
||||||
|
`Verify your sessions for enhanced secure messaging or sign out`
|
||||||
|
+ ` from those you don't recognize or use anymore.`,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
;
|
||||||
|
case DeviceSecurityVariation.Inactive:
|
||||||
|
return <div className='mx_FilteredDeviceList_securityCard'>
|
||||||
|
<DeviceSecurityCard
|
||||||
|
variation={DeviceSecurityVariation.Inactive}
|
||||||
|
heading={_t('Inactive sessions')}
|
||||||
|
description={_t(
|
||||||
|
`Consider signing out from old sessions ` +
|
||||||
|
`(%(inactiveAgeDays)s days or older) you don't use anymore`,
|
||||||
|
{ inactiveAgeDays: INACTIVE_DEVICE_AGE_DAYS },
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNoResultsMessage = (filter: DeviceSecurityVariation): string => {
|
||||||
|
switch (filter) {
|
||||||
|
case DeviceSecurityVariation.Verified:
|
||||||
|
return _t('No verified sessions found.');
|
||||||
|
case DeviceSecurityVariation.Unverified:
|
||||||
|
return _t('No unverified sessions found.');
|
||||||
|
case DeviceSecurityVariation.Inactive:
|
||||||
|
return _t('No inactive sessions found.');
|
||||||
|
default:
|
||||||
|
return _t('No sessions found.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
interface NoResultsProps { filter: DeviceSecurityVariation, clearFilter: () => void}
|
||||||
|
const NoResults: React.FC<NoResultsProps> = ({ filter, clearFilter }) =>
|
||||||
|
<div className='mx_FilteredDeviceList_noResults'>
|
||||||
|
{ getNoResultsMessage(filter) }
|
||||||
|
{
|
||||||
|
/* No clear filter button when filter is falsy (ie 'All') */
|
||||||
|
!!filter &&
|
||||||
|
<>
|
||||||
|
|
||||||
|
<AccessibleButton
|
||||||
|
kind='link_inline'
|
||||||
|
onClick={clearFilter}
|
||||||
|
data-testid='devices-clear-filter-btn'
|
||||||
|
>
|
||||||
|
{ _t('Show all') }
|
||||||
|
</AccessibleButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</div>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filtered list of devices
|
* Filtered list of devices
|
||||||
* Sorted by latest activity descending
|
* Sorted by latest activity descending
|
||||||
* TODO(kerrya) Filtering to added as part of PSG-648
|
|
||||||
*/
|
*/
|
||||||
const FilteredDeviceList: React.FC<Props> = ({ devices }) => {
|
const FilteredDeviceList: React.FC<Props> = ({ devices, filter, onFilterChange }) => {
|
||||||
const sortedDevices = getFilteredSortedDevices(devices);
|
const sortedDevices = getFilteredSortedDevices(devices, filter);
|
||||||
|
|
||||||
return <ol className='mx_FilteredDeviceList'>
|
const options = [
|
||||||
|
{ id: ALL_FILTER_ID, label: _t('All') },
|
||||||
|
{
|
||||||
|
id: DeviceSecurityVariation.Verified,
|
||||||
|
label: _t('Verified'),
|
||||||
|
description: _t('Ready for secure messaging'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: DeviceSecurityVariation.Unverified,
|
||||||
|
label: _t('Unverified'),
|
||||||
|
description: _t('Not ready for secure messaging'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: DeviceSecurityVariation.Inactive,
|
||||||
|
label: _t('Inactive'),
|
||||||
|
description: _t(
|
||||||
|
'Inactive for %(inactiveAgeDays)s days or longer',
|
||||||
|
{ inactiveAgeDays: INACTIVE_DEVICE_AGE_DAYS },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const onFilterOptionChange = (filterId: DeviceSecurityVariation | typeof ALL_FILTER_ID) => {
|
||||||
|
onFilterChange(filterId === ALL_FILTER_ID ? undefined : filterId as DeviceSecurityVariation);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <div className='mx_FilteredDeviceList'>
|
||||||
|
<div className='mx_FilteredDeviceList_header'>
|
||||||
|
<span className='mx_FilteredDeviceList_headerLabel'>
|
||||||
|
{ _t('Sessions') }
|
||||||
|
</span>
|
||||||
|
<Dropdown
|
||||||
|
id='device-list-filter'
|
||||||
|
label={_t('Filter devices')}
|
||||||
|
value={filter || ALL_FILTER_ID}
|
||||||
|
onOptionChange={onFilterOptionChange}
|
||||||
|
>
|
||||||
|
{ options.map(({ id, label }) =>
|
||||||
|
<div data-test-id={`device-filter-option-${id}`} key={id}>{ label }</div>,
|
||||||
|
) }
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
{ !!sortedDevices.length
|
||||||
|
? <FilterSecurityCard filter={filter} />
|
||||||
|
: <NoResults filter={filter} clearFilter={() => onFilterChange(undefined)} />
|
||||||
|
}
|
||||||
|
<ol className='mx_FilteredDeviceList_list'>
|
||||||
{ sortedDevices.map((device) =>
|
{ sortedDevices.map((device) =>
|
||||||
<li key={device.device_id}>
|
<li key={device.device_id}>
|
||||||
<DeviceTile
|
<DeviceTile
|
||||||
|
@ -49,7 +185,9 @@ const FilteredDeviceList: React.FC<Props> = ({ devices }) => {
|
||||||
</li>,
|
</li>,
|
||||||
|
|
||||||
) }
|
) }
|
||||||
</ol>;
|
</ol>
|
||||||
|
</div>
|
||||||
|
;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FilteredDeviceList;
|
export default FilteredDeviceList;
|
||||||
|
|
|
@ -20,16 +20,19 @@ import { _t } from '../../../../languageHandler';
|
||||||
import AccessibleButton from '../../elements/AccessibleButton';
|
import AccessibleButton from '../../elements/AccessibleButton';
|
||||||
import SettingsSubsection from '../shared/SettingsSubsection';
|
import SettingsSubsection from '../shared/SettingsSubsection';
|
||||||
import DeviceSecurityCard from './DeviceSecurityCard';
|
import DeviceSecurityCard from './DeviceSecurityCard';
|
||||||
import { filterDevicesBySecurityRecommendation, INACTIVE_DEVICE_AGE_MS } from './filter';
|
import { filterDevicesBySecurityRecommendation, INACTIVE_DEVICE_AGE_DAYS } from './filter';
|
||||||
import { DevicesDictionary, DeviceSecurityVariation } from './types';
|
import {
|
||||||
|
DeviceSecurityVariation,
|
||||||
|
DeviceWithVerification,
|
||||||
|
DevicesDictionary,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
devices: DevicesDictionary;
|
devices: DevicesDictionary;
|
||||||
}
|
}
|
||||||
const MS_DAY = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
const SecurityRecommendations: React.FC<Props> = ({ devices }) => {
|
const SecurityRecommendations: React.FC<Props> = ({ devices }) => {
|
||||||
const devicesArray = Object.values(devices);
|
const devicesArray = Object.values<DeviceWithVerification>(devices);
|
||||||
|
|
||||||
const unverifiedDevicesCount = filterDevicesBySecurityRecommendation(
|
const unverifiedDevicesCount = filterDevicesBySecurityRecommendation(
|
||||||
devicesArray,
|
devicesArray,
|
||||||
|
@ -44,7 +47,7 @@ const SecurityRecommendations: React.FC<Props> = ({ devices }) => {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inactiveAgeDays = INACTIVE_DEVICE_AGE_MS / MS_DAY;
|
const inactiveAgeDays = INACTIVE_DEVICE_AGE_DAYS;
|
||||||
|
|
||||||
// TODO(kerrya) stubbed until PSG-640/652
|
// TODO(kerrya) stubbed until PSG-640/652
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
|
@ -18,7 +18,9 @@ import { DeviceWithVerification, DeviceSecurityVariation } from "./types";
|
||||||
|
|
||||||
type DeviceFilterCondition = (device: DeviceWithVerification) => boolean;
|
type DeviceFilterCondition = (device: DeviceWithVerification) => boolean;
|
||||||
|
|
||||||
|
const MS_DAY = 24 * 60 * 60 * 1000;
|
||||||
export const INACTIVE_DEVICE_AGE_MS = 7.776e+9; // 90 days
|
export const INACTIVE_DEVICE_AGE_MS = 7.776e+9; // 90 days
|
||||||
|
export const INACTIVE_DEVICE_AGE_DAYS = INACTIVE_DEVICE_AGE_MS / MS_DAY;
|
||||||
|
|
||||||
export const isDeviceInactive: DeviceFilterCondition = device =>
|
export const isDeviceInactive: DeviceFilterCondition = device =>
|
||||||
!!device.last_seen_ts && device.last_seen_ts < Date.now() - INACTIVE_DEVICE_AGE_MS;
|
!!device.last_seen_ts && device.last_seen_ts < Date.now() - INACTIVE_DEVICE_AGE_MS;
|
||||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import { _t } from "../../../../../languageHandler";
|
import { _t } from "../../../../../languageHandler";
|
||||||
import { useOwnDevices } from '../../devices/useOwnDevices';
|
import { useOwnDevices } from '../../devices/useOwnDevices';
|
||||||
|
@ -22,10 +22,12 @@ import SettingsSubsection from '../../shared/SettingsSubsection';
|
||||||
import FilteredDeviceList from '../../devices/FilteredDeviceList';
|
import FilteredDeviceList from '../../devices/FilteredDeviceList';
|
||||||
import CurrentDeviceSection from '../../devices/CurrentDeviceSection';
|
import CurrentDeviceSection from '../../devices/CurrentDeviceSection';
|
||||||
import SecurityRecommendations from '../../devices/SecurityRecommendations';
|
import SecurityRecommendations from '../../devices/SecurityRecommendations';
|
||||||
|
import { DeviceSecurityVariation } from '../../devices/types';
|
||||||
import SettingsTab from '../SettingsTab';
|
import SettingsTab from '../SettingsTab';
|
||||||
|
|
||||||
const SessionManagerTab: React.FC = () => {
|
const SessionManagerTab: React.FC = () => {
|
||||||
const { devices, currentDeviceId, isLoading } = useOwnDevices();
|
const { devices, currentDeviceId, isLoading } = useOwnDevices();
|
||||||
|
const [filter, setFilter] = useState<DeviceSecurityVariation>();
|
||||||
|
|
||||||
const { [currentDeviceId]: currentDevice, ...otherDevices } = devices;
|
const { [currentDeviceId]: currentDevice, ...otherDevices } = devices;
|
||||||
const shouldShowOtherSessions = Object.keys(otherDevices).length > 0;
|
const shouldShowOtherSessions = Object.keys(otherDevices).length > 0;
|
||||||
|
@ -46,7 +48,11 @@ const SessionManagerTab: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
data-testid='other-sessions-section'
|
data-testid='other-sessions-section'
|
||||||
>
|
>
|
||||||
<FilteredDeviceList devices={otherDevices} />
|
<FilteredDeviceList
|
||||||
|
devices={otherDevices}
|
||||||
|
filter={filter}
|
||||||
|
onFilterChange={setFilter}
|
||||||
|
/>
|
||||||
</SettingsSubsection>
|
</SettingsSubsection>
|
||||||
}
|
}
|
||||||
</SettingsTab>;
|
</SettingsTab>;
|
||||||
|
|
|
@ -1708,13 +1708,26 @@
|
||||||
"This session is ready for secure messaging.": "This session is ready for secure messaging.",
|
"This session is ready for secure messaging.": "This session is ready for secure messaging.",
|
||||||
"Unverified session": "Unverified session",
|
"Unverified session": "Unverified session",
|
||||||
"Verify or sign out from this session for best security and reliability.": "Verify or sign out from this session for best security and reliability.",
|
"Verify or sign out from this session for best security and reliability.": "Verify or sign out from this session for best security and reliability.",
|
||||||
"Security recommendations": "Security recommendations",
|
"Verified sessions": "Verified sessions",
|
||||||
"Improve your account security by following these recommendations": "Improve your account security by following these recommendations",
|
"For best security, sign out from any session that you don't recognize or use anymore.": "For best security, sign out from any session that you don't recognize or use anymore.",
|
||||||
"Unverified sessions": "Unverified sessions",
|
"Unverified sessions": "Unverified sessions",
|
||||||
"Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.": "Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.",
|
"Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.": "Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.",
|
||||||
"View all": "View all",
|
|
||||||
"Inactive sessions": "Inactive sessions",
|
"Inactive sessions": "Inactive sessions",
|
||||||
"Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore": "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore",
|
"Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore": "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore",
|
||||||
|
"No verified sessions found.": "No verified sessions found.",
|
||||||
|
"No unverified sessions found.": "No unverified sessions found.",
|
||||||
|
"No inactive sessions found.": "No inactive sessions found.",
|
||||||
|
"No sessions found.": "No sessions found.",
|
||||||
|
"Show all": "Show all",
|
||||||
|
"All": "All",
|
||||||
|
"Ready for secure messaging": "Ready for secure messaging",
|
||||||
|
"Not ready for secure messaging": "Not ready for secure messaging",
|
||||||
|
"Inactive": "Inactive",
|
||||||
|
"Inactive for %(inactiveAgeDays)s days or longer": "Inactive for %(inactiveAgeDays)s days or longer",
|
||||||
|
"Filter devices": "Filter devices",
|
||||||
|
"Security recommendations": "Security recommendations",
|
||||||
|
"Improve your account security by following these recommendations": "Improve your account security by following these recommendations",
|
||||||
|
"View all": "View all",
|
||||||
"Unable to remove contact information": "Unable to remove contact information",
|
"Unable to remove contact information": "Unable to remove contact information",
|
||||||
"Remove %(email)s?": "Remove %(email)s?",
|
"Remove %(email)s?": "Remove %(email)s?",
|
||||||
"Invalid Email Address": "Invalid Email Address",
|
"Invalid Email Address": "Invalid Email Address",
|
||||||
|
@ -2234,7 +2247,6 @@
|
||||||
"Error decrypting video": "Error decrypting video",
|
"Error decrypting video": "Error decrypting video",
|
||||||
"Error processing voice message": "Error processing voice message",
|
"Error processing voice message": "Error processing voice message",
|
||||||
"Add reaction": "Add reaction",
|
"Add reaction": "Add reaction",
|
||||||
"Show all": "Show all",
|
|
||||||
"Reactions": "Reactions",
|
"Reactions": "Reactions",
|
||||||
"%(reactors)s reacted with %(content)s": "%(reactors)s reacted with %(content)s",
|
"%(reactors)s reacted with %(content)s": "%(reactors)s reacted with %(content)s",
|
||||||
"<reactors/><reactedWith>reacted with %(shortName)s</reactedWith>": "<reactors/><reactedWith>reacted with %(shortName)s</reactedWith>",
|
"<reactors/><reactedWith>reacted with %(shortName)s</reactedWith>": "<reactors/><reactedWith>reacted with %(shortName)s</reactedWith>",
|
||||||
|
|
|
@ -128,7 +128,7 @@ describe('<DevicesPanel />', () => {
|
||||||
|
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
// modal rendering has some weird sleeps
|
// modal rendering has some weird sleeps
|
||||||
await sleep(10);
|
await sleep(100);
|
||||||
|
|
||||||
expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith([device2.device_id], undefined);
|
expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith([device2.device_id], undefined);
|
||||||
|
|
||||||
|
|
|
@ -15,25 +15,39 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render } from '@testing-library/react';
|
import { act, fireEvent, render } from '@testing-library/react';
|
||||||
|
|
||||||
import FilteredDeviceList from '../../../../../src/components/views/settings/devices/FilteredDeviceList';
|
import FilteredDeviceList from '../../../../../src/components/views/settings/devices/FilteredDeviceList';
|
||||||
|
import { DeviceSecurityVariation } from '../../../../../src/components/views/settings/devices/types';
|
||||||
|
import { flushPromises, mockPlatformPeg } from '../../../../test-utils';
|
||||||
|
|
||||||
|
mockPlatformPeg();
|
||||||
|
|
||||||
|
const MS_DAY = 86400000;
|
||||||
describe('<FilteredDeviceList />', () => {
|
describe('<FilteredDeviceList />', () => {
|
||||||
const noMetaDevice = { device_id: 'no-meta-device', isVerified: true };
|
|
||||||
const oldDevice = { device_id: 'old', last_seen_ts: new Date(1993, 7, 3, 4).getTime(), isVerified: true };
|
|
||||||
const newDevice = {
|
const newDevice = {
|
||||||
device_id: 'new',
|
device_id: 'new',
|
||||||
last_seen_ts: new Date().getTime() - 500,
|
last_seen_ts: Date.now() - 500,
|
||||||
last_seen_ip: '123.456.789',
|
last_seen_ip: '123.456.789',
|
||||||
display_name: 'My Device',
|
display_name: 'My Device',
|
||||||
isVerified: true,
|
isVerified: true,
|
||||||
};
|
};
|
||||||
|
const unverifiedNoMetadata = { device_id: 'unverified-no-metadata', isVerified: false };
|
||||||
|
const verifiedNoMetadata = { device_id: 'verified-no-metadata', isVerified: true };
|
||||||
|
const hundredDaysOld = { device_id: '100-days-old', isVerified: true, last_seen_ts: Date.now() - (MS_DAY * 100) };
|
||||||
|
const hundredDaysOldUnverified = {
|
||||||
|
device_id: 'unverified-100-days-old',
|
||||||
|
isVerified: false,
|
||||||
|
last_seen_ts: Date.now() - (MS_DAY * 100),
|
||||||
|
};
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
|
onFilterChange: jest.fn(),
|
||||||
devices: {
|
devices: {
|
||||||
[noMetaDevice.device_id]: noMetaDevice,
|
[unverifiedNoMetadata.device_id]: unverifiedNoMetadata,
|
||||||
[oldDevice.device_id]: oldDevice,
|
[verifiedNoMetadata.device_id]: verifiedNoMetadata,
|
||||||
[newDevice.device_id]: newDevice,
|
[newDevice.device_id]: newDevice,
|
||||||
|
[hundredDaysOld.device_id]: hundredDaysOld,
|
||||||
|
[hundredDaysOldUnverified.device_id]: hundredDaysOldUnverified,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const getComponent = (props = {}) =>
|
const getComponent = (props = {}) =>
|
||||||
|
@ -43,14 +57,16 @@ describe('<FilteredDeviceList />', () => {
|
||||||
const { container } = render(getComponent());
|
const { container } = render(getComponent());
|
||||||
const tiles = container.querySelectorAll('.mx_DeviceTile');
|
const tiles = container.querySelectorAll('.mx_DeviceTile');
|
||||||
expect(tiles[0].getAttribute('data-testid')).toEqual(`device-tile-${newDevice.device_id}`);
|
expect(tiles[0].getAttribute('data-testid')).toEqual(`device-tile-${newDevice.device_id}`);
|
||||||
expect(tiles[1].getAttribute('data-testid')).toEqual(`device-tile-${oldDevice.device_id}`);
|
expect(tiles[1].getAttribute('data-testid')).toEqual(`device-tile-${hundredDaysOld.device_id}`);
|
||||||
expect(tiles[2].getAttribute('data-testid')).toEqual(`device-tile-${noMetaDevice.device_id}`);
|
expect(tiles[2].getAttribute('data-testid')).toEqual(`device-tile-${hundredDaysOldUnverified.device_id}`);
|
||||||
|
expect(tiles[3].getAttribute('data-testid')).toEqual(`device-tile-${unverifiedNoMetadata.device_id}`);
|
||||||
|
expect(tiles[4].getAttribute('data-testid')).toEqual(`device-tile-${verifiedNoMetadata.device_id}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates list order when devices change', () => {
|
it('updates list order when devices change', () => {
|
||||||
const updatedOldDevice = { ...oldDevice, last_seen_ts: new Date().getTime() };
|
const updatedOldDevice = { ...hundredDaysOld, last_seen_ts: new Date().getTime() };
|
||||||
const updatedDevices = {
|
const updatedDevices = {
|
||||||
[oldDevice.device_id]: updatedOldDevice,
|
[hundredDaysOld.device_id]: updatedOldDevice,
|
||||||
[newDevice.device_id]: newDevice,
|
[newDevice.device_id]: newDevice,
|
||||||
};
|
};
|
||||||
const { container, rerender } = render(getComponent());
|
const { container, rerender } = render(getComponent());
|
||||||
|
@ -59,7 +75,108 @@ describe('<FilteredDeviceList />', () => {
|
||||||
|
|
||||||
const tiles = container.querySelectorAll('.mx_DeviceTile');
|
const tiles = container.querySelectorAll('.mx_DeviceTile');
|
||||||
expect(tiles.length).toBe(2);
|
expect(tiles.length).toBe(2);
|
||||||
expect(tiles[0].getAttribute('data-testid')).toEqual(`device-tile-${oldDevice.device_id}`);
|
expect(tiles[0].getAttribute('data-testid')).toEqual(`device-tile-${hundredDaysOld.device_id}`);
|
||||||
expect(tiles[1].getAttribute('data-testid')).toEqual(`device-tile-${newDevice.device_id}`);
|
expect(tiles[1].getAttribute('data-testid')).toEqual(`device-tile-${newDevice.device_id}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('displays no results message when there are no devices', () => {
|
||||||
|
const { container } = render(getComponent({ devices: {} }));
|
||||||
|
|
||||||
|
expect(container.getElementsByClassName('mx_FilteredDeviceList_noResults')).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filtering', () => {
|
||||||
|
const setFilter = async (
|
||||||
|
container: HTMLElement,
|
||||||
|
option: DeviceSecurityVariation | string,
|
||||||
|
) => await act(async () => {
|
||||||
|
const dropdown = container.querySelector('[aria-label="Filter devices"]');
|
||||||
|
|
||||||
|
fireEvent.click(dropdown);
|
||||||
|
// tick to let dropdown render
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
fireEvent.click(container.querySelector(`#device-list-filter__${option}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not display filter description when filter is falsy', () => {
|
||||||
|
const { container } = render(getComponent({ filter: undefined }));
|
||||||
|
const tiles = container.querySelectorAll('.mx_DeviceTile');
|
||||||
|
expect(container.getElementsByClassName('mx_FilteredDeviceList_securityCard').length).toBeFalsy();
|
||||||
|
expect(tiles.length).toEqual(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates filter when prop changes', () => {
|
||||||
|
const { container, rerender } = render(getComponent({ filter: DeviceSecurityVariation.Verified }));
|
||||||
|
const tiles = container.querySelectorAll('.mx_DeviceTile');
|
||||||
|
expect(tiles.length).toEqual(3);
|
||||||
|
expect(tiles[0].getAttribute('data-testid')).toEqual(`device-tile-${newDevice.device_id}`);
|
||||||
|
expect(tiles[1].getAttribute('data-testid')).toEqual(`device-tile-${hundredDaysOld.device_id}`);
|
||||||
|
expect(tiles[2].getAttribute('data-testid')).toEqual(`device-tile-${verifiedNoMetadata.device_id}`);
|
||||||
|
|
||||||
|
rerender(getComponent({ filter: DeviceSecurityVariation.Inactive }));
|
||||||
|
|
||||||
|
const rerenderedTiles = container.querySelectorAll('.mx_DeviceTile');
|
||||||
|
expect(rerenderedTiles.length).toEqual(2);
|
||||||
|
expect(rerenderedTiles[0].getAttribute('data-testid')).toEqual(`device-tile-${hundredDaysOld.device_id}`);
|
||||||
|
expect(rerenderedTiles[1].getAttribute('data-testid')).toEqual(
|
||||||
|
`device-tile-${hundredDaysOldUnverified.device_id}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onFilterChange handler', async () => {
|
||||||
|
const onFilterChange = jest.fn();
|
||||||
|
const { container } = render(getComponent({ onFilterChange }));
|
||||||
|
await setFilter(container, DeviceSecurityVariation.Verified);
|
||||||
|
|
||||||
|
expect(onFilterChange).toHaveBeenCalledWith(DeviceSecurityVariation.Verified);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onFilterChange handler correctly when setting filter to All', async () => {
|
||||||
|
const onFilterChange = jest.fn();
|
||||||
|
const { container } = render(getComponent({ onFilterChange, filter: DeviceSecurityVariation.Verified }));
|
||||||
|
await setFilter(container, 'ALL');
|
||||||
|
|
||||||
|
// filter is cleared
|
||||||
|
expect(onFilterChange).toHaveBeenCalledWith(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[DeviceSecurityVariation.Verified, [newDevice, hundredDaysOld, verifiedNoMetadata]],
|
||||||
|
[DeviceSecurityVariation.Unverified, [hundredDaysOldUnverified, unverifiedNoMetadata]],
|
||||||
|
[DeviceSecurityVariation.Inactive, [hundredDaysOld, hundredDaysOldUnverified]],
|
||||||
|
])('filters correctly for %s', (filter, expectedDevices) => {
|
||||||
|
const { container } = render(getComponent({ filter }));
|
||||||
|
expect(container.getElementsByClassName('mx_FilteredDeviceList_securityCard')).toMatchSnapshot();
|
||||||
|
const tileDeviceIds = [...container.querySelectorAll('.mx_DeviceTile')]
|
||||||
|
.map(tile => tile.getAttribute('data-testid'));
|
||||||
|
expect(tileDeviceIds).toEqual(expectedDevices.map(device => `device-tile-${device.device_id}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[DeviceSecurityVariation.Verified],
|
||||||
|
[DeviceSecurityVariation.Unverified],
|
||||||
|
[DeviceSecurityVariation.Inactive],
|
||||||
|
])('renders no results correctly for %s', (filter) => {
|
||||||
|
const { container } = render(getComponent({ filter, devices: {} }));
|
||||||
|
expect(container.getElementsByClassName('mx_FilteredDeviceList_securityCard').length).toBeFalsy();
|
||||||
|
expect(container.getElementsByClassName('mx_FilteredDeviceList_noResults')).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears filter from no results message', () => {
|
||||||
|
const onFilterChange = jest.fn();
|
||||||
|
const { getByTestId } = render(getComponent({
|
||||||
|
onFilterChange,
|
||||||
|
filter: DeviceSecurityVariation.Verified,
|
||||||
|
devices: {
|
||||||
|
[unverifiedNoMetadata.device_id]: unverifiedNoMetadata,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
act(() => {
|
||||||
|
fireEvent.click(getByTestId('devices-clear-filter-btn'));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onFilterChange).toHaveBeenCalledWith(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,173 @@
|
||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`<FilteredDeviceList /> displays no results message when there are no devices 1`] = `
|
||||||
|
HTMLCollection [
|
||||||
|
<div
|
||||||
|
class="mx_FilteredDeviceList_noResults"
|
||||||
|
>
|
||||||
|
No sessions found.
|
||||||
|
</div>,
|
||||||
|
]
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<FilteredDeviceList /> filtering filters correctly for Inactive 1`] = `
|
||||||
|
HTMLCollection [
|
||||||
|
<div
|
||||||
|
class="mx_FilteredDeviceList_securityCard"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_DeviceSecurityCard"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_DeviceSecurityCard_icon Inactive"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
height="16"
|
||||||
|
width="16"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_DeviceSecurityCard_content"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="mx_DeviceSecurityCard_heading"
|
||||||
|
>
|
||||||
|
Inactive sessions
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="mx_DeviceSecurityCard_description"
|
||||||
|
>
|
||||||
|
Consider signing out from old sessions (90 days or older) you don't use anymore
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
]
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<FilteredDeviceList /> filtering filters correctly for Unverified 1`] = `
|
||||||
|
HTMLCollection [
|
||||||
|
<div
|
||||||
|
class="mx_FilteredDeviceList_securityCard"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_DeviceSecurityCard"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_DeviceSecurityCard_icon Unverified"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
height="16"
|
||||||
|
width="16"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_DeviceSecurityCard_content"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="mx_DeviceSecurityCard_heading"
|
||||||
|
>
|
||||||
|
Unverified sessions
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="mx_DeviceSecurityCard_description"
|
||||||
|
>
|
||||||
|
Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
]
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<FilteredDeviceList /> filtering filters correctly for Verified 1`] = `
|
||||||
|
HTMLCollection [
|
||||||
|
<div
|
||||||
|
class="mx_FilteredDeviceList_securityCard"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_DeviceSecurityCard"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_DeviceSecurityCard_icon Verified"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
height="16"
|
||||||
|
width="16"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_DeviceSecurityCard_content"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="mx_DeviceSecurityCard_heading"
|
||||||
|
>
|
||||||
|
Verified sessions
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="mx_DeviceSecurityCard_description"
|
||||||
|
>
|
||||||
|
For best security, sign out from any session that you don't recognize or use anymore.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
]
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<FilteredDeviceList /> filtering renders no results correctly for Inactive 1`] = `
|
||||||
|
HTMLCollection [
|
||||||
|
<div
|
||||||
|
class="mx_FilteredDeviceList_noResults"
|
||||||
|
>
|
||||||
|
No inactive sessions found.
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
|
||||||
|
data-testid="devices-clear-filter-btn"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
Show all
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
]
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<FilteredDeviceList /> filtering renders no results correctly for Unverified 1`] = `
|
||||||
|
HTMLCollection [
|
||||||
|
<div
|
||||||
|
class="mx_FilteredDeviceList_noResults"
|
||||||
|
>
|
||||||
|
No unverified sessions found.
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
|
||||||
|
data-testid="devices-clear-filter-btn"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
Show all
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
]
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<FilteredDeviceList /> filtering renders no results correctly for Verified 1`] = `
|
||||||
|
HTMLCollection [
|
||||||
|
<div
|
||||||
|
class="mx_FilteredDeviceList_noResults"
|
||||||
|
>
|
||||||
|
No verified sessions found.
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
|
||||||
|
data-testid="devices-clear-filter-btn"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
Show all
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
]
|
||||||
|
`;
|
Loading…
Add table
Add a link
Reference in a new issue