element-web/src/components/views/settings/DevicesPanel.tsx

231 lines
8.3 KiB
TypeScript
Raw Normal View History

/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
2021-08-14 12:17:19 +03:00
Copyright 2021 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
2021-08-14 12:17:19 +03:00
import { IMyDevice } from "matrix-js-sdk/src/client";
2021-06-29 15:11:58 +03:00
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler';
import Modal from '../../../Modal';
2021-06-29 15:11:58 +03:00
import { SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import InteractiveAuthDialog from "../dialogs/InteractiveAuthDialog";
2021-08-14 12:17:19 +03:00
interface IProps {
className?: string;
}
2021-08-14 12:17:19 +03:00
interface IState {
devices: IMyDevice[];
deviceLoadError?: string;
selectedDevices?: string[];
deleting?: boolean;
}
2021-08-14 12:17:19 +03:00
@replaceableComponent("views.settings.DevicesPanel")
export default class DevicesPanel extends React.Component<IProps, IState> {
private unmounted = false;
2021-08-14 12:17:19 +03:00
public componentDidMount(): void {
this.loadDevices();
}
2021-08-14 12:17:19 +03:00
public componentWillUnmount(): void {
this.unmounted = true;
}
2021-08-14 12:17:19 +03:00
private loadDevices(): void {
2019-11-18 13:03:05 +03:00
MatrixClientPeg.get().getDevices().then(
(resp) => {
2021-08-14 12:17:19 +03:00
if (this.unmounted) { return; }
2021-06-29 15:11:58 +03:00
this.setState({ devices: resp.devices || [] });
},
(error) => {
2021-08-14 12:17:19 +03:00
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.");
} else {
console.error("Error loading sessions:", error);
errtxt = _t("Unable to load session list");
}
2021-06-29 15:11:58 +03:00
this.setState({ deviceLoadError: errtxt });
},
);
}
/*
* compare two devices, sorting from most-recently-seen to least-recently-seen
* (and then, for stability, by device id)
*/
2021-08-14 12:17:19 +03:00
private deviceCompare(a: IMyDevice, b: IMyDevice): number {
// return < 0 if a comes before b, > 0 if a comes after b.
const lastSeenDelta =
(b.last_seen_ts || 0) - (a.last_seen_ts || 0);
if (lastSeenDelta !== 0) { return lastSeenDelta; }
const idA = a.device_id;
const idB = b.device_id;
return (idA < idB) ? -1 : (idA > idB) ? 1 : 0;
}
2021-08-14 12:17:19 +03:00
private onDeviceSelectionToggled = (device: IMyDevice): void => {
if (this.unmounted) { return; }
const deviceId = device.device_id;
this.setState((state, props) => {
// Make a copy of the selected devices, then add or remove the device
const selectedDevices = state.selectedDevices.slice();
const i = selectedDevices.indexOf(deviceId);
if (i === -1) {
selectedDevices.push(deviceId);
} else {
selectedDevices.splice(i, 1);
}
2021-06-29 15:11:58 +03:00
return { selectedDevices };
});
2021-08-14 12:17:19 +03:00
};
2021-08-14 12:17:19 +03:00
private onDeleteClick = (): void => {
this.setState({
deleting: true,
});
2021-08-14 12:17:19 +03:00
this.makeDeleteRequest(null).catch((error) => {
if (this.unmounted) { return; }
if (error.httpStatus !== 401 || !error.data || !error.data.flows) {
// doesn't look like an interactive-auth failure
throw error;
}
// pop up an interactive auth dialog
2020-04-07 20:46:53 +03:00
const numDevices = this.state.selectedDevices.length;
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("Use Single Sign On to continue"),
2020-04-07 20:46:53 +03:00
body: _t("Confirm deleting these sessions 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"),
2020-04-07 20:46:53 +03:00
body: _t("Click the button below to confirm deleting these sessions.", {
count: numDevices,
}),
2021-06-29 15:11:58 +03:00
continueText: _t("Delete sessions", { count: numDevices }),
continueKind: "danger",
},
};
Modal.createTrackedDialog('Delete Device Dialog', '', InteractiveAuthDialog, {
title: _t("Authentication"),
matrixClient: MatrixClientPeg.get(),
authData: error.data,
2021-08-14 12:17:19 +03:00
makeRequest: this.makeDeleteRequest.bind(this),
aestheticsForStagePhases: {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
},
});
}).catch((e) => {
console.error("Error deleting sessions", e);
2021-08-14 12:17:19 +03:00
if (this.unmounted) { return; }
}).finally(() => {
this.setState({
deleting: false,
});
});
2021-08-14 12:17:19 +03:00
};
2021-08-14 12:17:19 +03:00
// TODO: proper typing for auth
private makeDeleteRequest(auth?: any): Promise<any> {
return MatrixClientPeg.get().deleteMultipleDevices(this.state.selectedDevices, auth).then(
() => {
// Remove the deleted devices from `devices`, reset selection to []
this.setState({
devices: this.state.devices.filter(
2017-11-28 18:54:00 +03:00
(d) => !this.state.selectedDevices.includes(d.device_id),
),
selectedDevices: [],
});
},
);
}
2021-08-14 12:17:19 +03:00
private renderDevice = (device: IMyDevice): JSX.Element => {
const DevicesPanelEntry = sdk.getComponent('settings.DevicesPanelEntry');
return <DevicesPanelEntry
key={device.device_id}
device={device}
selected={this.state.selectedDevices.includes(device.device_id)}
2021-08-14 12:17:19 +03:00
onDeviceToggled={this.onDeviceSelectionToggled}
/>;
2021-08-14 12:17:19 +03:00
};
2021-08-14 12:17:19 +03:00
public render(): JSX.Element {
const Spinner = sdk.getComponent("elements.Spinner");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
if (this.state.deviceLoadError !== undefined) {
const classes = classNames(this.props.className, "error");
return (
<div className={classes}>
{ this.state.deviceLoadError }
</div>
);
}
const devices = this.state.devices;
if (devices === undefined) {
// still loading
const classes = this.props.className;
return <Spinner className={classes} />;
}
2021-08-14 12:17:19 +03:00
devices.sort(this.deviceCompare);
const deleteButton = this.state.deleting ?
<Spinner w={22} h={22} /> :
2021-08-14 12:17:19 +03:00
<AccessibleButton onClick={this.onDeleteClick} kind="danger_sm">
{ _t("Delete %(count)s sessions", { count: this.state.selectedDevices.length }) }
</AccessibleButton>;
const classes = classNames(this.props.className, "mx_DevicesPanel");
return (
<div className={classes}>
<div className="mx_DevicesPanel_header">
<div className="mx_DevicesPanel_deviceId">{ _t("ID") }</div>
<div className="mx_DevicesPanel_deviceName">{ _t("Public Name") }</div>
<div className="mx_DevicesPanel_deviceLastSeen">{ _t("Last seen") }</div>
<div className="mx_DevicesPanel_deviceButtons">
{ this.state.selectedDevices.length > 0 ? deleteButton : null }
</div>
</div>
2021-08-14 12:17:19 +03:00
{ devices.map(this.renderDevice) }
</div>
);
}
}