Refactor the various email/phone management UI into a single component (#12884)

* Refactor the various email/phone management UI into a single component

These were basically the same component copied & pasted 3 times and
tweaked to match the behaviour of each case. This de-dupes them into
one component.

This all could really benefit from playwright tests, but would require
setting up a dummy ID server in the playwright tests. This is all legacy
pre-MAS stuff so its questionable whether its worth the effort.

* Basic test, remove old tests

* Use different text to confirm remove & put headers back

although the two texts are both 'Remove' in practice

* Remove string

This was never triggered anyway with sydent & synapse because they
don't seem to agree on what error to return. In any case, I think it
makes more sense for it to be consistent with the email path, ie. using
a dialog.

* Avoid nested forms

* Snapshots

* More snapshots

* Test the hs side

* Snapshots

* Test IS bind/revoke

* Test remove can be cancelled

* Test unvalidated cases & fix phone error

* Reset state between tests

* Import useState directly

* One more direct React import
This commit is contained in:
David Baker 2024-08-14 14:13:57 +01:00 committed by GitHub
parent de898d1b62
commit 4751c52d82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1391 additions and 1981 deletions

View file

@ -37,7 +37,7 @@
@import "./components/views/messages/shared/_MediaProcessingError.pcss";
@import "./components/views/pips/_WidgetPip.pcss";
@import "./components/views/polls/_PollOption.pcss";
@import "./components/views/settings/_EmailAddressesPhoneNumbers.pcss";
@import "./components/views/settings/_AddRemoveThreepids.pcss";
@import "./components/views/settings/devices/_CurrentDeviceSection.pcss";
@import "./components/views/settings/devices/_DeviceDetailHeading.pcss";
@import "./components/views/settings/devices/_DeviceDetails.pcss";

View file

@ -21,17 +21,29 @@ limitations under the License.
* tab sensibly and before I can refactor these components.
*/
.mx_EmailAddressesPhoneNumbers_discovery_existing {
.mx_AddRemoveThreepids_existing {
display: flex;
align-items: center;
}
.mx_EmailAddressesPhoneNumbers_discovery_existing_address,
.mx_EmailAddressesPhoneNumbers_discovery_existing_promptText {
.mx_AddRemoveThreepids_existing_address,
.mx_AddRemoveThreepids_existing_promptText {
flex: 1;
margin-right: 10px;
}
.mx_EmailAddressesPhoneNumbers_discovery_existing_button {
.mx_AddRemoveThreepids_existing_button {
margin-left: 5px;
}
.mx_EmailAddressesPhoneNumbers_verify {
display: flex;
}
.mx_EmailAddressesPhoneNumbers_existing_button {
justify-content: right;
}
.mx_EmailAddressesPhoneNumbers_verify_instructions {
flex: 1;
}

View file

@ -271,9 +271,7 @@ export default class AddThreepid {
* with a "message" property which contains a human-readable message detailing why
* the request failed.
*/
public async haveMsisdnToken(
msisdnToken: string,
): Promise<[success?: boolean, result?: IAuthData | Error | null] | undefined> {
public async haveMsisdnToken(msisdnToken: string): Promise<[success?: boolean, result?: IAuthData | Error | null]> {
const authClient = new IdentityAuthClient();
if (this.submitUrl) {
@ -301,13 +299,14 @@ export default class AddThreepid {
id_server: getIdServerDomain(this.matrixClient),
id_access_token: await authClient.getAccessToken(),
});
return [true];
} else {
try {
await this.makeAddThreepidOnlyRequest();
// The spec has always required this to use UI auth but synapse briefly
// implemented it without, so this may just succeed and that's OK.
return;
return [true];
} catch (err) {
if (!(err instanceof MatrixError) || err.httpStatus !== 401 || !err.data || !err.data.flows) {
// doesn't look like an interactive-auth failure

View file

@ -0,0 +1,534 @@
/*
Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useRef, useState } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import {
IRequestMsisdnTokenResponse,
IRequestTokenResponse,
MatrixError,
ThreepidMedium,
} from "matrix-js-sdk/src/matrix";
import AddThreepid, { Binding, ThirdPartyIdentifier } from "../../../AddThreepid";
import { _t, UserFriendlyError } from "../../../languageHandler";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import Modal from "../../../Modal";
import ErrorDialog, { extractErrorMessageFromError } from "../dialogs/ErrorDialog";
import Field from "../elements/Field";
import { looksValid as emailLooksValid } from "../../../email";
import CountryDropdown from "../auth/CountryDropdown";
import { PhoneNumberCountryDefinition } from "../../../phonenumber";
import InlineSpinner from "../elements/InlineSpinner";
// Whether we're adding 3pids to the user's account on the homeserver or sharing them on an identity server
type TheepidControlMode = "hs" | "is";
interface ExistingThreepidProps {
mode: TheepidControlMode;
threepid: ThirdPartyIdentifier;
onChange: (threepid: ThirdPartyIdentifier) => void;
disabled?: boolean;
}
const ExistingThreepid: React.FC<ExistingThreepidProps> = ({ mode, threepid, onChange, disabled }) => {
const [isConfirming, setIsConfirming] = useState(false);
const client = useMatrixClientContext();
const bindTask = useRef<AddThreepid | undefined>();
const [isVerifyingBind, setIsVerifyingBind] = useState(false);
const [continueDisabled, setContinueDisabled] = useState(false);
const [verificationCode, setVerificationCode] = useState("");
const onRemoveClick = useCallback((e: ButtonEvent) => {
e.stopPropagation();
e.preventDefault();
setIsConfirming(true);
}, []);
const onCancelClick = useCallback((e: ButtonEvent) => {
e.stopPropagation();
e.preventDefault();
setIsConfirming(false);
}, []);
const onConfirmRemoveClick = useCallback(
(e: ButtonEvent) => {
e.stopPropagation();
e.preventDefault();
client
.deleteThreePid(threepid.medium, threepid.address)
.then(() => {
return onChange(threepid);
})
.catch((err) => {
logger.error("Unable to remove contact information: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_remove_3pid"),
description: err && err.message ? err.message : _t("invite|failed_generic"),
});
});
},
[client, threepid, onChange],
);
const changeBinding = useCallback(
async ({ bind, label, errorTitle }: Binding) => {
try {
if (bind) {
bindTask.current = new AddThreepid(client);
setContinueDisabled(true);
if (threepid.medium === ThreepidMedium.Email) {
await bindTask.current.bindEmailAddress(threepid.address);
} else {
// XXX: Sydent will accept a number without country code if you add
// a leading plus sign to a number in E.164 format (which the 3PID
// address is), but this goes against the spec.
// See https://github.com/matrix-org/matrix-doc/issues/2222
await bindTask.current.bindMsisdn(null as unknown as string, `+${threepid.address}`);
}
setContinueDisabled(false);
setIsVerifyingBind(true);
} else {
await client.unbindThreePid(threepid.medium, threepid.address);
onChange(threepid);
}
} catch (err) {
logger.error(`changeBinding: Unable to ${label} email address ${threepid.address}`, err);
setIsVerifyingBind(false);
setContinueDisabled(false);
bindTask.current = undefined;
Modal.createDialog(ErrorDialog, {
title: errorTitle,
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
}
},
[client, threepid, onChange],
);
const onRevokeClick = useCallback(
(e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
changeBinding({
bind: false,
label: "revoke",
errorTitle:
threepid.medium === "email"
? _t("settings|general|error_revoke_email_discovery")
: _t("settings|general|error_revoke_msisdn_discovery"),
}).then();
},
[changeBinding, threepid.medium],
);
const onShareClick = useCallback(
(e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
changeBinding({
bind: true,
label: "share",
errorTitle:
threepid.medium === "email"
? _t("settings|general|error_share_email_discovery")
: _t("settings|general|error_share_msisdn_discovery"),
}).then();
},
[changeBinding, threepid.medium],
);
const onContinueClick = useCallback(
async (e: ButtonEvent) => {
e.stopPropagation();
e.preventDefault();
setContinueDisabled(true);
try {
if (threepid.medium === ThreepidMedium.Email) {
await bindTask.current?.checkEmailLinkClicked();
} else {
await bindTask.current?.haveMsisdnToken(verificationCode);
}
setIsVerifyingBind(false);
onChange(threepid);
bindTask.current = undefined;
} catch (err) {
logger.error(`Unable to verify threepid:`, err);
let underlyingError = err;
if (err instanceof UserFriendlyError) {
underlyingError = err.cause;
}
if (underlyingError instanceof MatrixError && underlyingError.errcode === "M_THREEPID_AUTH_FAILED") {
Modal.createDialog(ErrorDialog, {
title:
threepid.medium === "email"
? _t("settings|general|email_not_verified")
: _t("settings|general|error_msisdn_verification"),
description:
threepid.medium === "email"
? _t("settings|general|email_verification_instructions")
: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
} else {
logger.error("Unable to verify email address: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_email_verification"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
}
} finally {
setContinueDisabled(false);
}
},
[verificationCode, onChange, threepid],
);
const onVerificationCodeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setVerificationCode(e.target.value);
}, []);
if (isConfirming) {
return (
<div className="mx_AddRemoveThreepids_existing">
<span className="mx_AddRemoveThreepids_existing_promptText">
{threepid.medium === ThreepidMedium.Email
? _t("settings|general|remove_email_prompt", { email: threepid.address })
: _t("settings|general|remove_msisdn_prompt", { phone: threepid.address })}
</span>
<AccessibleButton
onClick={onConfirmRemoveClick}
kind="danger_sm"
className="mx_AddRemoveThreepids_existing_button"
>
{_t("action|remove")}
</AccessibleButton>
<AccessibleButton
onClick={onCancelClick}
kind="link_sm"
className="mx_AddRemoveThreepids_existing_button"
>
{_t("action|cancel")}
</AccessibleButton>
</div>
);
}
if (isVerifyingBind) {
if (threepid.medium === ThreepidMedium.Email) {
return (
<div className="mx_EmailAddressesPhoneNumbers_verify">
<span className="mx_EmailAddressesPhoneNumbers_verify_instructions">
{_t("settings|general|discovery_email_verification_instructions")}
</span>
<AccessibleButton
className="mx_EmailAddressesPhoneNumbers_existing_button"
kind="primary_sm"
onClick={onContinueClick}
disabled={continueDisabled}
>
{_t("action|complete")}
</AccessibleButton>
</div>
);
} else {
return (
<div className="mx_EmailAddressesPhoneNumbers_verify">
<span className="mx_EmailAddressesPhoneNumbers_verify_instructions">
{_t("settings|general|msisdn_verification_instructions")}
</span>
<form onSubmit={onContinueClick} autoComplete="off" noValidate={true}>
<Field
type="text"
label={_t("settings|general|msisdn_verification_field_label")}
autoComplete="off"
disabled={continueDisabled}
value={verificationCode}
onChange={onVerificationCodeChange}
/>
</form>
</div>
);
}
}
return (
<div className="mx_AddRemoveThreepids_existing">
<span className="mx_AddRemoveThreepids_existing_address">{threepid.address}</span>
<AccessibleButton
onClick={mode === "hs" ? onRemoveClick : threepid.bound ? onRevokeClick : onShareClick}
kind={mode === "hs" || threepid.bound ? "danger_sm" : "primary_sm"}
disabled={disabled}
>
{mode === "hs" ? _t("action|remove") : threepid.bound ? _t("action|revoke") : _t("action|share")}
</AccessibleButton>
</div>
);
};
function isMsisdnResponse(
resp: IRequestTokenResponse | IRequestMsisdnTokenResponse,
): resp is IRequestMsisdnTokenResponse {
return (resp as IRequestMsisdnTokenResponse).msisdn !== undefined;
}
const AddThreepidSection: React.FC<{ medium: "email" | "msisdn"; disabled?: boolean; onChange: () => void }> = ({
medium,
disabled,
onChange,
}) => {
const addTask = useRef<AddThreepid | undefined>();
const [newThreepidInput, setNewThreepidInput] = useState("");
const [phoneCountryInput, setPhoneCountryInput] = useState("");
const [verificationCodeInput, setVerificationCodeInput] = useState("");
const [isVerifying, setIsVerifying] = useState(false);
const [continueDisabled, setContinueDisabled] = useState(false);
const [sentToMsisdn, setSentToMsisdn] = useState("");
const client = useMatrixClientContext();
const onPhoneCountryChanged = useCallback((country: PhoneNumberCountryDefinition) => {
setPhoneCountryInput(country.iso2);
}, []);
const onContinueClick = useCallback(
(e: ButtonEvent) => {
e.stopPropagation();
e.preventDefault();
if (!addTask.current) return;
setContinueDisabled(true);
const checkPromise =
medium === "email"
? addTask.current?.checkEmailLinkClicked()
: addTask.current?.haveMsisdnToken(verificationCodeInput);
checkPromise
.then(([finished]) => {
if (finished) {
addTask.current = undefined;
setIsVerifying(false);
setNewThreepidInput("");
onChange();
}
setContinueDisabled(false);
})
.catch((err) => {
logger.error("Unable to verify 3pid: ", err);
setContinueDisabled(false);
let underlyingError = err;
if (err instanceof UserFriendlyError) {
underlyingError = err.cause;
}
if (
underlyingError instanceof MatrixError &&
underlyingError.errcode === "M_THREEPID_AUTH_FAILED"
) {
Modal.createDialog(ErrorDialog, {
title:
medium === "email"
? _t("settings|general|email_not_verified")
: _t("settings|general|error_msisdn_verification"),
description: _t("settings|general|email_verification_instructions"),
});
} else {
Modal.createDialog(ErrorDialog, {
title:
medium == "email"
? _t("settings|general|error_email_verification")
: _t("settings|general|error_msisdn_verification"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
}
});
},
[onChange, medium, verificationCodeInput],
);
const onNewThreepidInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setNewThreepidInput(e.target.value);
}, []);
const onAddClick = useCallback(
(e: React.FormEvent) => {
e.stopPropagation();
e.preventDefault();
if (!newThreepidInput) return;
// TODO: Inline field validation
if (medium === "email" && !emailLooksValid(newThreepidInput)) {
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_invalid_email"),
description: _t("settings|general|error_invalid_email_detail"),
});
return;
}
addTask.current = new AddThreepid(client);
setIsVerifying(true);
setContinueDisabled(true);
const addPromise =
medium === "email"
? addTask.current.addEmailAddress(newThreepidInput)
: addTask.current.addMsisdn(phoneCountryInput, newThreepidInput);
addPromise
.then((resp: IRequestTokenResponse | IRequestMsisdnTokenResponse) => {
setContinueDisabled(false);
if (isMsisdnResponse(resp)) {
setSentToMsisdn(resp.msisdn);
}
})
.catch((err) => {
logger.error(`Unable to add threepid ${newThreepidInput}`, err);
setIsVerifying(false);
setContinueDisabled(false);
addTask.current = undefined;
Modal.createDialog(ErrorDialog, {
title: medium === "email" ? _t("settings|general|error_add_email") : _t("common|error"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
});
},
[client, phoneCountryInput, newThreepidInput, medium],
);
const onVerificationCodeInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setVerificationCodeInput(e.target.value);
}, []);
if (isVerifying && medium === "email") {
return (
<div>
<div>{_t("settings|general|add_email_instructions")}</div>
<AccessibleButton onClick={onContinueClick} kind="primary" disabled={continueDisabled}>
{_t("action|continue")}
</AccessibleButton>
</div>
);
} else if (isVerifying) {
return (
<div>
<div>
{_t("settings|general|add_msisdn_instructions", { msisdn: sentToMsisdn })}
<br />
</div>
<form onSubmit={onContinueClick} autoComplete="off" noValidate={true}>
<Field
type="text"
label={_t("settings|general|msisdn_verification_field_label")}
autoComplete="off"
disabled={disabled || continueDisabled}
value={verificationCodeInput}
onChange={onVerificationCodeInputChange}
/>
<AccessibleButton
onClick={onContinueClick}
kind="primary"
disabled={disabled || continueDisabled || verificationCodeInput.length === 0}
>
{_t("action|continue")}
</AccessibleButton>
</form>
</div>
);
}
const phoneCountry =
medium === "msisdn" ? (
<CountryDropdown
onOptionChange={onPhoneCountryChanged}
className="mx_PhoneNumbers_country"
value={phoneCountryInput}
disabled={isVerifying}
isSmall={true}
showPrefix={true}
/>
) : undefined;
return (
<form onSubmit={onAddClick} autoComplete="off" noValidate={true}>
<Field
type="text"
label={
medium === "email"
? _t("settings|general|email_address_label")
: _t("settings|general|msisdn_label")
}
autoComplete={medium === "email" ? "email" : "tel-national"}
disabled={disabled || isVerifying}
value={newThreepidInput}
onChange={onNewThreepidInputChange}
prefixComponent={phoneCountry}
/>
<AccessibleButton onClick={onAddClick} kind="primary" disabled={disabled}>
{_t("action|add")}
</AccessibleButton>
</form>
);
};
interface AddRemoveThreepidsProps {
// Whether the control is for adding 3pids to the user's homeserver account or sharing them on an IS
mode: TheepidControlMode;
// Whether the control is for emails or phone numbers
medium: ThreepidMedium;
// The current list of third party identifiers
threepids: ThirdPartyIdentifier[];
// If true, the component is disabled and no third party identifiers can be added or removed
disabled?: boolean;
// Called when changes are made to the list of third party identifiers
onChange: () => void;
// If true, a spinner is shown instead of the component
isLoading: boolean;
}
export const AddRemoveThreepids: React.FC<AddRemoveThreepidsProps> = ({
mode,
medium,
threepids,
disabled,
onChange,
isLoading,
}) => {
if (isLoading) {
return <InlineSpinner />;
}
const existingEmailElements = threepids.map((e) => {
return <ExistingThreepid mode={mode} threepid={e} onChange={onChange} key={e.address} disabled={disabled} />;
});
return (
<>
{existingEmailElements}
{mode === "hs" && <AddThreepidSection medium={medium} disabled={disabled} onChange={onChange} />}
</>
);
};

View file

@ -18,8 +18,6 @@ import React, { useCallback, useEffect, useState } from "react";
import { ThreepidMedium } from "matrix-js-sdk/src/matrix";
import { Alert } from "@vector-im/compound-web";
import AccountEmailAddresses from "./account/EmailAddresses";
import AccountPhoneNumbers from "./account/PhoneNumbers";
import { _t } from "../../../languageHandler";
import InlineSpinner from "../elements/InlineSpinner";
import SettingsSubsection from "./shared/SettingsSubsection";
@ -27,6 +25,7 @@ import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { ThirdPartyIdentifier } from "../../../AddThreepid";
import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature";
import { AddRemoveThreepids } from "./AddRemoveThreepids";
type LoadingState = "loading" | "loaded" | "error";
@ -64,8 +63,7 @@ export const UserPersonalInfoSettings: React.FC<UserPersonalInfoSettingsProps> =
const client = useMatrixClientContext();
useEffect(() => {
(async () => {
const updateThreepids = useCallback(async () => {
try {
const threepids = await client.getThreePids();
setEmails(threepids.threepids.filter((a) => a.medium === ThreepidMedium.Email));
@ -74,16 +72,19 @@ export const UserPersonalInfoSettings: React.FC<UserPersonalInfoSettingsProps> =
} catch (e) {
setLoadingState("error");
}
})();
}, [client]);
const onEmailsChange = useCallback((emails: ThirdPartyIdentifier[]) => {
setEmails(emails);
}, []);
useEffect(() => {
updateThreepids().then();
}, [updateThreepids]);
const onMsisdnsChange = useCallback((msisdns: ThirdPartyIdentifier[]) => {
setPhoneNumbers(msisdns);
}, []);
const onEmailsChange = useCallback(() => {
updateThreepids().then();
}, [updateThreepids]);
const onMsisdnsChange = useCallback(() => {
updateThreepids().then();
}, [updateThreepids]);
if (!SettingsStore.getValue(UIFeature.ThirdPartyID)) return null;
@ -99,10 +100,13 @@ export const UserPersonalInfoSettings: React.FC<UserPersonalInfoSettingsProps> =
error={_t("settings|general|unable_to_load_emails")}
loadingState={loadingState}
>
<AccountEmailAddresses
emails={emails!}
onEmailsChange={onEmailsChange}
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Email}
threepids={emails!}
onChange={onEmailsChange}
disabled={!canMake3pidChanges}
isLoading={loadingState === "loading"}
/>
</ThreepidSectionWrapper>
</SettingsSubsection>
@ -116,10 +120,13 @@ export const UserPersonalInfoSettings: React.FC<UserPersonalInfoSettingsProps> =
error={_t("settings|general|unable_to_load_msisdns")}
loadingState={loadingState}
>
<AccountPhoneNumbers
msisdns={phoneNumbers!}
onMsisdnsChange={onMsisdnsChange}
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Phone}
threepids={phoneNumbers!}
onChange={onMsisdnsChange}
disabled={!canMake3pidChanges}
isLoading={loadingState === "loading"}
/>
</ThreepidSectionWrapper>
</SettingsSubsection>

View file

@ -1,303 +0,0 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { ThreepidMedium, MatrixError } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { _t, UserFriendlyError } from "../../../../languageHandler";
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import Field from "../../elements/Field";
import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton";
import * as Email from "../../../../email";
import AddThreepid, { ThirdPartyIdentifier } from "../../../../AddThreepid";
import Modal from "../../../../Modal";
import ErrorDialog, { extractErrorMessageFromError } from "../../dialogs/ErrorDialog";
/*
TODO: Improve the UX for everything in here.
It's very much placeholder, but it gets the job done. The old way of handling
email addresses in user settings was to use dialogs to communicate state, however
due to our dialog system overriding dialogs (causing unmounts) this creates problems
for a sane UX. For instance, the user could easily end up entering an email address
and receive a dialog to verify the address, which then causes the component here
to forget what it was doing and ultimately fail. Dialogs are still used in some
places to communicate errors - these should be replaced with inline validation when
that is available.
*/
interface IExistingEmailAddressProps {
email: ThirdPartyIdentifier;
onRemoved: (emails: ThirdPartyIdentifier) => void;
/**
* Disallow removal of this email address when truthy
*/
disabled?: boolean;
}
interface IExistingEmailAddressState {
verifyRemove: boolean;
}
export class ExistingEmailAddress extends React.Component<IExistingEmailAddressProps, IExistingEmailAddressState> {
public constructor(props: IExistingEmailAddressProps) {
super(props);
this.state = {
verifyRemove: false,
};
}
private onRemove = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
this.setState({ verifyRemove: true });
};
private onDontRemove = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
this.setState({ verifyRemove: false });
};
private onActuallyRemove = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
MatrixClientPeg.safeGet()
.deleteThreePid(this.props.email.medium, this.props.email.address)
.then(() => {
return this.props.onRemoved(this.props.email);
})
.catch((err) => {
logger.error("Unable to remove contact information: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_remove_3pid"),
description: err && err.message ? err.message : _t("invite|failed_generic"),
});
});
};
public render(): React.ReactNode {
if (this.state.verifyRemove) {
return (
<div className="mx_EmailAddressesPhoneNumbers_discovery_existing">
<span className="mx_EmailAddressesPhoneNumbers_discovery_existing_promptText">
{_t("settings|general|remove_email_prompt", { email: this.props.email.address })}
</span>
<AccessibleButton
onClick={this.onActuallyRemove}
kind="danger_sm"
className="mx_EmailAddressesPhoneNumbers_discovery_existing_button"
>
{_t("action|remove")}
</AccessibleButton>
<AccessibleButton
onClick={this.onDontRemove}
kind="link_sm"
className="mx_EmailAddressesPhoneNumbers_discovery_existing_button"
>
{_t("action|cancel")}
</AccessibleButton>
</div>
);
}
return (
<div className="mx_EmailAddressesPhoneNumbers_discovery_existing">
<span className="mx_EmailAddressesPhoneNumbers_discovery_existing_address">
{this.props.email.address}
</span>
<AccessibleButton onClick={this.onRemove} kind="danger_sm" disabled={this.props.disabled}>
{_t("action|remove")}
</AccessibleButton>
</div>
);
}
}
interface IProps {
emails: ThirdPartyIdentifier[];
onEmailsChange: (emails: ThirdPartyIdentifier[]) => void;
/**
* Adding or removing emails is disabled when truthy
*/
disabled?: boolean;
}
interface IState {
verifying: boolean;
addTask: AddThreepid | null;
continueDisabled: boolean;
newEmailAddress: string;
}
export default class EmailAddresses extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
verifying: false,
addTask: null,
continueDisabled: false,
newEmailAddress: "",
};
}
private onRemoved = (address: ThirdPartyIdentifier): void => {
const emails = this.props.emails.filter((e) => e !== address);
this.props.onEmailsChange(emails);
};
private onChangeNewEmailAddress = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({
newEmailAddress: e.target.value,
});
};
private onAddClick = (e: React.FormEvent): void => {
e.stopPropagation();
e.preventDefault();
if (!this.state.newEmailAddress) return;
const email = this.state.newEmailAddress;
// TODO: Inline field validation
if (!Email.looksValid(email)) {
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_invalid_email"),
description: _t("settings|general|error_invalid_email_detail"),
});
return;
}
const task = new AddThreepid(MatrixClientPeg.safeGet());
this.setState({ verifying: true, continueDisabled: true, addTask: task });
task.addEmailAddress(email)
.then(() => {
this.setState({ continueDisabled: false });
})
.catch((err) => {
logger.error("Unable to add email address " + email + " " + err);
this.setState({ verifying: false, continueDisabled: false, addTask: null });
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_add_email"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
});
};
private onContinueClick = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
this.setState({ continueDisabled: true });
this.state.addTask
?.checkEmailLinkClicked()
.then(([finished]) => {
let newEmailAddress = this.state.newEmailAddress;
if (finished) {
const email = this.state.newEmailAddress;
const emails = [...this.props.emails, { address: email, medium: ThreepidMedium.Email }];
this.props.onEmailsChange(emails);
newEmailAddress = "";
}
this.setState({
addTask: null,
continueDisabled: false,
verifying: false,
newEmailAddress,
});
})
.catch((err) => {
logger.error("Unable to verify email address: ", err);
this.setState({ continueDisabled: false });
let underlyingError = err;
if (err instanceof UserFriendlyError) {
underlyingError = err.cause;
}
if (underlyingError instanceof MatrixError && underlyingError.errcode === "M_THREEPID_AUTH_FAILED") {
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|email_not_verified"),
description: _t("settings|general|email_verification_instructions"),
});
} else {
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_email_verification"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
}
});
};
public render(): React.ReactNode {
const existingEmailElements = this.props.emails.map((e) => {
return (
<ExistingEmailAddress
email={e}
onRemoved={this.onRemoved}
key={e.address}
disabled={this.props.disabled}
/>
);
});
let addButton = (
<AccessibleButton onClick={this.onAddClick} kind="primary" disabled={this.props.disabled}>
{_t("action|add")}
</AccessibleButton>
);
if (this.state.verifying) {
addButton = (
<div>
<div>{_t("settings|general|add_email_instructions")}</div>
<AccessibleButton
onClick={this.onContinueClick}
kind="primary"
disabled={this.state.continueDisabled}
>
{_t("action|continue")}
</AccessibleButton>
</div>
);
}
return (
<>
{existingEmailElements}
<form onSubmit={this.onAddClick} autoComplete="off" noValidate={true}>
<Field
type="text"
label={_t("settings|general|email_address_label")}
autoComplete="email"
disabled={this.props.disabled || this.state.verifying}
value={this.state.newEmailAddress}
onChange={this.onChangeNewEmailAddress}
/>
{addButton}
</form>
</>
);
}
}

