feat(crypto): Support verification violation composer banner (#29067)
* feat(crypto): Support verification violation composer banner * refactor UserIdentityWarning by using now a ViewModel fixup: logger import fixup: test lint type problems fix test having an unexpected verification violation fixup sonarcubes warnings * review: comments on types and inline some const * review: Quick refactor, better handling of action on button click * review: Small updates, remove commented code
This commit is contained in:
@@ -20,8 +20,14 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
margin-left: var(--cpd-space-6x);
|
margin-left: var(--cpd-space-6x);
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
.mx_UserIdentityWarning_main.critical {
|
||||||
|
color: var(--cpd-color-text-critical-primary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.mx_UserIdentityWarning.critical {
|
||||||
|
background: linear-gradient(180deg, var(--cpd-color-red-100) 0%, var(--cpd-color-theme-bg) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.mx_MessageComposer.mx_MessageComposer--compact > .mx_UserIdentityWarning {
|
.mx_MessageComposer.mx_MessageComposer--compact > .mx_UserIdentityWarning {
|
||||||
margin-left: calc(-25px + var(--RoomView_MessageList-padding));
|
margin-left: calc(-25px + var(--RoomView_MessageList-padding));
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
/*
|
||||||
|
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 { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { EventType, MatrixEvent, Room, RoomMember, RoomStateEvent } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { CryptoApi, CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
||||||
|
import { throttle } from "lodash";
|
||||||
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
|
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx";
|
||||||
|
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter.ts";
|
||||||
|
|
||||||
|
export type ViolationType = "PinViolation" | "VerificationViolation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a prompt to the user about a violation in the room.
|
||||||
|
* The type of violation and the member it relates to are included.
|
||||||
|
* If the type is "VerificationViolation", the warning is critical and should be reported with more urgency.
|
||||||
|
*/
|
||||||
|
export type ViolationPrompt = {
|
||||||
|
member: RoomMember;
|
||||||
|
type: ViolationType;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The state of the UserIdentityWarningViewModel.
|
||||||
|
* This includes the current prompt to show to the user and a callback to handle button clicks.
|
||||||
|
* If currentPrompt is undefined, there are no violations to show.
|
||||||
|
*/
|
||||||
|
export interface UserIdentityWarningState {
|
||||||
|
currentPrompt?: ViolationPrompt;
|
||||||
|
dispatchAction: (action: UserIdentityWarningViewModelAction) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of actions that can be dispatched to the UserIdentityWarningViewModel.
|
||||||
|
*/
|
||||||
|
export type UserIdentityWarningViewModelAction =
|
||||||
|
| { type: "PinUserIdentity"; userId: string }
|
||||||
|
| { type: "WithdrawVerification"; userId: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a list of room members to a list of violations.
|
||||||
|
* Checks for all members in the room to see if they have any violations.
|
||||||
|
* If no violations are found, an empty list is returned.
|
||||||
|
*
|
||||||
|
* @param cryptoApi
|
||||||
|
* @param members - The list of room members to check for violations.
|
||||||
|
*/
|
||||||
|
async function mapToViolations(cryptoApi: CryptoApi, members: RoomMember[]): Promise<ViolationPrompt[]> {
|
||||||
|
const violationList = new Array<ViolationPrompt>();
|
||||||
|
for (const member of members) {
|
||||||
|
const verificationStatus = await cryptoApi.getUserVerificationStatus(member.userId);
|
||||||
|
if (verificationStatus.wasCrossSigningVerified() && !verificationStatus.isCrossSigningVerified()) {
|
||||||
|
violationList.push({ member, type: "VerificationViolation" });
|
||||||
|
} else if (verificationStatus.needsUserApproval) {
|
||||||
|
violationList.push({ member, type: "PinViolation" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return violationList;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUserIdentityWarningViewModel(room: Room, key: string): UserIdentityWarningState {
|
||||||
|
const cli = useMatrixClientContext();
|
||||||
|
const crypto = cli.getCrypto();
|
||||||
|
|
||||||
|
const [members, setMembers] = useState<RoomMember[]>([]);
|
||||||
|
const [currentPrompt, setCurrentPrompt] = useState<ViolationPrompt | undefined>(undefined);
|
||||||
|
|
||||||
|
const loadViolations = useMemo(
|
||||||
|
() =>
|
||||||
|
throttle(async (): Promise<void> => {
|
||||||
|
const isEncrypted = crypto && (await crypto.isEncryptionEnabledInRoom(room.roomId));
|
||||||
|
if (!isEncrypted) {
|
||||||
|
setMembers([]);
|
||||||
|
setCurrentPrompt(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetMembers = await room.getEncryptionTargetMembers();
|
||||||
|
setMembers(targetMembers);
|
||||||
|
const violations = await mapToViolations(crypto, targetMembers);
|
||||||
|
|
||||||
|
let candidatePrompt: ViolationPrompt | undefined;
|
||||||
|
if (violations.length > 0) {
|
||||||
|
// sort by user ID to ensure consistent ordering
|
||||||
|
const sortedViolations = violations.sort((a, b) => a.member.userId.localeCompare(b.member.userId));
|
||||||
|
candidatePrompt = sortedViolations[0];
|
||||||
|
} else {
|
||||||
|
candidatePrompt = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// is the current prompt still valid?
|
||||||
|
setCurrentPrompt((existingPrompt): ViolationPrompt | undefined => {
|
||||||
|
if (existingPrompt && violations.includes(existingPrompt)) {
|
||||||
|
return existingPrompt;
|
||||||
|
} else if (candidatePrompt) {
|
||||||
|
return candidatePrompt;
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
[crypto, room],
|
||||||
|
);
|
||||||
|
|
||||||
|
// We need to listen for changes to the members list
|
||||||
|
useTypedEventEmitter(
|
||||||
|
cli,
|
||||||
|
RoomStateEvent.Events,
|
||||||
|
useCallback(
|
||||||
|
async (event: MatrixEvent): Promise<void> => {
|
||||||
|
if (!crypto || event.getRoomId() !== room.roomId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let shouldRefresh = false;
|
||||||
|
|
||||||
|
const eventType = event.getType();
|
||||||
|
|
||||||
|
if (eventType === EventType.RoomEncryption && event.getStateKey() === "") {
|
||||||
|
// Room is now encrypted, so we can initialise the component.
|
||||||
|
shouldRefresh = true;
|
||||||
|
} else if (eventType == EventType.RoomMember) {
|
||||||
|
// We're processing an m.room.member event
|
||||||
|
// Something has changed in membership, someone joined or someone left or
|
||||||
|
// someone changed their display name. Anyhow let's refresh.
|
||||||
|
const userId = event.getStateKey();
|
||||||
|
shouldRefresh = !!userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRefresh) {
|
||||||
|
loadViolations().catch((e) => {
|
||||||
|
logger.error("Error refreshing UserIdentityWarningViewModel:", e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[crypto, room, loadViolations],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// We need to listen for changes to the verification status of the members to refresh violations
|
||||||
|
useTypedEventEmitter(
|
||||||
|
cli,
|
||||||
|
CryptoEvent.UserTrustStatusChanged,
|
||||||
|
useCallback(
|
||||||
|
(userId: string): void => {
|
||||||
|
if (members.find((m) => m.userId == userId)) {
|
||||||
|
// This member is tracked, we need to refresh.
|
||||||
|
// refresh all for now?
|
||||||
|
// As a later optimisation we could store the current violations and only update the relevant one.
|
||||||
|
loadViolations().catch((e) => {
|
||||||
|
logger.error("Error refreshing UserIdentityWarning:", e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadViolations, members],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadViolations().catch((e) => {
|
||||||
|
logger.error("Error initialising UserIdentityWarning:", e);
|
||||||
|
});
|
||||||
|
}, [loadViolations]);
|
||||||
|
|
||||||
|
const dispatchAction = useCallback(
|
||||||
|
(action: UserIdentityWarningViewModelAction): void => {
|
||||||
|
if (!crypto) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.type === "PinUserIdentity") {
|
||||||
|
crypto.pinCurrentUserIdentity(action.userId).catch((e) => {
|
||||||
|
logger.error("Error pinning user identity:", e);
|
||||||
|
});
|
||||||
|
} else if (action.type === "WithdrawVerification") {
|
||||||
|
crypto.withdrawVerificationRequirement(action.userId).catch((e) => {
|
||||||
|
logger.error("Error withdrawing verification requirement:", e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[crypto],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentPrompt,
|
||||||
|
dispatchAction,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,16 +5,18 @@ 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.
|
Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React from "react";
|
||||||
import { EventType, KnownMembership, MatrixEvent, Room, RoomStateEvent, RoomMember } from "matrix-js-sdk/src/matrix";
|
import { Room, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||||
import { CryptoApi, CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
|
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
|
||||||
import { Button, Separator } from "@vector-im/compound-web";
|
import { Button, Separator } from "@vector-im/compound-web";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import MemberAvatar from "../avatars/MemberAvatar";
|
import MemberAvatar from "../avatars/MemberAvatar";
|
||||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
import {
|
||||||
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
useUserIdentityWarningViewModel,
|
||||||
|
ViolationPrompt,
|
||||||
|
} from "../../viewmodels/rooms/UserIdentityWarningViewModel.tsx";
|
||||||
|
import { ButtonEvent } from "../elements/AccessibleButton.tsx";
|
||||||
|
|
||||||
interface UserIdentityWarningProps {
|
interface UserIdentityWarningProps {
|
||||||
/**
|
/**
|
||||||
@@ -28,24 +30,6 @@ interface UserIdentityWarningProps {
|
|||||||
key: string;
|
key: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Does the given user's identity need to be approved?
|
|
||||||
*/
|
|
||||||
async function userNeedsApproval(crypto: CryptoApi, userId: string): Promise<boolean> {
|
|
||||||
const verificationStatus = await crypto.getUserVerificationStatus(userId);
|
|
||||||
return verificationStatus.needsUserApproval;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the component is uninitialised, is in the process of initialising, or
|
|
||||||
* has completed initialising.
|
|
||||||
*/
|
|
||||||
enum InitialisationStatus {
|
|
||||||
Uninitialised,
|
|
||||||
Initialising,
|
|
||||||
Completed,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays a banner warning when there is an issue with a user's identity.
|
* Displays a banner warning when there is an issue with a user's identity.
|
||||||
*
|
*
|
||||||
@@ -53,283 +37,101 @@ enum InitialisationStatus {
|
|||||||
* button to acknowledge the change.
|
* button to acknowledge the change.
|
||||||
*/
|
*/
|
||||||
export const UserIdentityWarning: React.FC<UserIdentityWarningProps> = ({ room }) => {
|
export const UserIdentityWarning: React.FC<UserIdentityWarningProps> = ({ room }) => {
|
||||||
const cli = useMatrixClientContext();
|
const { currentPrompt, dispatchAction } = useUserIdentityWarningViewModel(room, room.roomId);
|
||||||
const crypto = cli.getCrypto();
|
|
||||||
|
|
||||||
// The current room member that we are prompting the user to approve.
|
if (!currentPrompt) return null;
|
||||||
// `undefined` means we are not currently showing a prompt.
|
|
||||||
const [currentPrompt, setCurrentPrompt] = useState<RoomMember | undefined>(undefined);
|
|
||||||
|
|
||||||
// Whether or not we've already initialised the component by loading the
|
const [title, action] = getTitleAndAction(currentPrompt);
|
||||||
// room membership.
|
|
||||||
const initialisedRef = useRef<InitialisationStatus>(InitialisationStatus.Uninitialised);
|
const onButtonClick = (ev: ButtonEvent): void => {
|
||||||
// Which room members need their identity approved.
|
ev.preventDefault();
|
||||||
const membersNeedingApprovalRef = useRef<Map<string, RoomMember>>(new Map());
|
if (currentPrompt.type === "VerificationViolation") {
|
||||||
// For each user, we assign a sequence number to each verification status
|
dispatchAction({ type: "WithdrawVerification", userId: currentPrompt.member.userId });
|
||||||
// that we get, or fetch.
|
} else {
|
||||||
//
|
dispatchAction({ type: "PinUserIdentity", userId: currentPrompt.member.userId });
|
||||||
// Since fetching a verification status is asynchronous, we could get an
|
}
|
||||||
// update in the middle of fetching the verification status, which could
|
|
||||||
// mean that the status that we fetched is out of date. So if the current
|
|
||||||
// sequence number is not the same as the sequence number when we started
|
|
||||||
// the fetch, then we drop our fetched result, under the assumption that the
|
|
||||||
// update that we received is the most up-to-date version. If it is in fact
|
|
||||||
// not the most up-to-date version, then we should be receiving a new update
|
|
||||||
// soon with the newer value, so it will fix itself in the end.
|
|
||||||
//
|
|
||||||
// We also assign a sequence number when the user leaves the room, in order
|
|
||||||
// to prevent prompting about a user who leaves while we are fetching their
|
|
||||||
// verification status.
|
|
||||||
const verificationStatusSequencesRef = useRef<Map<string, number>>(new Map());
|
|
||||||
const incrementVerificationStatusSequence = (userId: string): number => {
|
|
||||||
const verificationStatusSequences = verificationStatusSequencesRef.current;
|
|
||||||
const value = verificationStatusSequences.get(userId);
|
|
||||||
const newValue = value === undefined ? 1 : value + 1;
|
|
||||||
verificationStatusSequences.set(userId, newValue);
|
|
||||||
return newValue;
|
|
||||||
};
|
};
|
||||||
|
return warningBanner(
|
||||||
// Update the current prompt. Select a new user if needed, or hide the
|
currentPrompt.type === "VerificationViolation",
|
||||||
// warning if we don't have anyone to warn about.
|
memberAvatar(currentPrompt.member),
|
||||||
const updateCurrentPrompt = useCallback((): undefined => {
|
title,
|
||||||
const membersNeedingApproval = membersNeedingApprovalRef.current;
|
action,
|
||||||
// We have to do this in a callback to `setCurrentPrompt`
|
onButtonClick,
|
||||||
// because this function could have been called after an
|
|
||||||
// `await`, and the `currentPrompt` that this function would
|
|
||||||
// have may be outdated.
|
|
||||||
setCurrentPrompt((currentPrompt) => {
|
|
||||||
// If we're already displaying a warning, and that user still needs
|
|
||||||
// approval, continue showing that user.
|
|
||||||
if (currentPrompt && membersNeedingApproval.has(currentPrompt.userId)) return currentPrompt;
|
|
||||||
|
|
||||||
if (membersNeedingApproval.size === 0) {
|
|
||||||
if (currentPrompt) {
|
|
||||||
// If we were previously showing a warning, log that we've stopped doing so.
|
|
||||||
logger.debug("UserIdentityWarning: no users left that need approval");
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// We pick the user with the smallest user ID.
|
|
||||||
const keys = Array.from(membersNeedingApproval.keys()).sort((a, b) => a.localeCompare(b));
|
|
||||||
const selection = membersNeedingApproval.get(keys[0]!);
|
|
||||||
logger.debug(`UserIdentityWarning: now warning about user ${selection?.userId}`);
|
|
||||||
return selection;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Add a user to the membersNeedingApproval map, and update the current
|
|
||||||
// prompt if necessary. The user will only be added if they are actually a
|
|
||||||
// member of the room. If they are not a member, this function will do
|
|
||||||
// nothing.
|
|
||||||
const addMemberNeedingApproval = useCallback(
|
|
||||||
(userId: string, member?: RoomMember): void => {
|
|
||||||
if (userId === cli.getUserId()) {
|
|
||||||
// We always skip our own user, because we can't pin our own identity.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
member = member ?? room.getMember(userId) ?? undefined;
|
|
||||||
if (!member) return;
|
|
||||||
|
|
||||||
membersNeedingApprovalRef.current.set(userId, member);
|
|
||||||
// We only select the prompt if we are done initialising,
|
|
||||||
// because we will select the prompt after we're done
|
|
||||||
// initialising, and we want to start by displaying a warning
|
|
||||||
// for the user with the smallest ID.
|
|
||||||
if (initialisedRef.current === InitialisationStatus.Completed) {
|
|
||||||
logger.debug(
|
|
||||||
`UserIdentityWarning: user ${userId} now needs approval; approval-pending list now [${Array.from(membersNeedingApprovalRef.current.keys())}]`,
|
|
||||||
);
|
|
||||||
updateCurrentPrompt();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[cli, room, updateCurrentPrompt],
|
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// For each user in the list check if their identity needs approval, and if
|
function getTitleAndAction(prompt: ViolationPrompt): [title: React.ReactNode, action: string] {
|
||||||
// so, add them to the membersNeedingApproval map and update the prompt if
|
let title: React.ReactNode;
|
||||||
// needed.
|
let action: string;
|
||||||
const addMembersWhoNeedApproval = useCallback(
|
if (prompt.type === "VerificationViolation") {
|
||||||
async (members: RoomMember[]): Promise<void> => {
|
if (prompt.member.rawDisplayName === prompt.member.userId) {
|
||||||
const verificationStatusSequences = verificationStatusSequencesRef.current;
|
title = _t(
|
||||||
|
"encryption|verified_identity_changed_no_displayname",
|
||||||
const promises: Promise<void>[] = [];
|
{ userId: prompt.member.userId },
|
||||||
|
{
|
||||||
for (const member of members) {
|
a: substituteATag,
|
||||||
const userId = member.userId;
|
b: substituteBTag,
|
||||||
const sequenceNum = incrementVerificationStatusSequence(userId);
|
},
|
||||||
promises.push(
|
);
|
||||||
userNeedsApproval(crypto!, userId).then((needsApproval) => {
|
} else {
|
||||||
if (needsApproval) {
|
title = _t(
|
||||||
// Only actually update the list if we have the most
|
"encryption|verified_identity_changed",
|
||||||
// recent value.
|
{ displayName: prompt.member.rawDisplayName, userId: prompt.member.userId },
|
||||||
if (verificationStatusSequences.get(userId) === sequenceNum) {
|
{
|
||||||
addMemberNeedingApproval(userId, member);
|
a: substituteATag,
|
||||||
}
|
b: substituteBTag,
|
||||||
}
|
},
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
},
|
|
||||||
[crypto, addMemberNeedingApproval],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Remove a user from the membersNeedingApproval map, and update the current
|
|
||||||
// prompt if necessary.
|
|
||||||
const removeMemberNeedingApproval = useCallback(
|
|
||||||
(userId: string): void => {
|
|
||||||
membersNeedingApprovalRef.current.delete(userId);
|
|
||||||
logger.debug(
|
|
||||||
`UserIdentityWarning: user ${userId} no longer needs approval; approval-pending list now [${Array.from(membersNeedingApprovalRef.current.keys())}]`,
|
|
||||||
);
|
);
|
||||||
updateCurrentPrompt();
|
|
||||||
},
|
|
||||||
[updateCurrentPrompt],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Initialise the component. Get the room members, check which ones need
|
|
||||||
// their identity approved, and pick one to display.
|
|
||||||
const loadMembers = useCallback(async (): Promise<void> => {
|
|
||||||
if (!crypto || initialisedRef.current !== InitialisationStatus.Uninitialised) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// If encryption is not enabled in the room, we don't need to do
|
action = _t("encryption|withdraw_verification_action");
|
||||||
// anything. If encryption gets enabled later, we will retry, via
|
} else {
|
||||||
// onRoomStateEvent.
|
if (prompt.member.rawDisplayName === prompt.member.userId) {
|
||||||
if (!(await crypto.isEncryptionEnabledInRoom(room.roomId))) {
|
title = _t(
|
||||||
return;
|
"encryption|pinned_identity_changed_no_displayname",
|
||||||
|
{ userId: prompt.member.userId },
|
||||||
|
{
|
||||||
|
a: substituteATag,
|
||||||
|
b: substituteBTag,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
title = _t(
|
||||||
|
"encryption|pinned_identity_changed",
|
||||||
|
{ displayName: prompt.member.rawDisplayName, userId: prompt.member.userId },
|
||||||
|
{
|
||||||
|
a: substituteATag,
|
||||||
|
b: substituteBTag,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
initialisedRef.current = InitialisationStatus.Initialising;
|
action = _t("action|ok");
|
||||||
|
}
|
||||||
const members = await room.getEncryptionTargetMembers();
|
return [title, action];
|
||||||
await addMembersWhoNeedApproval(members);
|
}
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Initialised UserIdentityWarning component for room ${room.roomId} with approval-pending list [${Array.from(membersNeedingApprovalRef.current.keys())}]`,
|
|
||||||
);
|
|
||||||
updateCurrentPrompt();
|
|
||||||
initialisedRef.current = InitialisationStatus.Completed;
|
|
||||||
}, [crypto, room, addMembersWhoNeedApproval, updateCurrentPrompt]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadMembers().catch((e) => {
|
|
||||||
logger.error("Error initialising UserIdentityWarning:", e);
|
|
||||||
});
|
|
||||||
}, [loadMembers]);
|
|
||||||
|
|
||||||
// When a user's verification status changes, we check if they need to be
|
|
||||||
// added/removed from the set of members needing approval.
|
|
||||||
const onUserVerificationStatusChanged = useCallback(
|
|
||||||
(userId: string, verificationStatus: UserVerificationStatus): void => {
|
|
||||||
// If we haven't started initialising, that means that we're in a
|
|
||||||
// room where we don't need to display any warnings.
|
|
||||||
if (initialisedRef.current === InitialisationStatus.Uninitialised) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
incrementVerificationStatusSequence(userId);
|
|
||||||
|
|
||||||
if (verificationStatus.needsUserApproval) {
|
|
||||||
addMemberNeedingApproval(userId);
|
|
||||||
} else {
|
|
||||||
removeMemberNeedingApproval(userId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[addMemberNeedingApproval, removeMemberNeedingApproval],
|
|
||||||
);
|
|
||||||
useTypedEventEmitter(cli, CryptoEvent.UserTrustStatusChanged, onUserVerificationStatusChanged);
|
|
||||||
|
|
||||||
// We watch for encryption events (since we only display warnings in
|
|
||||||
// encrypted rooms), and for membership changes (since we only display
|
|
||||||
// warnings for users in the room).
|
|
||||||
const onRoomStateEvent = useCallback(
|
|
||||||
async (event: MatrixEvent): Promise<void> => {
|
|
||||||
if (!crypto || event.getRoomId() !== room.roomId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventType = event.getType();
|
|
||||||
if (eventType === EventType.RoomEncryption && event.getStateKey() === "") {
|
|
||||||
// Room is now encrypted, so we can initialise the component.
|
|
||||||
return loadMembers().catch((e) => {
|
|
||||||
logger.error("Error initialising UserIdentityWarning:", e);
|
|
||||||
});
|
|
||||||
} else if (eventType !== EventType.RoomMember) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// We're processing an m.room.member event
|
|
||||||
|
|
||||||
if (initialisedRef.current === InitialisationStatus.Uninitialised) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = event.getStateKey();
|
|
||||||
|
|
||||||
if (!userId) return;
|
|
||||||
|
|
||||||
if (
|
|
||||||
event.getContent().membership === KnownMembership.Join ||
|
|
||||||
(event.getContent().membership === KnownMembership.Invite && room.shouldEncryptForInvitedMembers())
|
|
||||||
) {
|
|
||||||
// Someone's membership changed and we will now encrypt to them. If
|
|
||||||
// their identity needs approval, show a warning.
|
|
||||||
const member = room.getMember(userId);
|
|
||||||
if (member) {
|
|
||||||
await addMembersWhoNeedApproval([member]).catch((e) => {
|
|
||||||
logger.error("Error adding member in UserIdentityWarning:", e);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Someone's membership changed and we no longer encrypt to them.
|
|
||||||
// If we're showing a warning about them, we don't need to any more.
|
|
||||||
removeMemberNeedingApproval(userId);
|
|
||||||
incrementVerificationStatusSequence(userId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[crypto, room, addMembersWhoNeedApproval, removeMemberNeedingApproval, loadMembers],
|
|
||||||
);
|
|
||||||
useTypedEventEmitter(cli, RoomStateEvent.Events, onRoomStateEvent);
|
|
||||||
|
|
||||||
if (!crypto || !currentPrompt) return null;
|
|
||||||
|
|
||||||
const confirmIdentity = async (): Promise<void> => {
|
|
||||||
await crypto.pinCurrentUserIdentity(currentPrompt.userId);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
function warningBanner(
|
||||||
|
isCritical: boolean,
|
||||||
|
avatar: React.ReactNode,
|
||||||
|
title: React.ReactNode,
|
||||||
|
action: string,
|
||||||
|
onButtonClick: (ev: ButtonEvent) => void,
|
||||||
|
): React.ReactNode {
|
||||||
return (
|
return (
|
||||||
<div className="mx_UserIdentityWarning">
|
<div className={classNames("mx_UserIdentityWarning", { critical: isCritical })}>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="mx_UserIdentityWarning_row">
|
<div className="mx_UserIdentityWarning_row">
|
||||||
<MemberAvatar member={currentPrompt} title={currentPrompt.userId} size="30px" />
|
{avatar}
|
||||||
<span className="mx_UserIdentityWarning_main">
|
<span className={classNames("mx_UserIdentityWarning_main", { critical: isCritical })}>{title}</span>
|
||||||
{currentPrompt.rawDisplayName === currentPrompt.userId
|
<Button kind="secondary" size="sm" onClick={onButtonClick}>
|
||||||
? _t(
|
{action}
|
||||||
"encryption|pinned_identity_changed_no_displayname",
|
|
||||||
{ userId: currentPrompt.userId },
|
|
||||||
{
|
|
||||||
a: substituteATag,
|
|
||||||
b: substituteBTag,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: _t(
|
|
||||||
"encryption|pinned_identity_changed",
|
|
||||||
{ displayName: currentPrompt.rawDisplayName, userId: currentPrompt.userId },
|
|
||||||
{
|
|
||||||
a: substituteATag,
|
|
||||||
b: substituteBTag,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<Button kind="primary" size="sm" onClick={confirmIdentity}>
|
|
||||||
{_t("action|ok")}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
function memberAvatar(member: RoomMember): React.ReactNode {
|
||||||
|
return <MemberAvatar member={member} title={member.userId} size="30px" />;
|
||||||
|
}
|
||||||
|
|
||||||
function substituteATag(sub: string): React.ReactNode {
|
function substituteATag(sub: string): React.ReactNode {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1058,8 +1058,11 @@
|
|||||||
"waiting_other_user": "Waiting for %(displayName)s to verify…"
|
"waiting_other_user": "Waiting for %(displayName)s to verify…"
|
||||||
},
|
},
|
||||||
"verification_requested_toast_title": "Verification requested",
|
"verification_requested_toast_title": "Verification requested",
|
||||||
|
"verified_identity_changed": "%(displayName)s's (<b>%(userId)s</b>) verified identity has changed. <a>Learn more</a>",
|
||||||
|
"verified_identity_changed_no_displayname": "<b>%(userId)s</b>'s verified identity has changed. <a>Learn more</a>",
|
||||||
"verify_toast_description": "Other users may not trust it",
|
"verify_toast_description": "Other users may not trust it",
|
||||||
"verify_toast_title": "Verify this session"
|
"verify_toast_title": "Verify this session",
|
||||||
|
"withdraw_verification_action": "Withdraw verification"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"admin_contact": "Please <a>contact your service administrator</a> to continue using this service.",
|
"admin_contact": "Please <a>contact your service administrator</a> to continue using this service.",
|
||||||
|
|||||||
@@ -424,7 +424,7 @@ describe("RoomView", () => {
|
|||||||
jest.spyOn(cli, "getCrypto").mockReturnValue(crypto);
|
jest.spyOn(cli, "getCrypto").mockReturnValue(crypto);
|
||||||
jest.spyOn(cli.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true);
|
jest.spyOn(cli.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true);
|
||||||
jest.spyOn(cli.getCrypto()!, "getUserVerificationStatus").mockResolvedValue(
|
jest.spyOn(cli.getCrypto()!, "getUserVerificationStatus").mockResolvedValue(
|
||||||
new UserVerificationStatus(false, true, false),
|
new UserVerificationStatus(false, false, false),
|
||||||
);
|
);
|
||||||
localRoom.encrypted = true;
|
localRoom.encrypted = true;
|
||||||
localRoom.currentState.setStateEvents([
|
localRoom.currentState.setStateEvents([
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { sleep } from "matrix-js-sdk/src/utils";
|
import { sleep, defer } from "matrix-js-sdk/src/utils";
|
||||||
import {
|
import {
|
||||||
EventType,
|
EventType,
|
||||||
MatrixClient,
|
MatrixClient,
|
||||||
@@ -37,6 +37,50 @@ function mockRoom(): Room {
|
|||||||
return room;
|
return room;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mockMembershipForRoom(room: Room, users: string[] | [string, "joined" | "invited"][]): void {
|
||||||
|
const encryptToInvited = room.shouldEncryptForInvitedMembers();
|
||||||
|
const members = users
|
||||||
|
.filter((user) => {
|
||||||
|
if (Array.isArray(user)) {
|
||||||
|
return encryptToInvited || user[1] === "joined";
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map((id) => {
|
||||||
|
if (Array.isArray(id)) {
|
||||||
|
return mockRoomMember(id[0]);
|
||||||
|
} else {
|
||||||
|
return mockRoomMember(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue(members);
|
||||||
|
|
||||||
|
jest.spyOn(room, "getMember").mockImplementation((userId) => {
|
||||||
|
return members.find((member) => member.userId === userId) ?? null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitMembershipChange(client: MatrixClient, userId: string, membership: "join" | "leave" | "invite"): void {
|
||||||
|
const sender = membership === "invite" ? "@carol:example.org" : userId;
|
||||||
|
client.emit(
|
||||||
|
RoomStateEvent.Events,
|
||||||
|
new MatrixEvent({
|
||||||
|
event_id: "$event_id",
|
||||||
|
type: EventType.RoomMember,
|
||||||
|
state_key: userId,
|
||||||
|
content: {
|
||||||
|
membership: membership,
|
||||||
|
},
|
||||||
|
room_id: ROOM_ID,
|
||||||
|
sender: sender,
|
||||||
|
}),
|
||||||
|
dummyRoomState(),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function mockRoomMember(userId: string, name?: string): RoomMember {
|
function mockRoomMember(userId: string, name?: string): RoomMember {
|
||||||
return {
|
return {
|
||||||
userId,
|
userId,
|
||||||
@@ -97,7 +141,7 @@ describe("UserIdentityWarning", () => {
|
|||||||
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(
|
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(
|
||||||
new UserVerificationStatus(false, false, false, true),
|
new UserVerificationStatus(false, false, false, true),
|
||||||
);
|
);
|
||||||
crypto.pinCurrentUserIdentity = jest.fn();
|
crypto.pinCurrentUserIdentity = jest.fn().mockResolvedValue(undefined);
|
||||||
renderComponent(client, room);
|
renderComponent(client, room);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@@ -109,6 +153,49 @@ describe("UserIdentityWarning", () => {
|
|||||||
await waitFor(() => expect(crypto.pinCurrentUserIdentity).toHaveBeenCalledWith("@alice:example.org"));
|
await waitFor(() => expect(crypto.pinCurrentUserIdentity).toHaveBeenCalledWith("@alice:example.org"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// This tests the basic functionality of the component. If we have a room
|
||||||
|
// member whose identity is in verification violation, we should display a warning. When
|
||||||
|
// the "Withdraw verification" button gets pressed, it should call `withdrawVerification`.
|
||||||
|
it("displays a warning when a user's identity is in verification violation", async () => {
|
||||||
|
jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([
|
||||||
|
mockRoomMember("@alice:example.org", "Alice"),
|
||||||
|
]);
|
||||||
|
const crypto = client.getCrypto()!;
|
||||||
|
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(
|
||||||
|
new UserVerificationStatus(false, true, false, true),
|
||||||
|
);
|
||||||
|
crypto.withdrawVerificationRequirement = jest.fn().mockResolvedValue(undefined);
|
||||||
|
renderComponent(client, room);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(getWarningByText("Alice's (@alice:example.org) verified identity has changed.")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "Withdraw verification",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole("button")!);
|
||||||
|
await waitFor(() => expect(crypto.withdrawVerificationRequirement).toHaveBeenCalledWith("@alice:example.org"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Should not display a warning if the user was verified and is still verified", async () => {
|
||||||
|
jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([
|
||||||
|
mockRoomMember("@alice:example.org", "Alice"),
|
||||||
|
]);
|
||||||
|
const crypto = client.getCrypto()!;
|
||||||
|
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(
|
||||||
|
new UserVerificationStatus(true, true, false, false),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderComponent(client, room);
|
||||||
|
await sleep(10); // give it some time to finish initialising
|
||||||
|
|
||||||
|
expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow();
|
||||||
|
expect(() => getWarningByText("Alice's (@alice:example.org) verified identity has changed.")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
// We don't display warnings in non-encrypted rooms, but if encryption is
|
// We don't display warnings in non-encrypted rooms, but if encryption is
|
||||||
// enabled, then we should display a warning if there are any users whose
|
// enabled, then we should display a warning if there are any users whose
|
||||||
// identity need accepting.
|
// identity need accepting.
|
||||||
@@ -124,6 +211,7 @@ describe("UserIdentityWarning", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
renderComponent(client, room);
|
renderComponent(client, room);
|
||||||
|
|
||||||
await sleep(10); // give it some time to finish initialising
|
await sleep(10); // give it some time to finish initialising
|
||||||
expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow();
|
expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow();
|
||||||
|
|
||||||
@@ -152,6 +240,57 @@ describe("UserIdentityWarning", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Warnings are displayed in consistent order", () => {
|
||||||
|
it("Ensure lexicographic order for prompt", async () => {
|
||||||
|
// members are not returned lexicographic order
|
||||||
|
mockMembershipForRoom(room, ["@b:example.org", "@a:example.org"]);
|
||||||
|
|
||||||
|
const crypto = client.getCrypto()!;
|
||||||
|
|
||||||
|
// All identities needs approval
|
||||||
|
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(
|
||||||
|
new UserVerificationStatus(false, false, false, true),
|
||||||
|
);
|
||||||
|
|
||||||
|
crypto.pinCurrentUserIdentity = jest.fn();
|
||||||
|
renderComponent(client, room);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(getWarningByText("@a:example.org's identity appears to have changed.")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Ensure existing prompt stays even if a new violation with lower lexicographic order detected", async () => {
|
||||||
|
mockMembershipForRoom(room, ["@b:example.org"]);
|
||||||
|
|
||||||
|
const crypto = client.getCrypto()!;
|
||||||
|
|
||||||
|
// All identities needs approval
|
||||||
|
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(
|
||||||
|
new UserVerificationStatus(false, false, false, true),
|
||||||
|
);
|
||||||
|
|
||||||
|
crypto.pinCurrentUserIdentity = jest.fn();
|
||||||
|
renderComponent(client, room);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(getWarningByText("@b:example.org's identity appears to have changed.")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate a new member joined with lower lexico order and also in violation
|
||||||
|
mockMembershipForRoom(room, ["@a:example.org", "@b:example.org"]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
emitMembershipChange(client, "@a:example.org", "join");
|
||||||
|
});
|
||||||
|
|
||||||
|
// We should still display the warning for @b:example.org
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(getWarningByText("@b:example.org's identity appears to have changed.")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// When a user's identity needs approval, or has been approved, the display
|
// When a user's identity needs approval, or has been approved, the display
|
||||||
// should update appropriately.
|
// should update appropriately.
|
||||||
it("updates the display when identity changes", async () => {
|
it("updates the display when identity changes", async () => {
|
||||||
@@ -163,18 +302,20 @@ describe("UserIdentityWarning", () => {
|
|||||||
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(
|
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(
|
||||||
new UserVerificationStatus(false, false, false, false),
|
new UserVerificationStatus(false, false, false, false),
|
||||||
);
|
);
|
||||||
renderComponent(client, room);
|
await act(async () => {
|
||||||
await sleep(10); // give it some time to finish initialising
|
renderComponent(client, room);
|
||||||
|
await sleep(50);
|
||||||
|
});
|
||||||
|
|
||||||
expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow();
|
expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow();
|
||||||
|
|
||||||
// The user changes their identity, so we should show the warning.
|
// The user changes their identity, so we should show the warning.
|
||||||
act(() => {
|
act(() => {
|
||||||
client.emit(
|
const newStatus = new UserVerificationStatus(false, false, false, true);
|
||||||
CryptoEvent.UserTrustStatusChanged,
|
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(newStatus);
|
||||||
"@alice:example.org",
|
client.emit(CryptoEvent.UserTrustStatusChanged, "@alice:example.org", newStatus);
|
||||||
new UserVerificationStatus(false, false, false, true),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(
|
expect(
|
||||||
getWarningByText("Alice's (@alice:example.org) identity appears to have changed."),
|
getWarningByText("Alice's (@alice:example.org) identity appears to have changed."),
|
||||||
@@ -184,11 +325,9 @@ describe("UserIdentityWarning", () => {
|
|||||||
// Simulate the user's new identity having been approved, so we no
|
// Simulate the user's new identity having been approved, so we no
|
||||||
// longer show the warning.
|
// longer show the warning.
|
||||||
act(() => {
|
act(() => {
|
||||||
client.emit(
|
const newStatus = new UserVerificationStatus(false, false, false, false);
|
||||||
CryptoEvent.UserTrustStatusChanged,
|
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(newStatus);
|
||||||
"@alice:example.org",
|
client.emit(CryptoEvent.UserTrustStatusChanged, "@alice:example.org", newStatus);
|
||||||
new UserVerificationStatus(false, false, false, false),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow(),
|
expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow(),
|
||||||
@@ -200,8 +339,7 @@ describe("UserIdentityWarning", () => {
|
|||||||
describe("updates the display when a member joins/leaves", () => {
|
describe("updates the display when a member joins/leaves", () => {
|
||||||
it("when invited users can see encrypted messages", async () => {
|
it("when invited users can see encrypted messages", async () => {
|
||||||
// Nobody in the room yet
|
// Nobody in the room yet
|
||||||
jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([]);
|
mockMembershipForRoom(room, []);
|
||||||
jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId));
|
|
||||||
jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(true);
|
jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(true);
|
||||||
const crypto = client.getCrypto()!;
|
const crypto = client.getCrypto()!;
|
||||||
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(
|
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(
|
||||||
@@ -211,62 +349,29 @@ describe("UserIdentityWarning", () => {
|
|||||||
await sleep(10); // give it some time to finish initialising
|
await sleep(10); // give it some time to finish initialising
|
||||||
|
|
||||||
// Alice joins. Her identity needs approval, so we should show a warning.
|
// Alice joins. Her identity needs approval, so we should show a warning.
|
||||||
client.emit(
|
act(() => {
|
||||||
RoomStateEvent.Events,
|
mockMembershipForRoom(room, ["@alice:example.org"]);
|
||||||
new MatrixEvent({
|
emitMembershipChange(client, "@alice:example.org", "join");
|
||||||
event_id: "$event_id",
|
});
|
||||||
type: EventType.RoomMember,
|
|
||||||
state_key: "@alice:example.org",
|
|
||||||
content: {
|
|
||||||
membership: "join",
|
|
||||||
},
|
|
||||||
room_id: ROOM_ID,
|
|
||||||
sender: "@alice:example.org",
|
|
||||||
}),
|
|
||||||
dummyRoomState(),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(getWarningByText("@alice:example.org's identity appears to have changed.")).toBeInTheDocument(),
|
expect(getWarningByText("@alice:example.org's identity appears to have changed.")).toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Bob is invited. His identity needs approval, so we should show a
|
// Bob is invited. His identity needs approval, so we should show a
|
||||||
// warning for him after Alice's warning is resolved by her leaving.
|
// warning for him after Alice's warning is resolved by her leaving.
|
||||||
client.emit(
|
act(() => {
|
||||||
RoomStateEvent.Events,
|
mockMembershipForRoom(room, ["@alice:example.org", "@bob:example.org"]);
|
||||||
new MatrixEvent({
|
emitMembershipChange(client, "@bob:example.org", "invite");
|
||||||
event_id: "$event_id",
|
});
|
||||||
type: EventType.RoomMember,
|
|
||||||
state_key: "@bob:example.org",
|
|
||||||
content: {
|
|
||||||
membership: "invite",
|
|
||||||
},
|
|
||||||
room_id: ROOM_ID,
|
|
||||||
sender: "@carol:example.org",
|
|
||||||
}),
|
|
||||||
dummyRoomState(),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Alice leaves, so we no longer show her warning, but we will show
|
// Alice leaves, so we no longer show her warning, but we will show
|
||||||
// a warning for Bob.
|
// a warning for Bob.
|
||||||
act(() => {
|
act(() => {
|
||||||
client.emit(
|
mockMembershipForRoom(room, ["@bob:example.org"]);
|
||||||
RoomStateEvent.Events,
|
emitMembershipChange(client, "@alice:example.org", "leave");
|
||||||
new MatrixEvent({
|
|
||||||
event_id: "$event_id",
|
|
||||||
type: EventType.RoomMember,
|
|
||||||
state_key: "@alice:example.org",
|
|
||||||
content: {
|
|
||||||
membership: "leave",
|
|
||||||
},
|
|
||||||
room_id: ROOM_ID,
|
|
||||||
sender: "@alice:example.org",
|
|
||||||
}),
|
|
||||||
dummyRoomState(),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow(),
|
expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow(),
|
||||||
);
|
);
|
||||||
@@ -277,8 +382,9 @@ describe("UserIdentityWarning", () => {
|
|||||||
|
|
||||||
it("when invited users cannot see encrypted messages", async () => {
|
it("when invited users cannot see encrypted messages", async () => {
|
||||||
// Nobody in the room yet
|
// Nobody in the room yet
|
||||||
jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([]);
|
mockMembershipForRoom(room, []);
|
||||||
jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId));
|
// jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([]);
|
||||||
|
// jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId));
|
||||||
jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(false);
|
jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(false);
|
||||||
const crypto = client.getCrypto()!;
|
const crypto = client.getCrypto()!;
|
||||||
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(
|
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(
|
||||||
@@ -288,21 +394,10 @@ describe("UserIdentityWarning", () => {
|
|||||||
await sleep(10); // give it some time to finish initialising
|
await sleep(10); // give it some time to finish initialising
|
||||||
|
|
||||||
// Alice joins. Her identity needs approval, so we should show a warning.
|
// Alice joins. Her identity needs approval, so we should show a warning.
|
||||||
client.emit(
|
act(() => {
|
||||||
RoomStateEvent.Events,
|
mockMembershipForRoom(room, ["@alice:example.org"]);
|
||||||
new MatrixEvent({
|
emitMembershipChange(client, "@alice:example.org", "join");
|
||||||
event_id: "$event_id",
|
});
|
||||||
type: EventType.RoomMember,
|
|
||||||
state_key: "@alice:example.org",
|
|
||||||
content: {
|
|
||||||
membership: "join",
|
|
||||||
},
|
|
||||||
room_id: ROOM_ID,
|
|
||||||
sender: "@alice:example.org",
|
|
||||||
}),
|
|
||||||
dummyRoomState(),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(getWarningByText("@alice:example.org's identity appears to have changed.")).toBeInTheDocument(),
|
expect(getWarningByText("@alice:example.org's identity appears to have changed.")).toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
@@ -310,40 +405,19 @@ describe("UserIdentityWarning", () => {
|
|||||||
// Bob is invited. His identity needs approval, but we don't encrypt
|
// Bob is invited. His identity needs approval, but we don't encrypt
|
||||||
// to him, so we won't show a warning. (When Alice leaves, the
|
// to him, so we won't show a warning. (When Alice leaves, the
|
||||||
// display won't be updated to show a warningfor Bob.)
|
// display won't be updated to show a warningfor Bob.)
|
||||||
client.emit(
|
act(() => {
|
||||||
RoomStateEvent.Events,
|
mockMembershipForRoom(room, [
|
||||||
new MatrixEvent({
|
["@alice:example.org", "joined"],
|
||||||
event_id: "$event_id",
|
["@bob:example.org", "invited"],
|
||||||
type: EventType.RoomMember,
|
]);
|
||||||
state_key: "@bob:example.org",
|
emitMembershipChange(client, "@bob:example.org", "invite");
|
||||||
content: {
|
});
|
||||||
membership: "invite",
|
|
||||||
},
|
|
||||||
room_id: ROOM_ID,
|
|
||||||
sender: "@carol:example.org",
|
|
||||||
}),
|
|
||||||
dummyRoomState(),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Alice leaves, so we no longer show her warning, and we don't show
|
// Alice leaves, so we no longer show her warning, and we don't show
|
||||||
// a warning for Bob.
|
// a warning for Bob.
|
||||||
act(() => {
|
act(() => {
|
||||||
client.emit(
|
mockMembershipForRoom(room, [["@bob:example.org", "invited"]]);
|
||||||
RoomStateEvent.Events,
|
emitMembershipChange(client, "@alice:example.org", "leave");
|
||||||
new MatrixEvent({
|
|
||||||
event_id: "$event_id",
|
|
||||||
type: EventType.RoomMember,
|
|
||||||
state_key: "@alice:example.org",
|
|
||||||
content: {
|
|
||||||
membership: "leave",
|
|
||||||
},
|
|
||||||
room_id: ROOM_ID,
|
|
||||||
sender: "@alice:example.org",
|
|
||||||
}),
|
|
||||||
dummyRoomState(),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow(),
|
expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow(),
|
||||||
@@ -354,37 +428,26 @@ describe("UserIdentityWarning", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("when member leaves immediately after component is loaded", async () => {
|
it("when member leaves immediately after component is loaded", async () => {
|
||||||
|
let hasLeft = false;
|
||||||
jest.spyOn(room, "getEncryptionTargetMembers").mockImplementation(async () => {
|
jest.spyOn(room, "getEncryptionTargetMembers").mockImplementation(async () => {
|
||||||
|
if (hasLeft) return [];
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Alice immediately leaves after we get the room
|
emitMembershipChange(client, "@alice:example.org", "leave");
|
||||||
// membership, so we shouldn't show the warning any more
|
hasLeft = true;
|
||||||
client.emit(
|
|
||||||
RoomStateEvent.Events,
|
|
||||||
new MatrixEvent({
|
|
||||||
event_id: "$event_id",
|
|
||||||
type: EventType.RoomMember,
|
|
||||||
state_key: "@alice:example.org",
|
|
||||||
content: {
|
|
||||||
membership: "leave",
|
|
||||||
},
|
|
||||||
room_id: ROOM_ID,
|
|
||||||
sender: "@alice:example.org",
|
|
||||||
}),
|
|
||||||
dummyRoomState(),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
return [mockRoomMember("@alice:example.org")];
|
return [mockRoomMember("@alice:example.org")];
|
||||||
});
|
});
|
||||||
jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId));
|
|
||||||
jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(false);
|
jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(false);
|
||||||
const crypto = client.getCrypto()!;
|
const crypto = client.getCrypto()!;
|
||||||
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(
|
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(
|
||||||
new UserVerificationStatus(false, false, false, true),
|
new UserVerificationStatus(false, false, false, true),
|
||||||
);
|
);
|
||||||
renderComponent(client, room);
|
|
||||||
|
|
||||||
await sleep(10);
|
await act(async () => {
|
||||||
|
renderComponent(client, room);
|
||||||
|
await sleep(10);
|
||||||
|
});
|
||||||
expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow();
|
expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -461,6 +524,51 @@ describe("UserIdentityWarning", () => {
|
|||||||
// Simulate Alice's new identity having been approved, so now we warn
|
// Simulate Alice's new identity having been approved, so now we warn
|
||||||
// about Bob's identity.
|
// about Bob's identity.
|
||||||
act(() => {
|
act(() => {
|
||||||
|
const newStatus = new UserVerificationStatus(false, false, false, false);
|
||||||
|
jest.spyOn(crypto, "getUserVerificationStatus").mockImplementation(async (userId) => {
|
||||||
|
if (userId == "@alice:example.org") {
|
||||||
|
return newStatus;
|
||||||
|
} else {
|
||||||
|
return new UserVerificationStatus(false, false, false, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
client.emit(CryptoEvent.UserTrustStatusChanged, "@alice:example.org", newStatus);
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(getWarningByText("@bob:example.org's identity appears to have changed.")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays the next user when the verification requirement is withdrawn", async () => {
|
||||||
|
jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([
|
||||||
|
mockRoomMember("@alice:example.org", "Alice"),
|
||||||
|
mockRoomMember("@bob:example.org"),
|
||||||
|
]);
|
||||||
|
const crypto = client.getCrypto()!;
|
||||||
|
jest.spyOn(crypto, "getUserVerificationStatus").mockImplementation(async (userId) => {
|
||||||
|
if (userId == "@alice:example.org") {
|
||||||
|
return new UserVerificationStatus(false, true, false, true);
|
||||||
|
} else {
|
||||||
|
return new UserVerificationStatus(false, false, false, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
renderComponent(client, room);
|
||||||
|
// We should warn about Alice's identity first.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(getWarningByText("Alice's (@alice:example.org) verified identity has changed.")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate Alice's new identity having been approved, so now we warn
|
||||||
|
// about Bob's identity.
|
||||||
|
act(() => {
|
||||||
|
jest.spyOn(crypto, "getUserVerificationStatus").mockImplementation(async (userId) => {
|
||||||
|
if (userId == "@alice:example.org") {
|
||||||
|
return new UserVerificationStatus(false, false, false, false);
|
||||||
|
} else {
|
||||||
|
return new UserVerificationStatus(false, false, false, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
client.emit(
|
client.emit(
|
||||||
CryptoEvent.UserTrustStatusChanged,
|
CryptoEvent.UserTrustStatusChanged,
|
||||||
"@alice:example.org",
|
"@alice:example.org",
|
||||||
@@ -484,51 +592,36 @@ describe("UserIdentityWarning", () => {
|
|||||||
]);
|
]);
|
||||||
jest.spyOn(room, "getMember").mockReturnValue(mockRoomMember("@alice:example.org", "Alice"));
|
jest.spyOn(room, "getMember").mockReturnValue(mockRoomMember("@alice:example.org", "Alice"));
|
||||||
const crypto = client.getCrypto()!;
|
const crypto = client.getCrypto()!;
|
||||||
|
|
||||||
|
const firstStatusPromise = defer();
|
||||||
|
let callNumber = 0;
|
||||||
jest.spyOn(crypto, "getUserVerificationStatus").mockImplementation(async () => {
|
jest.spyOn(crypto, "getUserVerificationStatus").mockImplementation(async () => {
|
||||||
act(() => {
|
await firstStatusPromise.promise;
|
||||||
client.emit(
|
callNumber++;
|
||||||
CryptoEvent.UserTrustStatusChanged,
|
if (callNumber == 1) {
|
||||||
"@alice:example.org",
|
await sleep(40);
|
||||||
new UserVerificationStatus(false, false, false, true),
|
return new UserVerificationStatus(false, false, false, false);
|
||||||
);
|
} else {
|
||||||
});
|
return new UserVerificationStatus(false, false, false, true);
|
||||||
return Promise.resolve(new UserVerificationStatus(false, false, false, false));
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
renderComponent(client, room);
|
renderComponent(client, room);
|
||||||
await sleep(10); // give it some time to finish initialising
|
await sleep(10); // give it some time to finish initialising
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
client.emit(
|
||||||
|
CryptoEvent.UserTrustStatusChanged,
|
||||||
|
"@alice:example.org",
|
||||||
|
new UserVerificationStatus(false, false, false, true),
|
||||||
|
);
|
||||||
|
firstStatusPromise.resolve(undefined);
|
||||||
|
});
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(
|
expect(
|
||||||
getWarningByText("Alice's (@alice:example.org) identity appears to have changed."),
|
getWarningByText("Alice's (@alice:example.org) identity appears to have changed."),
|
||||||
).toBeInTheDocument(),
|
).toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Second case: check that if the update says that the user identity
|
|
||||||
// doesn't needs approval, but the fetch says it does, we don't show the
|
|
||||||
// warning.
|
|
||||||
it("update says identity doesn't need approval", async () => {
|
|
||||||
jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([
|
|
||||||
mockRoomMember("@alice:example.org", "Alice"),
|
|
||||||
]);
|
|
||||||
jest.spyOn(room, "getMember").mockReturnValue(mockRoomMember("@alice:example.org", "Alice"));
|
|
||||||
const crypto = client.getCrypto()!;
|
|
||||||
jest.spyOn(crypto, "getUserVerificationStatus").mockImplementation(async () => {
|
|
||||||
act(() => {
|
|
||||||
client.emit(
|
|
||||||
CryptoEvent.UserTrustStatusChanged,
|
|
||||||
"@alice:example.org",
|
|
||||||
new UserVerificationStatus(false, false, false, false),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return Promise.resolve(new UserVerificationStatus(false, false, false, true));
|
|
||||||
});
|
|
||||||
renderComponent(client, room);
|
|
||||||
await sleep(10); // give it some time to finish initialising
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(() =>
|
|
||||||
getWarningByText("Alice's (@alice:example.org) identity appears to have changed."),
|
|
||||||
).toThrow(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user