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
|
@ -115,3 +115,43 @@ limitations under the License.
|
|||
.mx_AccessibleButton_kind_link_sm.mx_AccessibleButton_disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.mx_AccessibleButton_hasKind.mx_AccessibleButton_kind_confirm_sm {
|
||||
background-color: $button-primary-bg-color;
|
||||
|
||||
&::before {
|
||||
mask-image: url('$(res)/img/feather-customised/check.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.mx_AccessibleButton_hasKind.mx_AccessibleButton_kind_cancel_sm {
|
||||
background-color: $button-danger-bg-color;
|
||||
|
||||
&::before {
|
||||
mask-image: url('$(res)/img/feather-customised/x.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.mx_AccessibleButton_kind_confirm_sm,
|
||||
.mx_AccessibleButton_kind_cancel_sm {
|
||||
padding: 0px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 100%;
|
||||
position: relative;
|
||||
display: block;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: #ffffff;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-size: 80%;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,42 +15,81 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
.mx_DevicesPanel {
|
||||
table-layout: fixed;
|
||||
// Normally the panel is 880px, however this can easily overflow the container.
|
||||
// TODO: Fix the table to not be squishy
|
||||
width: auto;
|
||||
max-width: 880px;
|
||||
border-spacing: 10px;
|
||||
|
||||
hr {
|
||||
opacity: 0.2;
|
||||
border: none;
|
||||
border-bottom: 1px solid $primary-content;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_DevicesPanel_header {
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-block: 10px;
|
||||
|
||||
.mx_DevicesPanel_header_title {
|
||||
font-size: $font-18px;
|
||||
font-weight: 600;
|
||||
color: $primary-content;
|
||||
}
|
||||
|
||||
.mx_DevicesPanel_selectButton {
|
||||
padding-top: 9px;
|
||||
}
|
||||
|
||||
.mx_E2EIcon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-left: 0;
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_DevicesPanel_header .mx_DevicesPanel_deviceButtons {
|
||||
height: 48px; // make this tall so the table doesn't move down when the delete button appears
|
||||
width: 20%;
|
||||
.mx_DevicesPanel_deleteButton {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.mx_DevicesPanel_header th {
|
||||
padding: 0px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
.mx_DevicesPanel_device {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-block: 10px;
|
||||
min-height: 35px;
|
||||
}
|
||||
|
||||
.mx_DevicesPanel_header .mx_DevicesPanel_deviceName {
|
||||
width: 50%;
|
||||
.mx_DevicesPanel_icon, .mx_DevicesPanel_checkbox {
|
||||
margin-left: 9px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.mx_DevicesPanel_header .mx_DevicesPanel_deviceLastSeen {
|
||||
width: 30%;
|
||||
.mx_DevicesPanel_deviceInfo {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.mx_DevicesPanel_device td {
|
||||
vertical-align: baseline;
|
||||
padding: 0px;
|
||||
.mx_DevicesPanel_deviceName {
|
||||
color: $primary-content;
|
||||
}
|
||||
|
||||
.mx_DevicesPanel_myDevice {
|
||||
font-weight: bold;
|
||||
.mx_DevicesPanel_lastSeen {
|
||||
font-size: $font-12px;
|
||||
}
|
||||
|
||||
.mx_DevicesPanel_deviceButtons {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.mx_DevicesPanel_renameForm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
|
||||
.mx_Field_input {
|
||||
width: 240px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,7 +39,7 @@ function keyHasPassphrase(keyInfo: ISecretStorageKeyInfo): boolean {
|
|||
}
|
||||
|
||||
interface IProps {
|
||||
onFinished: (boolean) => void;
|
||||
onFinished: () => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -70,7 +70,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
|
|||
private onStoreUpdate = () => {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
if (store.phase === Phase.Finished) {
|
||||
this.props.onFinished(true);
|
||||
this.props.onFinished();
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
|
@ -97,13 +97,16 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
|
|||
const userId = cli.getUserId();
|
||||
const requestPromise = cli.requestVerification(userId);
|
||||
|
||||
this.props.onFinished(true);
|
||||
// We need to call onFinished now to close this dialog, and
|
||||
// again later to signal that the verification is complete.
|
||||
this.props.onFinished();
|
||||
Modal.createTrackedDialog('New Session Verification', 'Starting dialog', VerificationRequestDialog, {
|
||||
verificationRequestPromise: requestPromise,
|
||||
member: cli.getUser(userId),
|
||||
onFinished: async () => {
|
||||
const request = await requestPromise;
|
||||
request.cancel();
|
||||
this.props.onFinished();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -125,6 +128,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
|
|||
};
|
||||
|
||||
private onResetConfirmClick = () => {
|
||||
this.props.onFinished();
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.resetConfirm();
|
||||
};
|
||||
|
@ -140,7 +144,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
|
|||
};
|
||||
|
||||
private onEncryptionPanelClose = () => {
|
||||
this.props.onFinished(false);
|
||||
this.props.onFinished();
|
||||
};
|
||||
|
||||
public render() {
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -1138,22 +1138,30 @@
|
|||
"Cryptography": "Cryptography",
|
||||
"Session ID:": "Session ID:",
|
||||
"Session key:": "Session key:",
|
||||
"Your homeserver does not support session management.": "Your homeserver does not support session management.",
|
||||
"Unable to load session list": "Unable to load session list",
|
||||
"Confirm deleting these sessions by using Single Sign On to prove your identity.|other": "Confirm deleting these sessions by using Single Sign On to prove your identity.",
|
||||
"Confirm deleting these sessions by using Single Sign On to prove your identity.|one": "Confirm deleting this session by using Single Sign On to prove your identity.",
|
||||
"Confirm deleting these sessions": "Confirm deleting these sessions",
|
||||
"Click the button below to confirm deleting these sessions.|other": "Click the button below to confirm deleting these sessions.",
|
||||
"Click the button below to confirm deleting these sessions.|one": "Click the button below to confirm deleting this session.",
|
||||
"Delete sessions|other": "Delete sessions",
|
||||
"Delete sessions|one": "Delete session",
|
||||
"Your homeserver does not support device management.": "Your homeserver does not support device management.",
|
||||
"Unable to load device list": "Unable to load device list",
|
||||
"Confirm logging out these devices by using Single Sign On to prove your identity.|other": "Confirm logging out these devices by using Single Sign On to prove your identity.",
|
||||
"Confirm logging out these devices by using Single Sign On to prove your identity.|one": "Confirm logging out this device by using Single Sign On to prove your identity.",
|
||||
"Confirm signing out these devices": "Confirm signing out these devices",
|
||||
"Click the button below to confirm signing out these devices.|other": "Click the button below to confirm signing out these devices.",
|
||||
"Click the button below to confirm signing out these devices.|one": "Click the button below to confirm signing out this device.",
|
||||
"Sign out devices|other": "Sign out devices",
|
||||
"Sign out devices|one": "Sign out device",
|
||||
"Authentication": "Authentication",
|
||||
"Delete %(count)s sessions|other": "Delete %(count)s sessions",
|
||||
"Delete %(count)s sessions|one": "Delete %(count)s session",
|
||||
"ID": "ID",
|
||||
"Public Name": "Public Name",
|
||||
"Last seen": "Last seen",
|
||||
"Deselect all": "Deselect all",
|
||||
"Select all": "Select all",
|
||||
"Verified devices": "Verified devices",
|
||||
"Unverified devices": "Unverified devices",
|
||||
"Devices without encryption support": "Devices without encryption support",
|
||||
"Sign out %(count)s selected devices|other": "Sign out %(count)s selected devices",
|
||||
"Sign out %(count)s selected devices|one": "Sign out %(count)s selected device",
|
||||
"You aren't signed into any other devices.": "You aren't signed into any other devices.",
|
||||
"This device": "This device",
|
||||
"Failed to set display name": "Failed to set display name",
|
||||
"Last seen %(date)s at %(ip)s": "Last seen %(date)s at %(ip)s",
|
||||
"Sign Out": "Sign Out",
|
||||
"Display Name": "Display Name",
|
||||
"Rename": "Rename",
|
||||
"Encryption": "Encryption",
|
||||
"Individually verify each session used by a user to mark it as trusted, not trusting cross-signed devices.": "Individually verify each session used by a user to mark it as trusted, not trusting cross-signed devices.",
|
||||
"Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.|other": "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.",
|
||||
|
@ -1217,7 +1225,6 @@
|
|||
"The operation could not be completed": "The operation could not be completed",
|
||||
"<a>Upgrade</a> to your own domain": "<a>Upgrade</a> to your own domain",
|
||||
"Profile": "Profile",
|
||||
"Display Name": "Display Name",
|
||||
"Profile picture": "Profile picture",
|
||||
"Save": "Save",
|
||||
"Delete Backup": "Delete Backup",
|
||||
|
@ -1416,9 +1423,8 @@
|
|||
"%(brand)s collects anonymous analytics to allow us to improve the application.": "%(brand)s collects anonymous analytics to allow us to improve the application.",
|
||||
"Privacy is important to us, so we don't collect any personal or identifiable data for our analytics.": "Privacy is important to us, so we don't collect any personal or identifiable data for our analytics.",
|
||||
"Learn more about how we use analytics.": "Learn more about how we use analytics.",
|
||||
"Where you’re logged in": "Where you’re logged in",
|
||||
"Manage the names of and sign out of your sessions below or <a>verify them in your User Profile</a>.": "Manage the names of and sign out of your sessions below or <a>verify them in your User Profile</a>.",
|
||||
"A session's public name is visible to people you communicate with": "A session's public name is visible to people you communicate with",
|
||||
"Where you’re signed in": "Where you’re signed in",
|
||||
"Manage your signed-in devices below. A device's name is visible to people you communicate with.": "Manage your signed-in devices below. A device's name is visible to people you communicate with.",
|
||||
"Default Device": "Default Device",
|
||||
"No media permissions": "No media permissions",
|
||||
"You may need to manually permit %(brand)s to access your microphone/webcam": "You may need to manually permit %(brand)s to access your microphone/webcam",
|
||||
|
@ -1859,6 +1865,7 @@
|
|||
"Room settings": "Room settings",
|
||||
"Trusted": "Trusted",
|
||||
"Not trusted": "Not trusted",
|
||||
"Unable to load session list": "Unable to load session list",
|
||||
"%(count)s verified sessions|other": "%(count)s verified sessions",
|
||||
"%(count)s verified sessions|one": "1 verified session",
|
||||
"Hide verified sessions": "Hide verified sessions",
|
||||
|
|
|
@ -16,10 +16,11 @@ limitations under the License.
|
|||
|
||||
import { _t } from '../languageHandler';
|
||||
import dis from "../dispatcher/dispatcher";
|
||||
import { MatrixClientPeg } from '../MatrixClientPeg';
|
||||
import DeviceListener from '../DeviceListener';
|
||||
import GenericToast from "../components/views/toasts/GenericToast";
|
||||
import ToastStore from "../stores/ToastStore";
|
||||
import { Action } from "../dispatcher/actions";
|
||||
import { UserTab } from "../components/views/dialogs/UserSettingsDialog";
|
||||
|
||||
const TOAST_KEY = "reviewsessions";
|
||||
|
||||
|
@ -28,8 +29,8 @@ export const showToast = (deviceIds: Set<string>) => {
|
|||
DeviceListener.sharedInstance().dismissUnverifiedSessions(deviceIds);
|
||||
|
||||
dis.dispatch({
|
||||
action: 'view_user_info',
|
||||
userId: MatrixClientPeg.get().getUserId(),
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.Security,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
Loading…
Reference in a new issue