Implementation of MSC3824 to make the client OIDC-aware (#8681)
							parent
							
								
									32bd350b7e
								
							
						
					
					
						commit
						d698193196
					
				|  | @ -22,6 +22,7 @@ import { encodeUnpaddedBase64 } from "matrix-js-sdk/src/crypto/olmlib"; | |||
| import { logger } from "matrix-js-sdk/src/logger"; | ||||
| import { MatrixEvent } from "matrix-js-sdk/src/models/event"; | ||||
| import { Room } from "matrix-js-sdk/src/models/room"; | ||||
| import { SSOAction } from "matrix-js-sdk/src/@types/auth"; | ||||
| 
 | ||||
| import dis from "./dispatcher/dispatcher"; | ||||
| import BaseEventIndexManager from "./indexing/BaseEventIndexManager"; | ||||
|  | @ -308,9 +309,9 @@ export default abstract class BasePlatform { | |||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     protected getSSOCallbackUrl(fragmentAfterLogin: string): URL { | ||||
|     protected getSSOCallbackUrl(fragmentAfterLogin = ""): URL { | ||||
|         const url = new URL(window.location.href); | ||||
|         url.hash = fragmentAfterLogin || ""; | ||||
|         url.hash = fragmentAfterLogin; | ||||
|         return url; | ||||
|     } | ||||
| 
 | ||||
|  | @ -319,13 +320,15 @@ export default abstract class BasePlatform { | |||
|      * @param {MatrixClient} mxClient the matrix client using which we should start the flow | ||||
|      * @param {"sso"|"cas"} loginType the type of SSO it is, CAS/SSO. | ||||
|      * @param {string} fragmentAfterLogin the hash to pass to the app during sso callback. | ||||
|      * @param {SSOAction} action the SSO flow to indicate to the IdP, optional. | ||||
|      * @param {string} idpId The ID of the Identity Provider being targeted, optional. | ||||
|      */ | ||||
|     public startSingleSignOn( | ||||
|         mxClient: MatrixClient, | ||||
|         loginType: "sso" | "cas", | ||||
|         fragmentAfterLogin: string, | ||||
|         fragmentAfterLogin?: string, | ||||
|         idpId?: string, | ||||
|         action?: SSOAction, | ||||
|     ): void { | ||||
|         // persist hs url and is url for when the user is returned to the app with the login token
 | ||||
|         localStorage.setItem(SSO_HOMESERVER_URL_KEY, mxClient.getHomeserverUrl()); | ||||
|  | @ -336,7 +339,7 @@ export default abstract class BasePlatform { | |||
|             localStorage.setItem(SSO_IDP_ID_KEY, idpId); | ||||
|         } | ||||
|         const callbackUrl = this.getSSOCallbackUrl(fragmentAfterLogin); | ||||
|         window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType, idpId); // redirect to SSO
 | ||||
|         window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType, idpId, action); // redirect to SSO
 | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  |  | |||
|  | @ -23,6 +23,7 @@ import { MatrixClient } from "matrix-js-sdk/src/client"; | |||
| import { decryptAES, encryptAES, IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes"; | ||||
| import { QueryDict } from "matrix-js-sdk/src/utils"; | ||||
| import { logger } from "matrix-js-sdk/src/logger"; | ||||
| import { SSOAction } from "matrix-js-sdk/src/@types/auth"; | ||||
| 
 | ||||
| import { IMatrixClientCreds, MatrixClientPeg } from "./MatrixClientPeg"; | ||||
| import SecurityCustomisations from "./customisations/Security"; | ||||
|  | @ -248,7 +249,7 @@ export function attemptTokenLogin( | |||
|                             idBaseUrl: identityServer, | ||||
|                         }); | ||||
|                         const idpId = localStorage.getItem(SSO_IDP_ID_KEY) || undefined; | ||||
|                         PlatformPeg.get().startSingleSignOn(cli, "sso", fragmentAfterLogin, idpId); | ||||
|                         PlatformPeg.get()?.startSingleSignOn(cli, "sso", fragmentAfterLogin, idpId, SSOAction.LOGIN); | ||||
|                     } | ||||
|                 }, | ||||
|             }); | ||||
|  |  | |||
							
								
								
									
										12
									
								
								src/Login.ts
								
								
								
								
							
							
						
						
									
										12
									
								
								src/Login.ts
								
								
								
								
							|  | @ -19,7 +19,7 @@ limitations under the License. | |||
