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/free-solid-svg-icons": "^6.6.0",
|
||||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||||
"@tanstack/react-query": "^5.45.1",
|
"@tanstack/react-query": "^5.45.1",
|
||||||
|
"@tanstack/react-query-devtools": "^5.51.21",
|
||||||
"@tanstack/react-router": "^1.38.1",
|
"@tanstack/react-router": "^1.38.1",
|
||||||
"@tanstack/router-devtools": "^1.39.4",
|
"@tanstack/router-devtools": "^1.39.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@ -5864,20 +5865,32 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.45.0",
|
"version": "5.51.21",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.45.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.51.21.tgz",
|
||||||
"integrity": "sha512-RVfIZQmFUTdjhSAAblvueimfngYyfN6HlwaJUPK71PKd7yi43Vs1S/rdimmZedPWX/WGppcq/U1HOj7O7FwYxw==",
|
"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": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/react-query": {
|
||||||
"version": "5.45.1",
|
"version": "5.51.21",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.45.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.51.21.tgz",
|
||||||
"integrity": "sha512-mYYfJujKg2kxmkRRjA6nn4YKG3ITsKuH22f1kteJ5IuVQqgKUgbaSQfYwVP0gBS05mhwxO03HVpD0t7BMN7WOA==",
|
"integrity": "sha512-Q/V81x3sAYgCsxjwOkfLXfrmoG+FmDhLeHH5okC/Bp8Aaw2c33lbEo/mMcMnkxUPVtB2FLpzHT0tq3c+OlZEbw==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.45.0"
|
"@tanstack/query-core": "5.51.21"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@ -5887,6 +5900,23 @@
|
|||||||
"react": "^18.0.0"
|
"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": {
|
"node_modules/@tanstack/react-router": {
|
||||||
"version": "1.39.4",
|
"version": "1.39.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.39.4.tgz",
|
"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/free-solid-svg-icons": "^6.6.0",
|
||||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||||
"@tanstack/react-query": "^5.45.1",
|
"@tanstack/react-query": "^5.45.1",
|
||||||
|
"@tanstack/react-query-devtools": "^5.51.21",
|
||||||
"@tanstack/react-router": "^1.38.1",
|
"@tanstack/react-router": "^1.38.1",
|
||||||
"@tanstack/router-devtools": "^1.39.4",
|
"@tanstack/router-devtools": "^1.39.4",
|
||||||
"react": "^18.2.0",
|
"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'
|
import { AuthContextType } from './auth'
|
||||||
|
|
||||||
export default interface RouterContext {
|
export default interface RouterContext {
|
||||||
auth: AuthContextType
|
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 { ApiError } from './ApiError'
|
||||||
|
import ExpiredTokenError from './ExpiredTokenError'
|
||||||
import { toImageView } from './types/ImageView'
|
import { toImageView } from './types/ImageView'
|
||||||
import RecipeInfosView, { RawRecipeInfosView } from './types/RecipeInfosView'
|
import RecipeInfosView, { RawRecipeInfosView } from './types/RecipeInfosView'
|
||||||
|
|
||||||
@ -58,6 +59,8 @@ const getRecipeInfos = async ({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else if (response.status === 401) {
|
||||||
|
throw new ExpiredTokenError()
|
||||||
} else {
|
} else {
|
||||||
throw new ApiError(response.status, response.statusText)
|
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 (
|
const login = async (
|
||||||
username: string,
|
username: string,
|
||||||
@ -17,10 +17,18 @@ const login = async (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const loginView = (await response.json()) as LoginView
|
const {
|
||||||
|
username,
|
||||||
|
accessToken,
|
||||||
|
expires: rawExpires
|
||||||
|
} = (await response.json()) as RawLoginView
|
||||||
return {
|
return {
|
||||||
_tag: 'success',
|
_tag: 'success',
|
||||||
loginView
|
loginView: {
|
||||||
|
username,
|
||||||
|
accessToken,
|
||||||
|
expires: new Date(rawExpires)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let error: string
|
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
|
error: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RawLoginView {
|
||||||
|
username: string
|
||||||
|
accessToken: string
|
||||||
|
expires: string
|
||||||
|
}
|
||||||
|
|
||||||
interface LoginView {
|
interface LoginView {
|
||||||
username: string
|
username: string
|
||||||
accessToken: string
|
accessToken: string
|
||||||
|
expires: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
export default LoginView
|
export default LoginView
|
||||||
|
32
src/main.tsx
32
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 { library } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { fas } from '@fortawesome/free-solid-svg-icons'
|
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 './main.css'
|
||||||
|
import { routeTree } from './routeTree.gen'
|
||||||
|
|
||||||
// Font-Awesome: load icons
|
// Font-Awesome: load icons
|
||||||
library.add(fas)
|
library.add(fas)
|
||||||
|
|
||||||
// Create queryClient
|
|
||||||
const queryClient = new QueryClient()
|
|
||||||
|
|
||||||
// Create router
|
// Create router
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
context: {
|
context: {
|
||||||
auth: undefined!,
|
auth: undefined!
|
||||||
queryClient
|
|
||||||
},
|
},
|
||||||
routeTree
|
routeTree
|
||||||
})
|
})
|
||||||
@ -32,15 +28,23 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
const InnerApp = () => {
|
const InnerApp = () => {
|
||||||
const auth = useAuth()
|
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(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<InnerApp />
|
<InnerApp />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</QueryClientProvider>
|
|
||||||
</React.StrictMode>
|
</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 getRecipeInfos from '../../api/getRecipeInfos'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useAuth } from '../../auth'
|
import { useAuth } from '../../auth'
|
||||||
@ -12,7 +12,9 @@ const Recipes = () => {
|
|||||||
|
|
||||||
const { token } = useAuth()
|
const { token } = useAuth()
|
||||||
|
|
||||||
const { data, isPending, error } = useQuery({
|
const queryClient = useQueryClient()
|
||||||
|
const { data, isPending, error } = useQuery(
|
||||||
|
{
|
||||||
queryKey: ['recipeInfos'],
|
queryKey: ['recipeInfos'],
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
getRecipeInfos({
|
getRecipeInfos({
|
||||||
@ -21,7 +23,9 @@ const Recipes = () => {
|
|||||||
pageSize,
|
pageSize,
|
||||||
token
|
token
|
||||||
})
|
})
|
||||||
})
|
},
|
||||||
|
queryClient
|
||||||
|
)
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return <p>Loading...</p>
|
return <p>Loading...</p>
|
||||||
|
@ -1,55 +1,38 @@
|
|||||||
import {
|
import { useQuery } from '@tanstack/react-query'
|
||||||
createFileRoute,
|
import { createFileRoute, useParams } from '@tanstack/react-router'
|
||||||
ErrorComponent,
|
|
||||||
useLoaderData,
|
|
||||||
useParams
|
|
||||||
} from '@tanstack/react-router'
|
|
||||||
import { ApiError } from '../../api/ApiError'
|
|
||||||
import getRecipe from '../../api/getRecipe'
|
import getRecipe from '../../api/getRecipe'
|
||||||
|
import { useAuth } from '../../auth'
|
||||||
import Recipe from '../../pages/recipe/Recipe'
|
import Recipe from '../../pages/recipe/Recipe'
|
||||||
|
|
||||||
export const Route = createFileRoute('/recipes/$username/$slug')({
|
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() {
|
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({
|
const { username, slug } = useParams({
|
||||||
from: '/recipes/$username/$slug'
|
from: '/recipes/$username/$slug'
|
||||||
})
|
})
|
||||||
return (
|
const authContext = useAuth()
|
||||||
<p>
|
const {
|
||||||
404: Could not find a Recipe for {username}/{slug}
|
isLoading,
|
||||||
</p>
|
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