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 = ;
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 = ;
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 = ;
}
- 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 = ;
}
- 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;