Application Android et IOS pour l'amicale des élèves
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

LoginScreen.tsx 13KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. /*
  2. * Copyright (c) 2019 - 2020 Arnaud Vergnet.
  3. *
  4. * This file is part of Campus INSAT.
  5. *
  6. * Campus INSAT is free software: you can redistribute it and/or modify
  7. * it under the terms of the GNU General Public License as published by
  8. * the Free Software Foundation, either version 3 of the License, or
  9. * (at your option) any later version.
  10. *
  11. * Campus INSAT is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. * GNU General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU General Public License
  17. * along with Campus INSAT. If not, see <https://www.gnu.org/licenses/>.
  18. */
  19. import * as React from 'react';
  20. import {Image, KeyboardAvoidingView, StyleSheet, View} from 'react-native';
  21. import {
  22. Button,
  23. Card,
  24. HelperText,
  25. TextInput,
  26. withTheme,
  27. } from 'react-native-paper';
  28. import i18n from 'i18n-js';
  29. import {StackNavigationProp, StackScreenProps} from '@react-navigation/stack';
  30. import LinearGradient from 'react-native-linear-gradient';
  31. import ConnectionManager from '../../managers/ConnectionManager';
  32. import ErrorDialog from '../../components/Dialogs/ErrorDialog';
  33. import AsyncStorageManager from '../../managers/AsyncStorageManager';
  34. import AvailableWebsites from '../../constants/AvailableWebsites';
  35. import {MASCOT_STYLE} from '../../components/Mascot/Mascot';
  36. import MascotPopup from '../../components/Mascot/MascotPopup';
  37. import CollapsibleScrollView from '../../components/Collapsible/CollapsibleScrollView';
  38. import {MainStackParamsList} from '../../navigation/MainNavigator';
  39. type LoginScreenNavigationProp = StackScreenProps<MainStackParamsList, 'login'>;
  40. type Props = LoginScreenNavigationProp & {
  41. navigation: StackNavigationProp<any>;
  42. theme: ReactNativePaper.Theme;
  43. };
  44. type StateType = {
  45. email: string;
  46. password: string;
  47. isEmailValidated: boolean;
  48. isPasswordValidated: boolean;
  49. loading: boolean;
  50. dialogVisible: boolean;
  51. dialogError: number;
  52. mascotDialogVisible: boolean;
  53. };
  54. const ICON_AMICALE = require('../../../assets/amicale.png');
  55. const RESET_PASSWORD_PATH = 'https://www.amicale-insat.fr/password/reset';
  56. const emailRegex = /^.+@.+\..+$/;
  57. const styles = StyleSheet.create({
  58. container: {
  59. flex: 1,
  60. },
  61. card: {
  62. marginTop: 'auto',
  63. marginBottom: 'auto',
  64. },
  65. header: {
  66. fontSize: 36,
  67. marginBottom: 48,
  68. },
  69. textInput: {},
  70. btnContainer: {
  71. marginTop: 5,
  72. marginBottom: 10,
  73. },
  74. });
  75. class LoginScreen extends React.Component<Props, StateType> {
  76. onEmailChange: (value: string) => void;
  77. onPasswordChange: (value: string) => void;
  78. passwordInputRef: {
  79. // @ts-ignore
  80. current: null | TextInput;
  81. };
  82. nextScreen: string | null;
  83. constructor(props: Props) {
  84. super(props);
  85. this.nextScreen = null;
  86. this.passwordInputRef = React.createRef();
  87. this.onEmailChange = (value: string) => {
  88. this.onInputChange(true, value);
  89. };
  90. this.onPasswordChange = (value: string) => {
  91. this.onInputChange(false, value);
  92. };
  93. props.navigation.addListener('focus', this.onScreenFocus);
  94. this.state = {
  95. email: '',
  96. password: '',
  97. isEmailValidated: false,
  98. isPasswordValidated: false,
  99. loading: false,
  100. dialogVisible: false,
  101. dialogError: 0,
  102. mascotDialogVisible: AsyncStorageManager.getBool(
  103. AsyncStorageManager.PREFERENCES.loginShowMascot.key,
  104. ),
  105. };
  106. }
  107. onScreenFocus = () => {
  108. this.handleNavigationParams();
  109. };
  110. /**
  111. * Navigates to the Amicale website screen with the reset password link as navigation parameters
  112. */
  113. onResetPasswordClick = () => {
  114. const {navigation} = this.props;
  115. navigation.navigate('website', {
  116. host: AvailableWebsites.websites.AMICALE,
  117. path: RESET_PASSWORD_PATH,
  118. title: i18n.t('screens.websites.amicale'),
  119. });
  120. };
  121. /**
  122. * Called when the user input changes in the email or password field.
  123. * This saves the new value in the State and disabled input validation (to prevent errors to show while typing)
  124. *
  125. * @param isEmail True if the field is the email field
  126. * @param value The new field value
  127. */
  128. onInputChange(isEmail: boolean, value: string) {
  129. if (isEmail) {
  130. this.setState({
  131. email: value,
  132. isEmailValidated: false,
  133. });
  134. } else {
  135. this.setState({
  136. password: value,
  137. isPasswordValidated: false,
  138. });
  139. }
  140. }
  141. /**
  142. * Focuses the password field when the email field is done
  143. *
  144. * @returns {*}
  145. */
  146. onEmailSubmit = () => {
  147. if (this.passwordInputRef.current != null) {
  148. this.passwordInputRef.current.focus();
  149. }
  150. };
  151. /**
  152. * Called when the user clicks on login or finishes to type his password.
  153. *
  154. * Checks if we should allow the user to login,
  155. * then makes the login request and enters a loading state until the request finishes
  156. *
  157. */
  158. onSubmit = () => {
  159. const {email, password} = this.state;
  160. if (this.shouldEnableLogin()) {
  161. this.setState({loading: true});
  162. ConnectionManager.getInstance()
  163. .connect(email, password)
  164. .then(this.handleSuccess)
  165. .catch(this.showErrorDialog)
  166. .finally(() => {
  167. this.setState({loading: false});
  168. });
  169. }
  170. };
  171. /**
  172. * Gets the form input
  173. *
  174. * @returns {*}
  175. */
  176. getFormInput() {
  177. const {email, password} = this.state;
  178. return (
  179. <View>
  180. <TextInput
  181. label={i18n.t('screens.login.email')}
  182. mode="outlined"
  183. value={email}
  184. onChangeText={this.onEmailChange}
  185. onBlur={this.validateEmail}
  186. onSubmitEditing={this.onEmailSubmit}
  187. error={this.shouldShowEmailError()}
  188. textContentType="emailAddress"
  189. autoCapitalize="none"
  190. autoCompleteType="email"
  191. autoCorrect={false}
  192. keyboardType="email-address"
  193. returnKeyType="next"
  194. secureTextEntry={false}
  195. />
  196. <HelperText type="error" visible={this.shouldShowEmailError()}>
  197. {i18n.t('screens.login.emailError')}
  198. </HelperText>
  199. <TextInput
  200. ref={this.passwordInputRef}
  201. label={i18n.t('screens.login.password')}
  202. mode="outlined"
  203. value={password}
  204. onChangeText={this.onPasswordChange}
  205. onBlur={this.validatePassword}
  206. onSubmitEditing={this.onSubmit}
  207. error={this.shouldShowPasswordError()}
  208. textContentType="password"
  209. autoCapitalize="none"
  210. autoCompleteType="password"
  211. autoCorrect={false}
  212. keyboardType="default"
  213. returnKeyType="done"
  214. secureTextEntry
  215. />
  216. <HelperText type="error" visible={this.shouldShowPasswordError()}>
  217. {i18n.t('screens.login.passwordError')}
  218. </HelperText>
  219. </View>
  220. );
  221. }
  222. /**
  223. * Gets the card containing the input form
  224. * @returns {*}
  225. */
  226. getMainCard() {
  227. const {props, state} = this;
  228. return (
  229. <View style={styles.card}>
  230. <Card.Title
  231. title={i18n.t('screens.login.title')}
  232. titleStyle={{color: '#fff'}}
  233. subtitle={i18n.t('screens.login.subtitle')}
  234. subtitleStyle={{color: '#fff'}}
  235. left={({size}) => (
  236. <Image
  237. source={ICON_AMICALE}
  238. style={{
  239. width: size,
  240. height: size,
  241. }}
  242. />
  243. )}
  244. />
  245. <Card.Content>
  246. {this.getFormInput()}
  247. <Card.Actions style={{flexWrap: 'wrap'}}>
  248. <Button
  249. icon="lock-question"
  250. mode="contained"
  251. onPress={this.onResetPasswordClick}
  252. color={props.theme.colors.warning}
  253. style={{marginRight: 'auto', marginBottom: 20}}>
  254. {i18n.t('screens.login.resetPassword')}
  255. </Button>
  256. <Button
  257. icon="send"
  258. mode="contained"
  259. disabled={!this.shouldEnableLogin()}
  260. loading={state.loading}
  261. onPress={this.onSubmit}
  262. style={{marginLeft: 'auto'}}>
  263. {i18n.t('screens.login.title')}
  264. </Button>
  265. </Card.Actions>
  266. <Card.Actions>
  267. <Button
  268. icon="help-circle"
  269. mode="contained"
  270. onPress={this.showMascotDialog}
  271. style={{
  272. marginLeft: 'auto',
  273. marginRight: 'auto',
  274. }}>
  275. {i18n.t('screens.login.mascotDialog.title')}
  276. </Button>
  277. </Card.Actions>
  278. </Card.Content>
  279. </View>
  280. );
  281. }
  282. /**
  283. * The user has unfocused the input, his email is ready to be validated
  284. */
  285. validateEmail = () => {
  286. this.setState({isEmailValidated: true});
  287. };
  288. /**
  289. * The user has unfocused the input, his password is ready to be validated
  290. */
  291. validatePassword = () => {
  292. this.setState({isPasswordValidated: true});
  293. };
  294. hideMascotDialog = () => {
  295. AsyncStorageManager.set(
  296. AsyncStorageManager.PREFERENCES.loginShowMascot.key,
  297. false,
  298. );
  299. this.setState({mascotDialogVisible: false});
  300. };
  301. showMascotDialog = () => {
  302. this.setState({mascotDialogVisible: true});
  303. };
  304. /**
  305. * Shows an error dialog with the corresponding login error
  306. *
  307. * @param error The error given by the login request
  308. */
  309. showErrorDialog = (error: number) => {
  310. this.setState({
  311. dialogVisible: true,
  312. dialogError: error,
  313. });
  314. };
  315. hideErrorDialog = () => {
  316. this.setState({dialogVisible: false});
  317. };
  318. /**
  319. * Navigates to the screen specified in navigation parameters or simply go back tha stack.
  320. * Saves in user preferences to not show the login banner again.
  321. */
  322. handleSuccess = () => {
  323. const {navigation} = this.props;
  324. // Do not show the home login banner again
  325. AsyncStorageManager.set(
  326. AsyncStorageManager.PREFERENCES.homeShowMascot.key,
  327. false,
  328. );
  329. if (this.nextScreen == null) {
  330. navigation.goBack();
  331. } else {
  332. navigation.replace(this.nextScreen);
  333. }
  334. };
  335. /**
  336. * Saves the screen to navigate to after a successful login if one was provided in navigation parameters
  337. */
  338. handleNavigationParams() {
  339. this.nextScreen = this.props.route.params.nextScreen;
  340. }
  341. /**
  342. * Checks if the entered email is valid (matches the regex)
  343. *
  344. * @returns {boolean}
  345. */
  346. isEmailValid(): boolean {
  347. const {email} = this.state;
  348. return emailRegex.test(email);
  349. }
  350. /**
  351. * Checks if we should tell the user his email is invalid.
  352. * We should only show this if his email is invalid and has been checked when un-focusing the input
  353. *
  354. * @returns {boolean|boolean}
  355. */
  356. shouldShowEmailError(): boolean {
  357. const {isEmailValidated} = this.state;
  358. return isEmailValidated && !this.isEmailValid();
  359. }
  360. /**
  361. * Checks if the user has entered a password
  362. *
  363. * @returns {boolean}
  364. */
  365. isPasswordValid(): boolean {
  366. const {password} = this.state;
  367. return password !== '';
  368. }
  369. /**
  370. * Checks if we should tell the user his password is invalid.
  371. * We should only show this if his password is invalid and has been checked when un-focusing the input
  372. *
  373. * @returns {boolean|boolean}
  374. */
  375. shouldShowPasswordError(): boolean {
  376. const {isPasswordValidated} = this.state;
  377. return isPasswordValidated && !this.isPasswordValid();
  378. }
  379. /**
  380. * If the email and password are valid, and we are not loading a request, then the login button can be enabled
  381. *
  382. * @returns {boolean}
  383. */
  384. shouldEnableLogin(): boolean {
  385. const {loading} = this.state;
  386. return this.isEmailValid() && this.isPasswordValid() && !loading;
  387. }
  388. render() {
  389. const {mascotDialogVisible, dialogVisible, dialogError} = this.state;
  390. return (
  391. <LinearGradient
  392. style={{
  393. height: '100%',
  394. }}
  395. colors={['#9e0d18', '#530209']}
  396. start={{x: 0, y: 0.1}}
  397. end={{x: 0.1, y: 1}}>
  398. <KeyboardAvoidingView
  399. behavior="height"
  400. contentContainerStyle={styles.container}
  401. style={styles.container}
  402. enabled
  403. keyboardVerticalOffset={100}>
  404. <CollapsibleScrollView>
  405. <View style={{height: '100%'}}>{this.getMainCard()}</View>
  406. <MascotPopup
  407. visible={mascotDialogVisible}
  408. title={i18n.t('screens.login.mascotDialog.title')}
  409. message={i18n.t('screens.login.mascotDialog.message')}
  410. icon="help"
  411. buttons={{
  412. cancel: {
  413. message: i18n.t('screens.login.mascotDialog.button'),
  414. icon: 'check',
  415. onPress: this.hideMascotDialog,
  416. },
  417. }}
  418. emotion={MASCOT_STYLE.NORMAL}
  419. />
  420. <ErrorDialog
  421. visible={dialogVisible}
  422. onDismiss={this.hideErrorDialog}
  423. errorCode={dialogError}
  424. />
  425. </CollapsibleScrollView>
  426. </KeyboardAvoidingView>
  427. </LinearGradient>
  428. );
  429. }
  430. }
  431. export default withTheme(LoginScreen);