Files
ThreadNet-Web/src/components/views/settings/AddRemoveThreepids.tsx
T
David BakerandGitHub 4751c52d82 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
2024-08-14 13:13:57 +00:00

535 lines
21 KiB
TypeScript

/*
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} />}
</>
);
};