| import { createClient } from "matrix-js-sdk/src/matrix"; | ||||
| import { MatrixClient } from "matrix-js-sdk/src/client"; | ||||
| import { logger } from "matrix-js-sdk/src/logger"; | ||||
| import { ILoginParams, LoginFlow } from "matrix-js-sdk/src/@types/auth"; | ||||
| import { DELEGATED_OIDC_COMPATIBILITY, ILoginParams, LoginFlow } from "matrix-js-sdk/src/@types/auth"; | ||||
| 
 | ||||
| import { IMatrixClientCreds } from "./MatrixClientPeg"; | ||||
| import SecurityCustomisations from "./customisations/Security"; | ||||
|  | @ -32,7 +32,6 @@ export default class Login { | |||
|     private hsUrl: string; | ||||
|     private isUrl: string; | ||||
|     private fallbackHsUrl: string; | ||||
|     // TODO: Flows need a type in JS SDK
 | ||||
|     private flows: Array<LoginFlow>; | ||||
|     private defaultDeviceDisplayName: string; | ||||
|     private tempClient: MatrixClient; | ||||
|  | @ -81,8 +80,13 @@ export default class Login { | |||
| 
 | ||||
|     public async getFlows(): Promise<Array<LoginFlow>> { | ||||
|         const client = this.createTemporaryClient(); | ||||
|         const { flows } = await client.loginFlows(); | ||||
|         this.flows = flows; | ||||
|         const { flows }: { flows: LoginFlow[] } = await client.loginFlows(); | ||||
|         // If an m.login.sso flow is present which is also flagged as being for MSC3824 OIDC compatibility then we only
 | ||||
|         // return that flow as (per MSC3824) it is the only one that the user should be offered to give the best experience
 | ||||
|         const oidcCompatibilityFlow = flows.find( | ||||
|             (f) => f.type === "m.login.sso" && DELEGATED_OIDC_COMPATIBILITY.findIn(f), | ||||
|         ); | ||||
|         this.flows = oidcCompatibilityFlow ? [oidcCompatibilityFlow] : flows; | ||||
|         return this.flows; | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -18,7 +18,7 @@ import React, { ReactNode } from "react"; | |||
| import { ConnectionError, MatrixError } from "matrix-js-sdk/src/http-api"; | ||||
| import classNames from "classnames"; | ||||
| import { logger } from "matrix-js-sdk/src/logger"; | ||||
| import { ISSOFlow, LoginFlow } from "matrix-js-sdk/src/@types/auth"; | ||||
| import { ISSOFlow, LoginFlow, SSOAction } from "matrix-js-sdk/src/@types/auth"; | ||||
| 
 | ||||
| import { _t, _td } from "../../../languageHandler"; | ||||
| import Login from "../../../Login"; | ||||
|  | @ -345,6 +345,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState> | |||
|                 this.loginLogic.createTemporaryClient(), | ||||
|                 ssoKind, | ||||
|                 this.props.fragmentAfterLogin, | ||||
|                 SSOAction.REGISTER, | ||||
|             ); | ||||
|         } else { | ||||
|             // Don't intercept - just go through to the register page
 | ||||
|  | @ -549,6 +550,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState> | |||
|                 loginType={loginType} | ||||
|                 fragmentAfterLogin={this.props.fragmentAfterLogin} | ||||
|                 primary={!this.state.flows.find((flow) => flow.type === "m.login.password")} | ||||
|                 action={SSOAction.LOGIN} | ||||
|             /> | ||||
|         ); | ||||
|     }; | ||||
|  |  | |||
|  | @ -19,7 +19,7 @@ import React, { Fragment, ReactNode } from "react"; | |||
| import { IRequestTokenResponse, MatrixClient } from "matrix-js-sdk/src/client"; | ||||
| import classNames from "classnames"; | ||||
| import { logger } from "matrix-js-sdk/src/logger"; | ||||
| import { ISSOFlow } from "matrix-js-sdk/src/@types/auth"; | ||||
| import { ISSOFlow, SSOAction } from "matrix-js-sdk/src/@types/auth"; | ||||
| 
 | ||||
