diff --git a/client/app/_layout.tsx b/client/app/_layout.tsx index fe93acd..9ca2ec0 100644 --- a/client/app/_layout.tsx +++ b/client/app/_layout.tsx @@ -1,33 +1,45 @@ import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native' -import { Stack } from "expo-router" -import { useColorScheme } from '@/hooks/useColorScheme' +import { Stack, useNavigationContainerRef } from 'expo-router' import { StatusBar } from 'expo-status-bar' import { Provider as StoreProvider } from 'react-redux' import { MD3DarkTheme, MD3LightTheme, PaperProvider } from 'react-native-paper' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useReactNavigationDevTools } from '@dev-plugins/react-navigation' +import { useReactQueryDevTools } from '@dev-plugins/react-query' +import { useColorScheme } from '@/hooks/useColorScheme' import store from '@/utils/store' import { useStartBackgroundFetchServiceEffect } from '@/utils/background' import { useStartGeolocationServiceEffect } from '@/utils/geolocation' import LoginProvider from '@/components/LoginProvider' +const queryClient = new QueryClient() + export default function RootLayout() { useStartGeolocationServiceEffect() useStartBackgroundFetchServiceEffect() const colorScheme = useColorScheme() + const navigationRef = useNavigationContainerRef() + useReactNavigationDevTools(navigationRef) + + useReactQueryDevTools(queryClient) + return ( - - - - - - - - - - - - + + + + + + + + + + + + + + ) } diff --git a/client/app/login.tsx b/client/app/login.tsx index f3a464c..7281b39 100644 --- a/client/app/login.tsx +++ b/client/app/login.tsx @@ -1,8 +1,8 @@ +import { useLoginMutation } from "@/hooks/mutations/useLoginMutation" import { useAuth, useAuthLogin, useAuthLogout } from "@/hooks/useAuth" -import * as SecureStore from "@/utils/SecureStore" +import { useMutation } from "@tanstack/react-query" import { useRouter } from "expo-router" import { useRef, useState } from "react" -import { Platform } from "react-native" import { Appbar, Button, Dialog, Portal, Surface, Text, TextInput } from "react-native-paper" export default function Login() { @@ -13,7 +13,6 @@ export default function Login() { const authLogout = useAuthLogout() const isLoggedIn = auth.loggedIn - const [loggingIn, setLoggingIn] = useState(false) const [name, setName] = useState(auth.name ?? "") const [password, setPassword] = useState("") const [errorDialogVisible, setErrorDialogVisible] = useState(false) @@ -25,80 +24,55 @@ export default function Login() { const hideErrorDialog = () => setErrorDialogVisible(false) - async function login() { - if (loggingIn) - return - setLoggingIn(true) - const resp = await fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/auth/login/`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: name, password: password }) - }) - .then(resp => resp.json()) - .catch(err => { + const loginMutation = useLoginMutation({ + authLogin, + onPostSuccess: () => { + if (router.canGoBack()) + router.back() + else + router.navigate('/') + }, + onError: ({ response, error }) => { setErrorDialogVisible(true) - setErrorTitle("Erreur") - setErrorText("Une erreur inconnue est survenue lors de la connexion. Veuillez réessayer plus tard. " + err) - setLoggingIn(false) - }) - if (!resp) - return - else if (resp.error) { - setErrorDialogVisible(true) - setErrorTitle(resp.error) - setErrorText(resp.message) - setLoggingIn(false) - return + if (response) { + setErrorTitle(response.error) + setErrorText(response.message) + } + else { + setErrorTitle("Erreur") + setErrorText(`Une erreur est survenue lors de la connexion : ${error}`) + } } - setLoggingIn(false) - authLogin({ name: name, token: resp.accessToken }) - SecureStore.setItem("apiName", name) - if (Platform.OS !== "web") { - // Le stockage navigateur n'est pas sûr, on évite de stocker un mot de passe à l'intérieur - SecureStore.setItem("apiPassword", password) - } - SecureStore.setItem("apiToken", resp.accessToken) - if (router.canGoBack()) - router.back() - else - router.navigate('/') - } - - async function logout() { - authLogout() - await SecureStore.deleteItemAsync("apiName") - await SecureStore.deleteItemAsync("apiPassword") - await SecureStore.deleteItemAsync("apiToken") - } + }) return ( {isLoggedIn && router.canGoBack() ? router.back()} /> : undefined} - {isLoggedIn ? : undefined} + {isLoggedIn ? : undefined} setName(text)} + onChangeText={setName} onSubmitEditing={() => passwordRef?.current.focus()} style={{ margin: 8 }} /> setPassword(text)} - onSubmitEditing={login} + onChangeText={setPassword} + onSubmitEditing={() => loginMutation.mutate({ name, password })} secureTextEntry={true} style={{ margin: 8 }} /> diff --git a/client/components/LoginProvider.tsx b/client/components/LoginProvider.tsx index 6d23c65..4b48f12 100644 --- a/client/components/LoginProvider.tsx +++ b/client/components/LoginProvider.tsx @@ -3,6 +3,7 @@ import { useRouteInfo } from 'expo-router/build/hooks' import { ReactNode, useEffect } from 'react' import { useAuth, useAuthLogin } from '@/hooks/useAuth' import * as SecureStore from '@/utils/SecureStore' +import { useLoginMutation } from '@/hooks/mutations/useLoginMutation' type Props = { loginRedirect: Href @@ -14,6 +15,15 @@ export default function LoginProvider({ loginRedirect, children }: Props) { const route = useRouteInfo() const auth = useAuth() const authLogin = useAuthLogin() + const loginMutation = useLoginMutation({ + authLogin, + onError: ({ response }) => { + if (response) + authLogin({ name: auth.name ?? "", password: null, token: null }) + else + authLogin({ name: auth.name ?? "", token: null }) + } + }) // Renouvellement auto du jeton d'authentification useEffect(() => { @@ -31,23 +41,8 @@ export default function LoginProvider({ loginRedirect, children }: Props) { } const timeout = setTimeout(async () => { const password = SecureStore.getItem('apiPassword') - if (password) { - await fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/auth/login/`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: name, password: password }) - }) - .then(resp => resp.json()) - .then(resp => { - if (!resp.error) - authLogin({ name: name, token: resp.accessToken }) - else - authLogin({ name: name, token: null }) - }) - } - else { - authLogin({ name: name, token: null }) - } + if (password) + loginMutation.mutate({ name, password }) }, waitTime) return () => clearTimeout(timeout) }, [auth]) diff --git a/client/hooks/mutations/useLoginMutation.ts b/client/hooks/mutations/useLoginMutation.ts new file mode 100644 index 0000000..3c04bbd --- /dev/null +++ b/client/hooks/mutations/useLoginMutation.ts @@ -0,0 +1,49 @@ +import { AuthPayload } from "@/utils/features/location/authSlice" +import { useMutation } from "@tanstack/react-query" + +type ErrorResponse = { + error: string + message: string + statusCode: number +} + +type LoginForm = { + name: string + password: string +} + +type onPostSuccessFunc = () => void +type ErrorFuncProps = { response?: ErrorResponse, error?: Error } +type onErrorFunc = (props: ErrorFuncProps) => void + +type LoginProps = { + authLogin: (payload: AuthPayload) => { payload: AuthPayload, type: "auth/login" } + onPostSuccess?: onPostSuccessFunc + onError?: onErrorFunc +} + +export const useLoginMutation = ({ authLogin, onPostSuccess, onError }: LoginProps) => { + return useMutation({ + mutationFn: ({ name, password }: LoginForm) => { + return fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/auth/login/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: name, password: password }) + }).then(resp => resp.json()) + }, + onSuccess: (data, { name, password }: LoginForm) => { + if (data.error) { + if (onError) + onError({ response: data }) + return + } + authLogin({ name: name, password: password, token: data.accessToken }) + if (onPostSuccess) + onPostSuccess() + }, + onError: (error: Error) => { + if (onError) + onError({ error: error }) + } + }) +} diff --git a/client/package-lock.json b/client/package-lock.json index 7ccf17c..b1fee79 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,12 +8,15 @@ "name": "traintrape-moi-client", "version": "1.0.0", "dependencies": { + "@dev-plugins/react-navigation": "^0.1.0", + "@dev-plugins/react-query": "^0.1.0", "@expo/vector-icons": "^14.0.2", "@maplibre/maplibre-react-native": "^10.0.0-alpha.28", "@pchmn/expo-material3-theme": "github:pchmn/expo-material3-theme", "@react-navigation/bottom-tabs": "^7.0.0", "@react-navigation/native": "^7.0.0", "@reduxjs/toolkit": "^2.4.0", + "@tanstack/react-query": "^5.62.7", "@turf/circle": "^7.1.0", "expo": "~52.0.11", "expo-background-fetch": "~13.0.3", @@ -2313,6 +2316,51 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@dev-plugins/react-navigation": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@dev-plugins/react-navigation/-/react-navigation-0.1.0.tgz", + "integrity": "sha512-eV6gkA/wH2E7LS6UfH5Kw1Qhk4zcMU3OMhQSqn4XCZn33HoT/Q2tcBHrUiiWcBVX9MwB9NyJB0hPnJ8lNAIQxg==", + "license": "MIT", + "dependencies": { + "@react-navigation/devtools": "^7.0.1", + "nanoid": "^5.0.8" + }, + "peerDependencies": { + "@react-navigation/core": "*", + "expo": "^52.0.0" + } + }, + "node_modules/@dev-plugins/react-navigation/node_modules/nanoid": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.9.tgz", + "integrity": "sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@dev-plugins/react-query": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@dev-plugins/react-query/-/react-query-0.1.0.tgz", + "integrity": "sha512-GZeF9+AHI98DjUuga6YCwbhlq2BpaQ3rGUc7o9weO4oEXNFF7rv2asWpMDdBa0BXvVZjdERSW7HrRx2JAWF+6w==", + "license": "MIT", + "dependencies": { + "flatted": "^3.3.1" + }, + "peerDependencies": { + "@tanstack/react-query": "*", + "expo": "^52.0.0" + } + }, "node_modules/@egjs/hammerjs": { "version": "2.0.17", "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", @@ -4207,6 +4255,20 @@ "react": ">= 18.2.0" } }, + "node_modules/@react-navigation/devtools": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/@react-navigation/devtools/-/devtools-7.0.14.tgz", + "integrity": "sha512-Pb80D3kO84wW/VezHXTks1Bpbk0O6XTEJSIyzanXC3h4clICq/sVQIcXjHbHrtQnNB0SJdhf6/LG9l+SRyatYw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "nanoid": "3.3.7", + "stacktrace-parser": "^0.1.10" + }, + "peerDependencies": { + "react": ">= 18.2.0" + } + }, "node_modules/@react-navigation/elements": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.2.0.tgz", @@ -4445,6 +4507,32 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.62.7", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.62.7.tgz", + "integrity": "sha512-fgpfmwatsrUal6V+8EC2cxZIQVl9xvL7qYa03gsdsCy985UTUlS4N+/3hCzwR0PclYDqisca2AqR1BVgJGpUDA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.62.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.62.7.tgz", + "integrity": "sha512-+xCtP4UAFDTlRTYyEjLx0sRtWyr5GIk7TZjZwBu4YaNahi3Rt2oMyRqfpfVrtwsqY2sayP4iXVCwmC+ZqqFmuw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.62.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -8381,6 +8469,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flatted": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", + "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", + "license": "ISC" + }, "node_modules/flow-enums-runtime": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", diff --git a/client/package.json b/client/package.json index cbc1aef..73e476b 100644 --- a/client/package.json +++ b/client/package.json @@ -14,12 +14,15 @@ "preset": "jest-expo" }, "dependencies": { + "@dev-plugins/react-navigation": "^0.1.0", + "@dev-plugins/react-query": "^0.1.0", "@expo/vector-icons": "^14.0.2", "@maplibre/maplibre-react-native": "^10.0.0-alpha.28", "@pchmn/expo-material3-theme": "github:pchmn/expo-material3-theme", "@react-navigation/bottom-tabs": "^7.0.0", "@react-navigation/native": "^7.0.0", "@reduxjs/toolkit": "^2.4.0", + "@tanstack/react-query": "^5.62.7", "@turf/circle": "^7.1.0", "expo": "~52.0.11", "expo-background-fetch": "~13.0.3", @@ -31,7 +34,6 @@ "expo-linking": "~7.0.3", "expo-location": "^18.0.2", "expo-notifications": "~0.29.11", - "expo-updates": "~0.26.10", "expo-router": "~4.0.9", "expo-secure-store": "~14.0.0", "expo-splash-screen": "~0.29.13", @@ -39,6 +41,7 @@ "expo-symbols": "~0.2.0", "expo-system-ui": "~4.0.4", "expo-task-manager": "^12.0.3", + "expo-updates": "~0.26.10", "expo-web-browser": "~14.0.1", "maplibre-gl": "^4.7.1", "maplibre-react-components": "^0.1.9", diff --git a/client/utils/features/location/authSlice.ts b/client/utils/features/location/authSlice.ts index 8f0028f..e8ce9c7 100644 --- a/client/utils/features/location/authSlice.ts +++ b/client/utils/features/location/authSlice.ts @@ -1,5 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import * as SecureStore from '@/utils/SecureStore' +import { Platform } from 'react-native' interface AuthState { loggedIn: boolean, @@ -9,6 +10,7 @@ interface AuthState { export interface AuthPayload { name: string, + password?: string | null, token: string | null, } @@ -26,12 +28,28 @@ export const authSlice = createSlice({ state.loggedIn = action.payload.token !== null state.name = action.payload.name state.token = action.payload.token - console.log(state) + + SecureStore.setItem('apiName', action.payload.name) + if (action.payload.password !== undefined && Platform.OS !== "web") { + // Le stockage navigateur n'est pas sûr, on évite de stocker un mot de passe à l'intérieur + if (action.payload.password) + SecureStore.setItem('apiPassword', action.payload.password) + else + SecureStore.deleteItemAsync('apiPassword') + } + if (action.payload.token) + SecureStore.setItem('apiToken', action.payload.token) + else + SecureStore.deleteItemAsync('apiToken') }, logout: (state) => { state.loggedIn = false state.name = null state.token = null + + SecureStore.deleteItemAsync('apiName') + SecureStore.deleteItemAsync('apiPassword') + SecureStore.deleteItemAsync('apiToken') } }, })