diff --git a/res/css/views/auth/_AuthBody.scss b/res/css/views/auth/_AuthBody.scss index 8f0c758e7a..90dca32e48 100644 --- a/res/css/views/auth/_AuthBody.scss +++ b/res/css/views/auth/_AuthBody.scss @@ -34,7 +34,7 @@ limitations under the License. h3 { font-size: $font-14px; font-weight: 600; - color: $authpage-primary-color; + color: $authpage-secondary-color; } h3.mx_AuthBody_centered { diff --git a/res/css/views/elements/_SSOButtons.scss b/res/css/views/elements/_SSOButtons.scss index c61247655c..add048efb0 100644 --- a/res/css/views/elements/_SSOButtons.scss +++ b/res/css/views/elements/_SSOButtons.scss @@ -28,8 +28,14 @@ limitations under the License. .mx_SSOButton { position: relative; width: 100%; - padding-left: 32px; - padding-right: 32px; + padding: 7px 32px; + text-align: center; + border-radius: 8px; + display: inline-block; + font-size: $font-14px; + font-weight: $font-semi-bold; + border: 1px solid $input-border-color; + color: $primary-fg-color; > img { object-fit: contain; @@ -39,6 +45,16 @@ limitations under the License. } } + .mx_SSOButton_default { + color: $button-primary-bg-color; + background-color: $button-secondary-bg-color; + border-color: $button-primary-bg-color; + } + .mx_SSOButton_default.mx_SSOButton_primary { + color: $button-primary-fg-color; + background-color: $button-primary-bg-color; + } + .mx_SSOButton_mini { box-sizing: border-box; width: 50px; // 48px + 1px border on all sides @@ -56,3 +72,14 @@ limitations under the License. } } } + +.mx_SSOButton.mx_SSOButton_brand_facebook { + background-color: #3c5a99; + border-color: #3c5a99; + color: #ffffff; +} +.mx_SSOButton.mx_SSOButton_brand_twitter { + background-color: #47acdf; + border-color: #47acdf; + color: #ffffff; +} diff --git a/res/img/element-icons/brands/apple.svg b/res/img/element-icons/brands/apple.svg new file mode 100644 index 0000000000..308c3c5d5a --- /dev/null +++ b/res/img/element-icons/brands/apple.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/brands/facebook.svg b/res/img/element-icons/brands/facebook.svg new file mode 100644 index 0000000000..087ddacdff --- /dev/null +++ b/res/img/element-icons/brands/facebook.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/element-icons/brands/github.svg b/res/img/element-icons/brands/github.svg new file mode 100644 index 0000000000..503719520b --- /dev/null +++ b/res/img/element-icons/brands/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/brands/gitlab.svg b/res/img/element-icons/brands/gitlab.svg new file mode 100644 index 0000000000..df84c41e21 --- /dev/null +++ b/res/img/element-icons/brands/gitlab.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/res/img/element-icons/brands/google.svg b/res/img/element-icons/brands/google.svg new file mode 100644 index 0000000000..1b0b19ae5b --- /dev/null +++ b/res/img/element-icons/brands/google.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/img/element-icons/brands/twitter.svg b/res/img/element-icons/brands/twitter.svg new file mode 100644 index 0000000000..4fc3d2f2a2 --- /dev/null +++ b/res/img/element-icons/brands/twitter.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index f87af1a791..4602f2b5bb 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -366,7 +366,7 @@ async function abortLogin() { // The plan is to gradually move the localStorage access done here into // SessionStore to avoid bugs where the view becomes out-of-sync with // localStorage (e.g. isGuest etc.) -async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): Promise { +export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): Promise { const ignoreGuest = opts?.ignoreGuest; if (!localStorage) { diff --git a/src/Login.ts b/src/Login.ts index 6493b244e0..aecc0493c7 100644 --- a/src/Login.ts +++ b/src/Login.ts @@ -33,10 +33,20 @@ interface IPasswordFlow { type: "m.login.password"; } +export enum IdentityProviderBrand { + Gitlab = "org.matrix.gitlab", + Github = "org.matrix.github", + Apple = "org.matrix.apple", + Google = "org.matrix.google", + Facebook = "org.matrix.facebook", + Twitter = "org.matrix.twitter", +} + export interface IIdentityProvider { id: string; name: string; icon?: string; + brand?: IdentityProviderBrand | string; } export interface ISSOFlow { diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index c8fcd7e9ca..ac7049ed88 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -177,7 +177,14 @@ export default class InteractiveAuthComponent extends React.Component { stageState: stageState, errorText: stageState.error, }, () => { - if (oldStage != stageType) this._setFocus(); + if (oldStage !== stageType) { + this._setFocus(); + } else if ( + !stageState.error && this._stageComponent.current && + this._stageComponent.current.attemptFailed + ) { + this._stageComponent.current.attemptFailed(); + } }); }; diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 2b1b93ebec..2c652f7c8c 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -219,6 +219,7 @@ export default class MatrixChat extends React.PureComponent { private screenAfterLogin?: IScreen; private windowWidth: number; private pageChanging: boolean; + private tokenLogin?: boolean; private accountPassword?: string; private accountPasswordTimer?: NodeJS.Timeout; private focusComposer: boolean; @@ -324,13 +325,20 @@ export default class MatrixChat extends React.PureComponent { Lifecycle.attemptTokenLogin( this.props.realQueryParams, this.props.defaultDeviceDisplayName, - ).then((loggedIn) => { - if (loggedIn) { + ).then(async (loggedIn) => { + if (this.props.realQueryParams?.loginToken) { + // remove the loginToken from the URL regardless this.props.onTokenLoginCompleted(); + } - // don't do anything else until the page reloads - just stay in - // the 'loading' state. - return; + if (loggedIn) { + this.tokenLogin = true; + + // Create and start the client + await Lifecycle.restoreFromLocalStorage({ + ignoreGuest: true, + }); + return this.postLoginSetup(); } // if the user has followed a login or register link, don't reanimate @@ -354,6 +362,42 @@ export default class MatrixChat extends React.PureComponent { CountlyAnalytics.instance.enable(/* anonymous = */ true); } + private async postLoginSetup() { + const cli = MatrixClientPeg.get(); + const cryptoEnabled = cli.isCryptoEnabled(); + if (!cryptoEnabled) { + this.onLoggedIn(); + } + + const promisesList = [this.firstSyncPromise.promise]; + if (cryptoEnabled) { + // wait for the client to finish downloading cross-signing keys for us so we + // know whether or not we have keys set up on this account + promisesList.push(cli.downloadKeys([cli.getUserId()])); + } + + // Now update the state to say we're waiting for the first sync to complete rather + // than for the login to finish. + this.setState({ pendingInitialSync: true }); + + await Promise.all(promisesList); + + if (!cryptoEnabled) { + this.setState({ pendingInitialSync: false }); + return; + } + + const crossSigningIsSetUp = cli.getStoredCrossSigningForUser(cli.getUserId()); + if (crossSigningIsSetUp) { + this.setStateForNewView({ view: Views.COMPLETE_SECURITY }); + } else if (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) { + this.setStateForNewView({ view: Views.E2E_SETUP }); + } else { + this.onLoggedIn(); + } + this.setState({ pendingInitialSync: false }); + } + // TODO: [REACT-WARNING] Replace with appropriate lifecycle stage // eslint-disable-next-line camelcase UNSAFE_componentWillUpdate(props, state) { @@ -1839,40 +1883,7 @@ export default class MatrixChat extends React.PureComponent { // Create and start the client await Lifecycle.setLoggedIn(credentials); - - const cli = MatrixClientPeg.get(); - const cryptoEnabled = cli.isCryptoEnabled(); - if (!cryptoEnabled) { - this.onLoggedIn(); - } - - const promisesList = [this.firstSyncPromise.promise]; - if (cryptoEnabled) { - // wait for the client to finish downloading cross-signing keys for us so we - // know whether or not we have keys set up on this account - promisesList.push(cli.downloadKeys([cli.getUserId()])); - } - - // Now update the state to say we're waiting for the first sync to complete rather - // than for the login to finish. - this.setState({ pendingInitialSync: true }); - - await Promise.all(promisesList); - - if (!cryptoEnabled) { - this.setState({ pendingInitialSync: false }); - return; - } - - const crossSigningIsSetUp = cli.getStoredCrossSigningForUser(cli.getUserId()); - if (crossSigningIsSetUp) { - this.setStateForNewView({ view: Views.COMPLETE_SECURITY }); - } else if (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) { - this.setStateForNewView({ view: Views.E2E_SETUP }); - } else { - this.onLoggedIn(); - } - this.setState({ pendingInitialSync: false }); + await this.postLoginSetup(); }; // complete security / e2e setup has finished @@ -1916,6 +1927,7 @@ export default class MatrixChat extends React.PureComponent { ); } else if (this.state.view === Views.LOGGED_IN) { diff --git a/src/components/structures/auth/E2eSetup.js b/src/components/structures/auth/E2eSetup.js index 6df8158002..d97a972718 100644 --- a/src/components/structures/auth/E2eSetup.js +++ b/src/components/structures/auth/E2eSetup.js @@ -24,6 +24,7 @@ export default class E2eSetup extends React.Component { static propTypes = { onFinished: PropTypes.func.isRequired, accountPassword: PropTypes.string, + tokenLogin: PropTypes.bool, }; render() { @@ -33,6 +34,7 @@ export default class E2eSetup extends React.Component { diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.js b/src/components/views/auth/InteractiveAuthEntryComponents.js index 60e57afc98..7dc1976641 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.js +++ b/src/components/views/auth/InteractiveAuthEntryComponents.js @@ -609,8 +609,12 @@ export class SSOAuthEntry extends React.Component { this.props.authSessionId, ); + this._popupWindow = null; + window.addEventListener("message", this._onReceiveMessage); + this.state = { phase: SSOAuthEntry.PHASE_PREAUTH, + attemptFailed: false, }; } @@ -618,12 +622,35 @@ export class SSOAuthEntry extends React.Component { this.props.onPhaseChange(SSOAuthEntry.PHASE_PREAUTH); } + componentWillUnmount() { + window.removeEventListener("message", this._onReceiveMessage); + if (this._popupWindow) { + this._popupWindow.close(); + this._popupWindow = null; + } + } + + attemptFailed = () => { + this.setState({ + attemptFailed: true, + }); + }; + + _onReceiveMessage = event => { + if (event.data === "authDone" && event.origin === this.props.matrixClient.getHomeserverUrl()) { + if (this._popupWindow) { + this._popupWindow.close(); + this._popupWindow = null; + } + } + }; + onStartAuthClick = () => { // Note: We don't use PlatformPeg's startSsoAuth functions because we almost // certainly will need to open the thing in a new tab to avoid losing application // context. - window.open(this._ssoUrl, '_blank'); + this._popupWindow = window.open(this._ssoUrl, "_blank"); this.setState({phase: SSOAuthEntry.PHASE_POSTAUTH}); this.props.onPhaseChange(SSOAuthEntry.PHASE_POSTAUTH); }; @@ -656,10 +683,28 @@ export class SSOAuthEntry extends React.Component { ); } - return
- {cancelButton} - {continueButton} -
; + let errorSection; + if (this.props.errorText) { + errorSection = ( +
+ { this.props.errorText } +
+ ); + } else if (this.state.attemptFailed) { + errorSection = ( +
+ { _t("Something went wrong in confirming your identity. Cancel and try again.") } +
+ ); + } + + return + { errorSection } +
+ {cancelButton} + {continueButton} +
+
; } } @@ -710,8 +755,7 @@ export class FallbackAuthEntry extends React.Component { this.props.loginType, this.props.authSessionId, ); - this._popupWindow = window.open(url); - this._popupWindow.opener = null; + this._popupWindow = window.open(url, "_blank"); }; _onReceiveMessage = event => { diff --git a/src/components/views/dialogs/security/CreateCrossSigningDialog.js b/src/components/views/dialogs/security/CreateCrossSigningDialog.js index 226419e759..be546d2616 100644 --- a/src/components/views/dialogs/security/CreateCrossSigningDialog.js +++ b/src/components/views/dialogs/security/CreateCrossSigningDialog.js @@ -34,6 +34,7 @@ import InteractiveAuthDialog from '../InteractiveAuthDialog'; export default class CreateCrossSigningDialog extends React.PureComponent { static propTypes = { accountPassword: PropTypes.string, + tokenLogin: PropTypes.bool, }; constructor(props) { @@ -96,6 +97,9 @@ export default class CreateCrossSigningDialog extends React.PureComponent { user: MatrixClientPeg.get().getUserId(), password: this.state.accountPassword, }); + } else if (this.props.tokenLogin) { + // We are hoping the grace period is active + await makeRequest({}); } else { const dialogAesthetics = { [SSOAuthEntry.PHASE_PREAUTH]: { @@ -144,6 +148,12 @@ export default class CreateCrossSigningDialog extends React.PureComponent { }); this.props.onFinished(true); } catch (e) { + if (this.props.tokenLogin) { + // ignore any failures, we are relying on grace period here + this.props.onFinished(); + return; + } + this.setState({ error: e }); console.error("Error bootstrapping cross-signing", e); } diff --git a/src/components/views/elements/SSOButtons.tsx b/src/components/views/elements/SSOButtons.tsx index 5c3098d807..3a03252ebd 100644 --- a/src/components/views/elements/SSOButtons.tsx +++ b/src/components/views/elements/SSOButtons.tsx @@ -22,13 +22,33 @@ 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 {IdentityProviderBrand, IIdentityProvider, ISSOFlow} from "../../../Login"; +import AccessibleTooltipButton from "./AccessibleTooltipButton"; interface ISSOButtonProps extends Omit { idp: IIdentityProvider; mini?: boolean; } +const getIcon = (brand: IdentityProviderBrand | string) => { + switch (brand) { + case IdentityProviderBrand.Apple: + return require(`../../../../res/img/element-icons/brands/apple.svg`); + case IdentityProviderBrand.Facebook: + return require(`../../../../res/img/element-icons/brands/facebook.svg`); + case IdentityProviderBrand.Github: + return require(`../../../../res/img/element-icons/brands/github.svg`); + case IdentityProviderBrand.Gitlab: + return require(`../../../../res/img/element-icons/brands/gitlab.svg`); + case IdentityProviderBrand.Google: + return require(`../../../../res/img/element-icons/brands/google.svg`); + case IdentityProviderBrand.Twitter: + return require(`../../../../res/img/element-icons/brands/twitter.svg`); + default: + return null; + } +} + const SSOButton: React.FC = ({ matrixClient, loginType, @@ -38,7 +58,6 @@ const SSOButton: React.FC = ({ 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 = () => { @@ -46,30 +65,35 @@ const SSOButton: React.FC = ({ }; let icon; - if (typeof idp?.icon === "string" && (idp.icon.startsWith("mxc://") || idp.icon.startsWith("https://"))) { - icon = {label}; + let brandClass; + const brandIcon = idp ? getIcon(idp.brand) : null; + if (brandIcon) { + const brandName = idp.brand.split(".").pop(); + brandClass = `mx_SSOButton_brand_${brandName}`; + icon = {brandName}; + } else if (typeof idp?.icon === "string" && idp.icon.startsWith("mxc://")) { + const src = matrixClient.mxcUrlToHttp(idp.icon, 24, 24, "crop", true); + icon = {idp.name}; } const classes = classNames("mx_SSOButton", { + [brandClass]: brandClass, mx_SSOButton_mini: mini, + mx_SSOButton_default: !idp, + mx_SSOButton_primary: primary, }); if (mini) { // TODO fallback icon return ( - + { icon } - + ); } return ( - + { icon } { label } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 54ad41b814..235c72be92 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2339,6 +2339,7 @@ "Please enter the code it contains:": "Please enter the code it contains:", "Code": "Code", "Submit": "Submit", + "Something went wrong in confirming your identity. Cancel and try again.": "Something went wrong in confirming your identity. Cancel and try again.", "Start authentication": "Start authentication", "Enter password": "Enter password", "Nice, strong password!": "Nice, strong password!",