Working on refresh auth flow.

This commit is contained in:
Jesse Brault 2024-08-06 21:54:08 -05:00
parent c35ef2f60b
commit 401b5bef43
12 changed files with 260 additions and 85 deletions

44
package-lock.json generated
View File

@ -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",

View File

@ -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",

View 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

View File

@ -1,7 +1,5 @@
import { QueryClient } from '@tanstack/react-query'
import { AuthContextType } from './auth'
export default interface RouterContext {
auth: AuthContextType
queryClient: QueryClient
}

View 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

View File

@ -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)
}

View File

@ -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
View 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

View File

@ -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

View File

@ -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>
)

View File

@ -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>

View File

@ -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
}
}
})