* Support for login + E2EE set up with QR * Whitespace * Padding * Refactor of fetch * Whitespace * CSS whitespace * Add link to MSC3906 * Handle incorrect typing in MatrixClientPeg.get() * Use unstable class name * fix: use unstable class name * Use default fetch client instead * Update to revised function name * Refactor device manager panel and make it work with new sessions manager * Lint fix * Add missing interstitials and update wording * Linting * i18n * Lint * Use sensible sdk config name for fallback server * Improve error handling for QR code generation * Refactor feature availability logic * Hide device manager panel if no options available * Put sign in with QR behind lab setting * Reduce scope of PR to just showing code on existing device * i18n updates * Handle null features * Testing for LoginWithQRSection * Refactor to handle UIA * Imports * Reduce diff complexity * Remove unnecessary change * Remove unused styles * Support UIA * Tidy up * i18n * Remove additional unused parts of flow * Add extra instruction when showing QR code * Add getVersions to server mocks * Use proper colours for theme support * Test cases * Lint * Remove obsolete snapshot * Don't override error if already set * Remove unused var * Update src/components/views/settings/devices/LoginWithQRSection.tsx Co-authored-by: Travis Ralston <travisr@matrix.org> * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston <travisr@matrix.org> * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston <travisr@matrix.org> * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston <travisr@matrix.org> * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston <travisr@matrix.org> * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston <travisr@matrix.org> * Update res/css/views/auth/_LoginWithQR.pcss Co-authored-by: Kerry <kerrya@element.io> * Use spacing variables * Remove debug * Style + docs * preventDefault * Names of tests * Fixes for js-sdk refactor * Update snapshots to match test names * Refactor labs config to make deployment simpler * i18n * Unused imports * Typo * Stateless component * Whitespace * Use context not MatrixClientPeg * Add missing context * Type updates to match js-sdk * Wrap click handlers in useCallback * Update src/components/views/settings/DevicesPanel.tsx Co-authored-by: Travis Ralston <travisr@matrix.org> * Wait for DOM update instead of timeout * Add missing snapshot update from last commit * Remove void keyword in favour of then() clauses * test main paths in LoginWithQR Co-authored-by: Travis Ralston <travisr@matrix.org> Co-authored-by: Kerry <kerrya@element.io>
379 lines
13 KiB
TypeScript
379 lines
13 KiB
TypeScript
/*
|
|
Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
|
|
|
|
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';
|
|
import { IMyDevice } from "matrix-js-sdk/src/client";
|
|
import { logger } from "matrix-js-sdk/src/logger";
|
|
import { CrossSigningInfo } from "matrix-js-sdk/src/crypto/CrossSigning";
|
|
import { CryptoEvent } from 'matrix-js-sdk/src/crypto';
|
|
|
|
import { _t } from '../../../languageHandler';
|
|
import DevicesPanelEntry from "./DevicesPanelEntry";
|
|
import Spinner from "../elements/Spinner";
|
|
import AccessibleButton from "../elements/AccessibleButton";
|
|
import { deleteDevicesWithInteractiveAuth } from './devices/deleteDevices';
|
|
import MatrixClientContext from '../../../contexts/MatrixClientContext';
|
|
|
|
interface IProps {
|
|
className?: string;
|
|
}
|
|
|
|
interface IState {
|
|
devices: IMyDevice[];
|
|
crossSigningInfo?: CrossSigningInfo;
|
|
deviceLoadError?: string;
|
|
selectedDevices: string[];
|
|
deleting?: boolean;
|
|
}
|
|
|
|
export default class DevicesPanel extends React.Component<IProps, IState> {
|
|
public static contextType = MatrixClientContext;
|
|
public context!: React.ContextType<typeof MatrixClientContext>;
|
|
private unmounted = false;
|
|
|
|
constructor(props: IProps) {
|
|
super(props);
|
|
this.state = {
|
|
devices: [],
|
|
selectedDevices: [],
|
|
};
|
|
this.loadDevices = this.loadDevices.bind(this);
|
|
}
|
|
|
|
public componentDidMount(): void {
|
|
this.context.on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
|
|
this.loadDevices();
|
|
}
|
|
|
|
public componentWillUnmount(): void {
|
|
this.context.off(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
|
|
this.unmounted = true;
|
|
}
|
|
|
|
private onDevicesUpdated = (users: string[]) => {
|
|
if (!users.includes(this.context.getUserId())) return;
|
|
this.loadDevices();
|
|
};
|
|
|
|
private loadDevices(): void {
|
|
const cli = this.context;
|
|
cli.getDevices().then(
|
|
(resp) => {
|
|
if (this.unmounted) { return; }
|
|
|
|
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,
|
|
};
|
|
});
|
|
},
|
|
(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 device management.");
|
|
} else {
|
|
logger.error("Error loading sessions:", error);
|
|
errtxt = _t("Unable to load device list");
|
|
}
|
|
this.setState({ deviceLoadError: errtxt });
|
|
},
|
|
);
|
|
}
|
|
|
|
/*
|
|
* compare two devices, sorting from most-recently-seen to least-recently-seen
|
|
* (and then, for stability, by device id)
|
|
*/
|
|
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;
|
|
}
|
|
|
|
private isDeviceVerified(device: IMyDevice): boolean | null {
|
|
try {
|
|
const cli = this.context;
|
|
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; }
|
|
|
|
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);
|
|
}
|
|
|
|
return { selectedDevices };
|
|
});
|
|
};
|
|
|
|
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 = async (): Promise<void> => {
|
|
if (this.state.selectedDevices.length === 0) { return; }
|
|
|
|
this.setState({
|
|
deleting: true,
|
|
});
|
|
|
|
try {
|
|
await deleteDevicesWithInteractiveAuth(
|
|
this.context,
|
|
this.state.selectedDevices,
|
|
(success) => {
|
|
if (success) {
|
|
// Reset selection to [], update device list
|
|
this.setState({
|
|
selectedDevices: [],
|
|
});
|
|
this.loadDevices();
|
|
}
|
|
this.setState({
|
|
deleting: false,
|
|
});
|
|
},
|
|
);
|
|
} catch (error) {
|
|
logger.error("Error deleting sessions", error);
|
|
this.setState({
|
|
deleting: false,
|
|
});
|
|
}
|
|
};
|
|
|
|
private renderDevice = (device: IMyDevice): JSX.Element => {
|
|
const myDeviceId = this.context.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) {
|
|
return loadError;
|
|
}
|
|
|
|
const devices = this.state.devices;
|
|
if (devices === undefined) {
|
|
// still loading
|
|
return <Spinner />;
|
|
}
|
|
|
|
const myDeviceId = this.context.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
|
|
className="mx_DevicesPanel_deleteButton"
|
|
onClick={this.onDeleteClick}
|
|
kind="danger_outline"
|
|
disabled={this.state.selectedDevices.length === 0}
|
|
data-testid='sign-out-devices-btn'
|
|
>
|
|
{ _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 (
|
|
<div className={classes}>
|
|
<div className="mx_DevicesPanel_header">
|
|
<div className="mx_DevicesPanel_header_title">
|
|
{ _t("This device") }
|
|
</div>
|
|
</div>
|
|
{ this.renderDevice(myDevice) }
|
|
{ otherDevicesSection }
|
|
</div>
|
|
);
|
|
}
|
|
}
|