diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 76e581e63..20d5c1cd8 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -43,6 +43,12 @@ + + + + + + {buttonText} @@ -154,18 +156,31 @@ export default class LoginOptions extends PureComponent { const forceHideFromLocal = LocalConfig.HideGitLabLoginExperimental; if (!forceHideFromLocal && config.EnableSignUpWithGitLab === 'true') { + const additionalButtonStyle = { + backgroundColor: '#548', + borderColor: 'transparent', + borderWidth: 0, + }; + + const logoStyle = { + height: 18, + marginRight: 5, + width: 18, + }; + + const textColor = 'white'; return ( + ); + } + + return null; + }; + renderO365Option = () => { const {config, license} = this.props; const forceHideFromLocal = LocalConfig.HideO365LoginExperimental; const o365Enabled = config.EnableSignUpWithOffice365 === 'true' && license.IsLicensed === 'true' && license.Office365OAuth === 'true'; if (!forceHideFromLocal && o365Enabled) { - const backgroundColor = '#2389d7'; - const additionalStyle = { - backgroundColor, + const additionalButtonStyle = { + backgroundColor: '#2389d7', borderColor: 'transparent', borderWidth: 0, }; @@ -195,7 +248,7 @@ export default class LoginOptions extends PureComponent { + ); + } + + return null; + }; + renderSamlOption = () => { const {config, license} = this.props; const forceHideFromLocal = LocalConfig.HideSAMLLoginExperimental; @@ -279,8 +363,10 @@ export default class LoginOptions extends PureComponent { {this.renderEmailOption()} {this.renderLdapOption()} {this.renderGitlabOption()} + {this.renderGoogleOption()} {this.renderSamlOption()} {this.renderO365Option()} + {this.renderOpenIdOption()} ); diff --git a/app/screens/login_options/login_options.test.js b/app/screens/login_options/login_options.test.js new file mode 100644 index 000000000..c94777692 --- /dev/null +++ b/app/screens/login_options/login_options.test.js @@ -0,0 +1,63 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import FormattedText from 'app/components/formatted_text'; + +import {shallowWithIntl} from 'test/intl-test-helper'; + +import LoginOptions from './login_options'; + +describe('Login options', () => { + const baseProps = { + config: { + Version: '5.32.0', + }, + license: { + IsLicensed: 'true', + }, + }; + + test('should show google signin button only when enabled', () => { + const basicWrapper = shallowWithIntl(); + expect(basicWrapper.find(FormattedText).find({id: 'signup.google'}).exists()).toBe(false); + + const props = { + ...baseProps, + config: { + ...baseProps.config, + EnableSignUpWithGoogle: 'true', + }, + }; + const configuredWrapper = shallowWithIntl(); + expect(configuredWrapper.find(FormattedText).find({id: 'signup.google'}).exists()).toBe(true); + }); + + test('should show open id button only when enabled and from version 5.33', () => { + const basicWrapper = shallowWithIntl(); + expect(basicWrapper.find(FormattedText).find({id: 'signup.openid'}).exists()).toBe(false); + + const newVersionProps = { + ...baseProps, + config: { + ...baseProps.config, + EnableSignUpWithOpenId: 'true', + Version: '5.33.0', + }, + }; + const newVersionWrapper = shallowWithIntl(); + expect(newVersionWrapper.find(FormattedText).find({id: 'signup.openid'}).exists()).toBe(true); + + const oldVersionProps = { + ...baseProps, + config: { + ...baseProps.config, + EnableSignUpWithOpenId: 'true', + Version: '5.30.0', + }, + }; + const oldVersionWrapper = shallowWithIntl(); + expect(oldVersionWrapper.find(FormattedText).find({id: 'signup.openid'}).exists()).toBe(false); + }); +}); diff --git a/app/screens/select_server/select_server.js b/app/screens/select_server/select_server.js index 53c05f051..8ebd00f37 100644 --- a/app/screens/select_server/select_server.js +++ b/app/screens/select_server/select_server.js @@ -34,6 +34,7 @@ import FormattedText from '@components/formatted_text'; import fetchConfig from '@init/fetch'; import globalEventHandler from '@init/global_event_handler'; import {Client4} from '@mm-redux/client'; +import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; import {checkUpgradeType, isUpgradeAvailable} from '@utils/client_upgrade'; import {t} from '@utils/i18n'; import {preventDoubleTap} from '@utils/tap'; @@ -255,10 +256,12 @@ export default class SelectServer extends PureComponent { const {config, license} = props; const samlEnabled = config.EnableSaml === 'true' && license.IsLicensed === 'true' && license.SAML === 'true'; const gitlabEnabled = config.EnableSignUpWithGitLab === 'true'; + const googleEnabled = config.EnableSignUpWithGoogle === 'true' && license.IsLicensed === 'true'; const o365Enabled = config.EnableSignUpWithOffice365 === 'true' && license.IsLicensed === 'true' && license.Office365OAuth === 'true'; + const openIdEnabled = config.EnableSignUpWithOpenId === 'true' && license.IsLicensed === 'true' && isMinimumServerVersion(config.Version, 5, 33, 0); let options = 0; - if (samlEnabled || gitlabEnabled || o365Enabled) { + if (samlEnabled || gitlabEnabled || googleEnabled || o365Enabled || openIdEnabled) { options += 1; } diff --git a/app/screens/sso/index.js b/app/screens/sso/index.js deleted file mode 100644 index 0a9aa2434..000000000 --- a/app/screens/sso/index.js +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {bindActionCreators} from 'redux'; -import {connect} from 'react-redux'; - -import {ssoLogin} from '@actions/views/user'; -import {scheduleExpiredNotification} from '@actions/views/session'; -import {getTheme} from '@mm-redux/selectors/entities/preferences'; - -import SSO from './sso'; - -function mapStateToProps(state) { - return { - ...state.views.selectServer, - theme: getTheme(state), - }; -} - -function mapDispatchToProps(dispatch) { - return { - actions: bindActionCreators({ - scheduleExpiredNotification, - ssoLogin, - }, dispatch), - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(SSO); diff --git a/app/screens/sso/index.tsx b/app/screens/sso/index.tsx new file mode 100644 index 000000000..5cd9ce245 --- /dev/null +++ b/app/screens/sso/index.tsx @@ -0,0 +1,130 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; +import {injectIntl, intlShape} from 'react-intl'; +import {shallowEqual, useDispatch, useSelector} from 'react-redux'; + +import type {GlobalState} from '@mm-redux/types/store'; + +import {resetToChannel} from 'app/actions/navigation'; +import {ViewTypes} from 'app/constants'; +import tracker from 'app/utils/time_tracker'; +import {scheduleExpiredNotification} from '@actions/views/session'; +import {ssoLogin} from '@actions/views/user'; +import {DispatchFunc} from '@mm-redux/types/actions'; +import {Client4} from '@mm-redux/client'; +import {getTheme} from '@mm-redux/selectors/entities/preferences'; +import {getConfig} from '@mm-redux/selectors/entities/general'; +import {ErrorApi} from '@mm-redux/types/client4'; +import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; + +import SSOWithRedirectURL from './sso_with_redirect_url'; +import SSOWithWebView from './sso_with_webview'; + +interface SSOProps { + intl: typeof intlShape; + ssoType: string; +} + +function SSO({intl, ssoType}: SSOProps) { + const [config, serverUrl, theme] = useSelector((state: GlobalState) => ([ + getConfig(state), + state.views.selectServer.serverUrl, + getTheme(state), + ]), shallowEqual); + + const [loginError, setLoginError] = React.useState(''); + + const asyncDispatch: DispatchFunc = useDispatch(); + const dispatch = useDispatch(); + + let completeUrlPath = ''; + let loginUrl = ''; + switch (ssoType) { + case ViewTypes.GOOGLE: { + completeUrlPath = '/signup/google/complete'; + loginUrl = `${serverUrl}/oauth/google/mobile_login`; + break; + } + case ViewTypes.GITLAB: { + completeUrlPath = '/signup/gitlab/complete'; + loginUrl = `${serverUrl}/oauth/gitlab/mobile_login`; + break; + } + case ViewTypes.SAML: { + completeUrlPath = '/login/sso/saml'; + loginUrl = `${serverUrl}/login/sso/saml?action=mobile`; + break; + } + case ViewTypes.OFFICE365: { + completeUrlPath = '/signup/office365/complete'; + loginUrl = `${serverUrl}/oauth/office365/mobile_login`; + break; + } + case ViewTypes.OPENID: { + completeUrlPath = '/signup/openid/complete'; + loginUrl = `${serverUrl}/oauth/openid/mobile_login`; + break; + } + } + + const onLoadEndError = (e: ErrorApi) => { + console.warn('Failed to set store from local data', e); // eslint-disable-line no-console + let errorMessage = e.message; + if (e.url) { + errorMessage += `\nURL: ${e.url}`; + } + setLoginError(errorMessage); + }; + + const onMMToken = async (token: string) => { + Client4.setToken(token); + asyncDispatch(ssoLogin()).then((result: any) => { + if (result && result.error) { + onLoadEndError(result.error); + return; + } + goToChannel(); + }).catch(() => { + setLoginError(''); + }); + }; + + const goToChannel = () => { + tracker.initialLoad = Date.now(); + scheduleSessionExpiredNotification(); + resetToChannel(); + }; + + const scheduleSessionExpiredNotification = () => { + dispatch(scheduleExpiredNotification(intl)); + }; + + const isSSOWithRedirectURLAvailable = isMinimumServerVersion(config.Version, 5, 33, 0); + + const props = { + intl, + loginError, + loginUrl, + onCSRFToken: Client4.setCSRF, + onMMToken, + setLoginError, + theme, + }; + + if (isSSOWithRedirectURLAvailable) { + return ( + + ); + } + return ( + + ); +} + +export default React.memo(injectIntl(SSO)); diff --git a/app/screens/sso/sso.js b/app/screens/sso/sso.js deleted file mode 100644 index b1ee42783..000000000 --- a/app/screens/sso/sso.js +++ /dev/null @@ -1,300 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {PureComponent} from 'react'; -import PropTypes from 'prop-types'; -import {injectIntl, intlShape} from 'react-intl'; -import { - Text, - View, - Platform, -} from 'react-native'; -import {WebView} from 'react-native-webview'; -import CookieManager from 'react-native-cookies'; -import {SafeAreaView} from 'react-native-safe-area-context'; -import urlParse from 'url-parse'; - -import {Client4} from '@mm-redux/client'; - -import {ViewTypes} from 'app/constants'; -import Loading from 'app/components/loading'; -import StatusBar from 'app/components/status_bar'; -import {resetToChannel} from 'app/actions/navigation'; -import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; -import tracker from 'app/utils/time_tracker'; - -const HEADERS = { - 'X-Mobile-App': 'mattermost', -}; - -const postMessageJS = "window.postMessage(document.body.innerText, '*');"; - -// Used to make sure that OneLogin forms scale appropriately on both platforms. -const oneLoginFormScalingJS = ` - (function() { - var loginPage = document.getElementById('login-page'); - var submitButton = document.getElementById('user_submit'); - - if (loginPage) { - loginPage.setAttribute('style', 'background-repeat: repeat-y;'); - } - - function resetPadding() { - var mainBody = document.getElementById('body-main'); - - if (mainBody) { - mainBody.setAttribute('style', 'height: auto; padding: 10px 0;'); - } - - if (submitButton) { - submitButton.removeEventListener('click', resetPadding); - } - } - - resetPadding(); - - if (submitButton) { - submitButton.addEventListener('click', resetPadding); - } - })(); -`; - -class SSO extends PureComponent { - static propTypes = { - actions: PropTypes.shape({ - scheduleExpiredNotification: PropTypes.func.isRequired, - ssoLogin: PropTypes.func.isRequired, - }).isRequired, - intl: intlShape.isRequired, - serverUrl: PropTypes.string.isRequired, - ssoType: PropTypes.string.isRequired, - theme: PropTypes.object, - }; - - useWebkit = true; - - constructor(props) { - super(props); - - this.state = { - error: null, - renderWebView: true, - jsCode: '', - messagingEnabled: false, - }; - - switch (props.ssoType) { - case ViewTypes.GITLAB: - this.loginUrl = `${props.serverUrl}/oauth/gitlab/mobile_login`; - this.completeUrlPath = '/signup/gitlab/complete'; - break; - case ViewTypes.SAML: - this.loginUrl = `${props.serverUrl}/login/sso/saml?action=mobile`; - this.completeUrlPath = '/login/sso/saml'; - break; - case ViewTypes.OFFICE365: - this.loginUrl = `${props.serverUrl}/oauth/office365/mobile_login`; - this.completeUrlPath = '/signup/office365/complete'; - break; - } - - if (Platform.OS === 'ios') { - this.useWebkit = parseInt(Platform.Version, 10) >= 11; - } - } - - componentWillUnmount() { - clearTimeout(this.cookiesTimeout); - } - - extractCookie = (parsedUrl) => { - const original = urlParse(this.props.serverUrl); - - // Check whether we need to set a sub-path - parsedUrl.set('pathname', original.pathname || ''); - - // Rebuild the server url without query string and/or hash - const url = `${parsedUrl.origin}${parsedUrl.pathname}`; - Client4.setUrl(url); - - CookieManager.get(url, true).then((res) => { - const mmtoken = res.MMAUTHTOKEN; - const csrf = res.MMCSRF; - const token = typeof mmtoken === 'object' ? mmtoken.value : mmtoken; - const csrfToken = typeof csrf === 'object' ? csrf.value : csrf; - - if (csrfToken) { - Client4.setCSRF(csrfToken); - } - - if (token) { - clearTimeout(this.cookiesTimeout); - this.setState({renderWebView: false}); - const { - ssoLogin, - } = this.props.actions; - - Client4.setToken(token); - ssoLogin().then((result) => { - if (result.error) { - this.onLoadEndError(result.error); - return; - } - this.goToChannel(); - }); - } else if (this.webView && !this.state.error) { - this.webView.injectJavaScript(postMessageJS); - this.cookiesTimeout = setTimeout(this.extractCookie.bind(null, parsedUrl), 250); - } - }); - } - - goToChannel = () => { - tracker.initialLoad = Date.now(); - - this.scheduleSessionExpiredNotification(); - - resetToChannel(); - }; - - onMessage = (event) => { - try { - const response = JSON.parse(event.nativeEvent.data); - if (response) { - const { - id, - message, - status_code: statusCode, - } = response; - if (id && message && statusCode !== 200) { - clearTimeout(this.cookiesTimeout); - this.setState({error: message}); - } - } - } catch (e) { - // do nothing - } - }; - - onNavigationStateChange = (navState) => { - const {url} = navState; - const nextState = { - messagingEnabled: false, - }; - const parsed = urlParse(url); - - if (parsed.host.includes('.onelogin.com')) { - nextState.jsCode = oneLoginFormScalingJS; - } else if (parsed.pathname === this.completeUrlPath) { - // To avoid `window.postMessage` conflicts in any of the SSO flows - // we enable the onMessage handler only When the webView navigates to the final SSO URL. - nextState.messagingEnabled = true; - } - - this.setState(nextState); - }; - - onLoadEnd = (event) => { - const url = event.nativeEvent.url; - const parsed = urlParse(url); - - let isLastRedirect = url.includes(this.completeUrlPath); - if (this.props.ssoType === ViewTypes.SAML) { - isLastRedirect = isLastRedirect && !parsed.query; - } - - if (isLastRedirect) { - this.extractCookie(parsed); - } - }; - - onLoadEndError = (e) => { - console.warn('Failed to set store from local data', e); // eslint-disable-line no-console - let error = e.message; - if (e.details) { - error += `\n${e.details.message}`; - } - - if (e.url) { - error += `\nURL: ${e.url}`; - } - this.setState({error}); - }; - - scheduleSessionExpiredNotification = () => { - const {actions, intl} = this.props; - - actions.scheduleExpiredNotification(intl); - }; - - renderLoading = () => { - return ; - }; - - webViewRef = (ref) => { - this.webView = ref; - }; - - render() { - const {theme} = this.props; - const {error, messagingEnabled, renderWebView, jsCode} = this.state; - const style = getStyleSheet(theme); - - let content; - if (error) { - content = ( - - {error} - - ); - } else if (renderWebView) { - content = ( - true} - injectedJavaScript={jsCode} - onLoadEnd={this.onLoadEnd} - onMessage={messagingEnabled ? this.onMessage : null} - useSharedProcessPool={false} - cacheEnabled={false} - /> - ); - } else { - content = this.renderLoading(); - } - - return ( - - - {content} - - ); - } -} - -const getStyleSheet = makeStyleSheetFromTheme((theme) => { - return { - container: { - flex: 1, - }, - errorContainer: { - alignItems: 'center', - flex: 1, - marginTop: 40, - }, - errorText: { - color: changeOpacity(theme.centerChannelColor, 0.4), - fontSize: 16, - fontWeight: '400', - lineHeight: 23, - paddingHorizontal: 30, - }, - }; -}); - -export default injectIntl(SSO); diff --git a/app/screens/sso/sso.test.tsx b/app/screens/sso/sso.test.tsx new file mode 100644 index 000000000..ba790ce0b --- /dev/null +++ b/app/screens/sso/sso.test.tsx @@ -0,0 +1,50 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Linking} from 'react-native'; + +import {renderWithReduxIntl} from 'test/testing_library'; +import configureStore from 'test/test_store'; + +import SSOComponent from './index'; + +describe('SSO', () => { + const baseProps = { + config: {}, + license: { + IsLicensed: 'true', + }, + }; + + test('implement with webview when version is less than 5.32 version', async () => { + const store = await configureStore({ + entities: { + general: { + config: { + Version: '5.30.0', + }, + }, + }, + }); + const basicWrapper = renderWithReduxIntl(, store); + expect(basicWrapper.queryByTestId('sso.webview')).toBeTruthy(); + expect(basicWrapper.queryByTestId('sso.redirect_url')).toBeFalsy(); + }); + + test('implement with OS browser & redirect url from version 5.33', async () => { + (Linking.openURL as jest.Mock).mockResolvedValueOnce(''); + const store = await configureStore({ + entities: { + general: { + config: { + Version: '5.33.0', + }, + }, + }, + }); + const basicWrapper = renderWithReduxIntl(, store); + expect(basicWrapper.queryByTestId('sso.webview')).toBeFalsy(); + expect(basicWrapper.queryByTestId('sso.redirect_url')).toBeTruthy(); + }); +}); diff --git a/app/screens/sso/sso_with_redirect_url.test.tsx b/app/screens/sso/sso_with_redirect_url.test.tsx new file mode 100644 index 000000000..1fc32d744 --- /dev/null +++ b/app/screens/sso/sso_with_redirect_url.test.tsx @@ -0,0 +1,41 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {shallow} from 'enzyme'; + +import Preferences from '@mm-redux/constants/preferences'; +import FormattedText from 'app/components/formatted_text'; + +import SSOWithRedirectURL from './sso_with_redirect_url'; + +describe('SSO with redirect url', () => { + const baseProps = { + customUrlScheme: 'mmauth://', + intl: {}, + loginError: '', + loginUrl: '', + onCSRFToken: jest.fn(), + onMMToken: jest.fn(), + setLoginError: jest.fn(), + theme: Preferences.THEMES.default, + }; + + test('should show message when user navigates to the page', () => { + const wrapperWithBaseProps = shallow(); + expect(wrapperWithBaseProps.find(FormattedText).find({id: 'mobile.oauth.switch_to_browser'}).exists()).toBe(true); + }); + + test('should show "try again" and hide default message when error text is displayed', () => { + const wrapperWithBaseProps = shallow(); + expect(wrapperWithBaseProps.find(FormattedText).find({id: 'mobile.oauth.try_again'}).exists()).toBe(false); + const wrapper = shallow( + , + ); + expect(wrapper.find(FormattedText).find({id: 'mobile.oauth.try_again'}).exists()).toBe(true); + expect(wrapper.find(FormattedText).find({id: 'mobile.oauth.switch_to_browser'}).exists()).toBe(false); + }); +}); diff --git a/app/screens/sso/sso_with_redirect_url.tsx b/app/screens/sso/sso_with_redirect_url.tsx new file mode 100644 index 000000000..c77d84130 --- /dev/null +++ b/app/screens/sso/sso_with_redirect_url.tsx @@ -0,0 +1,179 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; +import {intlShape} from 'react-intl'; +import {Linking, Text, TouchableOpacity, View} from 'react-native'; +import DeviceInfo from 'react-native-device-info'; +import {SafeAreaView} from 'react-native-safe-area-context'; +import urlParse from 'url-parse'; + +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +import {setDeepLinkURL} from '@actions/views/root'; +import FormattedText from '@components/formatted_text'; +import Loading from '@components/loading'; +import {Theme} from '@mm-redux/types/preferences'; +import Store from '@store/store'; +import {tryOpenURL} from '@utils/url'; + +interface SSOWithRedirectURLProps { + intl: typeof intlShape; + loginError: string; + loginUrl: string; + onCSRFToken: (token: string) => void; + onMMToken: (token: string) => void; + setLoginError: (value: string) => void; + theme: Theme +} + +function SSOWithRedirectURL({ + intl, + loginError, + loginUrl, + onCSRFToken, + onMMToken, + setLoginError, + theme, +}: SSOWithRedirectURLProps) { + const [error, setError] = React.useState(''); + const style = getStyleSheet(theme); + + let customUrlScheme = 'mmauth://'; + if (DeviceInfo.getBundleId && DeviceInfo.getBundleId().includes('rnbeta')) { + customUrlScheme = 'mmauthbeta://'; + } + + const redirectUrl = customUrlScheme + 'callback'; + + const init = (resetErrors?: boolean) => { + if (resetErrors !== false) { + setError(''); + setLoginError(''); + } + const parsedUrl = urlParse(loginUrl, true); + parsedUrl.set('query', { + ...parsedUrl.query, + redirect_to: redirectUrl, + }); + const url = parsedUrl.toString(); + + const onError = () => setError( + intl.formatMessage({ + id: 'mobile.oauth.failed_to_open_link', + defaultMessage: 'The link failed to open. Please try again.', + }), + ); + tryOpenURL(url, onError); + }; + + const onURLChange = ({url}: { url: string }) => { + if (url && url.startsWith(redirectUrl)) { + Store?.redux?.dispatch(setDeepLinkURL('')); + const parsedUrl = urlParse(url, true); + if (parsedUrl.query && parsedUrl.query.MMCSRF && parsedUrl.query.MMAUTHTOKEN) { + onCSRFToken(parsedUrl.query.MMCSRF); + onMMToken(parsedUrl.query.MMAUTHTOKEN); + } else { + setError( + intl.formatMessage({ + id: 'mobile.oauth.failed_to_login', + defaultMessage: 'Your login attempt failed. Please try again.', + }), + ); + } + } + }; + + React.useEffect(() => { + Linking.addEventListener('url', onURLChange); + init(false); + return () => { + Linking.removeEventListener('url', onURLChange); + }; + }, []); + + return ( + + {loginError || error ? ( + + + + {`${loginError || error}.`} + + + init()}> + + + + ) : ( + + + init()}> + + + + + )} + + ); +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + flex: 1, + }, + errorContainer: { + alignItems: 'center', + flex: 1, + marginTop: 40, + }, + errorTextContainer: { + marginBottom: 12, + }, + errorText: { + color: changeOpacity(theme.centerChannelColor, 0.6), + fontSize: 16, + fontWeight: '400', + lineHeight: 23, + }, + infoContainer: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + marginTop: 40, + }, + infoText: { + color: changeOpacity(theme.centerChannelColor, 0.6), + fontSize: 16, + fontWeight: '400', + lineHeight: 23, + marginBottom: 6, + }, + button: { + backgroundColor: theme.buttonBg, + color: theme.buttonColor, + fontSize: 16, + paddingHorizontal: 9, + paddingVertical: 9, + marginTop: 3, + }, + }; +}); + +export default SSOWithRedirectURL; diff --git a/app/screens/sso/sso_with_webview.tsx b/app/screens/sso/sso_with_webview.tsx new file mode 100644 index 000000000..150db18de --- /dev/null +++ b/app/screens/sso/sso_with_webview.tsx @@ -0,0 +1,256 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {intlShape} from 'react-intl'; +import {Alert, Text, View} from 'react-native'; +import CookieManager from 'react-native-cookies'; +import {SafeAreaView} from 'react-native-safe-area-context'; +import {WebView} from 'react-native-webview'; +import {WebViewErrorEvent, WebViewMessageEvent, WebViewNavigation, WebViewNavigationEvent} from 'react-native-webview/lib/WebViewTypes'; +import urlParse from 'url-parse'; + +import {Client4} from '@mm-redux/client'; +import type {Theme} from '@mm-redux/types/preferences'; + +import Loading from 'app/components/loading'; +import StatusBar from 'app/components/status_bar'; +import {ViewTypes} from 'app/constants'; +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; +import {popTopScreen} from '@actions/navigation'; + +const HEADERS = { + 'X-Mobile-App': 'mattermost', +}; + +const postMessageJS = "window.postMessage(document.body.innerText, '*');"; + +// Used to make sure that OneLogin forms scale appropriately on both platforms. +const oneLoginFormScalingJS = ` + (function() { + var loginPage = document.getElementById('login-page'); + var submitButton = document.getElementById('user_submit'); + + if (loginPage) { + loginPage.setAttribute('style', 'background-repeat: repeat-y;'); + } + + function resetPadding() { + var mainBody = document.getElementById('body-main'); + + if (mainBody) { + mainBody.setAttribute('style', 'height: auto; padding: 10px 0;'); + } + + if (submitButton) { + submitButton.removeEventListener('click', resetPadding); + } + } + + resetPadding(); + + if (submitButton) { + submitButton.addEventListener('click', resetPadding); + } + })(); +`; + +interface SSOWithWebViewProps { + completeUrlPath: string; + intl: typeof intlShape; + loginError: string; + loginUrl: string; + onCSRFToken: (token: string) => void; + onMMToken: (token: string) => void; + serverUrl: string; + ssoType: string; + theme: Theme +} + +type CookieResponseType = { + MMAUTHTOKEN: string | { + value: string + }; + MMCSRF: string | { + value: string + }; +} + +function SSOWithWebView({completeUrlPath, intl, loginError, loginUrl, onCSRFToken, onMMToken, serverUrl, ssoType, theme}: SSOWithWebViewProps) { + const style = getStyleSheet(theme); + + const [error, setError] = React.useState(null); + const [jsCode, setJSCode] = React.useState(''); + const [messagingEnabled, setMessagingEnabled] = React.useState(false); + const [shouldRenderWebView, setShouldRenderWebView] = React.useState(true); + const cookiesTimeout = React.useRef(); + const webView = React.useRef(null); + + React.useEffect(() => { + return () => { + if (cookiesTimeout.current) { + clearTimeout(cookiesTimeout.current); + } + }; + }, []); + + const extractCookie = (parsedUrl: urlParse) => { + try { + const original = urlParse(serverUrl); + + // Check whether we need to set a sub-path + parsedUrl.set('pathname', original.pathname || ''); + + // Rebuild the server url without query string and/or hash + const url = `${parsedUrl.origin}${parsedUrl.pathname}`; + Client4.setUrl(url); + + CookieManager.get(url, true).then((res: CookieResponseType) => { + const mmtoken = res.MMAUTHTOKEN; + const csrf = res.MMCSRF; + const token = typeof mmtoken === 'object' ? mmtoken.value : mmtoken; + const csrfToken = typeof csrf === 'object' ? csrf.value : csrf; + + if (csrfToken) { + onCSRFToken(csrfToken); + } + if (token) { + onMMToken(token); + if (cookiesTimeout.current) { + clearTimeout(cookiesTimeout.current); + } + setShouldRenderWebView(false); + } else if (webView.current && !error) { + webView.current.injectJavaScript(postMessageJS); + cookiesTimeout.current = setTimeout(extractCookie.bind(null, parsedUrl), 250); + } + }); + } catch (e) { + Alert.alert( + intl.formatMessage({ + id: 'mobile.oauth.something_wrong', + defaultMessage: 'Something went wrong', + }), + '', + [{ + text: intl.formatMessage({ + id: 'mobile.oauth.something_wrong.okButon', + defaultMessage: 'Ok', + }), + onPress: () => { + popTopScreen(); + }, + }], + ); + } + }; + + const onMessage = (event: WebViewMessageEvent) => { + try { + const response = JSON.parse(event.nativeEvent.data); + if (response) { + const { + id, + message, + status_code: statusCode, + } = response; + if (id && message && statusCode !== 200) { + if (cookiesTimeout.current) { + clearTimeout(cookiesTimeout.current); + } + setError(message); + } + } + } catch (e) { + // do nothing + } + }; + + const onNavigationStateChange = (navState: WebViewNavigation) => { + const {url} = navState; + let isMessagingEnabled = false; + const parsed = urlParse(url); + if (parsed.host.includes('.onelogin.com')) { + setJSCode(oneLoginFormScalingJS); + } else if (parsed.pathname === completeUrlPath) { + // To avoid `window.postMessage` conflicts in any of the SSO flows + // we enable the onMessage handler only When the webView navigates to the final SSO URL. + isMessagingEnabled = true; + } + setMessagingEnabled(isMessagingEnabled); + }; + + const onLoadEnd = (event: WebViewNavigationEvent | WebViewErrorEvent) => { + const url = event.nativeEvent.url; + const parsed = urlParse(url); + + let isLastRedirect = url.includes(completeUrlPath); + if (ssoType === ViewTypes.SAML) { + isLastRedirect = isLastRedirect && !parsed.query; + } + + if (isLastRedirect) { + extractCookie(parsed); + } + }; + + const renderWebView = () => { + if (shouldRenderWebView) { + const userAgent = ssoType === ViewTypes.GOOGLE ? 'Mozilla/5.0 (Linux; Android 10; Android SDK built for x86 Build/LMY48X) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/81.0.4044.117 Mobile Safari/608.2.11' : undefined; + return ( + true} + ref={webView} + source={{uri: loginUrl, headers: HEADERS}} + startInLoadingState={true} + userAgent={userAgent} + useSharedProcessPool={false} + /> + ); + } + return ; + }; + + return ( + + + {error || loginError ? ( + + {error || loginError} + + ) : renderWebView()} + + ); +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + flex: 1, + }, + errorContainer: { + alignItems: 'center', + flex: 1, + marginTop: 40, + }, + errorText: { + color: changeOpacity(theme.centerChannelColor, 0.4), + fontSize: 16, + fontWeight: '400', + lineHeight: 23, + paddingHorizontal: 30, + }, + }; +}); + +export default SSOWithWebView; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 9bbc5c85c..987677739 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -362,8 +362,15 @@ "mobile.notification_settings.modal_cancel": "CANCEL", "mobile.notification_settings.modal_save": "SAVE", "mobile.notification_settings.ooo_auto_responder": "Automatic Direct Message Replies", - "mobile.notification_settings.save_failed_description": "The notification settings failed to save due to a connection issue, please try again.", + "mobile.notification_settings.save_failed_description": "The notification settings failed to save due to a connection issue. Please try again.", "mobile.notification_settings.save_failed_title": "Connection issue", + "mobile.oauth.failed_to_login": "Your login attempt failed. Please try again.", + "mobile.oauth.failed_to_open_link": "The link failed to open. Please try again.", + "mobile.oauth.restart_login": "Restart login", + "mobile.oauth.something_wrong": "Something went wrong", + "mobile.oauth.something_wrong.okButon": "OK", + "mobile.oauth.switch_to_browser": "Please use your browser to complete the login process.", + "mobile.oauth.try_again": "Try again", "mobile.offlineIndicator.connected": "Connected", "mobile.offlineIndicator.connecting": "Connecting...", "mobile.offlineIndicator.offline": "No internet connection", @@ -591,6 +598,7 @@ "sidebar.types.recent": "RECENT ACTIVITY", "sidebar.unreads": "More unreads", "signup.email": "Email and Password", + "signup.google": "Google Apps", "signup.office365": "Office 365", "status_dropdown.set_away": "Away", "status_dropdown.set_dnd": "Do Not Disturb", diff --git a/assets/base/images/google.png b/assets/base/images/google.png new file mode 100644 index 000000000..927188432 Binary files /dev/null and b/assets/base/images/google.png differ diff --git a/fastlane/Fastfile b/fastlane/Fastfile index f4acd8330..7254539e8 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -429,12 +429,13 @@ platform :ios do # Set the deep link prefix app_scheme = ENV['APP_SCHEME'] || 'mattermost' + app_auth_scheme = ENV['BETA_BUILD'] == 'true' ? 'mmauthbeta' : 'mmauth' update_info_plist( xcodeproj: './ios/Mattermost.xcodeproj', plist_path: 'Mattermost/Info.plist', block: proc do |plist| urlScheme = plist["CFBundleURLTypes"].find{|scheme| scheme["CFBundleURLName"] == "com.mattermost"} - urlScheme[:CFBundleURLSchemes] = [app_scheme] + urlScheme[:CFBundleURLSchemes] = [app_scheme, app_auth_scheme] end ) @@ -660,6 +661,14 @@ platform :android do new_string: "scheme=\'#{app_scheme}\'" ) + if ENV['BETA_BUILD'] != 'true' + find_replace_string( + path_to_file: "./android/app/src/main/AndroidManifest.xml", + old_string: 'scheme="mmauthbeta"', + new_string: 'scheme="mmauth"', + ) + end + beta_dir = './android/app/src/main/java/com/mattermost/rnbeta/' release_dir = "./android/app/src/main/java/#{package_id.gsub '.', '/'}/" if ENV['BUILD_FOR_RELEASE'] == 'true' diff --git a/ios/Mattermost/Info.plist b/ios/Mattermost/Info.plist index 676db6b4c..270fd5ad5 100644 --- a/ios/Mattermost/Info.plist +++ b/ios/Mattermost/Info.plist @@ -32,6 +32,7 @@ CFBundleURLSchemes mattermost + mmauthbeta