Add support for managing sessions (#415)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Wed, 23 Mar 2022 13:14:38 +0000 (18:44 +0530)
committerGitHub <noreply@github.com>
Wed, 23 Mar 2022 13:14:38 +0000 (18:44 +0530)
* 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>
src/app/molecules/popup-window/PopupWindow.scss
src/app/molecules/setting-tile/SettingTile.jsx
src/app/organisms/settings/DeviceManage.jsx [new file with mode: 0644]
src/app/organisms/settings/DeviceManage.scss [new file with mode: 0644]
src/app/organisms/settings/Settings.jsx
src/app/organisms/settings/Settings.scss

index 421c9bbc9d05c78c7dd0c196030e0132141d07f5..2d72963f0026d2790e7f5bde1df9cb92e2a6ca0d 100644 (file)
@@ -1,7 +1,7 @@
 @use '../../partials/dir';
 
 .pw-model {
-  --modal-height: 656px;
+  --modal-height: 774px;
   max-height: var(--modal-height) !important;
   height: 100%;
 }
index 15ab5384f9075165b8b1764b7e064619463f6276..6b221965d08c681ca512803746ab65ac9144874d 100644 (file)
@@ -9,7 +9,11 @@ function SettingTile({ title, options, content }) {
     <div className="setting-tile">
       <div className="setting-tile__content">
         <div className="setting-tile__title">
-          <Text variant="b1">{title}</Text>
+          {
+            typeof title === 'string'
+              ? <Text variant="b1">{title}</Text>
+              : title
+          }
         </div>
         {content}
       </div>
@@ -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 (file)
index 0000000..ccc6c47
--- /dev/null
@@ -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;
diff --git a/src/app/organisms/settings/DeviceManage.scss b/src/app/organisms/settings/DeviceManage.scss
new file mode 100644 (file)
index 0000000..0daf2e6
--- /dev/null
@@ -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
index 84013cc94511e3fad7d15df69decd621d609d446..acfef5c9de4d212d00c70742228095ae6b59af75 100644 (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 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 (
     <div className="settings-security">
       <div className="settings-security__card">
-        <MenuHeader>Device Info</MenuHeader>
+        <MenuHeader>Session Info</MenuHeader>
         <SettingTile
-          title={`Device ID: ${initMatrix.matrixClient.getDeviceId()}`}
+          title={`Session ID: ${initMatrix.matrixClient.getDeviceId()}`}
         />
         <SettingTile
-          title={`Device 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>}
+          title={`Session key: ${initMatrix.matrixClient.getDeviceEd25519Key().match(/.{1,4}/g).join(' ')}`}
+          content={<Text variant="b3">Use this session ID-key combo to verify or manage this session.</Text>}
         />
       </div>
+      <DeviceManage />
       <div className="settings-security__card">
         <MenuHeader>Encryption</MenuHeader>
         <SettingTile
index 2d086c6d61606f1dcfe2397c694ea7874af01e51..360efc5b55d1e68cf156bcdd90087c4721bf2857 100644 (file)
@@ -38,6 +38,7 @@
 .settings-appearance__card,
 .settings-notifications,
 .settings-security__card,
+.settings-security .device-manage,
 .settings-about__card {
   @extend .settings-window__card;
 }