View file

@ -1,342 +0,0 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { IAuthData, ThreepidMedium } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { _t, UserFriendlyError } from "../../../../languageHandler";
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import Field from "../../elements/Field";
import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton";
import AddThreepid, { ThirdPartyIdentifier } from "../../../../AddThreepid";
import CountryDropdown from "../../auth/CountryDropdown";
import Modal from "../../../../Modal";
import ErrorDialog, { extractErrorMessageFromError } from "../../dialogs/ErrorDialog";
import { PhoneNumberCountryDefinition } from "../../../../phonenumber";
/*
TODO: Improve the UX for everything in here.
This is a copy/paste of EmailAddresses, mostly.
*/
// TODO: Combine EmailAddresses and PhoneNumbers to be 3pid agnostic
interface IExistingPhoneNumberProps {
msisdn: ThirdPartyIdentifier;
onRemoved: (phoneNumber: ThirdPartyIdentifier) => void;
/**
* Disable removing phone number
*/
disabled?: boolean;
}
interface IExistingPhoneNumberState {
verifyRemove: boolean;
}
export class ExistingPhoneNumber extends React.Component<IExistingPhoneNumberProps, IExistingPhoneNumberState> {
public constructor(props: IExistingPhoneNumberProps) {
super(props);
this.state = {
verifyRemove: false,
};
}
private onRemove = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
this.setState({ verifyRemove: true });
};
private onDontRemove = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
this.setState({ verifyRemove: false });
};
private onActuallyRemove = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
MatrixClientPeg.safeGet()
.deleteThreePid(this.props.msisdn.medium, this.props.msisdn.address)
.then(() => {
return this.props.onRemoved(this.props.msisdn);
})
.catch((err) => {
logger.error("Unable to remove contact information: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_remove_3pid"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
});
};
public render(): React.ReactNode {
if (this.state.verifyRemove) {
return (
<div className="mx_EmailAddressesPhoneNumbers_discovery_existing">
<span className="mx_EmailAddressesPhoneNumbers_discovery_existing_promptText">
{_t("settings|general|remove_msisdn_prompt", { phone: this.props.msisdn.address })}
</span>
<AccessibleButton
onClick={this.onActuallyRemove}
kind="danger_sm"
className="mx_EmailAddressesPhoneNumbers_discovery_existing_button"
>
{_t("action|remove")}
</AccessibleButton>
<AccessibleButton
onClick={this.onDontRemove}
kind="link_sm"
className="mx_EmailAddressesPhoneNumbers_discovery_existing_button"
>
{_t("action|cancel")}
</AccessibleButton>
</div>
);
}
return (
<div className="mx_EmailAddressesPhoneNumbers_discovery_existing">
<span className="mx_EmailAddressesPhoneNumbers_discovery_existing_address">
+{this.props.msisdn.address}
</span>
<AccessibleButton onClick={this.onRemove} kind="danger_sm" disabled={this.props.disabled}>
{_t("action|remove")}
</AccessibleButton>
</div>
);
}
}
interface IProps {
msisdns: ThirdPartyIdentifier[];
onMsisdnsChange: (phoneNumbers: ThirdPartyIdentifier[]) => void;
/**
* Adding or removing phone numbers is disabled when truthy
*/
disabled?: boolean;
}
interface IState {
verifying: boolean;
verifyError: string | null;
verifyMsisdn: string;
addTask: AddThreepid | null;
continueDisabled: boolean;
phoneCountry: string;
newPhoneNumber: string;
newPhoneNumberCode: string;
}
export default class PhoneNumbers extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
verifying: false,
verifyError: null,
verifyMsisdn: "",
addTask: null,
continueDisabled: false,
phoneCountry: "",
newPhoneNumber: "",
newPhoneNumberCode: "",
};
}
private onRemoved = (address: ThirdPartyIdentifier): void => {
const msisdns = this.props.msisdns.filter((e) => e !== address);
this.props.onMsisdnsChange(msisdns);
};
private onChangeNewPhoneNumber = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({
newPhoneNumber: e.target.value,
});
};
private onChangeNewPhoneNumberCode = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({
newPhoneNumberCode: e.target.value,
});
};
private onAddClick = (e: ButtonEvent | React.FormEvent): void => {
e.stopPropagation();
e.preventDefault();
if (!this.state.newPhoneNumber) return;
const phoneNumber = this.state.newPhoneNumber;
const phoneCountry = this.state.phoneCountry;
const task = new AddThreepid(MatrixClientPeg.safeGet());
this.setState({ verifying: true, continueDisabled: true, addTask: task });
task.addMsisdn(phoneCountry, phoneNumber)
.then((response) => {
this.setState({ continueDisabled: false, verifyMsisdn: response.msisdn });
})
.catch((err) => {
logger.error("Unable to add phone number " + phoneNumber + " " + err);
this.setState({ verifying: false, continueDisabled: false, addTask: null });
Modal.createDialog(ErrorDialog, {
title: _t("common|error"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
});
};
private onContinueClick = (e: ButtonEvent | React.FormEvent): void => {
e.stopPropagation();
e.preventDefault();
this.setState({ continueDisabled: true });
const token = this.state.newPhoneNumberCode;
const address = this.state.verifyMsisdn;
this.state.addTask
?.haveMsisdnToken(token)
.then(([finished]: [success?: boolean, result?: IAuthData | Error | null] = []) => {
let newPhoneNumber = this.state.newPhoneNumber;
if (finished !== false) {
const msisdns = [...this.props.msisdns, { address, medium: ThreepidMedium.Phone }];
this.props.onMsisdnsChange(msisdns);
newPhoneNumber = "";
}
this.setState({
addTask: null,
continueDisabled: false,
verifying: false,
verifyMsisdn: "",
verifyError: null,
newPhoneNumber,
newPhoneNumberCode: "",
});
})
.catch((err) => {
logger.error("Unable to verify phone number: " + err);
this.setState({ continueDisabled: false });
let underlyingError = err;
if (err instanceof UserFriendlyError) {
underlyingError = err.cause;
}
if (underlyingError.errcode !== "M_THREEPID_AUTH_FAILED") {
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_msisdn_verification"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
} else {
this.setState({ verifyError: _t("settings|general|incorrect_msisdn_verification") });
}
});
};
private onCountryChanged = (country: PhoneNumberCountryDefinition): void => {
this.setState({ phoneCountry: country.iso2 });
};
public render(): React.ReactNode {
const existingPhoneElements = this.props.msisdns.map((p) => {
return (
<ExistingPhoneNumber
msisdn={p}
onRemoved={this.onRemoved}
key={p.address}
disabled={this.props.disabled}
/>
);
});
let addVerifySection = (
<AccessibleButton onClick={this.onAddClick} kind="primary" disabled={this.props.disabled}>
{_t("action|add")}
</AccessibleButton>
);
if (this.state.verifying) {
const msisdn = this.state.verifyMsisdn;
addVerifySection = (
<div>
<div>
{_t("settings|general|add_msisdn_instructions", { msisdn: msisdn })}
<br />
{this.state.verifyError}
</div>
<form onSubmit={this.onContinueClick} autoComplete="off" noValidate={true}>
<Field
type="text"
label={_t("settings|general|msisdn_verification_field_label")}
autoComplete="off"
disabled={this.props.disabled || this.state.continueDisabled}
value={this.state.newPhoneNumberCode}
onChange={this.onChangeNewPhoneNumberCode}
/>
<AccessibleButton
onClick={this.onContinueClick}
kind="primary"
disabled={
this.props.disabled ||
this.state.continueDisabled ||
this.state.newPhoneNumberCode.length === 0
}
>
{_t("action|continue")}
</AccessibleButton>
</form>
</div>
);
}
const phoneCountry = (
<CountryDropdown
onOptionChange={this.onCountryChanged}
className="mx_PhoneNumbers_country"
value={this.state.phoneCountry}
disabled={this.state.verifying}
isSmall={true}
showPrefix={true}
/>
);
return (
<>
{existingPhoneElements}
<form onSubmit={this.onAddClick} autoComplete="off" noValidate={true} className="mx_PhoneNumbers_new">
<div className="mx_PhoneNumbers_input">
<Field
type="text"
label={_t("settings|general|msisdn_label")}
autoComplete="tel-national"
disabled={this.props.disabled || this.state.verifying}
prefixComponent={phoneCountry}
value={this.state.newPhoneNumber}
onChange={this.onChangeNewPhoneNumber}
/>
</div>
</form>
{addVerifySection}
</>
);
}
}

View file

@ -19,8 +19,6 @@ import { SERVICE_TYPES, ThreepidMedium } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { Alert } from "@vector-im/compound-web";
import DiscoveryEmailAddresses from "../discovery/EmailAddresses";
import DiscoveryPhoneNumbers from "../discovery/PhoneNumbers";
import { getThreepidsWithBindStatus } from "../../../../boundThreepids";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { ThirdPartyIdentifier } from "../../../../AddThreepid";
@ -36,6 +34,7 @@ import { abbreviateUrl } from "../../../../utils/UrlUtils";
import { useDispatcher } from "../../../../hooks/useDispatcher";
import defaultDispatcher from "../../../../dispatcher/dispatcher";
import { ActionPayload } from "../../../../dispatcher/payloads";
import { AddRemoveThreepids } from "../AddRemoveThreepids";
type RequiredPolicyInfo =
| {
@ -56,9 +55,9 @@ type RequiredPolicyInfo =
export const DiscoverySettings: React.FC = () => {
const client = useMatrixClientContext();
const [isLoadingThreepids, setIsLoadingThreepids] = useState<boolean>(true);
const [emails, setEmails] = useState<ThirdPartyIdentifier[]>([]);
const [phoneNumbers, setPhoneNumbers] = useState<ThirdPartyIdentifier[]>([]);
const [loadingState, setLoadingState] = useState<"loading" | "loaded" | "error">("loading");
const [idServerName, setIdServerName] = useState<string | undefined>(abbreviateUrl(client.getIdentityServerUrl()));
const [canMake3pidChanges, setCanMake3pidChanges] = useState<boolean>(false);
@ -71,9 +70,11 @@ export const DiscoverySettings: React.FC = () => {
const [hasTerms, setHasTerms] = useState<boolean>(false);
const getThreepidState = useCallback(async () => {
setIsLoadingThreepids(true);
const threepids = await getThreepidsWithBindStatus(client);
setEmails(threepids.filter((a) => a.medium === ThreepidMedium.Email));
setPhoneNumbers(threepids.filter((a) => a.medium === ThreepidMedium.Phone));
setIsLoadingThreepids(false);
}, [client]);
useDispatcher(
@ -133,11 +134,7 @@ export const DiscoverySettings: React.FC = () => {
);
logger.warn(e);
}
setLoadingState("loaded");
} catch (e) {
setLoadingState("error");
}
} catch (e) {}
})();
}, [client, getThreepidState]);
@ -163,23 +160,44 @@ export const DiscoverySettings: React.FC = () => {
);
}
const threepidSection = idServerName ? (
let threepidSection;
if (idServerName) {
threepidSection = (
<>
<DiscoveryEmailAddresses
emails={emails}
isLoading={loadingState === "loading"}
<SettingsSubsection
heading={_t("settings|general|emails_heading")}
description={emails.length === 0 ? _t("settings|general|discovery_email_empty") : undefined}
stretchContent
>
<AddRemoveThreepids
mode="is"
medium={ThreepidMedium.Email}
threepids={emails}
onChange={getThreepidState}
disabled={!canMake3pidChanges}
isLoading={isLoadingThreepids}
/>
<DiscoveryPhoneNumbers
msisdns={phoneNumbers}
isLoading={loadingState === "loading"}
</SettingsSubsection>
<SettingsSubsection
heading={_t("settings|general|msisdns_heading")}
description={phoneNumbers.length === 0 ? _t("settings|general|discovery_msisdn_empty") : undefined}
stretchContent
>
<AddRemoveThreepids
mode="is"
medium={ThreepidMedium.Phone}
threepids={phoneNumbers}
onChange={getThreepidState}
disabled={!canMake3pidChanges}
isLoading={isLoadingThreepids}
/>
</SettingsSubsection>
</>
) : null;
);
}
return (
<SettingsSubsection heading={_t("settings|discovery|title")} data-testid="discoverySection">
<SettingsSubsection heading={_t("settings|discovery|title")} data-testid="discoverySection" stretchContent>
{threepidSection}
{/* has its own heading as it includes the current identity server */}
<SetIdServer missingTerms={false} />

View file

@ -1,251 +0,0 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixError } from "matrix-js-sdk/src/matrix";
import { _t, UserFriendlyError } from "../../../../languageHandler";
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import Modal from "../../../../Modal";
import AddThreepid, { Binding, ThirdPartyIdentifier } from "../../../../AddThreepid";
import ErrorDialog, { extractErrorMessageFromError } from "../../dialogs/ErrorDialog";
import SettingsSubsection from "../shared/SettingsSubsection";
import InlineSpinner from "../../elements/InlineSpinner";
import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton";
/*
TODO: Improve the UX for everything in here.
It's very much placeholder, but it gets the job done. The old way of handling
email addresses in user settings was to use dialogs to communicate state, however
due to our dialog system overriding dialogs (causing unmounts) this creates problems
for a sane UX. For instance, the user could easily end up entering an email address
and receive a dialog to verify the address, which then causes the component here
to forget what it was doing and ultimately fail. Dialogs are still used in some
places to communicate errors - these should be replaced with inline validation when
that is available.
*/
/*
TODO: Reduce all the copying between account vs. discovery components.
*/
interface IEmailAddressProps {
email: ThirdPartyIdentifier;
disabled?: boolean;
}
interface IEmailAddressState {
verifying: boolean;
addTask: AddThreepid | null;
continueDisabled: boolean;
bound?: boolean;
}
export class EmailAddress extends React.Component<IEmailAddressProps, IEmailAddressState> {
public constructor(props: IEmailAddressProps) {
super(props);
const { bound } = props.email;
this.state = {
verifying: false,
addTask: null,
continueDisabled: false,
bound,
};
}
public componentDidUpdate(prevProps: Readonly<IEmailAddressProps>): void {
if (this.props.email !== prevProps.email) {
const { bound } = this.props.email;
this.setState({ bound });
}
}
private async changeBinding({ bind, label, errorTitle }: Binding): Promise<void> {
const { medium, address } = this.props.email;
try {
if (bind) {
const task = new AddThreepid(MatrixClientPeg.safeGet());
this.setState({
verifying: true,
continueDisabled: true,
addTask: task,
});
await task.bindEmailAddress(address);
this.setState({
continueDisabled: false,
});
} else {
await MatrixClientPeg.safeGet().unbindThreePid(medium, address);
}
this.setState({ bound: bind });
} catch (err) {
logger.error(`changeBinding: Unable to ${label} email address ${address}`, err);
this.setState({
verifying: false,
continueDisabled: false,
addTask: null,
});
Modal.createDialog(ErrorDialog, {
title: errorTitle,
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
}
}
private onRevokeClick = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
this.changeBinding({
bind: false,
label: "revoke",
errorTitle: _t("settings|general|error_revoke_email_discovery"),
});
};
private onShareClick = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
this.changeBinding({
bind: true,
label: "share",
errorTitle: _t("settings|general|error_share_email_discovery"),
});
};
private onContinueClick = async (e: ButtonEvent): Promise<void> => {
e.stopPropagation();
e.preventDefault();
// Prevent the continue button from being pressed multiple times while we're working
this.setState({ continueDisabled: true });
try {
await this.state.addTask?.checkEmailLinkClicked();
this.setState({
addTask: null,
verifying: false,
});
} catch (err) {
logger.error(`Unable to verify email address:`, err);
let underlyingError = err;
if (err instanceof UserFriendlyError) {
underlyingError = err.cause;
}
if (underlyingError instanceof MatrixError && underlyingError.errcode === "M_THREEPID_AUTH_FAILED") {
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|email_not_verified"),
description: _t("settings|general|email_verification_instructions"),
});
} else {
logger.error("Unable to verify email address: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_email_verification"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
}
} finally {
// Re-enable the continue button so the user can retry
this.setState({ continueDisabled: false });
}
};
public render(): React.ReactNode {
const { address } = this.props.email;
const { verifying, bound } = this.state;
let status;
if (verifying) {
status = (
<span>
{_t("settings|general|discovery_email_verification_instructions")}
<AccessibleButton
className="mx_EmailAddressesPhoneNumbers_discovery_existing_button"
kind="primary_sm"
onClick={this.onContinueClick}
disabled={this.state.continueDisabled}
>
{_t("action|complete")}
</AccessibleButton>
</span>
);
} else if (bound) {
status = (
<AccessibleButton
className="mx_EmailAddressesPhoneNumbers_discovery_existing_button"
kind="danger_sm"
onClick={this.onRevokeClick}
disabled={this.props.disabled}
>
{_t("action|revoke")}
</AccessibleButton>
);
} else {
status = (
<AccessibleButton
className="mx_EmailAddressesPhoneNumbers_discovery_existing_button"
kind="primary_sm"
onClick={this.onShareClick}
disabled={this.props.disabled}
>
{_t("action|share")}
</AccessibleButton>
);
}
return (
<div className="mx_EmailAddressesPhoneNumbers_discovery_existing">
<span className="mx_EmailAddressesPhoneNumbers_discovery_existing_address">{address}</span>
{status}
</div>
);
}
}
interface IProps {
emails: ThirdPartyIdentifier[];
isLoading?: boolean;
disabled?: boolean;
}
export default class EmailAddresses extends React.Component<IProps> {
public render(): React.ReactNode {
let content;
if (this.props.isLoading) {
content = <InlineSpinner />;
} else if (this.props.emails.length > 0) {
content = this.props.emails.map((e) => {
return <EmailAddress email={e} key={e.address} disabled={this.props.disabled} />;
});
}
const hasEmails = !!this.props.emails.length;
return (
<SettingsSubsection
heading={_t("settings|general|emails_heading")}
description={(!hasEmails && _t("settings|general|discovery_email_empty")) || undefined}
stretchContent
>
{content}
</SettingsSubsection>
);
}
}

