mattermost-mobile-upstream/app/screens/terms_of_service/terms_of_service.tsx
yasser khan 08f83d4e7f
Add E2E coverage for custom terms of service login flow (#10105)
* Add E2E coverage for custom terms of service login flow

- Add detox test covering accept (MM-T1194) and decline (MM-T1193) paths
- Add TermsOfServiceScreen page object and Alert.termsDeclinedTitle
- Add TermsOfService server API helpers (create/enable/disable)
- Add testIDs to the terms of service screen, title and action buttons
2026-09-01 03:03:53 +05:30

308 lines
10 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {
Alert,
ScrollView,
Text,
View,
} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {logout} from '@actions/remote/session';
import {fetchTermsOfService, updateTermsOfServiceStatus} from '@actions/remote/terms_of_service';
import Button from '@components/button';
import Loading from '@components/loading';
import Markdown from '@components/markdown';
import {Screens} from '@constants/index';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useDidMount from '@hooks/did_mount';
import {navigateBack} from '@screens/navigation';
import {NavigationStore} from '@store/navigation_store';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type TermsOfServiceProps = {
siteName?: string;
showToS: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
root: {
flex: 1,
backgroundColor: changeOpacity('#000000', 0.50),
justifyContent: 'center',
alginIntems: 'center',
},
baseText: {
color: theme.centerChannelColor,
...typography('Body', 100, 'Regular'),
},
container: {
flex: 1,
maxWidth: 680,
alignSelf: 'center',
alignContent: 'center',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
},
wrapper: {
backgroundColor: theme.centerChannelBg,
borderRadius: 12,
margin: 10,
opacity: 1,
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
padding: 24,
},
scrollView: {
marginBottom: 24,
},
title: {
color: theme.centerChannelColor,
...typography('Heading', 600, 'SemiBold'),
borderBottomWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
marginBottom: 12,
},
errorTitle: {
color: theme.centerChannelColor,
...typography('Heading', 400, 'SemiBold'),
},
errorDescription: {
marginBottom: 24,
color: theme.centerChannelColor,
...typography('Body', 200, 'Regular'),
},
loadingContainer: {
justifyContent: 'center',
},
buttonContainer: {
flexDirection: 'column',
gap: 10,
},
};
});
const TermsOfService = ({
siteName = 'Mattermost',
showToS,
}: TermsOfServiceProps) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const intl = useIntl();
const serverUrl = useServerUrl();
const insets = useSafeAreaInsets();
const [loading, setLoading] = useState(true);
const [getTermsError, setGetTermError] = useState(false);
const [termsId, setTermsId] = useState('');
const [termsText, setTermsText] = useState('');
const getTerms = useCallback(async () => {
setLoading(true);
setGetTermError(false);
const {terms} = await fetchTermsOfService(serverUrl);
if (terms) {
setLoading(false);
setTermsId(terms.id);
setTermsText(terms.text);
} else {
setLoading(false);
setGetTermError(true);
}
}, [serverUrl]);
const closeTermsAndLogout = useCallback(async () => {
await navigateBack();
logout(serverUrl, intl, {logoutOnAlert: true});
}, [serverUrl, intl]);
const alertError = useCallback((retry: () => void) => {
Alert.alert(
siteName,
intl.formatMessage({
id: 'terms_of_service.api_error',
defaultMessage: 'Unable to complete the request. If this issue persists, contact your System Administrator.',
}),
[{
text: intl.formatMessage({id: 'terms_of_service.alert_cancel', defaultMessage: 'Cancel'}),
style: 'cancel',
onPress: closeTermsAndLogout,
}, {
text: intl.formatMessage({id: 'terms_of_service.alert_retry', defaultMessage: 'Try Again'}),
onPress: retry,
}],
);
}, [siteName, intl, closeTermsAndLogout]);
const alertDecline = useCallback(() => {
Alert.alert(
intl.formatMessage({
id: 'terms_of_service.terms_declined.title',
defaultMessage: 'You must accept the terms of service',
}),
intl.formatMessage({
id: 'terms_of_service.terms_declined.text',
defaultMessage: 'You must accept the terms of service to access this server. Please contact your system administrator for more details. You will now be logged out. Log in again to accept the terms of service.',
}),
[{
text: intl.formatMessage({id: 'terms_of_service.terms_declined.ok', defaultMessage: 'OK'}),
onPress: closeTermsAndLogout,
}],
);
}, [intl, closeTermsAndLogout]);
const acceptTerms = useCallback(async () => {
setLoading(true);
const {error} = await updateTermsOfServiceStatus(serverUrl, termsId, true);
if (error) {
alertError(acceptTerms);
}
}, [alertError, termsId, serverUrl]);
const declineTerms = useCallback(async () => {
setLoading(true);
const {error} = await updateTermsOfServiceStatus(serverUrl, termsId, false);
if (error) {
alertError(declineTerms);
} else {
alertDecline();
}
}, [serverUrl, termsId, alertError, alertDecline]);
const onPressClose = useCallback(async () => {
if (getTermsError) {
closeTermsAndLogout();
} else {
declineTerms();
}
}, [declineTerms, closeTermsAndLogout, getTermsError]);
useDidMount(() => {
getTerms();
});
useEffect(() => {
return () => {
NavigationStore.setToSOpen(false);
};
}, []);
useEffect(() => {
if (!showToS) {
navigateBack();
}
}, [showToS]);
useAndroidHardwareBackHandler(Screens.TERMS_OF_SERVICE, onPressClose);
let content;
if (loading) {
content = (
<View style={styles.loadingContainer}>
<Loading color={theme.centerChannelColor}/>
</View>
);
} else if (getTermsError) {
content = (
<>
<Text style={styles.errorTitle}>
{intl.formatMessage({id: 'terms_of_service.error.title', defaultMessage: 'Failed to get the ToS.'})}
</Text>
<Text style={styles.errorDescription}>
{intl.formatMessage({id: 'terms_of_service.error.description', defaultMessage: 'It was not possible to get the Terms of Service from the Server.'})}
</Text>
<View style={styles.buttonContainer}>
<Button
onPress={getTerms}
theme={theme}
text={intl.formatMessage({id: 'terms_of_service.error.retry', defaultMessage: 'Retry'})}
size={'lg'}
/>
<Button
onPress={onPressClose}
theme={theme}
text={intl.formatMessage({id: 'terms_of_service.error.logout', defaultMessage: 'Logout'})}
size={'lg'}
emphasis={'link'}
/>
</View>
</>
);
} else {
content = (
<>
<ScrollView
style={styles.scrollView}
>
<Markdown
baseTextStyle={styles.baseText}
value={termsText}
disableHashtags={true}
disableAtMentions={true}
disableChannelLink={true}
disableGallery={true}
location={Screens.TERMS_OF_SERVICE}
theme={theme}
/>
</ScrollView>
<Button
onPress={acceptTerms}
theme={theme}
text={intl.formatMessage({id: 'terms_of_service.acceptButton', defaultMessage: 'Accept'})}
size={'lg'}
testID='terms_of_service.accept.button'
/>
<Button
onPress={onPressClose}
theme={theme}
text={intl.formatMessage({id: 'terms_of_service.decline', defaultMessage: 'Decline'})}
size={'lg'}
emphasis={'link'}
testID='terms_of_service.decline.button'
/>
</>
);
}
const containerStyle = useMemo(() => {
return [{
paddingBottom: insets.bottom,
paddingLeft: insets.left,
paddingRight: insets.right,
paddingTop: insets.top,
}, styles.container];
}, [styles, insets]);
return (
<View
style={styles.root}
testID='terms_of_service.screen'
>
<View style={containerStyle}>
<View style={styles.wrapper}>
<Text
style={styles.title}
testID='terms_of_service.title'
>
{intl.formatMessage({id: 'terms_of_service.title', defaultMessage: 'Terms of Service'})}
</Text>
{content}
</View>
</View>
</View>
);
};
export default TermsOfService;