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
|
@ -22,7 +22,7 @@ import { formatDate, formatRelativeTime } from "../../../../DateUtils";
|
|||
import TooltipTarget from "../../elements/TooltipTarget";
|
||||
import { Alignment } from "../../elements/Tooltip";
|
||||
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";
|
||||
export interface DeviceTileProps {
|
||||
device: DeviceWithVerification;
|
||||
|
@ -64,12 +64,11 @@ const getInactiveMetadata = (device: DeviceWithVerification): { id: string, valu
|
|||
if (!isInactive) {
|
||||
return undefined;
|
||||
}
|
||||
const inactiveAgeDays = Math.round(INACTIVE_DEVICE_AGE_MS / MS_DAY);
|
||||
return { id: 'inactive', value: (
|
||||
<>
|
||||
<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)})`
|
||||
}
|
||||
</>),
|
||||
|
|
|
@ -16,40 +16,178 @@ limitations under the License.
|
|||
|
||||
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 { filterDevicesBySecurityRecommendation } from './filter';
|
||||
import { DevicesDictionary, DeviceWithVerification } from './types';
|
||||
import {
|
||||
filterDevicesBySecurityRecommendation,
|
||||
INACTIVE_DEVICE_AGE_DAYS,
|
||||
} from './filter';
|
||||
import {
|
||||
DevicesDictionary,
|
||||
DeviceSecurityVariation,
|
||||
DeviceWithVerification,
|
||||
} from './types';
|
||||
|
||||
interface Props {
|
||||
devices: DevicesDictionary;
|
||||
filter?: DeviceSecurityVariation;
|
||||
onFilterChange: (filter: DeviceSecurityVariation | undefined) => void;
|
||||
}
|
||||
|
||||
// devices without timestamp metadata should be sorted last
|
||||
const sortDevicesByLatestActivity = (left: DeviceWithVerification, right: DeviceWithVerification) =>
|
||||
(right.last_seen_ts || 0) - (left.last_seen_ts || 0);
|
||||
|
||||
const getFilteredSortedDevices = (devices: DevicesDictionary) =>
|
||||
filterDevicesBySecurityRecommendation(Object.values(devices), [])
|
||||
const getFilteredSortedDevices = (devices: DevicesDictionary, filter: DeviceSecurityVariation) =>
|
||||
filterDevicesBySecurityRecommendation(Object.values(devices), filter ? [filter] : [])
|
||||
.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
|
||||
* Sorted by latest activity descending
|
||||
* TODO(kerrya) Filtering to added as part of PSG-648
|
||||
*/
|
||||
const FilteredDeviceList: React.FC<Props> = ({ devices }) => {
|
||||
const sortedDevices = getFilteredSortedDevices(devices);
|
||||
const FilteredDeviceList: React.FC<Props> = ({ devices, filter, onFilterChange }) => {
|
||||
const sortedDevices = getFilteredSortedDevices(devices, filter);
|
||||
|
||||
return <ol className='mx_FilteredDeviceList'>
|
||||
{ sortedDevices.map((device) =>
|
||||
<li key={device.device_id}>
|
||||
<DeviceTile
|
||||
device={device}
|
||||
/>
|
||||
</li>,
|
||||
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 },
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
) }
|
||||
</ol>;
|
||||
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) =>
|
||||
<li key={device.device_id}>
|
||||
<DeviceTile
|
||||
device={device}
|
||||
/>
|
||||
</li>,
|
||||
|
||||
) }
|
||||
</ol>
|
||||
</div>
|
||||
;
|
||||
};
|
||||
|
||||
export default FilteredDeviceList;
|
||||
|
|
|
@ -20,16 +20,19 @@ import { _t } from '../../../../languageHandler';
|
|||
import AccessibleButton from '../../elements/AccessibleButton';
|
||||
import SettingsSubsection from '../shared/SettingsSubsection';
|
||||
import DeviceSecurityCard from './DeviceSecurityCard';
|
||||
import { filterDevicesBySecurityRecommendation, INACTIVE_DEVICE_AGE_MS } from './filter';
|
||||
import { DevicesDictionary, DeviceSecurityVariation } from './types';
|
||||
import { filterDevicesBySecurityRecommendation, INACTIVE_DEVICE_AGE_DAYS } from './filter';
|
||||
import {
|
||||
DeviceSecurityVariation,
|
||||
DeviceWithVerification,
|
||||
DevicesDictionary,
|
||||
} from './types';
|
||||
|
||||
interface Props {
|
||||
devices: DevicesDictionary;
|
||||
}
|
||||
const MS_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
const SecurityRecommendations: React.FC<Props> = ({ devices }) => {
|
||||
const devicesArray = Object.values(devices);
|
||||
const devicesArray = Object.values<DeviceWithVerification>(devices);
|
||||
|
||||
const unverifiedDevicesCount = filterDevicesBySecurityRecommendation(
|
||||
devicesArray,
|
||||
|
@ -44,7 +47,7 @@ const SecurityRecommendations: React.FC<Props> = ({ devices }) => {
|
|||
return null;
|
||||
}
|
||||
|
||||
const inactiveAgeDays = INACTIVE_DEVICE_AGE_MS / MS_DAY;
|
||||
const inactiveAgeDays = INACTIVE_DEVICE_AGE_DAYS;
|
||||
|
||||
// TODO(kerrya) stubbed until PSG-640/652
|
||||
const noop = () => {};
|
||||
|
|
|
@ -18,7 +18,9 @@ import { DeviceWithVerification, DeviceSecurityVariation } from "./types";
|
|||
|
||||
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_DAYS = INACTIVE_DEVICE_AGE_MS / MS_DAY;
|
||||
|
||||
export const isDeviceInactive: DeviceFilterCondition = device =>
|
||||
!!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.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { _t } from "../../../../../languageHandler";
|
||||
import { useOwnDevices } from '../../devices/useOwnDevices';
|
||||
|
@ -22,10 +22,12 @@ import SettingsSubsection from '../../shared/SettingsSubsection';
|
|||
import FilteredDeviceList from '../../devices/FilteredDeviceList';
|
||||
import CurrentDeviceSection from '../../devices/CurrentDeviceSection';
|
||||
import SecurityRecommendations from '../../devices/SecurityRecommendations';
|
||||
import { DeviceSecurityVariation } from '../../devices/types';
|
||||
import SettingsTab from '../SettingsTab';
|
||||
|
||||
const SessionManagerTab: React.FC = () => {
|
||||
const { devices, currentDeviceId, isLoading } = useOwnDevices();
|
||||
const [filter, setFilter] = useState<DeviceSecurityVariation>();
|
||||
|
||||
const { [currentDeviceId]: currentDevice, ...otherDevices } = devices;
|
||||
const shouldShowOtherSessions = Object.keys(otherDevices).length > 0;
|
||||
|
@ -46,7 +48,11 @@ const SessionManagerTab: React.FC = () => {
|
|||
)}
|
||||
data-testid='other-sessions-section'
|
||||
>
|
||||
<FilteredDeviceList devices={otherDevices} />
|
||||
<FilteredDeviceList
|
||||
devices={otherDevices}
|
||||
filter={filter}
|
||||
onFilterChange={setFilter}
|
||||
/>
|
||||
</SettingsSubsection>
|
||||
}
|
||||
</SettingsTab>;
|
||||
|
|
|
@ -1708,13 +1708,26 @@
|
|||
"This session is ready for secure messaging.": "This session is ready for secure messaging.",
|
||||
"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.",
|
||||
"Security recommendations": "Security recommendations",
|
||||
"Improve your account security by following these recommendations": "Improve your account security by following these recommendations",
|
||||
"Verified sessions": "Verified sessions",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"Remove %(email)s?": "Remove %(email)s?",
|
||||
"Invalid Email Address": "Invalid Email Address",
|
||||
|
@ -2234,7 +2247,6 @@
|
|||
"Error decrypting video": "Error decrypting video",
|
||||
"Error processing voice message": "Error processing voice message",
|
||||
"Add reaction": "Add reaction",
|
||||
"Show all": "Show all",
|
||||
"Reactions": "Reactions",
|
||||
"%(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>",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue