Improve device list in Security & Privacy settings (#7004)
Overhaul the device list in the "Security and Privacy" settings tab to include device trust status, provide buttons for verifying unverified devices, and improve overall usability and style. This should now be the primary interface for checking and changing the trust status of your own devices, rather than looking at your own user profile in the right panel.
This commit is contained in:
parent
ea54ea89d4
commit
d88b8efd19
8 changed files with 489 additions and 130 deletions
|
@ -27,6 +27,7 @@ import InteractiveAuthDialog from "../dialogs/InteractiveAuthDialog";
|
|||
import DevicesPanelEntry from "./DevicesPanelEntry";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { CrossSigningInfo } from "matrix-js-sdk/src/crypto/CrossSigning";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
|
@ -36,6 +37,7 @@ interface IProps {
|
|||
|
||||
interface IState {
|
||||
devices: IMyDevice[];
|
||||
crossSigningInfo?: CrossSigningInfo;
|
||||
deviceLoadError?: string;
|
||||
selectedDevices: string[];
|
||||
deleting?: boolean;
|
||||
|
@ -51,6 +53,7 @@ export default class DevicesPanel extends React.Component<IProps, IState> {
|
|||
devices: [],
|
||||
selectedDevices: [],
|
||||
};
|
||||
this.loadDevices = this.loadDevices.bind(this);
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
|
@ -62,20 +65,34 @@ export default class DevicesPanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
private loadDevices(): void {
|
||||
MatrixClientPeg.get().getDevices().then(
|
||||
const cli = MatrixClientPeg.get();
|
||||
cli.getDevices().then(
|
||||
(resp) => {
|
||||
if (this.unmounted) { return; }
|
||||
this.setState({ devices: resp.devices || [] });
|
||||
|
||||
const crossSigningInfo = cli.getStoredCrossSigningForUser(cli.getUserId());
|
||||
this.setState((state, props) => {
|
||||
const deviceIds = resp.devices.map((device) => device.device_id);
|
||||
const selectedDevices = state.selectedDevices.filter(
|
||||
(deviceId) => deviceIds.includes(deviceId),
|
||||
);
|
||||
return {
|
||||
devices: resp.devices || [],
|
||||
selectedDevices,
|
||||
crossSigningInfo: crossSigningInfo,
|
||||
};
|
||||
});
|
||||
console.log(this.state);
|
||||
},
|
||||
(error) => {
|
||||
if (this.unmounted) { return; }
|
||||
let errtxt;
|
||||
if (error.httpStatus == 404) {
|
||||
// 404 probably means the HS doesn't yet support the API.
|
||||
errtxt = _t("Your homeserver does not support session management.");
|
||||
errtxt = _t("Your homeserver does not support device management.");
|
||||
} else {
|
||||
logger.error("Error loading sessions:", error);
|
||||
errtxt = _t("Unable to load session list");
|
||||
errtxt = _t("Unable to load device list");
|
||||
}
|
||||
this.setState({ deviceLoadError: errtxt });
|
||||
},
|
||||
|
@ -98,6 +115,22 @@ export default class DevicesPanel extends React.Component<IProps, IState> {
|
|||
return (idA < idB) ? -1 : (idA > idB) ? 1 : 0;
|
||||
}
|
||||
|
||||
private isDeviceVerified(device: IMyDevice): boolean | null {
|
||||
try {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const deviceInfo = cli.getStoredDevice(cli.getUserId(), device.device_id);
|
||||
return this.state.crossSigningInfo.checkDeviceTrust(
|
||||
this.state.crossSigningInfo,
|
||||
deviceInfo,
|
||||
false,
|
||||
true,
|
||||
).isCrossSigningVerified();
|
||||
} catch (e) {
|
||||
console.error("Error getting device cross-signing info", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private onDeviceSelectionToggled = (device: IMyDevice): void => {
|
||||
if (this.unmounted) { return; }
|
||||
|
||||
|
@ -117,7 +150,40 @@ export default class DevicesPanel extends React.Component<IProps, IState> {
|
|||
});
|
||||
};
|
||||
|
||||
private selectAll = (devices: IMyDevice[]): void => {
|
||||
this.setState((state, props) => {
|
||||
const selectedDevices = state.selectedDevices.slice();
|
||||
|
||||
for (const device of devices) {
|
||||
const deviceId = device.device_id;
|
||||
if (!selectedDevices.includes(deviceId)) {
|
||||
selectedDevices.push(deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
return { selectedDevices };
|
||||
});
|
||||
};
|
||||
|
||||
private deselectAll = (devices: IMyDevice[]): void => {
|
||||
this.setState((state, props) => {
|
||||
const selectedDevices = state.selectedDevices.slice();
|
||||
|
||||
for (const device of devices) {
|
||||
const deviceId = device.device_id;
|
||||
const i = selectedDevices.indexOf(deviceId);
|
||||
if (i !== -1) {
|
||||
selectedDevices.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return { selectedDevices };
|
||||
});
|
||||
};
|
||||
|
||||
private onDeleteClick = (): void => {
|
||||
if (this.state.selectedDevices.length === 0) { return; }
|
||||
|
||||
this.setState({
|
||||
deleting: true,
|
||||
});
|
||||
|
@ -135,18 +201,18 @@ export default class DevicesPanel extends React.Component<IProps, IState> {
|
|||
const dialogAesthetics = {
|
||||
[SSOAuthEntry.PHASE_PREAUTH]: {
|
||||
title: _t("Use Single Sign On to continue"),
|
||||
body: _t("Confirm deleting these sessions by using Single Sign On to prove your identity.", {
|
||||
body: _t("Confirm logging out these devices by using Single Sign On to prove your identity.", {
|
||||
count: numDevices,
|
||||
}),
|
||||
continueText: _t("Single Sign On"),
|
||||
continueKind: "primary",
|
||||
},
|
||||
[SSOAuthEntry.PHASE_POSTAUTH]: {
|
||||
title: _t("Confirm deleting these sessions"),
|
||||
body: _t("Click the button below to confirm deleting these sessions.", {
|
||||
title: _t("Confirm signing out these devices"),
|
||||
body: _t("Click the button below to confirm signing out these devices.", {
|
||||
count: numDevices,
|
||||
}),
|
||||
continueText: _t("Delete sessions", { count: numDevices }),
|
||||
continueText: _t("Sign out devices", { count: numDevices }),
|
||||
continueKind: "danger",
|
||||
},
|
||||
};
|
||||
|
@ -174,34 +240,46 @@ export default class DevicesPanel extends React.Component<IProps, IState> {
|
|||
private makeDeleteRequest(auth?: any): Promise<any> {
|
||||
return MatrixClientPeg.get().deleteMultipleDevices(this.state.selectedDevices, auth).then(
|
||||
() => {
|
||||
// Remove the deleted devices from `devices`, reset selection to []
|
||||
// Reset selection to [], update device list
|
||||
this.setState({
|
||||
devices: this.state.devices.filter(
|
||||
(d) => !this.state.selectedDevices.includes(d.device_id),
|
||||
),
|
||||
selectedDevices: [],
|
||||
});
|
||||
this.loadDevices();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private renderDevice = (device: IMyDevice): JSX.Element => {
|
||||
const myDeviceId = MatrixClientPeg.get().getDeviceId();
|
||||
const myDevice = this.state.devices.find((device) => (device.device_id === myDeviceId));
|
||||
|
||||
const isOwnDevice = device.device_id === myDeviceId;
|
||||
|
||||
// If our own device is unverified, it can't verify other
|
||||
// devices, it can only request verification for itself
|
||||
const canBeVerified = (myDevice && this.isDeviceVerified(myDevice)) || isOwnDevice;
|
||||
|
||||
return <DevicesPanelEntry
|
||||
key={device.device_id}
|
||||
device={device}
|
||||
selected={this.state.selectedDevices.includes(device.device_id)}
|
||||
isOwnDevice={isOwnDevice}
|
||||
verified={this.isDeviceVerified(device)}
|
||||
canBeVerified={canBeVerified}
|
||||
onDeviceChange={this.loadDevices}
|
||||
onDeviceToggled={this.onDeviceSelectionToggled}
|
||||
/>;
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
const loadError = (
|
||||
<div className={classNames(this.props.className, "error")}>
|
||||
{ this.state.deviceLoadError }
|
||||
</div>
|
||||
);
|
||||
|
||||
if (this.state.deviceLoadError !== undefined) {
|
||||
const classes = classNames(this.props.className, "error");
|
||||
return (
|
||||
<div className={classes}>
|
||||
{ this.state.deviceLoadError }
|
||||
</div>
|
||||
);
|
||||
return loadError;
|
||||
}
|
||||
|
||||
const devices = this.state.devices;
|
||||
|
@ -210,31 +288,121 @@ export default class DevicesPanel extends React.Component<IProps, IState> {
|
|||
return <Spinner />;
|
||||
}
|
||||
|
||||
devices.sort(this.deviceCompare);
|
||||
const myDeviceId = MatrixClientPeg.get().getDeviceId();
|
||||
const myDevice = devices.find((device) => (device.device_id === myDeviceId));
|
||||
if (!myDevice) {
|
||||
return loadError;
|
||||
}
|
||||
|
||||
const otherDevices = devices.filter((device) => (device.device_id !== myDeviceId));
|
||||
otherDevices.sort(this.deviceCompare);
|
||||
|
||||
const verifiedDevices = [];
|
||||
const unverifiedDevices = [];
|
||||
const nonCryptoDevices = [];
|
||||
for (const device of otherDevices) {
|
||||
const verified = this.isDeviceVerified(device);
|
||||
if (verified === true) {
|
||||
verifiedDevices.push(device);
|
||||
} else if (verified === false) {
|
||||
unverifiedDevices.push(device);
|
||||
} else {
|
||||
nonCryptoDevices.push(device);
|
||||
}
|
||||
}
|
||||
|
||||
const section = (trustIcon: JSX.Element, title: string, deviceList: IMyDevice[]): JSX.Element => {
|
||||
if (deviceList.length === 0) {
|
||||
return <React.Fragment />;
|
||||
}
|
||||
|
||||
let selectButton: JSX.Element;
|
||||
if (deviceList.length > 1) {
|
||||
const anySelected = deviceList.some((device) => this.state.selectedDevices.includes(device.device_id));
|
||||
const buttonAction = anySelected ?
|
||||
() => { this.deselectAll(deviceList); } :
|
||||
() => { this.selectAll(deviceList); };
|
||||
const buttonText = anySelected ? _t("Deselect all") : _t("Select all");
|
||||
selectButton = <div className="mx_DevicesPanel_header_button">
|
||||
<AccessibleButton
|
||||
className="mx_DevicesPanel_selectButton"
|
||||
kind="secondary"
|
||||
onClick={buttonAction}
|
||||
>
|
||||
{ buttonText }
|
||||
</AccessibleButton>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <React.Fragment>
|
||||
<hr />
|
||||
<div className="mx_DevicesPanel_header">
|
||||
<div className="mx_DevicesPanel_header_trust">
|
||||
{ trustIcon }
|
||||
</div>
|
||||
<div className="mx_DevicesPanel_header_title">
|
||||
{ title }
|
||||
</div>
|
||||
{ selectButton }
|
||||
</div>
|
||||
{ deviceList.map(this.renderDevice) }
|
||||
</React.Fragment>;
|
||||
};
|
||||
|
||||
const verifiedDevicesSection = section(
|
||||
<span className="mx_DevicesPanel_header_icon mx_E2EIcon mx_E2EIcon_verified" />,
|
||||
_t("Verified devices"),
|
||||
verifiedDevices,
|
||||
);
|
||||
|
||||
const unverifiedDevicesSection = section(
|
||||
<span className="mx_DevicesPanel_header_icon mx_E2EIcon mx_E2EIcon_warning" />,
|
||||
_t("Unverified devices"),
|
||||
unverifiedDevices,
|
||||
);
|
||||
|
||||
const nonCryptoDevicesSection = section(
|
||||
<React.Fragment />,
|
||||
_t("Devices without encryption support"),
|
||||
nonCryptoDevices,
|
||||
);
|
||||
|
||||
const deleteButton = this.state.deleting ?
|
||||
<Spinner w={22} h={22} /> :
|
||||
<AccessibleButton onClick={this.onDeleteClick} kind="danger_sm">
|
||||
{ _t("Delete %(count)s sessions", { count: this.state.selectedDevices.length }) }
|
||||
<AccessibleButton
|
||||
className="mx_DevicesPanel_deleteButton"
|
||||
onClick={this.onDeleteClick}
|
||||
kind="danger_outline"
|
||||
disabled={this.state.selectedDevices.length === 0}
|
||||
>
|
||||
{ _t("Sign out %(count)s selected devices", { count: this.state.selectedDevices.length }) }
|
||||
</AccessibleButton>;
|
||||
|
||||
const otherDevicesSection = (otherDevices.length > 0) ?
|
||||
<React.Fragment>
|
||||
{ verifiedDevicesSection }
|
||||
{ unverifiedDevicesSection }
|
||||
{ nonCryptoDevicesSection }
|
||||
{ deleteButton }
|
||||
</React.Fragment> :
|
||||
<React.Fragment>
|
||||
<hr />
|
||||
<div className="mx_DevicesPanel_noOtherDevices">
|
||||
{ _t("You aren't signed into any other devices.") }
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
|
||||
const classes = classNames(this.props.className, "mx_DevicesPanel");
|
||||
return (
|
||||
<table className={classes}>
|
||||
<thead className="mx_DevicesPanel_header">
|
||||
<tr>
|
||||
<th className="mx_DevicesPanel_deviceId">{ _t("ID") }</th>
|
||||
<th className="mx_DevicesPanel_deviceName">{ _t("Public Name") }</th>
|
||||
<th className="mx_DevicesPanel_deviceLastSeen">{ _t("Last seen") }</th>
|
||||
<th className="mx_DevicesPanel_deviceButtons">
|
||||
{ this.state.selectedDevices.length > 0 ? deleteButton : null }
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ devices.map(this.renderDevice) }
|
||||
</tbody>
|
||||
</table>
|
||||
<div className={classes}>
|
||||
<div className="mx_DevicesPanel_header">
|
||||
<div className="mx_DevicesPanel_header_title">
|
||||
{ _t("This device") }
|
||||
</div>
|
||||
</div>
|
||||
{ this.renderDevice(myDevice) }
|
||||
{ otherDevicesSection }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,34 +22,98 @@ import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
|||
import { formatDate } from '../../../DateUtils';
|
||||
import StyledCheckbox from '../elements/StyledCheckbox';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import EditableTextContainer from "../elements/EditableTextContainer";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import Field from "../elements/Field";
|
||||
import TextWithTooltip from "../elements/TextWithTooltip";
|
||||
import Modal from "../../../Modal";
|
||||
import SetupEncryptionDialog from '../dialogs/security/SetupEncryptionDialog';
|
||||
import VerificationRequestDialog from '../../views/dialogs/VerificationRequestDialog';
|
||||
import LogoutDialog from '../dialogs/LogoutDialog';
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
interface IProps {
|
||||
device?: IMyDevice;
|
||||
onDeviceToggled?: (device: IMyDevice) => void;
|
||||
selected?: boolean;
|
||||
device: IMyDevice;
|
||||
isOwnDevice: boolean;
|
||||
verified: boolean | null;
|
||||
canBeVerified: boolean;
|
||||
onDeviceChange: () => void;
|
||||
onDeviceToggled: (device: IMyDevice) => void;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
renaming: boolean;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.settings.DevicesPanelEntry")
|
||||
export default class DevicesPanelEntry extends React.Component<IProps> {
|
||||
public static defaultProps = {
|
||||
onDeviceToggled: () => {},
|
||||
export default class DevicesPanelEntry extends React.Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
renaming: false,
|
||||
displayName: props.device.display_name,
|
||||
};
|
||||
}
|
||||
|
||||
private onDeviceToggled = (): void => {
|
||||
this.props.onDeviceToggled(this.props.device);
|
||||
};
|
||||
|
||||
private onDisplayNameChanged = (value: string): Promise<{}> => {
|
||||
const device = this.props.device;
|
||||
return MatrixClientPeg.get().setDeviceDetails(device.device_id, {
|
||||
display_name: value,
|
||||
private onRename = (): void => {
|
||||
this.setState({ renaming: true });
|
||||
};
|
||||
|
||||
private onChangeDisplayName = (ev: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
this.setState({
|
||||
displayName: ev.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
private onRenameSubmit = async () => {
|
||||
this.setState({ renaming: false });
|
||||
await MatrixClientPeg.get().setDeviceDetails(this.props.device.device_id, {
|
||||
display_name: this.state.displayName,
|
||||
}).catch((e) => {
|
||||
logger.error("Error setting session display name", e);
|
||||
throw new Error(_t("Failed to set display name"));
|
||||
});
|
||||
this.props.onDeviceChange();
|
||||
};
|
||||
|
||||
private onDeviceToggled = (): void => {
|
||||
this.props.onDeviceToggled(this.props.device);
|
||||
private onRenameCancel = (): void => {
|
||||
this.setState({ renaming: false });
|
||||
};
|
||||
|
||||
private onOwnDeviceSignOut = (): void => {
|
||||
Modal.createTrackedDialog('Logout from device list', '', LogoutDialog,
|
||||
/* props= */{}, /* className= */null,
|
||||
/* isPriority= */false, /* isStatic= */true);
|
||||
};
|
||||
|
||||
private verify = async () => {
|
||||
if (this.props.isOwnDevice) {
|
||||
Modal.createTrackedDialog("Verify session", "Verify session", SetupEncryptionDialog, {
|
||||
onFinished: this.props.onDeviceChange,
|
||||
});
|
||||
} else {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const userId = cli.getUserId();
|
||||
const verificationRequestPromise = cli.requestVerification(
|
||||
userId,
|
||||
[this.props.device.device_id],
|
||||
);
|
||||
Modal.createTrackedDialog('New Session Verification', 'Starting dialog', VerificationRequestDialog, {
|
||||
verificationRequestPromise,
|
||||
member: cli.getUser(userId),
|
||||
onFinished: async () => {
|
||||
const request = await verificationRequestPromise;
|
||||
request.cancel();
|
||||
this.props.onDeviceChange();
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
|
@ -57,34 +121,78 @@ export default class DevicesPanelEntry extends React.Component<IProps> {
|
|||
|
||||
let lastSeen = "";
|
||||
if (device.last_seen_ts) {
|
||||
const lastSeenDate = formatDate(new Date(device.last_seen_ts));
|
||||
lastSeen = device.last_seen_ip + " @ " +
|
||||
lastSeenDate.toLocaleString();
|
||||
const lastSeenDate = new Date(device.last_seen_ts);
|
||||
lastSeen = _t("Last seen %(date)s at %(ip)s", {
|
||||
date: formatDate(lastSeenDate),
|
||||
ip: device.last_seen_ip,
|
||||
});
|
||||
}
|
||||
|
||||
let myDeviceClass = '';
|
||||
if (device.device_id === MatrixClientPeg.get().getDeviceId()) {
|
||||
myDeviceClass = " mx_DevicesPanel_myDevice";
|
||||
const myDeviceClass = this.props.isOwnDevice ? " mx_DevicesPanel_myDevice" : '';
|
||||
|
||||
let iconClass = '';
|
||||
let verifyButton: JSX.Element;
|
||||
if (this.props.verified !== null) {
|
||||
iconClass = this.props.verified ? "mx_E2EIcon_verified" : "mx_E2EIcon_warning";
|
||||
if (!this.props.verified && this.props.canBeVerified) {
|
||||
verifyButton = <AccessibleButton kind="primary" onClick={this.verify}>
|
||||
{ _t("Verify") }
|
||||
</AccessibleButton>;
|
||||
}
|
||||
}
|
||||
|
||||
let signOutButton: JSX.Element;
|
||||
if (this.props.isOwnDevice) {
|
||||
signOutButton = <AccessibleButton kind="danger_outline" onClick={this.onOwnDeviceSignOut}>
|
||||
{ _t("Sign Out") }
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
const left = this.props.isOwnDevice ?
|
||||
<div className="mx_DevicesPanel_deviceTrust">
|
||||
<span className={"mx_DevicesPanel_icon mx_E2EIcon " + iconClass} />
|
||||
</div> :
|
||||
<div className="mx_DevicesPanel_checkbox">
|
||||
<StyledCheckbox onChange={this.onDeviceToggled} checked={this.props.selected} />
|
||||
</div>;
|
||||
|
||||
const buttons = this.state.renaming ?
|
||||
<form className="mx_DevicesPanel_renameForm" onSubmit={this.onRenameSubmit}>
|
||||
<Field
|
||||
label={_t("Display Name")}
|
||||
type="text"
|
||||
value={this.state.displayName}
|
||||
autoComplete="off"
|
||||
onChange={this.onChangeDisplayName}
|
||||
/>
|
||||
<AccessibleButton onClick={this.onRenameSubmit} kind="confirm_sm" />
|
||||
<AccessibleButton onClick={this.onRenameCancel} kind="cancel_sm" />
|
||||
</form> :
|
||||
<React.Fragment>
|
||||
{ signOutButton }
|
||||
{ verifyButton }
|
||||
<AccessibleButton kind="primary_outline" onClick={this.onRename}>
|
||||
{ _t("Rename") }
|
||||
</AccessibleButton>
|
||||
</React.Fragment>;
|
||||
|
||||
return (
|
||||
<tr className={"mx_DevicesPanel_device" + myDeviceClass}>
|
||||
<td className="mx_DevicesPanel_deviceId">
|
||||
{ device.device_id }
|
||||
</td>
|
||||
<td className="mx_DevicesPanel_deviceName">
|
||||
<EditableTextContainer initialValue={device.display_name}
|
||||
onSubmit={this.onDisplayNameChanged}
|
||||
placeholder={device.device_id}
|
||||
/>
|
||||
</td>
|
||||
<td className="mx_DevicesPanel_lastSeen">
|
||||
{ lastSeen }
|
||||
</td>
|
||||
<td className="mx_DevicesPanel_deviceButtons">
|
||||
<StyledCheckbox onChange={this.onDeviceToggled} checked={this.props.selected} />
|
||||
</td>
|
||||
</tr>
|
||||
<div className={"mx_DevicesPanel_device" + myDeviceClass}>
|
||||
{ left }
|
||||
<div className="mx_DevicesPanel_deviceInfo">
|
||||
<div className="mx_DevicesPanel_deviceName">
|
||||
<TextWithTooltip tooltip={device.display_name + " (" + device.device_id + ")"}>
|
||||
{ device.display_name }
|
||||
</TextWithTooltip>
|
||||
</div>
|
||||
<div className="mx_DevicesPanel_lastSeen">
|
||||
{ lastSeen }
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx_DevicesPanel_deviceButtons">
|
||||
{ buttons }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -326,23 +326,15 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
|
|||
return (
|
||||
<div className="mx_SettingsTab mx_SecurityUserSettingsTab">
|
||||
{ warning }
|
||||
<div className="mx_SettingsTab_heading">{ _t("Where you’re logged in") }</div>
|
||||
<div className="mx_SettingsTab_heading">{ _t("Where you’re signed in") }</div>
|
||||
<div className="mx_SettingsTab_section">
|
||||
<span>
|
||||
{ _t(
|
||||
"Manage the names of and sign out of your sessions below or " +
|
||||
"<a>verify them in your User Profile</a>.", {},
|
||||
{
|
||||
a: sub => <AccessibleButton kind="link" onClick={this.onGoToUserProfileClick}>
|
||||
{ sub }
|
||||
</AccessibleButton>,
|
||||
},
|
||||
"Manage your signed-in devices below. " +
|
||||
"A device's name is visible to people you communicate with.",
|
||||
) }
|
||||
</span>
|
||||
<div className='mx_SettingsTab_subsectionText'>
|
||||
{ _t("A session's public name is visible to people you communicate with") }
|
||||
<DevicesPanel />
|
||||
</div>
|
||||
<DevicesPanel />
|
||||
</div>
|
||||
<div className="mx_SettingsTab_heading">{ _t("Encryption") }</div>
|
||||
<div className="mx_SettingsTab_section">
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue