Apply prettier formatting

This commit is contained in:
Michael Weimann 2022-12-12 12:24:14 +01:00
parent 1cac306093
commit 526645c791
No known key found for this signature in database
GPG key ID: 53F535A266BB9584
1576 changed files with 65385 additions and 62478 deletions

View file

@ -14,20 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useState } from 'react';
import { LocalNotificationSettings } from 'matrix-js-sdk/src/@types/local_notifications';
import React, { useState } from "react";
import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifications";
import { _t } from '../../../../languageHandler';
import Spinner from '../../elements/Spinner';
import SettingsSubsection from '../shared/SettingsSubsection';
import { SettingsSubsectionHeading } from '../shared/SettingsSubsectionHeading';
import DeviceDetails from './DeviceDetails';
import { DeviceExpandDetailsButton } from './DeviceExpandDetailsButton';
import DeviceTile from './DeviceTile';
import { DeviceVerificationStatusCard } from './DeviceVerificationStatusCard';
import { ExtendedDevice } from './types';
import { KebabContextMenu } from '../../context_menus/KebabContextMenu';
import { IconizedContextMenuOption } from '../../context_menus/IconizedContextMenu';
import { _t } from "../../../../languageHandler";
import Spinner from "../../elements/Spinner";
import SettingsSubsection from "../shared/SettingsSubsection";
import { SettingsSubsectionHeading } from "../shared/SettingsSubsectionHeading";
import DeviceDetails from "./DeviceDetails";
import { DeviceExpandDetailsButton } from "./DeviceExpandDetailsButton";
import DeviceTile from "./DeviceTile";
import { DeviceVerificationStatusCard } from "./DeviceVerificationStatusCard";
import { ExtendedDevice } from "./types";
import { KebabContextMenu } from "../../context_menus/KebabContextMenu";
import { IconizedContextMenuOption } from "../../context_menus/IconizedContextMenu";
interface Props {
device?: ExtendedDevice;
@ -41,9 +41,9 @@ interface Props {
saveDeviceName: (deviceName: string) => Promise<void>;
}
type CurrentDeviceSectionHeadingProps =
Pick<Props, 'onSignOutCurrentDevice' | 'signOutAllOtherSessions'>
& { disabled?: boolean };
type CurrentDeviceSectionHeadingProps = Pick<Props, "onSignOutCurrentDevice" | "signOutAllOtherSessions"> & {
disabled?: boolean;
};
const CurrentDeviceSectionHeading: React.FC<CurrentDeviceSectionHeadingProps> = ({
onSignOutCurrentDevice,
@ -53,30 +53,31 @@ const CurrentDeviceSectionHeading: React.FC<CurrentDeviceSectionHeadingProps> =
const menuOptions = [
<IconizedContextMenuOption
key="sign-out"
label={_t('Sign out')}
label={_t("Sign out")}
onClick={onSignOutCurrentDevice}
isDestructive
/>,
...(signOutAllOtherSessions
? [
<IconizedContextMenuOption
key="sign-out-all-others"
label={_t('Sign out all other sessions')}
onClick={signOutAllOtherSessions}
isDestructive
/>,
]
: []
),
<IconizedContextMenuOption
key="sign-out-all-others"
label={_t("Sign out all other sessions")}
onClick={signOutAllOtherSessions}
isDestructive
/>,
]
: []),
];
return <SettingsSubsectionHeading heading={_t('Current session')}>
<KebabContextMenu
disabled={disabled}
title={_t('Options')}
options={menuOptions}
data-testid='current-session-menu'
/>
</SettingsSubsectionHeading>;
return (
<SettingsSubsectionHeading heading={_t("Current session")}>
<KebabContextMenu
disabled={disabled}
title={_t("Options")}
options={menuOptions}
data-testid="current-session-menu"
/>
</SettingsSubsectionHeading>
);
};
const CurrentDeviceSection: React.FC<Props> = ({
@ -92,43 +93,45 @@ const CurrentDeviceSection: React.FC<Props> = ({
}) => {
const [isExpanded, setIsExpanded] = useState(false);
return <SettingsSubsection
data-testid='current-session-section'
heading={<CurrentDeviceSectionHeading
onSignOutCurrentDevice={onSignOutCurrentDevice}
signOutAllOtherSessions={signOutAllOtherSessions}
disabled={isLoading || !device || isSigningOut}
/>}
>
{ /* only show big spinner on first load */ }
{ isLoading && !device && <Spinner /> }
{ !!device && <>
<DeviceTile
device={device}
onClick={() => setIsExpanded(!isExpanded)}
>
<DeviceExpandDetailsButton
data-testid='current-session-toggle-details'
isExpanded={isExpanded}
onClick={() => setIsExpanded(!isExpanded)}
/>
</DeviceTile>
{ isExpanded &&
<DeviceDetails
device={device}
localNotificationSettings={localNotificationSettings}
setPushNotifications={setPushNotifications}
isSigningOut={isSigningOut}
onVerifyDevice={onVerifyCurrentDevice}
onSignOutDevice={onSignOutCurrentDevice}
saveDeviceName={saveDeviceName}
return (
<SettingsSubsection
data-testid="current-session-section"
heading={
<CurrentDeviceSectionHeading
onSignOutCurrentDevice={onSignOutCurrentDevice}
signOutAllOtherSessions={signOutAllOtherSessions}
disabled={isLoading || !device || isSigningOut}
/>
}
<br />
<DeviceVerificationStatusCard device={device} onVerifyDevice={onVerifyCurrentDevice} />
</>
}
</SettingsSubsection>;
>
{/* only show big spinner on first load */}
{isLoading && !device && <Spinner />}
{!!device && (
<>
<DeviceTile device={device} onClick={() => setIsExpanded(!isExpanded)}>
<DeviceExpandDetailsButton
data-testid="current-session-toggle-details"
isExpanded={isExpanded}
onClick={() => setIsExpanded(!isExpanded)}
/>
</DeviceTile>
{isExpanded && (
<DeviceDetails
device={device}
localNotificationSettings={localNotificationSettings}
setPushNotifications={setPushNotifications}
isSigningOut={isSigningOut}
onVerifyDevice={onVerifyCurrentDevice}
onSignOutDevice={onSignOutCurrentDevice}
saveDeviceName={saveDeviceName}
/>
)}
<br />
<DeviceVerificationStatusCard device={device} onVerifyDevice={onVerifyCurrentDevice} />
</>
)}
</SettingsSubsection>
);
};
export default CurrentDeviceSection;

View file

@ -14,35 +14,32 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { FormEvent, useEffect, useState } from 'react';
import React, { FormEvent, useEffect, useState } from "react";
import { _t } from '../../../../languageHandler';
import AccessibleButton from '../../elements/AccessibleButton';
import Field from '../../elements/Field';
import LearnMore from '../../elements/LearnMore';
import Spinner from '../../elements/Spinner';
import { Caption } from '../../typography/Caption';
import Heading from '../../typography/Heading';
import { ExtendedDevice } from './types';
import { _t } from "../../../../languageHandler";
import AccessibleButton from "../../elements/AccessibleButton";
import Field from "../../elements/Field";
import LearnMore from "../../elements/LearnMore";
import Spinner from "../../elements/Spinner";
import { Caption } from "../../typography/Caption";
import Heading from "../../typography/Heading";
import { ExtendedDevice } from "./types";
interface Props {
device: ExtendedDevice;
saveDeviceName: (deviceName: string) => Promise<void>;
}
const DeviceNameEditor: React.FC<Props & { stopEditing: () => void }> = ({
device, saveDeviceName, stopEditing,
}) => {
const [deviceName, setDeviceName] = useState(device.display_name || '');
const DeviceNameEditor: React.FC<Props & { stopEditing: () => void }> = ({ device, saveDeviceName, stopEditing }) => {
const [deviceName, setDeviceName] = useState(device.display_name || "");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setDeviceName(device.display_name || '');
setDeviceName(device.display_name || "");
}, [device.display_name]);
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>): void =>
setDeviceName(event.target.value);
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>): void => setDeviceName(event.target.value);
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
setIsLoading(true);
@ -52,7 +49,7 @@ const DeviceNameEditor: React.FC<Props & { stopEditing: () => void }> = ({
await saveDeviceName(deviceName);
stopEditing();
} catch (error) {
setError(_t('Failed to set display name'));
setError(_t("Failed to set display name"));
setIsLoading(false);
}
};
@ -60,102 +57,92 @@ const DeviceNameEditor: React.FC<Props & { stopEditing: () => void }> = ({
const headingId = `device-rename-${device.device_id}`;
const descriptionId = `device-rename-description-${device.device_id}`;
return <form
aria-disabled={isLoading}
className="mx_DeviceDetailHeading_renameForm"
onSubmit={onSubmit}
method="post"
>
<p
id={headingId}
className="mx_DeviceDetailHeading_renameFormHeading"
>
{ _t('Rename session') }
</p>
<div>
<Field
data-testid='device-rename-input'
type="text"
value={deviceName}
autoComplete="off"
onChange={onInputChange}
autoFocus
disabled={isLoading}
aria-labelledby={headingId}
aria-describedby={descriptionId}
className="mx_DeviceDetailHeading_renameFormInput"
maxLength={100}
/>
<Caption
id={descriptionId}
>
{ _t('Please be aware that session names are also visible to people you communicate with.') }
<LearnMore
title={_t('Renaming sessions')}
description={<>
<p>
{ _t(`Other users in direct messages and rooms that you join ` +
`are able to view a full list of your sessions.`,
) }
</p>
<p>
{ _t(`This provides them with confidence that they are really speaking to you, ` +
`but it also means they can see the session name you enter here.`,
) }
</p>
</>}
return (
<form aria-disabled={isLoading} className="mx_DeviceDetailHeading_renameForm" onSubmit={onSubmit} method="post">
<p id={headingId} className="mx_DeviceDetailHeading_renameFormHeading">
{_t("Rename session")}
</p>
<div>
<Field
data-testid="device-rename-input"
type="text"
value={deviceName}
autoComplete="off"
onChange={onInputChange}
autoFocus
disabled={isLoading}
aria-labelledby={headingId}
aria-describedby={descriptionId}
className="mx_DeviceDetailHeading_renameFormInput"
maxLength={100}
/>
{ !!error &&
<span
data-testid="device-rename-error"
className='mx_DeviceDetailHeading_renameFormError'>
{ error }
</span>
}
</Caption>
</div>
<div className="mx_DeviceDetailHeading_renameFormButtons">
<AccessibleButton
onClick={onSubmit}
kind="primary"
data-testid='device-rename-submit-cta'
disabled={isLoading}
>
{ _t('Save') }
</AccessibleButton>
<AccessibleButton
onClick={stopEditing}
kind="secondary"
data-testid='device-rename-cancel-cta'
disabled={isLoading}
>
{ _t('Cancel') }
</AccessibleButton>
{ isLoading && <Spinner w={16} h={16} /> }
</div>
</form>;
<Caption id={descriptionId}>
{_t("Please be aware that session names are also visible to people you communicate with.")}
<LearnMore
title={_t("Renaming sessions")}
description={
<>
<p>
{_t(
`Other users in direct messages and rooms that you join ` +
`are able to view a full list of your sessions.`,
)}
</p>
<p>
{_t(
`This provides them with confidence that they are really speaking to you, ` +
`but it also means they can see the session name you enter here.`,
)}
</p>
</>
}
/>
{!!error && (
<span data-testid="device-rename-error" className="mx_DeviceDetailHeading_renameFormError">
{error}
</span>
)}
</Caption>
</div>
<div className="mx_DeviceDetailHeading_renameFormButtons">
<AccessibleButton
onClick={onSubmit}
kind="primary"
data-testid="device-rename-submit-cta"
disabled={isLoading}
>
{_t("Save")}
</AccessibleButton>
<AccessibleButton
onClick={stopEditing}
kind="secondary"
data-testid="device-rename-cancel-cta"
disabled={isLoading}
>
{_t("Cancel")}
</AccessibleButton>
{isLoading && <Spinner w={16} h={16} />}
</div>
</form>
);
};
export const DeviceDetailHeading: React.FC<Props> = ({
device, saveDeviceName,
}) => {
export const DeviceDetailHeading: React.FC<Props> = ({ device, saveDeviceName }) => {
const [isEditing, setIsEditing] = useState(false);
return isEditing
? <DeviceNameEditor
device={device}
saveDeviceName={saveDeviceName}
stopEditing={() => setIsEditing(false)}
/>
: <div className='mx_DeviceDetailHeading' data-testid='device-detail-heading'>
<Heading size='h3'>{ device.display_name || device.device_id }</Heading>
return isEditing ? (
<DeviceNameEditor device={device} saveDeviceName={saveDeviceName} stopEditing={() => setIsEditing(false)} />
) : (
<div className="mx_DeviceDetailHeading" data-testid="device-detail-heading">
<Heading size="h3">{device.display_name || device.device_id}</Heading>
<AccessibleButton
kind='link_inline'
kind="link_inline"
onClick={() => setIsEditing(true)}
className='mx_DeviceDetailHeading_renameCta'
data-testid='device-heading-rename-cta'
className="mx_DeviceDetailHeading_renameCta"
data-testid="device-heading-rename-cta"
>
{ _t('Rename') }
{_t("Rename")}
</AccessibleButton>
</div>;
</div>
);
};

View file

@ -14,19 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { IPusher } from 'matrix-js-sdk/src/@types/PushRules';
import { PUSHER_ENABLED } from 'matrix-js-sdk/src/@types/event';
import { LocalNotificationSettings } from 'matrix-js-sdk/src/@types/local_notifications';
import React from "react";
import { IPusher } from "matrix-js-sdk/src/@types/PushRules";
import { PUSHER_ENABLED } from "matrix-js-sdk/src/@types/event";
import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifications";
import { formatDate } from '../../../../DateUtils';
import { _t } from '../../../../languageHandler';
import AccessibleButton from '../../elements/AccessibleButton';
import Spinner from '../../elements/Spinner';
import ToggleSwitch from '../../elements/ToggleSwitch';
import { DeviceDetailHeading } from './DeviceDetailHeading';
import { DeviceVerificationStatusCard } from './DeviceVerificationStatusCard';
import { ExtendedDevice } from './types';
import { formatDate } from "../../../../DateUtils";
import { _t } from "../../../../languageHandler";
import AccessibleButton from "../../elements/AccessibleButton";
import Spinner from "../../elements/Spinner";
import ToggleSwitch from "../../elements/ToggleSwitch";
import { DeviceDetailHeading } from "./DeviceDetailHeading";
import { DeviceVerificationStatusCard } from "./DeviceVerificationStatusCard";
import { ExtendedDevice } from "./types";
interface Props {
device: ExtendedDevice;
@ -43,7 +43,7 @@ interface Props {
interface MetadataTable {
id: string;
heading?: string;
values: { label: string, value?: string | React.ReactNode }[];
values: { label: string; value?: string | React.ReactNode }[];
}
const DeviceDetails: React.FC<Props> = ({
@ -59,40 +59,43 @@ const DeviceDetails: React.FC<Props> = ({
}) => {
const metadata: MetadataTable[] = [
{
id: 'session',
id: "session",
values: [
{ label: _t('Session ID'), value: device.device_id },
{ label: _t("Session ID"), value: device.device_id },
{
label: _t('Last activity'),
label: _t("Last activity"),
value: device.last_seen_ts && formatDate(new Date(device.last_seen_ts)),
},
],
},
{
id: 'application',
heading: _t('Application'),
id: "application",
heading: _t("Application"),
values: [
{ label: _t('Name'), value: device.appName },
{ label: _t('Version'), value: device.appVersion },
{ label: _t('URL'), value: device.url },
{ label: _t("Name"), value: device.appName },
{ label: _t("Version"), value: device.appVersion },
{ label: _t("URL"), value: device.url },
],
},
{
id: 'device',
heading: _t('Device'),
id: "device",
heading: _t("Device"),
values: [
{ label: _t('Model'), value: device.deviceModel },
{ label: _t('Operating system'), value: device.deviceOperatingSystem },
{ label: _t('Browser'), value: device.client },
{ label: _t('IP address'), value: device.last_seen_ip },
{ label: _t("Model"), value: device.deviceModel },
{ label: _t("Operating system"), value: device.deviceOperatingSystem },
{ label: _t("Browser"), value: device.client },
{ label: _t("IP address"), value: device.last_seen_ip },
],
},
].map(section =>
// filter out falsy values
({ ...section, values: section.values.filter(row => !!row.value) }))
.filter(section =>
// then filter out sections with no values
section.values.length,
]
.map((section) =>
// filter out falsy values
({ ...section, values: section.values.filter((row) => !!row.value) }),
)
.filter(
(section) =>
// then filter out sections with no values
section.values.length,
);
const showPushNotificationSection = !!pusher || !!localNotificationSettings;
@ -109,75 +112,75 @@ const DeviceDetails: React.FC<Props> = ({
return false;
}
return <div className='mx_DeviceDetails' data-testid={`device-detail-${device.device_id}`}>
<section className='mx_DeviceDetails_section'>
<DeviceDetailHeading
device={device}
saveDeviceName={saveDeviceName}
/>
<DeviceVerificationStatusCard
device={device}
onVerifyDevice={onVerifyDevice}
/>
</section>
<section className='mx_DeviceDetails_section'>
<p className='mx_DeviceDetails_sectionHeading'>{ _t('Session details') }</p>
{ metadata.map(({ heading, values, id }, index) => <table
className='mx_DeviceDetails_metadataTable'
key={index}
data-testid={`device-detail-metadata-${id}`}
>
{ heading &&
<thead>
<tr><th>{ heading }</th></tr>
</thead>
}
<tbody>
{ values.map(({ label, value }) => <tr key={label}>
<td className='mxDeviceDetails_metadataLabel'>{ label }</td>
<td className='mxDeviceDetails_metadataValue'>{ value }</td>
</tr>) }
</tbody>
</table>,
) }
</section>
{ showPushNotificationSection && (
<section
className='mx_DeviceDetails_section mx_DeviceDetails_pushNotifications'
data-testid='device-detail-push-notification'
>
<ToggleSwitch
// For backwards compatibility, if `enabled` is missing
// default to `true`
checked={isPushNotificationsEnabled(pusher, localNotificationSettings)}
disabled={isCheckboxDisabled(pusher, localNotificationSettings)}
onChange={checked => setPushNotifications?.(device.device_id, checked)}
title={_t("Toggle push notifications on this session.")}
data-testid='device-detail-push-notification-checkbox'
/>
<p className='mx_DeviceDetails_sectionHeading'>
{ _t('Push notifications') }
<small className='mx_DeviceDetails_sectionSubheading'>
{ _t('Receive push notifications on this session.') }
</small>
</p>
return (
<div className="mx_DeviceDetails" data-testid={`device-detail-${device.device_id}`}>
<section className="mx_DeviceDetails_section">
<DeviceDetailHeading device={device} saveDeviceName={saveDeviceName} />
<DeviceVerificationStatusCard device={device} onVerifyDevice={onVerifyDevice} />
</section>
) }
<section className='mx_DeviceDetails_section'>
<AccessibleButton
onClick={onSignOutDevice}
kind='danger_inline'
disabled={isSigningOut}
data-testid='device-detail-sign-out-cta'
>
<span className='mx_DeviceDetails_signOutButtonContent'>
{ _t('Sign out of this session') }
{ isSigningOut && <Spinner w={16} h={16} /> }
</span>
</AccessibleButton>
</section>
</div>;
<section className="mx_DeviceDetails_section">
<p className="mx_DeviceDetails_sectionHeading">{_t("Session details")}</p>
{metadata.map(({ heading, values, id }, index) => (
<table
className="mx_DeviceDetails_metadataTable"
key={index}
data-testid={`device-detail-metadata-${id}`}
>
{heading && (
<thead>
<tr>
<th>{heading}</th>
</tr>
</thead>
)}
<tbody>
{values.map(({ label, value }) => (
<tr key={label}>
<td className="mxDeviceDetails_metadataLabel">{label}</td>
<td className="mxDeviceDetails_metadataValue">{value}</td>
</tr>
))}
</tbody>
</table>
))}
</section>
{showPushNotificationSection && (
<section
className="mx_DeviceDetails_section mx_DeviceDetails_pushNotifications"
data-testid="device-detail-push-notification"
>
<ToggleSwitch
// For backwards compatibility, if `enabled` is missing
// default to `true`
checked={isPushNotificationsEnabled(pusher, localNotificationSettings)}
disabled={isCheckboxDisabled(pusher, localNotificationSettings)}
onChange={(checked) => setPushNotifications?.(device.device_id, checked)}
title={_t("Toggle push notifications on this session.")}
data-testid="device-detail-push-notification-checkbox"
/>
<p className="mx_DeviceDetails_sectionHeading">
{_t("Push notifications")}
<small className="mx_DeviceDetails_sectionSubheading">
{_t("Receive push notifications on this session.")}
</small>
</p>
</section>
)}
<section className="mx_DeviceDetails_section">
<AccessibleButton
onClick={onSignOutDevice}
kind="danger_inline"
disabled={isSigningOut}
data-testid="device-detail-sign-out-cta"
>
<span className="mx_DeviceDetails_signOutButtonContent">
{_t("Sign out of this session")}
{isSigningOut && <Spinner w={16} h={16} />}
</span>
</AccessibleButton>
</section>
</div>
);
};
export default DeviceDetails;

View file

@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import classNames from 'classnames';
import React from 'react';
import classNames from "classnames";
import React from "react";
import { Icon as CaretIcon } from '../../../../../res/img/feather-customised/dropdown-arrow.svg';
import { _t } from '../../../../languageHandler';
import AccessibleTooltipButton from '../../elements/AccessibleTooltipButton';
import { Icon as CaretIcon } from "../../../../../res/img/feather-customised/dropdown-arrow.svg";
import { _t } from "../../../../languageHandler";
import AccessibleTooltipButton from "../../elements/AccessibleTooltipButton";
interface Props extends React.ComponentProps<typeof AccessibleTooltipButton> {
isExpanded: boolean;
@ -27,17 +27,19 @@ interface Props extends React.ComponentProps<typeof AccessibleTooltipButton> {
}
export const DeviceExpandDetailsButton: React.FC<Props> = ({ isExpanded, onClick, ...rest }) => {
const label = isExpanded ? _t('Hide details') : _t('Show details');
return <AccessibleTooltipButton
{...rest}
aria-label={label}
title={label}
kind='icon'
className={classNames('mx_DeviceExpandDetailsButton', {
mx_DeviceExpandDetailsButton_expanded: isExpanded,
})}
onClick={onClick}
>
<CaretIcon className='mx_DeviceExpandDetailsButton_icon' />
</AccessibleTooltipButton>;
const label = isExpanded ? _t("Hide details") : _t("Show details");
return (
<AccessibleTooltipButton
{...rest}
aria-label={label}
title={label}
kind="icon"
className={classNames("mx_DeviceExpandDetailsButton", {
mx_DeviceExpandDetailsButton_expanded: isExpanded,
})}
onClick={onClick}
>
<CaretIcon className="mx_DeviceExpandDetailsButton_icon" />
</AccessibleTooltipButton>
);
};

View file

@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import classNames from 'classnames';
import React from 'react';
import classNames from "classnames";
import React from "react";
import { Icon as VerifiedIcon } from '../../../../../res/img/e2e/verified.svg';
import { Icon as UnverifiedIcon } from '../../../../../res/img/e2e/warning.svg';
import { Icon as InactiveIcon } from '../../../../../res/img/element-icons/settings/inactive.svg';
import { DeviceSecurityVariation } from './types';
import { Icon as VerifiedIcon } from "../../../../../res/img/e2e/verified.svg";
import { Icon as UnverifiedIcon } from "../../../../../res/img/e2e/warning.svg";
import { Icon as InactiveIcon } from "../../../../../res/img/element-icons/settings/inactive.svg";
import { DeviceSecurityVariation } from "./types";
interface Props {
variation: DeviceSecurityVariation;
heading: string;
@ -37,22 +37,24 @@ const VariationIcon: Record<DeviceSecurityVariation, React.FC<React.SVGProps<SVG
const DeviceSecurityIcon: React.FC<{ variation: DeviceSecurityVariation }> = ({ variation }) => {
const Icon = VariationIcon[variation];
return <div className={classNames('mx_DeviceSecurityCard_icon', variation)}>
<Icon height={16} width={16} />
</div>;
return (
<div className={classNames("mx_DeviceSecurityCard_icon", variation)}>
<Icon height={16} width={16} />
</div>
);
};
const DeviceSecurityCard: React.FC<Props> = ({ variation, heading, description, children }) => {
return <div className='mx_DeviceSecurityCard'>
<DeviceSecurityIcon variation={variation} />
<div className='mx_DeviceSecurityCard_content'>
<p className='mx_DeviceSecurityCard_heading'>{ heading }</p>
<p className='mx_DeviceSecurityCard_description'>{ description }</p>
{ !!children && <div className='mx_DeviceSecurityCard_actions'>
{ children }
</div> }
return (
<div className="mx_DeviceSecurityCard">
<DeviceSecurityIcon variation={variation} />
<div className="mx_DeviceSecurityCard_content">
<p className="mx_DeviceSecurityCard_heading">{heading}</p>
<p className="mx_DeviceSecurityCard_description">{description}</p>
{!!children && <div className="mx_DeviceSecurityCard_actions">{children}</div>}
</div>
</div>
</div>;
);
};
export default DeviceSecurityCard;

View file

@ -14,83 +14,98 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React from "react";
import { _t } from "../../../../languageHandler";
import LearnMore, { LearnMoreProps } from "../../elements/LearnMore";
import { DeviceSecurityVariation } from "./types";
interface Props extends Omit<LearnMoreProps, 'title' | 'description'> {
interface Props extends Omit<LearnMoreProps, "title" | "description"> {
variation: DeviceSecurityVariation;
}
const securityCardContent: Record<DeviceSecurityVariation, {
title: string;
description: React.ReactNode | string;
}> = {
[DeviceSecurityVariation.Verified]: {
title: _t('Verified sessions'),
description: <>
<p>{ _t('Verified sessions are anywhere you are using this account after entering your passphrase or confirming your identity with another verified session.') }
</p>
<p>
{ _t(
`This means that you have all the keys needed to unlock your encrypted messages ` +
`and confirm to other users that you trust this session.`,
)
}
</p>
</>,
},
[DeviceSecurityVariation.Unverified]: {
title: _t('Unverified sessions'),
description: <>
<p>{ _t('Unverified sessions are sessions that have logged in with your credentials but have not been cross-verified.') }
</p>
<p>
{ _t(
`You should make especially certain that you recognise these sessions ` +
`as they could represent an unauthorised use of your account.`,
)
}
</p>
</>,
},
// unverifiable uses single-session case
// because it is only ever displayed on a single session detail
[DeviceSecurityVariation.Unverifiable]: {
title: _t('Unverified session'),
description: <>
<p>{ _t(`This session doesn't support encryption, so it can't be verified.`) }
</p>
<p>
{ _t(
`You won't be able to participate in rooms where encryption is enabled when using this session.`,
)
}
</p><p>
{ _t(
`For best security and privacy, it is recommended to use Matrix clients that support encryption.`,
)
}
</p>
</>,
},
[DeviceSecurityVariation.Inactive]: {
title: _t('Inactive sessions'),
description: <>
<p>{ _t('Inactive sessions are sessions you have not used in some time, but they continue to receive encryption keys.') }
</p>
<p>
{ _t(
`Removing inactive sessions improves security and performance, ` +
`and makes it easier for you to identify if a new session is suspicious.`,
)
}
</p>
</>,
},
};
const securityCardContent: Record<
DeviceSecurityVariation,
{
title: string;
description: React.ReactNode | string;
}
> = {
[DeviceSecurityVariation.Verified]: {
title: _t("Verified sessions"),
description: (
<>
<p>
{_t(
"Verified sessions are anywhere you are using this account after entering your passphrase or confirming your identity with another verified session.",
)}
</p>
<p>
{_t(
`This means that you have all the keys needed to unlock your encrypted messages ` +
`and confirm to other users that you trust this session.`,
)}
</p>
</>
),
},
[DeviceSecurityVariation.Unverified]: {
title: _t("Unverified sessions"),
description: (
<>
<p>
{_t(
"Unverified sessions are sessions that have logged in with your credentials but have not been cross-verified.",
)}
</p>
<p>
{_t(
`You should make especially certain that you recognise these sessions ` +
`as they could represent an unauthorised use of your account.`,
)}
</p>
</>
),
},
// unverifiable uses single-session case
// because it is only ever displayed on a single session detail
[DeviceSecurityVariation.Unverifiable]: {
title: _t("Unverified session"),
description: (
<>
<p>{_t(`This session doesn't support encryption, so it can't be verified.`)}</p>
<p>
{_t(
`You won't be able to participate in rooms where encryption is enabled when using this session.`,
)}
</p>
<p>
{_t(
`For best security and privacy, it is recommended to use Matrix clients that support encryption.`,
)}
</p>
</>
),
},
[DeviceSecurityVariation.Inactive]: {
title: _t("Inactive sessions"),
description: (
<>
<p>
{_t(
"Inactive sessions are sessions you have not used in some time, but they continue to receive encryption keys.",
)}
</p>
<p>
{_t(
`Removing inactive sessions improves security and performance, ` +
`and makes it easier for you to identify if a new session is suspicious.`,
)}
</p>
</>
),
},
};
/**
* LearnMore with content for device security warnings

View file

@ -17,7 +17,7 @@ limitations under the License.
import React, { Fragment } from "react";
import classNames from "classnames";
import { Icon as InactiveIcon } from '../../../../../res/img/element-icons/settings/inactive.svg';
import { Icon as InactiveIcon } from "../../../../../res/img/element-icons/settings/inactive.svg";
import { _t } from "../../../../languageHandler";
import { formatDate, formatRelativeTime } from "../../../../DateUtils";
import Heading from "../../typography/Heading";
@ -33,9 +33,7 @@ export interface DeviceTileProps {
}
const DeviceTileName: React.FC<{ device: ExtendedDevice }> = ({ device }) => {
return <Heading size='h4'>
{ device.display_name || device.device_id }
</Heading>;
return <Heading size="h4">{device.display_name || device.device_id}</Heading>;
};
const MS_DAY = 24 * 60 * 60 * 1000;
@ -50,76 +48,66 @@ const formatLastActivity = (timestamp: number, now = new Date().getTime()): stri
return formatRelativeTime(new Date(timestamp));
};
const getInactiveMetadata = (device: ExtendedDevice): { id: string, value: React.ReactNode } | undefined => {
const getInactiveMetadata = (device: ExtendedDevice): { id: string; value: React.ReactNode } | undefined => {
const isInactive = isDeviceInactive(device);
if (!isInactive) {
return undefined;
}
return { id: 'inactive', value: (
<>
<InactiveIcon className="mx_DeviceTile_inactiveIcon" />
{
_t('Inactive for %(inactiveAgeDays)s+ days', { inactiveAgeDays: INACTIVE_DEVICE_AGE_DAYS }) +
` (${formatLastActivity(device.last_seen_ts)})`
}
</>),
return {
id: "inactive",
value: (
<>
<InactiveIcon className="mx_DeviceTile_inactiveIcon" />
{_t("Inactive for %(inactiveAgeDays)s+ days", { inactiveAgeDays: INACTIVE_DEVICE_AGE_DAYS }) +
` (${formatLastActivity(device.last_seen_ts)})`}
</>
),
};
};
const DeviceMetadata: React.FC<{ value: string | React.ReactNode, id: string }> = ({ value, id }) => (
value ? <span data-testid={`device-metadata-${id}`}>{ value }</span> : null
);
const DeviceMetadata: React.FC<{ value: string | React.ReactNode; id: string }> = ({ value, id }) =>
value ? <span data-testid={`device-metadata-${id}`}>{value}</span> : null;
const DeviceTile: React.FC<DeviceTileProps> = ({
device,
children,
isSelected,
onClick,
}) => {
const DeviceTile: React.FC<DeviceTileProps> = ({ device, children, isSelected, onClick }) => {
const inactive = getInactiveMetadata(device);
const lastActivity = device.last_seen_ts && `${_t('Last activity')} ${formatLastActivity(device.last_seen_ts)}`;
const verificationStatus = device.isVerified ? _t('Verified') : _t('Unverified');
const lastActivity = device.last_seen_ts && `${_t("Last activity")} ${formatLastActivity(device.last_seen_ts)}`;
const verificationStatus = device.isVerified ? _t("Verified") : _t("Unverified");
// if device is inactive, don't display last activity or verificationStatus
const metadata = inactive
? [inactive, { id: 'lastSeenIp', value: device.last_seen_ip }]
? [inactive, { id: "lastSeenIp", value: device.last_seen_ip }]
: [
{ id: 'isVerified', value: verificationStatus },
{ id: 'lastActivity', value: lastActivity },
{ id: 'lastSeenIp', value: device.last_seen_ip },
{ id: 'deviceId', value: device.device_id },
];
{ id: "isVerified", value: verificationStatus },
{ id: "lastActivity", value: lastActivity },
{ id: "lastSeenIp", value: device.last_seen_ip },
{ id: "deviceId", value: device.device_id },
];
return <div
className={classNames(
"mx_DeviceTile",
{ "mx_DeviceTile_interactive": !!onClick },
)}
data-testid={`device-tile-${device.device_id}`}
onClick={onClick}
>
<DeviceTypeIcon
isVerified={device.isVerified}
isSelected={isSelected}
deviceType={device.deviceType}
/>
<div className="mx_DeviceTile_info">
<DeviceTileName device={device} />
<div className="mx_DeviceTile_metadata">
{ metadata.map(({ id, value }, index) =>
!!value
? <Fragment key={id}>
{ !!index && ' · ' }
<DeviceMetadata id={id} value={value} />
</Fragment>
: null,
) }
return (
<div
className={classNames("mx_DeviceTile", { mx_DeviceTile_interactive: !!onClick })}
data-testid={`device-tile-${device.device_id}`}
onClick={onClick}
>
<DeviceTypeIcon isVerified={device.isVerified} isSelected={isSelected} deviceType={device.deviceType} />
<div className="mx_DeviceTile_info">
<DeviceTileName device={device} />
<div className="mx_DeviceTile_metadata">
{metadata.map(({ id, value }, index) =>
!!value ? (
<Fragment key={id}>
{!!index && " · "}
<DeviceMetadata id={id} value={value} />
</Fragment>
) : null,
)}
</div>
</div>
<div className="mx_DeviceTile_actions" onClick={preventDefaultWrapper(() => {})}>
{children}
</div>
</div>
<div className="mx_DeviceTile_actions" onClick={preventDefaultWrapper(() => {})}>
{ children }
</div>
</div>;
);
};
export default DeviceTile;

View file

@ -14,21 +14,21 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import React from "react";
import classNames from "classnames";
import { Icon as UnknownDeviceIcon } from '../../../../../res/img/element-icons/settings/unknown-device.svg';
import { Icon as DesktopIcon } from '../../../../../res/img/element-icons/settings/desktop.svg';
import { Icon as WebIcon } from '../../../../../res/img/element-icons/settings/web.svg';
import { Icon as MobileIcon } from '../../../../../res/img/element-icons/settings/mobile.svg';
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 { ExtendedDevice } from './types';
import { DeviceType } from '../../../../utils/device/parseUserAgent';
import { Icon as UnknownDeviceIcon } from "../../../../../res/img/element-icons/settings/unknown-device.svg";
import { Icon as DesktopIcon } from "../../../../../res/img/element-icons/settings/desktop.svg";
import { Icon as WebIcon } from "../../../../../res/img/element-icons/settings/web.svg";
import { Icon as MobileIcon } from "../../../../../res/img/element-icons/settings/mobile.svg";
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 { ExtendedDevice } from "./types";
import { DeviceType } from "../../../../utils/device/parseUserAgent";
interface Props {
isVerified?: ExtendedDevice['isVerified'];
isVerified?: ExtendedDevice["isVerified"];
isSelected?: boolean;
deviceType?: DeviceType;
}
@ -40,44 +40,37 @@ const deviceTypeIcon: Record<DeviceType, React.FC<React.SVGProps<SVGSVGElement>>
[DeviceType.Unknown]: UnknownDeviceIcon,
};
const deviceTypeLabel: Record<DeviceType, string> = {
[DeviceType.Desktop]: _t('Desktop session'),
[DeviceType.Mobile]: _t('Mobile session'),
[DeviceType.Web]: _t('Web session'),
[DeviceType.Unknown]: _t('Unknown session type'),
[DeviceType.Desktop]: _t("Desktop session"),
[DeviceType.Mobile]: _t("Mobile session"),
[DeviceType.Web]: _t("Web session"),
[DeviceType.Unknown]: _t("Unknown session type"),
};
export const DeviceTypeIcon: React.FC<Props> = ({
isVerified,
isSelected,
deviceType,
}) => {
export const DeviceTypeIcon: React.FC<Props> = ({ isVerified, isSelected, deviceType }) => {
const Icon = deviceTypeIcon[deviceType] || deviceTypeIcon[DeviceType.Unknown];
const label = deviceTypeLabel[deviceType] || deviceTypeLabel[DeviceType.Unknown];
return (
<div className={classNames('mx_DeviceTypeIcon', {
mx_DeviceTypeIcon_selected: isSelected,
})}
<div
className={classNames("mx_DeviceTypeIcon", {
mx_DeviceTypeIcon_selected: isSelected,
})}
>
<div className='mx_DeviceTypeIcon_deviceIconWrapper'>
<Icon
className='mx_DeviceTypeIcon_deviceIcon'
role='img'
aria-label={label}
/>
<div className="mx_DeviceTypeIcon_deviceIconWrapper">
<Icon className="mx_DeviceTypeIcon_deviceIcon" role="img" aria-label={label} />
</div>
{
isVerified
? <VerifiedIcon
className={classNames('mx_DeviceTypeIcon_verificationIcon', 'verified')}
role='img'
aria-label={_t('Verified')}
/>
: <UnverifiedIcon
className={classNames('mx_DeviceTypeIcon_verificationIcon', 'unverified')}
role='img'
aria-label={_t('Unverified')}
/>
}
</div>);
{isVerified ? (
<VerifiedIcon
className={classNames("mx_DeviceTypeIcon_verificationIcon", "verified")}
role="img"
aria-label={_t("Verified")}
/>
) : (
<UnverifiedIcon
className={classNames("mx_DeviceTypeIcon_verificationIcon", "unverified")}
role="img"
aria-label={_t("Unverified")}
/>
)}
</div>
);
};

View file

@ -14,23 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React from "react";
import { _t } from '../../../../languageHandler';
import AccessibleButton from '../../elements/AccessibleButton';
import DeviceSecurityCard from './DeviceSecurityCard';
import { DeviceSecurityLearnMore } from './DeviceSecurityLearnMore';
import {
DeviceSecurityVariation,
ExtendedDevice,
} from './types';
import { _t } from "../../../../languageHandler";
import AccessibleButton from "../../elements/AccessibleButton";
import DeviceSecurityCard from "./DeviceSecurityCard";
import { DeviceSecurityLearnMore } from "./DeviceSecurityLearnMore";
import { DeviceSecurityVariation, ExtendedDevice } from "./types";
interface Props {
device: ExtendedDevice;
onVerifyDevice?: () => void;
}
const getCardProps = (device: ExtendedDevice): {
const getCardProps = (
device: ExtendedDevice,
): {
variation: DeviceSecurityVariation;
heading: string;
description: React.ReactNode;
@ -38,52 +37,55 @@ const getCardProps = (device: ExtendedDevice): {
if (device.isVerified) {
return {
variation: DeviceSecurityVariation.Verified,
heading: _t('Verified session'),
description: <>
{ _t('This session is ready for secure messaging.') }
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Verified} />
</>,
heading: _t("Verified session"),
description: (
<>
{_t("This session is ready for secure messaging.")}
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Verified} />
</>
),
};
}
if (device.isVerified === null) {
return {
variation: DeviceSecurityVariation.Unverified,
heading: _t('Unverified session'),
description: <>
{ _t(`This session doesn't support encryption and thus can't be verified.`) }
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Unverifiable} />
</>,
heading: _t("Unverified session"),
description: (
<>
{_t(`This session doesn't support encryption and thus can't be verified.`)}
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Unverifiable} />
</>
),
};
}
return {
variation: DeviceSecurityVariation.Unverified,
heading: _t('Unverified session'),
description: <>
{ _t('Verify or sign out from this session for best security and reliability.') }
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Unverified} />
</>,
heading: _t("Unverified session"),
description: (
<>
{_t("Verify or sign out from this session for best security and reliability.")}
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Unverified} />
</>
),
};
};
export const DeviceVerificationStatusCard: React.FC<Props> = ({
device,
onVerifyDevice,
}) => {
export const DeviceVerificationStatusCard: React.FC<Props> = ({ device, onVerifyDevice }) => {
const securityCardProps = getCardProps(device);
return <DeviceSecurityCard
{...securityCardProps}
>
{ /* check for explicit false to exclude unverifiable devices */ }
{ device.isVerified === false && !!onVerifyDevice &&
<AccessibleButton
kind='primary'
onClick={onVerifyDevice}
data-testid={`verification-status-button-${device.device_id}`}
>
{ _t('Verify session') }
</AccessibleButton>
}
</DeviceSecurityCard>;
return (
<DeviceSecurityCard {...securityCardProps}>
{/* check for explicit false to exclude unverifiable devices */}
{device.isVerified === false && !!onVerifyDevice && (
<AccessibleButton
kind="primary"
onClick={onVerifyDevice}
data-testid={`verification-status-button-${device.device_id}`}
>
{_t("Verify session")}
</AccessibleButton>
)}
</DeviceSecurityCard>
);
};

View file

@ -14,122 +14,117 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ForwardedRef, forwardRef } from 'react';
import { IPusher } from 'matrix-js-sdk/src/@types/PushRules';
import { PUSHER_DEVICE_ID } from 'matrix-js-sdk/src/@types/event';
import { LocalNotificationSettings } from 'matrix-js-sdk/src/@types/local_notifications';
import React, { ForwardedRef, forwardRef } from "react";
import { IPusher } from "matrix-js-sdk/src/@types/PushRules";
import { PUSHER_DEVICE_ID } from "matrix-js-sdk/src/@types/event";
import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifications";
import { _t } from '../../../../languageHandler';
import AccessibleButton from '../../elements/AccessibleButton';
import { FilterDropdown, FilterDropdownOption } from '../../elements/FilterDropdown';
import DeviceDetails from './DeviceDetails';
import { DeviceExpandDetailsButton } from './DeviceExpandDetailsButton';
import DeviceSecurityCard from './DeviceSecurityCard';
import {
filterDevicesBySecurityRecommendation,
FilterVariation,
INACTIVE_DEVICE_AGE_DAYS,
} from './filter';
import SelectableDeviceTile from './SelectableDeviceTile';
import {
DevicesDictionary,
DeviceSecurityVariation,
ExtendedDevice,
} from './types';
import { DevicesState } from './useOwnDevices';
import FilteredDeviceListHeader from './FilteredDeviceListHeader';
import Spinner from '../../elements/Spinner';
import { DeviceSecurityLearnMore } from './DeviceSecurityLearnMore';
import { _t } from "../../../../languageHandler";
import AccessibleButton from "../../elements/AccessibleButton";
import { FilterDropdown, FilterDropdownOption } from "../../elements/FilterDropdown";
import DeviceDetails from "./DeviceDetails";
import { DeviceExpandDetailsButton } from "./DeviceExpandDetailsButton";
import DeviceSecurityCard from "./DeviceSecurityCard";
import { filterDevicesBySecurityRecommendation, FilterVariation, INACTIVE_DEVICE_AGE_DAYS } from "./filter";
import SelectableDeviceTile from "./SelectableDeviceTile";
import { DevicesDictionary, DeviceSecurityVariation, ExtendedDevice } from "./types";
import { DevicesState } from "./useOwnDevices";
import FilteredDeviceListHeader from "./FilteredDeviceListHeader";
import Spinner from "../../elements/Spinner";
import { DeviceSecurityLearnMore } from "./DeviceSecurityLearnMore";
interface Props {
devices: DevicesDictionary;
pushers: IPusher[];
localNotificationSettings: Map<string, LocalNotificationSettings>;
expandedDeviceIds: ExtendedDevice['device_id'][];
signingOutDeviceIds: ExtendedDevice['device_id'][];
selectedDeviceIds: ExtendedDevice['device_id'][];
expandedDeviceIds: ExtendedDevice["device_id"][];
signingOutDeviceIds: ExtendedDevice["device_id"][];
selectedDeviceIds: ExtendedDevice["device_id"][];
filter?: FilterVariation;
onFilterChange: (filter: FilterVariation | undefined) => void;
onDeviceExpandToggle: (deviceId: ExtendedDevice['device_id']) => void;
onSignOutDevices: (deviceIds: ExtendedDevice['device_id'][]) => void;
saveDeviceName: DevicesState['saveDeviceName'];
onRequestDeviceVerification?: (deviceId: ExtendedDevice['device_id']) => void;
onDeviceExpandToggle: (deviceId: ExtendedDevice["device_id"]) => void;
onSignOutDevices: (deviceIds: ExtendedDevice["device_id"][]) => void;
saveDeviceName: DevicesState["saveDeviceName"];
onRequestDeviceVerification?: (deviceId: ExtendedDevice["device_id"]) => void;
setPushNotifications: (deviceId: string, enabled: boolean) => Promise<void>;
setSelectedDeviceIds: (deviceIds: ExtendedDevice['device_id'][]) => void;
setSelectedDeviceIds: (deviceIds: ExtendedDevice["device_id"][]) => void;
supportsMSC3881?: boolean | undefined;
}
const isDeviceSelected = (
deviceId: ExtendedDevice['device_id'],
selectedDeviceIds: ExtendedDevice['device_id'][],
) => selectedDeviceIds.includes(deviceId);
const isDeviceSelected = (deviceId: ExtendedDevice["device_id"], selectedDeviceIds: ExtendedDevice["device_id"][]) =>
selectedDeviceIds.includes(deviceId);
// devices without timestamp metadata should be sorted last
const sortDevicesByLatestActivityThenDisplayName = (left: ExtendedDevice, right: ExtendedDevice) =>
(right.last_seen_ts || 0) - (left.last_seen_ts || 0)
|| ((left.display_name || left.device_id).localeCompare(right.display_name || right.device_id));
(right.last_seen_ts || 0) - (left.last_seen_ts || 0) ||
(left.display_name || left.device_id).localeCompare(right.display_name || right.device_id);
const getFilteredSortedDevices = (devices: DevicesDictionary, filter?: FilterVariation) =>
filterDevicesBySecurityRecommendation(Object.values(devices), filter ? [filter] : [])
.sort(sortDevicesByLatestActivityThenDisplayName);
filterDevicesBySecurityRecommendation(Object.values(devices), filter ? [filter] : []).sort(
sortDevicesByLatestActivityThenDisplayName,
);
const ALL_FILTER_ID = 'ALL';
const ALL_FILTER_ID = "ALL";
type DeviceFilterKey = FilterVariation | typeof ALL_FILTER_ID;
const securityCardContent: Record<DeviceSecurityVariation, {
title: string;
description: string;
}> = {
[DeviceSecurityVariation.Verified]: {
title: _t('Verified sessions'),
description: _t('For best security, sign out from any session that you don\'t recognize or use anymore.'),
},
[DeviceSecurityVariation.Unverified]: {
title: _t('Unverified sessions'),
description: _t(
`Verify your sessions for enhanced secure messaging or ` +
`sign out from those you don't recognize or use anymore.`,
),
},
[DeviceSecurityVariation.Unverifiable]: {
title: _t('Unverified session'),
description: _t(
`This session doesn't support encryption and thus can't be verified.`,
),
},
[DeviceSecurityVariation.Inactive]: {
title: _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 },
),
},
};
const securityCardContent: Record<
DeviceSecurityVariation,
{
title: string;
description: string;
}
> = {
[DeviceSecurityVariation.Verified]: {
title: _t("Verified sessions"),
description: _t("For best security, sign out from any session that you don't recognize or use anymore."),
},
[DeviceSecurityVariation.Unverified]: {
title: _t("Unverified sessions"),
description: _t(
`Verify your sessions for enhanced secure messaging or ` +
`sign out from those you don't recognize or use anymore.`,
),
},
[DeviceSecurityVariation.Unverifiable]: {
title: _t("Unverified session"),
description: _t(`This session doesn't support encryption and thus can't be verified.`),
},
[DeviceSecurityVariation.Inactive]: {
title: _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 },
),
},
};
const isSecurityVariation = (filter?: DeviceFilterKey): filter is FilterVariation =>
!!filter && ([
DeviceSecurityVariation.Inactive,
DeviceSecurityVariation.Unverified,
DeviceSecurityVariation.Verified,
] as string[]).includes(filter);
!!filter &&
(
[
DeviceSecurityVariation.Inactive,
DeviceSecurityVariation.Unverified,
DeviceSecurityVariation.Verified,
] as string[]
).includes(filter);
const FilterSecurityCard: React.FC<{ filter?: DeviceFilterKey }> = ({ filter }) => {
if (isSecurityVariation(filter)) {
const { title, description } = securityCardContent[filter];
return <div className='mx_FilteredDeviceList_securityCard'>
<DeviceSecurityCard
variation={filter}
heading={title}
description={<span>
{ description }
<DeviceSecurityLearnMore
variation={filter}
/>
</span>}
/>
</div>
;
return (
<div className="mx_FilteredDeviceList_securityCard">
<DeviceSecurityCard
variation={filter}
heading={title}
description={
<span>
{description}
<DeviceSecurityLearnMore variation={filter} />
</span>
}
/>
</div>
);
}
return null;
@ -138,34 +133,35 @@ const FilterSecurityCard: React.FC<{ filter?: DeviceFilterKey }> = ({ filter })
const getNoResultsMessage = (filter?: FilterVariation): string => {
switch (filter) {
case DeviceSecurityVariation.Verified:
return _t('No verified sessions found.');
return _t("No verified sessions found.");
case DeviceSecurityVariation.Unverified:
return _t('No unverified sessions found.');
return _t("No unverified sessions found.");
case DeviceSecurityVariation.Inactive:
return _t('No inactive sessions found.');
return _t("No inactive sessions found.");
default:
return _t('No sessions found.');
return _t("No sessions found.");
}
};
interface NoResultsProps { filter?: FilterVariation, clearFilter: () => void}
const NoResults: React.FC<NoResultsProps> = ({ filter, clearFilter }) =>
<div className='mx_FilteredDeviceList_noResults'>
{ getNoResultsMessage(filter) }
interface NoResultsProps {
filter?: FilterVariation;
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 &&
<>
&nbsp;
<AccessibleButton
kind='link_inline'
onClick={clearFilter}
data-testid='devices-clear-filter-btn'
>
{ _t('Show all') }
</AccessibleButton>
</>
!!filter && (
<>
&nbsp;
<AccessibleButton kind="link_inline" onClick={clearFilter} data-testid="devices-clear-filter-btn">
{_t("Show all")}
</AccessibleButton>
</>
)
}
</div>;
</div>
);
const DeviceListItem: React.FC<{
device: ExtendedDevice;
@ -195,96 +191,96 @@ const DeviceListItem: React.FC<{
setPushNotifications,
toggleSelected,
supportsMSC3881,
}) => <li className='mx_FilteredDeviceList_listItem'>
<SelectableDeviceTile
isSelected={isSelected}
onSelect={toggleSelected}
onClick={onDeviceExpandToggle}
device={device}
>
{ isSigningOut && <Spinner w={16} h={16} /> }
<DeviceExpandDetailsButton
isExpanded={isExpanded}
}) => (
<li className="mx_FilteredDeviceList_listItem">
<SelectableDeviceTile
isSelected={isSelected}
onSelect={toggleSelected}
onClick={onDeviceExpandToggle}
/>
</SelectableDeviceTile>
{
isExpanded &&
<DeviceDetails
device={device}
pusher={pusher}
localNotificationSettings={localNotificationSettings}
isSigningOut={isSigningOut}
onVerifyDevice={onRequestDeviceVerification}
onSignOutDevice={onSignOutDevice}
saveDeviceName={saveDeviceName}
setPushNotifications={setPushNotifications}
supportsMSC3881={supportsMSC3881}
/>
}
</li>;
>
{isSigningOut && <Spinner w={16} h={16} />}
<DeviceExpandDetailsButton isExpanded={isExpanded} onClick={onDeviceExpandToggle} />
</SelectableDeviceTile>
{isExpanded && (
<DeviceDetails
device={device}
pusher={pusher}
localNotificationSettings={localNotificationSettings}
isSigningOut={isSigningOut}
onVerifyDevice={onRequestDeviceVerification}
onSignOutDevice={onSignOutDevice}
saveDeviceName={saveDeviceName}
setPushNotifications={setPushNotifications}
supportsMSC3881={supportsMSC3881}
/>
)}
</li>
);
/**
* Filtered list of devices
* Sorted by latest activity descending
*/
export const FilteredDeviceList =
forwardRef(({
devices,
pushers,
localNotificationSettings,
filter,
expandedDeviceIds,
signingOutDeviceIds,
selectedDeviceIds,
onFilterChange,
onDeviceExpandToggle,
saveDeviceName,
onSignOutDevices,
onRequestDeviceVerification,
setPushNotifications,
setSelectedDeviceIds,
supportsMSC3881,
}: Props, ref: ForwardedRef<HTMLDivElement>) => {
export const FilteredDeviceList = forwardRef(
(
{
devices,
pushers,
localNotificationSettings,
filter,
expandedDeviceIds,
signingOutDeviceIds,
selectedDeviceIds,
onFilterChange,
onDeviceExpandToggle,
saveDeviceName,
onSignOutDevices,
onRequestDeviceVerification,
setPushNotifications,
setSelectedDeviceIds,
supportsMSC3881,
}: Props,
ref: ForwardedRef<HTMLDivElement>,
) => {
const sortedDevices = getFilteredSortedDevices(devices, filter);
function getPusherForDevice(device: ExtendedDevice): IPusher | undefined {
return pushers.find(pusher => pusher[PUSHER_DEVICE_ID.name] === device.device_id);
return pushers.find((pusher) => pusher[PUSHER_DEVICE_ID.name] === device.device_id);
}
const toggleSelection = (deviceId: ExtendedDevice['device_id']): void => {
const toggleSelection = (deviceId: ExtendedDevice["device_id"]): void => {
if (isDeviceSelected(deviceId, selectedDeviceIds)) {
// remove from selection
setSelectedDeviceIds(selectedDeviceIds.filter(id => id !== deviceId));
setSelectedDeviceIds(selectedDeviceIds.filter((id) => id !== deviceId));
} else {
setSelectedDeviceIds([...selectedDeviceIds, deviceId]);
}
};
const options: FilterDropdownOption<DeviceFilterKey>[] = [
{ id: ALL_FILTER_ID, label: _t('All') },
{ id: ALL_FILTER_ID, label: _t("All") },
{
id: DeviceSecurityVariation.Verified,
label: _t('Verified'),
description: _t('Ready for secure messaging'),
label: _t("Verified"),
description: _t("Ready for secure messaging"),
},
{
id: DeviceSecurityVariation.Unverified,
label: _t('Unverified'),
description: _t('Not ready for secure messaging'),
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 },
),
label: _t("Inactive"),
description: _t("Inactive for %(inactiveAgeDays)s days or longer", {
inactiveAgeDays: INACTIVE_DEVICE_AGE_DAYS,
}),
},
];
const onFilterOptionChange = (filterId: DeviceFilterKey) => {
onFilterChange(filterId === ALL_FILTER_ID ? undefined : filterId as FilterVariation);
onFilterChange(filterId === ALL_FILTER_ID ? undefined : (filterId as FilterVariation));
};
const isAllSelected = selectedDeviceIds.length >= sortedDevices.length;
@ -292,77 +288,82 @@ export const FilteredDeviceList =
if (isAllSelected) {
setSelectedDeviceIds([]);
} else {
setSelectedDeviceIds(sortedDevices.map(device => device.device_id));
setSelectedDeviceIds(sortedDevices.map((device) => device.device_id));
}
};
const isSigningOut = !!signingOutDeviceIds.length;
return <div className='mx_FilteredDeviceList' ref={ref}>
<FilteredDeviceListHeader
selectedDeviceCount={selectedDeviceIds.length}
isAllSelected={isAllSelected}
toggleSelectAll={toggleSelectAll}
>
{ selectedDeviceIds.length
? <>
<AccessibleButton
data-testid='sign-out-selection-cta'
kind='danger_inline'
disabled={isSigningOut}
onClick={() => onSignOutDevices(selectedDeviceIds)}
className='mx_FilteredDeviceList_headerButton'
>
{ isSigningOut && <Spinner w={16} h={16} /> }
{ _t('Sign out') }
</AccessibleButton>
<AccessibleButton
data-testid='cancel-selection-cta'
kind='content_inline'
disabled={isSigningOut}
onClick={() => setSelectedDeviceIds([])}
className='mx_FilteredDeviceList_headerButton'
>
{ _t('Cancel') }
</AccessibleButton>
</>
: <FilterDropdown<DeviceFilterKey>
id='device-list-filter'
label={_t('Filter devices')}
value={filter || ALL_FILTER_ID}
onOptionChange={onFilterOptionChange}
options={options}
selectedLabel={_t('Show')}
/>
}
</FilteredDeviceListHeader>
{ !!sortedDevices.length
? <FilterSecurityCard filter={filter} />
: <NoResults filter={filter} clearFilter={() => onFilterChange(undefined)} />
}
<ol className='mx_FilteredDeviceList_list'>
{ sortedDevices.map((device) => <DeviceListItem
key={device.device_id}
device={device}
pusher={getPusherForDevice(device)}
localNotificationSettings={localNotificationSettings.get(device.device_id)}
isExpanded={expandedDeviceIds.includes(device.device_id)}
isSigningOut={signingOutDeviceIds.includes(device.device_id)}
isSelected={isDeviceSelected(device.device_id, selectedDeviceIds)}
onDeviceExpandToggle={() => onDeviceExpandToggle(device.device_id)}
onSignOutDevice={() => onSignOutDevices([device.device_id])}
saveDeviceName={(deviceName: string) => saveDeviceName(device.device_id, deviceName)}
onRequestDeviceVerification={
onRequestDeviceVerification
? () => onRequestDeviceVerification(device.device_id)
: undefined
}
setPushNotifications={setPushNotifications}
toggleSelected={() => toggleSelection(device.device_id)}
supportsMSC3881={supportsMSC3881}
/>,
) }
</ol>
</div>;
});
return (
<div className="mx_FilteredDeviceList" ref={ref}>
<FilteredDeviceListHeader
selectedDeviceCount={selectedDeviceIds.length}
isAllSelected={isAllSelected}
toggleSelectAll={toggleSelectAll}
>
{selectedDeviceIds.length ? (
<>
<AccessibleButton
data-testid="sign-out-selection-cta"
kind="danger_inline"
disabled={isSigningOut}
onClick={() => onSignOutDevices(selectedDeviceIds)}
className="mx_FilteredDeviceList_headerButton"
>
{isSigningOut && <Spinner w={16} h={16} />}
{_t("Sign out")}
</AccessibleButton>
<AccessibleButton
data-testid="cancel-selection-cta"
kind="content_inline"
disabled={isSigningOut}
onClick={() => setSelectedDeviceIds([])}
className="mx_FilteredDeviceList_headerButton"
>
{_t("Cancel")}
</AccessibleButton>
</>
) : (
<FilterDropdown<DeviceFilterKey>
id="device-list-filter"
label={_t("Filter devices")}
value={filter || ALL_FILTER_ID}
onOptionChange={onFilterOptionChange}
options={options}
selectedLabel={_t("Show")}
/>
)}
</FilteredDeviceListHeader>
{!!sortedDevices.length ? (
<FilterSecurityCard filter={filter} />
) : (
<NoResults filter={filter} clearFilter={() => onFilterChange(undefined)} />
)}
<ol className="mx_FilteredDeviceList_list">
{sortedDevices.map((device) => (
<DeviceListItem
key={device.device_id}
device={device}
pusher={getPusherForDevice(device)}
localNotificationSettings={localNotificationSettings.get(device.device_id)}
isExpanded={expandedDeviceIds.includes(device.device_id)}
isSigningOut={signingOutDeviceIds.includes(device.device_id)}
isSelected={isDeviceSelected(device.device_id, selectedDeviceIds)}
onDeviceExpandToggle={() => onDeviceExpandToggle(device.device_id)}
onSignOutDevice={() => onSignOutDevices([device.device_id])}
saveDeviceName={(deviceName: string) => saveDeviceName(device.device_id, deviceName)}
onRequestDeviceVerification={
onRequestDeviceVerification
? () => onRequestDeviceVerification(device.device_id)
: undefined
}
setPushNotifications={setPushNotifications}
toggleSelected={() => toggleSelection(device.device_id)}
supportsMSC3881={supportsMSC3881}
/>
))}
</ol>
</div>
);
},
);

