Initial support for MSC2858

pull/21833/head
Michael Telatynski 2020-11-23 17:10:15 +00:00
parent 613710b75c
commit 1d53a5cf23
10 changed files with 237 additions and 154 deletions

View File

@ -124,6 +124,7 @@
@import "./views/elements/_RichText.scss";
@import "./views/elements/_RoleButton.scss";
@import "./views/elements/_RoomAliasField.scss";
@import "./views/elements/_SSOButtons.scss";
@import "./views/elements/_Slider.scss";
@import "./views/elements/_Spinner.scss";
@import "./views/elements/_StyledCheckbox.scss";

View File

@ -33,12 +33,6 @@ limitations under the License.
cursor: default;
}
.mx_AuthBody a.mx_Login_sso_link:link,
.mx_AuthBody a.mx_Login_sso_link:hover,
.mx_AuthBody a.mx_Login_sso_link:visited {
color: $button-primary-fg-color;
}
.mx_Login_loader {
display: inline;
position: relative;

View File

@ -0,0 +1,41 @@
/*
Copyright 2020 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.
*/
.mx_SSOButtons {
display: flex;
justify-content: center;
.mx_SSOButton {
position: relative;
> img {
object-fit: contain;
position: absolute;
left: 12px;
top: 12px;
}
}
.mx_SSOButton_mini {
box-sizing: border-box;
width: 50px; // 48px + 1px border on all sides
height: 50px; // 48px + 1px border on all sides
& + .mx_SSOButton_mini {
margin-left: 24px;
}
}
}

View File

@ -30,9 +30,24 @@ interface ILoginOptions {
// TODO: Move this to JS SDK
interface ILoginFlow {
type: string;
type: "m.login.password" | "m.login.cas";
}
export interface IIdentityProvider {
id: string;
name: string;
icon?: string;
}
export interface ISSOFlow {
type: "m.login.sso";
// eslint-disable-next-line camelcase
identity_providers: IIdentityProvider[];
"org.matrix.msc2858.identity_providers": IIdentityProvider[]; // Unstable prefix for MSC2858
}
export type LoginFlow = ISSOFlow | ILoginFlow;
// TODO: Move this to JS SDK
/* eslint-disable camelcase */
interface ILoginParams {
@ -48,9 +63,8 @@ export default class Login {
private hsUrl: string;
private isUrl: string;
private fallbackHsUrl: string;
private currentFlowIndex: number;
// TODO: Flows need a type in JS SDK
private flows: Array<ILoginFlow>;
private flows: Array<LoginFlow>;
private defaultDeviceDisplayName: string;
private tempClient: MatrixClient;
@ -63,7 +77,6 @@ export default class Login {
this.hsUrl = hsUrl;
this.isUrl = isUrl;
this.fallbackHsUrl = fallbackHsUrl;
this.currentFlowIndex = 0;
this.flows = [];
this.defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
this.tempClient = null; // memoize
@ -100,27 +113,13 @@ export default class Login {
});
}
public async getFlows(): Promise<Array<ILoginFlow>> {
public async getFlows(): Promise<Array<LoginFlow>> {
const client = this.createTemporaryClient();
const { flows } = await client.loginFlows();
this.flows = flows;
this.currentFlowIndex = 0;
// technically the UI should display options for all flows for the
// user to then choose one, so return all the flows here.
return this.flows;
}
public chooseFlow(flowIndex): void {
this.currentFlowIndex = flowIndex;
}
public getCurrentFlowStep(): string {
// technically the flow can have multiple steps, but no one does this
// for login so we can ignore it.
const flowStep = this.flows[this.currentFlowIndex];
return flowStep ? flowStep.type : null;
}
public loginViaPassword(
username: string,
phoneCountry: string,

View File

@ -14,17 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {ComponentProps, ReactNode} from 'react';
import React, {ReactNode} from 'react';
import {MatrixError} from "matrix-js-sdk/src/http-api";
import {_t, _td} from '../../../languageHandler';
import * as sdk from '../../../index';
import Login from '../../../Login';
import Login, {ISSOFlow, LoginFlow} from '../../../Login';
import SdkConfig from '../../../SdkConfig';
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import classNames from "classnames";
import AuthPage from "../../views/auth/AuthPage";
import SSOButton from "../../views/elements/SSOButton";
import PlatformPeg from '../../../PlatformPeg';
import SettingsStore from "../../../settings/SettingsStore";
import {UIFeature} from "../../../settings/UIFeature";
@ -35,6 +35,7 @@ import PasswordLogin from "../../views/auth/PasswordLogin";
import SignInToText from "../../views/auth/SignInToText";
import InlineSpinner from "../../views/elements/InlineSpinner";
import Spinner from "../../views/elements/Spinner";
import SSOButtons from "../../views/elements/SSOButtons";
// Enable phases for login
const PHASES_ENABLED = true;
@ -90,17 +91,14 @@ interface IState {
// can we attempt to log in or are there validation errors?
canTryLogin: boolean;
phase: Phase;
flows?: LoginFlow[];
// used for preserving form values when changing homeserver
username: string;
phoneCountry?: string;
phoneNumber: string;
// Phase of the overall login dialog.
phase: Phase;
// The current login flow, such as password, SSO, etc.
// we need to load the flows from the server
currentFlow?: string;
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
@ -113,9 +111,10 @@ interface IState {
/*
* A wire component which glues together login UI components and Login logic
*/
export default class LoginComponent extends React.Component<IProps, IState> {
export default class LoginComponent extends React.PureComponent<IProps, IState> {
private unmounted = false;
private loginLogic: Login;
private readonly stepRendererMap: Record<string, () => ReactNode>;
constructor(props) {
@ -127,11 +126,14 @@ export default class LoginComponent extends React.Component<IProps, IState> {
errorText: null,
loginIncorrect: false,
canTryLogin: true,
phase: Phase.Login,
flows: null,
username: "",
phoneCountry: null,
phoneNumber: "",
phase: Phase.Login,
currentFlow: null,
serverIsAlive: true,
serverErrorIsFatal: false,
serverDeadError: "",
@ -351,13 +353,14 @@ export default class LoginComponent extends React.Component<IProps, IState> {
};
onTryRegisterClick = ev => {
const step = this.getCurrentFlowStep();
if (step === 'm.login.sso' || step === 'm.login.cas') {
const hasPasswordFlow = this.state.flows.find(flow => flow.type === "m.login.password");
if (!hasPasswordFlow) {
// If we're showing SSO it means that registration is also probably disabled,
// so intercept the click and instead pretend the user clicked 'Sign in with SSO'.
ev.preventDefault();
ev.stopPropagation();
const ssoKind = step === 'm.login.sso' ? 'sso' : 'cas';
const step = this.state.flows.find(flow => flow.type === "m.login.sso" || flow.type === "m.login.cas");
const ssoKind = step.type === 'm.login.sso' ? 'sso' : 'cas';
PlatformPeg.get().startSingleSignOn(this.loginLogic.createTemporaryClient(), ssoKind,
this.props.fragmentAfterLogin);
} else {
@ -397,7 +400,6 @@ export default class LoginComponent extends React.Component<IProps, IState> {
this.setState({
busy: true,
currentFlow: null, // reset flow
loginIncorrect: false,
});
@ -432,27 +434,18 @@ export default class LoginComponent extends React.Component<IProps, IState> {
loginLogic.getFlows().then((flows) => {
// look for a flow where we understand all of the steps.
for (let i = 0; i < flows.length; i++ ) {
if (!this.isSupportedFlow(flows[i])) {
continue;
}
const supportedFlows = flows.filter(this.isSupportedFlow);
// we just pick the first flow where we support all the
// steps. (we don't have a UI for multiple logins so let's skip
// that for now).
loginLogic.chooseFlow(i);
if (supportedFlows.length > 0) {
this.setState({
currentFlow: this.getCurrentFlowStep(),
});
return;
}
// we got to the end of the list without finding a suitable
// flow.
// we got to the end of the list without finding a suitable flow.
this.setState({
errorText: _t(
"This homeserver doesn't offer any login flows which are " +
"supported by this client.",
),
errorText: _t("This homeserver doesn't offer any login flows which are supported by this client."),
});
}, (err) => {
this.setState({
@ -467,7 +460,7 @@ export default class LoginComponent extends React.Component<IProps, IState> {
});
}
private isSupportedFlow(flow) {
private isSupportedFlow = (flow: LoginFlow): boolean => {
// technically the flow can have multiple steps, but no one does this
// for login and loginLogic doesn't support it so we can ignore it.
if (!this.stepRendererMap[flow.type]) {
@ -475,13 +468,9 @@ export default class LoginComponent extends React.Component<IProps, IState> {
return false;
}
return true;
}
};
private getCurrentFlowStep() {
return this.loginLogic ? this.loginLogic.getCurrentFlowStep() : null;
}
private errorTextFromError(err) {
private errorTextFromError(err: MatrixError): ReactNode {
let errCode = err.errcode;
if (!errCode && err.httpStatus) {
errCode = "HTTP " + err.httpStatus;
@ -550,37 +539,38 @@ export default class LoginComponent extends React.Component<IProps, IState> {
/>;
}
private renderLoginComponentForStep() {
if (PHASES_ENABLED && this.state.phase !== Phase.Login) {
return null;
}
renderLoginComponentForFlows() {
if (!this.state.flows) return null;
const step = this.state.currentFlow;
// this is the ideal order we want to show the flows in
const order = [
"m.login.password",
"m.login.sso",
];
if (!step) {
return null;
}
const stepRenderer = this.stepRendererMap[step];
if (stepRenderer) {
return stepRenderer();
}
return null;
}
private renderPasswordStep = () => {
let onEditServerDetailsClick = null;
// If custom URLs are allowed, wire up the server details edit link.
if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) {
if (!SdkConfig.get()['disable_custom_urls']) {
onEditServerDetailsClick = this.onEditServerDetailsClick;
}
const flows = order.map(type => this.state.flows.find(flow => flow.type === type)).filter(Boolean);
return <React.Fragment>
<SignInToText
serverConfig={this.props.serverConfig}
onEditServerDetailsClick={onEditServerDetailsClick}
/>
{ flows.map(flow => {
const stepRenderer = this.stepRendererMap[flow.type];
return <React.Fragment key={flow.type}>{ stepRenderer() }</React.Fragment>
}) }
</React.Fragment>
}
private renderPasswordStep = () => {
return (
<PasswordLogin
onSubmit={this.onPasswordLogin}
onEditServerDetailsClick={onEditServerDetailsClick}
username={this.state.username}
phoneCountry={this.state.phoneCountry}
phoneNumber={this.state.phoneNumber}
@ -598,29 +588,16 @@ export default class LoginComponent extends React.Component<IProps, IState> {
};
private renderSsoStep = loginType => {
let onEditServerDetailsClick = null;
// If custom URLs are allowed, wire up the server details edit link.
if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) {
onEditServerDetailsClick = this.onEditServerDetailsClick;
}
// XXX: This link does *not* have a target="_blank" because single sign-on relies on
// redirecting the user back to a URI once they're logged in. On the web, this means
// we use the same window and redirect back to Element. On Electron, this actually
// opens the SSO page in the Electron app itself due to
// https://github.com/electron/electron/issues/8841 and so happens to work.
// If this bug gets fixed, it will break SSO since it will open the SSO page in the
// user's browser, let them log into their SSO provider, then redirect their browser
// to vector://vector which, of course, will not work.
const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType) as ISSOFlow;
return (
<div>
<SignInToText serverConfig={this.props.serverConfig}
onEditServerDetailsClick={onEditServerDetailsClick} />
<SSOButton
className="mx_Login_sso_link mx_Login_submit"
<SSOButtons
matrixClient={this.loginLogic.createTemporaryClient()}
flow={flow}
loginType={loginType}
fragmentAfterLogin={this.props.fragmentAfterLogin}
primary={!this.state.flows.find(flow => flow.type === "m.login.password")}
/>
</div>
);
@ -689,7 +666,7 @@ export default class LoginComponent extends React.Component<IProps, IState> {
{ errorTextSection }
{ serverDeadSection }
{ this.renderServerComponent() }
{ this.renderLoginComponentForStep() }
{ this.renderLoginComponentForFlows() }
{ footer }
</AuthBody>
</AuthPage>

View File

@ -24,8 +24,8 @@ import Modal from '../../../Modal';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {sendLoginRequest} from "../../../Login";
import AuthPage from "../../views/auth/AuthPage";
import SSOButton from "../../views/elements/SSOButton";
import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "../../../BasePlatform";
import SSOButtons from "../../views/elements/SSOButtons";
const LOGIN_VIEW = {
LOADING: 1,
@ -101,10 +101,11 @@ export default class SoftLogout extends React.Component {
// Note: we don't use the existing Login class because it is heavily flow-based. We don't
// care about login flows here, unless it is the single flow we support.
const client = MatrixClientPeg.get();
const loginViews = (await client.loginFlows()).flows.map(f => FLOWS_TO_VIEWS[f.type]);
const flows = (await client.loginFlows()).flows;
const loginViews = flows.map(f => FLOWS_TO_VIEWS[f.type]);
const chosenView = loginViews.filter(f => !!f)[0] || LOGIN_VIEW.UNSUPPORTED;
this.setState({loginView: chosenView});
this.setState({ flows, loginView: chosenView });
}
onPasswordChange = (ev) => {
@ -240,13 +241,18 @@ export default class SoftLogout extends React.Component {
introText = _t("Sign in and regain access to your account.");
} // else we already have a message and should use it (key backup warning)
const loginType = this.state.loginView === LOGIN_VIEW.CAS ? "cas" : "sso";
const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType);
return (
<div>
<p>{introText}</p>
<SSOButton
<SSOButtons
matrixClient={MatrixClientPeg.get()}
loginType={this.state.loginView === LOGIN_VIEW.CAS ? "cas" : "sso"}
flow={flow}
loginType={loginType}
fragmentAfterLogin={this.props.fragmentAfterLogin}
primary={!this.state.flows.find(flow => flow.type === "m.login.password")}
/>
</div>
);

View File

@ -26,7 +26,6 @@ import withValidation from "../elements/Validation";
import * as Email from "../../../email";
import Field from "../elements/Field";
import CountryDropdown from "./CountryDropdown";
import SignInToText from "./SignInToText";
// For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
@ -47,7 +46,6 @@ interface IProps {
onUsernameBlur?(username: string): void;
onPhoneCountryChanged?(phoneCountry: string): void;
onPhoneNumberChanged?(phoneNumber: string): void;
onEditServerDetailsClick?(): void;
onForgotPasswordClick?(): void;
}
@ -70,7 +68,6 @@ enum LoginField {
*/
export default class PasswordLogin extends React.PureComponent<IProps, IState> {
static defaultProps = {
onEditServerDetailsClick: null,
onUsernameChanged: function() {},
onUsernameBlur: function() {},
onPhoneCountryChanged: function() {},
@ -460,8 +457,6 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
return (
<div>
<SignInToText serverConfig={this.props.serverConfig}
onEditServerDetailsClick={this.props.onEditServerDetailsClick} />
<form onSubmit={this.onSubmitForm}>
{loginType}
{loginField}

View File

@ -1,42 +0,0 @@
/*
Copyright 2020 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 React from 'react';
import PropTypes from 'prop-types';
import PlatformPeg from "../../../PlatformPeg";
import AccessibleButton from "./AccessibleButton";
import {_t} from "../../../languageHandler";
const SSOButton = ({matrixClient, loginType, fragmentAfterLogin, ...props}) => {
const onClick = () => {
PlatformPeg.get().startSingleSignOn(matrixClient, loginType, fragmentAfterLogin);
};
return (
<AccessibleButton {...props} kind="primary" onClick={onClick}>
{_t("Sign in with single sign-on")}
</AccessibleButton>
);
};
SSOButton.propTypes = {
matrixClient: PropTypes.object.isRequired, // does not use context as may use a temporary client
loginType: PropTypes.oneOf(["sso", "cas"]), // defaults to "sso" in base-apis
fragmentAfterLogin: PropTypes.string,
};
export default SSOButton;

View File

@ -0,0 +1,111 @@
/*
Copyright 2020 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 React from "react";
import {MatrixClient} from "matrix-js-sdk/src/client";
import PlatformPeg from "../../../PlatformPeg";
import AccessibleButton from "./AccessibleButton";
import {_t} from "../../../languageHandler";
import {IIdentityProvider, ISSOFlow} from "../../../Login";
import classNames from "classnames";
interface ISSOButtonProps extends Omit<IProps, "flow"> {
idp: IIdentityProvider;
mini?: boolean;
}
const SSOButton: React.FC<ISSOButtonProps> = ({
matrixClient,
loginType,
fragmentAfterLogin,
idp,
primary,
mini,
...props
}) => {
const kind = primary ? "primary" : "primary_outline";
const label = idp ? _t("Continue with %(provider)s", { provider: idp.name }) : _t("Sign in with single sign-on");
const onClick = () => {
PlatformPeg.get().startSingleSignOn(matrixClient, loginType, fragmentAfterLogin, idp.id);
};
let icon;
if (idp && idp.icon && idp.icon.startsWith("https://")) {
// TODO sanitize images
icon = <img src={idp.icon} height="24" width="24" alt={label} />;
}
const classes = classNames("mx_SSOButton", {
mx_SSOButton_mini: mini,
});
if (mini) {
// TODO fallback icon
return (
<AccessibleButton {...props} className={classes} kind={kind} onClick={onClick}>
{ icon }
</AccessibleButton>
);
}
return (
<AccessibleButton {...props} className={classes} kind={kind} onClick={onClick}>
{ icon }
{ label }
</AccessibleButton>
);
};
interface IProps {
matrixClient: MatrixClient;
flow: ISSOFlow;
loginType?: "sso" | "cas";
fragmentAfterLogin?: string;
primary?: boolean;
}
const SSOButtons: React.FC<IProps> = ({matrixClient, flow, loginType, fragmentAfterLogin, primary}) => {
const providers = flow.identity_providers || flow["org.matrix.msc2858.identity_providers"] || [];
if (providers.length < 2) {
return <div className="mx_SSOButtons">
<SSOButton
matrixClient={matrixClient}
loginType={loginType}
fragmentAfterLogin={fragmentAfterLogin}
idp={providers[0]}
primary={primary}
/>
</div>;
}
return <div className="mx_SSOButtons">
{ providers.map(idp => (
<SSOButton
key={idp.id}
matrixClient={matrixClient}
loginType={loginType}
fragmentAfterLogin={fragmentAfterLogin}
idp={idp}
mini={true}
primary={primary}
/>
)) }
</div>;
};
export default SSOButtons;

View File

@ -1827,6 +1827,7 @@
"This address is available to use": "This address is available to use",
"This address is already in use": "This address is already in use",
"Room directory": "Room directory",
"Continue with %(provider)s": "Continue with %(provider)s",
"Sign in with single sign-on": "Sign in with single sign-on",
"And %(count)s more...|other": "And %(count)s more...",
"Home": "Home",