| import { _t, _td } from "../../../languageHandler"; | ||||
| import { messageForResourceLimitError } from "../../../utils/ErrorUtils"; | ||||
|  | @ -539,6 +539,7 @@ export default class Registration extends React.Component<IProps, IState> { | |||
|                             flow={this.state.ssoFlow} | ||||
|                             loginType={this.state.ssoFlow.type === "m.login.sso" ? "sso" : "cas"} | ||||
|                             fragmentAfterLogin={this.props.fragmentAfterLogin} | ||||
|                             action={SSOAction.REGISTER} | ||||
|                         /> | ||||
|                         <h2 className="mx_AuthBody_centered"> | ||||
|                             {_t("%(ssoButtons)s Or %(usernamePassword)s", { | ||||
|  |  | |||
|  | @ -17,7 +17,7 @@ limitations under the License. | |||
| import React from "react"; | ||||
| import { logger } from "matrix-js-sdk/src/logger"; | ||||
| import { Optional } from "matrix-events-sdk"; | ||||
| import { ISSOFlow, LoginFlow } from "matrix-js-sdk/src/@types/auth"; | ||||
| import { ISSOFlow, LoginFlow, SSOAction } from "matrix-js-sdk/src/@types/auth"; | ||||
| 
 | ||||
| import { _t } from "../../../languageHandler"; | ||||
| import dis from "../../../dispatcher/dispatcher"; | ||||
|  | @ -256,6 +256,7 @@ export default class SoftLogout extends React.Component<IProps, IState> { | |||
|                     loginType={loginType} | ||||
|                     fragmentAfterLogin={this.props.fragmentAfterLogin} | ||||
|                     primary={!this.state.flows.find((flow) => flow.type === "m.login.password")} | ||||
|                     action={SSOAction.LOGIN} | ||||
|                 /> | ||||
|             </div> | ||||
|         ); | ||||
|  |  | |||
|  | @ -19,7 +19,13 @@ import { chunk } from "lodash"; | |||
| import classNames from "classnames"; | ||||
| import { MatrixClient } from "matrix-js-sdk/src/client"; | ||||
| import { Signup } from "@matrix-org/analytics-events/types/typescript/Signup"; | ||||
| import { IdentityProviderBrand, IIdentityProvider, ISSOFlow } from "matrix-js-sdk/src/@types/auth"; | ||||
| import { | ||||
|     IdentityProviderBrand, | ||||
|     IIdentityProvider, | ||||
|     ISSOFlow, | ||||
|     DELEGATED_OIDC_COMPATIBILITY, | ||||
|     SSOAction, | ||||
| } from "matrix-js-sdk/src/@types/auth"; | ||||
| 
 | ||||
| import PlatformPeg from "../../../PlatformPeg"; | ||||
| import AccessibleButton from "./AccessibleButton"; | ||||
|  | @ -28,9 +34,10 @@ import AccessibleTooltipButton from "./AccessibleTooltipButton"; | |||
| import { mediaFromMxc } from "../../../customisations/Media"; | ||||
| import { PosthogAnalytics } from "../../../PosthogAnalytics"; | ||||
| 
 | ||||
| interface ISSOButtonProps extends Omit<IProps, "flow"> { | ||||
| interface ISSOButtonProps extends IProps { | ||||
|     idp?: IIdentityProvider; | ||||
|     mini?: boolean; | ||||
|     action?: SSOAction; | ||||
| } | ||||
| 
 | ||||
| const getIcon = (brand: IdentityProviderBrand | string): string | null => { | ||||
|  | @ -79,20 +86,29 @@ const SSOButton: React.FC<ISSOButtonProps> = ({ | |||
|     idp, | ||||
|     primary, | ||||
|     mini, | ||||
|     action, | ||||
|     flow, | ||||
|     ...props | ||||
| }) => { | ||||
|     const label = idp ? _t("Continue with %(provider)s", { provider: idp.name }) : _t("Sign in with single sign-on"); | ||||
|     let label: string; | ||||
|     if (idp) { | ||||
|         label = _t("Continue with %(provider)s", { provider: idp.name }); | ||||
|     } else if (DELEGATED_OIDC_COMPATIBILITY.findIn<boolean>(flow)) { | ||||
|         label = _t("Continue"); | ||||
|     } else { | ||||
|         label = _t("Sign in with single sign-on"); | ||||
|     } | ||||
| 
 | ||||
|     const onClick = (): void => { | ||||
|         const authenticationType = getAuthenticationType(idp?.brand ?? ""); | ||||
|         PosthogAnalytics.instance.setAuthenticationType(authenticationType); | ||||
|         PlatformPeg.get().startSingleSignOn(matrixClient, loginType, fragmentAfterLogin, idp?.id); | ||||
|         PlatformPeg.get()?.startSingleSignOn(matrixClient, loginType, fragmentAfterLogin, idp?.id, action); | ||||
|     }; | ||||
| 
 | ||||
|     let icon; | ||||
|     let brandClass; | ||||
|     const brandIcon = idp ? getIcon(idp.brand) : null; | ||||
|     if (brandIcon) { | ||||
|     let icon: JSX.Element | undefined; | ||||
|     let brandClass: string | undefined; | ||||
|     const brandIcon = idp?.brand ? getIcon(idp.brand) : null; | ||||
|     if (idp?.brand && brandIcon) { | ||||
|         const brandName = idp.brand.split(".").pop(); | ||||
|         brandClass = `mx_SSOButton_brand_${brandName}`; | ||||
|         icon = <img src={brandIcon} height="24" width="24" alt={brandName} />; | ||||
|  | @ -101,12 +117,16 @@ const SSOButton: React.FC<ISSOButtonProps> = ({ | |||
|         icon = <img src={src} height="24" width="24" alt={idp.name} />; | ||||
|     } | ||||
| 
 | ||||
|     const classes = classNames("mx_SSOButton", { | ||||
|         [brandClass]: brandClass, | ||||
|         mx_SSOButton_mini: mini, | ||||
|         mx_SSOButton_default: !idp, | ||||
|         mx_SSOButton_primary: primary, | ||||
|     }); | ||||
|     const brandPart = brandClass ? { [brandClass]: brandClass } : undefined; | ||||
|     const classes = classNames( | ||||
|         "mx_SSOButton", | ||||
|         { | ||||
|             mx_SSOButton_mini: mini, | ||||
|             mx_SSOButton_default: !idp, | ||||
|             mx_SSOButton_primary: primary, | ||||
|         }, | ||||
|         brandPart, | ||||
|     ); | ||||
| 
 | ||||
|     if (mini) { | ||||
|         // TODO fallback icon
 | ||||
|  | @ -128,14 +148,15 @@ const SSOButton: React.FC<ISSOButtonProps> = ({ | |||
| interface IProps { | ||||
|     matrixClient: MatrixClient; | ||||
|     flow: ISSOFlow; | ||||
|     loginType?: "sso" | "cas"; | ||||
|     loginType: "sso" | "cas"; | ||||
|     fragmentAfterLogin?: string; | ||||
|     primary?: boolean; | ||||
|     action?: SSOAction; | ||||
| } | ||||
| 
 | ||||
| const MAX_PER_ROW = 6; | ||||
| 
 | ||||
| const SSOButtons: React.FC<IProps> = ({ matrixClient, flow, loginType, fragmentAfterLogin, primary }) => { | ||||
| const SSOButtons: React.FC<IProps> = ({ matrixClient, flow, loginType, fragmentAfterLogin, primary, action }) => { | ||||
|     const providers = flow.identity_providers || []; | ||||
|     if (providers.length < 2) { | ||||
|         return ( | ||||
|  | @ -146,6 +167,8 @@ const SSOButtons: React.FC<IProps> = ({ matrixClient, flow, loginType, fragmentA | |||
|                     fragmentAfterLogin={fragmentAfterLogin} | ||||
|                     idp={providers[0]} | ||||
|                     primary={primary} | ||||
|                     action={action} | ||||
|                     flow={flow} | ||||
|                 /> | ||||
|             </div> | ||||
|         ); | ||||
|  | @ -167,6 +190,8 @@ const SSOButtons: React.FC<IProps> = ({ matrixClient, flow, loginType, fragmentA | |||
|                             idp={idp} | ||||
|                             mini={true} | ||||
|                             primary={primary} | ||||
|                             action={action} | ||||
|                             flow={flow} | ||||
|                         /> | ||||
|                     ))} | ||||
|                 </div> | ||||
|  |  | |||
|  | @ -20,6 +20,7 @@ import React from "react"; | |||
| import { SERVICE_TYPES } from "matrix-js-sdk/src/service-types"; | ||||
| import { IThreepid } from "matrix-js-sdk/src/@types/threepids"; | ||||
| import { logger } from "matrix-js-sdk/src/logger"; | ||||
| import { IDelegatedAuthConfig, M_AUTHENTICATION } from "matrix-js-sdk/src/matrix"; | ||||
| 
 | ||||
| import { _t } from "../../../../../languageHandler"; | ||||
| import ProfileSettings from "../../ProfileSettings"; | ||||
|  | @ -79,6 +80,7 @@ interface IState { | |||
|     loading3pids: boolean; // whether or not the emails and msisdns have been loaded
 | ||||
|     canChangePassword: boolean; | ||||
|     idServerName: string; | ||||
|     externalAccountManagementUrl?: string; | ||||
| } | ||||
| 
 | ||||
| export default class GeneralUserSettingsTab extends React.Component<IProps, IState> { | ||||
|  | @ -106,6 +108,7 @@ export default class GeneralUserSettingsTab extends React.Component<IProps, ISta | |||
|             loading3pids: true, // whether or not the emails and msisdns have been loaded
 | ||||
|             canChangePassword: false, | ||||
|             idServerName: null, | ||||
|             externalAccountManagementUrl: undefined, | ||||
|         }; | ||||
| 
 | ||||
|         this.dispatcherRef = dis.register(this.onAction); | ||||
|  | @ -161,7 +164,10 @@ export default class GeneralUserSettingsTab extends React.Component<IProps, ISta | |||
|         // the enabled flag value.
 | ||||
|         const canChangePassword = !changePasswordCap || changePasswordCap["enabled"] !== false; | ||||
| 
 | ||||
|         this.setState({ serverSupportsSeparateAddAndBind, canChangePassword }); | ||||
|         const delegatedAuthConfig = M_AUTHENTICATION.findIn<IDelegatedAuthConfig | undefined>(cli.getClientWellKnown()); | ||||
|         const externalAccountManagementUrl = delegatedAuthConfig?.account; | ||||
| 
 | ||||
|         this.setState({ serverSupportsSeparateAddAndBind, canChangePassword, externalAccountManagementUrl }); | ||||
|     } | ||||
| 
 | ||||
|     private async getThreepidState(): Promise<void> { | ||||
|  | @ -348,9 +354,37 @@ export default class GeneralUserSettingsTab extends React.Component<IProps, ISta | |||
|             passwordChangeForm = null; | ||||
|         } | ||||
| 
 | ||||
|         let externalAccountManagement: JSX.Element | undefined; | ||||
|         if (this.state.externalAccountManagementUrl) { | ||||
|             const { hostname } = new URL(this.state.externalAccountManagementUrl); | ||||
| 
 | ||||
|             externalAccountManagement = ( | ||||
|                 <> | ||||
|                     <p className="mx_SettingsTab_subsectionText" data-testid="external-account-management-outer"> | ||||
|                         {_t( | ||||
|                             "Your account details are managed separately at <code>%(hostname)s</code>.", | ||||
|                             { hostname }, | ||||
|                             { code: (sub) => <code>{sub}</code> }, | ||||
|                         )} | ||||
|                     </p> | ||||
|                     <AccessibleButton | ||||
|                         onClick={null} | ||||
|                         element="a" | ||||
|                         kind="primary" | ||||
|                         target="_blank" | ||||
|                         rel="noreferrer noopener" | ||||
|                         href={this.state.externalAccountManagementUrl} | ||||
|                         data-testid="external-account-management-link" | ||||
|                     > | ||||
|                         {_t("Manage account")} | ||||
|                     </AccessibleButton> | ||||
|                 </> | ||||
|             ); | ||||
|         } | ||||
|         return ( | ||||
|             <div className="mx_SettingsTab_section mx_GeneralUserSettingsTab_accountSection"> | ||||
|                 <span className="mx_SettingsTab_subheading">{_t("Account")}</span> | ||||
|                 {externalAccountManagement} | ||||
|                 <p className="mx_SettingsTab_subsectionText">{passwordChangeText}</p> | ||||
|                 {passwordChangeForm} | ||||
|                 {threepidSection} | ||||
|  |  | |||
|  | @ -1532,6 +1532,8 @@ | |||
|     "Email addresses": "Email addresses", | ||||
|     "Phone numbers": "Phone numbers", | ||||
|     "Set a new account password...": "Set a new account password...", | ||||
|     "Your account details are managed separately at <code>%(hostname)s</code>.": "Your account details are managed separately at <code>%(hostname)s</code>.", | ||||
|     "Manage account": "Manage account", | ||||
|     "Account": "Account", | ||||
|     "Language and region": "Language and region", | ||||
|     "Spell check": "Spell check", | ||||
|  |  | |||
|  | @ -19,6 +19,7 @@ import { fireEvent, render, screen, waitForElementToBeRemoved } from "@testing-l | |||
| import { mocked, MockedObject } from "jest-mock"; | ||||
| import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix"; | ||||
| import fetchMock from "fetch-mock-jest"; | ||||
| import { DELEGATED_OIDC_COMPATIBILITY, IdentityProviderBrand } from "matrix-js-sdk/src/@types/auth"; | ||||
| 
 | ||||
| import SdkConfig from "../../../../src/SdkConfig"; | ||||
| import { mkServerConfig, mockPlatformPeg, unmockPlatformPeg } from "../../../test-utils"; | ||||
|  | @ -192,4 +193,64 @@ describe("Login", function () { | |||
|         fireEvent.click(container.querySelector(".mx_SSOButton")); | ||||
|         expect(platform.startSingleSignOn.mock.calls[1][0].baseUrl).toBe("https://server2"); | ||||
|     }); | ||||
| 
 | ||||
|     it("should show single Continue button if OIDC MSC3824 compatibility is given by server", async () => { | ||||
|         mockClient.loginFlows.mockResolvedValue({ | ||||
|             flows: [ | ||||
|                 { | ||||
|                     type: "m.login.sso", | ||||
|                     [DELEGATED_OIDC_COMPATIBILITY.name]: true, | ||||
|                 }, | ||||
|                 { | ||||
|                     type: "m.login.password", | ||||
|                 }, | ||||
|             ], | ||||
|         }); | ||||
| 
 | ||||
|         const { container } = getComponent(); | ||||
|         await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading...")); | ||||
| 
 | ||||
|         const ssoButtons = container.querySelectorAll(".mx_SSOButton"); | ||||
| 
 | ||||
|         expect(ssoButtons.length).toBe(1); | ||||
|         expect(ssoButtons[0].textContent).toBe("Continue"); | ||||
| 
 | ||||
|         // no password form visible
 | ||||
|         expect(container.querySelector("form")).toBeFalsy(); | ||||
|     }); | ||||
| 
 | ||||
|     it("should show branded SSO buttons", async () => { | ||||
|         const idpsWithIcons = Object.values(IdentityProviderBrand).map((brand) => ({ | ||||
|             id: brand, | ||||
|             brand, | ||||
|             name: `Provider ${brand}`, | ||||
|         })); | ||||
| 
 | ||||
|         mockClient.loginFlows.mockResolvedValue({ | ||||
|             flows: [ | ||||
|                 { | ||||
|                     type: "m.login.sso", | ||||
|                     identity_providers: [ | ||||
|                         ...idpsWithIcons, | ||||
|                         { | ||||
|                             id: "foo", | ||||
|                             name: "Provider foo", | ||||
|                         }, | ||||
|                     ], | ||||
|                 }, | ||||
|             ], | ||||
|         }); | ||||
| 
 | ||||
|         const { container } = getComponent(); | ||||
|         await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading...")); | ||||
| 
 | ||||
|         for (const idp of idpsWithIcons) { | ||||
|             const ssoButton = container.querySelector(`.mx_SSOButton.mx_SSOButton_brand_${idp.brand}`); | ||||
|             expect(ssoButton).toBeTruthy(); | ||||
|             expect(ssoButton?.querySelector(`img[alt="${idp.brand}"]`)).toBeTruthy(); | ||||
|         } | ||||
| 
 | ||||
|         const ssoButtons = container.querySelectorAll(".mx_SSOButton"); | ||||
|         expect(ssoButtons.length).toBe(idpsWithIcons.length + 1); | ||||
|     }); | ||||
| }); | ||||
|  |  | |||
|  | @ -0,0 +1,77 @@ | |||
| /* | ||||
| Copyright 2023 The Matrix.org Foundation C.I.C. | ||||
| Licensed under the Apache License, Version 2.0 (the "License"); | ||||
| you may not use this file except in compliance with the License. | ||||
| You may obtain a copy of the License at | ||||
|     http://www.apache.org/licenses/LICENSE-2.0
 | ||||
| Unless required by applicable law or agreed to in writing, software | ||||
| distributed under the License is distributed on an "AS IS" BASIS, | ||||
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
| See the License for the specific language governing permissions and | ||||
| limitations under the License. | ||||
| */ | ||||
| import { render } from "@testing-library/react"; | ||||
| import React from "react"; | ||||
| import { M_AUTHENTICATION } from "matrix-js-sdk/src/matrix"; | ||||
| 
 | ||||
| import GeneralUserSettingsTab from "../../../../../../src/components/views/settings/tabs/user/GeneralUserSettingsTab"; | ||||
| import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext"; | ||||
| import SettingsStore from "../../../../../../src/settings/SettingsStore"; | ||||
| import { | ||||
|     getMockClientWithEventEmitter, | ||||
|     mockClientMethodsServer, | ||||
|     mockClientMethodsUser, | ||||
|     mockPlatformPeg, | ||||
|     flushPromises, | ||||
| } from "../../../../../test-utils"; | ||||
| 
 | ||||
| describe("<GeneralUserSettingsTab />", () => { | ||||
|     const defaultProps = { | ||||
|         closeSettingsFn: jest.fn(), | ||||
|     }; | ||||
| 
 | ||||
|     const userId = "@alice:server.org"; | ||||
|     const mockClient = getMockClientWithEventEmitter({ | ||||
|         ...mockClientMethodsUser(userId), | ||||
|         ...mockClientMethodsServer(), | ||||
|     }); | ||||
| 
 | ||||
|     const getComponent = () => ( | ||||
|         <MatrixClientContext.Provider value={mockClient}> | ||||
|             <GeneralUserSettingsTab {...defaultProps} /> | ||||
|         </MatrixClientContext.Provider> | ||||
|     ); | ||||
| 
 | ||||
|     jest.spyOn(SettingsStore, "getValue").mockReturnValue(false); | ||||
|     const clientWellKnownSpy = jest.spyOn(mockClient, "getClientWellKnown"); | ||||
| 
 | ||||
|     beforeEach(() => { | ||||
|         mockPlatformPeg(); | ||||
|         jest.clearAllMocks(); | ||||
|         clientWellKnownSpy.mockReturnValue({}); | ||||
|     }); | ||||
| 
 | ||||
|     it("does not show account management link when not available", () => { | ||||
|         const { queryByTestId } = render(getComponent()); | ||||
| 
 | ||||
|         expect(queryByTestId("external-account-management-outer")).toBeFalsy(); | ||||
|         expect(queryByTestId("external-account-management-link")).toBeFalsy(); | ||||
|     }); | ||||
| 
 | ||||
|     it("show account management link in expected format", async () => { | ||||
|         const accountManagementLink = "https://id.server.org/my-account"; | ||||
|         clientWellKnownSpy.mockReturnValue({ | ||||
|             [M_AUTHENTICATION.name]: { | ||||
|                 issuer: "https://id.server.org", | ||||
|                 account: accountManagementLink, | ||||
|             }, | ||||
|         }); | ||||
|         const { getByTestId } = render(getComponent()); | ||||
| 
 | ||||
|         // wait for well-known call to settle
 | ||||
|         await flushPromises(); | ||||
| 
 | ||||
|         expect(getByTestId("external-account-management-outer").textContent).toMatch(/.*id\.server\.org/); | ||||
|         expect(getByTestId("external-account-management-link").getAttribute("href")).toMatch(accountManagementLink); | ||||
|     }); | ||||
| }); | ||||
		Loading…
	
		Reference in New Issue
	
	 Hugh Nimmo-Smith
						Hugh Nimmo-Smith