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:
parent
de898d1b62
commit
4751c52d82
21 changed files with 1391 additions and 1981 deletions
|
@ -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";
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
|
|
534
src/components/views/settings/AddRemoveThreepids.tsx
Normal file
534
src/components/views/settings/AddRemoveThreepids.tsx
Normal 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} />}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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<UserPersonalInfoSettingsProps> =
|
|||
|
||||
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<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>
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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 ? (
|
||||
<>
|
||||
<DiscoveryEmailAddresses
|
||||
emails={emails}
|
||||
isLoading={loadingState === "loading"}
|
||||
disabled={!canMake3pidChanges}
|
||||
/>
|
||||
<DiscoveryPhoneNumbers
|
||||
msisdns={phoneNumbers}
|
||||
isLoading={loadingState === "loading"}
|
||||
disabled={!canMake3pidChanges}
|
||||
/>
|
||||
</>
|
||||
) : null;
|
||||
let threepidSection;
|
||||
if (idServerName) {
|
||||
threepidSection = (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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} />
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
|
|
534
test/components/views/settings/AddRemoveThreepids-test.tsx
Normal file
534
test/components/views/settings/AddRemoveThreepids-test.tsx
Normal 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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
`;
|
|
@ -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" }]);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
`;
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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");
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
`;
|
|
@ -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>
|
||||
`;
|
|
@ -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,75 +101,70 @@ exports[`<AccountUserSettingsTab /> 3pids should display 3pid email addresses an
|
|||
</div>
|
||||
<form
|
||||
autocomplete="off"
|
||||
class="mx_PhoneNumbers_new"
|
||||
novalidate=""
|
||||
>
|
||||
<div
|
||||
class="mx_PhoneNumbers_input"
|
||||
class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft"
|
||||
>
|
||||
<div
|
||||
class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft"
|
||||
<span
|
||||
class="mx_Field_prefix"
|
||||
>
|
||||
<span
|
||||
class="mx_Field_prefix"
|
||||
<div
|
||||
class="mx_Dropdown mx_PhoneNumbers_country mx_CountryDropdown"
|
||||
>
|
||||
<div
|
||||
class="mx_Dropdown mx_PhoneNumbers_country mx_CountryDropdown"
|
||||
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
|
||||
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"
|
||||
class="mx_Dropdown_option"
|
||||
id="mx_CountryDropdown_value"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
class="mx_CountryDropdown_shortOption"
|
||||
>
|
||||
<div
|
||||
class="mx_Dropdown_option_emoji"
|
||||
>
|
||||
🇺🇸
|
||||
</div>
|
||||
+1
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="mx_Dropdown_arrow"
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
<input
|
||||
autocomplete="tel-national"
|
||||
id="mx_Field_10"
|
||||
label="Phone Number"
|
||||
placeholder="Phone Number"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
<label
|
||||
for="mx_Field_10"
|
||||
>
|
||||
Phone Number
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<input
|
||||
autocomplete="tel-national"
|
||||
id="mx_Field_10"
|
||||
label="Phone Number"
|
||||
placeholder="Phone Number"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
<label
|
||||
for="mx_Field_10"
|
||||
>
|
||||
Phone Number
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Add
|
||||
</div>
|
||||
</form>
|
||||
<div
|
||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Add
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
|
@ -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(),
|
||||
|
|
Loading…
Reference in a new issue