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
|
@ -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",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue