Add a devtool for looking at users and their devices (#30983)
* add devtool for viewing users and their devices * show number of devices * apply changes from review * Fix typo Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> --------- Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import SettingExplorer from "./devtools/SettingExplorer";
|
||||
import { RoomStateExplorer } from "./devtools/RoomState";
|
||||
import BaseTool, { DevtoolsContext, type IDevtoolsProps } from "./devtools/BaseTool";
|
||||
import WidgetExplorer from "./devtools/WidgetExplorer";
|
||||
import { UserList } from "./devtools/Users";
|
||||
import { AccountDataExplorer, RoomAccountDataExplorer } from "./devtools/AccountData";
|
||||
import SettingsFlag from "../elements/SettingsFlag";
|
||||
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
@@ -46,6 +47,7 @@ const Tools: Record<Category, [label: TranslationKey, tool: Tool][]> = {
|
||||
[_td("devtools|view_servers_in_room"), ServersInRoom],
|
||||
[_td("devtools|notifications_debug"), RoomNotifications],
|
||||
[_td("devtools|active_widgets"), WidgetExplorer],
|
||||
[_td("devtools|users"), UserList],
|
||||
],
|
||||
[Category.Other]: [
|
||||
[_td("devtools|explore_account_data"), AccountDataExplorer],
|
||||
|
||||
359
src/components/views/dialogs/devtools/Users.tsx
Normal file
359
src/components/views/dialogs/devtools/Users.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Devtool for viewing room members and their devices.
|
||||
*/
|
||||
|
||||
import React, { type JSX, useContext, useState } from "react";
|
||||
import { type Device, type RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { type CryptoApi } from "matrix-js-sdk/src/crypto-api";
|
||||
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import BaseTool, { DevtoolsContext, type IDevtoolsProps } from "./BaseTool";
|
||||
import FilteredList from "./FilteredList";
|
||||
import LabelledToggleSwitch from "../../elements/LabelledToggleSwitch";
|
||||
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
|
||||
import CopyableText from "../../elements/CopyableText";
|
||||
import E2EIcon from "../../rooms/E2EIcon";
|
||||
import { E2EStatus } from "../../../../utils/ShieldUtils";
|
||||
|
||||
/**
|
||||
* Replacement function for `<i>` tags in translation strings.
|
||||
*/
|
||||
function i(sub: string): JSX.Element {
|
||||
return <i>{sub}</i>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a list of users in the room, and allows selecting a user to view.
|
||||
*
|
||||
* Initially, filters to only show joined users, but offers the user an option to show all users.
|
||||
*
|
||||
* Once the user chooses a specific member, delegates to {@link UserView} to view a single user.
|
||||
*/
|
||||
export const UserList: React.FC<Pick<IDevtoolsProps, "onBack">> = ({ onBack }) => {
|
||||
const context = useContext(DevtoolsContext);
|
||||
const [query, setQuery] = useState("");
|
||||
// Show only joined users or all users with member events?
|
||||
const [showOnlyJoined, setShowOnlyJoined] = useState(true);
|
||||
// The `RoomMember` for the selected user (if any)
|
||||
const [member, setMember] = useState<RoomMember | null>(null);
|
||||
|
||||
if (member) {
|
||||
return <UserView member={member} onBack={() => setMember(null)} />;
|
||||
}
|
||||
|
||||
const members = showOnlyJoined ? context.room.getJoinedMembers() : context.room.getMembers();
|
||||
|
||||
return (
|
||||
<BaseTool onBack={onBack}>
|
||||
<FilteredList query={query} onChange={setQuery}>
|
||||
{members.map((member) => (
|
||||
<UserButton key={member.userId} member={member} onClick={() => setMember(member)} />
|
||||
))}
|
||||
</FilteredList>
|
||||
<LabelledToggleSwitch
|
||||
label={_t("devtools|only_joined_members")}
|
||||
onChange={setShowOnlyJoined}
|
||||
value={showOnlyJoined}
|
||||
/>
|
||||
</BaseTool>
|
||||
);
|
||||
};
|
||||
|
||||
interface UserButtonProps {
|
||||
member: RoomMember;
|
||||
onClick(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Button to select a user to view.
|
||||
*/
|
||||
const UserButton: React.FC<UserButtonProps> = ({ member, onClick }) => {
|
||||
return (
|
||||
<button className="mx_DevTools_button" onClick={onClick}>
|
||||
{member.userId}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
interface UserProps extends Pick<IDevtoolsProps, "onBack"> {
|
||||
member: RoomMember;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a single user to view, and allows selecting a device to view.
|
||||
*
|
||||
* Once the user chooses a specific device, delegates to {@link DeviceView} to show a single device.
|
||||
*/
|
||||
const UserView: React.FC<UserProps> = ({ member, onBack }) => {
|
||||
const context = useContext(DevtoolsContext);
|
||||
const crypto = context.room.client.getCrypto();
|
||||
// An element to show the verification status of the device (unknown,
|
||||
// unverified, verified by cross signing, signed by owner). The element
|
||||
// will show text as well as an icon. If crypto is not available, the value
|
||||
// will be `null`.
|
||||
const verificationStatus = useAsyncMemo(
|
||||
async () => {
|
||||
if (!crypto) {
|
||||
return null;
|
||||
}
|
||||
const status = await crypto.getUserVerificationStatus(member.userId);
|
||||
if (status.isCrossSigningVerified()) {
|
||||
const e2eIcon = (): JSX.Element => (
|
||||
<E2EIcon
|
||||
isUser={true}
|
||||
hideTooltip={true}
|
||||
status={E2EStatus.Verified}
|
||||
className="mx_E2EIcon_inline"
|
||||
/>
|
||||
);
|
||||
return _t("devtools|user_verification_status|verified", {}, { E2EIcon: e2eIcon });
|
||||
} else if (status.wasCrossSigningVerified()) {
|
||||
const e2eIcon = (): JSX.Element => (
|
||||
<E2EIcon
|
||||
isUser={true}
|
||||
hideTooltip={true}
|
||||
status={E2EStatus.Warning}
|
||||
className="mx_E2EIcon_inline"
|
||||
/>
|
||||
);
|
||||
return _t("devtools|user_verification_status|was_verified", {}, { E2EIcon: e2eIcon });
|
||||
} else if (status.needsUserApproval) {
|
||||
const e2eIcon = (): JSX.Element => (
|
||||
<E2EIcon
|
||||
isUser={true}
|
||||
hideTooltip={true}
|
||||
status={E2EStatus.Warning}
|
||||
className="mx_E2EIcon_inline"
|
||||
/>
|
||||
);
|
||||
return _t("devtools|user_verification_status|identity_changed", {}, { E2EIcon: e2eIcon });
|
||||
} else {
|
||||
const e2eIcon = (): JSX.Element => (
|
||||
<E2EIcon isUser={true} hideTooltip={true} status={E2EStatus.Normal} className="mx_E2EIcon_inline" />
|
||||
);
|
||||
return _t("devtools|user_verification_status|unverified", {}, { E2EIcon: e2eIcon });
|
||||
}
|
||||
},
|
||||
[context, member],
|
||||
_t("common|loading"),
|
||||
);
|
||||
// The user's devices, as a Map from device ID to device information (see
|
||||
// the `Device` type in `matrix-js-sdk/src/models/device.ts`).
|
||||
const devices = useAsyncMemo(
|
||||
async () => {
|
||||
const devices = await crypto?.getUserDeviceInfo([member.userId]);
|
||||
return devices?.get(member.userId) ?? new Map();
|
||||
},
|
||||
[context, member],
|
||||
new Map(),
|
||||
);
|
||||
// The device to show, if any.
|
||||
const [device, setDevice] = useState<Device | null>(null);
|
||||
|
||||
if (device) {
|
||||
return <DeviceView crypto={crypto!} device={device} onBack={() => setDevice(null)} />;
|
||||
}
|
||||
|
||||
const avatarUrl = member.getMxcAvatarUrl();
|
||||
const memberEventContent = member.events.member?.getContent();
|
||||
|
||||
return (
|
||||
<BaseTool onBack={onBack}>
|
||||
<ul>
|
||||
<li>
|
||||
<CopyableText getTextToCopy={() => member.userId} border={false}>
|
||||
{_t("devtools|user_id", { userId: member.userId })}
|
||||
</CopyableText>
|
||||
</li>
|
||||
<li>{_t("devtools|user_room_membership", { membership: member.membership ?? "leave" })}</li>
|
||||
<li>
|
||||
{memberEventContent && "displayname" in memberEventContent
|
||||
? _t("devtools|user_displayname", { displayname: member.rawDisplayName })
|
||||
: _t("devtools|user_no_displayname", {}, { i })}
|
||||
</li>
|
||||
<li>
|
||||
{avatarUrl !== undefined ? (
|
||||
<CopyableText getTextToCopy={() => avatarUrl} border={false}>
|
||||
{_t("devtools|user_avatar", { avatar: avatarUrl })}
|
||||
</CopyableText>
|
||||
) : (
|
||||
_t("devtools|user_no_avatar", {}, { i })
|
||||
)}
|
||||
</li>
|
||||
<li>{verificationStatus}</li>
|
||||
</ul>
|
||||
<section>
|
||||
<h2>{_t("devtools|devices", { count: devices.size })}</h2>
|
||||
<ul>
|
||||
{Array.from(devices.values()).map((device) => (
|
||||
<li key={device.deviceId}>
|
||||
<DeviceButton crypto={crypto!} device={device} onClick={() => setDevice(device)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</BaseTool>
|
||||
);
|
||||
};
|
||||
|
||||
interface DeviceButtonProps {
|
||||
crypto: CryptoApi;
|
||||
device: Device;
|
||||
onClick(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Button to select a user to view.
|
||||
*/
|
||||
const DeviceButton: React.FC<DeviceButtonProps> = ({ crypto, device, onClick }) => {
|
||||
const verificationIcon = useAsyncMemo(
|
||||
async () => {
|
||||
const status = await crypto.getDeviceVerificationStatus(device.userId, device.deviceId);
|
||||
if (!status) {
|
||||
return;
|
||||
} else if (status.crossSigningVerified) {
|
||||
return (
|
||||
<E2EIcon
|
||||
isUser={true}
|
||||
hideTooltip={true}
|
||||
status={E2EStatus.Verified}
|
||||
className="mx_E2EIcon_inline"
|
||||
/>
|
||||
);
|
||||
} else if (status.signedByOwner) {
|
||||
return (
|
||||
<E2EIcon isUser={true} hideTooltip={true} status={E2EStatus.Normal} className="mx_E2EIcon_inline" />
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<E2EIcon
|
||||
isUser={true}
|
||||
hideTooltip={true}
|
||||
status={E2EStatus.Warning}
|
||||
className="mx_E2EIcon_inline"
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
[crypto, device],
|
||||
null,
|
||||
);
|
||||
return (
|
||||
<button className="mx_DevTools_button" onClick={onClick}>
|
||||
{verificationIcon}
|
||||
{device.deviceId}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
interface DeviceProps extends Pick<IDevtoolsProps, "onBack"> {
|
||||
crypto: CryptoApi;
|
||||
device: Device;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a single device to view.
|
||||
*/
|
||||
const DeviceView: React.FC<DeviceProps> = ({ crypto, device, onBack }) => {
|
||||
// An element to show the verification status of the device (unknown,
|
||||
// unverified, verified by cross signing, signed by owner). The element
|
||||
// will show text as well as an icon if applicable.
|
||||
const verificationStatus = useAsyncMemo(
|
||||
async () => {
|
||||
const status = await crypto.getDeviceVerificationStatus(device.userId, device.deviceId);
|
||||
if (!status) {
|
||||
// `status` will be `null` if the device is unknown or if the
|
||||
// device doesn't have device keys. In either case, it's not a
|
||||
// security issue since we won't be sending it decryption keys.
|
||||
return _t("devtools|device_verification_status|unknown");
|
||||
} else if (status.crossSigningVerified) {
|
||||
const e2eIcon = (): JSX.Element => (
|
||||
<E2EIcon
|
||||
isUser={true}
|
||||
hideTooltip={true}
|
||||
status={E2EStatus.Verified}
|
||||
className="mx_E2EIcon_inline"
|
||||
/>
|
||||
);
|
||||
return _t("devtools|device_verification_status|verified", {}, { E2EIcon: e2eIcon });
|
||||
} else if (status.signedByOwner) {
|
||||
const e2eIcon = (): JSX.Element => (
|
||||
<E2EIcon isUser={true} hideTooltip={true} status={E2EStatus.Normal} className="mx_E2EIcon_inline" />
|
||||
);
|
||||
return _t("devtools|device_verification_status|signed_by_owner", {}, { E2EIcon: e2eIcon });
|
||||
} else {
|
||||
const e2eIcon = (): JSX.Element => (
|
||||
<E2EIcon
|
||||
isUser={true}
|
||||
hideTooltip={true}
|
||||
status={E2EStatus.Warning}
|
||||
className="mx_E2EIcon_inline"
|
||||
/>
|
||||
);
|
||||
return _t("devtools|device_verification_status|unverified", {}, { E2EIcon: e2eIcon });
|
||||
}
|
||||
},
|
||||
[crypto, device],
|
||||
_t("common|loading"),
|
||||
);
|
||||
|
||||
const keyIdSuffix = ":" + device.deviceId;
|
||||
const deviceKeys = (
|
||||
<ul>
|
||||
{Array.from(device.keys.entries()).map(([keyId, key]) => {
|
||||
if (keyId.endsWith(keyIdSuffix)) {
|
||||
return (
|
||||
<li key={keyId}>
|
||||
<CopyableText getTextToCopy={() => key} border={false}>
|
||||
{keyId.slice(0, -keyIdSuffix.length)}: {key}
|
||||
</CopyableText>
|
||||
</li>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<li key={keyId}>
|
||||
<i>{_t("devtools|invalid_device_key_id")}</i>: {keyId}: {key}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseTool onBack={onBack}>
|
||||
<ul>
|
||||
<li>
|
||||
<CopyableText getTextToCopy={() => device.userId} border={false}>
|
||||
{_t("devtools|user_id", { userId: device.userId })}
|
||||
</CopyableText>
|
||||
</li>
|
||||
<li>
|
||||
<CopyableText getTextToCopy={() => device.deviceId} border={false}>
|
||||
{_t("devtools|device_id", { deviceId: device.deviceId })}
|
||||
</CopyableText>
|
||||
</li>
|
||||
<li>
|
||||
{"displayName" in device
|
||||
? _t("devtools|user_displayname", { displayname: device.displayName })
|
||||
: _t("devtools|user_no_displayname", {}, { i })}
|
||||
</li>
|
||||
<li>{verificationStatus}</li>
|
||||
<li>
|
||||
{device.dehydrated ? _t("devtools|device_dehydrated_yes") : _t("devtools|device_dehydrated_no")}
|
||||
</li>
|
||||
<li>
|
||||
{_t("devtools|device_keys")}
|
||||
{deviceKeys}
|
||||
</li>
|
||||
</ul>
|
||||
</BaseTool>
|
||||
);
|
||||
};
|
||||
@@ -805,6 +805,17 @@
|
||||
},
|
||||
"developer_mode": "Developer mode",
|
||||
"developer_tools": "Developer Tools",
|
||||
"device_dehydrated_no": "Dehydrated: No",
|
||||
"device_dehydrated_yes": "Dehydrated: Yes",
|
||||
"device_id": "Device ID: %(deviceId)s",
|
||||
"device_keys": "Device keys",
|
||||
"device_verification_status": {
|
||||
"signed_by_owner": "Verification status: <E2EIcon /> Signed by owner",
|
||||
"unknown": "Verification status: Unknown",
|
||||
"unverified": "Verification status: <E2EIcon /> Not signed by owner",
|
||||
"verified": "Verification status: <E2EIcon /> Verified by cross-signing"
|
||||
},
|
||||
"devices": "Cryptographic devices (%(count)s)",
|
||||
"edit_setting": "Edit setting",
|
||||
"edit_values": "Edit values",
|
||||
"empty_string": "<empty string>",
|
||||
@@ -820,6 +831,7 @@
|
||||
"failed_to_save": "Failed to save settings.",
|
||||
"failed_to_send": "Failed to send event!",
|
||||
"id": "ID: ",
|
||||
"invalid_device_key_id": "Invalid device key ID",
|
||||
"invalid_json": "Doesn't look like valid JSON.",
|
||||
"level": "Level",
|
||||
"low_bandwidth_mode": "Low bandwidth mode",
|
||||
@@ -830,6 +842,7 @@
|
||||
"notification_state": "Notification state is <strong>%(notificationState)s</strong>",
|
||||
"notifications_debug": "Notifications debug",
|
||||
"number_of_users": "Number of users",
|
||||
"only_joined_members": "Only joined users",
|
||||
"original_event_source": "Original event source",
|
||||
"room_encrypted": "Room is <strong>encrypted ✅</strong>",
|
||||
"room_id": "Room ID: %(roomId)s",
|
||||
@@ -876,10 +889,23 @@
|
||||
"toggle_event": "toggle event",
|
||||
"toolbox": "Toolbox",
|
||||
"use_at_own_risk": "This UI does NOT check the types of the values. Use at your own risk.",
|
||||
"user_avatar": "Avatar: %(avatar)s",
|
||||
"user_displayname": "Displayname: %(displayname)s",
|
||||
"user_id": "User ID: %(userId)s",
|
||||
"user_no_avatar": "Avatar: <i>None</i>",
|
||||
"user_no_displayname": "Displayname: <i>None</i>",
|
||||
"user_read_up_to": "User read up to: ",
|
||||
"user_read_up_to_ignore_synthetic": "User read up to (ignoreSynthetic): ",
|
||||
"user_read_up_to_private": "User read up to (m.read.private): ",
|
||||
"user_read_up_to_private_ignore_synthetic": "User read up to (m.read.private;ignoreSynthetic): ",
|
||||
"user_room_membership": "Membership: %(membership)s",
|
||||
"user_verification_status": {
|
||||
"identity_changed": "Verification status: <E2EIcon /> Unverified, and identity changed",
|
||||
"unverified": "Verification status: <E2EIcon /> Unverified",
|
||||
"verified": "Verification status: <E2EIcon /> Verified",
|
||||
"was_verified": "Verification status: <E2EIcon /> Was verified, but identity changed"
|
||||
},
|
||||
"users": "Users",
|
||||
"value": "Value",
|
||||
"value_colon": "Value:",
|
||||
"value_in_this_room": "Value in this room",
|
||||
|
||||
Reference in New Issue
Block a user