From 4751c52d82e9efc824c9dd65d5f1f504aac546bd Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 14 Aug 2024 14:13:57 +0100 Subject: [PATCH] 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 --- res/css/_components.pcss | 2 +- ...eNumbers.pcss => _AddRemoveThreepids.pcss} | 20 +- src/AddThreepid.ts | 7 +- .../views/settings/AddRemoveThreepids.tsx | 534 ++++++++++++++++++ .../settings/UserPersonalInfoSettings.tsx | 57 +- .../views/settings/account/EmailAddresses.tsx | 303 ---------- .../views/settings/account/PhoneNumbers.tsx | 342 ----------- .../settings/discovery/DiscoverySettings.tsx | 64 ++- .../settings/discovery/EmailAddresses.tsx | 251 -------- .../views/settings/discovery/PhoneNumbers.tsx | 263 --------- src/i18n/strings/en_EN.json | 1 - .../settings/AddRemoveThreepids-test.tsx | 534 ++++++++++++++++++ .../AddRemoveThreepids-test.tsx.snap | 172 ++++++ .../settings/account/PhoneNumbers-test.tsx | 67 --- .../__snapshots__/PhoneNumbers-test.tsx.snap | 110 ---- .../discovery/EmailAddresses-test.tsx | 167 ------ .../settings/discovery/PhoneNumbers-test.tsx | 101 ---- .../EmailAddresses-test.tsx.snap | 97 ---- .../__snapshots__/PhoneNumbers-test.tsx.snap | 163 ------ .../AccountUserSettingsTab-test.tsx.snap | 112 ++-- test/test-utils/test-utils.ts | 5 + 21 files changed, 1391 insertions(+), 1981 deletions(-) rename res/css/components/views/settings/{_EmailAddressesPhoneNumbers.pcss => _AddRemoveThreepids.pcss} (72%) create mode 100644 src/components/views/settings/AddRemoveThreepids.tsx delete mode 100644 src/components/views/settings/account/EmailAddresses.tsx delete mode 100644 src/components/views/settings/account/PhoneNumbers.tsx delete mode 100644 src/components/views/settings/discovery/EmailAddresses.tsx delete mode 100644 src/components/views/settings/discovery/PhoneNumbers.tsx create mode 100644 test/components/views/settings/AddRemoveThreepids-test.tsx create mode 100644 test/components/views/settings/__snapshots__/AddRemoveThreepids-test.tsx.snap delete mode 100644 test/components/views/settings/account/PhoneNumbers-test.tsx delete mode 100644 test/components/views/settings/account/__snapshots__/PhoneNumbers-test.tsx.snap delete mode 100644 test/components/views/settings/discovery/EmailAddresses-test.tsx delete mode 100644 test/components/views/settings/discovery/PhoneNumbers-test.tsx delete mode 100644 test/components/views/settings/discovery/__snapshots__/EmailAddresses-test.tsx.snap delete mode 100644 test/components/views/settings/discovery/__snapshots__/PhoneNumbers-test.tsx.snap diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 85ac596d08..e273667218 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -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"; diff --git a/res/css/components/views/settings/_EmailAddressesPhoneNumbers.pcss b/res/css/components/views/settings/_AddRemoveThreepids.pcss similarity index 72% rename from res/css/components/views/settings/_EmailAddressesPhoneNumbers.pcss rename to res/css/components/views/settings/_AddRemoveThreepids.pcss index 76dfc2d73b..0e9ef83ae7 100644 --- a/res/css/components/views/settings/_EmailAddressesPhoneNumbers.pcss +++ b/res/css/components/views/settings/_AddRemoveThreepids.pcss @@ -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; +} diff --git a/src/AddThreepid.ts b/src/AddThreepid.ts index ee244f2c92..9549e6d08b 100644 --- a/src/AddThreepid.ts +++ b/src/AddThreepid.ts @@ -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 diff --git a/src/components/views/settings/AddRemoveThreepids.tsx b/src/components/views/settings/AddRemoveThreepids.tsx new file mode 100644 index 0000000000..242afd272c --- /dev/null +++ b/src/components/views/settings/AddRemoveThreepids.tsx @@ -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 = ({ mode, threepid, onChange, disabled }) => { + const [isConfirming, setIsConfirming] = useState(false); + const client = useMatrixClientContext(); + const bindTask = useRef(); + + 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) => { + setVerificationCode(e.target.value); + }, []); + + if (isConfirming) { + return ( +
+ + {threepid.medium === ThreepidMedium.Email + ? _t("settings|general|remove_email_prompt", { email: threepid.address }) + : _t("settings|general|remove_msisdn_prompt", { phone: threepid.address })} + + + {_t("action|remove")} + + + {_t("action|cancel")} + +
+ ); + } + + if (isVerifyingBind) { + if (threepid.medium === ThreepidMedium.Email) { + return ( +
+ + {_t("settings|general|discovery_email_verification_instructions")} + + + {_t("action|complete")} + +
+ ); + } else { + return ( +
+ + {_t("settings|general|msisdn_verification_instructions")} + +
+ + +
+ ); + } + } + + return ( +
+ {threepid.address} + + {mode === "hs" ? _t("action|remove") : threepid.bound ? _t("action|revoke") : _t("action|share")} + +
+ ); +}; + +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(); + 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) => { + 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) => { + setVerificationCodeInput(e.target.value); + }, []); + + if (isVerifying && medium === "email") { + return ( +
+
{_t("settings|general|add_email_instructions")}
+ + {_t("action|continue")} + +
+ ); + } else if (isVerifying) { + return ( +
+
+ {_t("settings|general|add_msisdn_instructions", { msisdn: sentToMsisdn })} +
+
+
+ + + {_t("action|continue")} + + +
+ ); + } + + const phoneCountry = + medium === "msisdn" ? ( + + ) : undefined; + + return ( +
+ + + {_t("action|add")} + + + ); +}; + +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 = ({ + mode, + medium, + threepids, + disabled, + onChange, + isLoading, +}) => { + if (isLoading) { + return ; + } + + const existingEmailElements = threepids.map((e) => { + return ; + }); + + return ( + <> + {existingEmailElements} + {mode === "hs" && } + + ); +}; diff --git a/src/components/views/settings/UserPersonalInfoSettings.tsx b/src/components/views/settings/UserPersonalInfoSettings.tsx index 8e5880a517..63925424aa 100644 --- a/src/components/views/settings/UserPersonalInfoSettings.tsx +++ b/src/components/views/settings/UserPersonalInfoSettings.tsx @@ -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,26 +63,28 @@ export const UserPersonalInfoSettings: React.FC = const client = useMatrixClientContext(); - useEffect(() => { - (async () => { - try { - const threepids = await client.getThreePids(); - setEmails(threepids.threepids.filter((a) => a.medium === ThreepidMedium.Email)); - setPhoneNumbers(threepids.threepids.filter((a) => a.medium === ThreepidMedium.Phone)); - setLoadingState("loaded"); - } catch (e) { - setLoadingState("error"); - } - })(); + const updateThreepids = useCallback(async () => { + try { + const threepids = await client.getThreePids(); + setEmails(threepids.threepids.filter((a) => a.medium === ThreepidMedium.Email)); + setPhoneNumbers(threepids.threepids.filter((a) => a.medium === ThreepidMedium.Phone)); + setLoadingState("loaded"); + } 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 = error={_t("settings|general|unable_to_load_emails")} loadingState={loadingState} > - @@ -116,10 +120,13 @@ export const UserPersonalInfoSettings: React.FC = error={_t("settings|general|unable_to_load_msisdns")} loadingState={loadingState} > - diff --git a/src/components/views/settings/account/EmailAddresses.tsx b/src/components/views/settings/account/EmailAddresses.tsx deleted file mode 100644 index b18f3de61d..0000000000 --- a/src/components/views/settings/account/EmailAddresses.tsx +++ /dev/null @@ -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 { - 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 ( -
- - {_t("settings|general|remove_email_prompt", { email: this.props.email.address })} - - - {_t("action|remove")} - - - {_t("action|cancel")} - -
- ); - } - - return ( -
- - {this.props.email.address} - - - {_t("action|remove")} - -
- ); - } -} - -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 { - 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): 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 ( - - ); - }); - - let addButton = ( - - {_t("action|add")} - - ); - if (this.state.verifying) { - addButton = ( -
-
{_t("settings|general|add_email_instructions")}
- - {_t("action|continue")} - -
- ); - } - - return ( - <> - {existingEmailElements} -
- - {addButton} - - - ); - } -} diff --git a/src/components/views/settings/account/PhoneNumbers.tsx b/src/components/views/settings/account/PhoneNumbers.tsx deleted file mode 100644 index 33c00c49b9..0000000000 --- a/src/components/views/settings/account/PhoneNumbers.tsx +++ /dev/null @@ -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 { - 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 ( -
- - {_t("settings|general|remove_msisdn_prompt", { phone: this.props.msisdn.address })} - - - {_t("action|remove")} - - - {_t("action|cancel")} - -
- ); - } - - return ( -
- - +{this.props.msisdn.address} - - - {_t("action|remove")} - -
- ); - } -} - -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 { - 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): void => { - this.setState({ - newPhoneNumber: e.target.value, - }); - }; - - private onChangeNewPhoneNumberCode = (e: React.ChangeEvent): 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 ( - - ); - }); - - let addVerifySection = ( - - {_t("action|add")} - - ); - if (this.state.verifying) { - const msisdn = this.state.verifyMsisdn; - addVerifySection = ( -
-
- {_t("settings|general|add_msisdn_instructions", { msisdn: msisdn })} -
- {this.state.verifyError} -
-
- - - {_t("action|continue")} - - -
- ); - } - - const phoneCountry = ( - - ); - - return ( - <> - {existingPhoneElements} -
-
- -
-
- {addVerifySection} - - ); - } -} diff --git a/src/components/views/settings/discovery/DiscoverySettings.tsx b/src/components/views/settings/discovery/DiscoverySettings.tsx index 8b1a20ac2e..4eec56e41f 100644 --- a/src/components/views/settings/discovery/DiscoverySettings.tsx +++ b/src/components/views/settings/discovery/DiscoverySettings.tsx @@ -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(true); const [emails, setEmails] = useState([]); const [phoneNumbers, setPhoneNumbers] = useState([]); - const [loadingState, setLoadingState] = useState<"loading" | "loaded" | "error">("loading"); const [idServerName, setIdServerName] = useState(abbreviateUrl(client.getIdentityServerUrl())); const [canMake3pidChanges, setCanMake3pidChanges] = useState(false); @@ -71,9 +70,11 @@ export const DiscoverySettings: React.FC = () => { const [hasTerms, setHasTerms] = useState(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 ? ( - <> - - - - ) : null; + let threepidSection; + if (idServerName) { + threepidSection = ( + <> + + + + + + + + ); + } return ( - + {threepidSection} {/* has its own heading as it includes the current identity server */} diff --git a/src/components/views/settings/discovery/EmailAddresses.tsx b/src/components/views/settings/discovery/EmailAddresses.tsx deleted file mode 100644 index 3f0568f544..0000000000 --- a/src/components/views/settings/discovery/EmailAddresses.tsx +++ /dev/null @@ -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 { - public constructor(props: IEmailAddressProps) { - super(props); - - const { bound } = props.email; - - this.state = { - verifying: false, - addTask: null, - continueDisabled: false, - bound, - }; - } - - public componentDidUpdate(prevProps: Readonly): void { - if (this.props.email !== prevProps.email) { - const { bound } = this.props.email; - this.setState({ bound }); - } - } - - private async changeBinding({ bind, label, errorTitle }: Binding): Promise { - 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 => { - 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 = ( - - {_t("settings|general|discovery_email_verification_instructions")} - - {_t("action|complete")} - - - ); - } else if (bound) { - status = ( - - {_t("action|revoke")} - - ); - } else { - status = ( - - {_t("action|share")} - - ); - } - - return ( -
- {address} - {status} -
- ); - } -} -interface IProps { - emails: ThirdPartyIdentifier[]; - isLoading?: boolean; - disabled?: boolean; -} - -export default class EmailAddresses extends React.Component { - public render(): React.ReactNode { - let content; - if (this.props.isLoading) { - content = ; - } else if (this.props.emails.length > 0) { - content = this.props.emails.map((e) => { - return ; - }); - } - - const hasEmails = !!this.props.emails.length; - - return ( - - {content} - - ); - } -} diff --git a/src/components/views/settings/discovery/PhoneNumbers.tsx b/src/components/views/settings/discovery/PhoneNumbers.tsx deleted file mode 100644 index e7a3326be2..0000000000 --- a/src/components/views/settings/discovery/PhoneNumbers.tsx +++ /dev/null @@ -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 { - 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): void { - if (this.props.msisdn !== prevProps.msisdn) { - const { bound } = this.props.msisdn; - this.setState({ bound }); - } - } - - private async changeBinding({ bind, label, errorTitle }: Binding): Promise { - 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): void => { - this.setState({ - verificationCode: e.target.value, - }); - }; - - private onContinueClick = async (e: ButtonEvent | React.FormEvent): Promise => { - 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 = ( - - - {_t("settings|general|msisdn_verification_instructions")} -
- {this.state.verifyError} -
-
- - -
- ); - } else if (bound) { - status = ( - - {_t("action|revoke")} - - ); - } else { - status = ( - - {_t("action|share")} - - ); - } - - return ( -
- +{address} - {status} -
- ); - } -} - -interface IProps { - msisdns: ThirdPartyIdentifier[]; - isLoading?: boolean; - disabled?: boolean; -} - -export default class PhoneNumbers extends React.Component { - public render(): React.ReactNode { - let content; - if (this.props.isLoading) { - content = ; - } else if (this.props.msisdns.length > 0) { - content = this.props.msisdns.map((e) => { - return ; - }); - } - - const description = (!content && _t("settings|general|discovery_msisdn_empty")) || undefined; - - return ( - - {content} - - ); - } -} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index ff216691ec..8bae5b5bad 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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", diff --git a/test/components/views/settings/AddRemoveThreepids-test.tsx b/test/components/views/settings/AddRemoveThreepids-test.tsx new file mode 100644 index 0000000000..404df3c37e --- /dev/null +++ b/test/components/views/settings/AddRemoveThreepids-test.tsx @@ -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 }) => ( + {children} + ); + + it("should render a loader while loading", async () => { + render( + {}} + />, + ); + + expect(screen.getByLabelText("Loading…")).toBeInTheDocument(); + }); + + it("should render email addresses", async () => { + const { container } = render( + {}} + />, + ); + + expect(container).toMatchSnapshot(); + }); + + it("should render phone numbers", async () => { + const { container } = render( + {}} + />, + ); + + expect(container).toMatchSnapshot(); + }); + + it("should handle no email addresses", async () => { + const { container } = render( + {}} + />, + ); + + expect(container).toMatchSnapshot(); + }); + + it("should add an email address", async () => { + const onChangeFn = jest.fn(); + mocked(client.requestAdd3pidEmailToken).mockResolvedValue({ sid: "1" }); + + render( + , + { + 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( + , + { + 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( + , + { + 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( + , + { + 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( + , + { + 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( + , + { + 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( + , + { + 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( + , + { + 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( + , + { + 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( + , + { + 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( + , + { + 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(); + }); +}); diff --git a/test/components/views/settings/__snapshots__/AddRemoveThreepids-test.tsx.snap b/test/components/views/settings/__snapshots__/AddRemoveThreepids-test.tsx.snap new file mode 100644 index 0000000000..52e754d691 --- /dev/null +++ b/test/components/views/settings/__snapshots__/AddRemoveThreepids-test.tsx.snap @@ -0,0 +1,172 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AddRemoveThreepids should handle no email addresses 1`] = ` +
+
+
+ + +
+
+ Add +
+
+
+`; + +exports[`AddRemoveThreepids should render email addresses 1`] = ` +
+
+ + alice@nowhere.dummy + +
+ Remove +
+
+
+
+ + +
+
+ Add +
+
+
+`; + +exports[`AddRemoveThreepids should render phone numbers 1`] = ` +
+
+ + 447700900000 + +
+ Remove +
+
+
+
+ +
+ +
+
+ + +
+
+ Add +
+
+
+`; diff --git a/test/components/views/settings/account/PhoneNumbers-test.tsx b/test/components/views/settings/account/PhoneNumbers-test.tsx deleted file mode 100644 index 0846371d6c..0000000000 --- a/test/components/views/settings/account/PhoneNumbers-test.tsx +++ /dev/null @@ -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("", () => { - 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( - , - ); - - 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" }]); - }); -}); diff --git a/test/components/views/settings/account/__snapshots__/PhoneNumbers-test.tsx.snap b/test/components/views/settings/account/__snapshots__/PhoneNumbers-test.tsx.snap deleted file mode 100644 index 417101d360..0000000000 --- a/test/components/views/settings/account/__snapshots__/PhoneNumbers-test.tsx.snap +++ /dev/null @@ -1,110 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` should allow a phone number to be added 1`] = ` - -
-
-
- -
- -
-
- - -
-
-
-
-
- A text message has been sent to +447900111222. Please enter the verification code it contains. -
-
-
-
- - -
-
- Continue -
-
-
-
-`; diff --git a/test/components/views/settings/discovery/EmailAddresses-test.tsx b/test/components/views/settings/discovery/EmailAddresses-test.tsx deleted file mode 100644 index a7f73a5941..0000000000 --- a/test/components/views/settings/discovery/EmailAddresses-test.tsx +++ /dev/null @@ -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("", () => { - 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(); - await screen.findByText("Share"); - - rerender( - , - ); - await screen.findByText("Revoke"); - }); - - describe("Email verification share phase", () => { - it("shows translated error message", async () => { - render(); - 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(); - 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("", () => { - it("should render a loader while loading", async () => { - const { container } = render(); - - expect(container).toMatchSnapshot(); - }); - - it("should render email addresses", async () => { - const { container } = render(); - - expect(container).toMatchSnapshot(); - }); - - it("should handle no email addresses", async () => { - const { container } = render(); - - expect(container).toMatchSnapshot(); - }); -}); diff --git a/test/components/views/settings/discovery/PhoneNumbers-test.tsx b/test/components/views/settings/discovery/PhoneNumbers-test.tsx deleted file mode 100644 index 19aede79cd..0000000000 --- a/test/components/views/settings/discovery/PhoneNumbers-test.tsx +++ /dev/null @@ -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("", () => { - it("should track props.msisdn.bound changes", async () => { - const { rerender } = render(); - await screen.findByText("Share"); - - rerender(); - await screen.findByText("Revoke"); - }); -}); - -const mockGetAccessToken = jest.fn().mockResolvedValue("$$getAccessToken"); -jest.mock("../../../../../src/IdentityAuthClient", () => - jest.fn().mockImplementation(() => ({ - getAccessToken: mockGetAccessToken, - })), -); - -describe("", () => { - it("should render a loader while loading", async () => { - const { container } = render(); - - expect(container).toMatchSnapshot(); - }); - - it("should render phone numbers", async () => { - const { container } = render(); - - expect(container).toMatchSnapshot(); - }); - - it("should handle no numbers", async () => { - const { container } = render(); - - expect(container).toMatchSnapshot(); - }); - - it("should allow binding msisdn", async () => { - const cli = stubClient(); - const { getByText, getByLabelText, asFragment } = render( - , - ); - - 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"); - }); -}); diff --git a/test/components/views/settings/discovery/__snapshots__/EmailAddresses-test.tsx.snap b/test/components/views/settings/discovery/__snapshots__/EmailAddresses-test.tsx.snap deleted file mode 100644 index 37c6a20dd5..0000000000 --- a/test/components/views/settings/discovery/__snapshots__/EmailAddresses-test.tsx.snap +++ /dev/null @@ -1,97 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` should handle no email addresses 1`] = ` -
-
-
-

- Email addresses -

-
-
-
- Discovery options will appear once you have added an email. -
-
-
-
-`; - -exports[` should render a loader while loading 1`] = ` -
-
-
-

- Email addresses -

-
-
-
-
-
-
-
-
-`; - -exports[` should render email addresses 1`] = ` -
-
-
-

- Email addresses -

-
-
-
- - foo@bar.com - -
- Share -
-
-
-
-
-`; diff --git a/test/components/views/settings/discovery/__snapshots__/PhoneNumbers-test.tsx.snap b/test/components/views/settings/discovery/__snapshots__/PhoneNumbers-test.tsx.snap deleted file mode 100644 index 9e0d15c2ac..0000000000 --- a/test/components/views/settings/discovery/__snapshots__/PhoneNumbers-test.tsx.snap +++ /dev/null @@ -1,163 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` should allow binding msisdn 1`] = ` - -
-
-

- Phone numbers -

-
-
-
- - +441111111111 - - - - Please enter verification code sent via text. -
-
-
-
- - -
-
-
-
-
-
-
-`; - -exports[` should handle no numbers 1`] = ` -
-
-
-

- Phone numbers -

-
-
-
- Discovery options will appear once you have added a phone number. -
-
-
-
-`; - -exports[` should render a loader while loading 1`] = ` -
-
-
-

- Phone numbers -

-
-
-
-
-
-
-
-
-`; - -exports[` should render phone numbers 1`] = ` -
-
-
-

- Phone numbers -

-
-
-
- - + - 441111111111 - -
- Share -
-
-
-
-
-`; diff --git a/test/components/views/settings/tabs/user/__snapshots__/AccountUserSettingsTab-test.tsx.snap b/test/components/views/settings/tabs/user/__snapshots__/AccountUserSettingsTab-test.tsx.snap index 2e5b6e5827..6c51cc41ab 100644 --- a/test/components/views/settings/tabs/user/__snapshots__/AccountUserSettingsTab-test.tsx.snap +++ b/test/components/views/settings/tabs/user/__snapshots__/AccountUserSettingsTab-test.tsx.snap @@ -18,10 +18,10 @@ exports[` 3pids should display 3pid email addresses an class="mx_SettingsSubsection_content mx_SettingsSubsection_contentStretch" >
test@test.io @@ -84,12 +84,11 @@ exports[` 3pids should display 3pid email addresses an class="mx_SettingsSubsection_content mx_SettingsSubsection_contentStretch" >
- + 123456789
3pids should display 3pid email addresses an
-
- - - - -
+
+ + + +
+
+ Add
-
- Add -
`; diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index a263ff296c..8aa2a5ec31 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -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(),