diff --git a/app/screens/sso/components/auth_error.tsx b/app/screens/sso/components/auth_error.tsx new file mode 100644 index 00000000000..897ac7d3e64 --- /dev/null +++ b/app/screens/sso/components/auth_error.tsx @@ -0,0 +1,75 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Button} from '@rneui/base'; +import React from 'react'; +import {Text, View} from 'react-native'; + +import FormattedText from '@components/formatted_text'; +import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +interface AuthErrorProps { + error: string; + retry: () => void; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + button: { + marginTop: 25, + }, + errorText: { + color: changeOpacity(theme.centerChannelColor, 0.72), + textAlign: 'center', + ...typography('Body', 200, 'Regular'), + }, + infoContainer: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + }, + infoText: { + color: changeOpacity(theme.centerChannelColor, 0.72), + ...typography('Body', 100, 'Regular'), + }, + infoTitle: { + color: theme.centerChannelColor, + marginBottom: 4, + ...typography('Heading', 700), + }, + }; +}); + +const AuthError = ({error, retry, theme}: AuthErrorProps) => { + const style = getStyleSheet(theme); + + return ( + + + + {`${error}.`} + + + + ); +}; + +export default AuthError; diff --git a/app/screens/sso/components/auth_redirect.tsx b/app/screens/sso/components/auth_redirect.tsx new file mode 100644 index 00000000000..f374aa9d4cb --- /dev/null +++ b/app/screens/sso/components/auth_redirect.tsx @@ -0,0 +1,55 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {View} from 'react-native'; + +import FormattedText from '@components/formatted_text'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +interface AuthRedirectProps { + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + infoContainer: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + }, + infoText: { + color: changeOpacity(theme.centerChannelColor, 0.72), + ...typography('Body', 100, 'Regular'), + }, + infoTitle: { + color: theme.centerChannelColor, + marginBottom: 4, + ...typography('Heading', 700), + }, + }; +}); + +const AuthRedirect = ({theme}: AuthRedirectProps) => { + const style = getStyleSheet(theme); + + return ( + + + + + ); +}; + +export default AuthRedirect; diff --git a/app/screens/sso/components/auth_success.tsx b/app/screens/sso/components/auth_success.tsx new file mode 100644 index 00000000000..2550450b939 --- /dev/null +++ b/app/screens/sso/components/auth_success.tsx @@ -0,0 +1,57 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {View} from 'react-native'; + +import FormattedText from '@components/formatted_text'; +import Loading from '@components/loading'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +interface AuthSuccessProps { + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + infoContainer: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + }, + infoText: { + color: changeOpacity(theme.centerChannelColor, 0.72), + ...typography('Body', 100, 'Regular'), + }, + infoTitle: { + color: theme.centerChannelColor, + marginBottom: 4, + ...typography('Heading', 700), + }, + }; +}); + +const AuthSuccess = ({theme}: AuthSuccessProps) => { + const style = getStyleSheet(theme); + + return ( + + + + + + ); +}; + +export default AuthSuccess; diff --git a/app/screens/sso/index.tsx b/app/screens/sso/index.tsx index 1d127d5dd38..6cabcbf47c8 100644 --- a/app/screens/sso/index.tsx +++ b/app/screens/sso/index.tsx @@ -18,6 +18,7 @@ import {getFullErrorMessage, isErrorWithUrl} from '@utils/errors'; import {logWarning} from '@utils/log'; import SSOAuthentication from './sso_authentication'; +import SSOAuthenticationWithExternalBrowser from './sso_authentication_with_external_browser'; import type {LaunchProps} from '@typings/launch'; import type {AvailableScreens} from '@typings/screens/navigation'; @@ -155,14 +156,28 @@ const SSO = ({ theme, }; + let authentication; + if (config.MobileExternalBrowser === 'true') { + authentication = ( + + ); + } else { + authentication = ( + + ); + } + return ( - + {authentication} ); diff --git a/app/screens/sso/sso_authentication.tsx b/app/screens/sso/sso_authentication.tsx index 5e6d6fbc4b0..c60007ce01c 100644 --- a/app/screens/sso/sso_authentication.tsx +++ b/app/screens/sso/sso_authentication.tsx @@ -1,24 +1,22 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Button} from '@rneui/base'; import {openAuthSessionAsync} from 'expo-web-browser'; import qs from 'querystringify'; import React, {useEffect, useState} from 'react'; import {useIntl} from 'react-intl'; -import {Linking, Platform, Text, View, type EventSubscription} from 'react-native'; +import {Linking, Platform, StyleSheet, View, type EventSubscription} from 'react-native'; import urlParse from 'url-parse'; -import FormattedText from '@components/formatted_text'; -import Loading from '@components/loading'; import {Sso} from '@constants'; import NetworkManager from '@managers/network_manager'; -import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; import {isBetaApp} from '@utils/general'; -import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; -import {typography} from '@utils/typography'; -interface SSOWithRedirectURLProps { +import AuthError from './components/auth_error'; +import AuthRedirect from './components/auth_redirect'; +import AuthSuccess from './components/auth_success'; + +interface SSOAuthenticationProps { doSSOLogin: (bearerToken: string, csrfToken: string) => void; loginError: string; loginUrl: string; @@ -27,41 +25,16 @@ interface SSOWithRedirectURLProps { theme: Theme; } -const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { - return { - button: { - marginTop: 25, - }, - container: { - flex: 1, - paddingHorizontal: 24, - }, - errorText: { - color: changeOpacity(theme.centerChannelColor, 0.72), - textAlign: 'center', - ...typography('Body', 200, 'Regular'), - }, - infoContainer: { - alignItems: 'center', - flex: 1, - justifyContent: 'center', - }, - infoText: { - color: changeOpacity(theme.centerChannelColor, 0.72), - ...typography('Body', 100, 'Regular'), - }, - infoTitle: { - color: theme.centerChannelColor, - marginBottom: 4, - ...typography('Heading', 700), - }, - }; +const style = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: 24, + }, }); -const SSOAuthentication = ({doSSOLogin, loginError, loginUrl, serverUrl, setLoginError, theme}: SSOWithRedirectURLProps) => { +const SSOAuthentication = ({doSSOLogin, loginError, loginUrl, serverUrl, setLoginError, theme}: SSOAuthenticationProps) => { const [error, setError] = useState(''); const [loginSuccess, setLoginSuccess] = useState(false); - const style = getStyleSheet(theme); const intl = useIntl(); let customUrlScheme = Sso.REDIRECT_URL_SCHEME; if (isBetaApp) { @@ -84,7 +57,7 @@ const SSOAuthentication = ({doSSOLogin, loginError, loginUrl, serverUrl, setLogi }; parsedUrl.set('query', qs.stringify(query)); const url = parsedUrl.toString(); - const result = await openAuthSessionAsync(url, null, {preferEphemeralSession: true}); + const result = await openAuthSessionAsync(url, null, {preferEphemeralSession: true, createTask: false}); if ('url' in result && result.url) { const resultUrl = urlParse(result.url, true); const bearerToken = resultUrl.query?.MMAUTHTOKEN; @@ -142,65 +115,17 @@ const SSOAuthentication = ({doSSOLogin, loginError, loginUrl, serverUrl, setLogi let content; if (loginSuccess) { - content = ( - - - - - - ); + content = (); } else if (loginError || error) { content = ( - - - - {`${loginError || error}.`} - - - + ); } else { - content = ( - - - - - ); + content = (); } return ( diff --git a/app/screens/sso/sso_authentication_with_external_browser.tsx b/app/screens/sso/sso_authentication_with_external_browser.tsx new file mode 100644 index 00000000000..af439c23b5b --- /dev/null +++ b/app/screens/sso/sso_authentication_with_external_browser.tsx @@ -0,0 +1,166 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import qs from 'querystringify'; +import React, {useEffect, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {Linking, Platform, View} from 'react-native'; +import urlParse from 'url-parse'; + +import {Sso} from '@constants'; +import NetworkManager from '@managers/network_manager'; +import {isErrorWithMessage} from '@utils/errors'; +import {isBetaApp} from '@utils/general'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; +import {tryOpenURL} from '@utils/url'; + +import AuthError from './components/auth_error'; +import AuthRedirect from './components/auth_redirect'; +import AuthSuccess from './components/auth_success'; + +interface SSOWithRedirectURLProps { + doSSOLogin: (bearerToken: string, csrfToken: string) => void; + loginError: string; + loginUrl: string; + serverUrl: string; + setLoginError: (value: string) => void; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + button: { + marginTop: 25, + }, + container: { + flex: 1, + paddingHorizontal: 24, + }, + errorText: { + color: changeOpacity(theme.centerChannelColor, 0.72), + textAlign: 'center', + ...typography('Body', 200, 'Regular'), + }, + infoContainer: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + }, + infoText: { + color: changeOpacity(theme.centerChannelColor, 0.72), + ...typography('Body', 100, 'Regular'), + }, + infoTitle: { + color: theme.centerChannelColor, + marginBottom: 4, + ...typography('Heading', 700), + }, + }; +}); + +const SSOAuthenticationWithExternalBrowser = ({doSSOLogin, loginError, loginUrl, serverUrl, setLoginError, theme}: SSOWithRedirectURLProps) => { + const [error, setError] = useState(''); + const [loginSuccess, setLoginSuccess] = useState(false); + const style = getStyleSheet(theme); + const intl = useIntl(); + let customUrlScheme = Sso.REDIRECT_URL_SCHEME; + if (isBetaApp) { + customUrlScheme = Sso.REDIRECT_URL_SCHEME_DEV; + } + + const redirectUrl = customUrlScheme + 'callback'; + const init = (resetErrors = true) => { + setLoginSuccess(false); + if (resetErrors !== false) { + setError(''); + setLoginError(''); + NetworkManager.invalidateClient(serverUrl); + NetworkManager.createClient(serverUrl); + } + const parsedUrl = urlParse(loginUrl, true); + const query: Record = { + ...parsedUrl.query, + redirect_to: redirectUrl, + }; + parsedUrl.set('query', qs.stringify(query)); + const url = parsedUrl.toString(); + + const onError = (e: Error) => { + let message; + if (e && Platform.OS === 'android' && isErrorWithMessage(e) && e.message.match(/no activity found to handle intent/i)) { + message = intl.formatMessage({ + id: 'mobile.oauth.failed_to_open_link_no_browser', + defaultMessage: 'The link failed to open. Please verify that a browser is installed on the device.', + }); + } else { + message = intl.formatMessage({ + id: 'mobile.oauth.failed_to_open_link', + defaultMessage: 'The link failed to open. Please try again.', + }); + } + setError( + message, + ); + }; + + tryOpenURL(url, onError); + }; + + useEffect(() => { + const onURLChange = ({url}: { url: string }) => { + if (url && url.startsWith(redirectUrl)) { + const parsedUrl = urlParse(url, true); + const bearerToken = parsedUrl.query?.MMAUTHTOKEN; + const csrfToken = parsedUrl.query?.MMCSRF; + if (bearerToken && csrfToken) { + setLoginSuccess(true); + doSSOLogin(bearerToken, csrfToken); + } else { + setError( + intl.formatMessage({ + id: 'mobile.oauth.failed_to_login', + defaultMessage: 'Your login attempt failed. Please try again.', + }), + ); + } + } + }; + + const listener = Linking.addEventListener('url', onURLChange); + + const timeout = setTimeout(() => { + init(false); + }, 1000); + return () => { + listener.remove(); + clearTimeout(timeout); + }; + }, []); + + let content; + if (loginSuccess) { + content = (); + } else if (loginError || error) { + content = ( + + ); + } else { + content = (); + } + + return ( + + {content} + + ); +}; + +export default SSOAuthenticationWithExternalBrowser; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 6adffcd30af..f3ce56964e7 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -702,6 +702,8 @@ "mobile.no_results_with_term.messages": "No matches found for “{term}”", "mobile.no_results.spelling": "Check the spelling or try another search.", "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.failed_to_open_link_no_browser": "The link failed to open. Please verify that a browser is installed on the device.", "mobile.oauth.something_wrong.okButton": "OK", "mobile.oauth.success.description": "Signing in now, just a moment...", "mobile.oauth.success.title": "Authentication successful", diff --git a/types/api/config.d.ts b/types/api/config.d.ts index 3354f41e51a..29b95a32738 100644 --- a/types/api/config.d.ts +++ b/types/api/config.d.ts @@ -147,6 +147,7 @@ interface ClientConfig { MaxNotificationsPerChannel: string; MaxPostSize: string; MinimumHashtagLength: string; + MobileExternalBrowser: string; OpenIdButtonColor: string; OpenIdButtonText: string; PasswordEnableForgotLink: string;