View file

@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { HTMLProps } from 'react';
import React, { HTMLProps } from "react";
import { _t } from '../../../../languageHandler';
import StyledCheckbox, { CheckboxStyle } from '../../elements/StyledCheckbox';
import { Alignment } from '../../elements/Tooltip';
import TooltipTarget from '../../elements/TooltipTarget';
import { _t } from "../../../../languageHandler";
import StyledCheckbox, { CheckboxStyle } from "../../elements/StyledCheckbox";
import { Alignment } from "../../elements/Tooltip";
import TooltipTarget from "../../elements/TooltipTarget";
interface Props extends Omit<HTMLProps<HTMLDivElement>, 'className'> {
interface Props extends Omit<HTMLProps<HTMLDivElement>, "className"> {
selectedDeviceCount: number;
isAllSelected: boolean;
toggleSelectAll: () => void;
@ -35,29 +35,27 @@ const FilteredDeviceListHeader: React.FC<Props> = ({
children,
...rest
}) => {
const checkboxLabel = isAllSelected ? _t('Deselect all') : _t('Select all');
return <div className='mx_FilteredDeviceListHeader' {...rest}>
<TooltipTarget
label={checkboxLabel}
alignment={Alignment.Top}
>
<StyledCheckbox
kind={CheckboxStyle.Solid}
checked={isAllSelected}
onChange={toggleSelectAll}
id='device-select-all-checkbox'
data-testid='device-select-all-checkbox'
aria-label={checkboxLabel}
/>
</TooltipTarget>
<span className='mx_FilteredDeviceListHeader_label'>
{ selectedDeviceCount > 0
? _t('%(selectedDeviceCount)s sessions selected', { selectedDeviceCount })
: _t('Sessions')
}
</span>
{ children }
</div>;
const checkboxLabel = isAllSelected ? _t("Deselect all") : _t("Select all");
return (
<div className="mx_FilteredDeviceListHeader" {...rest}>
<TooltipTarget label={checkboxLabel} alignment={Alignment.Top}>
<StyledCheckbox
kind={CheckboxStyle.Solid}
checked={isAllSelected}
onChange={toggleSelectAll}
id="device-select-all-checkbox"
data-testid="device-select-all-checkbox"
aria-label={checkboxLabel}
/>
</TooltipTarget>
<span className="mx_FilteredDeviceListHeader_label">
{selectedDeviceCount > 0
? _t("%(selectedDeviceCount)s sessions selected", { selectedDeviceCount })
: _t("Sessions")}
</span>
{children}
</div>
);
};
export default FilteredDeviceListHeader;

View file

@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React from "react";
import type { IServerVersions } from 'matrix-js-sdk/src/matrix';
import { _t } from '../../../../languageHandler';
import AccessibleButton from '../../elements/AccessibleButton';
import SettingsSubsection from '../shared/SettingsSubsection';
import SettingsStore from '../../../../settings/SettingsStore';
import type { IServerVersions } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../../languageHandler";
import AccessibleButton from "../../elements/AccessibleButton";
import SettingsSubsection from "../shared/SettingsSubsection";
import SettingsStore from "../../../../settings/SettingsStore";
interface IProps {
onShowQr: () => void;
@ -33,31 +33,32 @@ export default class LoginWithQRSection extends React.Component<IProps> {
}
public render(): JSX.Element | null {
const msc3882Supported = !!this.props.versions?.unstable_features?.['org.matrix.msc3882'];
const msc3886Supported = !!this.props.versions?.unstable_features?.['org.matrix.msc3886'];
const msc3882Supported = !!this.props.versions?.unstable_features?.["org.matrix.msc3882"];
const msc3886Supported = !!this.props.versions?.unstable_features?.["org.matrix.msc3886"];
// Needs to be enabled as a feature + server support MSC3886 or have a default rendezvous server configured:
const offerShowQr = SettingsStore.getValue("feature_qr_signin_reciprocate_show") &&
msc3882Supported && msc3886Supported; // We don't support configuration of a fallback at the moment so we just check the MSCs
const offerShowQr =
SettingsStore.getValue("feature_qr_signin_reciprocate_show") && msc3882Supported && msc3886Supported; // We don't support configuration of a fallback at the moment so we just check the MSCs
// don't show anything if no method is available
if (!offerShowQr) {
return null;
}
return <SettingsSubsection
heading={_t('Sign in with QR code')}
>
<div className="mx_LoginWithQRSection">
<p className="mx_SettingsTab_subsectionText">{
_t("You can use this device to sign in a new device with a QR code. You will need to " +
"scan the QR code shown on this device with your device that's signed out.")
}</p>
<AccessibleButton
onClick={this.props.onShowQr}
kind="primary"
>{ _t("Show QR code") }</AccessibleButton>
</div>
</SettingsSubsection>;
return (
<SettingsSubsection heading={_t("Sign in with QR code")}>
<div className="mx_LoginWithQRSection">
<p className="mx_SettingsTab_subsectionText">
{_t(
"You can use this device to sign in a new device with a QR code. You will need to " +
"scan the QR code shown on this device with your device that's signed out.",
)}
</p>
<AccessibleButton onClick={this.props.onShowQr} kind="primary">
{_t("Show QR code")}
</AccessibleButton>
</div>
</SettingsSubsection>
);
}
}

View file

@ -14,46 +14,35 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React from "react";
import { _t } from '../../../../languageHandler';
import AccessibleButton from '../../elements/AccessibleButton';
import SettingsSubsection from '../shared/SettingsSubsection';
import DeviceSecurityCard from './DeviceSecurityCard';
import { DeviceSecurityLearnMore } from './DeviceSecurityLearnMore';
import { filterDevicesBySecurityRecommendation, FilterVariation, INACTIVE_DEVICE_AGE_DAYS } from './filter';
import {
DeviceSecurityVariation,
ExtendedDevice,
DevicesDictionary,
} from './types';
import { _t } from "../../../../languageHandler";
import AccessibleButton from "../../elements/AccessibleButton";
import SettingsSubsection from "../shared/SettingsSubsection";
import DeviceSecurityCard from "./DeviceSecurityCard";
import { DeviceSecurityLearnMore } from "./DeviceSecurityLearnMore";
import { filterDevicesBySecurityRecommendation, FilterVariation, INACTIVE_DEVICE_AGE_DAYS } from "./filter";
import { DeviceSecurityVariation, ExtendedDevice, DevicesDictionary } from "./types";
interface Props {
devices: DevicesDictionary;
currentDeviceId: ExtendedDevice['device_id'];
currentDeviceId: ExtendedDevice["device_id"];
goToFilteredList: (filter: FilterVariation) => void;
}
const SecurityRecommendations: React.FC<Props> = ({
devices,
currentDeviceId,
goToFilteredList,
}) => {
const SecurityRecommendations: React.FC<Props> = ({ devices, currentDeviceId, goToFilteredList }) => {
const devicesArray = Object.values<ExtendedDevice>(devices);
const unverifiedDevicesCount = filterDevicesBySecurityRecommendation(
devicesArray,
[DeviceSecurityVariation.Unverified],
)
const unverifiedDevicesCount = filterDevicesBySecurityRecommendation(devicesArray, [
DeviceSecurityVariation.Unverified,
])
// filter out the current device
// as unverfied warning and actions
// will be shown in current session section
.filter((device) => device.device_id !== currentDeviceId)
.length;
const inactiveDevicesCount = filterDevicesBySecurityRecommendation(
devicesArray,
[DeviceSecurityVariation.Inactive],
).length;
.filter((device) => device.device_id !== currentDeviceId).length;
const inactiveDevicesCount = filterDevicesBySecurityRecommendation(devicesArray, [
DeviceSecurityVariation.Inactive,
]).length;
if (!(unverifiedDevicesCount | inactiveDevicesCount)) {
return null;
@ -61,61 +50,64 @@ const SecurityRecommendations: React.FC<Props> = ({
const inactiveAgeDays = INACTIVE_DEVICE_AGE_DAYS;
return <SettingsSubsection
heading={_t('Security recommendations')}
description={_t('Improve your account security by following these recommendations')}
data-testid='security-recommendations-section'
>
{
!!unverifiedDevicesCount &&
<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.`,
) }
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Unverified} />
</>}
>
<AccessibleButton
kind='link_inline'
onClick={() => goToFilteredList(DeviceSecurityVariation.Unverified)}
data-testid='unverified-devices-cta'
>
{ _t('View all') + ` (${unverifiedDevicesCount})` }
</AccessibleButton>
</DeviceSecurityCard>
}
{
!!inactiveDevicesCount &&
<>
{ !!unverifiedDevicesCount && <div className='mx_SecurityRecommendations_spacing' /> }
return (
<SettingsSubsection
heading={_t("Security recommendations")}
description={_t("Improve your account security by following these recommendations")}
data-testid="security-recommendations-section"
>
{!!unverifiedDevicesCount && (
<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 },
) }
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Inactive} />
</>
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.`,
)}
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Unverified} />
</>
}
>
<AccessibleButton
kind='link_inline'
onClick={() => goToFilteredList(DeviceSecurityVariation.Inactive)}
data-testid='inactive-devices-cta'
kind="link_inline"
onClick={() => goToFilteredList(DeviceSecurityVariation.Unverified)}
data-testid="unverified-devices-cta"
>
{ _t('View all') + ` (${inactiveDevicesCount})` }
{_t("View all") + ` (${unverifiedDevicesCount})`}
</AccessibleButton>
</DeviceSecurityCard>
</>
}
</SettingsSubsection>;
)}
{!!inactiveDevicesCount && (
<>
{!!unverifiedDevicesCount && <div className="mx_SecurityRecommendations_spacing" />}
<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 },
)}
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Inactive} />
</>
}
>
<AccessibleButton
kind="link_inline"
onClick={() => goToFilteredList(DeviceSecurityVariation.Inactive)}
data-testid="inactive-devices-cta"
>
{_t("View all") + ` (${inactiveDevicesCount})`}
</AccessibleButton>
</DeviceSecurityCard>
</>
)}
</SettingsSubsection>
);
};
export default SecurityRecommendations;

