feat: integrate the api for authentication

This commit is contained in:
2026-08-18 16:44:57 +07:00
parent c4abd0a1f9
commit e71ce423e2
24 changed files with 471 additions and 207 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# Backend API URL (optional - leave blank to use mock auth in development)
# VITE_API_URL=https://api.your-domain.com
VITE_API_URL=http://localhost:8787
# Mock password for local development
VITE_MOCK_PASSWORD=admin123
+10
View File
@@ -5,6 +5,7 @@
"": {
"name": "s3-drive-storage-manage",
"dependencies": {
"@tanstack/react-query": "^5.101.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^19.2.8",
@@ -16,6 +17,7 @@
"@babel/core": "^7.29.7",
"@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query-devtools": "^5.101.4",
"@types/babel__core": "^7.20.5",
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
@@ -310,6 +312,14 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
"@tanstack/query-core": ["@tanstack/query-core@5.101.4", "", {}, "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw=="],
"@tanstack/query-devtools": ["@tanstack/query-devtools@5.101.4", "", {}, "sha512-z5IPHnDX3aUWeTWlRKLyooBQekaCAw4xRpZqPQ390RiWTDBcTynjpPT221BArw0u2+pnQMdGvPQI9YNNubBcmA=="],
"@tanstack/react-query": ["@tanstack/react-query@5.101.4", "", { "dependencies": { "@tanstack/query-core": "5.101.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA=="],
"@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.101.4", "", { "dependencies": { "@tanstack/query-devtools": "5.101.4" }, "peerDependencies": { "@tanstack/react-query": "^5.101.4", "react": "^18 || ^19" } }, "sha512-VeK2gtmfj7kvRBjtxS7TKxt/6qKhn8VzabY4UiYMr7NV9CddjSRYRgeYyld+NpjAkgMV9dd+2Qdr8ah5I03NeA=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
+2
View File
@@ -11,6 +11,7 @@
"deploy": "wrangler pages deploy dist"
},
"dependencies": {
"@tanstack/react-query": "^5.101.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^19.2.8",
@@ -22,6 +23,7 @@
"@babel/core": "^7.29.7",
"@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query-devtools": "^5.101.4",
"@types/babel__core": "^7.20.5",
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
+3 -3
View File
@@ -1,10 +1,10 @@
import { AuthProvider } from "@/contexts/AuthContext"
import { QueryProvider } from "@/providers/QueryProvider"
import { AppRouter } from "@/router"
export default function App() {
return (
<AuthProvider>
<QueryProvider>
<AppRouter />
</AuthProvider>
</QueryProvider>
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
import React from "react"
import { Navigate } from "react-router"
import { useAuth } from "@/hooks/useAuth"
import { useAuth } from "@/features/auth/hooks/useAuth"
import { Loader2 } from "lucide-react"
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
-54
View File
@@ -1,54 +0,0 @@
import React, { useState, useEffect } from "react"
import {
isAuthenticated as checkIsAuthenticated,
login as authLogin,
logout as authLogout,
type AuthResult,
} from "@/lib/auth"
import { AuthContext } from "./auth-context-def"
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false)
const [isLoading, setIsLoading] = useState<boolean>(true)
useEffect(() => {
// Check initial auth state from localStorage
setIsAuthenticated(checkIsAuthenticated())
setIsLoading(false)
}, [])
const signIn = async (password: string): Promise<AuthResult> => {
setIsLoading(true)
try {
const result = await authLogin(password)
if (result.success) {
setIsAuthenticated(true)
}
return result
} finally {
setIsLoading(false)
}
}
const signOut = () => {
authLogout()
setIsAuthenticated(false)
}
return (
<AuthContext.Provider
value={{
isAuthenticated,
isLoading,
signIn,
signOut,
}}
>
{children}
</AuthContext.Provider>
)
}
export type { AuthContextType } from "./auth-context-def"
export { AuthContext } from "./auth-context-def"
-11
View File
@@ -1,11 +0,0 @@
import { createContext } from "react"
import type { AuthResult } from "@/lib/auth"
export interface AuthContextType {
isAuthenticated: boolean
isLoading: boolean
signIn: (password: string) => Promise<AuthResult>
signOut: () => void
}
export const AuthContext = createContext<AuthContextType | undefined>(undefined)
+54
View File
@@ -0,0 +1,54 @@
import { apiRequest, ApiError, IS_MOCK_MODE } from "@/lib/api-client"
import { hashPassword } from "../lib/hash-password"
import { mockLogin, mockLogout, mockVerifySession } from "./auth.mock"
import type { LoginResponse, SessionResponse } from "./auth.types"
/**
* Perform login by hashing the password and validating it against the API (or mock).
*/
export async function login(password: string): Promise<LoginResponse> {
if (!password.trim()) {
throw new ApiError(400, "Password cannot be empty")
}
const hashedPassword = await hashPassword(password)
if (IS_MOCK_MODE) {
return mockLogin(hashedPassword)
}
return apiRequest<LoginResponse>("/auth/login", {
method: "POST",
body: { passwordHash: hashedPassword },
auth: false,
})
}
/**
* Fetch and verify the current session with the API (or mock).
*/
export async function fetchSession(signal?: AbortSignal): Promise<SessionResponse> {
if (IS_MOCK_MODE) {
await mockVerifySession()
return { valid: true }
}
return apiRequest<SessionResponse>("/auth/session", {
method: "GET",
signal,
})
}
/**
* Perform logout with the API (or mock).
*/
export async function logout(): Promise<void> {
if (IS_MOCK_MODE) {
await mockLogout()
return
}
await apiRequest<void>("/auth/logout", {
method: "POST",
})
}
+4
View File
@@ -0,0 +1,4 @@
export const authKeys = {
all: ["auth"] as const,
session: () => [...authKeys.all, "session"] as const,
}
@@ -1,4 +1,6 @@
import { hashPassword, setAuthToken, type AuthResult } from "./auth"
import { ApiError } from "@/lib/api-client"
import { hashPassword } from "../lib/hash-password"
import type { LoginResponse } from "./auth.types"
// Default mock password if not set in .env
const MOCK_ENV_PASSWORD = import.meta.env.VITE_MOCK_PASSWORD || "admin123"
@@ -7,7 +9,7 @@ const MOCK_ENV_PASSWORD = import.meta.env.VITE_MOCK_PASSWORD || "admin123"
* Mock login function for development when backend is not connected.
* Verifies the incoming password hash against the hash of VITE_MOCK_PASSWORD.
*/
export async function mockLogin(incomingPasswordHash: string): Promise<AuthResult> {
export async function mockLogin(incomingPasswordHash: string): Promise<LoginResponse> {
// Simulate network latency
await new Promise((resolve) => setTimeout(resolve, 600))
@@ -15,12 +17,22 @@ export async function mockLogin(incomingPasswordHash: string): Promise<AuthResul
if (incomingPasswordHash === expectedHash) {
const mockToken = `mock_token_${Date.now()}_${Math.random().toString(36).substring(2)}`
setAuthToken(mockToken)
return { success: true, token: mockToken }
return { token: mockToken, expiresIn: 86400 }
}
return {
success: false,
error: "Incorrect password. Please try again.",
throw new ApiError(401, "Incorrect password. Please try again.")
}
/**
* Mock session verification when backend is not connected.
*/
export async function mockVerifySession(): Promise<void> {
// No-op for mock
}
/**
* Mock logout when backend is not connected.
*/
export async function mockLogout(): Promise<void> {
// No-op for mock
}
+12
View File
@@ -0,0 +1,12 @@
export interface LoginResponse {
token: string
expiresIn: number
}
export interface SessionResponse {
valid: true
}
export interface ApiErrorBody {
message?: string
}
+25
View File
@@ -0,0 +1,25 @@
import { useAuthToken } from "@/lib/auth-storage"
import { useSession } from "./useSession"
import { useLogin } from "./useLogin"
import { useLogout } from "./useLogout"
/**
* Facade hook providing authentication status and actions.
*/
export function useAuth() {
const token = useAuthToken()
const session = useSession()
const loginMutation = useLogin()
const logoutMutation = useLogout()
return {
token,
isAuthenticated: session.data?.valid === true,
isLoading: session.isLoading,
signIn: loginMutation.mutateAsync,
signOut: logoutMutation.mutateAsync,
isSigningIn: loginMutation.isPending,
signInError: loginMutation.error,
resetSignInError: loginMutation.reset,
}
}
+20
View File
@@ -0,0 +1,20 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { setAuthToken } from "@/lib/auth-storage"
import { login } from "../api/auth.api"
import { authKeys } from "../api/auth.keys"
import type { LoginResponse } from "../api/auth.types"
/**
* Mutation hook for authenticating and logging in.
*/
export function useLogin() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (password: string) => login(password),
onSuccess: (data: LoginResponse) => {
setAuthToken(data.token)
queryClient.setQueryData(authKeys.session(), { valid: true })
},
})
}
+19
View File
@@ -0,0 +1,19 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { clearAuthToken } from "@/lib/auth-storage"
import { logout } from "../api/auth.api"
import { authKeys } from "../api/auth.keys"
/**
* Mutation hook for logging out and clearing credentials.
*/
export function useLogout() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => logout(),
onSettled: () => {
clearAuthToken()
queryClient.removeQueries({ queryKey: authKeys.all })
},
})
}
+25
View File
@@ -0,0 +1,25 @@
import { queryOptions, useQuery } from "@tanstack/react-query"
import { useAuthToken } from "@/lib/auth-storage"
import { fetchSession } from "../api/auth.api"
import { authKeys } from "../api/auth.keys"
/**
* Query options definition for session verification.
*/
export function sessionQueryOptions(token: string | null) {
return queryOptions({
queryKey: authKeys.session(),
queryFn: ({ signal }) => fetchSession(signal),
enabled: token !== null,
staleTime: 5 * 60_000,
retry: false,
})
}
/**
* Hook to retrieve and subscribe to the active session.
*/
export function useSession() {
const token = useAuthToken()
return useQuery(sessionQueryOptions(token))
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Hash a plain-text password to a SHA-256 hexadecimal string using the browser's Web Crypto API.
*/
export async function hashPassword(password: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(password)
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
}
-11
View File
@@ -1,11 +0,0 @@
import { useContext } from "react"
import { AuthContext, type AuthContextType } from "@/contexts/auth-context-def"
export function useAuth(): AuthContextType {
const context = useContext(AuthContext)
if (!context) {
throw new Error("useAuth must be used within an AuthProvider")
}
return context
}
+93
View File
@@ -0,0 +1,93 @@
import { getAuthToken } from "./auth-storage"
export const API_BASE_URL: string = import.meta.env.VITE_API_URL || ""
export const IS_MOCK_MODE: boolean = !API_BASE_URL
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.name = "ApiError"
this.status = status
}
}
export interface ApiRequestOptions extends Omit<RequestInit, "body"> {
body?: unknown
auth?: boolean
}
/**
* Type-safe fetch wrapper with error handling and automatic bearer authentication.
*/
export async function apiRequest<T>(
path: string,
options: ApiRequestOptions = {}
): Promise<T> {
const { body, auth = true, headers: customHeaders, ...restOptions } = options
const headers = new Headers(customHeaders)
if (auth) {
const token = getAuthToken()
if (token) {
headers.set("Authorization", `Bearer ${token}`)
}
}
let requestBody: BodyInit | null | undefined
if (body !== undefined) {
if (
typeof body === "string" ||
body instanceof FormData ||
body instanceof Blob ||
body instanceof ArrayBuffer ||
body instanceof URLSearchParams
) {
requestBody = body as BodyInit
} else {
headers.set("Content-Type", "application/json")
requestBody = JSON.stringify(body)
}
}
let response: Response
try {
response = await fetch(`${API_BASE_URL}${path}`, {
...restOptions,
headers,
body: requestBody,
})
} catch {
throw new ApiError(0, "Failed to connect to authentication service")
}
if (!response.ok) {
let errorMessage = "Request failed"
try {
const data = (await response.json()) as { message?: string }
if (data && typeof data.message === "string") {
errorMessage = data.message
}
} catch {
errorMessage = response.statusText || `Request failed with status ${response.status}`
}
throw new ApiError(response.status, errorMessage)
}
if (response.status === 204) {
return undefined as T
}
const text = await response.text()
if (!text) {
return undefined as T
}
try {
return JSON.parse(text) as T
} catch {
return text as unknown as T
}
}
+67
View File
@@ -0,0 +1,67 @@
import { useSyncExternalStore } from "react"
export const AUTH_STORAGE_KEY = "s3_drive_storage_auth_token"
const listeners = new Set<() => void>()
function emitChange(): void {
for (const listener of listeners) {
listener()
}
}
/**
* Retrieve the auth token from localStorage.
*/
export function getAuthToken(): string | null {
if (typeof window === "undefined") {
return null
}
return localStorage.getItem(AUTH_STORAGE_KEY)
}
/**
* Store the auth token in localStorage and notify listeners.
*/
export function setAuthToken(token: string): void {
localStorage.setItem(AUTH_STORAGE_KEY, token)
emitChange()
}
/**
* Clear the auth token from localStorage and notify listeners.
*/
export function clearAuthToken(): void {
localStorage.removeItem(AUTH_STORAGE_KEY)
emitChange()
}
function subscribe(listener: () => void): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
function getSnapshot(): string | null {
return getAuthToken()
}
function getServerSnapshot(): string | null {
return null
}
if (typeof window !== "undefined") {
window.addEventListener("storage", (event: StorageEvent) => {
if (event.key === AUTH_STORAGE_KEY) {
emitChange()
}
})
}
/**
* Hook to reactively subscribe to the auth token in localStorage.
*/
export function useAuthToken(): string | null {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}
-93
View File
@@ -1,93 +0,0 @@
import { mockLogin } from "./auth-mock"
export const AUTH_STORAGE_KEY = "s3_drive_storage_auth_token"
export const API_BASE_URL = import.meta.env.VITE_API_URL || ""
export interface AuthResult {
success: boolean
token?: string
error?: string
}
/**
* Hash a plain-text password to a SHA-256 hexadecimal string using the browser's Web Crypto API.
*/
export async function hashPassword(password: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(password)
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
}
/**
* Perform login by hashing the password and validating it against the API (or mock).
*/
export async function login(password: string): Promise<AuthResult> {
if (!password.trim()) {
return { success: false, error: "Password cannot be empty" }
}
const hashedPassword = await hashPassword(password)
// If no API_BASE_URL configured or in mock mode, use mock authentication
if (!API_BASE_URL) {
return mockLogin(hashedPassword)
}
try {
const response = await fetch(`${API_BASE_URL}/auth/verify`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ passwordHash: hashedPassword }),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
return {
success: false,
error: data.message || "Invalid password",
}
}
const data = await response.json()
const token = data.token || "authenticated"
setAuthToken(token)
return { success: true, token }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Failed to connect to authentication service",
}
}
}
/**
* Store the auth token in localStorage.
*/
export function setAuthToken(token: string): void {
localStorage.setItem(AUTH_STORAGE_KEY, token)
}
/**
* Retrieve the auth token from localStorage.
*/
export function getAuthToken(): string | null {
return localStorage.getItem(AUTH_STORAGE_KEY)
}
/**
* Clear the auth token from localStorage.
*/
export function logout(): void {
localStorage.removeItem(AUTH_STORAGE_KEY)
}
/**
* Check if the user is currently authenticated.
*/
export function isAuthenticated(): boolean {
return !!getAuthToken()
}
+41
View File
@@ -0,0 +1,41 @@
import { QueryClient, QueryCache } from "@tanstack/react-query"
import { ApiError } from "./api-client"
import { clearAuthToken } from "./auth-storage"
/**
* Factory function to create a new configured QueryClient instance.
*/
export function createQueryClient(): QueryClient {
let client: QueryClient
const queryCache = new QueryCache({
onError: (error) => {
if (error instanceof ApiError && error.status === 401) {
clearAuthToken()
client?.clear()
}
},
})
client = new QueryClient({
queryCache,
defaultOptions: {
queries: {
staleTime: 60_000,
gcTime: 5 * 60_000,
refetchOnWindowFocus: true,
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
return false
}
return failureCount < 2
},
},
mutations: {
retry: false,
},
},
})
return client
}
+3 -3
View File
@@ -16,7 +16,7 @@ import {
Cpu,
Sparkles,
} from "lucide-react"
import { useAuth } from "@/hooks/useAuth"
import { useAuth } from "@/features/auth/hooks/useAuth"
import { Button } from "@/components/ui/button"
import {
Card,
@@ -30,8 +30,8 @@ export default function Dashboard() {
const { signOut } = useAuth()
const navigate = useNavigate()
const handleSignOut = () => {
signOut()
const handleSignOut = async () => {
await signOut()
navigate("/sign-in", { replace: true })
}
+32 -21
View File
@@ -1,7 +1,8 @@
import React, { useState } from "react"
import { useNavigate, Navigate } from "react-router"
import { Eye, EyeOff, Lock, Database, ArrowRight, Loader2, AlertCircle, Sparkles, Shield, Cloud } from "lucide-react"
import { useAuth } from "@/hooks/useAuth"
import { useAuth } from "@/features/auth/hooks/useAuth"
import { ApiError } from "@/lib/api-client"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -14,13 +15,31 @@ import {
CardTitle,
} from "@/components/ui/card"
function getErrorMessage(error: Error | null): string | null {
if (!error) return null
if (error instanceof ApiError) {
if (error.status === 429) {
return "Too many failed attempts. Account is temporarily locked out. Please try again later."
}
if (error.status === 503) {
return "Authentication service is unavailable or DASHBOARD_PASSWORD is not configured on backend."
}
if (error.status === 401) {
return error.message || "Incorrect password. Please try again."
}
if (error.status === 0) {
return error.message || "Failed to connect to authentication service"
}
return error.message
}
return error.message || "An unexpected error occurred. Please try again."
}
export default function SignIn() {
const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const { signIn, isAuthenticated, isLoading } = useAuth()
const { signIn, isAuthenticated, isLoading, isSigningIn, signInError, resetSignInError } = useAuth()
const navigate = useNavigate()
// If already authenticated and not in loading state, redirect to dashboard
@@ -28,27 +47,19 @@ export default function SignIn() {
return <Navigate to="/" replace />
}
const error = getErrorMessage(signInError)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!password.trim()) {
setError("Please enter your password")
if (!password.trim() || isSigningIn) {
return
}
setError(null)
setSubmitting(true)
try {
const result = await signIn(password)
if (result.success) {
await signIn(password)
navigate("/", { replace: true })
} else {
setError(result.error || "Authentication failed")
}
} catch {
setError("An unexpected error occurred. Please try again.")
} finally {
setSubmitting(false)
// Error handled by mutation state
}
}
@@ -129,11 +140,11 @@ export default function SignIn() {
value={password}
onChange={(e) => {
setPassword(e.target.value)
if (error) setError(null)
if (signInError) resetSignInError()
}}
placeholder="Enter management password"
className="pl-10 pr-10 h-11 text-sm bg-background/80 border-border/80 rounded-xl focus-visible:ring-2 focus-visible:ring-blue-500/40 focus-visible:border-blue-500 transition-all"
disabled={submitting}
disabled={isSigningIn}
autoComplete="current-password"
autoFocus
/>
@@ -158,9 +169,9 @@ export default function SignIn() {
<Button
type="submit"
className="w-full h-11 font-semibold rounded-xl text-sm"
disabled={submitting || !password.trim()}
disabled={isSigningIn || !password.trim()}
>
{submitting ? (
{isSigningIn ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Verifying Credentials...
+28
View File
@@ -0,0 +1,28 @@
import React, { useState, lazy, Suspense } from "react"
import { QueryClientProvider } from "@tanstack/react-query"
import { createQueryClient } from "@/lib/query-client"
const ReactQueryDevtools = import.meta.env.DEV
? lazy(() =>
import("@tanstack/react-query-devtools").then((m) => ({
default: m.ReactQueryDevtools,
}))
)
: () => null
export interface QueryProviderProps {
children: React.ReactNode
}
export function QueryProvider({ children }: QueryProviderProps) {
const [queryClient] = useState(() => createQueryClient())
return (
<QueryClientProvider client={queryClient}>
{children}
<Suspense fallback={null}>
<ReactQueryDevtools initialIsOpen={false} />
</Suspense>
</QueryClientProvider>
)
}