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