From 7c0c756ca09e2ef6efad6b8948189b3326a5ee99 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 5 Jan 2023 14:28:01 +0000 Subject: [PATCH 01/18] Implementation of MSC3824 to make the client OIDC-aware --- src/BasePlatform.ts | 11 ++- src/Lifecycle.ts | 3 +- src/Login.ts | 12 ++- src/components/structures/auth/Login.tsx | 4 +- .../structures/auth/Registration.tsx | 3 +- src/components/structures/auth/SoftLogout.tsx | 3 +- src/components/views/elements/SSOButtons.tsx | 33 ++++++-- .../tabs/user/GeneralUserSettingsTab.tsx | 34 +++++++- .../tabs/user/GeneralUserSettingsTab-test.tsx | 77 +++++++++++++++++++ 9 files changed, 161 insertions(+), 19 deletions(-) create mode 100644 test/components/views/settings/tabs/user/GeneralUserSettingsTab-test.tsx diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index 46a406271c2..75f85818650 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -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 } /** diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index db6d15c1883..79c0a53c36a 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -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); } }, }); diff --git a/src/Login.ts b/src/Login.ts index ec769e8cb36..ef8121fe8d1 100644 --- a/src/Login.ts +++ b/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; private defaultDeviceDisplayName: string; private tempClient: MatrixClient; @@ -81,8 +80,13 @@ export default class Login { public async getFlows(): Promise> { 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; } diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index 49d826c74a4..be61956929d 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -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 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 loginType={loginType} fragmentAfterLogin={this.props.fragmentAfterLogin} primary={!this.state.flows.find((flow) => flow.type === "m.login.password")} + action={SSOAction.LOGIN} /> ); }; diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx index d65cfd7828f..c80464b6bc5 100644 --- a/src/components/structures/auth/Registration.tsx +++ b/src/components/structures/auth/Registration.tsx @@ -19,7 +19,7 @@ import React, { Fragment, ReactNode } from "react"; import { 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"; @@ -534,6 +534,7 @@ export default class Registration extends React.Component { flow={this.state.ssoFlow} loginType={this.state.ssoFlow.type === "m.login.sso" ? "sso" : "cas"} fragmentAfterLogin={this.props.fragmentAfterLogin} + action={SSOAction.REGISTER} />

{_t("%(ssoButtons)s Or %(usernamePassword)s", { diff --git a/src/components/structures/auth/SoftLogout.tsx b/src/components/structures/auth/SoftLogout.tsx index e4e18cabb3f..a7b4a4c4159 100644 --- a/src/components/structures/auth/SoftLogout.tsx +++ b/src/components/structures/auth/SoftLogout.tsx @@ -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 { loginType={loginType} fragmentAfterLogin={this.props.fragmentAfterLogin} primary={!this.state.flows.find((flow) => flow.type === "m.login.password")} + action={SSOAction.LOGIN} /> ); diff --git a/src/components/views/elements/SSOButtons.tsx b/src/components/views/elements/SSOButtons.tsx index 93d9764eb64..fce190d783f 100644 --- a/src/components/views/elements/SSOButtons.tsx +++ b/src/components/views/elements/SSOButtons.tsx @@ -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 { +interface ISSOButtonProps extends IProps { idp?: IIdentityProvider; mini?: boolean; + action?: SSOAction; } const getIcon = (brand: IdentityProviderBrand | string) => { @@ -79,14 +86,23 @@ const SSOButton: React.FC = ({ 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(flow)) { + label = _t("Continue"); + } else { + label = _t("Sign in with single sign-on"); + } const onClick = () => { 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; @@ -128,14 +144,15 @@ const SSOButton: React.FC = ({ 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 = ({ matrixClient, flow, loginType, fragmentAfterLogin, primary }) => { +const SSOButtons: React.FC = ({ matrixClient, flow, loginType, fragmentAfterLogin, primary, action }) => { const providers = flow.identity_providers || []; if (providers.length < 2) { return ( @@ -146,6 +163,8 @@ const SSOButtons: React.FC = ({ matrixClient, flow, loginType, fragmentA fragmentAfterLogin={fragmentAfterLogin} idp={providers[0]} primary={primary} + action={action} + flow={flow} /> ); @@ -167,6 +186,8 @@ const SSOButtons: React.FC = ({ matrixClient, flow, loginType, fragmentA idp={idp} mini={true} primary={primary} + action={action} + flow={flow} /> ))} diff --git a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx index 89d070210bd..b729fa8e96a 100644 --- a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx @@ -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 { @@ -106,6 +108,7 @@ export default class GeneralUserSettingsTab extends React.Component(cli.getClientWellKnown()); + const externalAccountManagementUrl = delegatedAuthConfig?.account; + + this.setState({ serverSupportsSeparateAddAndBind, canChangePassword, externalAccountManagementUrl }); } private async getThreepidState(): Promise { @@ -348,9 +354,35 @@ export default class GeneralUserSettingsTab extends React.Component + {_t( + "Manage your account at %(hostname)s.", + { hostname }, + { + a: (hostname) => ( + + {hostname} + + ), + }, + )} +

+ ); + } return (
{_t("Account")} + {externalAccountManagement}

{passwordChangeText}

{passwordChangeForm} {threepidSection} diff --git a/test/components/views/settings/tabs/user/GeneralUserSettingsTab-test.tsx b/test/components/views/settings/tabs/user/GeneralUserSettingsTab-test.tsx new file mode 100644 index 00000000000..863f1fbe557 --- /dev/null +++ b/test/components/views/settings/tabs/user/GeneralUserSettingsTab-test.tsx @@ -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("", () => { + const defaultProps = { + closeSettingsFn: jest.fn(), + }; + + const userId = "@alice:server.org"; + const mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(userId), + ...mockClientMethodsServer(), + }); + + const getComponent = () => ( + + + + ); + + 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); + }); +}); From 0fd0a4b5166e75b2ef64f5a5b4402011b03e29a2 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 5 Jan 2023 14:38:38 +0000 Subject: [PATCH 02/18] i18n --- src/i18n/strings/en_EN.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index e481dfca6fb..ebd07aea6be 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1528,6 +1528,7 @@ "Email addresses": "Email addresses", "Phone numbers": "Phone numbers", "Set a new account password...": "Set a new account password...", + "Manage your account at %(hostname)s.": "Manage your account at %(hostname)s.", "Account": "Account", "Language and region": "Language and region", "Spell check": "Spell check", From 1c03b8ec36f8f138a499b438fc37d0eb5747c09e Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 5 Jan 2023 14:48:36 +0000 Subject: [PATCH 03/18] Strict fixes --- src/components/views/elements/SSOButtons.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/elements/SSOButtons.tsx b/src/components/views/elements/SSOButtons.tsx index fce190d783f..418fcf7fe0c 100644 --- a/src/components/views/elements/SSOButtons.tsx +++ b/src/components/views/elements/SSOButtons.tsx @@ -107,7 +107,7 @@ const SSOButton: React.FC = ({ let icon; let brandClass; - const brandIcon = idp ? getIcon(idp.brand) : null; + const brandIcon = idp?.brand ? getIcon(idp.brand) : null; if (brandIcon) { const brandName = idp.brand.split(".").pop(); brandClass = `mx_SSOButton_brand_${brandName}`; From 8cb14cfc386db47965208880df5a8cc9a539bcef Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 5 Jan 2023 14:58:08 +0000 Subject: [PATCH 04/18] Test case for SSO buttons --- .../components/structures/auth/Login-test.tsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/components/structures/auth/Login-test.tsx b/test/components/structures/auth/Login-test.tsx index 48df7f410fc..b902285e36e 100644 --- a/test/components/structures/auth/Login-test.tsx +++ b/test/components/structures/auth/Login-test.tsx @@ -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 } from "matrix-js-sdk/src/@types/auth"; import SdkConfig from "../../../../src/SdkConfig"; import { mkServerConfig, mockPlatformPeg, unmockPlatformPeg } from "../../../test-utils"; @@ -192,4 +193,29 @@ 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(); + }); }); From 7d8fc4a2f32fe13b74c77d2366ae082e0d0b47c0 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 5 Jan 2023 16:14:50 +0000 Subject: [PATCH 05/18] Strictness --- src/components/views/elements/SSOButtons.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/views/elements/SSOButtons.tsx b/src/components/views/elements/SSOButtons.tsx index 418fcf7fe0c..06a872dcdb0 100644 --- a/src/components/views/elements/SSOButtons.tsx +++ b/src/components/views/elements/SSOButtons.tsx @@ -105,10 +105,10 @@ const SSOButton: React.FC = ({ PlatformPeg.get().startSingleSignOn(matrixClient, loginType, fragmentAfterLogin, idp?.id, action); }; - let icon; - let brandClass; + let icon: JSX.Element | undefined; + let brandClass: string | undefined; const brandIcon = idp?.brand ? getIcon(idp.brand) : null; - if (brandIcon) { + if (idp?.brand && brandIcon) { const brandName = idp.brand.split(".").pop(); brandClass = `mx_SSOButton_brand_${brandName}`; icon = {brandName}; From 1ba7774a70ff3126c76d9da03c755272c4227a3b Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 5 Jan 2023 14:28:01 +0000 Subject: [PATCH 06/18] Implementation of MSC3824 to make the client OIDC-aware --- src/BasePlatform.ts | 11 ++- src/Lifecycle.ts | 3 +- src/Login.ts | 12 ++- src/components/structures/auth/Login.tsx | 4 +- .../structures/auth/Registration.tsx | 3 +- src/components/structures/auth/SoftLogout.tsx | 3 +- src/components/views/elements/SSOButtons.tsx | 33 ++++++-- .../tabs/user/GeneralUserSettingsTab.tsx | 34 +++++++- .../tabs/user/GeneralUserSettingsTab-test.tsx | 77 +++++++++++++++++++ 9 files changed, 161 insertions(+), 19 deletions(-) create mode 100644 test/components/views/settings/tabs/user/GeneralUserSettingsTab-test.tsx diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index 46a406271c2..75f85818650 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -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 } /** diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index db6d15c1883..79c0a53c36a 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -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); } }, }); diff --git a/src/Login.ts b/src/Login.ts index ec769e8cb36..ef8121fe8d1 100644 --- a/src/Login.ts +++ b/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; private defaultDeviceDisplayName: string; private tempClient: MatrixClient; @@ -81,8 +80,13 @@ export default class Login { public async getFlows(): Promise> { 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; } diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index 49d826c74a4..be61956929d 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -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 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 loginType={loginType} fragmentAfterLogin={this.props.fragmentAfterLogin} primary={!this.state.flows.find((flow) => flow.type === "m.login.password")} + action={SSOAction.LOGIN} /> ); }; diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx index d65cfd7828f..c80464b6bc5 100644 --- a/src/components/structures/auth/Registration.tsx +++ b/src/components/structures/auth/Registration.tsx @@ -19,7 +19,7 @@ import React, { Fragment, ReactNode } from "react"; import { 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"; @@ -534,6 +534,7 @@ export default class Registration extends React.Component { flow={this.state.ssoFlow} loginType={this.state.ssoFlow.type === "m.login.sso" ? "sso" : "cas"} fragmentAfterLogin={this.props.fragmentAfterLogin} + action={SSOAction.REGISTER} />

{_t("%(ssoButtons)s Or %(usernamePassword)s", { diff --git a/src/components/structures/auth/SoftLogout.tsx b/src/components/structures/auth/SoftLogout.tsx index e4e18cabb3f..a7b4a4c4159 100644 --- a/src/components/structures/auth/SoftLogout.tsx +++ b/src/components/structures/auth/SoftLogout.tsx @@ -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 { loginType={loginType} fragmentAfterLogin={this.props.fragmentAfterLogin} primary={!this.state.flows.find((flow) => flow.type === "m.login.password")} + action={SSOAction.LOGIN} />

); diff --git a/src/components/views/elements/SSOButtons.tsx b/src/components/views/elements/SSOButtons.tsx index 93d9764eb64..fce190d783f 100644 --- a/src/components/views/elements/SSOButtons.tsx +++ b/src/components/views/elements/SSOButtons.tsx @@ -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 { +interface ISSOButtonProps extends IProps { idp?: IIdentityProvider; mini?: boolean; + action?: SSOAction; } const getIcon = (brand: IdentityProviderBrand | string) => { @@ -79,14 +86,23 @@ const SSOButton: React.FC = ({ 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(flow)) { + label = _t("Continue"); + } else { + label = _t("Sign in with single sign-on"); + } const onClick = () => { 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; @@ -128,14 +144,15 @@ const SSOButton: React.FC = ({ 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 = ({ matrixClient, flow, loginType, fragmentAfterLogin, primary }) => { +const SSOButtons: React.FC = ({ matrixClient, flow, loginType, fragmentAfterLogin, primary, action }) => { const providers = flow.identity_providers || []; if (providers.length < 2) { return ( @@ -146,6 +163,8 @@ const SSOButtons: React.FC = ({ matrixClient, flow, loginType, fragmentA fragmentAfterLogin={fragmentAfterLogin} idp={providers[0]} primary={primary} + action={action} + flow={flow} /> ); @@ -167,6 +186,8 @@ const SSOButtons: React.FC = ({ matrixClient, flow, loginType, fragmentA idp={idp} mini={true} primary={primary} + action={action} + flow={flow} /> ))} diff --git a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx index 89d070210bd..b729fa8e96a 100644 --- a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx @@ -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 { @@ -106,6 +108,7 @@ export default class GeneralUserSettingsTab extends React.Component(cli.getClientWellKnown()); + const externalAccountManagementUrl = delegatedAuthConfig?.account; + + this.setState({ serverSupportsSeparateAddAndBind, canChangePassword, externalAccountManagementUrl }); } private async getThreepidState(): Promise { @@ -348,9 +354,35 @@ export default class GeneralUserSettingsTab extends React.Component + {_t( + "Manage your account at %(hostname)s.", + { hostname }, + { + a: (hostname) => ( + + {hostname} + + ), + }, + )} +

+ ); + } return (
{_t("Account")} + {externalAccountManagement}

{passwordChangeText}

{passwordChangeForm} {threepidSection} diff --git a/test/components/views/settings/tabs/user/GeneralUserSettingsTab-test.tsx b/test/components/views/settings/tabs/user/GeneralUserSettingsTab-test.tsx new file mode 100644 index 00000000000..863f1fbe557 --- /dev/null +++ b/test/components/views/settings/tabs/user/GeneralUserSettingsTab-test.tsx @@ -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("", () => { + const defaultProps = { + closeSettingsFn: jest.fn(), + }; + + const userId = "@alice:server.org"; + const mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(userId), + ...mockClientMethodsServer(), + }); + + const getComponent = () => ( + + + + ); + + 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); + }); +}); From b10a6141cb7372ae0196ca574b048d156391f35b Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 5 Jan 2023 14:38:38 +0000 Subject: [PATCH 07/18] i18n --- src/i18n/strings/en_EN.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index e481dfca6fb..ebd07aea6be 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1528,6 +1528,7 @@ "Email addresses": "Email addresses", "Phone numbers": "Phone numbers", "Set a new account password...": "Set a new account password...", + "Manage your account at %(hostname)s.": "Manage your account at %(hostname)s.", "Account": "Account", "Language and region": "Language and region", "Spell check": "Spell check", From ab96a6ed82a2bdb40d7bcea976267a4b39b69190 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 5 Jan 2023 14:48:36 +0000 Subject: [PATCH 08/18] Strict fixes --- src/components/views/elements/SSOButtons.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/elements/SSOButtons.tsx b/src/components/views/elements/SSOButtons.tsx index fce190d783f..418fcf7fe0c 100644 --- a/src/components/views/elements/SSOButtons.tsx +++ b/src/components/views/elements/SSOButtons.tsx @@ -107,7 +107,7 @@ const SSOButton: React.FC = ({ let icon; let brandClass; - const brandIcon = idp ? getIcon(idp.brand) : null; + const brandIcon = idp?.brand ? getIcon(idp.brand) : null; if (brandIcon) { const brandName = idp.brand.split(".").pop(); brandClass = `mx_SSOButton_brand_${brandName}`; From 8aec82b2c15ee83131af1bf9b981b6f5799da9d7 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 5 Jan 2023 14:58:08 +0000 Subject: [PATCH 09/18] Test case for SSO buttons --- .../components/structures/auth/Login-test.tsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/components/structures/auth/Login-test.tsx b/test/components/structures/auth/Login-test.tsx index 48df7f410fc..b902285e36e 100644 --- a/test/components/structures/auth/Login-test.tsx +++ b/test/components/structures/auth/Login-test.tsx @@ -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 } from "matrix-js-sdk/src/@types/auth"; import SdkConfig from "../../../../src/SdkConfig"; import { mkServerConfig, mockPlatformPeg, unmockPlatformPeg } from "../../../test-utils"; @@ -192,4 +193,29 @@ 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(); + }); }); From 64af3e425856ab5c1e86359c7cbfeb99ad418b5d Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 5 Jan 2023 16:14:50 +0000 Subject: [PATCH 10/18] Strictness --- src/components/views/elements/SSOButtons.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/views/elements/SSOButtons.tsx b/src/components/views/elements/SSOButtons.tsx index 418fcf7fe0c..06a872dcdb0 100644 --- a/src/components/views/elements/SSOButtons.tsx +++ b/src/components/views/elements/SSOButtons.tsx @@ -105,10 +105,10 @@ const SSOButton: React.FC = ({ PlatformPeg.get().startSingleSignOn(matrixClient, loginType, fragmentAfterLogin, idp?.id, action); }; - let icon; - let brandClass; + let icon: JSX.Element | undefined; + let brandClass: string | undefined; const brandIcon = idp?.brand ? getIcon(idp.brand) : null; - if (brandIcon) { + if (idp?.brand && brandIcon) { const brandName = idp.brand.split(".").pop(); brandClass = `mx_SSOButton_brand_${brandName}`; icon = {brandName}; From 4605df9cd23222a58c5bd6cc405197d8cd58d449 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 5 Jan 2023 16:36:20 +0000 Subject: [PATCH 11/18] Strict fix --- src/components/views/elements/SSOButtons.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/views/elements/SSOButtons.tsx b/src/components/views/elements/SSOButtons.tsx index 06a872dcdb0..d3d2cf5f6a5 100644 --- a/src/components/views/elements/SSOButtons.tsx +++ b/src/components/views/elements/SSOButtons.tsx @@ -117,12 +117,15 @@ const SSOButton: React.FC = ({ icon = {idp.name}; } - const classes = classNames("mx_SSOButton", { - [brandClass]: brandClass, + const classesSecondPart: classNames.Argument = { mx_SSOButton_mini: mini, mx_SSOButton_default: !idp, mx_SSOButton_primary: primary, - }); + }; + if (brandClass) { + classesSecondPart[brandClass] = true; + } + const classes = classNames("mx_SSOButton", classesSecondPart); if (mini) { // TODO fallback icon From ab3fb3a121c763b7b651106469a71efe7a4bd6de Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 5 Jan 2023 17:21:10 +0000 Subject: [PATCH 12/18] Improve test coverage on SSO buttons --- src/components/views/elements/SSOButtons.tsx | 19 +++++----- .../components/structures/auth/Login-test.tsx | 38 ++++++++++++++++++- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/components/views/elements/SSOButtons.tsx b/src/components/views/elements/SSOButtons.tsx index d3d2cf5f6a5..21a8feb8c26 100644 --- a/src/components/views/elements/SSOButtons.tsx +++ b/src/components/views/elements/SSOButtons.tsx @@ -117,15 +117,16 @@ const SSOButton: React.FC = ({ icon = {idp.name}; } - const classesSecondPart: classNames.Argument = { - mx_SSOButton_mini: mini, - mx_SSOButton_default: !idp, - mx_SSOButton_primary: primary, - }; - if (brandClass) { - classesSecondPart[brandClass] = true; - } - const classes = classNames("mx_SSOButton", classesSecondPart); + 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 diff --git a/test/components/structures/auth/Login-test.tsx b/test/components/structures/auth/Login-test.tsx index b902285e36e..8fba99f33d2 100644 --- a/test/components/structures/auth/Login-test.tsx +++ b/test/components/structures/auth/Login-test.tsx @@ -19,7 +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 } from "matrix-js-sdk/src/@types/auth"; +import { DELEGATED_OIDC_COMPATIBILITY, IdentityProviderBrand } from "matrix-js-sdk/src/@types/auth"; import SdkConfig from "../../../../src/SdkConfig"; import { mkServerConfig, mockPlatformPeg, unmockPlatformPeg } from "../../../test-utils"; @@ -218,4 +218,40 @@ describe("Login", function () { // no password form visible expect(container.querySelector("form")).toBeFalsy(); }); + + it("should show branded SSO buttons", async () => { + const idpsWithBrands = Object.values(IdentityProviderBrand).map((brand) => ({ + id: brand, + brand, + name: `Provider ${brand}`, + })); + + mockClient.loginFlows.mockResolvedValue({ + flows: [ + { + type: "m.login.sso", + identity_providers: [ + ...idpsWithBrands, + { + id: "foo", + name: "Provider foo", + } + ], + }, + ], + }); + + const { container } = getComponent(); + await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading...")); + + for (const idp of idpsWithBrands) { + 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(idpsWithBrands.length + 1); + }); + }); From 3b0735afb819fc72335ae02abe80ca321073a508 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 5 Jan 2023 17:30:44 +0000 Subject: [PATCH 13/18] Cleanup tests --- test/components/structures/auth/Login-test.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/test/components/structures/auth/Login-test.tsx b/test/components/structures/auth/Login-test.tsx index 8fba99f33d2..25f3263749f 100644 --- a/test/components/structures/auth/Login-test.tsx +++ b/test/components/structures/auth/Login-test.tsx @@ -220,7 +220,7 @@ describe("Login", function () { }); it("should show branded SSO buttons", async () => { - const idpsWithBrands = Object.values(IdentityProviderBrand).map((brand) => ({ + const idpsWithIcons = Object.values(IdentityProviderBrand).map((brand) => ({ id: brand, brand, name: `Provider ${brand}`, @@ -231,11 +231,11 @@ describe("Login", function () { { type: "m.login.sso", identity_providers: [ - ...idpsWithBrands, + ...idpsWithIcons, { id: "foo", name: "Provider foo", - } + }, ], }, ], @@ -244,14 +244,13 @@ describe("Login", function () { const { container } = getComponent(); await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading...")); - for (const idp of idpsWithBrands) { + 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(); + expect(ssoButton?.querySelector(`img[alt="${idp.brand}"]`)).toBeTruthy(); } const ssoButtons = container.querySelectorAll(".mx_SSOButton"); - expect(ssoButtons.length).toBe(idpsWithBrands.length + 1); + expect(ssoButtons.length).toBe(idpsWithIcons.length + 1); }); - }); From 7b5a2ee46b437f652f515b57666ea3c8a4caf966 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 23 Jan 2023 16:54:54 +0000 Subject: [PATCH 14/18] Revised design --- .../tabs/user/GeneralUserSettingsTab.tsx | 32 ++++++++----------- src/i18n/strings/en_EN.json | 3 +- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx index b729fa8e96a..0aef857bfd5 100644 --- a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx @@ -302,6 +302,8 @@ export default class GeneralUserSettingsTab extends React.Component {sub}; + private renderAccountSection(): JSX.Element { let passwordChangeForm = ( - {_t( - "Manage your account at %(hostname)s.", - { hostname }, - { - a: (hostname) => ( - - {hostname} - - ), - }, - )} -

+ <> +

+ {_t( + "Your account details are managed separately at %(hostname)s.", + { hostname }, + { code: this.codeTag } + )} +

+ + {_t("Manage account")} + + ); } return ( diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index ebd07aea6be..d9c6a09e36c 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1528,7 +1528,8 @@ "Email addresses": "Email addresses", "Phone numbers": "Phone numbers", "Set a new account password...": "Set a new account password...", - "Manage your account at %(hostname)s.": "Manage your account at %(hostname)s.", + "Your account details are managed separately at %(hostname)s.": "Your account details are managed separately at %(hostname)s.", + "Manage account": "Manage account", "Account": "Account", "Language and region": "Language and region", "Spell check": "Spell check", From 28f79aa5948c953a10541db9115641a318083693 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 23 Jan 2023 17:11:26 +0000 Subject: [PATCH 15/18] Lint --- .../views/settings/tabs/user/GeneralUserSettingsTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx index 0aef857bfd5..44f0217d8dc 100644 --- a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx @@ -302,7 +302,7 @@ export default class GeneralUserSettingsTab extends React.Component {sub}; + private codeTag = (sub): JSX.Element => {sub}; private renderAccountSection(): JSX.Element { let passwordChangeForm = ( From e2a89f01343a87e5adb1f4087b86e8659528497d Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 23 Jan 2023 17:15:38 +0000 Subject: [PATCH 16/18] Lint --- .../settings/tabs/user/GeneralUserSettingsTab.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx index 44f0217d8dc..7dcf4050501 100644 --- a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx @@ -366,10 +366,18 @@ export default class GeneralUserSettingsTab extends React.Component%(hostname)s.", { hostname }, - { code: this.codeTag } + { code: this.codeTag }, )}

- + {_t("Manage account")} From 5f337e93f508bb03f09df7121777fd16bf47e982 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 23 Jan 2023 17:24:07 +0000 Subject: [PATCH 17/18] Lint --- .../views/settings/tabs/user/GeneralUserSettingsTab.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx index 7dcf4050501..b1744e80cca 100644 --- a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx @@ -302,8 +302,6 @@ export default class GeneralUserSettingsTab extends React.Component {sub}; - private renderAccountSection(): JSX.Element { let passwordChangeForm = ( %(hostname)s.", { hostname }, - { code: this.codeTag }, + { code: (sub) => {sub} }, )}

Date: Mon, 23 Jan 2023 17:26:56 +0000 Subject: [PATCH 18/18] Strict lint --- src/Lifecycle.ts | 2 +- src/components/views/elements/SSOButtons.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 0988ab32896..b28a7f80380 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -249,7 +249,7 @@ export function attemptTokenLogin( idBaseUrl: identityServer, }); const idpId = localStorage.getItem(SSO_IDP_ID_KEY) || undefined; - PlatformPeg.get().startSingleSignOn(cli, "sso", fragmentAfterLogin, idpId, SSOAction.LOGIN); + PlatformPeg.get()?.startSingleSignOn(cli, "sso", fragmentAfterLogin, idpId, SSOAction.LOGIN); } }, }); diff --git a/src/components/views/elements/SSOButtons.tsx b/src/components/views/elements/SSOButtons.tsx index a96a27e2898..0dffacb7ce9 100644 --- a/src/components/views/elements/SSOButtons.tsx +++ b/src/components/views/elements/SSOButtons.tsx @@ -102,7 +102,7 @@ const SSOButton: React.FC = ({ const onClick = (): void => { const authenticationType = getAuthenticationType(idp?.brand ?? ""); PosthogAnalytics.instance.setAuthenticationType(authenticationType); - PlatformPeg.get().startSingleSignOn(matrixClient, loginType, fragmentAfterLogin, idp?.id, action); + PlatformPeg.get()?.startSingleSignOn(matrixClient, loginType, fragmentAfterLogin, idp?.id, action); }; let icon: JSX.Element | undefined;