Only allow to start a DM with one email if encryption by default is enabled (#10253)

pull/28788/head^2
Michael Weimann 2023-03-06 12:08:17 +01:00 committed by GitHub
parent db6ee53535
commit 9b74b0f057
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 241 additions and 23 deletions

View File

@ -452,3 +452,8 @@ limitations under the License.
.mx_InviteDialog_identityServer { .mx_InviteDialog_identityServer {
margin-top: 1em; /* TODO: Use a spacing variable */ margin-top: 1em; /* TODO: Use a spacing variable */
} }
.mx_InviteDialog_oneThreepid {
font-size: $font-12px;
margin: $spacing-8 0;
}

View File

@ -73,13 +73,14 @@ import {
import { InviteKind } from "./InviteDialogTypes"; import { InviteKind } from "./InviteDialogTypes";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
import { privateShouldBeEncrypted } from "../../../utils/rooms";
// we have a number of types defined from the Matrix spec which can't reasonably be altered here. // we have a number of types defined from the Matrix spec which can't reasonably be altered here.
/* eslint-disable camelcase */ /* eslint-disable camelcase */
interface Result { interface Result {
userId: string; userId: string;
user: RoomMember | DirectoryMember | ThreepidMember; user: Member;
lastActive?: number; lastActive?: number;
} }
@ -130,6 +131,20 @@ class DMUserTile extends React.PureComponent<IDMUserTileProps> {
} }
} }
/**
* Converts a RoomMember to a Member.
* Returns the Member if it is already a Member.
*/
const toMember = (member: RoomMember | Member): Member => {
return member instanceof RoomMember
? new DirectoryMember({
user_id: member.userId,
display_name: member.name,
avatar_url: member.getMxcAvatarUrl(),
})
: member;
};
interface IDMRoomTileProps { interface IDMRoomTileProps {
member: Member; member: Member;
lastActiveTs?: number; lastActiveTs?: number;
@ -232,7 +247,7 @@ class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
const caption = (this.props.member as ThreepidMember).isEmail const caption = (this.props.member as ThreepidMember).isEmail
? _t("Invite by email") ? _t("Invite by email")
: this.highlightName(userIdentifier); : this.highlightName(userIdentifier || this.props.member.userId);
return ( return (
<div className="mx_InviteDialog_tile mx_InviteDialog_tile--room" onClick={this.onClick}> <div className="mx_InviteDialog_tile mx_InviteDialog_tile--room" onClick={this.onClick}>
@ -314,6 +329,7 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
private editorRef = createRef<HTMLInputElement>(); private editorRef = createRef<HTMLInputElement>();
private numberEntryFieldRef: React.RefObject<Field> = createRef(); private numberEntryFieldRef: React.RefObject<Field> = createRef();
private unmounted = false; private unmounted = false;
private encryptionByDefault = false;
public constructor(props: Props) { public constructor(props: Props) {
super(props); super(props);
@ -324,7 +340,10 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
throw new Error("When using InviteKind.CallTransfer a call is required for an InviteDialog"); throw new Error("When using InviteKind.CallTransfer a call is required for an InviteDialog");
} }
const alreadyInvited = new Set([MatrixClientPeg.get().getUserId()!, SdkConfig.get("welcome_user_id")]); const alreadyInvited = new Set([MatrixClientPeg.get().getUserId()!]);
const welcomeUserId = SdkConfig.get("welcome_user_id");
if (welcomeUserId) alreadyInvited.add(welcomeUserId);
if (isRoomInvite(props)) { if (isRoomInvite(props)) {
const room = MatrixClientPeg.get().getRoom(props.roomId); const room = MatrixClientPeg.get().getRoom(props.roomId);
if (!room) throw new Error("Room ID given to InviteDialog does not look like a room"); if (!room) throw new Error("Room ID given to InviteDialog does not look like a room");
@ -355,6 +374,8 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
} }
public componentDidMount(): void { public componentDidMount(): void {
this.encryptionByDefault = privateShouldBeEncrypted();
if (this.props.initialText) { if (this.props.initialText) {
this.updateSuggestions(this.props.initialText); this.updateSuggestions(this.props.initialText);
} }
@ -387,9 +408,10 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
const recents: { const recents: {
userId: string; userId: string;
user: RoomMember; user: Member;
lastActive: number; lastActive: number;
}[] = []; }[] = [];
for (const userId in rooms) { for (const userId in rooms) {
// Filter out user IDs that are already in the room / should be excluded // Filter out user IDs that are already in the room / should be excluded
if (excludedTargetIds.has(userId)) { if (excludedTargetIds.has(userId)) {
@ -398,8 +420,8 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
} }
const room = rooms[userId]; const room = rooms[userId];
const member = room.getMember(userId); const roomMember = room.getMember(userId);
if (!member) { if (!roomMember) {
// just skip people who don't have memberships for some reason // just skip people who don't have memberships for some reason
logger.warn(`[Invite:Recents] ${userId} is missing a member object in their own DM (${room.roomId})`); logger.warn(`[Invite:Recents] ${userId} is missing a member object in their own DM (${room.roomId})`);
continue; continue;
@ -425,7 +447,7 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
continue; continue;
} }
recents.push({ userId, user: member, lastActive: lastEventTs }); recents.push({ userId, user: toMember(roomMember), lastActive: lastEventTs });
} }
if (!recents) logger.warn("[Invite:Recents] No recents to suggest!"); if (!recents) logger.warn("[Invite:Recents] No recents to suggest!");
@ -435,17 +457,18 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
return recents; return recents;
} }
private buildSuggestions(excludedTargetIds: Set<string>): { userId: string; user: RoomMember }[] { private buildSuggestions(excludedTargetIds: Set<string>): { userId: string; user: Member }[] {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const activityScores = buildActivityScores(cli); const activityScores = buildActivityScores(cli);
const memberScores = buildMemberScores(cli); const memberScores = buildMemberScores(cli);
const memberComparator = compareMembers(activityScores, memberScores); const memberComparator = compareMembers(activityScores, memberScores);
return Object.values(memberScores) return Object.values(memberScores)
.map(({ member }) => member) .map(({ member }) => member)
.filter((member) => !excludedTargetIds.has(member.userId)) .filter((member) => !excludedTargetIds.has(member.userId))
.sort(memberComparator) .sort(memberComparator)
.map((member) => ({ userId: member.userId, user: member })); .map((member) => ({ userId: member.userId, user: toMember(member) }));
} }
private shouldAbortAfterInviteError(result: IInviteResult, room: Room): boolean { private shouldAbortAfterInviteError(result: IInviteResult, room: Room): boolean {
@ -458,14 +481,21 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
// Check to see if there's anything to convert first // Check to see if there's anything to convert first
if (!this.state.filterText || !this.state.filterText.includes("@")) return this.state.targets || []; if (!this.state.filterText || !this.state.filterText.includes("@")) return this.state.targets || [];
if (!this.canInviteMore()) {
// There should only be one third-party invite → do not allow more targets
return this.state.targets;
}
let newMember: Member | undefined; let newMember: Member | undefined;
if (this.state.filterText.startsWith("@")) { if (this.state.filterText.startsWith("@")) {
// Assume mxid // Assume mxid
newMember = new DirectoryMember({ user_id: this.state.filterText }); newMember = new DirectoryMember({ user_id: this.state.filterText });
} else if (SettingsStore.getValue(UIFeature.IdentityServer)) { } else if (SettingsStore.getValue(UIFeature.IdentityServer)) {
// Assume email // Assume email
if (this.canInviteThirdParty()) {
newMember = new ThreepidMember(this.state.filterText); newMember = new ThreepidMember(this.state.filterText);
} }
}
if (!newMember) return this.state.targets; if (!newMember) return this.state.targets;
const newTargets = [...(this.state.targets || []), newMember]; const newTargets = [...(this.state.targets || []), newMember];
@ -657,7 +687,7 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
this.setState({ tryingIdentityServer: true }); this.setState({ tryingIdentityServer: true });
return; return;
} }
if (Email.looksValid(term) && SettingsStore.getValue(UIFeature.IdentityServer)) { if (Email.looksValid(term) && this.canInviteThirdParty() && SettingsStore.getValue(UIFeature.IdentityServer)) {
// Start off by suggesting the plain email while we try and resolve it // Start off by suggesting the plain email while we try and resolve it
// to a real account. // to a real account.
this.setState({ this.setState({
@ -667,6 +697,9 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
try { try {
const authClient = new IdentityAuthClient(); const authClient = new IdentityAuthClient();
const token = await authClient.getAccessToken(); const token = await authClient.getAccessToken();
// No token → unable to try a lookup
if (!token) return;
if (term !== this.state.filterText) return; // abandon hope if (term !== this.state.filterText) return; // abandon hope
const lookup = await MatrixClientPeg.get().lookupThreePid("email", term, token); const lookup = await MatrixClientPeg.get().lookupThreePid("email", term, token);
@ -764,6 +797,13 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
} }
}; };
private parseFilter(filter: string): string[] {
return filter
.split(/[\s,]+/)
.map((p) => p.trim())
.filter((p) => !!p); // filter empty strings
}
private onPaste = async (e: React.ClipboardEvent): Promise<void> => { private onPaste = async (e: React.ClipboardEvent): Promise<void> => {
if (this.state.filterText) { if (this.state.filterText) {
// if the user has already typed something, just let them // if the user has already typed something, just let them
@ -785,19 +825,32 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
]; ];
const toAdd: Member[] = []; const toAdd: Member[] = [];
const failed: string[] = []; const failed: string[] = [];
const potentialAddresses = text
.split(/[\s,]+/) // Addresses that could not be added.
.map((p) => p.trim()) // Will be displayed as filter text to provide feedback.
.filter((p) => !!p); // filter empty strings const unableToAddMore: string[] = [];
const potentialAddresses = this.parseFilter(text);
for (const address of potentialAddresses) { for (const address of potentialAddresses) {
const member = possibleMembers.find((m) => m.userId === address); const member = possibleMembers.find((m) => m.userId === address);
if (member) { if (member) {
if (this.canInviteMore([...this.state.targets, ...toAdd])) {
toAdd.push(member.user); toAdd.push(member.user);
} else {
// Invite not possible for current targets and pasted targets.
unableToAddMore.push(address);
}
continue; continue;
} }
if (Email.looksValid(address)) { if (Email.looksValid(address)) {
if (this.canInviteThirdParty([...this.state.targets, ...toAdd])) {
toAdd.push(new ThreepidMember(address)); toAdd.push(new ThreepidMember(address));
} else {
// Third-party invite not possible for current targets and pasted targets.
unableToAddMore.push(address);
}
continue; continue;
} }
@ -834,7 +887,16 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
}); });
} }
this.setState({ targets: [...this.state.targets, ...toAdd] }); if (unableToAddMore) {
this.setState({
filterText: unableToAddMore.join(" "),
targets: [...this.state.targets, ...toAdd],
});
} else {
this.setState({
targets: [...this.state.targets, ...toAdd],
});
}
}; };
private onClickInputArea = (e: React.MouseEvent): void => { private onClickInputArea = (e: React.MouseEvent): void => {
@ -898,6 +960,11 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
// Hide the section if there's nothing to filter by // Hide the section if there's nothing to filter by
if (sourceMembers.length === 0 && !hasAdditionalMembers) return null; if (sourceMembers.length === 0 && !hasAdditionalMembers) return null;
if (!this.canInviteThirdParty()) {
// It is currently not allowed to add more third-party invites. Filter them out.
priorityAdditionalMembers = priorityAdditionalMembers.filter((s) => s instanceof ThreepidMember);
}
// Do some simple filtering on the input before going much further. If we get no results, say so. // Do some simple filtering on the input before going much further. If we get no results, say so.
if (this.state.filterText) { if (this.state.filterText) {
const filterBy = this.state.filterText.toLowerCase(); const filterBy = this.state.filterText.toLowerCase();
@ -1092,6 +1159,42 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
} }
} }
/**
* If encryption by default is enabled, third-party invites should be encrypted as well.
* For encryption to work, the other side requires a device.
* To achieve this Element implements a waiting room until all have joined.
* Waiting for many users degrades the UX only one email invite is allowed at a time.
*
* @param targets - Optional member list to check. Uses targets from state if not provided.
*/
private canInviteMore(targets?: (Member | RoomMember)[]): boolean {
targets = targets || this.state.targets;
return this.canInviteThirdParty(targets) || !targets.some((t) => t instanceof ThreepidMember);
}
/**
* A third-party invite is possible if
* - this is a non-DM dialog or
* - there are no invites yet or
* - encryption by default is not enabled
*
* Also see {@link InviteDialog#canInviteMore}.
*
* @param targets - Optional member list to check. Uses targets from state if not provided.
*/
private canInviteThirdParty(targets?: (Member | RoomMember)[]): boolean {
targets = targets || this.state.targets;
return this.props.kind !== InviteKind.Dm || targets.length === 0 || !this.encryptionByDefault;
}
private hasFilterAtLeastOneEmail(): boolean {
if (!this.state.filterText) return false;
return this.parseFilter(this.state.filterText).some((address: string) => {
return Email.looksValid(address);
});
}
public render(): React.ReactNode { public render(): React.ReactNode {
let spinner: JSX.Element | undefined; let spinner: JSX.Element | undefined;
if (this.state.busy) { if (this.state.busy) {
@ -1277,6 +1380,26 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
</AccessibleButton> </AccessibleButton>
); );
let results: React.ReactNode | null = null;
let onlyOneThreepidNote: React.ReactNode | null = null;
if (!this.canInviteMore() || (this.hasFilterAtLeastOneEmail() && !this.canInviteThirdParty())) {
// We are in DM case here, because of the checks in canInviteMore() / canInviteThirdParty().
onlyOneThreepidNote = (
<div className="mx_InviteDialog_oneThreepid">
{_t("Invites by email can only be sent one at a time")}
</div>
);
} else {
results = (
<div className="mx_InviteDialog_userSections">
{this.renderSection("recents")}
{this.renderSection("suggestions")}
{extraSection}
</div>
);
}
const usersSection = ( const usersSection = (
<React.Fragment> <React.Fragment>
<p className="mx_InviteDialog_helpText">{helpText}</p> <p className="mx_InviteDialog_helpText">{helpText}</p>
@ -1290,11 +1413,8 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
{keySharingWarning} {keySharingWarning}
{this.renderIdentityServerWarning()} {this.renderIdentityServerWarning()}
<div className="error">{this.state.errorText}</div> <div className="error">{this.state.errorText}</div>
<div className="mx_InviteDialog_userSections"> {onlyOneThreepidNote}
{this.renderSection("recents")} {results}
{this.renderSection("suggestions")}
{extraSection}
</div>
{footer} {footer}
</React.Fragment> </React.Fragment>
); );

View File

@ -2863,6 +2863,7 @@
"Invited people will be able to read old messages.": "Invited people will be able to read old messages.", "Invited people will be able to read old messages.": "Invited people will be able to read old messages.",
"Transfer": "Transfer", "Transfer": "Transfer",
"Consult first": "Consult first", "Consult first": "Consult first",
"Invites by email can only be sent one at a time": "Invites by email can only be sent one at a time",
"User Directory": "User Directory", "User Directory": "User Directory",
"Dial pad": "Dial pad", "Dial pad": "Dial pad",
"a new master key signature": "a new master key signature", "a new master key signature": "a new master key signature",

View File

@ -35,11 +35,37 @@ jest.mock("../../../../src/IdentityAuthClient", () =>
})), })),
); );
const getSearchField = () => screen.getByTestId("invite-dialog-input");
const enterIntoSearchField = async (value: string) => {
const searchField = getSearchField();
await userEvent.clear(searchField);
await userEvent.type(searchField, value + "{enter}");
};
const pasteIntoSearchField = async (value: string) => {
const searchField = getSearchField();
await userEvent.clear(searchField);
searchField.focus();
await userEvent.paste(value);
};
const expectPill = (value: string) => {
expect(screen.getByText(value)).toBeInTheDocument();
expect(getSearchField()).toHaveValue("");
};
const expectNoPill = (value: string) => {
expect(screen.queryByText(value)).not.toBeInTheDocument();
expect(getSearchField()).toHaveValue(value);
};
describe("InviteDialog", () => { describe("InviteDialog", () => {
const roomId = "!111111111111111111:example.org"; const roomId = "!111111111111111111:example.org";
const aliceId = "@alice:example.org"; const aliceId = "@alice:example.org";
const aliceEmail = "foobar@email.com"; const aliceEmail = "foobar@email.com";
const bobId = "@bob:example.org"; const bobId = "@bob:example.org";
const bobEmail = "bobbob@example.com"; // bob@example.com is already used as an example in the invite dialog
const mockClient = getMockClientWithEventEmitter({ const mockClient = getMockClientWithEventEmitter({
getUserId: jest.fn().mockReturnValue(bobId), getUserId: jest.fn().mockReturnValue(bobId),
getSafeUserId: jest.fn().mockReturnValue(bobId), getSafeUserId: jest.fn().mockReturnValue(bobId),
@ -64,6 +90,7 @@ describe("InviteDialog", () => {
getTerms: jest.fn().mockResolvedValue({ policies: [] }), getTerms: jest.fn().mockResolvedValue({ policies: [] }),
supportsThreads: jest.fn().mockReturnValue(false), supportsThreads: jest.fn().mockReturnValue(false),
isInitialSyncComplete: jest.fn().mockReturnValue(true), isInitialSyncComplete: jest.fn().mockReturnValue(true),
getClientWellKnown: jest.fn(),
}); });
let room: Room; let room: Room;
@ -72,6 +99,7 @@ describe("InviteDialog", () => {
DMRoomMap.makeShared(); DMRoomMap.makeShared();
jest.clearAllMocks(); jest.clearAllMocks();
mockClient.getUserId.mockReturnValue(bobId); mockClient.getUserId.mockReturnValue(bobId);
mockClient.getClientWellKnown.mockReturnValue({});
room = new Room(roomId, mockClient, mockClient.getSafeUserId()); room = new Room(roomId, mockClient, mockClient.getSafeUserId());
room.addLiveEvents([ room.addLiveEvents([
@ -208,4 +236,68 @@ describe("InviteDialog", () => {
await screen.findByText(aliceEmail); await screen.findByText(aliceEmail);
expect(input).toHaveValue(""); expect(input).toHaveValue("");
}); });
it("should allow to invite multiple emails to a room", async () => {
render(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={jest.fn()} />);
await enterIntoSearchField(aliceEmail);
expectPill(aliceEmail);
await enterIntoSearchField(bobEmail);
expectPill(bobEmail);
});
describe("when encryption by default is disabled", () => {
beforeEach(() => {
mockClient.getClientWellKnown.mockReturnValue({
"io.element.e2ee": {
default: false,
},
});
});
it("should allow to invite more than one email to a DM", async () => {
render(<InviteDialog kind={InviteKind.Dm} onFinished={jest.fn()} />);
await enterIntoSearchField(aliceEmail);
expectPill(aliceEmail);
await enterIntoSearchField(bobEmail);
expectPill(bobEmail);
});
});
it("should not allow to invite more than one email to a DM", async () => {
render(<InviteDialog kind={InviteKind.Dm} onFinished={jest.fn()} />);
// Start with an email → should convert to a pill
await enterIntoSearchField(aliceEmail);
expect(screen.getByText("Invites by email can only be sent one at a time")).toBeInTheDocument();
expectPill(aliceEmail);
// Everything else from now on should not convert to a pill
await enterIntoSearchField(bobEmail);
expectNoPill(bobEmail);
await enterIntoSearchField(aliceId);
expectNoPill(aliceId);
await pasteIntoSearchField(bobEmail);
expectNoPill(bobEmail);
});
it("should not allow to invite a MXID and an email to a DM", async () => {
render(<InviteDialog kind={InviteKind.Dm} onFinished={jest.fn()} />);
// Start with a MXID → should convert to a pill
await enterIntoSearchField("@carol:example.com");
expect(screen.queryByText("Invites by email can only be sent one at a time")).not.toBeInTheDocument();
expectPill("@carol:example.com");
// Add an email → should not convert to a pill
await enterIntoSearchField(bobEmail);
expect(screen.getByText("Invites by email can only be sent one at a time")).toBeInTheDocument();
expectNoPill(bobEmail);
});
}); });