266 lines
8.7 KiB
JavaScript
266 lines
8.7 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import './DeviceManage.scss';
|
|
import dateFormat from 'dateformat';
|
|
|
|
import initMatrix from '../../../client/initMatrix';
|
|
import { isCrossVerified } from '../../../util/matrixUtil';
|
|
import { openReusableDialog, openEmojiVerification } from '../../../client/action/navigation';
|
|
|
|
import Text from '../../atoms/text/Text';
|
|
import Button from '../../atoms/button/Button';
|
|
import IconButton from '../../atoms/button/IconButton';
|
|
import Input from '../../atoms/input/Input';
|
|
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
|
|
import InfoCard from '../../atoms/card/InfoCard';
|
|
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 InfoIC from '../../../../public/res/ic/outlined/info.svg';
|
|
|
|
import { authRequest } from './AuthRequest';
|
|
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
|
|
|
import { useStore } from '../../hooks/useStore';
|
|
import { useDeviceList } from '../../hooks/useDeviceList';
|
|
import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
|
|
import { accessSecretStorage } from './SecretStorageAccess';
|
|
|
|
const promptDeviceName = async (deviceName) => new Promise((resolve) => {
|
|
let isCompleted = false;
|
|
|
|
const renderContent = (onComplete) => {
|
|
const handleSubmit = (e) => {
|
|
e.preventDefault();
|
|
const name = e.target.session.value;
|
|
if (typeof name !== 'string') onComplete(null);
|
|
onComplete(name);
|
|
};
|
|
return (
|
|
<form className="device-manage__rename" onSubmit={handleSubmit}>
|
|
<Input value={deviceName} label="Session name" name="session" />
|
|
<div className="device-manage__rename-btn">
|
|
<Button variant="primary" type="submit">Save</Button>
|
|
<Button onClick={() => onComplete(null)}>Cancel</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
};
|
|
|
|
openReusableDialog(
|
|
<Text variant="s1" weight="medium">Edit session name</Text>,
|
|
(requestClose) => renderContent((name) => {
|
|
isCompleted = true;
|
|
resolve(name);
|
|
requestClose();
|
|
}),
|
|
() => {
|
|
if (!isCompleted) resolve(null);
|
|
},
|
|
);
|
|
});
|
|
|
|
function DeviceManage() {
|
|
const TRUNCATED_COUNT = 4;
|
|
const mx = initMatrix.matrixClient;
|
|
const isCSEnabled = useCrossSigningStatus();
|
|
const deviceList = useDeviceList();
|
|
const [processing, setProcessing] = useState([]);
|
|
const [truncated, setTruncated] = useState(true);
|
|
const mountStore = useStore();
|
|
mountStore.setItem(true);
|
|
const isMeVerified = isCrossVerified(mx.deviceId);
|
|
|
|
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 = await promptDeviceName(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) => {
|
|
const isConfirmed = await confirmDialog(
|
|
`Logout ${device.display_name}`,
|
|
`You are about to logout "${device.display_name}" session.`,
|
|
'Logout',
|
|
'danger',
|
|
);
|
|
if (!isConfirmed) return;
|
|
addToProcessing(device);
|
|
await authRequest(`Logout "${device.display_name}"`, async (auth) => {
|
|
await mx.deleteDevice(device.device_id, auth);
|
|
});
|
|
|
|
if (!mountStore.getItem()) return;
|
|
removeFromProcessing(device);
|
|
};
|
|
|
|
const verifyWithKey = async (device) => {
|
|
const keyData = await accessSecretStorage('Session verification');
|
|
if (!keyData) return;
|
|
addToProcessing(device);
|
|
await mx.checkOwnCrossSigningTrust();
|
|
};
|
|
|
|
const verifyWithEmojis = async (deviceId) => {
|
|
const req = await mx.requestVerification(mx.getUserId(), [deviceId]);
|
|
openEmojiVerification(req, { userId: mx.getUserId(), deviceId });
|
|
};
|
|
|
|
const verify = (deviceId, isCurrentDevice) => {
|
|
if (isCurrentDevice) {
|
|
verifyWithKey(deviceId);
|
|
return;
|
|
}
|
|
verifyWithEmojis(deviceId);
|
|
};
|
|
|
|
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;
|
|
const isCurrentDevice = mx.deviceId === deviceId;
|
|
const canVerify = isVerified === false && (isMeVerified || isCurrentDevice);
|
|
|
|
return (
|
|
<SettingTile
|
|
key={deviceId}
|
|
title={(
|
|
<Text style={{ color: isVerified !== false ? '' : 'var(--tc-danger-high)' }}>
|
|
{displayName}
|
|
<Text variant="b3" span>{`${displayName ? ' — ' : ''}${deviceId}`}</Text>
|
|
{isCurrentDevice && <Text span className="device-manage__current-label" variant="b3">Current</Text>}
|
|
</Text>
|
|
)}
|
|
options={
|
|
processing.includes(deviceId)
|
|
? <Spinner size="small" />
|
|
: (
|
|
<>
|
|
{(isCSEnabled && canVerify) && <Button onClick={() => verify(deviceId, isCurrentDevice)} variant="positive">Verify</Button>}
|
|
<IconButton size="small" onClick={() => handleRename(device)} src={PencilIC} tooltip="Rename" />
|
|
<IconButton size="small" onClick={() => handleRemove(device)} src={BinIC} tooltip="Remove session" />
|
|
</>
|
|
)
|
|
}
|
|
content={(
|
|
<>
|
|
{lastTS && (
|
|
<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>
|
|
)}
|
|
{isCurrentDevice && (
|
|
<Text style={{ marginTop: 'var(--sp-ultra-tight)' }} variant="b3">
|
|
{`Session Key: ${initMatrix.matrixClient.getDeviceEd25519Key().match(/.{1,4}/g).join(' ')}`}
|
|
</Text>
|
|
)}
|
|
</>
|
|
)}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const unverified = [];
|
|
const verified = [];
|
|
const noEncryption = [];
|
|
deviceList.sort((a, b) => b.last_seen_ts - a.last_seen_ts).forEach((device) => {
|
|
const isVerified = isCrossVerified(device.device_id);
|
|
if (isVerified === true) {
|
|
verified.push(device);
|
|
} else if (isVerified === false) {
|
|
unverified.push(device);
|
|
} else {
|
|
noEncryption.push(device);
|
|
}
|
|
});
|
|
return (
|
|
<div className="device-manage">
|
|
<div>
|
|
<MenuHeader>Unverified sessions</MenuHeader>
|
|
{!isCSEnabled && (
|
|
<div style={{ padding: 'var(--sp-extra-tight) var(--sp-normal)' }}>
|
|
<InfoCard
|
|
rounded
|
|
variant="caution"
|
|
iconSrc={InfoIC}
|
|
title="Setup cross signing in case you lose all your sessions."
|
|
/>
|
|
</div>
|
|
)}
|
|
{
|
|
unverified.length > 0
|
|
? unverified.map((device) => renderDevice(device, false))
|
|
: <Text className="device-manage__info">No unverified sessions</Text>
|
|
}
|
|
</div>
|
|
{noEncryption.length > 0 && (
|
|
<div>
|
|
<MenuHeader>Sessions without encryption support</MenuHeader>
|
|
{noEncryption.map((device) => renderDevice(device, null))}
|
|
</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 sessions</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;
|