mirror of
https://github.com/element-hq/element-web.git
synced 2025-12-27 23:11:21 +00:00
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:
parent
5888dfd29d
commit
b7db85146f
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 52 KiB |
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -83,6 +83,11 @@ exports[`DevtoolsDialog renders the devtools dialog 1`] = `
|
||||
>
|
||||
Active Widgets
|
||||
</button>
|
||||
<button
|
||||
class="mx_DevTools_button"
|
||||
>
|
||||
Users
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<h2
|
||||
|
||||
361
test/unit-tests/components/views/dialogs/devtools/Users-test.tsx
Normal file
361
test/unit-tests/components/views/dialogs/devtools/Users-test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
`;
|
||||
Loading…
Reference in New Issue
Block a user