From 401b5bef434536ec0a84f720a6b40c6f4304673d Mon Sep 17 00:00:00 2001 From: Jesse Brault Date: Tue, 6 Aug 2024 21:54:08 -0500 Subject: [PATCH] Working on refresh auth flow. --- package-lock.json | 44 ++++++++++++--- package.json | 1 + src/AuthAwareQueryClientProvider.tsx | 73 +++++++++++++++++++++++++ src/RouterContext.ts | 2 - src/api/ExpiredTokenError.ts | 10 ++++ src/api/getRecipeInfos.ts | 3 + src/api/login.ts | 14 ++++- src/api/refresh.ts | 54 ++++++++++++++++++ src/api/types/LoginView.ts | 7 +++ src/main.tsx | 38 +++++++------ src/pages/recipes/Recipes.tsx | 26 +++++---- src/routes/recipes_/$username.$slug.tsx | 73 ++++++++++--------------- 12 files changed, 260 insertions(+), 85 deletions(-) create mode 100644 src/AuthAwareQueryClientProvider.tsx create mode 100644 src/api/ExpiredTokenError.ts create mode 100644 src/api/refresh.ts diff --git a/package-lock.json b/package-lock.json index 036566d..38fc36c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4976974..a056219 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/AuthAwareQueryClientProvider.tsx b/src/AuthAwareQueryClientProvider.tsx new file mode 100644 index 0000000..8998382 --- /dev/null +++ b/src/AuthAwareQueryClientProvider.tsx @@ -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( + () => + 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 ( + + {children} + + + ) +} + +export default AuthAwareQueryClientProvider diff --git a/src/RouterContext.ts b/src/RouterContext.ts index c2d31b6..7d71095 100644 --- a/src/RouterContext.ts +++ b/src/RouterContext.ts @@ -1,7 +1,5 @@ -import { QueryClient } from '@tanstack/react-query' import { AuthContextType } from './auth' export default interface RouterContext { auth: AuthContextType - queryClient: QueryClient } diff --git a/src/api/ExpiredTokenError.ts b/src/api/ExpiredTokenError.ts new file mode 100644 index 0000000..b5bcff3 --- /dev/null +++ b/src/api/ExpiredTokenError.ts @@ -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 diff --git a/src/api/getRecipeInfos.ts b/src/api/getRecipeInfos.ts index 53fb17e..f1dfbd1 100644 --- a/src/api/getRecipeInfos.ts +++ b/src/api/getRecipeInfos.ts @@ -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) } diff --git a/src/api/login.ts b/src/api/login.ts index d616785..1db6cbe 100644 --- a/src/api/login.ts +++ b/src/api/login.ts @@ -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 diff --git a/src/api/refresh.ts b/src/api/refresh.ts new file mode 100644 index 0000000..e2a25eb --- /dev/null +++ b/src/api/refresh.ts @@ -0,0 +1,54 @@ +import { LoginResult, RawLoginView } from './types/LoginView' + +const refresh = async (): Promise => { + 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 diff --git a/src/api/types/LoginView.ts b/src/api/types/LoginView.ts index 75eb7c5..4d1a123 100644 --- a/src/api/types/LoginView.ts +++ b/src/api/types/LoginView.ts @@ -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 diff --git a/src/main.tsx b/src/main.tsx index b25889d..3758550 100644 --- a/src/main.tsx +++ b/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 + return ( + ( + + {children} + + )} + > + ) } ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - + + + ) diff --git a/src/pages/recipes/Recipes.tsx b/src/pages/recipes/Recipes.tsx index 58cde4e..8c1b2d8 100644 --- a/src/pages/recipes/Recipes.tsx +++ b/src/pages/recipes/Recipes.tsx @@ -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

Loading...

diff --git a/src/routes/recipes_/$username.$slug.tsx b/src/routes/recipes_/$username.$slug.tsx index 03bfa72..489999e 100644 --- a/src/routes/recipes_/$username.$slug.tsx +++ b/src/routes/recipes_/$username.$slug.tsx @@ -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 - }, - errorComponent({ error }) { - if (error instanceof ApiError) { - return ( -

- {error.status}: {error.message} -

- ) - } else if (error instanceof Error) { - return ( -

- {error.name}: {error.message} -

- ) - } - return - }, - notFoundComponent() { const { username, slug } = useParams({ from: '/recipes/$username/$slug' }) - return ( -

- 404: Could not find a Recipe for {username}/{slug} -

- ) + 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 + } else { + return null + } } })