diff --git a/src/app/molecules/popup-window/PopupWindow.scss b/src/app/molecules/popup-window/PopupWindow.scss
index 421c9bbc..2d72963f 100644
--- a/src/app/molecules/popup-window/PopupWindow.scss
+++ b/src/app/molecules/popup-window/PopupWindow.scss
@@ -1,7 +1,7 @@
@use '../../partials/dir';
.pw-model {
- --modal-height: 656px;
+ --modal-height: 774px;
max-height: var(--modal-height) !important;
height: 100%;
}
diff --git a/src/app/molecules/setting-tile/SettingTile.jsx b/src/app/molecules/setting-tile/SettingTile.jsx
index 15ab5384..6b221965 100644
--- a/src/app/molecules/setting-tile/SettingTile.jsx
+++ b/src/app/molecules/setting-tile/SettingTile.jsx
@@ -9,7 +9,11 @@ function SettingTile({ title, options, content }) {
- {title}
+ {
+ typeof title === 'string'
+ ? {title}
+ : title
+ }
{content}
@@ -24,7 +28,7 @@ SettingTile.defaultProps = {
};
SettingTile.propTypes = {
- title: PropTypes.string.isRequired,
+ title: PropTypes.node.isRequired,
options: PropTypes.node,
content: PropTypes.node,
};
diff --git a/src/app/organisms/settings/DeviceManage.jsx b/src/app/organisms/settings/DeviceManage.jsx
new file mode 100644
index 00000000..ccc6c478
--- /dev/null
+++ b/src/app/organisms/settings/DeviceManage.jsx
@@ -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 (
+
+
+
+ Loading devices...
+
+
+ );
+ }
+
+ 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 (
+
+ {displayName}
+ {` — ${deviceId}${mx.deviceId === deviceId ? ' (current)' : ''}`}
+
+ )}
+ options={
+ processing.includes(deviceId)
+ ?
+ : (
+ <>
+ handleRename(device)} src={PencilIC} tooltip="Rename" />
+ handleRemove(device)} src={BinIC} tooltip="Remove session" />
+ >
+ )
+ }
+ content={(
+
+ Last activity
+
+ {dateFormat(new Date(lastTS), ' hh:MM TT, dd/mm/yyyy')}
+
+ {lastIP ? ` at ${lastIP}` : ''}
+
+ )}
+ />
+ );
+ };
+
+ 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 (
+
+
+ Unverified sessions
+ {
+ unverified.length > 0
+ ? unverified.map((device) => renderDevice(device, false))
+ : No unverified session
+ }
+
+
+ Verified sessions
+ {
+ verified.length > 0
+ ? verified.map((device, index) => {
+ if (truncated && index >= TRUNCATED_COUNT) return null;
+ return renderDevice(device, true);
+ })
+ : No verified session
+ }
+ { verified.length > TRUNCATED_COUNT && (
+ setTruncated(!truncated)}>
+ {truncated ? `View ${verified.length - 4} more` : 'View less'}
+
+ )}
+ { deviceList.length > 0 && (
+ Session names are visible to everyone, so do not put any private info here.
+ )}
+
+
+ );
+}
+
+export default DeviceManage;
diff --git a/src/app/organisms/settings/DeviceManage.scss b/src/app/organisms/settings/DeviceManage.scss
new file mode 100644
index 00000000..0daf2e61
--- /dev/null
+++ b/src/app/organisms/settings/DeviceManage.scss
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/src/app/organisms/settings/Settings.jsx b/src/app/organisms/settings/Settings.jsx
index 84013cc9..acfef5c9 100644
--- a/src/app/organisms/settings/Settings.jsx
+++ b/src/app/organisms/settings/Settings.jsx
@@ -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 ProfileEditor from '../profile-editor/ProfileEditor';
+import DeviceManage from './DeviceManage';
import SunIC from '../../../../public/res/ic/outlined/sun.svg';
import LockIC from '../../../../public/res/ic/outlined/lock.svg';
@@ -167,15 +168,16 @@ function SecuritySection() {
return (
- Device Info
+ Session Info
Use this device ID-key combo to verify or manage this session from Element client.}
+ title={`Session key: ${initMatrix.matrixClient.getDeviceEd25519Key().match(/.{1,4}/g).join(' ')}`}
+ content={Use this session ID-key combo to verify or manage this session. }
/>
+
Encryption