Add new verification section to user profile (#29200)

* Create new verification section

* Remove old code and use new VerificationSection

* Add styling and translation

* Fix tests

* Remove dead code

* Fix broken test

* Remove imports

* Remove console.log

* Update snapshots

* Fix broken tests

* Fix lint

* Make badge expand with content

* Remove unused code
This commit is contained in:
R Midhun Suresh
2025-02-10 16:52:58 +05:30
committed by GitHub
parent bb8b4d7991
commit 52b42c0b1c
14 changed files with 489 additions and 1095 deletions

View File

@@ -6,21 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type Locator, type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { viewRoomSummaryByName } from "../right-panel/utils";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { completeCreateSecretStorageDialog, createBot, logIntoElement } from "./utils.ts";
import { type Client } from "../../pages/client.ts";
const ROOM_NAME = "Test room";
const NAME = "Alice";
function getMemberTileByName(page: Page, name: string): Locator {
return page.locator(`.mx_MemberTileView, [title="${name}"]`);
}
test.use({
displayName: NAME,
synapseConfig: {
@@ -70,23 +62,6 @@ test.describe("Dehydration", () => {
// device.
const sessionsTab = await app.settings.openUserSettings("Sessions");
await expect(sessionsTab.getByText("Dehydrated device")).not.toBeVisible();
await app.settings.closeDialog();
// now check that the user info right-panel shows the dehydrated device
// as a feature rather than as a normal device
await app.client.createRoom({ name: ROOM_NAME });
await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.locator(".mx_RightPanel").getByRole("menuitem", { name: "People" }).click();
await expect(page.locator(".mx_MemberListView")).toBeVisible();
await getMemberTileByName(page, NAME).click();
await page.locator(".mx_UserInfo_devices .mx_UserInfo_expand").click();
await expect(page.locator(".mx_UserInfo_devices").getByText("Offline device enabled")).toBeVisible();
await expect(page.locator(".mx_UserInfo_devices").getByText("Dehydrated device")).not.toBeVisible();
});
test("Reset recovery key during login re-creates dehydrated device", async ({

View File

@@ -17,6 +17,7 @@ import {
logIntoElement,
logOutOfElement,
verify,
waitForDevices,
} from "./utils";
import { bootstrapCrossSigningForClient } from "../../pages/client.ts";
import { type ElementAppPage } from "../../pages/ElementAppPage.ts";
@@ -144,25 +145,8 @@ test.describe("Cryptography", function () {
// bob deletes his second device
await bobSecondDevice.evaluate((cli) => cli.logout(true));
// wait for the logout to propagate. Workaround for https://github.com/vector-im/element-web/issues/26263 by repeatedly closing and reopening Bob's user info.
async function awaitOneDevice(iterations = 1) {
const rightPanel = page.locator(".mx_RightPanel");
await rightPanel.getByTestId("base-card-back-button").click();
await rightPanel.getByText("Bob").click();
const sessionCountText = await rightPanel
.locator(".mx_UserInfo_devices")
.getByText(" session", { exact: false })
.textContent();
// cf https://github.com/vector-im/element-web/issues/26279: Element-R uses the wrong text here
if (sessionCountText != "1 session" && sessionCountText != "1 verified session") {
if (iterations >= 10) {
throw new Error(`Bob still has ${sessionCountText} after 10 iterations`);
}
await awaitOneDevice(iterations + 1);
}
}
await awaitOneDevice();
// wait for the logout to propagate.
await waitForDevices(app, bob.credentials.userId, 1);
// close and reopen the room, to get the shield to update.
await app.viewRoomByName("Bob");
@@ -285,11 +269,7 @@ test.describe("Cryptography", function () {
// Workaround for https://github.com/element-hq/element-web/issues/28640:
// make sure that Alice has seen Bob's identity before she goes offline. We do this by opening
// his user info.
await app.toggleRoomInfoPanel();
const rightPanel = page.locator(".mx_RightPanel");
await rightPanel.getByRole("menuitem", { name: "People" }).click();
await rightPanel.getByRole("button", { name: bob.credentials!.userId }).click();
await expect(rightPanel.locator(".mx_UserInfo_devices")).toContainText("1 session");
await waitForDevices(app, bob.credentials.userId, 1);
// Our app is blocked from syncing while Bob sends his messages.
await app.client.network.goOffline();

View File

@@ -8,9 +8,8 @@ Please see LICENSE files in the repository root for full details.
import { type Preset, type Visibility } from "matrix-js-sdk/src/matrix";
import type { Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { doTwoWaySasVerification, awaitVerifier } from "./utils";
import { doTwoWaySasVerification, awaitVerifier, waitForDevices } from "./utils";
import { type Client } from "../../pages/client";
test.describe("User verification", () => {
@@ -33,13 +32,17 @@ test.describe("User verification", () => {
});
test("can receive a verification request when there is no existing DM", async ({
app,
page,
bot: bob,
user: aliceCredentials,
toasts,
room: { roomId: dmRoomId },
}) => {
await waitForDeviceKeys(page);
await waitForDevices(app, bob.credentials.userId, 1);
await expect(page.getByRole("button", { name: "Avatar" })).toBeVisible();
const avatar = page.getByRole("button", { name: "Avatar" });
await avatar.click();
// once Alice has joined, Bob starts the verification
const bobVerificationRequest = await bob.evaluateHandle(
@@ -84,13 +87,17 @@ test.describe("User verification", () => {
});
test("can abort emoji verification when emoji mismatch", async ({
app,
page,
bot: bob,
user: aliceCredentials,
toasts,
room: { roomId: dmRoomId },
}) => {
await waitForDeviceKeys(page);
await waitForDevices(app, bob.credentials.userId, 1);
await expect(page.getByRole("button", { name: "Avatar" })).toBeVisible();
const avatar = page.getByRole("button", { name: "Avatar" });
await avatar.click();
// once Alice has joined, Bob starts the verification
const bobVerificationRequest = await bob.evaluateHandle(
@@ -154,15 +161,3 @@ async function createDMRoom(client: Client, userId: string): Promise<string> {
],
});
}
/**
* Wait until we get the other user's device keys.
* In newer rust-crypto versions, the verification request will be ignored if we
* don't have the sender's device keys.
*/
async function waitForDeviceKeys(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Avatar" })).toBeVisible();
const avatar = await page.getByRole("button", { name: "Avatar" });
await avatar.click();
await expect(page.getByText("1 session")).toBeVisible();
}

View File

@@ -502,3 +502,31 @@ export async function deleteCachedSecrets(page: Page) {
});
await page.reload();
}
/**
* Wait until the given user has a given number of devices.
* This function will check the device keys ten times and if
* the expected number of devices were not found by then, an
* error is thrown.
*/
export async function waitForDevices(
app: ElementAppPage,
userId: string,
expectedNumberOfDevices: number,
): Promise<void> {
const result = await app.client.evaluate(
async (cli, { userId, expectedNumberOfDevices }) => {
for (let i = 0; i < 10; ++i) {
const userDeviceMap = await cli.getCrypto()?.getUserDeviceInfo([userId], true);
const deviceMap = userDeviceMap?.get(userId);
if (deviceMap.size === expectedNumberOfDevices) return true;
await new Promise((r) => setTimeout(r, 500));
}
return false;
},
{ userId, expectedNumberOfDevices },
);
if (!result) {
throw new Error(`User ${userId} did not have ${expectedNumberOfDevices} devices within ten iterations!`);
}
}

View File

@@ -19,7 +19,6 @@ test.describe("UserView", () => {
const rightPanel = page.locator("#mx_RightPanel");
await expect(rightPanel.getByRole("heading", { name: bot.credentials.displayName, exact: true })).toBeVisible();
await expect(rightPanel.getByText("1 session")).toBeVisible();
await expect(rightPanel).toMatchScreenshot("user-info.png", {
mask: [page.locator(".mx_UserInfo_profile_mxid")],
css: `

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -37,10 +37,6 @@ Please see LICENSE files in the repository root for full details.
padding: var(--cpd-space-2x) 0 var(--cpd-space-4x);
margin: 0 var(--cpd-space-4x);
.mx_UserInfo_container_verifyButton {
margin-top: $spacing-8;
}
& + .mx_UserInfo_container {
border-top: 1px solid $separator;
}
@@ -180,6 +176,28 @@ Please see LICENSE files in the repository root for full details.
opacity: 1;
}
.mx_UserInfo_verification {
margin-top: var(--cpd-space-4x);
height: 36px;
.mx_UserInfo_verified_badge {
min-width: 68px;
height: 20px;
.mx_UserInfo_verified_icon {
flex-shrink: 0;
}
.mx_UserInfo_verified_label {
margin: 0;
}
}
.mx_UserInfo_verification_unavailable {
color: var(--cpd-color-text-secondary);
}
}
.mx_UserInfo_memberDetails {
.mx_UserInfo_profileField {
display: flex;
@@ -226,45 +244,6 @@ Please see LICENSE files in the repository root for full details.
flex: 1 1 0;
}
.mx_UserInfo_devices {
.mx_UserInfo_device {
display: flex;
margin: $spacing-8 0;
&.mx_UserInfo_device_verified {
.mx_UserInfo_device_trusted {
color: $accent;
}
}
&.mx_UserInfo_device_unverified {
.mx_UserInfo_device_trusted {
color: $alert;
}
}
.mx_UserInfo_device_name {
flex: 1;
margin: 0 5px;
word-break: break-word;
}
}
/* both for icon in expand button and device item */
.mx_E2EIcon {
/* don't squeeze */
flex: 0 0 auto;
margin: 0;
width: 12px;
height: 12px;
}
.mx_UserInfo_expand {
column-gap: 5px; /* cf: mx_UserInfo_device_name */
margin-bottom: 11px;
align-items: initial; /* Cancel the default property */
}
}
&.mx_UserInfo_smallAvatar {
.mx_UserInfo_avatar {
.mx_UserInfo_avatar_transition {

View File

@@ -25,7 +25,8 @@ import {
import { KnownMembership } from "matrix-js-sdk/src/types";
import { type UserVerificationStatus, type VerificationRequest, CryptoEvent } from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger";
import { Heading, MenuItem, Text, Tooltip } from "@vector-im/compound-web";
import { Badge, Button, Heading, InlineSpinner, MenuItem, Text, Tooltip } from "@vector-im/compound-web";
import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified";
import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
import ShareIcon from "@vector-im/compound-design-tokens/assets/web/icons/share";
@@ -42,21 +43,19 @@ import dis from "../../../dispatcher/dispatcher";
import Modal from "../../../Modal";
import { _t, UserFriendlyError } from "../../../languageHandler";
import DMRoomMap from "../../../utils/DMRoomMap";
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
import { type ButtonEvent } from "../elements/AccessibleButton";
import SdkConfig from "../../../SdkConfig";
import MultiInviter from "../../../utils/MultiInviter";
import E2EIcon from "../rooms/E2EIcon";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
import { textualPowerLevel } from "../../../Roles";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
import EncryptionPanel from "./EncryptionPanel";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import { verifyDevice, verifyUser } from "../../../verification";
import { verifyUser } from "../../../verification";
import { Action } from "../../../dispatcher/actions";
import { useIsEncrypted } from "../../../hooks/useIsEncrypted";
import BaseCard from "./BaseCard";
import { E2EStatus } from "../../../utils/ShieldUtils";
import ImageView from "../elements/ImageView";
import Spinner from "../elements/Spinner";
import PowerSelector from "../elements/PowerSelector";
@@ -81,7 +80,6 @@ import PosthogTrackers from "../../../PosthogTrackers";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { DirectoryMember, startDmOnFirstMessage } from "../../../utils/direct-messages";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { asyncSome } from "../../../utils/arrays";
import { Flex } from "../../utils/Flex";
import CopyableText from "../elements/CopyableText";
import { useUserTimezone } from "../../../hooks/useUserTimezone";
@@ -107,32 +105,6 @@ export const disambiguateDevices = (devices: IDevice[]): void => {
}
};
export const getE2EStatus = async (
cli: MatrixClient,
userId: string,
devices: IDevice[],
): Promise<E2EStatus | undefined> => {
const crypto = cli.getCrypto();
if (!crypto) return undefined;
const isMe = userId === cli.getUserId();
const userTrust = await crypto.getUserVerificationStatus(userId);
if (!userTrust.isCrossSigningVerified()) {
return userTrust.wasCrossSigningVerified() ? E2EStatus.Warning : E2EStatus.Normal;
}
const anyDeviceUnverified = await asyncSome(devices, async (device) => {
const { deviceId } = device;
// For your own devices, we use the stricter check of cross-signing
// verification to encourage everyone to trust their own devices via
// cross-signing so that other users can then safely trust you.
// For other people's devices, the more general verified check that
// includes locally verified devices can be used.
const deviceTrust = await crypto.getDeviceVerificationStatus(userId, deviceId);
return isMe ? !deviceTrust?.crossSigningVerified : !deviceTrust?.isVerified();
});
return anyDeviceUnverified ? E2EStatus.Warning : E2EStatus.Verified;
};
/**
* Converts the member to a DirectoryMember and starts a DM with them.
*/
@@ -146,251 +118,6 @@ async function openDmForUser(matrixClient: MatrixClient, user: Member): Promise<
await startDmOnFirstMessage(matrixClient, [startDmUser]);
}
type SetUpdating = (updating: boolean) => void;
function useHasCrossSigningKeys(
cli: MatrixClient,
member: User,
canVerify: boolean,
setUpdating: SetUpdating,
): boolean | undefined {
return useAsyncMemo(async () => {
if (!canVerify) {
return undefined;
}
setUpdating(true);
try {
return await cli.getCrypto()?.userHasCrossSigningKeys(member.userId, true);
} finally {
setUpdating(false);
}
}, [cli, member, canVerify]);
}
/**
* Display one device and the related actions
* @param userId current user id
* @param device device to display
* @param isUserVerified false when the user is not verified
* @constructor
*/
export function DeviceItem({
userId,
device,
isUserVerified,
}: {
userId: string;
device: IDevice;
isUserVerified: boolean;
}): JSX.Element {
const cli = useContext(MatrixClientContext);
const isMe = userId === cli.getUserId();
/** is the device verified? */
const isVerified = useAsyncMemo(async () => {
const deviceTrust = await cli.getCrypto()?.getDeviceVerificationStatus(userId, device.deviceId);
if (!deviceTrust) return false;
// For your own devices, we use the stricter check of cross-signing
// verification to encourage everyone to trust their own devices via
// cross-signing so that other users can then safely trust you.
// For other people's devices, the more general verified check that
// includes locally verified devices can be used.
return isMe ? deviceTrust.crossSigningVerified : deviceTrust.isVerified();
}, [cli, userId, device]);
const classes = classNames("mx_UserInfo_device", {
mx_UserInfo_device_verified: isVerified,
mx_UserInfo_device_unverified: !isVerified,
});
const iconClasses = classNames("mx_E2EIcon", {
mx_E2EIcon_normal: !isUserVerified,
mx_E2EIcon_verified: isVerified,
mx_E2EIcon_warning: isUserVerified && !isVerified,
});
const onDeviceClick = (): void => {
const user = cli.getUser(userId);
if (user) {
verifyDevice(cli, user, device);
}
};
let deviceName;
if (!device.displayName?.trim()) {
deviceName = device.deviceId;
} else {
deviceName = device.ambiguous ? device.displayName + " (" + device.deviceId + ")" : device.displayName;
}
let trustedLabel: string | undefined;
if (isUserVerified) trustedLabel = isVerified ? _t("common|trusted") : _t("common|not_trusted");
if (isVerified === undefined) {
// we're still deciding if the device is verified
return <div className={classes} title={device.deviceId} />;
} else if (isVerified) {
return (
<div className={classes} title={device.deviceId}>
<div className={iconClasses} />
<div className="mx_UserInfo_device_name">{deviceName}</div>
<div className="mx_UserInfo_device_trusted">{trustedLabel}</div>
</div>
);
} else {
return (
<AccessibleButton
className={classes}
title={device.deviceId}
aria-label={deviceName}
onClick={onDeviceClick}
>
<div className={iconClasses} />
<div className="mx_UserInfo_device_name">{deviceName}</div>
<div className="mx_UserInfo_device_trusted">{trustedLabel}</div>
</AccessibleButton>
);
}
}
/**
* Display a list of devices
* @param devices devices to display
* @param userId current user id
* @param loading displays a spinner instead of the device section
* @param isUserVerified is false when
* - the user is not verified, or
* - `MatrixClient.getCrypto.getUserVerificationStatus` async call is in progress (in which case `loading` will also be `true`)
* @constructor
*/
function DevicesSection({
devices,
userId,
loading,
isUserVerified,
}: {
devices: IDevice[];
userId: string;
loading: boolean;
isUserVerified: boolean;
}): JSX.Element {
const cli = useContext(MatrixClientContext);
const [isExpanded, setExpanded] = useState(false);
const deviceTrusts = useAsyncMemo(() => {
const cryptoApi = cli.getCrypto();
if (!cryptoApi) return Promise.resolve(undefined);
return Promise.all(devices.map((d) => cryptoApi.getDeviceVerificationStatus(userId, d.deviceId)));
}, [cli, userId, devices]);
if (loading || deviceTrusts === undefined) {
// still loading
return <Spinner />;
}
const isMe = userId === cli.getUserId();
let expandSectionDevices: IDevice[] = [];
const unverifiedDevices: IDevice[] = [];
let expandCountCaption;
let expandHideCaption;
let expandIconClasses = "mx_E2EIcon";
const dehydratedDeviceIds: string[] = [];
for (const device of devices) {
if (device.dehydrated) {
dehydratedDeviceIds.push(device.deviceId);
}
}
// If the user has exactly one device marked as dehydrated, we consider
// that as the dehydrated device, and hide it as a normal device (but
// indicate that the user is using a dehydrated device). If the user has
// more than one, that is anomalous, and we show all the devices so that
// nothing is hidden.
const dehydratedDeviceId: string | undefined = dehydratedDeviceIds.length == 1 ? dehydratedDeviceIds[0] : undefined;
let dehydratedDeviceInExpandSection = false;
if (isUserVerified) {
for (let i = 0; i < devices.length; ++i) {
const device = devices[i];
const deviceTrust = deviceTrusts[i];
// For your own devices, we use the stricter check of cross-signing
// verification to encourage everyone to trust their own devices via
// cross-signing so that other users can then safely trust you.
// For other people's devices, the more general verified check that
// includes locally verified devices can be used.
const isVerified = deviceTrust && (isMe ? deviceTrust.crossSigningVerified : deviceTrust.isVerified());
if (isVerified) {
// don't show dehydrated device as a normal device, if it's
// verified
if (device.deviceId === dehydratedDeviceId) {
dehydratedDeviceInExpandSection = true;
} else {
expandSectionDevices.push(device);
}
} else {
unverifiedDevices.push(device);
}
}
expandCountCaption = _t("user_info|count_of_verified_sessions", { count: expandSectionDevices.length });
expandHideCaption = _t("user_info|hide_verified_sessions");
expandIconClasses += " mx_E2EIcon_verified";
} else {
if (dehydratedDeviceId) {
devices = devices.filter((device) => device.deviceId !== dehydratedDeviceId);
dehydratedDeviceInExpandSection = true;
}
expandSectionDevices = devices;
expandCountCaption = _t("user_info|count_of_sessions", { count: devices.length });
expandHideCaption = _t("user_info|hide_sessions");
expandIconClasses += " mx_E2EIcon_normal";
}
let expandButton;
if (expandSectionDevices.length) {
if (isExpanded) {
expandButton = (
<AccessibleButton kind="link" className="mx_UserInfo_expand" onClick={() => setExpanded(false)}>
<div>{expandHideCaption}</div>
</AccessibleButton>
);
} else {
expandButton = (
<AccessibleButton kind="link" className="mx_UserInfo_expand" onClick={() => setExpanded(true)}>
<div className={expandIconClasses} />
<div>{expandCountCaption}</div>
</AccessibleButton>
);
}
}
let deviceList = unverifiedDevices.map((device, i) => {
return <DeviceItem key={i} userId={userId} device={device} isUserVerified={isUserVerified} />;
});
if (isExpanded) {
const keyStart = unverifiedDevices.length;
deviceList = deviceList.concat(
expandSectionDevices.map((device, i) => {
return (
<DeviceItem key={i + keyStart} userId={userId} device={device} isUserVerified={isUserVerified} />
);
}),
);
if (dehydratedDeviceInExpandSection) {
deviceList.push(<div>{_t("user_info|dehydrated_device_enabled")}</div>);
}
}
return (
<div className="mx_UserInfo_devices">
<div>{deviceList}</div>
<div>{expandButton}</div>
</div>
);
}
const MessageButton = ({ member }: { member: Member }): JSX.Element => {
const cli = useContext(MatrixClientContext);
const [busy, setBusy] = useState(false);
@@ -1400,12 +1127,84 @@ export const useDevices = (userId: string): IDevice[] | undefined | null => {
return devices;
};
function useHasCrossSigningKeys(cli: MatrixClient, member: User, canVerify: boolean): boolean | undefined {
return useAsyncMemo(async () => {
if (!canVerify) return undefined;
return await cli.getCrypto()?.userHasCrossSigningKeys(member.userId, true);
}, [cli, member, canVerify]);
}
const VerificationSection: React.FC<{
member: User | RoomMember;
devices: IDevice[];
}> = ({ member, devices }) => {
const cli = useContext(MatrixClientContext);
let content;
const homeserverSupportsCrossSigning = useHomeserverSupportsCrossSigning(cli);
const userTrust = useAsyncMemo<UserVerificationStatus | undefined>(
async () => cli.getCrypto()?.getUserVerificationStatus(member.userId),
[member.userId],
// the user verification status is not initialized
undefined,
);
const hasUserVerificationStatus = Boolean(userTrust);
const isUserVerified = Boolean(userTrust?.isVerified());
const isMe = member.userId === cli.getUserId();
const canVerify =
hasUserVerificationStatus &&
homeserverSupportsCrossSigning &&
!isUserVerified &&
!isMe &&
devices &&
devices.length > 0;
const hasCrossSigningKeys = useHasCrossSigningKeys(cli, member as User, canVerify);
if (isUserVerified) {
content = (
<Badge kind="green" className="mx_UserInfo_verified_badge">
<VerifiedIcon className="mx_UserInfo_verified_icon" height="16px" width="16px" />
<Text size="sm" weight="medium" className="mx_UserInfo_verified_label">
{_t("common|verified")}
</Text>
</Badge>
);
} else if (hasCrossSigningKeys === undefined) {
// We are still fetching the cross-signing keys for the user, show spinner.
content = <InlineSpinner size={24} />;
} else if (canVerify && hasCrossSigningKeys) {
content = (
<div className="mx_UserInfo_container_verifyButton">
<Button
className="mx_UserInfo_verify_button"
kind="tertiary"
size="sm"
onClick={() => verifyUser(cli, member as User)}
>
{_t("user_info|verify_button")}
</Button>
</div>
);
} else {
content = (
<Text className="mx_UserInfo_verification_unavailable" size="sm">
({_t("user_info|verification_unavailable")})
</Text>
);
}
return (
<Flex justify="center" align="center" className="mx_UserInfo_verification">
{content}
</Flex>
);
};
const BasicUserInfo: React.FC<{
room: Room;
member: User | RoomMember;
devices: IDevice[];
isRoomEncrypted: boolean;
}> = ({ room, member, devices, isRoomEncrypted }) => {
}> = ({ room, member }) => {
const cli = useContext(MatrixClientContext);
const powerLevels = useRoomPowerLevels(cli, room);
@@ -1503,111 +1302,10 @@ const BasicUserInfo: React.FC<{
spinner = <Spinner />;
}
// only display the devices list if our client supports E2E
const cryptoEnabled = Boolean(cli.getCrypto());
let text;
if (!isRoomEncrypted) {
if (!cryptoEnabled) {
text = _t("encryption|unsupported");
} else if (room && !room.isSpaceRoom()) {
text = _t("user_info|room_unencrypted");
}
} else if (!room.isSpaceRoom()) {
text = _t("user_info|room_encrypted");
}
let verifyButton;
const homeserverSupportsCrossSigning = useHomeserverSupportsCrossSigning(cli);
const userTrust = useAsyncMemo<UserVerificationStatus | undefined>(
async () => cli.getCrypto()?.getUserVerificationStatus(member.userId),
[member.userId],
// the user verification status is not initialized
undefined,
);
const hasUserVerificationStatus = Boolean(userTrust);
const isUserVerified = Boolean(userTrust?.isVerified());
const isMe = member.userId === cli.getUserId();
const canVerify =
hasUserVerificationStatus &&
homeserverSupportsCrossSigning &&
!isUserVerified &&
!isMe &&
devices &&
devices.length > 0;
const setUpdating: SetUpdating = (updating) => {
setPendingUpdateCount((count) => count + (updating ? 1 : -1));
};
const hasCrossSigningKeys = useHasCrossSigningKeys(cli, member as User, canVerify, setUpdating);
// Display the spinner only when
// - the devices are not populated yet, or
// - the crypto is available and we don't have the user verification status yet
const showDeviceListSpinner = (cryptoEnabled && !hasUserVerificationStatus) || devices === undefined;
if (canVerify) {
if (hasCrossSigningKeys !== undefined) {
// Note: mx_UserInfo_verifyButton is for the end-to-end tests
verifyButton = (
<div className="mx_UserInfo_container_verifyButton">
<AccessibleButton
kind="link"
className="mx_UserInfo_field mx_UserInfo_verifyButton"
onClick={() => verifyUser(cli, member as User)}
>
{_t("action|verify")}
</AccessibleButton>
</div>
);
} else if (!showDeviceListSpinner) {
// HACK: only show a spinner if the device section spinner is not shown,
// to avoid showing a double spinner
// We should ask for a design that includes all the different loading states here
verifyButton = <Spinner />;
}
}
let editDevices;
if (member.userId == cli.getUserId()) {
editDevices = (
<div>
<AccessibleButton
kind="link"
className="mx_UserInfo_field"
onClick={() => {
dis.dispatch({
action: Action.ViewUserDeviceSettings,
});
}}
>
{_t("user_info|edit_own_devices")}
</AccessibleButton>
</div>
);
}
const securitySection = (
<Container>
<h2>{_t("common|security")}</h2>
<p>{text}</p>
{verifyButton}
{cryptoEnabled && (
<DevicesSection
loading={showDeviceListSpinner}
devices={devices}
userId={member.userId}
isUserVerified={isUserVerified}
/>
)}
{editDevices}
</Container>
);
return (
<React.Fragment>
{securitySection}
<UserOptionsSection
canInvite={roomPermissions.canInvite}
member={member as RoomMember}
@@ -1615,15 +1313,12 @@ const BasicUserInfo: React.FC<{
>
{memberDetails}
</UserOptionsSection>
{adminToolsContainer}
{!isMe && (
<Container>
<IgnoreToggleButton member={member} />
</Container>
)}
{spinner}
</React.Fragment>
);
@@ -1633,9 +1328,10 @@ export type Member = User | RoomMember;
export const UserInfoHeader: React.FC<{
member: Member;
e2eStatus?: E2EStatus;
devices: IDevice[];
roomId?: string;
}> = ({ member, e2eStatus, roomId }) => {
hideVerificationSection?: boolean;
}> = ({ member, devices, roomId, hideVerificationSection }) => {
const cli = useContext(MatrixClientContext);
const onMemberAvatarClick = useCallback(() => {
@@ -1686,7 +1382,6 @@ export const UserInfoHeader: React.FC<{
const timezoneInfo = useUserTimezone(cli, member.userId);
const e2eIcon = e2eStatus ? <E2EIcon size={18} status={e2eStatus} isUser={true} /> : null;
const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, {
roomId,
withDisplayName: true,
@@ -1715,7 +1410,6 @@ export const UserInfoHeader: React.FC<{
<Heading size="sm" weight="semibold" as="h1" dir="auto">
<Flex className="mx_UserInfo_profile_name" direction="row-reverse" align="center">
{displayName}
{e2eIcon}
</Flex>
</Heading>
{presenceLabel}
@@ -1734,6 +1428,7 @@ export const UserInfoHeader: React.FC<{
</CopyableText>
</Text>
</Flex>
{!hideVerificationSection && <VerificationSection member={member} devices={devices} />}
</Container>
</React.Fragment>
);
@@ -1757,13 +1452,6 @@ const UserInfo: React.FC<IProps> = ({ user, room, onClose, phase = RightPanelPha
const isRoomEncrypted = useIsEncrypted(cli, room);
const devices = useDevices(user.userId) ?? [];
const e2eStatus = useAsyncMemo(async () => {
if (!isRoomEncrypted || !devices) {
return undefined;
}
return await getE2EStatus(cli, user.userId, devices);
}, [cli, isRoomEncrypted, user.userId, devices]);
const classes = ["mx_UserInfo"];
let cardState: IRightPanelCardState = {};
@@ -1779,14 +1467,7 @@ const UserInfo: React.FC<IProps> = ({ user, room, onClose, phase = RightPanelPha
let content: JSX.Element | undefined;
switch (phase) {
case RightPanelPhases.MemberInfo:
content = (
<BasicUserInfo
room={room as Room}
member={member as User}
devices={devices}
isRoomEncrypted={Boolean(isRoomEncrypted)}
/>
);
content = <BasicUserInfo room={room as Room} member={member as User} />;
break;
case RightPanelPhases.EncryptionPanel:
classes.push("mx_UserInfo_smallAvatar");
@@ -1811,7 +1492,12 @@ const UserInfo: React.FC<IProps> = ({ user, room, onClose, phase = RightPanelPha
const header = (
<>
<UserInfoHeader member={member} e2eStatus={e2eStatus} roomId={room?.roomId} />
<UserInfoHeader
hideVerificationSection={phase === RightPanelPhases.EncryptionPanel}
member={member}
devices={devices}
roomId={room?.roomId}
/>
</>
);

View File

@@ -548,7 +548,6 @@
"saved": "Saved",
"saving": "Saving…",
"secure_backup": "Secure Backup",
"security": "Security",
"select_all": "Select all",
"server": "Server",
"settings": "Settings",
@@ -567,7 +566,6 @@
"thread": "Thread",
"threads": "Threads",
"timeline": "Timeline",
"trusted": "Trusted",
"unavailable": "unavailable",
"unencrypted": "Not encrypted",
"unmute": "Unmute",
@@ -971,7 +969,6 @@
"title": "Not Trusted"
},
"unable_to_setup_keys_error": "Unable to set up keys",
"unsupported": "This client does not support end-to-end encryption.",
"verification": {
"accepting": "Accepting…",
"after_new_login": {
@@ -3787,18 +3784,9 @@
"ban_room_confirm_title": "Ban from %(roomName)s",
"ban_space_everything": "Ban them from everything I'm able to",
"ban_space_specific": "Ban them from specific things I'm able to",
"count_of_sessions": {
"one": "%(count)s session",
"other": "%(count)s sessions"
},
"count_of_verified_sessions": {
"one": "1 verified session",
"other": "%(count)s verified sessions"
},
"deactivate_confirm_action": "Deactivate user",
"deactivate_confirm_description": "Deactivating this user will log them out and prevent them from logging back in. Additionally, they will leave all the rooms they are in. This action cannot be reversed. Are you sure you want to deactivate this user?",
"deactivate_confirm_title": "Deactivate user?",
"dehydrated_device_enabled": "Offline device enabled",
"demote_button": "Demote",
"demote_self_confirm_description_space": "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.",
"demote_self_confirm_room": "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.",
@@ -3806,15 +3794,12 @@
"disinvite_button_room": "Disinvite from room",
"disinvite_button_room_name": "Disinvite from %(roomName)s",
"disinvite_button_space": "Disinvite from space",
"edit_own_devices": "Edit devices",
"error_ban_user": "Failed to ban user",
"error_deactivate": "Failed to deactivate user",
"error_kicking_user": "Failed to remove user",
"error_mute_user": "Failed to mute user",
"error_revoke_3pid_invite_description": "Could not revoke the invite. The server may be experiencing a temporary problem or you do not have sufficient permissions to revoke the invite.",
"error_revoke_3pid_invite_title": "Failed to revoke invite",
"hide_sessions": "Hide sessions",
"hide_verified_sessions": "Hide verified sessions",
"ignore_button": "Ignore",
"ignore_confirm_description": "All messages and invites from this user will be hidden. Are you sure you want to ignore them?",
"ignore_confirm_title": "Ignore %(user)s",
@@ -3858,6 +3843,7 @@
"unban_space_specific": "Unban them from specific things I'm able to",
"unban_space_warning": "They won't be able to access whatever you're not an admin of.",
"unignore_button": "Unignore",
"verification_unavailable": "User verification unavailable",
"verify_button": "Verify User",
"verify_explainer": "For extra security, verify this user by checking a one-time code on both of your devices."
},

View File

@@ -10,11 +10,8 @@ import { type User, type MatrixClient, type RoomMember } from "matrix-js-sdk/src
import { CrossSigningKey, type VerificationRequest } from "matrix-js-sdk/src/crypto-api";
import dis from "./dispatcher/dispatcher";
import Modal from "./Modal";
import { RightPanelPhases } from "./stores/right-panel/RightPanelStorePhases";
import { accessSecretStorage } from "./SecurityManager";
import UntrustedDeviceDialog from "./components/views/dialogs/UntrustedDeviceDialog";
import { type IDevice } from "./components/views/right_panel/UserInfo";
import RightPanelStore from "./stores/right-panel/RightPanelStore";
import { type IRightPanelCardState } from "./stores/right-panel/RightPanelStoreIPanelState";
import { findDMForUser } from "./utils/dm/findDMForUser";
@@ -31,32 +28,6 @@ async function enable4SIfNeeded(matrixClient: MatrixClient): Promise<boolean> {
return true;
}
export async function verifyDevice(matrixClient: MatrixClient, user: User, device: IDevice): Promise<void> {
if (matrixClient.isGuest()) {
dis.dispatch({ action: "require_registration" });
return;
}
// if cross-signing is not explicitly disabled, check if it should be enabled first.
if (matrixClient.getCrypto()?.getTrustCrossSignedDevices()) {
if (!(await enable4SIfNeeded(matrixClient))) {
return;
}
}
Modal.createDialog(UntrustedDeviceDialog, {
user,
device,
onFinished: async (action): Promise<void> => {
if (action === "sas") {
const verificationRequestPromise = matrixClient
.getCrypto()
?.requestDeviceVerification(user.userId, device.deviceId);
setRightPanel({ member: user, verificationRequestPromise });
}
},
});
}
export async function verifyUser(matrixClient: MatrixClient, user: User): Promise<void> {
if (matrixClient.isGuest()) {
dis.dispatch({ action: "require_registration" });

View File

@@ -16,15 +16,15 @@ import { _t } from "../../../../src/languageHandler";
describe("<TabbedView />", () => {
const generalTab = new Tab("GENERAL", "common|general", "general", <div>general</div>);
const labsTab = new Tab("LABS", "common|labs", "labs", <div>labs</div>);
const securityTab = new Tab("SECURITY", "common|security", "security", <div>security</div>);
const appearanceTab = new Tab("APPEARANCE", "common|appearance", "appearance", <div>appearance</div>);
const defaultProps = {
tabLocation: TabLocation.LEFT,
tabs: [generalTab, labsTab, securityTab] as NonEmptyArray<Tab<any>>,
tabs: [generalTab, labsTab, appearanceTab] as NonEmptyArray<Tab<any>>,
onChange: () => {},
};
const getComponent = (
props: {
activeTabId: "GENERAL" | "LABS" | "SECURITY";
activeTabId: "GENERAL" | "LABS" | "APPEARANCE";
onChange?: () => any;
tabs?: NonEmptyArray<Tab<any>>;
} = {
@@ -44,9 +44,9 @@ describe("<TabbedView />", () => {
});
it("renders activeTabId tab as active when valid", () => {
const { container } = render(getComponent({ activeTabId: securityTab.id }));
expect(getActiveTab(container)?.textContent).toEqual(_t(securityTab.label));
expect(getActiveTabBody(container)?.textContent).toEqual("security");
const { container } = render(getComponent({ activeTabId: appearanceTab.id }));
expect(getActiveTab(container)?.textContent).toEqual(_t(appearanceTab.label));
expect(getActiveTabBody(container)?.textContent).toEqual("appearance");
});
it("calls onchange on on tab click", () => {
@@ -54,10 +54,10 @@ describe("<TabbedView />", () => {
const { getByTestId } = render(getComponent({ activeTabId: "GENERAL", onChange }));
act(() => {
fireEvent.click(getByTestId(getTabTestId(securityTab)));
fireEvent.click(getByTestId(getTabTestId(appearanceTab)));
});
expect(onChange).toHaveBeenCalledWith(securityTab.id);
expect(onChange).toHaveBeenCalledWith(appearanceTab.id);
});
it("keeps same tab active when order of tabs changes", () => {
@@ -66,7 +66,7 @@ describe("<TabbedView />", () => {
expect(getActiveTab(container)?.textContent).toEqual(_t(labsTab.label));
rerender(getComponent({ tabs: [labsTab, generalTab, securityTab], activeTabId: labsTab.id }));
rerender(getComponent({ tabs: [labsTab, generalTab, appearanceTab], activeTabId: labsTab.id }));
// labs tab still active
expect(getActiveTab(container)?.textContent).toEqual(_t(labsTab.label));

View File

@@ -47,21 +47,21 @@ exports[`<TabbedView /> renders tabs 1`] = `
</span>
</li>
<li
aria-controls="mx_tabpanel_SECURITY"
aria-controls="mx_tabpanel_APPEARANCE"
aria-selected="false"
class="mx_AccessibleButton mx_TabbedView_tabLabel"
data-testid="settings-tab-SECURITY"
data-testid="settings-tab-APPEARANCE"
role="tab"
tabindex="-1"
>
<span
class="mx_TabbedView_maskedIcon security"
class="mx_TabbedView_maskedIcon appearance"
/>
<span
class="mx_TabbedView_tabLabel_text"
id="mx_tabpanel_SECURITY_label"
id="mx_tabpanel_APPEARANCE_label"
>
Security
Appearance
</span>
</li>
</ul>

View File

@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { fireEvent, render, screen, cleanup, act, within, waitForElementToBeRemoved } from "jest-matrix-react";
import { fireEvent, render, screen, cleanup, act, waitForElementToBeRemoved, waitFor } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { type Mocked, mocked } from "jest-mock";
import {
@@ -28,12 +28,10 @@ import {
VerificationPhase as Phase,
VerificationRequestEvent,
type CryptoApi,
type DeviceVerificationStatus,
} from "matrix-js-sdk/src/crypto-api";
import UserInfo, {
BanToggleButton,
DeviceItem,
disambiguateDevices,
getPowerLevels,
isMuted,
@@ -48,9 +46,7 @@ import { RightPanelPhases } from "../../../../../src/stores/right-panel/RightPan
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import MultiInviter from "../../../../../src/utils/MultiInviter";
import * as mockVerification from "../../../../../src/verification";
import Modal from "../../../../../src/Modal";
import { E2EStatus } from "../../../../../src/utils/ShieldUtils";
import { DirectoryMember, startDmOnFirstMessage } from "../../../../../src/utils/direct-messages";
import { clearAllModals, flushPromises } from "../../../../test-utils";
import ErrorDialog from "../../../../../src/components/views/dialogs/ErrorDialog";
@@ -445,20 +441,6 @@ describe("<UserInfo />", () => {
mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap);
});
it("renders a device list which can be expanded", async () => {
renderComponent();
await flushPromises();
// check the button exists with the expected text
const devicesButton = screen.getByRole("button", { name: "1 session" });
// click it
await userEvent.click(devicesButton);
// there should now be a button with the device id which should contain the device name
expect(screen.getByRole("button", { name: "my device" })).toBeInTheDocument();
});
it("renders <BasicUserInfo />", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false));
@@ -468,190 +450,9 @@ describe("<UserInfo />", () => {
room: mockRoom,
});
await flushPromises();
await expect(screen.findByRole("button", { name: "Verify" })).resolves.toBeInTheDocument();
expect(container).toMatchSnapshot();
});
describe("device dehydration", () => {
it("hides a verified dehydrated device (unverified user)", async () => {
const device1 = new Device({
deviceId: "d1",
userId: defaultUserId,
displayName: "my device",
algorithms: [],
keys: new Map(),
});
const device2 = new Device({
deviceId: "d2",
userId: defaultUserId,
displayName: "dehydrated device",
algorithms: [],
keys: new Map(),
dehydrated: true,
});
const devicesMap = new Map<string, Device>([
[device1.deviceId, device1],
[device2.deviceId, device2],
]);
const userDeviceMap = new Map<string, Map<string, Device>>([[defaultUserId, devicesMap]]);
mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap);
renderComponent({ room: mockRoom });
await flushPromises();
// check the button exists with the expected text (the dehydrated device shouldn't be counted)
const devicesButton = screen.getByRole("button", { name: "1 session" });
// click it
await act(() => {
return userEvent.click(devicesButton);
});
// there should now be a button with the non-dehydrated device ID
expect(screen.getByRole("button", { name: "my device" })).toBeInTheDocument();
// but not for the dehydrated device ID
expect(screen.queryByRole("button", { name: "dehydrated device" })).not.toBeInTheDocument();
// there should be a line saying that the user has "Offline device" enabled
expect(screen.getByText("Offline device enabled")).toBeInTheDocument();
});
it("hides a verified dehydrated device (verified user)", async () => {
const device1 = new Device({
deviceId: "d1",
userId: defaultUserId,
displayName: "my device",
algorithms: [],
keys: new Map(),
});
const device2 = new Device({
deviceId: "d2",
userId: defaultUserId,
displayName: "dehydrated device",
algorithms: [],
keys: new Map(),
dehydrated: true,
});
const devicesMap = new Map<string, Device>([
[device1.deviceId, device1],
[device2.deviceId, device2],
]);
const userDeviceMap = new Map<string, Map<string, Device>>([[defaultUserId, devicesMap]]);
mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap);
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(true, true, true));
mockCrypto.getDeviceVerificationStatus.mockResolvedValue({
isVerified: () => true,
} as DeviceVerificationStatus);
renderComponent({ room: mockRoom });
await flushPromises();
// check the button exists with the expected text (the dehydrated device shouldn't be counted)
const devicesButton = screen.getByRole("button", { name: "1 verified session" });
// click it
await act(() => {
return userEvent.click(devicesButton);
});
// there should now be a button with the non-dehydrated device ID
expect(screen.getByTitle("d1")).toBeInTheDocument();
// but not for the dehydrated device ID
expect(screen.queryByTitle("d2")).not.toBeInTheDocument();
// there should be a line saying that the user has "Offline device" enabled
expect(screen.getByText("Offline device enabled")).toBeInTheDocument();
});
it("shows an unverified dehydrated device", async () => {
const device1 = new Device({
deviceId: "d1",
userId: defaultUserId,
displayName: "my device",
algorithms: [],
keys: new Map(),
});
const device2 = new Device({
deviceId: "d2",
userId: defaultUserId,
displayName: "dehydrated device",
algorithms: [],
keys: new Map(),
dehydrated: true,
});
const devicesMap = new Map<string, Device>([
[device1.deviceId, device1],
[device2.deviceId, device2],
]);
const userDeviceMap = new Map<string, Map<string, Device>>([[defaultUserId, devicesMap]]);
mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap);
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(true, true, true));
renderComponent({ room: mockRoom });
await flushPromises();
// the dehydrated device should be shown as an unverified device, which means
// there should now be a button with the device id ...
const deviceButton = screen.getByRole("button", { name: "dehydrated device" });
// ... which should contain the device name
expect(within(deviceButton).getByText("dehydrated device")).toBeInTheDocument();
});
it("shows dehydrated devices if there is more than one", async () => {
const device1 = new Device({
deviceId: "d1",
userId: defaultUserId,
displayName: "dehydrated device 1",
algorithms: [],
keys: new Map(),
dehydrated: true,
});
const device2 = new Device({
deviceId: "d2",
userId: defaultUserId,
displayName: "dehydrated device 2",
algorithms: [],
keys: new Map(),
dehydrated: true,
});
const devicesMap = new Map<string, Device>([
[device1.deviceId, device1],
[device2.deviceId, device2],
]);
const userDeviceMap = new Map<string, Map<string, Device>>([[defaultUserId, devicesMap]]);
mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap);
renderComponent({ room: mockRoom });
await flushPromises();
// check the button exists with the expected text (the dehydrated device shouldn't be counted)
const devicesButton = screen.getByRole("button", { name: "2 sessions" });
// click it
await act(() => {
return userEvent.click(devicesButton);
});
// the dehydrated devices should be shown as an unverified device, which means
// there should now be a button with the first dehydrated device...
const device1Button = screen.getByRole("button", { name: "dehydrated device 1" });
expect(device1Button).toBeVisible();
// ... which should contain the device name
expect(within(device1Button).getByText("dehydrated device 1")).toBeInTheDocument();
// and a button with the second dehydrated device...
const device2Button = screen.getByRole("button", { name: "dehydrated device 2" });
expect(device2Button).toBeVisible();
// ... which should contain the device name
expect(within(device2Button).getByText("dehydrated device 2")).toBeInTheDocument();
});
});
it("should render a deactivate button for users of the same server if we are a server admin", async () => {
mockClient.isSynapseAdministrator.mockResolvedValue(true);
mockClient.getDomain.mockReturnValue("example.com");
@@ -668,34 +469,6 @@ describe("<UserInfo />", () => {
expect(container).toMatchSnapshot();
});
});
describe("with an encrypted room", () => {
beforeEach(() => {
jest.spyOn(mockClient.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true);
});
it("renders unverified user info", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false));
renderComponent({ room: mockRoom });
await flushPromises();
const userHeading = screen.getByRole("heading", { name: /@user:example.com/ });
// there should be a "normal" E2E padlock
expect(userHeading.getElementsByClassName("mx_E2EIcon_normal")).toHaveLength(1);
});
it("renders verified user info", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(true, false, false));
renderComponent({ room: mockRoom });
await flushPromises();
const userHeading = screen.getByRole("heading", { name: /@user:example.com/ });
// there should be a "verified" E2E padlock
expect(userHeading.getElementsByClassName("mx_E2EIcon_verified")).toHaveLength(1);
});
});
});
describe("<UserInfoHeader />", () => {
@@ -707,179 +480,51 @@ describe("<UserInfoHeader />", () => {
};
const renderComponent = (props = {}) => {
const device1 = new Device({
deviceId: "d1",
userId: defaultUserId,
displayName: "my device",
algorithms: [],
keys: new Map(),
});
const devicesMap = new Map<string, Device>([[device1.deviceId, device1]]);
const userDeviceMap = new Map<string, Map<string, Device>>([[defaultUserId, devicesMap]]);
mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap);
mockClient.doesServerSupportUnstableFeature.mockResolvedValue(true);
const Wrapper = (wrapperProps = {}) => {
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
};
return render(<UserInfoHeader {...defaultProps} {...props} />, {
return render(<UserInfoHeader {...defaultProps} {...props} devices={[device1]} />, {
wrapper: Wrapper,
});
};
it("does not render an e2e icon in the header if e2eStatus prop is undefined", () => {
renderComponent();
const header = screen.getByRole("heading", { name: defaultUserId });
expect(header.getElementsByClassName("mx_E2EIcon")).toHaveLength(0);
});
it("renders an e2e icon in the header if e2eStatus prop is defined", () => {
renderComponent({ e2eStatus: E2EStatus.Normal });
const header = screen.getByRole("heading");
expect(header.getElementsByClassName("mx_E2EIcon")).toHaveLength(1);
});
it("renders custom user identifiers in the header", () => {
renderComponent();
expect(screen.getByText("customUserIdentifier")).toBeInTheDocument();
});
});
describe("<DeviceItem />", () => {
const device = { deviceId: "deviceId", displayName: "deviceName" } as Device;
const defaultProps = {
userId: defaultUserId,
device,
isUserVerified: false,
};
const renderComponent = (props = {}) => {
const Wrapper = (wrapperProps = {}) => {
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
};
return render(<DeviceItem {...defaultProps} {...props} />, {
wrapper: Wrapper,
});
};
const setMockDeviceTrust = (isVerified = false, isCrossSigningVerified = false) => {
mockCrypto.getDeviceVerificationStatus.mockResolvedValue({
isVerified: () => isVerified,
crossSigningVerified: isCrossSigningVerified,
} as DeviceVerificationStatus);
};
const mockVerifyDevice = jest.spyOn(mockVerification, "verifyDevice");
beforeEach(() => {
setMockDeviceTrust();
});
afterEach(() => {
mockCrypto.getDeviceVerificationStatus.mockReset();
mockVerifyDevice.mockClear();
});
afterAll(() => {
mockVerifyDevice.mockRestore();
});
it("with unverified user and device, displays button without a label", async () => {
renderComponent();
await flushPromises();
expect(screen.getByRole("button", { name: device.displayName! })).toBeInTheDocument();
expect(screen.queryByText(/trusted/i)).not.toBeInTheDocument();
});
it("with verified user only, displays button with a 'Not trusted' label", async () => {
renderComponent({ isUserVerified: true });
await flushPromises();
const button = screen.getByRole("button", { name: device.displayName });
expect(button).toHaveTextContent(`${device.displayName}Not trusted`);
});
it("with verified device only, displays no button without a label", async () => {
setMockDeviceTrust(true);
renderComponent();
await flushPromises();
expect(screen.getByText(device.displayName!)).toBeInTheDocument();
expect(screen.queryByText(/trusted/)).not.toBeInTheDocument();
});
it("when userId is the same as userId from client, uses isCrossSigningVerified to determine if button is shown", async () => {
const deferred = defer<DeviceVerificationStatus>();
mockCrypto.getDeviceVerificationStatus.mockReturnValue(deferred.promise);
mockClient.getSafeUserId.mockReturnValueOnce(defaultUserId);
mockClient.getUserId.mockReturnValueOnce(defaultUserId);
renderComponent();
await flushPromises();
// set trust to be false for isVerified, true for isCrossSigningVerified
deferred.resolve({
isVerified: () => false,
crossSigningVerified: true,
} as DeviceVerificationStatus);
await expect(screen.findByText(device.displayName!)).resolves.toBeInTheDocument();
// expect to see no button in this case
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("with verified user and device, displays no button and a 'Trusted' label", async () => {
setMockDeviceTrust(true);
renderComponent({ isUserVerified: true });
await flushPromises();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
expect(screen.getByText(device.displayName!)).toBeInTheDocument();
expect(screen.getByText("Trusted")).toBeInTheDocument();
});
it("does not call verifyDevice if client.getUser returns null", async () => {
mockClient.getUser.mockReturnValueOnce(null);
renderComponent();
await flushPromises();
const button = screen.getByRole("button", { name: device.displayName! });
expect(button).toBeInTheDocument();
await userEvent.click(button);
expect(mockVerifyDevice).not.toHaveBeenCalled();
});
it("calls verifyDevice if client.getUser returns an object", async () => {
mockClient.getUser.mockReturnValueOnce(defaultUser);
// set mock return of isGuest to short circuit verifyDevice call to avoid
// even more mocking
mockClient.isGuest.mockReturnValueOnce(true);
renderComponent();
await flushPromises();
const button = screen.getByRole("button", { name: device.displayName! });
expect(button).toBeInTheDocument();
await userEvent.click(button);
expect(mockVerifyDevice).toHaveBeenCalledTimes(1);
expect(mockVerifyDevice).toHaveBeenCalledWith(mockClient, defaultUser, device);
});
it("with display name", async () => {
it("renders verified badge when user is verified", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(true, true, false));
const { container } = renderComponent();
await flushPromises();
await waitFor(() => expect(screen.getByText("Verified")).toBeInTheDocument());
expect(container).toMatchSnapshot();
});
it("without display name", async () => {
const device = { deviceId: "deviceId" } as Device;
const { container } = renderComponent({ device, userId: defaultUserId });
await flushPromises();
it("renders verify button", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false));
mockCrypto.userHasCrossSigningKeys.mockResolvedValue(true);
const { container } = renderComponent();
await waitFor(() => expect(screen.getByText("Verify User")).toBeInTheDocument());
expect(container).toMatchSnapshot();
});
it("ambiguous display name", async () => {
const device = { deviceId: "deviceId", ambiguous: true, displayName: "my display name" };
const { container } = renderComponent({ device, userId: defaultUserId });
await flushPromises();
it("renders verification unavailable message", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false));
mockCrypto.userHasCrossSigningKeys.mockResolvedValue(false);
const { container } = renderComponent();
await waitFor(() => expect(screen.getByText("(User verification unavailable)")).toBeInTheDocument());
expect(container).toMatchSnapshot();
});
});

View File

@@ -1,74 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<DeviceItem /> ambiguous display name 1`] = `
<div>
<div
aria-label="my display name (deviceId)"
class="mx_AccessibleButton mx_UserInfo_device mx_UserInfo_device_unverified"
role="button"
tabindex="0"
>
<div
class="mx_E2EIcon mx_E2EIcon_normal"
/>
<div
class="mx_UserInfo_device_name"
>
my display name (deviceId)
</div>
<div
class="mx_UserInfo_device_trusted"
/>
</div>
</div>
`;
exports[`<DeviceItem /> with display name 1`] = `
<div>
<div
aria-label="deviceName"
class="mx_AccessibleButton mx_UserInfo_device mx_UserInfo_device_unverified"
role="button"
tabindex="0"
>
<div
class="mx_E2EIcon mx_E2EIcon_normal"
/>
<div
class="mx_UserInfo_device_name"
>
deviceName
</div>
<div
class="mx_UserInfo_device_trusted"
/>
</div>
</div>
`;
exports[`<DeviceItem /> without display name 1`] = `
<div>
<div
aria-label="deviceId"
class="mx_AccessibleButton mx_UserInfo_device mx_UserInfo_device_unverified"
role="button"
tabindex="0"
>
<div
class="mx_E2EIcon mx_E2EIcon_normal"
/>
<div
class="mx_UserInfo_device_name"
>
deviceId
</div>
<div
class="mx_UserInfo_device_trusted"
/>
</div>
</div>
`;
exports[`<UserInfo /> with crypto enabled renders <BasicUserInfo /> 1`] = `
<div>
<div
@@ -88,7 +19,7 @@ exports[`<UserInfo /> with crypto enabled renders <BasicUserInfo /> 1`] = `
</p>
</div>
<button
aria-labelledby=":r74:"
aria-labelledby=":r6m:"
class="_icon-button_bh2qc_17 _subtle-bg_bh2qc_38"
data-testid="base-card-close-button"
role="button"
@@ -180,45 +111,17 @@ exports[`<UserInfo /> with crypto enabled renders <BasicUserInfo /> 1`] = `
</div>
</p>
</div>
</div>
<div
class="mx_UserInfo_container"
class="mx_Flex mx_UserInfo_verification"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0;"
>
<h2>
Security
</h2>
<p>
Messages in this room are not end-to-end encrypted.
<p
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40 mx_UserInfo_verification_unavailable"
>
(
User verification unavailable
)
</p>
<div
class="mx_UserInfo_container_verifyButton"
>
<div
class="mx_AccessibleButton mx_UserInfo_field mx_UserInfo_verifyButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link"
role="button"
tabindex="0"
>
Verify
</div>
</div>
<div
class="mx_UserInfo_devices"
>
<div />
<div>
<div
class="mx_AccessibleButton mx_UserInfo_expand mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link"
role="button"
tabindex="0"
>
<div
class="mx_E2EIcon mx_E2EIcon_normal"
/>
<div>
1 session
</div>
</div>
</div>
</div>
</div>
<div
@@ -402,7 +305,7 @@ exports[`<UserInfo /> with crypto enabled should render a deactivate button for
</p>
</div>
<button
aria-labelledby=":r9a:"
aria-labelledby=":r70:"
class="_icon-button_bh2qc_17 _subtle-bg_bh2qc_38"
data-testid="base-card-close-button"
role="button"
@@ -494,45 +397,25 @@ exports[`<UserInfo /> with crypto enabled should render a deactivate button for
</div>
</p>
</div>
</div>
<div
class="mx_UserInfo_container"
class="mx_Flex mx_UserInfo_verification"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0;"
>
<h2>
Security
</h2>
<p>
Messages in this room are not end-to-end encrypted.
</p>
<div
class="mx_UserInfo_container_verifyButton"
<svg
class="_icon_1ye7b_27"
fill="currentColor"
height="1em"
style="width: 24px; height: 24px;"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<div
class="mx_AccessibleButton mx_UserInfo_field mx_UserInfo_verifyButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link"
role="button"
tabindex="0"
>
Verify
</div>
</div>
<div
class="mx_UserInfo_devices"
>
<div />
<div>
<div
class="mx_AccessibleButton mx_UserInfo_expand mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link"
role="button"
tabindex="0"
>
<div
class="mx_E2EIcon mx_E2EIcon_normal"
<path
clip-rule="evenodd"
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2Z"
fill-rule="evenodd"
/>
<div>
1 session
</div>
</div>
</div>
</svg>
</div>
</div>
<div
@@ -737,3 +620,270 @@ exports[`<UserInfo /> with crypto enabled should render a deactivate button for
</div>
</div>
`;
exports[`<UserInfoHeader /> renders verification unavailable message 1`] = `
<div>
<div
class="mx_UserInfo_avatar"
>
<div
class="mx_UserInfo_avatar_transition"
>
<div
class="mx_UserInfo_avatar_transition_child"
>
<button
aria-label="Profile picture"
aria-live="off"
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="button"
style="--cpd-avatar-size: 120px;"
title="customUserIdentifier"
>
u
</button>
</div>
</div>
</div>
<div
class="mx_UserInfo_container mx_UserInfo_header"
>
<div
class="mx_Flex mx_UserInfo_profile"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
>
<h1
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
dir="auto"
>
<div
class="mx_Flex mx_UserInfo_profile_name"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
>
@user:example.com
</div>
</h1>
<div
class="mx_PresenceLabel mx_UserInfo_profileStatus"
>
Unknown
</div>
<p
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 mx_UserInfo_profile_mxid"
>
<div
class="mx_CopyableText"
>
customUserIdentifier
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</p>
</div>
<div
class="mx_Flex mx_UserInfo_verification"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0;"
>
<p
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40 mx_UserInfo_verification_unavailable"
>
(
User verification unavailable
)
</p>
</div>
</div>
</div>
`;
exports[`<UserInfoHeader /> renders verified badge when user is verified 1`] = `
<div>
<div
class="mx_UserInfo_avatar"
>
<div
class="mx_UserInfo_avatar_transition"
>
<div
class="mx_UserInfo_avatar_transition_child"
>
<button
aria-label="Profile picture"
aria-live="off"
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="button"
style="--cpd-avatar-size: 120px;"
title="customUserIdentifier"
>
u
</button>
</div>
</div>
</div>
<div
class="mx_UserInfo_container mx_UserInfo_header"
>
<div
class="mx_Flex mx_UserInfo_profile"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
>
<h1
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
dir="auto"
>
<div
class="mx_Flex mx_UserInfo_profile_name"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
>
@user:example.com
</div>
</h1>
<div
class="mx_PresenceLabel mx_UserInfo_profileStatus"
>
Unknown
</div>
<p
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 mx_UserInfo_profile_mxid"
>
<div
class="mx_CopyableText"
>
customUserIdentifier
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</p>
</div>
<div
class="mx_Flex mx_UserInfo_verification"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0;"
>
<span
class="_typography_yh5dq_162 _font-body-sm-medium_yh5dq_50 _badge_1171v_17 mx_UserInfo_verified_badge"
data-kind="green"
>
<svg
class="mx_UserInfo_verified_icon"
fill="currentColor"
height="16px"
viewBox="0 0 24 24"
width="16px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.15 21.75 6.7 19.3l-2.75-.6a.943.943 0 0 1-.6-.387.928.928 0 0 1-.175-.688L3.45 14.8l-1.875-2.15a.934.934 0 0 1-.25-.65c0-.25.083-.467.25-.65L3.45 9.2l-.275-2.825a.928.928 0 0 1 .175-.688.943.943 0 0 1 .6-.387l2.75-.6 1.45-2.45a.983.983 0 0 1 .55-.438.97.97 0 0 1 .7.038l2.6 1.1 2.6-1.1a.97.97 0 0 1 .7-.038.983.983 0 0 1 .55.438L17.3 4.7l2.75.6c.25.05.45.18.6.388.15.208.208.437.175.687L20.55 9.2l1.875 2.15c.167.183.25.4.25.65s-.083.467-.25.65L20.55 14.8l.275 2.825a.928.928 0 0 1-.175.688.943.943 0 0 1-.6.387l-2.75.6-1.45 2.45a.983.983 0 0 1-.55.438.97.97 0 0 1-.7-.038l-2.6-1.1-2.6 1.1a.97.97 0 0 1-.7.038.983.983 0 0 1-.55-.438Zm2.8-9.05L9.5 11.275A.933.933 0 0 0 8.813 11c-.275 0-.513.1-.713.3a.948.948 0 0 0-.275.7.95.95 0 0 0 .275.7l2.15 2.15c.2.2.433.3.7.3.267 0 .5-.1.7-.3l4.25-4.25c.2-.2.296-.433.287-.7a1.055 1.055 0 0 0-.287-.7 1.02 1.02 0 0 0-.713-.313.93.93 0 0 0-.712.288L10.95 12.7Z"
/>
</svg>
<p
class="_typography_yh5dq_162 _font-body-sm-medium_yh5dq_50 mx_UserInfo_verified_label"
>
Verified
</p>
</span>
</div>
</div>
</div>
`;
exports[`<UserInfoHeader /> renders verify button 1`] = `
<div>
<div
class="mx_UserInfo_avatar"
>
<div
class="mx_UserInfo_avatar_transition"
>
<div
class="mx_UserInfo_avatar_transition_child"
>
<button
aria-label="Profile picture"
aria-live="off"
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="button"
style="--cpd-avatar-size: 120px;"
title="customUserIdentifier"
>
u
</button>
</div>
</div>
</div>
<div
class="mx_UserInfo_container mx_UserInfo_header"
>
<div
class="mx_Flex mx_UserInfo_profile"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
>
<h1
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
dir="auto"
>
<div
class="mx_Flex mx_UserInfo_profile_name"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
>
@user:example.com
</div>
</h1>
<div
class="mx_PresenceLabel mx_UserInfo_profileStatus"
>
Unknown
</div>
<p
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 mx_UserInfo_profile_mxid"
>
<div
class="mx_CopyableText"
>
customUserIdentifier
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</p>
</div>
<div
class="mx_Flex mx_UserInfo_verification"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0;"
>
<div
class="mx_UserInfo_container_verifyButton"
>
<button
class="_button_i91xf_17 mx_UserInfo_verify_button"
data-kind="tertiary"
data-size="sm"
role="button"
tabindex="0"
>
Verify User
</button>
</div>
</div>
</div>
</div>
`;