diff --git a/components.json b/components.json index e16fd910924..c17604a289d 100644 --- a/components.json +++ b/components.json @@ -24,5 +24,6 @@ "src/components/views/rooms/RoomSublist.tsx": "src/components/views/rooms/RoomSublist.tsx", "src/components/views/dialogs/FeedbackDialog.tsx": "src/components/views/dialogs/FeedbackDialog.tsx", "src/components/views/user-onboarding/UserOnboardingHeader.tsx": "src/components/views/user-onboarding/UserOnboardingHeader.tsx", - "src/components/views/dialogs/AppDownloadDialog.tsx": "src/components/views/dialogs/AppDownloadDialog.tsx" + "src/components/views/dialogs/AppDownloadDialog.tsx": "src/components/views/dialogs/AppDownloadDialog.tsx", + "src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx": "src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx" } diff --git a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx new file mode 100644 index 00000000000..eadf8a28a11 --- /dev/null +++ b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx @@ -0,0 +1,580 @@ +/* +Copyright 2019 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> + +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, { ReactNode } from "react"; +import { SERVICE_TYPES, HTTPError, IThreepid, ThreepidMedium } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; +import { Icon as WarningIcon } from "matrix-react-sdk/res/img/feather-customised/warning-triangle.svg"; +import { UserFriendlyError, _t } from "matrix-react-sdk/src/languageHandler"; +import ProfileSettings from "matrix-react-sdk/src/components/views/settings/ProfileSettings"; +import * as languageHandler from "matrix-react-sdk/src/languageHandler"; +import SettingsStore from "matrix-react-sdk/src/settings/SettingsStore"; +import LanguageDropdown from "matrix-react-sdk/src/components/views/elements/LanguageDropdown"; +import SpellCheckSettings from "matrix-react-sdk/src/components/views/settings/SpellCheckSettings"; +import AccessibleButton from "matrix-react-sdk/src/components/views/elements/AccessibleButton"; +import DeactivateAccountDialog from "matrix-react-sdk/src/components/views/dialogs/DeactivateAccountDialog"; +import PlatformPeg from "matrix-react-sdk/src/PlatformPeg"; +import Modal from "matrix-react-sdk/src/Modal"; +import dis from "matrix-react-sdk/src/dispatcher/dispatcher"; +import { Service, ServicePolicyPair, startTermsFlow } from "matrix-react-sdk/src/Terms"; +import IdentityAuthClient from "matrix-react-sdk/src/IdentityAuthClient"; +import { abbreviateUrl } from "matrix-react-sdk/src/utils/UrlUtils"; +import { getThreepidsWithBindStatus } from "matrix-react-sdk/src/boundThreepids"; +import { SettingLevel } from "matrix-react-sdk/src/settings/SettingLevel"; +import { UIFeature } from "matrix-react-sdk/src/settings/UIFeature"; +import { ActionPayload } from "matrix-react-sdk/src/dispatcher/payloads"; +import ErrorDialog, { extractErrorMessageFromError } from "matrix-react-sdk/src/components/views/dialogs/ErrorDialog"; +import AccountPhoneNumbers from "matrix-react-sdk/src/components/views/settings/account/PhoneNumbers"; +import AccountEmailAddresses from "matrix-react-sdk/src/components/views/settings/account/EmailAddresses"; +import DiscoveryEmailAddresses from "matrix-react-sdk/src/components/views/settings/discovery/EmailAddresses"; +import DiscoveryPhoneNumbers from "matrix-react-sdk/src/components/views/settings/discovery/PhoneNumbers"; +import ChangePassword from "matrix-react-sdk/src/components/views/settings/ChangePassword"; +import InlineTermsAgreement from "matrix-react-sdk/src/components/views/terms/InlineTermsAgreement"; +import SetIdServer from "matrix-react-sdk/src/components/views/settings/SetIdServer"; +import SetIntegrationManager from "matrix-react-sdk/src/components/views/settings/SetIntegrationManager"; +import ToggleSwitch from "matrix-react-sdk/src/components/views/elements/ToggleSwitch"; +import { IS_MAC } from "matrix-react-sdk/src/Keyboard"; +import SettingsTab from "matrix-react-sdk/src/components/views/settings/tabs/SettingsTab"; +import { SettingsSection } from "matrix-react-sdk/src/components/views/settings/shared/SettingsSection"; +import SettingsSubsection, { + SettingsSubsectionText, +} from "matrix-react-sdk/src/components/views/settings/shared/SettingsSubsection"; +import { SettingsSubsectionHeading } from "matrix-react-sdk/src/components/views/settings/shared/SettingsSubsectionHeading"; +import Heading from "matrix-react-sdk/src/components/views/typography/Heading"; +import InlineSpinner from "matrix-react-sdk/src/components/views/elements/InlineSpinner"; +import { ThirdPartyIdentifier } from "matrix-react-sdk/src/AddThreepid"; +import { SDKContext } from "matrix-react-sdk/src/contexts/SDKContext"; + +interface IProps { + closeSettingsFn: () => void; +} + +interface IState { + language: string; + spellCheckEnabled?: boolean; + spellCheckLanguages: string[]; + haveIdServer: boolean; + idServerHasUnsignedTerms: boolean; + requiredPolicyInfo: + | { + // This object is passed along to a component for handling + hasTerms: false; + policiesAndServices: null; // From the startTermsFlow callback + agreedUrls: null; // From the startTermsFlow callback + resolve: null; // Promise resolve function for startTermsFlow callback + } + | { + hasTerms: boolean; + policiesAndServices: ServicePolicyPair[]; + agreedUrls: string[]; + resolve: (values: string[]) => void; + }; + emails: ThirdPartyIdentifier[]; + msisdns: ThirdPartyIdentifier[]; + loading3pids: boolean; // whether or not the emails and msisdns have been loaded + canChangePassword: boolean; + idServerName?: string; + externalAccountManagementUrl?: string; + canMake3pidChanges: boolean; +} + +export default class GeneralUserSettingsTab extends React.Component { + public static contextType = SDKContext; + public context!: React.ContextType; + + private readonly dispatcherRef: string; + + public constructor(props: IProps, context: React.ContextType) { + super(props); + this.context = context; + + const cli = this.context.client!; + + this.state = { + language: languageHandler.getCurrentLanguage(), + spellCheckEnabled: false, + spellCheckLanguages: [], + haveIdServer: Boolean(cli.getIdentityServerUrl()), + idServerHasUnsignedTerms: false, + requiredPolicyInfo: { + // This object is passed along to a component for handling + hasTerms: false, + policiesAndServices: null, // From the startTermsFlow callback + agreedUrls: null, // From the startTermsFlow callback + resolve: null, // Promise resolve function for startTermsFlow callback + }, + emails: [], + msisdns: [], + loading3pids: true, // whether or not the emails and msisdns have been loaded + canChangePassword: false, + canMake3pidChanges: false, + }; + + this.dispatcherRef = dis.register(this.onAction); + + this.getCapabilities(); + this.getThreepidState(); + } + + public async componentDidMount(): Promise { + const plat = PlatformPeg.get(); + const [spellCheckEnabled, spellCheckLanguages] = await Promise.all([ + plat?.getSpellCheckEnabled(), + plat?.getSpellCheckLanguages(), + ]); + + if (spellCheckLanguages) { + this.setState({ + spellCheckEnabled, + spellCheckLanguages, + }); + } + } + + public componentWillUnmount(): void { + dis.unregister(this.dispatcherRef); + } + + private onAction = (payload: ActionPayload): void => { + if (payload.action === "id_server_changed") { + this.setState({ haveIdServer: Boolean(this.context.client!.getIdentityServerUrl()) }); + this.getThreepidState(); + } + }; + + private onEmailsChange = (emails: ThirdPartyIdentifier[]): void => { + this.setState({ emails }); + }; + + private onMsisdnsChange = (msisdns: ThirdPartyIdentifier[]): void => { + this.setState({ msisdns }); + }; + + private async getCapabilities(): Promise { + const cli = this.context.client!; + + const capabilities = await cli.getCapabilities(); // this is cached + const changePasswordCap = capabilities["m.change_password"]; + + // You can change your password so long as the capability isn't explicitly disabled. The implicit + // behaviour is you can change your password when the capability is missing or has not-false as + // the enabled flag value. + const canChangePassword = !changePasswordCap || changePasswordCap["enabled"] !== false; + + const externalAccountManagementUrl = this.context.oidcClientStore.accountManagementEndpoint; + // https://spec.matrix.org/v1.7/client-server-api/#m3pid_changes-capability + // We support as far back as v1.1 which doesn't have m.3pid_changes + // so the behaviour for when it is missing has to be assume true + const canMake3pidChanges = !capabilities["m.3pid_changes"] || capabilities["m.3pid_changes"].enabled === true; + + this.setState({ canChangePassword, externalAccountManagementUrl, canMake3pidChanges }); + } + + private async getThreepidState(): Promise { + const cli = this.context.client!; + + // Check to see if terms need accepting + this.checkTerms(); + + // Need to get 3PIDs generally for Account section and possibly also for + // Discovery (assuming we have an IS and terms are agreed). + let threepids: IThreepid[] = []; + try { + threepids = await getThreepidsWithBindStatus(cli); + } catch (e) { + const idServerUrl = cli.getIdentityServerUrl(); + logger.warn( + `Unable to reach identity server at ${idServerUrl} to check ` + `for 3PIDs bindings in Settings`, + ); + logger.warn(e); + } + this.setState({ + emails: threepids.filter((a) => a.medium === ThreepidMedium.Email), + msisdns: threepids.filter((a) => a.medium === ThreepidMedium.Phone), + loading3pids: false, + }); + } + + private async checkTerms(): Promise { + // By starting the terms flow we get the logic for checking which terms the user has signed + // for free. So we might as well use that for our own purposes. + const idServerUrl = this.context.client!.getIdentityServerUrl(); + if (!this.state.haveIdServer || !idServerUrl) { + this.setState({ idServerHasUnsignedTerms: false }); + return; + } + + const authClient = new IdentityAuthClient(); + try { + const idAccessToken = await authClient.getAccessToken({ check: false }); + await startTermsFlow( + this.context.client!, + [new Service(SERVICE_TYPES.IS, idServerUrl, idAccessToken!)], + (policiesAndServices, agreedUrls, extraClassNames) => { + return new Promise((resolve, reject) => { + this.setState({ + idServerName: abbreviateUrl(idServerUrl), + requiredPolicyInfo: { + hasTerms: true, + policiesAndServices, + agreedUrls, + resolve, + }, + }); + }); + }, + ); + // User accepted all terms + this.setState({ + requiredPolicyInfo: { + ...this.state.requiredPolicyInfo, // set first so we can override + hasTerms: false, + }, + }); + } catch (e) { + logger.warn(`Unable to reach identity server at ${idServerUrl} to check ` + `for terms in Settings`); + logger.warn(e); + } + } + + private onLanguageChange = (newLanguage: string): void => { + if (this.state.language === newLanguage) return; + + SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLanguage); + this.setState({ language: newLanguage }); + const platform = PlatformPeg.get(); + if (platform) { + platform.setLanguage([newLanguage]); + platform.reload(); + } + }; + + private onSpellCheckLanguagesChange = (languages: string[]): void => { + this.setState({ spellCheckLanguages: languages }); + PlatformPeg.get()?.setSpellCheckLanguages(languages); + }; + + private onSpellCheckEnabledChange = (spellCheckEnabled: boolean): void => { + this.setState({ spellCheckEnabled }); + PlatformPeg.get()?.setSpellCheckEnabled(spellCheckEnabled); + }; + + private onPasswordChangeError = (err: Error): void => { + logger.error("Failed to change password: " + err); + + let underlyingError = err; + if (err instanceof UserFriendlyError && err.cause instanceof Error) { + underlyingError = err.cause; + } + + const errorMessage = extractErrorMessageFromError( + err, + _t("settings|general|error_password_change_unknown", { + stringifiedError: String(err), + }), + ); + + let errorMessageToDisplay = errorMessage; + if (underlyingError instanceof HTTPError && underlyingError.httpStatus === 403) { + errorMessageToDisplay = _t("settings|general|error_password_change_403"); + } else if (underlyingError instanceof HTTPError) { + errorMessageToDisplay = _t("settings|general|error_password_change_http", { + errorMessage, + httpStatus: underlyingError.httpStatus, + }); + } + + // TODO: Figure out a design that doesn't involve replacing the current dialog + Modal.createDialog(ErrorDialog, { + title: _t("settings|general|error_password_change_title"), + description: errorMessageToDisplay, + }); + }; + + private onPasswordChanged = (): void => { + const description = _t("settings|general|password_change_success"); + // TODO: Figure out a design that doesn't involve replacing the current dialog + Modal.createDialog(ErrorDialog, { + title: _t("common|success"), + description, + }); + }; + + private onDeactivateClicked = (): void => { + Modal.createDialog(DeactivateAccountDialog, { + onFinished: (success) => { + if (success) this.props.closeSettingsFn(); + }, + }); + }; + + private renderAccountSection(): JSX.Element { + let threepidSection: ReactNode = null; + + if (SettingsStore.getValue(UIFeature.ThirdPartyID)) { + const emails = this.state.loading3pids ? ( + + ) : ( + + ); + const msisdns = this.state.loading3pids ? ( + + ) : ( + + ); + threepidSection = ( + <> + + {emails} + + + { + // Hide phone numbers TMP + false && ( + + {msisdns} + + ) + } + + ); + } + + let passwordChangeSection: ReactNode = null; + if (this.state.canChangePassword) { + passwordChangeSection = ( + <> + {_t("settings|general|password_change_section")} + + + ); + } + + let externalAccountManagement: JSX.Element | undefined; + if (this.state.externalAccountManagementUrl) { + const { hostname } = new URL(this.state.externalAccountManagementUrl); + + externalAccountManagement = ( + <> + + {_t( + "settings|general|external_account_management", + { hostname }, + { code: (sub) => {sub} }, + )} + + + {_t("settings|general|oidc_manage_button")} + + + ); + } + return ( + <> + + {externalAccountManagement} + {passwordChangeSection} + + {threepidSection} + + ); + } + + private renderLanguageSection(): JSX.Element { + // TODO: Convert to new-styled Field + return ( + + + + ); + } + + private renderSpellCheckSection(): JSX.Element { + const heading = ( + + + + ); + return ( + + {this.state.spellCheckEnabled && !IS_MAC && ( + + )} + + ); + } + + private renderDiscoverySection(): JSX.Element { + if (this.state.requiredPolicyInfo.hasTerms) { + const intro = ( + + {_t("settings|general|discovery_needs_terms", { serverName: this.state.idServerName })} + + ); + return ( + <> + + {/* has its own heading as it includes the current identity server */} + + + ); + } + + const threepidSection = this.state.haveIdServer ? ( + <> + + + + ) : null; + + return ( + <> + {threepidSection} + {/* has its own heading as it includes the current identity server */} + + + ); + } + + private renderManagementSection(): JSX.Element { + // TODO: Improve warning text for account deactivation + return ( + + + + {_t("settings|general|deactivate_section")} + + + + ); + } + + private renderIntegrationManagerSection(): ReactNode { + if (!SettingsStore.getValue(UIFeature.Widgets)) return null; + + return ; + } + + public render(): React.ReactNode { + const plaf = PlatformPeg.get(); + const supportsMultiLanguageSpellCheck = plaf?.supportsSpellCheckSettings(); + + let accountManagementSection: JSX.Element | undefined; + const isAccountManagedExternally = !!this.state.externalAccountManagementUrl; + if (SettingsStore.getValue(UIFeature.Deactivate) && !isAccountManagedExternally) { + accountManagementSection = this.renderManagementSection(); + } + + let discoverySection; + if (SettingsStore.getValue(UIFeature.IdentityServer)) { + const discoWarning = this.state.requiredPolicyInfo.hasTerms ? ( + + ) : null; + const heading = ( + + {discoWarning} + {_t("settings|general|discovery_section")} + + ); + discoverySection = ( + + {this.renderDiscoverySection()} + + ); + } + + return ( + + + + {this.renderAccountSection()} + {this.renderLanguageSection()} + {supportsMultiLanguageSpellCheck ? this.renderSpellCheckSection() : null} + + {false && discoverySection} + {this.renderIntegrationManagerSection()} + {accountManagementSection} + + ); + } +} diff --git a/src/vector/platform/ElectronPlatform.tsx b/src/vector/platform/ElectronPlatform.tsx index fdb46bb0eb5..44a357c8a44 100644 --- a/src/vector/platform/ElectronPlatform.tsx +++ b/src/vector/platform/ElectronPlatform.tsx @@ -445,6 +445,8 @@ export default class ElectronPlatform extends VectorBasePlatform { } public async getOidcClientMetadata(): Promise { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore const baseMetadata = await super.getOidcClientMetadata(); const redirectUri = this.getSSOCallbackUrl(); redirectUri.searchParams.delete(SSO_ID_KEY); // it will be shuttled via the state param instead