View file

@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React from "react";
import StyledCheckbox, { CheckboxStyle } from '../../elements/StyledCheckbox';
import DeviceTile, { DeviceTileProps } from './DeviceTile';
import StyledCheckbox, { CheckboxStyle } from "../../elements/StyledCheckbox";
import DeviceTile, { DeviceTileProps } from "./DeviceTile";
interface Props extends DeviceTileProps {
isSelected: boolean;
@ -25,26 +25,22 @@ interface Props extends DeviceTileProps {
onClick?: () => void;
}
const SelectableDeviceTile: React.FC<Props> = ({
children,
device,
isSelected,
onSelect,
onClick,
}) => {
return <div className='mx_SelectableDeviceTile'>
<StyledCheckbox
kind={CheckboxStyle.Solid}
checked={isSelected}
onChange={onSelect}
className='mx_SelectableDeviceTile_checkbox'
id={`device-tile-checkbox-${device.device_id}`}
data-testid={`device-tile-checkbox-${device.device_id}`}
/>
<DeviceTile device={device} onClick={onClick} isSelected={isSelected}>
{ children }
</DeviceTile>
</div>;
const SelectableDeviceTile: React.FC<Props> = ({ children, device, isSelected, onSelect, onClick }) => {
return (
<div className="mx_SelectableDeviceTile">
<StyledCheckbox
kind={CheckboxStyle.Solid}
checked={isSelected}
onChange={onSelect}
className="mx_SelectableDeviceTile_checkbox"
id={`device-tile-checkbox-${device.device_id}`}
data-testid={`device-tile-checkbox-${device.device_id}`}
/>
<DeviceTile device={device} onClick={onClick} isSelected={isSelected}>
{children}
</DeviceTile>
</div>
);
};
export default SelectableDeviceTile;

View file

@ -23,14 +23,16 @@ import { InteractiveAuthCallback } from "../../../structures/InteractiveAuth";
import { SSOAuthEntry } from "../../auth/InteractiveAuthEntryComponents";
import InteractiveAuthDialog from "../../dialogs/InteractiveAuthDialog";
const makeDeleteRequest = (
matrixClient: MatrixClient, deviceIds: string[],
) => async (auth?: IAuthData): Promise<void> => {
await matrixClient.deleteMultipleDevices(deviceIds, auth);
};
const makeDeleteRequest =
(matrixClient: MatrixClient, deviceIds: string[]) =>
async (auth?: IAuthData): Promise<void> => {
await matrixClient.deleteMultipleDevices(deviceIds, auth);
};
export const deleteDevicesWithInteractiveAuth = async (
matrixClient: MatrixClient, deviceIds: string[], onFinished?: InteractiveAuthCallback,
matrixClient: MatrixClient,
deviceIds: string[],
onFinished?: InteractiveAuthCallback,
) => {
if (!deviceIds.length) {
return;

View file

@ -19,19 +19,20 @@ import { ExtendedDevice, DeviceSecurityVariation } from "./types";
type DeviceFilterCondition = (device: ExtendedDevice) => 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.776e9; // 90 days
export const INACTIVE_DEVICE_AGE_DAYS = INACTIVE_DEVICE_AGE_MS / MS_DAY;
export type FilterVariation = DeviceSecurityVariation.Verified
export type FilterVariation =
| DeviceSecurityVariation.Verified
| DeviceSecurityVariation.Inactive
| DeviceSecurityVariation.Unverified;
export const isDeviceInactive: DeviceFilterCondition = device =>
export const isDeviceInactive: DeviceFilterCondition = (device) =>
!!device.last_seen_ts && device.last_seen_ts < Date.now() - INACTIVE_DEVICE_AGE_MS;
const filters: Record<FilterVariation, DeviceFilterCondition> = {
[DeviceSecurityVariation.Verified]: device => !!device.isVerified,
[DeviceSecurityVariation.Unverified]: device => !device.isVerified,
[DeviceSecurityVariation.Verified]: (device) => !!device.isVerified,
[DeviceSecurityVariation.Unverified]: (device) => !device.isVerified,
[DeviceSecurityVariation.Inactive]: isDeviceInactive,
};
@ -39,9 +40,9 @@ export const filterDevicesBySecurityRecommendation = (
devices: ExtendedDevice[],
securityVariations: FilterVariation[],
) => {
const activeFilters = securityVariations.map(variation => filters[variation]);
const activeFilters = securityVariations.map((variation) => filters[variation]);
if (!activeFilters.length) {
return devices;
}
return devices.filter(device => activeFilters.every(filter => filter(device)));
return devices.filter((device) => activeFilters.every((filter) => filter(device)));
};

View file

@ -26,13 +26,13 @@ export type ExtendedDeviceAppInfo = {
url?: string;
};
export type ExtendedDevice = DeviceWithVerification & ExtendedDeviceAppInfo & ExtendedDeviceInformation;
export type DevicesDictionary = Record<ExtendedDevice['device_id'], ExtendedDevice>;
export type DevicesDictionary = Record<ExtendedDevice["device_id"], ExtendedDevice>;
export enum DeviceSecurityVariation {
Verified = 'Verified',
Unverified = 'Unverified',
Inactive = 'Inactive',
Verified = "Verified",
Unverified = "Unverified",
Inactive = "Inactive",
// sessions that do not support encryption
// eg a session that logged in via api to get an access token
Unverifiable = 'Unverifiable'
Unverifiable = "Unverifiable",
}

View file

@ -48,18 +48,13 @@ const isDeviceVerified = (
try {
const userId = matrixClient.getUserId();
if (!userId) {
throw new Error('No user id');
throw new Error("No user id");
}
const deviceInfo = matrixClient.getStoredDevice(userId, device.device_id);
if (!deviceInfo) {
throw new Error('No device info available');
throw new Error("No device info available");
}
return crossSigningInfo.checkDeviceTrust(
crossSigningInfo,
deviceInfo,
false,
true,
).isCrossSigningVerified();
return crossSigningInfo.checkDeviceTrust(crossSigningInfo, deviceInfo, false, true).isCrossSigningVerified();
} catch (error) {
logger.error("Error getting device cross-signing info", error);
return null;
@ -79,27 +74,30 @@ const parseDeviceExtendedInformation = (matrixClient: MatrixClient, device: IMyD
const fetchDevicesWithVerification = async (
matrixClient: MatrixClient,
userId: string,
): Promise<DevicesState['devices']> => {
): Promise<DevicesState["devices"]> => {
const { devices } = await matrixClient.getDevices();
const crossSigningInfo = matrixClient.getStoredCrossSigningForUser(userId);
const devicesDict = devices.reduce((acc, device: IMyDevice) => ({
...acc,
[device.device_id]: {
...device,
isVerified: isDeviceVerified(matrixClient, crossSigningInfo, device),
...parseDeviceExtendedInformation(matrixClient, device),
...parseUserAgent(device[UNSTABLE_MSC3852_LAST_SEEN_UA.name]),
},
}), {});
const devicesDict = devices.reduce(
(acc, device: IMyDevice) => ({
...acc,
[device.device_id]: {
...device,
isVerified: isDeviceVerified(matrixClient, crossSigningInfo, device),
...parseDeviceExtendedInformation(matrixClient, device),
...parseUserAgent(device[UNSTABLE_MSC3852_LAST_SEEN_UA.name]),
},
}),
{},
);
return devicesDict;
};
export enum OwnDevicesError {
Unsupported = 'Unsupported',
Default = 'Default',
Unsupported = "Unsupported",
Default = "Default",
}
export type DevicesState = {
devices: DevicesDictionary;
@ -108,10 +106,10 @@ export type DevicesState = {
currentDeviceId: string;
isLoadingDeviceList: boolean;
// not provided when current session cannot request verification
requestDeviceVerification?: (deviceId: ExtendedDevice['device_id']) => Promise<VerificationRequest>;
requestDeviceVerification?: (deviceId: ExtendedDevice["device_id"]) => Promise<VerificationRequest>;
refreshDevices: () => Promise<void>;
saveDeviceName: (deviceId: ExtendedDevice['device_id'], deviceName: string) => Promise<void>;
setPushNotifications: (deviceId: ExtendedDevice['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;
};
@ -121,17 +119,18 @@ export const useOwnDevices = (): DevicesState => {
const currentDeviceId = matrixClient.getDeviceId();
const userId = matrixClient.getUserId();
const [devices, setDevices] = useState<DevicesState['devices']>({});
const [pushers, setPushers] = useState<DevicesState['pushers']>([]);
const [localNotificationSettings, setLocalNotificationSettings]
= useState<DevicesState['localNotificationSettings']>(new Map<string, LocalNotificationSettings>());
const [devices, setDevices] = useState<DevicesState["devices"]>({});
const [pushers, setPushers] = useState<DevicesState["pushers"]>([]);
const [localNotificationSettings, setLocalNotificationSettings] = useState<
DevicesState["localNotificationSettings"]
>(new Map<string, LocalNotificationSettings>());
const [isLoadingDeviceList, setIsLoadingDeviceList] = useState(true);
const [supportsMSC3881, setSupportsMSC3881] = useState(true); // optimisticly saying yes!
const [error, setError] = useState<OwnDevicesError>();
useEffect(() => {
matrixClient.doesServerSupportUnstableFeature("org.matrix.msc3881").then(hasSupport => {
matrixClient.doesServerSupportUnstableFeature("org.matrix.msc3881").then((hasSupport) => {
setSupportsMSC3881(hasSupport);
});
}, [matrixClient]);
@ -142,7 +141,7 @@ export const useOwnDevices = (): DevicesState => {
// realistically we should never hit this
// but it satisfies types
if (!userId) {
throw new Error('Cannot fetch devices without user id');
throw new Error("Cannot fetch devices without user id");
}
const devices = await fetchDevicesWithVerification(matrixClient, userId);
setDevices(devices);
@ -155,10 +154,7 @@ export const useOwnDevices = (): DevicesState => {
const eventType = `${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`;
const event = matrixClient.getAccountData(eventType);
if (event) {
notificationSettings.set(
deviceId,
event.getContent(),
);
notificationSettings.set(deviceId, event.getContent());
}
});
setLocalNotificationSettings(notificationSettings);
@ -198,17 +194,15 @@ export const useOwnDevices = (): DevicesState => {
const isCurrentDeviceVerified = !!devices[currentDeviceId]?.isVerified;
const requestDeviceVerification = isCurrentDeviceVerified && userId
? async (deviceId: ExtendedDevice['device_id']) => {
return await matrixClient.requestVerification(
userId,
[deviceId],
);
}
: undefined;
const requestDeviceVerification =
isCurrentDeviceVerified && userId
? async (deviceId: ExtendedDevice["device_id"]) => {
return await matrixClient.requestVerification(userId, [deviceId]);
}
: undefined;
const saveDeviceName = useCallback(
async (deviceId: ExtendedDevice['device_id'], deviceName: string): Promise<void> => {
async (deviceId: ExtendedDevice["device_id"], deviceName: string): Promise<void> => {
const device = devices[deviceId];
// no change
@ -217,21 +211,20 @@ export const useOwnDevices = (): DevicesState => {
}
try {
await matrixClient.setDeviceDetails(
deviceId,
{ display_name: deviceName },
);
await matrixClient.setDeviceDetails(deviceId, { display_name: deviceName });
await refreshDevices();
} catch (error) {
logger.error("Error setting session display name", error);
throw new Error(_t("Failed to set display name"));
}
}, [matrixClient, devices, refreshDevices]);
},
[matrixClient, devices, refreshDevices],
);
const setPushNotifications = useCallback(
async (deviceId: ExtendedDevice['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);
const pusher = pushers.find((pusher) => pusher[PUSHER_DEVICE_ID.name] === deviceId);
if (pusher) {
await matrixClient.setPusher({
...pusher,
@ -248,7 +241,8 @@ export const useOwnDevices = (): DevicesState => {
} finally {
await refreshDevices();
}
}, [matrixClient, pushers, localNotificationSettings, refreshDevices],
},
[matrixClient, pushers, localNotificationSettings, refreshDevices],
);
return {