Utilisation de mutations plutôt que d'appels fetch directs
This commit is contained in:
parent
b0c17db233
commit
1c52ff5a10
@ -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 (
|
||||
<StoreProvider store={store}>
|
||||
<LoginProvider loginRedirect={'/login'}>
|
||||
<PaperProvider theme={colorScheme === 'dark' ? MD3DarkTheme : MD3LightTheme}>
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="login" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
</ThemeProvider>
|
||||
</PaperProvider>
|
||||
</LoginProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LoginProvider loginRedirect={'/login'}>
|
||||
<PaperProvider theme={colorScheme === 'dark' ? MD3DarkTheme : MD3LightTheme}>
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="login" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
</ThemeProvider>
|
||||
</PaperProvider>
|
||||
</LoginProvider>
|
||||
</QueryClientProvider>
|
||||
</StoreProvider>
|
||||
)
|
||||
}
|
||||
|
@ -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 (
|
||||
<Surface style={{ flex: 1 }}>
|
||||
<Appbar.Header>
|
||||
{isLoggedIn && router.canGoBack() ? <Appbar.BackAction onPress={() => router.back()} /> : undefined}
|
||||
<Appbar.Content title={"Connexion"} />
|
||||
{isLoggedIn ? <Appbar.Action icon={"logout"} onPress={logout} /> : undefined}
|
||||
{isLoggedIn ? <Appbar.Action icon={"logout"} onPress={authLogout} /> : undefined}
|
||||
</Appbar.Header>
|
||||
<TextInput
|
||||
ref={loginRef}
|
||||
label="Nom"
|
||||
value={name}
|
||||
onChangeText={(text) => setName(text)}
|
||||
onChangeText={setName}
|
||||
onSubmitEditing={() => passwordRef?.current.focus()}
|
||||
style={{ margin: 8 }} />
|
||||
<TextInput
|
||||
ref={passwordRef}
|
||||
label="Mot de passe"
|
||||
value={password}
|
||||
onChangeText={(text) => setPassword(text)}
|
||||
onSubmitEditing={login}
|
||||
onChangeText={setPassword}
|
||||
onSubmitEditing={() => loginMutation.mutate({ name, password })}
|
||||
secureTextEntry={true}
|
||||
style={{ margin: 8 }} />
|
||||
<Button
|
||||
key={loggingIn ? "disabledLoginButton" : "loginButton"}
|
||||
onPress={login}
|
||||
key={loginMutation.isPending ? "disabledLoginButton" : "loginButton"}
|
||||
onPress={() => loginMutation.mutate({ name, password })}
|
||||
mode={"contained"}
|
||||
icon="login"
|
||||
disabled={loggingIn}
|
||||
disabled={loginMutation.isPending}
|
||||
style={{ margin: 8 }}>
|
||||
Se connecter
|
||||
</Button>
|
||||
|
@ -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])
|
||||
|
49
client/hooks/mutations/useLoginMutation.ts
Normal file
49
client/hooks/mutations/useLoginMutation.ts
Normal 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 })
|
||||
}
|
||||
})
|
||||
}
|
94
client/package-lock.json
generated
94
client/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user