Working on refresh auth flow.
This commit is contained in:
parent
c35ef2f60b
commit
401b5bef43
44
package-lock.json
generated
44
package-lock.json
generated
@ -13,6 +13,7 @@
|
||||
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"@tanstack/react-query": "^5.45.1",
|
||||
"@tanstack/react-query-devtools": "^5.51.21",
|
||||
"@tanstack/react-router": "^1.38.1",
|
||||
"@tanstack/router-devtools": "^1.39.4",
|
||||
"react": "^18.2.0",
|
||||
@ -5864,20 +5865,32 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.45.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.45.0.tgz",
|
||||
"integrity": "sha512-RVfIZQmFUTdjhSAAblvueimfngYyfN6HlwaJUPK71PKd7yi43Vs1S/rdimmZedPWX/WGppcq/U1HOj7O7FwYxw==",
|
||||
"version": "5.51.21",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.51.21.tgz",
|
||||
"integrity": "sha512-POQxm42IUp6n89kKWF4IZi18v3fxQWFRolvBA6phNVmA8psdfB1MvDnGacCJdS+EOX12w/CyHM62z//rHmYmvw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-devtools": {
|
||||
"version": "5.51.16",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.51.16.tgz",
|
||||
"integrity": "sha512-ajwuq4WnkNCMj/Hy3KR8d3RtZ6PSKc1dD2vs2T408MdjgKzQ3klVoL6zDgVO7X+5jlb5zfgcO3thh4ojPhfIaw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.45.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.45.1.tgz",
|
||||
"integrity": "sha512-mYYfJujKg2kxmkRRjA6nn4YKG3ITsKuH22f1kteJ5IuVQqgKUgbaSQfYwVP0gBS05mhwxO03HVpD0t7BMN7WOA==",
|
||||
"version": "5.51.21",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.51.21.tgz",
|
||||
"integrity": "sha512-Q/V81x3sAYgCsxjwOkfLXfrmoG+FmDhLeHH5okC/Bp8Aaw2c33lbEo/mMcMnkxUPVtB2FLpzHT0tq3c+OlZEbw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.45.0"
|
||||
"@tanstack/query-core": "5.51.21"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@ -5887,6 +5900,23 @@
|
||||
"react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query-devtools": {
|
||||
"version": "5.51.21",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.51.21.tgz",
|
||||
"integrity": "sha512-mi5ef8dvsS48GsG6/8M60O2EgrzPK1kNPngOcHBTlIUrB5dGkxP9fuHf05GQRxtSp5W5GlyeUpzOmtkKNpf9dQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-devtools": "5.51.16"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "^5.51.21",
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-router": {
|
||||
"version": "1.39.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.39.4.tgz",
|
||||
|
@ -17,6 +17,7 @@
|
||||
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"@tanstack/react-query": "^5.45.1",
|
||||
"@tanstack/react-query-devtools": "^5.51.21",
|
||||
"@tanstack/react-router": "^1.38.1",
|
||||
"@tanstack/router-devtools": "^1.39.4",
|
||||
"react": "^18.2.0",
|
||||
|
73
src/AuthAwareQueryClientProvider.tsx
Normal file
73
src/AuthAwareQueryClientProvider.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import {
|
||||
QueryCache,
|
||||
QueryClient,
|
||||
QueryClientProvider
|
||||
} from '@tanstack/react-query'
|
||||
import React, { useState } from 'react'
|
||||
import ExpiredTokenError from './api/ExpiredTokenError'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { useAuth } from './auth'
|
||||
import refresh from './api/refresh'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
|
||||
const AuthAwareQueryClientProvider = ({
|
||||
children
|
||||
}: React.PropsWithChildren) => {
|
||||
const { putToken } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [currentlyRefreshing, setCurrentlyRefreshing] = useState(false)
|
||||
|
||||
const doRefresh = async () => {
|
||||
if (!currentlyRefreshing) {
|
||||
console.log('starting refresh')
|
||||
setCurrentlyRefreshing(true)
|
||||
const refreshResult = await refresh()
|
||||
if (refreshResult._tag === 'success') {
|
||||
console.log('refresh success, putting token...')
|
||||
putToken(
|
||||
refreshResult.loginView.accessToken,
|
||||
refreshResult.loginView.username
|
||||
)
|
||||
} else {
|
||||
console.error(`refresh failure: ${refreshResult.error}`)
|
||||
navigate({ to: '/login' }) // not working
|
||||
}
|
||||
setCurrentlyRefreshing(false)
|
||||
console.log('refresh done')
|
||||
}
|
||||
}
|
||||
|
||||
const [queryClient] = useState<QueryClient>(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retryDelay(failureCount, error) {
|
||||
if (error instanceof ExpiredTokenError) {
|
||||
return 0
|
||||
} else {
|
||||
return failureCount * 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
queryCache: new QueryCache({
|
||||
onError(error) {
|
||||
if (error instanceof ExpiredTokenError) {
|
||||
console.error(error.message)
|
||||
doRefresh()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools position="right" buttonPosition="top-right" />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthAwareQueryClientProvider
|
@ -1,7 +1,5 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { AuthContextType } from './auth'
|
||||
|
||||
export default interface RouterContext {
|
||||
auth: AuthContextType
|
||||
queryClient: QueryClient
|
||||
}
|
||||
|
10
src/api/ExpiredTokenError.ts
Normal file
10
src/api/ExpiredTokenError.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { ApiError } from './ApiError'
|
||||
|
||||
class ExpiredTokenError extends ApiError {
|
||||
constructor() {
|
||||
super(401, 'Expired Access Token (ExpiredTokenError)')
|
||||
Object.setPrototypeOf(this, ExpiredTokenError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
export default ExpiredTokenError
|
@ -1,4 +1,5 @@
|
||||
import { ApiError } from './ApiError'
|
||||
import ExpiredTokenError from './ExpiredTokenError'
|
||||
import { toImageView } from './types/ImageView'
|
||||
import RecipeInfosView, { RawRecipeInfosView } from './types/RecipeInfosView'
|
||||
|
||||
@ -58,6 +59,8 @@ const getRecipeInfos = async ({
|
||||
})
|
||||
)
|
||||
}
|
||||
} else if (response.status === 401) {
|
||||
throw new ExpiredTokenError()
|
||||
} else {
|
||||
throw new ApiError(response.status, response.statusText)
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import LoginView, { LoginResult } from './types/LoginView'
|
||||
import { LoginResult, RawLoginView } from './types/LoginView'
|
||||
|
||||
const login = async (
|
||||
username: string,
|
||||
@ -17,10 +17,18 @@ const login = async (
|
||||
}
|
||||
)
|
||||
if (response.ok) {
|
||||
const loginView = (await response.json()) as LoginView
|
||||
const {
|
||||
username,
|
||||
accessToken,
|
||||
expires: rawExpires
|
||||
} = (await response.json()) as RawLoginView
|
||||
return {
|
||||
_tag: 'success',
|
||||
loginView
|
||||
loginView: {
|
||||
username,
|
||||
accessToken,
|
||||
expires: new Date(rawExpires)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let error: string
|
||||
|
54
src/api/refresh.ts
Normal file
54
src/api/refresh.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { LoginResult, RawLoginView } from './types/LoginView'
|
||||
|
||||
const refresh = async (): Promise<LoginResult> => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
import.meta.env.VITE_MME_API_URL + '/auth/refresh',
|
||||
{
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
mode: 'cors'
|
||||
}
|
||||
)
|
||||
if (response.ok) {
|
||||
const {
|
||||
username,
|
||||
accessToken,
|
||||
expires: rawExpires
|
||||
} = (await response.json()) as RawLoginView
|
||||
return {
|
||||
_tag: 'success',
|
||||
loginView: {
|
||||
username,
|
||||
accessToken,
|
||||
expires: new Date(rawExpires)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let error: string
|
||||
if (response.status === 401) {
|
||||
error = 'Invalid username or password.'
|
||||
} else if (response.status === 500) {
|
||||
error =
|
||||
'There was an internal server error. Please try again later.'
|
||||
} else {
|
||||
error = 'Unknown error.'
|
||||
console.error(
|
||||
`Unknown error: ${response.status} ${response.statusText}`
|
||||
)
|
||||
}
|
||||
return {
|
||||
_tag: 'failure',
|
||||
error
|
||||
}
|
||||
}
|
||||
} catch (fetchError) {
|
||||
console.error(`Unknown fetch error: ${fetchError}`)
|
||||
return {
|
||||
_tag: 'failure',
|
||||
error: 'Network error. Please try again later.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default refresh
|
@ -9,9 +9,16 @@ export interface LoginFailure {
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface RawLoginView {
|
||||
username: string
|
||||
accessToken: string
|
||||
expires: string
|
||||
}
|
||||
|
||||
interface LoginView {
|
||||
username: string
|
||||
accessToken: string
|
||||
expires: Date
|
||||
}
|
||||
|
||||
export default LoginView
|
||||
|
38
src/main.tsx
38
src/main.tsx
@ -1,24 +1,20 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { AuthProvider, useAuth } from './auth'
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { fas } from '@fortawesome/free-solid-svg-icons'
|
||||
import { 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 './main.css'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
// Font-Awesome: load icons
|
||||
library.add(fas)
|
||||
|
||||
// Create queryClient
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
// Create router
|
||||
const router = createRouter({
|
||||
context: {
|
||||
auth: undefined!,
|
||||
queryClient
|
||||
auth: undefined!
|
||||
},
|
||||
routeTree
|
||||
})
|
||||
@ -32,15 +28,23 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
const InnerApp = () => {
|
||||
const auth = useAuth()
|
||||
return <RouterProvider router={router} context={{ auth }}></RouterProvider>
|
||||
return (
|
||||
<RouterProvider
|
||||
router={router}
|
||||
context={{ auth }}
|
||||
InnerWrap={({ children }) => (
|
||||
<AuthAwareQueryClientProvider>
|
||||
{children}
|
||||
</AuthAwareQueryClientProvider>
|
||||
)}
|
||||
></RouterProvider>
|
||||
)
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<InnerApp />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
<AuthProvider>
|
||||
<InnerApp />
|
||||
</AuthProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import getRecipeInfos from '../../api/getRecipeInfos'
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '../../auth'
|
||||
@ -12,16 +12,20 @@ const Recipes = () => {
|
||||
|
||||
const { token } = useAuth()
|
||||
|
||||
const { data, isPending, error } = useQuery({
|
||||
queryKey: ['recipeInfos'],
|
||||
queryFn: ({ signal }) =>
|
||||
getRecipeInfos({
|
||||
abortSignal: signal,
|
||||
pageNumber,
|
||||
pageSize,
|
||||
token
|
||||
})
|
||||
})
|
||||
const queryClient = useQueryClient()
|
||||
const { data, isPending, error } = useQuery(
|
||||
{
|
||||
queryKey: ['recipeInfos'],
|
||||
queryFn: ({ signal }) =>
|
||||
getRecipeInfos({
|
||||
abortSignal: signal,
|
||||
pageNumber,
|
||||
pageSize,
|
||||
token
|
||||
})
|
||||
},
|
||||
queryClient
|
||||
)
|
||||
|
||||
if (isPending) {
|
||||
return <p>Loading...</p>
|
||||
|
@ -1,55 +1,38 @@
|
||||
import {
|
||||
createFileRoute,
|
||||
ErrorComponent,
|
||||
useLoaderData,
|
||||
useParams
|
||||
} from '@tanstack/react-router'
|
||||
import { ApiError } from '../../api/ApiError'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { createFileRoute, useParams } from '@tanstack/react-router'
|
||||
import getRecipe from '../../api/getRecipe'
|
||||
import { useAuth } from '../../auth'
|
||||
import Recipe from '../../pages/recipe/Recipe'
|
||||
|
||||
export const Route = createFileRoute('/recipes/$username/$slug')({
|
||||
loader: ({ abortController, context, params }) =>
|
||||
context.queryClient.ensureQueryData({
|
||||
queryKey: ['recipe', params.username, params.slug],
|
||||
queryFn: () =>
|
||||
getRecipe({
|
||||
abortSignal: abortController.signal,
|
||||
authContext: context.auth,
|
||||
username: params.username,
|
||||
slug: params.slug
|
||||
})
|
||||
}),
|
||||
component() {
|
||||
const recipe = useLoaderData({
|
||||
from: '/recipes/$username/$slug'
|
||||
})
|
||||
return <Recipe {...{ recipe }} />
|
||||
},
|
||||
errorComponent({ error }) {
|
||||
if (error instanceof ApiError) {
|
||||
return (
|
||||
<p>
|
||||
{error.status}: {error.message}
|
||||
</p>
|
||||
)
|
||||
} else if (error instanceof Error) {
|
||||
return (
|
||||
<p>
|
||||
{error.name}: {error.message}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return <ErrorComponent error={error} />
|
||||
},
|
||||
notFoundComponent() {
|
||||
const { username, slug } = useParams({
|
||||
from: '/recipes/$username/$slug'
|
||||
})
|
||||
return (
|
||||
<p>
|
||||
404: Could not find a Recipe for {username}/{slug}
|
||||
</p>
|
||||
)
|
||||
const authContext = useAuth()
|
||||
const {
|
||||
isLoading,
|
||||
error,
|
||||
data: recipe
|
||||
} = useQuery({
|
||||
queryKey: ['recipe', username, slug],
|
||||
queryFn({ signal: abortSignal }) {
|
||||
return getRecipe({
|
||||
abortSignal,
|
||||
authContext,
|
||||
username,
|
||||
slug
|
||||
})
|
||||
}
|
||||
})
|
||||
if (isLoading) {
|
||||
return 'Loading...'
|
||||
} else if (error !== null) {
|
||||
return `Error: ${error.name}${error.message}`
|
||||
} else if (recipe !== undefined) {
|
||||
return <Recipe {...{ recipe }} />
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user