Implementation of MSC3824 to make the client OIDC-aware (#8681)

pull/28788/head^2
Hugh Nimmo-Smith 2023-01-27 11:06:10 +00:00 committed by GitHub
parent 32bd350b7e
commit d698193196
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 240 additions and 29 deletions

View File

@ -22,6 +22,7 @@ import { encodeUnpaddedBase64 } from "matrix-js-sdk/src/crypto/olmlib";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { SSOAction } from "matrix-js-sdk/src/@types/auth";
import dis from "./dispatcher/dispatcher"; import dis from "./dispatcher/dispatcher";
import BaseEventIndexManager from "./indexing/BaseEventIndexManager"; import BaseEventIndexManager from "./indexing/BaseEventIndexManager";
@ -308,9 +309,9 @@ export default abstract class BasePlatform {
return null; return null;
} }
protected getSSOCallbackUrl(fragmentAfterLogin: string): URL { protected getSSOCallbackUrl(fragmentAfterLogin = ""): URL {
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.hash = fragmentAfterLogin || ""; url.hash = fragmentAfterLogin;
return url; 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 {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 {"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 {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. * @param {string} idpId The ID of the Identity Provider being targeted, optional.
*/ */
public startSingleSignOn( public startSingleSignOn(
mxClient: MatrixClient, mxClient: MatrixClient,
loginType: "sso" | "cas", loginType: "sso" | "cas",
fragmentAfterLogin: string, fragmentAfterLogin?: string,
idpId?: string, idpId?: string,
action?: SSOAction,
): void { ): void {
// persist hs url and is url for when the user is returned to the app with the login token // 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()); localStorage.setItem(SSO_HOMESERVER_URL_KEY, mxClient.getHomeserverUrl());
@ -336,7 +339,7 @@ export default abstract class BasePlatform {
localStorage.setItem(SSO_IDP_ID_KEY, idpId); localStorage.setItem(SSO_IDP_ID_KEY, idpId);
} }
const callbackUrl = this.getSSOCallbackUrl(fragmentAfterLogin); 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
} }
/** /**

View File

@ -23,6 +23,7 @@ import { MatrixClient } from "matrix-js-sdk/src/client";
import { decryptAES, encryptAES, IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes"; import { decryptAES, encryptAES, IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes";
import { QueryDict } from "matrix-js-sdk/src/utils"; import { QueryDict } from "matrix-js-sdk/src/utils";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { SSOAction } from "matrix-js-sdk/src/@types/auth";
import { IMatrixClientCreds, MatrixClientPeg } from "./MatrixClientPeg"; import { IMatrixClientCreds, MatrixClientPeg } from "./MatrixClientPeg";
import SecurityCustomisations from "./customisations/Security"; import SecurityCustomisations from "./customisations/Security";
@ -248,7 +249,7 @@ export function attemptTokenLogin(
idBaseUrl: identityServer, idBaseUrl: identityServer,
}); });
const idpId = localStorage.getItem(SSO_IDP_ID_KEY) || undefined; 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);
} }
}, },
}); });

View File

@ -19,7 +19,7 @@ limitations under the License.
import { createClient } from "matrix-js-sdk/src/matrix"; import { createClient } from "matrix-js-sdk/src/matrix";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { logger } from "matrix-js-sdk/src/logger"; 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 { IMatrixClientCreds } from "./MatrixClientPeg";
import SecurityCustomisations from "./customisations/Security"; import SecurityCustomisations from "./customisations/Security";
@ -32,7 +32,6 @@ export default class Login {
private hsUrl: string; private hsUrl: string;
private isUrl: string; private isUrl: string;
private fallbackHsUrl: string; private fallbackHsUrl: string;
// TODO: Flows need a type in JS SDK
private flows: Array<LoginFlow>; private flows: Array<LoginFlow>;
private defaultDeviceDisplayName: string; private defaultDeviceDisplayName: string;
private tempClient: MatrixClient; private tempClient: MatrixClient;
@ -81,8 +80,13 @@ export default class Login {
public async getFlows(): Promise<Array<LoginFlow>> { public async getFlows(): Promise<Array<LoginFlow>> {
const client = this.createTemporaryClient(); const client = this.createTemporaryClient();
const { flows } = await client.loginFlows(); const { flows }: { flows: LoginFlow[] } = await client.loginFlows();
this.flows = flows; // 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; return this.flows;
} }

View File

@ -18,7 +18,7 @@ import React, { ReactNode } from "react";
import { ConnectionError, MatrixError } from "matrix-js-sdk/src/http-api"; import { ConnectionError, MatrixError } from "matrix-js-sdk/src/http-api";
import classNames from "classnames"; import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger"; 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 { _t, _td } from "../../../languageHandler";
import Login from "../../../Login"; import Login from "../../../Login";
@ -345,6 +345,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
this.loginLogic.createTemporaryClient(), this.loginLogic.createTemporaryClient(),
ssoKind, ssoKind,
this.props.fragmentAfterLogin, this.props.fragmentAfterLogin,
SSOAction.REGISTER,
); );
} else { } else {
// Don't intercept - just go through to the register page // 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} loginType={loginType}
fragmentAfterLogin={this.props.fragmentAfterLogin} fragmentAfterLogin={this.props.fragmentAfterLogin}
primary={!this.state.flows.find((flow) => flow.type === "m.login.password")} primary={!this.state.flows.find((flow) => flow.type === "m.login.password")}
action={SSOAction.LOGIN}
/> />
); );
}; };

View File

@ -19,7 +19,7 @@ import React, { Fragment, ReactNode } from "react";
import { IRequestTokenResponse, MatrixClient } from "matrix-js-sdk/src/client"; import { IRequestTokenResponse, MatrixClient } from "matrix-js-sdk/src/client";
import classNames from "classnames"; import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger"; 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 { _t, _td } from "../../../languageHandler";
import { messageForResourceLimitError } from "../../../utils/ErrorUtils"; import { messageForResourceLimitError } from "../../../utils/ErrorUtils";
@ -539,6 +539,7 @@ export default class Registration extends React.Component<IProps, IState> {
flow={this.state.ssoFlow} flow={this.state.ssoFlow}
loginType={this.state.ssoFlow.type === "m.login.sso" ? "sso" : "cas"} loginType={this.state.ssoFlow.type === "m.login.sso" ? "sso" : "cas"}
fragmentAfterLogin={this.props.fragmentAfterLogin} fragmentAfterLogin={this.props.fragmentAfterLogin}
action={SSOAction.REGISTER}
/> />
<h2 className="mx_AuthBody_centered"> <h2 className="mx_AuthBody_centered">
{_t("%(ssoButtons)s Or %(usernamePassword)s", { {_t("%(ssoButtons)s Or %(usernamePassword)s", {

View File

@ -17,7 +17,7 @@ limitations under the License.
import React from "react"; import React from "react";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { Optional } from "matrix-events-sdk"; 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 { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
@ -256,6 +256,7 @@ export default class SoftLogout extends React.Component<IProps, IState> {
loginType={loginType} loginType={loginType}
fragmentAfterLogin={this.props.fragmentAfterLogin} fragmentAfterLogin={this.props.fragmentAfterLogin}
primary={!this.state.flows.find((flow) => flow.type === "m.login.password")} primary={!this.state.flows.find((flow) => flow.type === "m.login.password")}
action={SSOAction.LOGIN}
/> />
</div> </div>
); );

View File

@ -19,7 +19,13 @@ import { chunk } from "lodash";
import classNames from "classnames"; import classNames from "classnames";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { Signup } from "@matrix-org/analytics-events/types/typescript/Signup"; 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 PlatformPeg from "../../../PlatformPeg";
import AccessibleButton from "./AccessibleButton"; import AccessibleButton from "./AccessibleButton";
@ -28,9 +34,10 @@ import AccessibleTooltipButton from "./AccessibleTooltipButton";
import { mediaFromMxc } from "../../../customisations/Media"; import { mediaFromMxc } from "../../../customisations/Media";
import { PosthogAnalytics } from "../../../PosthogAnalytics"; import { PosthogAnalytics } from "../../../PosthogAnalytics";
interface ISSOButtonProps extends Omit<IProps, "flow"> { interface ISSOButtonProps extends IProps {
idp?: IIdentityProvider; idp?: IIdentityProvider;
mini?: boolean; mini?: boolean;
action?: SSOAction;
} }
const getIcon = (brand: IdentityProviderBrand | string): string | null => { const getIcon = (brand: IdentityProviderBrand | string): string | null => {
@ -79,20 +86,29 @@ const SSOButton: React.FC<ISSOButtonProps> = ({
idp, idp,
primary, primary,
mini, mini,
action,
flow,
...props ...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 onClick = (): void => {
const authenticationType = getAuthenticationType(idp?.brand ?? ""); const authenticationType = getAuthenticationType(idp?.brand ?? "");
PosthogAnalytics.instance.setAuthenticationType(authenticationType); PosthogAnalytics.instance.setAuthenticationType(authenticationType);
PlatformPeg.get().startSingleSignOn(matrixClient, loginType, fragmentAfterLogin, idp?.id); PlatformPeg.get()?.startSingleSignOn(matrixClient, loginType, fragmentAfterLogin, idp?.id, action);
}; };
let icon; let icon: JSX.Element | undefined;
let brandClass; let brandClass: string | undefined;
const brandIcon = idp ? getIcon(idp.brand) : null; const brandIcon = idp?.brand ? getIcon(idp.brand) : null;
if (brandIcon) { if (idp?.brand && brandIcon) {
const brandName = idp.brand.split(".").pop(); const brandName = idp.brand.split(".").pop();
brandClass = `mx_SSOButton_brand_${brandName}`; brandClass = `mx_SSOButton_brand_${brandName}`;
icon = <img src={brandIcon} height="24" width="24" alt={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} />; icon = <img src={src} height="24" width="24" alt={idp.name} />;
} }
const classes = classNames("mx_SSOButton", { const brandPart = brandClass ? { [brandClass]: brandClass } : undefined;
[brandClass]: brandClass, const classes = classNames(
"mx_SSOButton",
{
mx_SSOButton_mini: mini, mx_SSOButton_mini: mini,
mx_SSOButton_default: !idp, mx_SSOButton_default: !idp,
mx_SSOButton_primary: primary, mx_SSOButton_primary: primary,
}); },
brandPart,
);
if (mini) { if (mini) {
// TODO fallback icon // TODO fallback icon
@ -128,14 +148,15 @@ const SSOButton: React.FC<ISSOButtonProps> = ({
interface IProps { interface IProps {
matrixClient: MatrixClient; matrixClient: MatrixClient;
flow: ISSOFlow; flow: ISSOFlow;
loginType?: "sso" | "cas"; loginType: "sso" | "cas";
fragmentAfterLogin?: string; fragmentAfterLogin?: string;
primary?: boolean; primary?: boolean;
action?: SSOAction;
} }
const MAX_PER_ROW = 6; 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 || []; const providers = flow.identity_providers || [];
if (providers.length < 2) { if (providers.length < 2) {
return ( return (
@ -146,6 +167,8 @@ const SSOButtons: React.FC<IProps> = ({ matrixClient, flow, loginType, fragmentA
fragmentAfterLogin={fragmentAfterLogin} fragmentAfterLogin={fragmentAfterLogin}
idp={providers[0]} idp={providers[0]}
primary={primary} primary={primary}
action={action}
flow={flow}
/> />
</div> </div>
); );
@ -167,6 +190,8 @@ const SSOButtons: React.FC<IProps> = ({ matrixClient, flow, loginType, fragmentA
idp={idp} idp={idp}
mini={true} mini={true}
primary={primary} primary={primary}
action={action}
flow={flow}
/> />
))} ))}
</div> </div>

View File

@ -20,6 +20,7 @@ import React from "react";
import { SERVICE_TYPES } from "matrix-js-sdk/src/service-types"; import { SERVICE_TYPES } from "matrix-js-sdk/src/service-types";
import { IThreepid } from "matrix-js-sdk/src/@types/threepids"; import { IThreepid } from "matrix-js-sdk/src/@types/threepids";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { IDelegatedAuthConfig, M_AUTHENTICATION } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../../../languageHandler"; import { _t } from "../../../../../languageHandler";
import ProfileSettings from "../../ProfileSettings"; import ProfileSettings from "../../ProfileSettings";
@ -79,6 +80,7 @@ interface IState {
loading3pids: boolean; // whether or not the emails and msisdns have been loaded loading3pids: boolean; // whether or not the emails and msisdns have been loaded
canChangePassword: boolean; canChangePassword: boolean;
idServerName: string; idServerName: string;
externalAccountManagementUrl?: string;
} }
export default class GeneralUserSettingsTab extends React.Component<IProps, IState> { 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 loading3pids: true, // whether or not the emails and msisdns have been loaded
canChangePassword: false, canChangePassword: false,
idServerName: null, idServerName: null,
externalAccountManagementUrl: undefined,
}; };
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
@ -161,7 +164,10 @@ export default class GeneralUserSettingsTab extends React.Component<IProps, ISta
// the enabled flag value. // the enabled flag value.
const canChangePassword = !changePasswordCap || changePasswordCap["enabled"] !== false; 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> { private async getThreepidState(): Promise<void> {
@ -348,9 +354,37 @@ export default class GeneralUserSettingsTab extends React.Component<IProps, ISta
passwordChangeForm = null; 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 ( return (
<div className="mx_SettingsTab_section mx_GeneralUserSettingsTab_accountSection"> <div className="mx_SettingsTab_section mx_GeneralUserSettingsTab_accountSection">
<span className="mx_SettingsTab_subheading">{_t("Account")}</span> <span className="mx_SettingsTab_subheading">{_t("Account")}</span>
{externalAccountManagement}
<p className="mx_SettingsTab_subsectionText">{passwordChangeText}</p> <p className="mx_SettingsTab_subsectionText">{passwordChangeText}</p>
{passwordChangeForm} {passwordChangeForm}
{threepidSection} {threepidSection}

View File

@ -1532,6 +1532,8 @@
"Email addresses": "Email addresses", "Email addresses": "Email addresses",
"Phone numbers": "Phone numbers", "Phone numbers": "Phone numbers",
"Set a new account password...": "Set a new account password...", "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", "Account": "Account",
"Language and region": "Language and region", "Language and region": "Language and region",
"Spell check": "Spell check", "Spell check": "Spell check",

View File

@ -19,6 +19,7 @@ import { fireEvent, render, screen, waitForElementToBeRemoved } from "@testing-l
import { mocked, MockedObject } from "jest-mock"; import { mocked, MockedObject } from "jest-mock";
import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix"; import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
import fetchMock from "fetch-mock-jest"; import fetchMock from "fetch-mock-jest";
import { DELEGATED_OIDC_COMPATIBILITY, IdentityProviderBrand } from "matrix-js-sdk/src/@types/auth";
import SdkConfig from "../../../../src/SdkConfig"; import SdkConfig from "../../../../src/SdkConfig";
import { mkServerConfig, mockPlatformPeg, unmockPlatformPeg } from "../../../test-utils"; import { mkServerConfig, mockPlatformPeg, unmockPlatformPeg } from "../../../test-utils";
@ -192,4 +193,64 @@ describe("Login", function () {
fireEvent.click(container.querySelector(".mx_SSOButton")); fireEvent.click(container.querySelector(".mx_SSOButton"));
expect(platform.startSingleSignOn.mock.calls[1][0].baseUrl).toBe("https://server2"); 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);
});
}); });

View File

@ -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);
});
});