View file

@ -1,263 +0,0 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixError } from "matrix-js-sdk/src/matrix";
import { _t, UserFriendlyError } from "../../../../languageHandler";
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import Modal from "../../../../Modal";
import AddThreepid, { Binding, ThirdPartyIdentifier } from "../../../../AddThreepid";
import ErrorDialog, { extractErrorMessageFromError } from "../../dialogs/ErrorDialog";
import Field from "../../elements/Field";
import SettingsSubsection from "../shared/SettingsSubsection";
import InlineSpinner from "../../elements/InlineSpinner";
import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton";
/*
TODO: Improve the UX for everything in here.
This is a copy/paste of EmailAddresses, mostly.
*/
// TODO: Combine EmailAddresses and PhoneNumbers to be 3pid agnostic
interface IPhoneNumberProps {
msisdn: ThirdPartyIdentifier;
disabled?: boolean;
}
interface IPhoneNumberState {
verifying: boolean;
verificationCode: string;
addTask: AddThreepid | null;
continueDisabled: boolean;
bound?: boolean;
verifyError: string | null;
}
export class PhoneNumber extends React.Component<IPhoneNumberProps, IPhoneNumberState> {
public constructor(props: IPhoneNumberProps) {
super(props);
const { bound } = props.msisdn;
this.state = {
verifying: false,
verificationCode: "",
addTask: null,
continueDisabled: false,
bound,
verifyError: null,
};
}
public componentDidUpdate(prevProps: Readonly<IPhoneNumberProps>): void {
if (this.props.msisdn !== prevProps.msisdn) {
const { bound } = this.props.msisdn;
this.setState({ bound });
}
}
private async changeBinding({ bind, label, errorTitle }: Binding): Promise<void> {
const { medium, address } = this.props.msisdn;
try {
if (bind) {
const task = new AddThreepid(MatrixClientPeg.safeGet());
this.setState({
verifying: true,
continueDisabled: true,
addTask: task,
});
// XXX: Sydent will accept a number without country code if you add
// a leading plus sign to a number in E.164 format (which the 3PID
// address is), but this goes against the spec.
// See https://github.com/matrix-org/matrix-doc/issues/2222
// @ts-ignore
await task.bindMsisdn(null, `+${address}`);
this.setState({
continueDisabled: false,
});
} else {
await MatrixClientPeg.safeGet().unbindThreePid(medium, address);
}
this.setState({ bound: bind });
} catch (err) {
logger.error(`changeBinding: Unable to ${label} phone number ${address}`, err);
this.setState({
verifying: false,
continueDisabled: false,
addTask: null,
});
Modal.createDialog(ErrorDialog, {
title: errorTitle,
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
}
}
private onRevokeClick = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
this.changeBinding({
bind: false,
label: "revoke",
errorTitle: _t("settings|general|error_revoke_msisdn_discovery"),
});
};
private onShareClick = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
this.changeBinding({
bind: true,
label: "share",
errorTitle: _t("settings|general|error_share_msisdn_discovery"),
});
};
private onVerificationCodeChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({
verificationCode: e.target.value,
});
};
private onContinueClick = async (e: ButtonEvent | React.FormEvent): Promise<void> => {
e.stopPropagation();
e.preventDefault();
this.setState({ continueDisabled: true });
const token = this.state.verificationCode;
try {
await this.state.addTask?.haveMsisdnToken(token);
this.setState({
addTask: null,
continueDisabled: false,
verifying: false,
verifyError: null,
verificationCode: "",
});
} catch (err) {
logger.error("Unable to verify phone number:", err);
let underlyingError = err;
if (err instanceof UserFriendlyError) {
underlyingError = err.cause;
}
this.setState({ continueDisabled: false });
if (underlyingError instanceof MatrixError && underlyingError.errcode !== "M_THREEPID_AUTH_FAILED") {
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_msisdn_verification"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
} else {
this.setState({ verifyError: _t("settings|general|incorrect_msisdn_verification") });
}
}
};
public render(): React.ReactNode {
const { address } = this.props.msisdn;
const { verifying, bound } = this.state;
let status;
if (verifying) {
status = (
<span className="mx_EmailAddressesPhoneNumbers_discovery_existing_verification">
<span>
{_t("settings|general|msisdn_verification_instructions")}
<br />
{this.state.verifyError}
</span>
<form onSubmit={this.onContinueClick} autoComplete="off" noValidate={true}>
<Field
type="text"
label={_t("settings|general|msisdn_verification_field_label")}
autoComplete="off"
disabled={this.state.continueDisabled}
value={this.state.verificationCode}
onChange={this.onVerificationCodeChange}
/>
</form>
</span>
);
} else if (bound) {
status = (
<AccessibleButton
className="mx_EmailAddressesPhoneNumbers_discovery_existing_button"
kind="danger_sm"
onClick={this.onRevokeClick}
disabled={this.props.disabled}
>
{_t("action|revoke")}
</AccessibleButton>
);
} else {
status = (
<AccessibleButton
className="mx_EmailAddressesPhoneNumbers_discovery_existing_button"
kind="primary_sm"
onClick={this.onShareClick}
disabled={this.props.disabled}
>
{_t("action|share")}
</AccessibleButton>
);
}
return (
<div className="mx_EmailAddressesPhoneNumbers_discovery_existing">
<span className="mx_EmailAddressesPhoneNumbers_discovery_existing_address">+{address}</span>
{status}
</div>
);
}
}
interface IProps {
msisdns: ThirdPartyIdentifier[];
isLoading?: boolean;
disabled?: boolean;
}
export default class PhoneNumbers extends React.Component<IProps> {
public render(): React.ReactNode {
let content;
if (this.props.isLoading) {
content = <InlineSpinner />;
} else if (this.props.msisdns.length > 0) {
content = this.props.msisdns.map((e) => {
return <PhoneNumber msisdn={e} key={e.address} disabled={this.props.disabled} />;
});
}
const description = (!content && _t("settings|general|discovery_msisdn_empty")) || undefined;
return (
<SettingsSubsection
data-testid="mx_DiscoveryPhoneNumbers"
heading={_t("settings|general|msisdns_heading")}
description={description}
stretchContent
>
{content}
</SettingsSubsection>
);
}
}

