Add support for managing sessions (#415)

* Allow node type prop in setting tile

Signed-off-by: Ajay Bura <ajbura@gmail.com>

* Update popup window max height

Signed-off-by: Ajay Bura <ajbura@gmail.com>

* Add device management setting

Signed-off-by: Ajay Bura <ajbura@gmail.com>

* Add password based login

Signed-off-by: Ajay Bura <ajbura@gmail.com>

* truncate long list of verified devices

Signed-off-by: Ajay Bura <ajbura@gmail.com>
This commit is contained in:
Ajay Bura 2022-03-23 18:44:38 +05:30 committed by GitHub
parent fe997d8b01
commit 005434f79b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 251 additions and 7 deletions

View file

@ -1,7 +1,7 @@
@use '../../partials/dir'; @use '../../partials/dir';
.pw-model { .pw-model {
--modal-height: 656px; --modal-height: 774px;
max-height: var(--modal-height) !important; max-height: var(--modal-height) !important;
height: 100%; height: 100%;
} }

View file

@ -9,7 +9,11 @@ function SettingTile({ title, options, content }) {
<div className="setting-tile"> <div className="setting-tile">
<div className="setting-tile__content"> <div className="setting-tile__content">
<div className="setting-tile__title"> <div className="setting-tile__title">
<Text variant="b1">{title}</Text> {
typeof title === 'string'
? <Text variant="b1">{title}</Text>
: title
}
</div> </div>
{content} {content}
</div> </div>
@ -24,7 +28,7 @@ SettingTile.defaultProps = {
}; };
SettingTile.propTypes = { SettingTile.propTypes = {
title: PropTypes.string.isRequired, title: PropTypes.node.isRequired,
options: PropTypes.node, options: PropTypes.node,
content: PropTypes.node, content: PropTypes.node,
}; };

View file

@ -0,0 +1,219 @@
import React, { useState, useEffect } from 'react';
import './DeviceManage.scss';
import dateFormat from 'dateformat';
import initMatrix from '../../../client/initMatrix';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import IconButton from '../../atoms/button/IconButton';
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
import Spinner from '../../atoms/spinner/Spinner';
import SettingTile from '../../molecules/setting-tile/SettingTile';
import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
import { useStore } from '../../hooks/useStore';
function useDeviceList() {
const mx = initMatrix.matrixClient;
const [deviceList, setDeviceList] = useState(null);
useEffect(() => {
let isMounted = true;
const updateDevices = () => mx.getDevices().then((data) => {
if (!isMounted) return;
setDeviceList(data.devices || []);
});
updateDevices();
const handleDevicesUpdate = (users) => {
if (users.includes(mx.getUserId())) {
updateDevices();
}
};
mx.on('crypto.devicesUpdated', handleDevicesUpdate);
return () => {
mx.removeListener('crypto.devicesUpdated', handleDevicesUpdate);
isMounted = false;
};
}, []);
return deviceList;
}
function isCrossVerified(deviceId) {
try {
const mx = initMatrix.matrixClient;
const crossSignInfo = mx.getStoredCrossSigningForUser(mx.getUserId());
const deviceInfo = mx.getStoredDevice(mx.getUserId(), deviceId);
const deviceTrust = crossSignInfo.checkDeviceTrust(crossSignInfo, deviceInfo, false, true);
return deviceTrust.isCrossSigningVerified();
} catch {
return false;
}
}
function DeviceManage() {
const TRUNCATED_COUNT = 4;
const mx = initMatrix.matrixClient;
const deviceList = useDeviceList();
const [processing, setProcessing] = useState([]);
const [truncated, setTruncated] = useState(true);
const mountStore = useStore();
mountStore.setItem(true);
useEffect(() => {
setProcessing([]);
}, [deviceList]);
const addToProcessing = (device) => {
const old = [...processing];
old.push(device.device_id);
setProcessing(old);
};
const removeFromProcessing = () => {
setProcessing([]);
};
if (deviceList === null) {
return (
<div className="device-manage">
<div className="device-manage__loading">
<Spinner size="small" />
<Text>Loading devices...</Text>
</div>
</div>
);
}
const handleRename = async (device) => {
const newName = window.prompt('Edit session name', device.display_name);
if (newName === null || newName.trim() === '') return;
if (newName.trim() === device.display_name) return;
addToProcessing(device);
try {
await mx.setDeviceDetails(device.device_id, {
display_name: newName,
});
} catch {
if (!mountStore.getItem()) return;
removeFromProcessing(device);
}
};
const handleRemove = async (device, auth = undefined) => {
if (auth === undefined
? window.confirm(`You are about to logout "${device.display_name}" session?`)
: true
) {
addToProcessing(device);
try {
await mx.deleteDevice(device.device_id, auth);
} catch (e) {
if (e.httpStatus === 401 && e.data?.flows) {
const { flows } = e.data;
const flow = flows.find((f) => f.stages.includes('m.login.password'));
if (flow) {
const password = window.prompt('Please enter account password', '');
if (password && password.trim() !== '') {
handleRemove(device, {
session: e.data.session,
type: 'm.login.password',
password,
identifier: {
type: 'm.id.user',
user: mx.getUserId(),
},
});
return;
}
}
}
window.alert('Failed to remove session!');
if (!mountStore.getItem()) return;
removeFromProcessing(device);
}
}
};
const renderDevice = (device, isVerified) => {
const deviceId = device.device_id;
const displayName = device.display_name;
const lastIP = device.last_seen_ip;
const lastTS = device.last_seen_ts;
return (
<SettingTile
key={deviceId}
title={(
<Text style={{ color: isVerified ? '' : 'var(--tc-danger-high)' }}>
{displayName}
<Text variant="b3" span>{`${deviceId}${mx.deviceId === deviceId ? ' (current)' : ''}`}</Text>
</Text>
)}
options={
processing.includes(deviceId)
? <Spinner size="small" />
: (
<>
<IconButton size="small" onClick={() => handleRename(device)} src={PencilIC} tooltip="Rename" />
<IconButton size="small" onClick={() => handleRemove(device)} src={BinIC} tooltip="Remove session" />
</>
)
}
content={(
<Text variant="b3">
Last activity
<span style={{ color: 'var(--tc-surface-normal)' }}>
{dateFormat(new Date(lastTS), ' hh:MM TT, dd/mm/yyyy')}
</span>
{lastIP ? ` at ${lastIP}` : ''}
</Text>
)}
/>
);
};
const unverified = [];
const verified = [];
deviceList.sort((a, b) => b.last_seen_ts - a.last_seen_ts).forEach((device) => {
if (isCrossVerified(device.device_id)) verified.push(device);
else unverified.push(device);
});
return (
<div className="device-manage">
<div>
<MenuHeader>Unverified sessions</MenuHeader>
{
unverified.length > 0
? unverified.map((device) => renderDevice(device, false))
: <Text className="device-manage__info">No unverified session</Text>
}
</div>
<div>
<MenuHeader>Verified sessions</MenuHeader>
{
verified.length > 0
? verified.map((device, index) => {
if (truncated && index >= TRUNCATED_COUNT) return null;
return renderDevice(device, true);
})
: <Text className="device-manage__info">No verified session</Text>
}
{ verified.length > TRUNCATED_COUNT && (
<Button className="device-manage__info" onClick={() => setTruncated(!truncated)}>
{truncated ? `View ${verified.length - 4} more` : 'View less'}
</Button>
)}
{ deviceList.length > 0 && (
<Text className="device-manage__info" variant="b3">Session names are visible to everyone, so do not put any private info here.</Text>
)}
</div>
</div>
);
}
export default DeviceManage;

View file

@ -0,0 +1,18 @@
@use '../../partials/flex';
.device-manage {
&__loading {
@extend .cp-fx__row--c-c;
padding: var(--sp-extra-loose) var(--sp-normal);
.text {
margin: 0 var(--sp-normal);
}
}
&__info {
margin: var(--sp-normal);
}
& .setting-tile:last-of-type {
border-bottom: none;
}
}

View file

@ -26,6 +26,7 @@ import ImportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/Impor
import ExportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ExportE2ERoomKeys'; import ExportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ExportE2ERoomKeys';
import ProfileEditor from '../profile-editor/ProfileEditor'; import ProfileEditor from '../profile-editor/ProfileEditor';
import DeviceManage from './DeviceManage';
import SunIC from '../../../../public/res/ic/outlined/sun.svg'; import SunIC from '../../../../public/res/ic/outlined/sun.svg';
import LockIC from '../../../../public/res/ic/outlined/lock.svg'; import LockIC from '../../../../public/res/ic/outlined/lock.svg';
@ -167,15 +168,16 @@ function SecuritySection() {
return ( return (
<div className="settings-security"> <div className="settings-security">
<div className="settings-security__card"> <div className="settings-security__card">
<MenuHeader>Device Info</MenuHeader> <MenuHeader>Session Info</MenuHeader>
<SettingTile <SettingTile
title={`Device ID: ${initMatrix.matrixClient.getDeviceId()}`} title={`Session ID: ${initMatrix.matrixClient.getDeviceId()}`}
/> />
<SettingTile <SettingTile
title={`Device key: ${initMatrix.matrixClient.getDeviceEd25519Key().match(/.{1,4}/g).join(' ')}`} title={`Session key: ${initMatrix.matrixClient.getDeviceEd25519Key().match(/.{1,4}/g).join(' ')}`}
content={<Text variant="b3">Use this device ID-key combo to verify or manage this session from Element client.</Text>} content={<Text variant="b3">Use this session ID-key combo to verify or manage this session.</Text>}
/> />
</div> </div>
<DeviceManage />
<div className="settings-security__card"> <div className="settings-security__card">
<MenuHeader>Encryption</MenuHeader> <MenuHeader>Encryption</MenuHeader>
<SettingTile <SettingTile

View file

@ -38,6 +38,7 @@
.settings-appearance__card, .settings-appearance__card,
.settings-notifications, .settings-notifications,
.settings-security__card, .settings-security__card,
.settings-security .device-manage,
.settings-about__card { .settings-about__card {
@extend .settings-window__card; @extend .settings-window__card;
} }