Major refactor of auth, refresh, and api calls.
This commit is contained in:
parent
c099275b31
commit
c54d3832a3
@ -1,93 +0,0 @@
|
||||
import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { useRouter } from '@tanstack/react-router'
|
||||
import React, { useCallback, useMemo, useRef } from 'react'
|
||||
import { ApiError } from './api/ApiError'
|
||||
import ExpiredTokenError from './api/ExpiredTokenError'
|
||||
import refresh, { RefreshTokenError } from './api/refresh'
|
||||
import { useAuth } from './auth'
|
||||
|
||||
const AuthAwareQueryClientProvider = ({ children }: React.PropsWithChildren) => {
|
||||
const { putToken } = useAuth()
|
||||
const router = useRouter()
|
||||
const refreshing = useRef(false)
|
||||
|
||||
const doRefresh = useCallback(async () => {
|
||||
putToken(null)
|
||||
if (!refreshing.current) {
|
||||
refreshing.current = true
|
||||
try {
|
||||
const { accessToken: token, expires, username } = await refresh()
|
||||
putToken({
|
||||
token,
|
||||
expires,
|
||||
username
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof RefreshTokenError) {
|
||||
router.navigate({
|
||||
to: '/login',
|
||||
search: {
|
||||
reason: error.reason,
|
||||
redirect: router.state.location.href
|
||||
}
|
||||
})
|
||||
} else if (error instanceof ApiError) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
refreshing.current = false
|
||||
}
|
||||
}, [putToken, router])
|
||||
|
||||
const queryClient = useMemo(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
mutations: {
|
||||
onError(error) {
|
||||
if (error instanceof ExpiredTokenError) {
|
||||
doRefresh()
|
||||
}
|
||||
}
|
||||
},
|
||||
queries: {
|
||||
retry(failureCount, error) {
|
||||
if (
|
||||
error instanceof ExpiredTokenError ||
|
||||
(error instanceof ApiError && error.status === 404)
|
||||
) {
|
||||
return false
|
||||
} else {
|
||||
return failureCount <= 3
|
||||
}
|
||||
},
|
||||
retryDelay(failureCount, error) {
|
||||
if (error instanceof ExpiredTokenError) {
|
||||
return 0
|
||||
} else {
|
||||
return failureCount * 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
queryCache: new QueryCache({
|
||||
onError(error) {
|
||||
if (error instanceof ExpiredTokenError) {
|
||||
doRefresh()
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
[doRefresh]
|
||||
)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools position="right" buttonPosition="top-right" />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthAwareQueryClientProvider
|
@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, useReducer } from 'react'
|
||||
import { createContext, PropsWithChildren, useContext, useReducer } from 'react'
|
||||
import AccessToken from './types/AccessToken'
|
||||
|
||||
export interface AuthContextType {
|
||||
@ -36,7 +36,7 @@ const authReducer = (_state: AuthReducerState, action: AuthReducerAction): AuthR
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null)
|
||||
|
||||
export const AuthProvider = ({ children }: React.PropsWithChildren) => {
|
||||
export const AuthProvider = ({ children }: PropsWithChildren) => {
|
||||
const [state, dispatch] = useReducer(authReducer, initialState)
|
||||
|
||||
return (
|
73
src/RefreshProvider.tsx
Normal file
73
src/RefreshProvider.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { useRouter } from '@tanstack/react-router'
|
||||
import { createContext, useContext, PropsWithChildren, useRef, useCallback } from 'react'
|
||||
import { ApiError } from './api/ApiError'
|
||||
import refresh, { RefreshTokenError } from './api/refresh'
|
||||
import { useAuth } from './AuthProvider'
|
||||
import Refresh from './types/Refresh'
|
||||
|
||||
export interface RefreshContextType {
|
||||
refresh: Refresh
|
||||
}
|
||||
|
||||
const RefreshContext = createContext<RefreshContextType | null>(null)
|
||||
|
||||
export const useRefresh = () => {
|
||||
const refreshContext = useContext(RefreshContext)
|
||||
if (refreshContext === null) {
|
||||
throw new Error('refreshContext is null')
|
||||
}
|
||||
return refreshContext.refresh
|
||||
}
|
||||
|
||||
const RefreshProvider = ({ children }: PropsWithChildren) => {
|
||||
const { putToken } = useAuth()
|
||||
const router = useRouter()
|
||||
const refreshing = useRef(false)
|
||||
|
||||
const doRefresh: Refresh = useCallback(async () => {
|
||||
putToken(null)
|
||||
if (!refreshing.current) {
|
||||
refreshing.current = true
|
||||
try {
|
||||
const { accessToken: token, expires, username } = await refresh()
|
||||
putToken({
|
||||
token,
|
||||
expires,
|
||||
username
|
||||
})
|
||||
refreshing.current = false
|
||||
return {
|
||||
token,
|
||||
expires,
|
||||
username
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof RefreshTokenError) {
|
||||
router.navigate({
|
||||
to: '/login',
|
||||
search: {
|
||||
reason: error.reason,
|
||||
redirect: router.state.location.href
|
||||
}
|
||||
})
|
||||
} else if (error instanceof ApiError) {
|
||||
console.error(error)
|
||||
}
|
||||
refreshing.current = false
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [putToken, router])
|
||||
|
||||
return (
|
||||
<RefreshContext.Provider
|
||||
value={{
|
||||
refresh: doRefresh
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RefreshContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default RefreshProvider
|
@ -1,4 +1,4 @@
|
||||
import { AuthContextType } from './auth'
|
||||
import { AuthContextType } from './AuthProvider'
|
||||
|
||||
export default interface RouterContext {
|
||||
auth: AuthContextType
|
||||
|
62
src/api/apiCallFactory.ts
Normal file
62
src/api/apiCallFactory.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import AccessToken from '../types/AccessToken'
|
||||
import Refresh from '../types/Refresh'
|
||||
import { ApiError } from './ApiError'
|
||||
|
||||
export interface ApiCallDeps {
|
||||
accessToken: AccessToken | null
|
||||
endpoint: string
|
||||
query?: string
|
||||
refresh: Refresh
|
||||
signal: AbortSignal
|
||||
body?: any
|
||||
}
|
||||
|
||||
export type Method = 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
|
||||
const getApiCallFactory =
|
||||
(method: Method) =>
|
||||
<T>(handleBody?: (raw: any) => T) =>
|
||||
async ({ accessToken, endpoint, refresh, signal, body }: ApiCallDeps): Promise<T> => {
|
||||
const headers = new Headers()
|
||||
if (accessToken) {
|
||||
headers.set('Authorization', `Bearer ${accessToken.token}`)
|
||||
}
|
||||
if (body) {
|
||||
headers.set('Content-type', 'application/json')
|
||||
}
|
||||
const url = import.meta.env.VITE_MME_API_URL + endpoint
|
||||
const response = await fetch(url, {
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal,
|
||||
headers,
|
||||
mode: 'cors',
|
||||
method
|
||||
})
|
||||
if (response.ok && handleBody) {
|
||||
return handleBody(await response.json())
|
||||
} else if (response.status === 401) {
|
||||
const newToken = await refresh()
|
||||
if (newToken === null) {
|
||||
throw new ApiError(401, 'Could not get a refreshed access token.')
|
||||
}
|
||||
headers.set('Authorization', `Bearer ${newToken.token}`)
|
||||
const retry = await fetch(url, {
|
||||
signal,
|
||||
headers,
|
||||
mode: 'cors',
|
||||
method
|
||||
})
|
||||
if (retry.ok && handleBody) {
|
||||
return handleBody(await retry.json())
|
||||
} else {
|
||||
throw new ApiError(retry.status, retry.statusText)
|
||||
}
|
||||
} else {
|
||||
throw new ApiError(response.status, response.statusText)
|
||||
}
|
||||
}
|
||||
|
||||
export const getCallFactory = getApiCallFactory('GET')
|
||||
export const postCallFactory = getApiCallFactory('POST')
|
||||
export const putCallFactory = getApiCallFactory('PUT')
|
||||
export const deleteCallFactory = getApiCallFactory('DELETE')
|
@ -1,72 +1,24 @@
|
||||
import AccessToken from '../types/AccessToken'
|
||||
import { ApiError } from './ApiError'
|
||||
import ExpiredTokenError from './ExpiredTokenError'
|
||||
import { toImageView } from './types/ImageView'
|
||||
import RecipeInfosView, { RawRecipeInfosView } from './types/RecipeInfosView'
|
||||
import { addBearer } from './util'
|
||||
import Refresh from '../types/Refresh'
|
||||
import { getCallFactory } from './apiCallFactory'
|
||||
import { toRecipeInfosView } from './types/RecipeInfosView'
|
||||
|
||||
export interface GetRecipeInfosDeps {
|
||||
abortSignal: AbortSignal
|
||||
accessToken: AccessToken | null
|
||||
pageNumber: number
|
||||
pageSize: number
|
||||
refresh: Refresh
|
||||
signal: AbortSignal
|
||||
}
|
||||
|
||||
const getRecipeInfos = async ({
|
||||
abortSignal,
|
||||
const doGetRecipeInfos = getCallFactory(toRecipeInfosView)
|
||||
|
||||
const getRecipeInfos = ({ accessToken, pageNumber, pageSize, refresh, signal }: GetRecipeInfosDeps) =>
|
||||
doGetRecipeInfos({
|
||||
accessToken,
|
||||
pageNumber,
|
||||
pageSize
|
||||
}: GetRecipeInfosDeps): Promise<RecipeInfosView> => {
|
||||
const headers = new Headers()
|
||||
if (accessToken !== null) {
|
||||
addBearer(headers, accessToken)
|
||||
}
|
||||
const response = await fetch(import.meta.env.VITE_MME_API_URL + `/recipes?page=${pageNumber}&size=${pageSize}`, {
|
||||
signal: abortSignal,
|
||||
headers,
|
||||
mode: 'cors'
|
||||
endpoint: `/recipes?page=${pageNumber}&size=${pageSize}`,
|
||||
refresh,
|
||||
signal
|
||||
})
|
||||
if (response.ok) {
|
||||
const { pageNumber, pageSize, content } = (await response.json()) as RawRecipeInfosView
|
||||
return {
|
||||
pageNumber,
|
||||
pageSize,
|
||||
content: content.map(
|
||||
({
|
||||
id,
|
||||
updated: rawUpdated,
|
||||
title,
|
||||
preparationTime,
|
||||
cookingTime,
|
||||
totalTime,
|
||||
ownerId,
|
||||
owner,
|
||||
isPublic,
|
||||
starCount,
|
||||
mainImage: rawMainImage,
|
||||
slug
|
||||
}) => ({
|
||||
id,
|
||||
updated: new Date(rawUpdated),
|
||||
title,
|
||||
preparationTime,
|
||||
cookingTime,
|
||||
totalTime,
|
||||
ownerId,
|
||||
owner,
|
||||
isPublic,
|
||||
starCount,
|
||||
mainImage: rawMainImage !== null ? toImageView(rawMainImage) : null,
|
||||
slug
|
||||
})
|
||||
)
|
||||
}
|
||||
} else if (response.status === 401) {
|
||||
throw new ExpiredTokenError()
|
||||
} else {
|
||||
throw new ApiError(response.status, response.statusText)
|
||||
}
|
||||
}
|
||||
|
||||
export default getRecipeInfos
|
||||
|
@ -1,14 +1,14 @@
|
||||
import ImageView, { RawImageView } from './ImageView'
|
||||
import ImageView, { RawImageView, toImageView } from './ImageView'
|
||||
import UserInfoView from './UserInfoView'
|
||||
|
||||
export interface RawRecipeInfoView {
|
||||
id: number
|
||||
updated: string
|
||||
created: string
|
||||
modified: string | null
|
||||
title: string
|
||||
preparationTime: number
|
||||
cookingTime: number
|
||||
totalTime: number
|
||||
ownerId: number
|
||||
owner: UserInfoView
|
||||
isPublic: boolean
|
||||
starCount: number
|
||||
@ -18,7 +18,8 @@ export interface RawRecipeInfoView {
|
||||
|
||||
interface RecipeInfoView {
|
||||
id: number
|
||||
updated: Date
|
||||
created: Date
|
||||
modified: Date | null
|
||||
title: string
|
||||
preparationTime: number
|
||||
cookingTime: number
|
||||
@ -30,4 +31,32 @@ interface RecipeInfoView {
|
||||
slug: string
|
||||
}
|
||||
|
||||
export const toRecipeInfoView = ({
|
||||
id,
|
||||
created: rawCreated,
|
||||
modified: rawModified,
|
||||
title,
|
||||
preparationTime,
|
||||
cookingTime,
|
||||
totalTime,
|
||||
owner,
|
||||
isPublic,
|
||||
starCount,
|
||||
mainImage: rawMainImage,
|
||||
slug
|
||||
}: RawRecipeInfoView): RecipeInfoView => ({
|
||||
id,
|
||||
created: new Date(rawCreated),
|
||||
modified: rawModified !== null ? new Date(rawModified) : null,
|
||||
title,
|
||||
preparationTime,
|
||||
cookingTime,
|
||||
totalTime,
|
||||
owner,
|
||||
isPublic,
|
||||
starCount,
|
||||
mainImage: rawMainImage !== null ? toImageView(rawMainImage) : null,
|
||||
slug
|
||||
})
|
||||
|
||||
export default RecipeInfoView
|
||||
|
@ -1,4 +1,4 @@
|
||||
import RecipeInfoView, { RawRecipeInfoView } from './RecipeInfoView'
|
||||
import RecipeInfoView, { RawRecipeInfoView, toRecipeInfoView } from './RecipeInfoView'
|
||||
|
||||
export interface RawRecipeInfosView {
|
||||
pageNumber: number
|
||||
@ -12,4 +12,10 @@ interface RecipeInfosView {
|
||||
content: RecipeInfoView[]
|
||||
}
|
||||
|
||||
export const toRecipeInfosView = ({ pageNumber, pageSize, content }: RawRecipeInfosView): RecipeInfosView => ({
|
||||
pageNumber,
|
||||
pageSize,
|
||||
content: content.map(toRecipeInfoView)
|
||||
})
|
||||
|
||||
export default RecipeInfosView
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { useLocation, useNavigate } from '@tanstack/react-router'
|
||||
import { useAuth } from '../../auth'
|
||||
import { useAuth } from '../../AuthProvider'
|
||||
import classes from './header.module.css'
|
||||
|
||||
export interface HeaderProps {
|
||||
|
38
src/main.tsx
38
src/main.tsx
@ -1,12 +1,16 @@
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { fas } from '@fortawesome/free-solid-svg-icons'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { Router, RouterProvider, createRouter } from '@tanstack/react-router'
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { AuthProvider, useAuth } from './auth'
|
||||
import AuthAwareQueryClientProvider from './AuthAwareQueryClientProvider'
|
||||
import { ApiError } from './api/ApiError'
|
||||
import ExpiredTokenError from './api/ExpiredTokenError'
|
||||
import { AuthProvider, useAuth } from './AuthProvider'
|
||||
import './main.css'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import RefreshProvider from './RefreshProvider'
|
||||
|
||||
// Font-Awesome: load icons
|
||||
library.add(fas)
|
||||
@ -27,13 +31,41 @@ declare module '@tanstack/react-router' {
|
||||
}
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry(failureCount, error) {
|
||||
if (error instanceof ExpiredTokenError || (error instanceof ApiError && error.status === 404)) {
|
||||
return false
|
||||
} else {
|
||||
return failureCount <= 3
|
||||
}
|
||||
},
|
||||
retryDelay(failureCount, error) {
|
||||
if (error instanceof ExpiredTokenError) {
|
||||
return 0
|
||||
} else {
|
||||
return failureCount * 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const InnerApp = () => {
|
||||
const auth = useAuth()
|
||||
return (
|
||||
<RouterProvider
|
||||
router={router}
|
||||
context={{ auth }}
|
||||
InnerWrap={({ children }) => <AuthAwareQueryClientProvider>{children}</AuthAwareQueryClientProvider>}
|
||||
InnerWrap={({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RefreshProvider>
|
||||
{children}
|
||||
<ReactQueryDevtools position="right" buttonPosition="top-right" />
|
||||
</RefreshProvider>
|
||||
</QueryClientProvider>
|
||||
)}
|
||||
></RouterProvider>
|
||||
)
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import { ApiError } from '../../api/ApiError'
|
||||
import getRecipe from '../../api/getRecipe'
|
||||
import UpdateRecipeSpec, { fromFullRecipeView } from '../../api/types/UpdateRecipeSpec'
|
||||
import updateRecipe from '../../api/updateRecipe'
|
||||
import { useAuth } from '../../auth'
|
||||
import { useAuth } from '../../AuthProvider'
|
||||
import classes from './edit-recipe.module.css'
|
||||
|
||||
interface ControlProps {
|
||||
|
@ -6,7 +6,7 @@ import getImage from '../../api/getImage'
|
||||
import getRecipe from '../../api/getRecipe'
|
||||
import removeStar from '../../api/removeStar'
|
||||
import GetRecipeView from '../../api/types/GetRecipeView'
|
||||
import { useAuth } from '../../auth'
|
||||
import { useAuth } from '../../AuthProvider'
|
||||
import RecipeVisibilityIcon from '../../components/recipe-visibility-icon/RecipeVisibilityIcon'
|
||||
import UserIconAndName from '../../components/user-icon-and-name/UserIconAndName'
|
||||
import classes from './recipe.module.css'
|
||||
|
@ -3,15 +3,17 @@ import { useState } from 'react'
|
||||
import { ApiError } from '../../api/ApiError'
|
||||
import getImage from '../../api/getImage'
|
||||
import getRecipeInfos from '../../api/getRecipeInfos'
|
||||
import { useAuth } from '../../auth'
|
||||
import { useAuth } from '../../AuthProvider'
|
||||
import RecipeCard from '../../components/recipe-card/RecipeCard'
|
||||
import classes from './recipes.module.css'
|
||||
import { useRefresh } from '../../RefreshProvider'
|
||||
|
||||
const Recipes = () => {
|
||||
const [pageNumber, setPageNumber] = useState(0)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
const { accessToken } = useAuth()
|
||||
const refresh = useRefresh()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { data, isPending, error } = useQuery(
|
||||
@ -19,10 +21,11 @@ const Recipes = () => {
|
||||
queryKey: ['recipeInfos'],
|
||||
queryFn: ({ signal }) =>
|
||||
getRecipeInfos({
|
||||
abortSignal: signal,
|
||||
accessToken,
|
||||
pageNumber,
|
||||
pageSize,
|
||||
accessToken
|
||||
refresh,
|
||||
signal
|
||||
})
|
||||
},
|
||||
queryClient
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
|
||||
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
|
||||
import RouterContext from '../RouterContext'
|
||||
import { useAuth } from '../auth'
|
||||
import { useAuth } from '../AuthProvider'
|
||||
import Footer from '../components/footer/Footer'
|
||||
import Header from '../components/header/Header'
|
||||
import classes from './__root.module.css'
|
||||
|
@ -2,7 +2,7 @@ import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import login from '../api/login'
|
||||
import { useAuth } from '../auth'
|
||||
import { useAuth } from '../AuthProvider'
|
||||
|
||||
const Login = () => {
|
||||
const { putToken } = useAuth()
|
||||
|
7
src/types/Refresh.ts
Normal file
7
src/types/Refresh.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import AccessToken from './AccessToken'
|
||||
|
||||
interface Refresh {
|
||||
(): Promise<AccessToken | null>
|
||||
}
|
||||
|
||||
export default Refresh
|
Loading…
Reference in New Issue
Block a user