Utilisation de mutations plutôt que d'appels fetch directs

This commit is contained in:
Emmy D'Anello 2024-12-10 21:50:22 +01:00
parent b0c17db233
commit 1c52ff5a10
Signed by: ynerant
GPG Key ID: 3A75C55819C8CF85
7 changed files with 231 additions and 86 deletions

View File

@ -1,21 +1,32 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native' import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'
import { Stack } from "expo-router" import { Stack, useNavigationContainerRef } from 'expo-router'
import { useColorScheme } from '@/hooks/useColorScheme'
import { StatusBar } from 'expo-status-bar' import { StatusBar } from 'expo-status-bar'
import { Provider as StoreProvider } from 'react-redux' import { Provider as StoreProvider } from 'react-redux'
import { MD3DarkTheme, MD3LightTheme, PaperProvider } from 'react-native-paper' 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 store from '@/utils/store'
import { useStartBackgroundFetchServiceEffect } from '@/utils/background' import { useStartBackgroundFetchServiceEffect } from '@/utils/background'
import { useStartGeolocationServiceEffect } from '@/utils/geolocation' import { useStartGeolocationServiceEffect } from '@/utils/geolocation'
import LoginProvider from '@/components/LoginProvider' import LoginProvider from '@/components/LoginProvider'
const queryClient = new QueryClient()
export default function RootLayout() { export default function RootLayout() {
useStartGeolocationServiceEffect() useStartGeolocationServiceEffect()
useStartBackgroundFetchServiceEffect() useStartBackgroundFetchServiceEffect()
const colorScheme = useColorScheme() const colorScheme = useColorScheme()
const navigationRef = useNavigationContainerRef()
useReactNavigationDevTools(navigationRef)
useReactQueryDevTools(queryClient)
return ( return (
<StoreProvider store={store}> <StoreProvider store={store}>
<QueryClientProvider client={queryClient}>
<LoginProvider loginRedirect={'/login'}> <LoginProvider loginRedirect={'/login'}>
<PaperProvider theme={colorScheme === 'dark' ? MD3DarkTheme : MD3LightTheme}> <PaperProvider theme={colorScheme === 'dark' ? MD3DarkTheme : MD3LightTheme}>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> <ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
@ -28,6 +39,7 @@ export default function RootLayout() {
</ThemeProvider> </ThemeProvider>
</PaperProvider> </PaperProvider>
</LoginProvider> </LoginProvider>
</QueryClientProvider>
</StoreProvider> </StoreProvider>
) )
} }

View File

@ -1,8 +1,8 @@
import { useLoginMutation } from "@/hooks/mutations/useLoginMutation"
import { useAuth, useAuthLogin, useAuthLogout } from "@/hooks/useAuth" 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 { useRouter } from "expo-router"
import { useRef, useState } from "react" import { useRef, useState } from "react"
import { Platform } from "react-native"
import { Appbar, Button, Dialog, Portal, Surface, Text, TextInput } from "react-native-paper" import { Appbar, Button, Dialog, Portal, Surface, Text, TextInput } from "react-native-paper"
export default function Login() { export default function Login() {
@ -13,7 +13,6 @@ export default function Login() {
const authLogout = useAuthLogout() const authLogout = useAuthLogout()
const isLoggedIn = auth.loggedIn const isLoggedIn = auth.loggedIn
const [loggingIn, setLoggingIn] = useState(false)
const [name, setName] = useState(auth.name ?? "") const [name, setName] = useState(auth.name ?? "")
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const [errorDialogVisible, setErrorDialogVisible] = useState(false) const [errorDialogVisible, setErrorDialogVisible] = useState(false)
@ -25,80 +24,55 @@ export default function Login() {
const hideErrorDialog = () => setErrorDialogVisible(false) const hideErrorDialog = () => setErrorDialogVisible(false)
async function login() { const loginMutation = useLoginMutation({
if (loggingIn) authLogin,
return onPostSuccess: () => {
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 => {
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
}
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()) if (router.canGoBack())
router.back() router.back()
else else
router.navigate('/') router.navigate('/')
},
onError: ({ response, error }) => {
setErrorDialogVisible(true)
if (response) {
setErrorTitle(response.error)
setErrorText(response.message)
} }
else {
async function logout() { setErrorTitle("Erreur")
authLogout() setErrorText(`Une erreur est survenue lors de la connexion : ${error}`)
await SecureStore.deleteItemAsync("apiName")
await SecureStore.deleteItemAsync("apiPassword")
await SecureStore.deleteItemAsync("apiToken")
} }
}
})
return ( return (
<Surface style={{ flex: 1 }}> <Surface style={{ flex: 1 }}>
<Appbar.Header> <Appbar.Header>
{isLoggedIn && router.canGoBack() ? <Appbar.BackAction onPress={() => router.back()} /> : undefined} {isLoggedIn && router.canGoBack() ? <Appbar.BackAction onPress={() => router.back()} /> : undefined}
<Appbar.Content title={"Connexion"} /> <Appbar.Content title={"Connexion"} />
{isLoggedIn ? <Appbar.Action icon={"logout"} onPress={logout} /> : undefined} {isLoggedIn ? <Appbar.Action icon={"logout"} onPress={authLogout} /> : undefined}
</Appbar.Header> </Appbar.Header>
<TextInput <TextInput
ref={loginRef} ref={loginRef}
label="Nom" label="Nom"
value={name} value={name}
onChangeText={(text) => setName(text)} onChangeText={setName}
onSubmitEditing={() => passwordRef?.current.focus()} onSubmitEditing={() => passwordRef?.current.focus()}
style={{ margin: 8 }} /> style={{ margin: 8 }} />
<TextInput <TextInput
ref={passwordRef} ref={passwordRef}
label="Mot de passe" label="Mot de passe"
value={password} value={password}
onChangeText={(text) => setPassword(text)} onChangeText={setPassword}
onSubmitEditing={login} onSubmitEditing={() => loginMutation.mutate({ name, password })}
secureTextEntry={true} secureTextEntry={true}
style={{ margin: 8 }} /> style={{ margin: 8 }} />
<Button <Button
key={loggingIn ? "disabledLoginButton" : "loginButton"} key={loginMutation.isPending ? "disabledLoginButton" : "loginButton"}
onPress={login} onPress={() => loginMutation.mutate({ name, password })}
mode={"contained"} mode={"contained"}
icon="login" icon="login"
disabled={loggingIn} disabled={loginMutation.isPending}
style={{ margin: 8 }}> style={{ margin: 8 }}>
Se connecter Se connecter
</Button> </Button>

View File

@ -3,6 +3,7 @@ import { useRouteInfo } from 'expo-router/build/hooks'
import { ReactNode, useEffect } from 'react' import { ReactNode, useEffect } from 'react'
import { useAuth, useAuthLogin } from '@/hooks/useAuth' import { useAuth, useAuthLogin } from '@/hooks/useAuth'
import * as SecureStore from '@/utils/SecureStore' import * as SecureStore from '@/utils/SecureStore'
import { useLoginMutation } from '@/hooks/mutations/useLoginMutation'
type Props = { type Props = {
loginRedirect: Href loginRedirect: Href
@ -14,6 +15,15 @@ export default function LoginProvider({ loginRedirect, children }: Props) {
const route = useRouteInfo() const route = useRouteInfo()
const auth = useAuth() const auth = useAuth()
const authLogin = useAuthLogin() 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 // Renouvellement auto du jeton d'authentification
useEffect(() => { useEffect(() => {
@ -31,23 +41,8 @@ export default function LoginProvider({ loginRedirect, children }: Props) {
} }
const timeout = setTimeout(async () => { const timeout = setTimeout(async () => {
const password = SecureStore.getItem('apiPassword') const password = SecureStore.getItem('apiPassword')
if (password) { if (password)
await fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/auth/login/`, { loginMutation.mutate({ name, password })
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 })
}
}, waitTime) }, waitTime)
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
}, [auth]) }, [auth])

View File

@ -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 })
}
})
}

View File

@ -8,12 +8,15 @@
"name": "traintrape-moi-client", "name": "traintrape-moi-client",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@dev-plugins/react-navigation": "^0.1.0",
"@dev-plugins/react-query": "^0.1.0",
"@expo/vector-icons": "^14.0.2", "@expo/vector-icons": "^14.0.2",
"@maplibre/maplibre-react-native": "^10.0.0-alpha.28", "@maplibre/maplibre-react-native": "^10.0.0-alpha.28",
"@pchmn/expo-material3-theme": "github:pchmn/expo-material3-theme", "@pchmn/expo-material3-theme": "github:pchmn/expo-material3-theme",
"@react-navigation/bottom-tabs": "^7.0.0", "@react-navigation/bottom-tabs": "^7.0.0",
"@react-navigation/native": "^7.0.0", "@react-navigation/native": "^7.0.0",
"@reduxjs/toolkit": "^2.4.0", "@reduxjs/toolkit": "^2.4.0",
"@tanstack/react-query": "^5.62.7",
"@turf/circle": "^7.1.0", "@turf/circle": "^7.1.0",
"expo": "~52.0.11", "expo": "~52.0.11",
"expo-background-fetch": "~13.0.3", "expo-background-fetch": "~13.0.3",
@ -2313,6 +2316,51 @@
"@jridgewell/sourcemap-codec": "^1.4.10" "@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": { "node_modules/@egjs/hammerjs": {
"version": "2.0.17", "version": "2.0.17",
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
@ -4207,6 +4255,20 @@
"react": ">= 18.2.0" "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": { "node_modules/@react-navigation/elements": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.2.0.tgz", "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.2.0.tgz",
@ -4445,6 +4507,32 @@
"@sinonjs/commons": "^3.0.0" "@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": { "node_modules/@tootallnate/once": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
@ -8381,6 +8469,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/flow-enums-runtime": {
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz",

View File

@ -14,12 +14,15 @@
"preset": "jest-expo" "preset": "jest-expo"
}, },
"dependencies": { "dependencies": {
"@dev-plugins/react-navigation": "^0.1.0",
"@dev-plugins/react-query": "^0.1.0",
"@expo/vector-icons": "^14.0.2", "@expo/vector-icons": "^14.0.2",
"@maplibre/maplibre-react-native": "^10.0.0-alpha.28", "@maplibre/maplibre-react-native": "^10.0.0-alpha.28",
"@pchmn/expo-material3-theme": "github:pchmn/expo-material3-theme", "@pchmn/expo-material3-theme": "github:pchmn/expo-material3-theme",
"@react-navigation/bottom-tabs": "^7.0.0", "@react-navigation/bottom-tabs": "^7.0.0",
"@react-navigation/native": "^7.0.0", "@react-navigation/native": "^7.0.0",
"@reduxjs/toolkit": "^2.4.0", "@reduxjs/toolkit": "^2.4.0",
"@tanstack/react-query": "^5.62.7",
"@turf/circle": "^7.1.0", "@turf/circle": "^7.1.0",
"expo": "~52.0.11", "expo": "~52.0.11",
"expo-background-fetch": "~13.0.3", "expo-background-fetch": "~13.0.3",
@ -31,7 +34,6 @@
"expo-linking": "~7.0.3", "expo-linking": "~7.0.3",
"expo-location": "^18.0.2", "expo-location": "^18.0.2",
"expo-notifications": "~0.29.11", "expo-notifications": "~0.29.11",
"expo-updates": "~0.26.10",
"expo-router": "~4.0.9", "expo-router": "~4.0.9",
"expo-secure-store": "~14.0.0", "expo-secure-store": "~14.0.0",
"expo-splash-screen": "~0.29.13", "expo-splash-screen": "~0.29.13",
@ -39,6 +41,7 @@
"expo-symbols": "~0.2.0", "expo-symbols": "~0.2.0",
"expo-system-ui": "~4.0.4", "expo-system-ui": "~4.0.4",
"expo-task-manager": "^12.0.3", "expo-task-manager": "^12.0.3",
"expo-updates": "~0.26.10",
"expo-web-browser": "~14.0.1", "expo-web-browser": "~14.0.1",
"maplibre-gl": "^4.7.1", "maplibre-gl": "^4.7.1",
"maplibre-react-components": "^0.1.9", "maplibre-react-components": "^0.1.9",

View File

@ -1,5 +1,6 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import * as SecureStore from '@/utils/SecureStore' import * as SecureStore from '@/utils/SecureStore'
import { Platform } from 'react-native'
interface AuthState { interface AuthState {
loggedIn: boolean, loggedIn: boolean,
@ -9,6 +10,7 @@ interface AuthState {
export interface AuthPayload { export interface AuthPayload {
name: string, name: string,
password?: string | null,
token: string | null, token: string | null,
} }
@ -26,12 +28,28 @@ export const authSlice = createSlice({
state.loggedIn = action.payload.token !== null state.loggedIn = action.payload.token !== null
state.name = action.payload.name state.name = action.payload.name
state.token = action.payload.token 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) => { logout: (state) => {
state.loggedIn = false state.loggedIn = false
state.name = null state.name = null
state.token = null state.token = null
SecureStore.deleteItemAsync('apiName')
SecureStore.deleteItemAsync('apiPassword')
SecureStore.deleteItemAsync('apiToken')
} }
}, },
}) })