diff --git a/src/app/molecules/popup-window/PopupWindow.scss b/src/app/molecules/popup-window/PopupWindow.scss index 421c9bb..2d72963 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 15ab538..6b22196 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 0000000..ccc6c47 --- /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 && ( + + )} + { 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 0000000..0daf2e6 --- /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 84013cc..acfef5c 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