View file

@ -2532,7 +2532,6 @@
"error_share_msisdn_discovery": "Unable to share phone number",
"identity_server_no_token": "No identity access token found",
"identity_server_not_set": "Identity server not set",
"incorrect_msisdn_verification": "Incorrect verification code",
"language_section": "Language",
"msisdn_in_use": "This phone number is already in use",
"msisdn_label": "Phone Number",

View file

@ -0,0 +1,534 @@
/*
Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { render, screen } from "@testing-library/react";
import { MatrixClient, ThreepidMedium } from "matrix-js-sdk/src/matrix";
import React from "react";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";
import { AddRemoveThreepids } from "../../../../src/components/views/settings/AddRemoveThreepids";
import { clearAllModals, stubClient } from "../../../test-utils";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import Modal from "../../../../src/Modal";
const MOCK_IDENTITY_ACCESS_TOKEN = "mock_identity_access_token";
const mockGetAccessToken = jest.fn().mockResolvedValue(MOCK_IDENTITY_ACCESS_TOKEN);
jest.mock("../../../../src/IdentityAuthClient", () =>
jest.fn().mockImplementation(() => ({
getAccessToken: mockGetAccessToken,
})),
);
const EMAIL1 = {
medium: ThreepidMedium.Email,
address: "alice@nowhere.dummy",
};
const PHONE1 = {
medium: ThreepidMedium.Phone,
address: "447700900000",
};
const PHONE1_LOCALNUM = "07700900000";
describe("AddRemoveThreepids", () => {
let client: MatrixClient;
beforeEach(() => {
client = stubClient();
});
afterEach(() => {
jest.restoreAllMocks();
clearAllModals();
});
const clientProviderWrapper: React.FC = ({ children }) => (
<MatrixClientContext.Provider value={client}>{children}</MatrixClientContext.Provider>
);
it("should render a loader while loading", async () => {
render(
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Email}
threepids={[]}
isLoading={true}
onChange={() => {}}
/>,
);
expect(screen.getByLabelText("Loading…")).toBeInTheDocument();
});
it("should render email addresses", async () => {
const { container } = render(
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Email}
threepids={[EMAIL1]}
isLoading={false}
onChange={() => {}}
/>,
);
expect(container).toMatchSnapshot();
});
it("should render phone numbers", async () => {
const { container } = render(
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Phone}
threepids={[PHONE1]}
isLoading={false}
onChange={() => {}}
/>,
);
expect(container).toMatchSnapshot();
});
it("should handle no email addresses", async () => {
const { container } = render(
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Email}
threepids={[]}
isLoading={false}
onChange={() => {}}
/>,
);
expect(container).toMatchSnapshot();
});
it("should add an email address", async () => {
const onChangeFn = jest.fn();
mocked(client.requestAdd3pidEmailToken).mockResolvedValue({ sid: "1" });
render(
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Email}
threepids={[]}
isLoading={false}
onChange={onChangeFn}
/>,
{
wrapper: clientProviderWrapper,
},
);
const input = screen.getByRole("textbox", { name: "Email Address" });
await userEvent.type(input, EMAIL1.address);
const addButton = screen.getByRole("button", { name: "Add" });
await userEvent.click(addButton);
expect(client.requestAdd3pidEmailToken).toHaveBeenCalledWith(EMAIL1.address, client.generateClientSecret(), 1);
const continueButton = screen.getByRole("button", { name: "Continue" });
expect(continueButton).toBeEnabled();
await userEvent.click(continueButton);
expect(client.addThreePidOnly).toHaveBeenCalledWith({
client_secret: client.generateClientSecret(),
sid: "1",
auth: undefined,
});
expect(onChangeFn).toHaveBeenCalled();
});
it("should display an error if the link has not been clicked", async () => {
const onChangeFn = jest.fn();
const createDialogFn = jest.spyOn(Modal, "createDialog");
mocked(client.requestAdd3pidEmailToken).mockResolvedValue({ sid: "1" });
render(
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Email}
threepids={[]}
isLoading={false}
onChange={onChangeFn}
/>,
{
wrapper: clientProviderWrapper,
},
);
const input = screen.getByRole("textbox", { name: "Email Address" });
await userEvent.type(input, EMAIL1.address);
const addButton = screen.getByRole("button", { name: "Add" });
await userEvent.click(addButton);
const continueButton = screen.getByRole("button", { name: "Continue" });
expect(continueButton).toBeEnabled();
mocked(client).addThreePidOnly.mockRejectedValueOnce(new Error("Unauthorized"));
await userEvent.click(continueButton);
expect(createDialogFn).toHaveBeenCalledWith(expect.anything(), {
description: "Unauthorized",
title: "Unable to verify email address.",
});
expect(onChangeFn).not.toHaveBeenCalled();
});
it("should add a phone number", async () => {
const onChangeFn = jest.fn();
mocked(client.requestAdd3pidMsisdnToken).mockResolvedValue({
sid: "1",
msisdn: PHONE1.address,
intl_fmt: "+" + PHONE1.address,
success: true,
submit_url: "https://example.dummy",
});
render(
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Phone}
threepids={[]}
isLoading={false}
onChange={onChangeFn}
/>,
{
wrapper: clientProviderWrapper,
},
);
const countryDropdown = screen.getByRole("button", { name: "Country Dropdown" });
await userEvent.click(countryDropdown);
const gbOption = screen.getByRole("option", { name: "🇬🇧 United Kingdom (+44)" });
await userEvent.click(gbOption);
const input = screen.getByRole("textbox", { name: "Phone Number" });
await userEvent.type(input, PHONE1_LOCALNUM);
const addButton = screen.getByRole("button", { name: "Add" });
await userEvent.click(addButton);
expect(client.requestAdd3pidMsisdnToken).toHaveBeenCalledWith(
"GB",
PHONE1_LOCALNUM,
client.generateClientSecret(),
1,
);
const continueButton = screen.getByRole("button", { name: "Continue" });
expect(continueButton).toHaveAttribute("aria-disabled", "true");
const verificationInput = screen.getByRole("textbox", { name: "Verification code" });
await userEvent.type(verificationInput, "123456");
expect(continueButton).not.toHaveAttribute("aria-disabled", "true");
await userEvent.click(continueButton);
expect(client.addThreePidOnly).toHaveBeenCalledWith({
client_secret: client.generateClientSecret(),
sid: "1",
auth: undefined,
});
expect(onChangeFn).toHaveBeenCalled();
});
it("should display an error if the code is incorrect", async () => {
const onChangeFn = jest.fn();
const createDialogFn = jest.spyOn(Modal, "createDialog");
mocked(client.requestAdd3pidMsisdnToken).mockResolvedValue({
sid: "1",
msisdn: PHONE1.address,
intl_fmt: "+" + PHONE1.address,
success: true,
submit_url: "https://example.dummy",
});
render(
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Phone}
threepids={[]}
isLoading={false}
onChange={onChangeFn}
/>,
{
wrapper: clientProviderWrapper,
},
);
const input = screen.getByRole("textbox", { name: "Phone Number" });
await userEvent.type(input, PHONE1_LOCALNUM);
const countryDropdown = screen.getByRole("button", { name: "Country Dropdown" });
await userEvent.click(countryDropdown);
const gbOption = screen.getByRole("option", { name: "🇬🇧 United Kingdom (+44)" });
await userEvent.click(gbOption);
const addButton = screen.getByRole("button", { name: "Add" });
await userEvent.click(addButton);
mocked(client).addThreePidOnly.mockRejectedValueOnce(new Error("Unauthorized"));
const verificationInput = screen.getByRole("textbox", { name: "Verification code" });
await userEvent.type(verificationInput, "123457");
const continueButton = screen.getByRole("button", { name: "Continue" });
await userEvent.click(continueButton);
expect(createDialogFn).toHaveBeenCalledWith(expect.anything(), {
description: "Unauthorized",
title: "Unable to verify phone number.",
});
expect(onChangeFn).not.toHaveBeenCalled();
});
it("should remove an email address", async () => {
const onChangeFn = jest.fn();
render(
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Email}
threepids={[EMAIL1]}
isLoading={false}
onChange={onChangeFn}
/>,
{
wrapper: clientProviderWrapper,
},
);
const removeButton = screen.getByRole("button", { name: "Remove" });
await userEvent.click(removeButton);
expect(screen.getByText(`Remove ${EMAIL1.address}?`)).toBeVisible();
const confirmRemoveButton = screen.getByRole("button", { name: "Remove" });
await userEvent.click(confirmRemoveButton);
expect(client.deleteThreePid).toHaveBeenCalledWith(ThreepidMedium.Email, EMAIL1.address);
expect(onChangeFn).toHaveBeenCalled();
});
it("should return to default view if adding is cancelled", async () => {
const onChangeFn = jest.fn();
render(
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Email}
threepids={[EMAIL1]}
isLoading={false}
onChange={onChangeFn}
/>,
{
wrapper: clientProviderWrapper,
},
);
const removeButton = screen.getByRole("button", { name: "Remove" });
await userEvent.click(removeButton);
expect(screen.getByText(`Remove ${EMAIL1.address}?`)).toBeVisible();
const confirmRemoveButton = screen.getByRole("button", { name: "Cancel" });
await userEvent.click(confirmRemoveButton);
expect(screen.queryByText(`Remove ${EMAIL1.address}?`)).not.toBeInTheDocument();
expect(client.deleteThreePid).not.toHaveBeenCalledWith(ThreepidMedium.Email, EMAIL1.address);
expect(onChangeFn).not.toHaveBeenCalled();
});
it("should remove a phone number", async () => {
const onChangeFn = jest.fn();
render(
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Phone}
threepids={[PHONE1]}
isLoading={false}
onChange={onChangeFn}
/>,
{
wrapper: clientProviderWrapper,
},
);
const removeButton = screen.getByRole("button", { name: "Remove" });
await userEvent.click(removeButton);
expect(screen.getByText(`Remove ${PHONE1.address}?`)).toBeVisible();
const confirmRemoveButton = screen.getByRole("button", { name: "Remove" });
await userEvent.click(confirmRemoveButton);
expect(client.deleteThreePid).toHaveBeenCalledWith(ThreepidMedium.Phone, PHONE1.address);
expect(onChangeFn).toHaveBeenCalled();
});
it("should bind an email address", async () => {
mocked(client).requestEmailToken.mockResolvedValue({ sid: "1" });
mocked(client).getIdentityServerUrl.mockReturnValue("https://the_best_id_server.dummy");
const onChangeFn = jest.fn();
render(
<AddRemoveThreepids
mode="is"
medium={ThreepidMedium.Email}
threepids={[Object.assign({}, EMAIL1, { bound: false })]}
isLoading={false}
onChange={onChangeFn}
/>,
{
wrapper: clientProviderWrapper,
},
);
expect(screen.getByText(EMAIL1.address)).toBeVisible();
const shareButton = screen.getByRole("button", { name: "Share" });
await userEvent.click(shareButton);
expect(screen.getByText("Verify the link in your inbox")).toBeVisible();
expect(client.requestEmailToken).toHaveBeenCalledWith(
EMAIL1.address,
client.generateClientSecret(),
1,
undefined,
MOCK_IDENTITY_ACCESS_TOKEN,
);
const completeButton = screen.getByRole("button", { name: "Complete" });
await userEvent.click(completeButton);
expect(client.bindThreePid).toHaveBeenCalledWith({
sid: "1",
client_secret: client.generateClientSecret(),
id_server: "https://the_best_id_server.dummy",
id_access_token: MOCK_IDENTITY_ACCESS_TOKEN,
});
expect(onChangeFn).toHaveBeenCalled();
});
it("should bind a phone number", async () => {
mocked(client).requestMsisdnToken.mockResolvedValue({
success: true,
sid: "1",
msisdn: PHONE1.address,
intl_fmt: "+" + PHONE1.address,
});
mocked(client).getIdentityServerUrl.mockReturnValue("https://the_best_id_server.dummy");
const onChangeFn = jest.fn();
render(
<AddRemoveThreepids
mode="is"
medium={ThreepidMedium.Phone}
threepids={[Object.assign({}, PHONE1, { bound: false })]}
isLoading={false}
onChange={onChangeFn}
/>,
{
wrapper: clientProviderWrapper,
},
);
expect(screen.getByText(PHONE1.address)).toBeVisible();
const shareButton = screen.getByRole("button", { name: "Share" });
await userEvent.click(shareButton);
expect(screen.getByText("Please enter verification code sent via text.")).toBeVisible();
expect(client.requestMsisdnToken).toHaveBeenCalledWith(
null,
"+" + PHONE1.address,
client.generateClientSecret(),
1,
undefined,
MOCK_IDENTITY_ACCESS_TOKEN,
);
const codeInput = screen.getByRole("textbox", { name: "Verification code" });
await userEvent.type(codeInput, "123456");
await userEvent.keyboard("{Enter}");
expect(client.bindThreePid).toHaveBeenCalledWith({
sid: "1",
client_secret: client.generateClientSecret(),
id_server: "https://the_best_id_server.dummy",
id_access_token: MOCK_IDENTITY_ACCESS_TOKEN,
});
expect(onChangeFn).toHaveBeenCalled();
});
it("should revoke a bound email address", async () => {
const onChangeFn = jest.fn();
render(
<AddRemoveThreepids
mode="is"
medium={ThreepidMedium.Email}
threepids={[Object.assign({}, EMAIL1, { bound: true })]}
isLoading={false}
onChange={onChangeFn}
/>,
{
wrapper: clientProviderWrapper,
},
);
expect(screen.getByText(EMAIL1.address)).toBeVisible();
const revokeButton = screen.getByRole("button", { name: "Revoke" });
await userEvent.click(revokeButton);
expect(client.unbindThreePid).toHaveBeenCalledWith(ThreepidMedium.Email, EMAIL1.address);
expect(onChangeFn).toHaveBeenCalled();
});
it("should revoke a bound phone number", async () => {
const onChangeFn = jest.fn();
render(
<AddRemoveThreepids
mode="is"
medium={ThreepidMedium.Phone}
threepids={[Object.assign({}, PHONE1, { bound: true })]}
isLoading={false}
onChange={onChangeFn}
/>,
{
wrapper: clientProviderWrapper,
},
);
expect(screen.getByText(PHONE1.address)).toBeVisible();
const revokeButton = screen.getByRole("button", { name: "Revoke" });
await userEvent.click(revokeButton);
expect(client.unbindThreePid).toHaveBeenCalledWith(ThreepidMedium.Phone, PHONE1.address);
expect(onChangeFn).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,172 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AddRemoveThreepids should handle no email addresses 1`] = `
<div>
<form
autocomplete="off"
novalidate=""
>
<div
class="mx_Field mx_Field_input"
>
<input
autocomplete="email"
id="mx_Field_3"
label="Email Address"
placeholder="Email Address"
type="text"
value=""
/>
<label
for="mx_Field_3"
>
Email Address
</label>
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Add
</div>
</form>
</div>
`;
exports[`AddRemoveThreepids should render email addresses 1`] = `
<div>
<div
class="mx_AddRemoveThreepids_existing"
>
<span
class="mx_AddRemoveThreepids_existing_address"
>
alice@nowhere.dummy
</span>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger_sm"
role="button"
tabindex="0"
>
Remove
</div>
</div>
<form
autocomplete="off"
novalidate=""
>
<div
class="mx_Field mx_Field_input"
>
<input
autocomplete="email"
id="mx_Field_1"
label="Email Address"
placeholder="Email Address"
type="text"
value=""
/>
<label
for="mx_Field_1"
>
Email Address
</label>
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Add
</div>
</form>
</div>
`;
exports[`AddRemoveThreepids should render phone numbers 1`] = `
<div>
<div
class="mx_AddRemoveThreepids_existing"
>
<span
class="mx_AddRemoveThreepids_existing_address"
>
447700900000
</span>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger_sm"
role="button"
tabindex="0"
>
Remove
</div>
</div>
<form
autocomplete="off"
novalidate=""
>
<div
class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft"
>
<span
class="mx_Field_prefix"
>
<div
class="mx_Dropdown mx_PhoneNumbers_country mx_CountryDropdown"
>
<div
aria-describedby="mx_CountryDropdown_value"
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Country Dropdown"
aria-owns="mx_CountryDropdown_input"
class="mx_AccessibleButton mx_Dropdown_input mx_no_textinput"
role="button"
tabindex="0"
>
<div
class="mx_Dropdown_option"
id="mx_CountryDropdown_value"
>
<span
class="mx_CountryDropdown_shortOption"
>
<div
class="mx_Dropdown_option_emoji"
>
🇺🇸
</div>
+1
</span>
</div>
<span
class="mx_Dropdown_arrow"
/>
</div>
</div>
</span>
<input
autocomplete="tel-national"
id="mx_Field_2"
label="Phone Number"
placeholder="Phone Number"
type="text"
value=""
/>
<label
for="mx_Field_2"
>
Phone Number
</label>
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Add
</div>
</form>
</div>
`;

View file

@ -1,67 +0,0 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";
import PhoneNumbers from "../../../../../src/components/views/settings/account/PhoneNumbers";
import { stubClient } from "../../../../test-utils";
import SdkConfig from "../../../../../src/SdkConfig";
describe("<PhoneNumbers />", () => {
it("should allow a phone number to be added", async () => {
SdkConfig.add({
default_country_code: "GB",
});
const cli = stubClient();
const onMsisdnsChange = jest.fn();
const { asFragment, getByLabelText, getByText } = render(
<PhoneNumbers msisdns={[]} onMsisdnsChange={onMsisdnsChange} />,
);
mocked(cli.requestAdd3pidMsisdnToken).mockResolvedValue({
sid: "SID",
msisdn: "447900111222",
submit_url: "https://server.url",
success: true,
intl_fmt: "no-clue",
});
mocked(cli.submitMsisdnTokenOtherUrl).mockResolvedValue({ success: true });
mocked(cli.addThreePidOnly).mockResolvedValue({});
const phoneNumberField = getByLabelText("Phone Number");
await userEvent.type(phoneNumberField, "7900111222");
await userEvent.click(getByText("Add"));
expect(cli.requestAdd3pidMsisdnToken).toHaveBeenCalledWith("GB", "7900111222", "t35tcl1Ent5ECr3T", 1);
expect(asFragment()).toMatchSnapshot();
const verificationCodeField = getByLabelText("Verification code");
await userEvent.type(verificationCodeField, "123666");
await userEvent.click(getByText("Continue"));
expect(cli.submitMsisdnTokenOtherUrl).toHaveBeenCalledWith(
"https://server.url",
"SID",
"t35tcl1Ent5ECr3T",
"123666",
);
expect(onMsisdnsChange).toHaveBeenCalledWith([{ address: "447900111222", medium: "msisdn" }]);
});
});

View file

@ -1,110 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<PhoneNumbers /> should allow a phone number to be added 1`] = `
<DocumentFragment>
<form
autocomplete="off"
class="mx_PhoneNumbers_new"
novalidate=""
>
<div
class="mx_PhoneNumbers_input"
>
<div
class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft"
>
<span
class="mx_Field_prefix"
>
<div
class="mx_Dropdown mx_PhoneNumbers_country mx_CountryDropdown mx_Dropdown_disabled"
>
<div
aria-describedby="mx_CountryDropdown_value"
aria-disabled="true"
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Country Dropdown"
aria-owns="mx_CountryDropdown_input"
class="mx_AccessibleButton mx_Dropdown_input mx_no_textinput mx_AccessibleButton_disabled"
disabled=""
role="button"
tabindex="0"
>
<div
class="mx_Dropdown_option"
id="mx_CountryDropdown_value"
>
<span
class="mx_CountryDropdown_shortOption"
>
<div
class="mx_Dropdown_option_emoji"
>
🇬🇧
</div>
+44
</span>
</div>
<span
class="mx_Dropdown_arrow"
/>
</div>
</div>
</span>
<input
autocomplete="tel-national"
disabled=""
id="mx_Field_1"
label="Phone Number"
placeholder="Phone Number"
type="text"
value="7900111222"
/>
<label
for="mx_Field_1"
>
Phone Number
</label>
</div>
</div>
</form>
<div>
<div>
A text message has been sent to +447900111222. Please enter the verification code it contains.
<br />
</div>
<form
autocomplete="off"
novalidate=""
>
<div
class="mx_Field mx_Field_input"
>
<input
autocomplete="off"
id="mx_Field_2"
label="Verification code"
placeholder="Verification code"
type="text"
value=""
/>
<label
for="mx_Field_2"
>
Verification code
</label>
</div>
<div
aria-disabled="true"
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary mx_AccessibleButton_disabled"
disabled=""
role="button"
tabindex="0"
>
Continue
</div>
</form>
</div>
</DocumentFragment>
`;

View file

@ -1,167 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { IThreepid, ThreepidMedium, IRequestTokenResponse, MatrixError } from "matrix-js-sdk/src/matrix";
import { TranslationKey, UserFriendlyError } from "../../../../../src/languageHandler";
import EmailAddresses, { EmailAddress } from "../../../../../src/components/views/settings/discovery/EmailAddresses";
import { clearAllModals, getMockClientWithEventEmitter } from "../../../../test-utils";
const mockGetAccessToken = jest.fn().mockResolvedValue("getAccessToken");
jest.mock("../../../../../src/IdentityAuthClient", () =>
jest.fn().mockImplementation(() => ({
getAccessToken: mockGetAccessToken,
})),
);
const emailThreepidFixture: IThreepid = {
medium: ThreepidMedium.Email,
address: "foo@bar.com",
validated_at: 12345,
added_at: 12342,
bound: false,
};
describe("<EmailAddress/>", () => {
const mockClient = getMockClientWithEventEmitter({
getIdentityServerUrl: jest.fn().mockReturnValue("https://fake-identity-server"),
generateClientSecret: jest.fn(),
requestEmailToken: jest.fn(),
bindThreePid: jest.fn(),
});
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(async () => {
jest.useRealTimers();
await clearAllModals();
});
it("should track props.email.bound changes", async () => {
const { rerender } = render(<EmailAddress email={emailThreepidFixture} />);
await screen.findByText("Share");
rerender(
<EmailAddress
email={{
...emailThreepidFixture,
bound: true,
}}
/>,
);
await screen.findByText("Revoke");
});
describe("Email verification share phase", () => {
it("shows translated error message", async () => {
render(<EmailAddress email={emailThreepidFixture} />);
mockClient.requestEmailToken.mockRejectedValue(
new MatrixError(
{ errcode: "M_THREEPID_IN_USE", error: "Some fake MatrixError occured" },
400,
"https://fake-url/",
),
);
fireEvent.click(screen.getByText("Share"));
// Expect error dialog/modal to be shown. We have to wait for the UI to transition.
expect(await screen.findByText("This email address is already in use")).toBeInTheDocument();
});
});
describe("Email verification complete phase", () => {
beforeEach(async () => {
// Start these tests out at the "Complete" phase
render(<EmailAddress email={emailThreepidFixture} />);
mockClient.requestEmailToken.mockResolvedValue({ sid: "123-fake-sid" } satisfies IRequestTokenResponse);
fireEvent.click(screen.getByText("Share"));
// Then wait for the completion screen to come up
await screen.findByText("Complete");
});
it("Shows error dialog when share completion fails (email not verified yet)", async () => {
mockClient.bindThreePid.mockRejectedValue(
new MatrixError(
{ errcode: "M_THREEPID_AUTH_FAILED", error: "Some fake MatrixError occured" },
403,
"https://fake-url/",
),
);
await expect(screen.findByText("Complete")).resolves.not.toHaveAttribute("aria-disabled", "true");
fireEvent.click(screen.getByText("Complete"));
// Expect error dialog/modal to be shown. We have to wait for the UI to transition.
// Check the title
expect(await screen.findByText("Your email address hasn't been verified yet")).toBeInTheDocument();
// Check the description
expect(
await screen.findByText(
"Click the link in the email you received to verify and then click continue again.",
),
).toBeInTheDocument();
});
it("Shows error dialog when share completion fails (UserFriendlyError)", async () => {
const fakeErrorText = "Fake UserFriendlyError error in test" as TranslationKey;
mockClient.bindThreePid.mockRejectedValue(new UserFriendlyError(fakeErrorText));
await expect(screen.findByText("Complete")).resolves.not.toHaveAttribute("aria-disabled", "true");
fireEvent.click(screen.getByText("Complete"));
// Expect error dialog/modal to be shown. We have to wait for the UI to transition.
// Check the title
expect(await screen.findByText("Unable to verify email address.")).toBeInTheDocument();
// Check the description
expect(await screen.findByText(fakeErrorText)).toBeInTheDocument();
});
it("Shows error dialog when share completion fails (generic error)", async () => {
const fakeErrorText = "Fake plain error in test";
mockClient.bindThreePid.mockRejectedValue(new Error(fakeErrorText));
await expect(screen.findByText("Complete")).resolves.not.toHaveAttribute("aria-disabled", "true");
fireEvent.click(screen.getByText("Complete"));
// Expect error dialog/modal to be shown. We have to wait for the UI to transition.
// Check the title
expect(await screen.findByText("Unable to verify email address.")).toBeInTheDocument();
// Check the description
expect(await screen.findByText(fakeErrorText)).toBeInTheDocument();
});
});
});
describe("<EmailAddresses />", () => {
it("should render a loader while loading", async () => {
const { container } = render(<EmailAddresses emails={[emailThreepidFixture]} isLoading={true} />);
expect(container).toMatchSnapshot();
});
it("should render email addresses", async () => {
const { container } = render(<EmailAddresses emails={[emailThreepidFixture]} isLoading={false} />);
expect(container).toMatchSnapshot();
});
it("should handle no email addresses", async () => {
const { container } = render(<EmailAddresses emails={[]} isLoading={false} />);
expect(container).toMatchSnapshot();
});
});

View file

@ -1,101 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { IThreepid, ThreepidMedium } from "matrix-js-sdk/src/matrix";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";
import PhoneNumbers, { PhoneNumber } from "../../../../../src/components/views/settings/discovery/PhoneNumbers";
import { stubClient } from "../../../../test-utils";
const msisdn: IThreepid = {
medium: ThreepidMedium.Phone,
address: "441111111111",
validated_at: 12345,
added_at: 12342,
bound: false,
};
describe("<PhoneNumber/>", () => {
it("should track props.msisdn.bound changes", async () => {
const { rerender } = render(<PhoneNumber msisdn={{ ...msisdn }} />);
await screen.findByText("Share");
rerender(<PhoneNumber msisdn={{ ...msisdn, bound: true }} />);
await screen.findByText("Revoke");
});
});
const mockGetAccessToken = jest.fn().mockResolvedValue("$$getAccessToken");
jest.mock("../../../../../src/IdentityAuthClient", () =>
jest.fn().mockImplementation(() => ({
getAccessToken: mockGetAccessToken,
})),
);
describe("<PhoneNumbers />", () => {
it("should render a loader while loading", async () => {
const { container } = render(<PhoneNumbers msisdns={[{ ...msisdn }]} isLoading={true} />);
expect(container).toMatchSnapshot();
});
it("should render phone numbers", async () => {
const { container } = render(<PhoneNumbers msisdns={[{ ...msisdn }]} isLoading={false} />);
expect(container).toMatchSnapshot();
});
it("should handle no numbers", async () => {
const { container } = render(<PhoneNumbers msisdns={[]} isLoading={false} />);
expect(container).toMatchSnapshot();
});
it("should allow binding msisdn", async () => {
const cli = stubClient();
const { getByText, getByLabelText, asFragment } = render(
<PhoneNumbers msisdns={[{ ...msisdn }]} isLoading={false} />,
);
mocked(cli.requestMsisdnToken).mockResolvedValue({
sid: "SID",
msisdn: "+447900111222",
submit_url: "https://server.url",
success: true,
intl_fmt: "no-clue",
});
fireEvent.click(getByText("Share"));
await waitFor(() =>
expect(cli.requestMsisdnToken).toHaveBeenCalledWith(
null,
"+441111111111",
"t35tcl1Ent5ECr3T",
1,
undefined,
"$$getAccessToken",
),
);
expect(asFragment()).toMatchSnapshot();
const verificationCodeField = getByLabelText("Verification code");
await userEvent.type(verificationCodeField, "123666{Enter}");
expect(cli.submitMsisdnToken).toHaveBeenCalledWith("SID", "t35tcl1Ent5ECr3T", "123666", "$$getAccessToken");
});
});

View file

@ -1,97 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<EmailAddresses /> should handle no email addresses 1`] = `
<div>
<div
class="mx_SettingsSubsection"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Email addresses
</h3>
</div>
<div
class="mx_SettingsSubsection_description"
>
<div
class="mx_SettingsSubsection_text"
>
Discovery options will appear once you have added an email.
</div>
</div>
</div>
</div>
`;
exports[`<EmailAddresses /> should render a loader while loading 1`] = `
<div>
<div
class="mx_SettingsSubsection"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Email addresses
</h3>
</div>
<div
class="mx_SettingsSubsection_content mx_SettingsSubsection_contentStretch"
>
<div
class="mx_InlineSpinner"
>
<div
aria-label="Loading…"
class="mx_InlineSpinner_icon mx_Spinner_icon"
style="width: 16px; height: 16px;"
/>
</div>
</div>
</div>
</div>
`;
exports[`<EmailAddresses /> should render email addresses 1`] = `
<div>
<div
class="mx_SettingsSubsection"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Email addresses
</h3>
</div>
<div
class="mx_SettingsSubsection_content mx_SettingsSubsection_contentStretch"
>
<div
class="mx_EmailAddressesPhoneNumbers_discovery_existing"
>
<span
class="mx_EmailAddressesPhoneNumbers_discovery_existing_address"
>
foo@bar.com
</span>
<div
class="mx_AccessibleButton mx_EmailAddressesPhoneNumbers_discovery_existing_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_sm"
role="button"
tabindex="0"
>
Share
</div>
</div>
</div>
</div>
</div>
`;

View file

@ -1,163 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<PhoneNumbers /> should allow binding msisdn 1`] = `
<DocumentFragment>
<div
class="mx_SettingsSubsection"
data-testid="mx_DiscoveryPhoneNumbers"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Phone numbers
</h3>
</div>
<div
class="mx_SettingsSubsection_content mx_SettingsSubsection_contentStretch"
>
<div
class="mx_EmailAddressesPhoneNumbers_discovery_existing"
>
<span
class="mx_EmailAddressesPhoneNumbers_discovery_existing_address"
>
+441111111111
</span>
<span
class="mx_EmailAddressesPhoneNumbers_discovery_existing_verification"
>
<span>
Please enter verification code sent via text.
<br />
</span>
<form
autocomplete="off"
novalidate=""
>
<div
class="mx_Field mx_Field_input"
>
<input
autocomplete="off"
id="mx_Field_1"
label="Verification code"
placeholder="Verification code"
type="text"
value=""
/>
<label
for="mx_Field_1"
>
Verification code
</label>
</div>
</form>
</span>
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`<PhoneNumbers /> should handle no numbers 1`] = `
<div>
<div
class="mx_SettingsSubsection"
data-testid="mx_DiscoveryPhoneNumbers"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Phone numbers
</h3>
</div>
<div
class="mx_SettingsSubsection_description"
>
<div
class="mx_SettingsSubsection_text"
>
Discovery options will appear once you have added a phone number.
</div>
</div>
</div>
</div>
`;
exports[`<PhoneNumbers /> should render a loader while loading 1`] = `
<div>
<div
class="mx_SettingsSubsection"
data-testid="mx_DiscoveryPhoneNumbers"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Phone numbers
</h3>
</div>
<div
class="mx_SettingsSubsection_content mx_SettingsSubsection_contentStretch"
>
<div
class="mx_InlineSpinner"
>
<div
aria-label="Loading…"
class="mx_InlineSpinner_icon mx_Spinner_icon"
style="width: 16px; height: 16px;"
/>
</div>
</div>
</div>
</div>
`;
exports[`<PhoneNumbers /> should render phone numbers 1`] = `
<div>
<div
class="mx_SettingsSubsection"
data-testid="mx_DiscoveryPhoneNumbers"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Phone numbers
</h3>
</div>
<div
class="mx_SettingsSubsection_content mx_SettingsSubsection_contentStretch"
>
<div
class="mx_EmailAddressesPhoneNumbers_discovery_existing"
>
<span
class="mx_EmailAddressesPhoneNumbers_discovery_existing_address"
>
+
441111111111
</span>
<div
class="mx_AccessibleButton mx_EmailAddressesPhoneNumbers_discovery_existing_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_sm"
role="button"
tabindex="0"
>
Share
</div>
</div>
</div>
</div>
</div>
`;

View file

@ -18,10 +18,10 @@ exports[`<AccountUserSettingsTab /> 3pids should display 3pid email addresses an
class="mx_SettingsSubsection_content mx_SettingsSubsection_contentStretch"
>
<div
class="mx_EmailAddressesPhoneNumbers_discovery_existing"
class="mx_AddRemoveThreepids_existing"
>
<span
class="mx_EmailAddressesPhoneNumbers_discovery_existing_address"
class="mx_AddRemoveThreepids_existing_address"
>
test@test.io
</span>
@ -84,12 +84,11 @@ exports[`<AccountUserSettingsTab /> 3pids should display 3pid email addresses an
class="mx_SettingsSubsection_content mx_SettingsSubsection_contentStretch"
>
<div
class="mx_EmailAddressesPhoneNumbers_discovery_existing"
class="mx_AddRemoveThreepids_existing"
>
<span
class="mx_EmailAddressesPhoneNumbers_discovery_existing_address"
class="mx_AddRemoveThreepids_existing_address"
>
+
123456789
</span>
<div
@ -102,11 +101,7 @@ exports[`<AccountUserSettingsTab /> 3pids should display 3pid email addresses an
</div>
<form
autocomplete="off"
class="mx_PhoneNumbers_new"
novalidate=""
>
<div
class="mx_PhoneNumbers_input"
>
<div
class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft"
@ -162,8 +157,6 @@ exports[`<AccountUserSettingsTab /> 3pids should display 3pid email addresses an
Phone Number
</label>
</div>
</div>
</form>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
@ -171,6 +164,7 @@ exports[`<AccountUserSettingsTab /> 3pids should display 3pid email addresses an
>
Add
</div>
</form>
</div>
</div>
`;

View file

@ -265,8 +265,13 @@ export function createTestClient(): MatrixClient {
knockRoom: jest.fn(),
leave: jest.fn(),
getVersions: jest.fn().mockResolvedValue({ versions: ["v1.1"] }),
requestAdd3pidEmailToken: jest.fn(),
requestAdd3pidMsisdnToken: jest.fn(),
submitMsisdnTokenOtherUrl: jest.fn(),
deleteThreePid: jest.fn().mockResolvedValue({}),
bindThreePid: jest.fn().mockResolvedValue({}),
unbindThreePid: jest.fn().mockResolvedValue({}),
requestEmailToken: jest.fn(),
addThreePidOnly: jest.fn(),
requestMsisdnToken: jest.fn(),
submitMsisdnToken: jest.fn(),