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:
Hubert Chathi 2025-10-28 16:18:10 -04:00 committed by GitHub
parent 5888dfd29d
commit b7db85146f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1211 additions and 0 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

@ -13,6 +13,10 @@ Please see LICENSE files in the repository root for full details.
display: block;
}
.mx_E2EIcon.mx_E2EIcon_inline {
display: inline-block;
}
.mx_E2EIcon_warning,
.mx_E2EIcon_normal,
.mx_E2EIcon_verified {

View File

@ -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],

View 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>
);
};

View File

@ -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",

View File

@ -83,6 +83,11 @@ exports[`DevtoolsDialog renders the devtools dialog 1`] = `
>
Active Widgets
</button>
<button
class="mx_DevTools_button"
>
Users
</button>
</div>
<div>
<h2

View File

@ -0,0 +1,361 @@
/*
* 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.
*/
import React from "react";
import { mocked } from "jest-mock";
import { Device, DeviceVerification, type MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk/src/matrix";
import { render, screen, waitFor } from "jest-matrix-react";
import { Room, PendingEventOrdering } from "matrix-js-sdk/src/matrix";
import { type DeviceVerificationStatus, type UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import { createTestClient } from "../../../../../test-utils";
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
import { DevtoolsContext } from "../../../../../../src/components/views/dialogs/devtools/BaseTool";
import { UserList } from "../../../../../../src/components/views/dialogs/devtools/Users";
const userId = "@alice:example.com";
describe("<Users />", () => {
let matrixClient: MatrixClient;
beforeEach(() => {
matrixClient = createTestClient();
});
it("should render a user list", () => {
const room = new Room("!roomId", matrixClient, userId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
room.getJoinedMembers = jest.fn().mockReturnValue([]);
const { asFragment } = render(
<MatrixClientContext.Provider value={matrixClient}>
<DevtoolsContext.Provider value={{ room }}>
<UserList onBack={() => {}} />
</DevtoolsContext.Provider>
</MatrixClientContext.Provider>,
);
expect(asFragment()).toMatchSnapshot();
});
it("should render a single user", async () => {
const room = new Room("!roomId", matrixClient, userId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
const alice = new RoomMember("!roomId", userId);
alice.setMembershipEvent(
new MatrixEvent({
content: {
membership: "join",
},
state_key: userId,
room_id: "!roomId",
type: "m.room.member",
sender: userId,
}),
);
room.getJoinedMembers = jest.fn().mockReturnValue([alice]);
mocked(matrixClient.getCrypto()!.getUserVerificationStatus).mockResolvedValue({
isCrossSigningVerified: jest.fn().mockReturnValue(true),
wasCrossSigningVerified: jest.fn().mockReturnValue(true),
needsUserApproval: false,
} as unknown as UserVerificationStatus);
mocked(matrixClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue(
new Map([
[
userId,
new Map([
[
"VERIFIED",
new Device({
deviceId: "VERIFIED",
userId: userId,
algorithms: [],
keys: new Map([
["ed25519:VERIFIED", "an_ed25519_public_key"],
["curve25519:VERIFIED", "a_curve25519_public_key"],
]),
}),
],
[
"SIGNED",
new Device({
deviceId: "SIGNED",
userId: userId,
algorithms: [],
keys: new Map([
["ed25519:SIGNED", "an_ed25519_public_key"],
["curve25519:SIGNED", "a_curve25519_public_key"],
]),
}),
],
[
"UNSIGNED",
new Device({
deviceId: "UNSIGNED",
userId: userId,
algorithms: [],
keys: new Map([
["ed25519:UNSIGNED", "an_ed25519_public_key"],
["curve25519:UNSIGNED", "a_curve25519_public_key"],
]),
}),
],
]),
],
]),
);
mocked(matrixClient.getCrypto()!.getDeviceVerificationStatus).mockImplementation(
async (userId: string, deviceId: string) => {
switch (deviceId) {
case "VERIFIED":
return {
signedByOwner: true,
crossSigningVerified: true,
} as unknown as DeviceVerificationStatus;
case "SIGNED":
return {
signedByOwner: true,
crossSigningVerified: false,
} as unknown as DeviceVerificationStatus;
case "UNSIGNED":
return {
signedByOwner: false,
crossSigningVerified: false,
} as unknown as DeviceVerificationStatus;
default:
return null;
}
},
);
const { asFragment } = render(
<MatrixClientContext.Provider value={matrixClient}>
<DevtoolsContext.Provider value={{ room }}>
<UserList onBack={() => {}} />
</DevtoolsContext.Provider>
</MatrixClientContext.Provider>,
);
screen.getByRole("button", { name: userId }).click();
await waitFor(() => expect(screen.getByText(/Verification status:/)).toHaveTextContent(/Verified/));
await waitFor(() => expect(screen.getByRole("button", { name: "VERIFIED" })).toBeInTheDocument());
expect(asFragment()).toMatchSnapshot();
});
it("should render a single device - verified by cross-signing", async () => {
const room = new Room("!roomId", matrixClient, userId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
const alice = new RoomMember("!roomId", userId);
alice.setMembershipEvent(
new MatrixEvent({
content: {
membership: "join",
},
state_key: userId,
room_id: "!roomId",
type: "m.room.member",
sender: userId,
}),
);
room.getJoinedMembers = jest.fn().mockReturnValue([alice]);
mocked(matrixClient.getCrypto()!.getUserVerificationStatus).mockResolvedValue({
isCrossSigningVerified: jest.fn().mockReturnValue(true),
wasCrossSigningVerified: jest.fn().mockReturnValue(true),
needsUserApproval: false,
} as unknown as UserVerificationStatus);
mocked(matrixClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue(
new Map([
[
userId,
new Map([
[
"VERIFIED",
new Device({
deviceId: "VERIFIED",
userId: userId,
algorithms: [],
keys: new Map([
["ed25519:VERIFIED", "an_ed25519_public_key"],
["curve25519:VERIFIED", "a_curve25519_public_key"],
]),
}),
],
]),
],
]),
);
mocked(matrixClient.getCrypto()!.getDeviceVerificationStatus).mockResolvedValue({
signedByOwner: true,
crossSigningVerified: true,
} as unknown as DeviceVerificationStatus);
const { asFragment } = render(
<MatrixClientContext.Provider value={matrixClient}>
<DevtoolsContext.Provider value={{ room }}>
<UserList onBack={() => {}} />
</DevtoolsContext.Provider>
</MatrixClientContext.Provider>,
);
screen.getByRole("button", { name: userId }).click();
await waitFor(() => expect(screen.getByRole("button", { name: "VERIFIED" })).toBeInTheDocument());
screen.getByRole("button", { name: "VERIFIED" }).click();
await waitFor(() =>
expect(screen.getByText(/Verification status:/)).toHaveTextContent(/Verified by cross-signing/),
);
expect(asFragment()).toMatchSnapshot();
});
it("should render a single device - signed by owner", async () => {
const room = new Room("!roomId", matrixClient, userId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
const alice = new RoomMember("!roomId", userId);
alice.setMembershipEvent(
new MatrixEvent({
content: {
membership: "join",
},
state_key: userId,
room_id: "!roomId",
type: "m.room.member",
sender: userId,
}),
);
room.getJoinedMembers = jest.fn().mockReturnValue([alice]);
mocked(matrixClient.getCrypto()!.getUserVerificationStatus).mockResolvedValue({
isCrossSigningVerified: jest.fn().mockReturnValue(true),
wasCrossSigningVerified: jest.fn().mockReturnValue(true),
needsUserApproval: false,
} as unknown as UserVerificationStatus);
mocked(matrixClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue(
new Map([
[
userId,
new Map([
[
"SIGNED",
new Device({
deviceId: "SIGNED",
userId: userId,
algorithms: [],
keys: new Map([
["ed25519:SIGNED", "an_ed25519_public_key"],
["curve25519:SIGNED", "a_curve25519_public_key"],
]),
}),
],
]),
],
]),
);
mocked(matrixClient.getCrypto()!.getDeviceVerificationStatus).mockResolvedValue({
signedByOwner: true,
crossSigningVerified: false,
} as unknown as DeviceVerificationStatus);
const { asFragment } = render(
<MatrixClientContext.Provider value={matrixClient}>
<DevtoolsContext.Provider value={{ room }}>
<UserList onBack={() => {}} />
</DevtoolsContext.Provider>
</MatrixClientContext.Provider>,
);
screen.getByRole("button", { name: userId }).click();
await waitFor(() => expect(screen.getByRole("button", { name: "SIGNED" })).toBeInTheDocument());
screen.getByRole("button", { name: "SIGNED" }).click();
await waitFor(() => expect(screen.getByText(/Verification status:/)).toHaveTextContent(/Signed by owner/));
expect(asFragment()).toMatchSnapshot();
});
it("should render a single device - unsigned", async () => {
const room = new Room("!roomId", matrixClient, userId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
const alice = new RoomMember("!roomId", userId);
alice.setMembershipEvent(
new MatrixEvent({
content: {
membership: "join",
},
state_key: userId,
room_id: "!roomId",
type: "m.room.member",
sender: userId,
}),
);
room.getJoinedMembers = jest.fn().mockReturnValue([alice]);
mocked(matrixClient.getCrypto()!.getUserVerificationStatus).mockResolvedValue({
isCrossSigningVerified: jest.fn().mockReturnValue(true),
wasCrossSigningVerified: jest.fn().mockReturnValue(true),
needsUserApproval: false,
} as unknown as UserVerificationStatus);
mocked(matrixClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue(
new Map([
[
userId,
new Map([
[
"UNSIGNED",
new Device({
deviceId: "UNSIGNED",
userId: userId,
algorithms: [],
keys: new Map([
["ed25519:UNSIGNED", "an_ed25519_public_key"],
["curve25519:UNSIGNED", "a_curve25519_public_key"],
]),
verified: DeviceVerification.Verified,
}),
],
]),
],
]),
);
mocked(matrixClient.getCrypto()!.getDeviceVerificationStatus).mockResolvedValue({
signedByOwner: false,
crossSigningVerified: false,
} as unknown as DeviceVerificationStatus);
const { asFragment } = render(
<MatrixClientContext.Provider value={matrixClient}>
<DevtoolsContext.Provider value={{ room }}>
<UserList onBack={() => {}} />
</DevtoolsContext.Provider>
</MatrixClientContext.Provider>,
);
screen.getByRole("button", { name: userId }).click();
await waitFor(() => expect(screen.getByText(/Verification status:/)).toHaveTextContent(/Verified/));
await waitFor(() => expect(screen.getByRole("button", { name: "UNSIGNED" })).toBeInTheDocument());
screen.getByRole("button", { name: "UNSIGNED" }).click();
await waitFor(() => expect(screen.getByText(/Verification status:/)).toHaveTextContent(/Not signed by owner/));
expect(asFragment()).toMatchSnapshot();
});
});

View File

@ -0,0 +1,454 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Users /> should render a single device - signed by owner 1`] = `
<DocumentFragment>
<div
class="mx_DevTools_content"
>
<ul>
<li>
<div
class="mx_CopyableText"
>
User ID: @alice:example.com
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
<li>
<div
class="mx_CopyableText"
>
Device ID: SIGNED
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
<li>
Displayname:
</li>
<li>
<span>
Verification status:
<div
class="mx_E2EIcon mx_E2EIcon_normal mx_E2EIcon_inline"
data-testid="e2e-icon"
/>
Signed by owner
</span>
</li>
<li>
Dehydrated: No
</li>
<li>
Device keys
<ul>
<li>
<div
class="mx_CopyableText"
>
ed25519: an_ed25519_public_key
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
<li>
<div
class="mx_CopyableText"
>
curve25519: a_curve25519_public_key
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
</ul>
</li>
</ul>
</div>
<div
class="mx_Dialog_buttons"
>
<button>
Back
</button>
</div>
</DocumentFragment>
`;
exports[`<Users /> should render a single device - unsigned 1`] = `
<DocumentFragment>
<div
class="mx_DevTools_content"
>
<ul>
<li>
<div
class="mx_CopyableText"
>
User ID: @alice:example.com
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
<li>
<div
class="mx_CopyableText"
>
Device ID: UNSIGNED
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
<li>
Displayname:
</li>
<li>
<span>
Verification status:
<div
class="mx_E2EIcon mx_E2EIcon_warning mx_E2EIcon_inline"
data-testid="e2e-icon"
>
<div
class="mx_E2EIcon_normal"
/>
</div>
Not signed by owner
</span>
</li>
<li>
Dehydrated: No
</li>
<li>
Device keys
<ul>
<li>
<div
class="mx_CopyableText"
>
ed25519: an_ed25519_public_key
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
<li>
<div
class="mx_CopyableText"
>
curve25519: a_curve25519_public_key
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
</ul>
</li>
</ul>
</div>
<div
class="mx_Dialog_buttons"
>
<button>
Back
</button>
</div>
</DocumentFragment>
`;
exports[`<Users /> should render a single device - verified by cross-signing 1`] = `
<DocumentFragment>
<div
class="mx_DevTools_content"
>
<ul>
<li>
<div
class="mx_CopyableText"
>
User ID: @alice:example.com
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
<li>
<div
class="mx_CopyableText"
>
Device ID: VERIFIED
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
<li>
Displayname:
</li>
<li>
<span>
Verification status:
<div
class="mx_E2EIcon mx_E2EIcon_verified mx_E2EIcon_inline"
data-testid="e2e-icon"
>
<div
class="mx_E2EIcon_normal"
/>
</div>
Verified by cross-signing
</span>
</li>
<li>
Dehydrated: No
</li>
<li>
Device keys
<ul>
<li>
<div
class="mx_CopyableText"
>
ed25519: an_ed25519_public_key
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
<li>
<div
class="mx_CopyableText"
>
curve25519: a_curve25519_public_key
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
</ul>
</li>
</ul>
</div>
<div
class="mx_Dialog_buttons"
>
<button>
Back
</button>
</div>
</DocumentFragment>
`;
exports[`<Users /> should render a single user 1`] = `
<DocumentFragment>
<div
class="mx_DevTools_content"
>
<ul>
<li>
<div
class="mx_CopyableText"
>
User ID: @alice:example.com
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
<li>
Membership: join
</li>
<li>
<span>
Displayname:
<i>
None
</i>
</span>
</li>
<li>
<span>
Avatar:
<i>
None
</i>
</span>
</li>
<li>
<span>
Verification status:
<div
class="mx_E2EIcon mx_E2EIcon_verified mx_E2EIcon_inline"
data-testid="e2e-icon"
>
<div
class="mx_E2EIcon_normal"
/>
</div>
Verified
</span>
</li>
</ul>
<section>
<h2>
Cryptographic devices (3)
</h2>
<ul>
<li>
<button
class="mx_DevTools_button"
>
<div
class="mx_E2EIcon mx_E2EIcon_verified mx_E2EIcon_inline"
data-testid="e2e-icon"
>
<div
class="mx_E2EIcon_normal"
/>
</div>
VERIFIED
</button>
</li>
<li>
<button
class="mx_DevTools_button"
>
<div
class="mx_E2EIcon mx_E2EIcon_normal mx_E2EIcon_inline"
data-testid="e2e-icon"
/>
SIGNED
</button>
</li>
<li>
<button
class="mx_DevTools_button"
>
<div
class="mx_E2EIcon mx_E2EIcon_warning mx_E2EIcon_inline"
data-testid="e2e-icon"
>
<div
class="mx_E2EIcon_normal"
/>
</div>
UNSIGNED
</button>
</li>
</ul>
</section>
</div>
<div
class="mx_Dialog_buttons"
>
<button>
Back
</button>
</div>
</DocumentFragment>
`;
exports[`<Users /> should render a user list 1`] = `
<DocumentFragment>
<div
class="mx_DevTools_content"
>
<div
class="mx_Field mx_Field_input mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
>
<input
autocomplete="off"
id="mx_Field_1"
label="Filter results"
placeholder="Filter results"
size="64"
type="text"
value=""
/>
<label
for="mx_Field_1"
>
Filter results
</label>
</div>
No results found
<div
class="mx_SettingsFlag"
>
<span
class="mx_SettingsFlag_label"
>
<div
id="mx_LabelledToggleSwitch__r_4_"
>
Only joined users
</div>
</span>
<div
aria-checked="true"
aria-disabled="false"
aria-labelledby="mx_LabelledToggleSwitch__r_4_"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on mx_ToggleSwitch_enabled"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
</div>
<div
class="mx_Dialog_buttons"
>
<button>
Back
</button>
</div>
</DocumentFragment>
`;