diff --git a/res/css/_components.scss b/res/css/_components.scss index 9dd65d2a4f..53a72c4ce8 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -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"; diff --git a/res/css/structures/auth/_Login.scss b/res/css/structures/auth/_Login.scss index 0774ac273d..a8cb7d7eee 100644 --- a/res/css/structures/auth/_Login.scss +++ b/res/css/structures/auth/_Login.scss @@ -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; diff --git a/res/css/views/elements/_SSOButtons.scss b/res/css/views/elements/_SSOButtons.scss new file mode 100644 index 0000000000..8dc5d30257 --- /dev/null +++ b/res/css/views/elements/_SSOButtons.scss @@ -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; + } + } +} diff --git a/src/Login.ts b/src/Login.ts index ae4aa226ed..d5776da856 100644 --- a/src/Login.ts +++ b/src/Login.ts @@ -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; + private flows: Array; 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> { + public async getFlows(): Promise> { 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, diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index 17220981c9..dd1fcc4d9a 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -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 { +export default class LoginComponent extends React.PureComponent { private unmounted = false; private loginLogic: Login; + private readonly stepRendererMap: Record ReactNode>; constructor(props) { @@ -127,11 +126,14 @@ export default class LoginComponent extends React.Component { 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 { }; 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 { this.setState({ busy: true, - currentFlow: null, // reset flow loginIncorrect: false, }); @@ -432,27 +434,18 @@ export default class LoginComponent extends React.Component { 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 { }); } - 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 { 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 { />; } - 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 + + { flows.map(flow => { + const stepRenderer = this.stepRendererMap[flow.type]; + return { stepRenderer() } + }) } + + } + + private renderPasswordStep = () => { return ( { }; 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 (
- - - flow.type === "m.login.password")} />
); @@ -689,7 +666,7 @@ export default class LoginComponent extends React.Component { { errorTextSection } { serverDeadSection } { this.renderServerComponent() } - { this.renderLoginComponentForStep() } + { this.renderLoginComponentForFlows() } { footer } diff --git a/src/components/structures/auth/SoftLogout.js b/src/components/structures/auth/SoftLogout.js index a539c8c9ee..fdc1aec96d 100644 --- a/src/components/structures/auth/SoftLogout.js +++ b/src/components/structures/auth/SoftLogout.js @@ -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 (

{introText}

- flow.type === "m.login.password")} />
); diff --git a/src/components/views/auth/PasswordLogin.tsx b/src/components/views/auth/PasswordLogin.tsx index 198c76849c..80384ba26e 100644 --- a/src/components/views/auth/PasswordLogin.tsx +++ b/src/components/views/auth/PasswordLogin.tsx @@ -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 { static defaultProps = { - onEditServerDetailsClick: null, onUsernameChanged: function() {}, onUsernameBlur: function() {}, onPhoneCountryChanged: function() {}, @@ -460,8 +457,6 @@ export default class PasswordLogin extends React.PureComponent { return (
-
{loginType} {loginField} diff --git a/src/components/views/elements/SSOButton.js b/src/components/views/elements/SSOButton.js deleted file mode 100644 index 1126ae3cd7..0000000000 --- a/src/components/views/elements/SSOButton.js +++ /dev/null @@ -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 ( - - {_t("Sign in with single sign-on")} - - ); -}; - -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; diff --git a/src/components/views/elements/SSOButtons.tsx b/src/components/views/elements/SSOButtons.tsx new file mode 100644 index 0000000000..8247d17db8 --- /dev/null +++ b/src/components/views/elements/SSOButtons.tsx @@ -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 { + idp: IIdentityProvider; + mini?: boolean; +} + +const SSOButton: React.FC = ({ + 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 = {label}; + } + + const classes = classNames("mx_SSOButton", { + mx_SSOButton_mini: mini, + }); + + if (mini) { + // TODO fallback icon + return ( + + { icon } + + ); + } + + return ( + + { icon } + { label } + + ); +}; + +interface IProps { + matrixClient: MatrixClient; + flow: ISSOFlow; + loginType?: "sso" | "cas"; + fragmentAfterLogin?: string; + primary?: boolean; +} + +const SSOButtons: React.FC = ({matrixClient, flow, loginType, fragmentAfterLogin, primary}) => { + const providers = flow.identity_providers || flow["org.matrix.msc2858.identity_providers"] || []; + if (providers.length < 2) { + return
+ +
; + } + + return
+ { providers.map(idp => ( + + )) } +
; +}; + +export default SSOButtons; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index fff7bdac44..cfa9dd2363 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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",