diff --git a/src/AuthAwareQueryClientProvider.tsx b/src/AuthAwareQueryClientProvider.tsx index 32c175b..bee58d9 100644 --- a/src/AuthAwareQueryClientProvider.tsx +++ b/src/AuthAwareQueryClientProvider.tsx @@ -1,51 +1,47 @@ import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' -import { useRouter } from '@tanstack/react-router' -import React, { useState } from 'react' +import { useLocation, useNavigate } 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 LoginView from './api/types/LoginView' import { useAuth } from './auth' const AuthAwareQueryClientProvider = ({ children }: React.PropsWithChildren) => { - const { putToken, clearToken } = useAuth() - const router = useRouter() - const [currentlyRefreshing, setCurrentlyRefreshing] = useState(false) + const { putToken } = useAuth() + const navigate = useNavigate() + const location = useLocation() + const refreshing = useRef(false) - const doRefresh = async () => { - if (!currentlyRefreshing) { - console.log('starting refresh') - setCurrentlyRefreshing(true) - let refreshResult: LoginView + const doRefresh = useCallback(async () => { + putToken(null) + if (!refreshing.current) { + refreshing.current = true try { - refreshResult = await refresh() + const { accessToken: token, expires, username } = await refresh() + putToken({ + token, + expires, + username + }) } catch (error) { if (error instanceof RefreshTokenError) { - console.log(`RefreshTokenError: ${error.reason}`) - setCurrentlyRefreshing(false) - clearToken() - await router.navigate({ + navigate({ to: '/login', search: { reason: error.reason, - redirect: router.state.location.href + redirect: location.href } }) - console.log('post-navigate') - return - } else { - setCurrentlyRefreshing(false) - throw error + } else if (error instanceof ApiError) { + console.error(error) } } - putToken(refreshResult.accessToken, refreshResult.username) - setCurrentlyRefreshing(false) - console.log('refresh done') + refreshing.current = false } - } + }, [putToken, navigate, location]) - const [queryClient] = useState( + const queryClient = useMemo( () => new QueryClient({ defaultOptions: { @@ -83,7 +79,8 @@ const AuthAwareQueryClientProvider = ({ children }: React.PropsWithChildren) => } } }) - }) + }), + [doRefresh] ) return ( diff --git a/src/api/addStar.ts b/src/api/addStar.ts index 770faf9..12100e5 100644 --- a/src/api/addStar.ts +++ b/src/api/addStar.ts @@ -1,15 +1,17 @@ +import AccessToken from '../types/AccessToken' import { ApiError } from './ApiError' import ExpiredTokenError from './ExpiredTokenError' +import { addBearer } from './util' export interface AddStarDeps { - token: string + accessToken: AccessToken username: string slug: string } -const addStar = async ({ slug, token, username }: AddStarDeps): Promise => { +const addStar = async ({ slug, accessToken, username }: AddStarDeps): Promise => { const headers = new Headers() - headers.set('Authorization', `Bearer ${token}`) + addBearer(headers, accessToken) const response = await fetch(import.meta.env.VITE_MME_API_URL + `/recipes/${username}/${slug}/star`, { headers, method: 'POST', diff --git a/src/api/getImage.ts b/src/api/getImage.ts index 1417f50..6c50337 100644 --- a/src/api/getImage.ts +++ b/src/api/getImage.ts @@ -1,8 +1,10 @@ +import AccessToken from '../types/AccessToken' import { ApiError } from './ApiError' import ExpiredTokenError from './ExpiredTokenError' +import { addBearer } from './util' export interface GetImageDeps { - accessToken: string | null + accessToken: AccessToken | null signal: AbortSignal url: string } @@ -10,7 +12,7 @@ export interface GetImageDeps { const getImage = async ({ accessToken, signal, url }: GetImageDeps): Promise => { const headers = new Headers() if (accessToken !== null) { - headers.set('Authorization', `Bearer ${accessToken}`) + addBearer(headers, accessToken) } const response = await fetch(url, { headers, diff --git a/src/api/getRecipe.ts b/src/api/getRecipe.ts index bee63d6..5823b2a 100644 --- a/src/api/getRecipe.ts +++ b/src/api/getRecipe.ts @@ -1,4 +1,4 @@ -import { AuthContextType } from '../auth' +import AccessToken from '../types/AccessToken' import { ApiError } from './ApiError' import ExpiredTokenError from './ExpiredTokenError' import GetRecipeView, { @@ -8,9 +8,10 @@ import GetRecipeView, { toGetRecipeView, toGetRecipeViewWithRawText } from './types/GetRecipeView' +import { addBearer } from './util' export interface GetRecipeCommonDeps { - authContext: AuthContextType + accessToken: AccessToken | null username: string slug: string abortSignal: AbortSignal @@ -30,15 +31,15 @@ export interface GetRecipe { } const getRecipe = (async ({ - authContext, + accessToken, username, slug, abortSignal, includeRawText }: GetRecipeDeps | GetRecipeDepsIncludeRawText): Promise => { const headers = new Headers() - if (authContext.token !== null) { - headers.set('Authorization', `Bearer ${authContext.token}`) + if (accessToken !== null) { + addBearer(headers, accessToken) } const query = includeRawText ? '?includeRawText=true' : '' const response = await fetch(import.meta.env.VITE_MME_API_URL + `/recipes/${username}/${slug}${query}`, { diff --git a/src/api/getRecipeInfos.ts b/src/api/getRecipeInfos.ts index 8124575..03b54ff 100644 --- a/src/api/getRecipeInfos.ts +++ b/src/api/getRecipeInfos.ts @@ -1,24 +1,26 @@ +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' export interface GetRecipeInfosDeps { abortSignal: AbortSignal - token: string | null + accessToken: AccessToken | null pageNumber: number pageSize: number } const getRecipeInfos = async ({ abortSignal, - token, + accessToken, pageNumber, pageSize }: GetRecipeInfosDeps): Promise => { const headers = new Headers() - if (token !== null) { - headers.set('Authorization', `Bearer ${token}`) + if (accessToken !== null) { + addBearer(headers, accessToken) } const response = await fetch(import.meta.env.VITE_MME_API_URL + `/recipes?page=${pageNumber}&size=${pageSize}`, { signal: abortSignal, diff --git a/src/api/removeStar.ts b/src/api/removeStar.ts index a851647..1725c57 100644 --- a/src/api/removeStar.ts +++ b/src/api/removeStar.ts @@ -1,15 +1,17 @@ +import AccessToken from '../types/AccessToken' import { ApiError } from './ApiError' import ExpiredTokenError from './ExpiredTokenError' +import { addBearer } from './util' export interface RemoveStarDeps { - token: string + accessToken: AccessToken username: string slug: string } -const removeStar = async ({ token, username, slug }: RemoveStarDeps) => { +const removeStar = async ({ accessToken, username, slug }: RemoveStarDeps) => { const headers = new Headers() - headers.set('Authorization', `Bearer ${token}`) + addBearer(headers, accessToken) const response = await fetch(import.meta.env.VITE_MME_API_URL + `/recipes/${username}/${slug}/star`, { headers, method: 'DELETE', diff --git a/src/api/updateRecipe.ts b/src/api/updateRecipe.ts index 0829989..de72130 100644 --- a/src/api/updateRecipe.ts +++ b/src/api/updateRecipe.ts @@ -1,3 +1,4 @@ +import AccessToken from '../types/AccessToken' import { ApiError } from './ApiError' import ExpiredTokenError from './ExpiredTokenError' import { @@ -6,17 +7,23 @@ import { toGetRecipeViewWithRawText } from './types/GetRecipeView' import UpdateRecipeSpec from './types/UpdateRecipeSpec' +import { addBearer } from './util' export interface UpdateRecipeDeps { spec: UpdateRecipeSpec - token: string + accessToken: AccessToken username: string slug: string } -const updateRecipe = async ({ spec, token, username, slug }: UpdateRecipeDeps): Promise => { +const updateRecipe = async ({ + spec, + accessToken, + username, + slug +}: UpdateRecipeDeps): Promise => { const headers = new Headers() - headers.set('Authorization', `Bearer ${token}`) + addBearer(headers, accessToken) headers.set('Content-type', 'application/json') const response = await fetch(import.meta.env.VITE_MME_API_URL + `/recipes/${username}/${slug}`, { headers, diff --git a/src/api/util.ts b/src/api/util.ts new file mode 100644 index 0000000..2db767a --- /dev/null +++ b/src/api/util.ts @@ -0,0 +1,5 @@ +import AccessToken from '../types/AccessToken' + +export const addBearer = (headers: Headers, accessToken: AccessToken) => { + headers.set('Authorization', `Bearer ${accessToken.token}`) +} diff --git a/src/auth.tsx b/src/auth.tsx index 14c8777..ba4a3c7 100644 --- a/src/auth.tsx +++ b/src/auth.tsx @@ -1,55 +1,54 @@ -import React, { createContext, useContext, useEffect, useState } from 'react' +import React, { createContext, useContext, useReducer } from 'react' +import AccessToken from './types/AccessToken' export interface AuthContextType { - token: string | null - username: string | null - putToken(token: string, username: string, cb?: () => void): void - clearToken(cb?: () => void): void + accessToken: AccessToken | null + putToken: (token: AccessToken | null) => void } -interface AuthState { - token: string | null - username: string | null - putCb?: () => void - clearCb?: () => void +interface AuthReducerState { + accessToken: AccessToken | null +} + +const initialState: AuthReducerState = { + accessToken: null +} + +type AuthReducerAction = PutTokenAction | ClearTokenAction + +interface PutTokenAction { + tag: 'putToken' + accessToken: AccessToken +} + +interface ClearTokenAction { + tag: 'clearToken' +} + +const authReducer = (_state: AuthReducerState, action: AuthReducerAction): AuthReducerState => { + switch (action.tag) { + case 'putToken': + return { accessToken: action.accessToken } + case 'clearToken': + return { accessToken: null } + } } const AuthContext = createContext(null) export const AuthProvider = ({ children }: React.PropsWithChildren) => { - const [authState, setAuthState] = useState({ - token: null, - username: null - }) - - useEffect(() => { - if (authState.token === null && authState.clearCb !== undefined) { - authState.clearCb() - setAuthState({ ...authState, clearCb: undefined }) - } else if (authState.token !== null && authState.putCb !== undefined) { - authState.putCb() - setAuthState({ ...authState, putCb: undefined }) - } - }, [authState.token]) + const [state, dispatch] = useReducer(authReducer, initialState) return ( { + if (token === null) { + dispatch({ tag: 'clearToken' }) + } else { + dispatch({ tag: 'putToken', accessToken: token }) + } } }} > diff --git a/src/components/header/Header.tsx b/src/components/header/Header.tsx index 1151e97..51b3752 100644 --- a/src/components/header/Header.tsx +++ b/src/components/header/Header.tsx @@ -1,4 +1,4 @@ -import { useLocation, useNavigate, useRouter } from '@tanstack/react-router' +import { useLocation, useNavigate } from '@tanstack/react-router' import { useAuth } from '../../auth' import classes from './header.module.css' @@ -7,20 +7,17 @@ export interface HeaderProps { } const Header = ({ username }: HeaderProps) => { - const auth = useAuth() - const router = useRouter() + const { putToken } = useAuth() const navigate = useNavigate() const location = useLocation() - const onLogin = async () => { + const onLogin = () => { navigate({ to: '/login', search: { redirect: location.href } }) } - const onLogout = async () => { - auth.clearToken(async () => { - await router.invalidate() - await navigate({ to: '/login' }) - }) + const onLogout = () => { + putToken(null) + navigate({ to: '/login' }) } return ( diff --git a/src/pages/edit-recipe/EditRecipe.tsx b/src/pages/edit-recipe/EditRecipe.tsx index 276a770..ff32c12 100644 --- a/src/pages/edit-recipe/EditRecipe.tsx +++ b/src/pages/edit-recipe/EditRecipe.tsx @@ -86,7 +86,7 @@ export interface EditRecipeProps { } const EditRecipe = ({ username, slug }: EditRecipeProps) => { - const auth = useAuth() + const { accessToken } = useAuth() const navigate = useNavigate() // useEffect(() => { @@ -105,7 +105,7 @@ const EditRecipe = ({ username, slug }: EditRecipeProps) => { queryKey: ['recipes', username, slug], queryFn: ({ signal }) => getRecipe({ - authContext: auth, + accessToken, username, slug, abortSignal: signal, @@ -140,10 +140,10 @@ const EditRecipe = ({ username, slug }: EditRecipeProps) => { const mutation = useMutation( { mutationFn: () => { - if (auth.token !== null) { + if (accessToken !== null) { return updateRecipe({ spec, - token: auth.token, + accessToken, username, slug }) diff --git a/src/pages/recipe/Recipe.tsx b/src/pages/recipe/Recipe.tsx index 4fd45fd..c5f176a 100644 --- a/src/pages/recipe/Recipe.tsx +++ b/src/pages/recipe/Recipe.tsx @@ -51,14 +51,14 @@ interface RecipeStarButtonProps { } const RecipeStarButton = ({ username, slug, isStarred, starCount }: RecipeStarButtonProps) => { - const authContext = useAuth() + const { accessToken } = useAuth() const queryClient = useQueryClient() const addStarMutation = useMutation({ mutationFn: () => { - if (authContext.token !== null) { + if (accessToken !== null) { return addStar({ - token: authContext.token, + accessToken, slug, username }) @@ -73,9 +73,9 @@ const RecipeStarButton = ({ username, slug, isStarred, starCount }: RecipeStarBu const removeStarMutation = useMutation({ mutationFn: () => { - if (authContext.token !== null) { + if (accessToken !== null) { return removeStar({ - token: authContext.token, + accessToken, slug, username }) @@ -111,7 +111,7 @@ export interface RecipeProps { } const Recipe = ({ username, slug }: RecipeProps) => { - const authContext = useAuth() + const { accessToken } = useAuth() const queryClient = useQueryClient() const recipeQuery = useQuery( @@ -120,7 +120,7 @@ const Recipe = ({ username, slug }: RecipeProps) => { queryFn: ({ signal: abortSignal }) => getRecipe({ abortSignal, - authContext, + accessToken, username, slug }) @@ -138,7 +138,7 @@ const Recipe = ({ username, slug }: RecipeProps) => { ], queryFn: ({ signal }) => getImage({ - accessToken: authContext.token, + accessToken, signal, url: recipeQuery.data!.recipe.mainImage!.url }) diff --git a/src/pages/recipes/Recipes.tsx b/src/pages/recipes/Recipes.tsx index ff1d472..c12ef24 100644 --- a/src/pages/recipes/Recipes.tsx +++ b/src/pages/recipes/Recipes.tsx @@ -11,7 +11,7 @@ const Recipes = () => { const [pageNumber, setPageNumber] = useState(0) const [pageSize, setPageSize] = useState(20) - const { token } = useAuth() + const { accessToken } = useAuth() const queryClient = useQueryClient() const { data, isPending, error } = useQuery( @@ -22,7 +22,7 @@ const Recipes = () => { abortSignal: signal, pageNumber, pageSize, - token + accessToken }) }, queryClient @@ -42,7 +42,7 @@ const Recipes = () => { queryFn: async ({ signal }: any) => { // any needed in the params const imgUrl = await getImage({ - accessToken: token, + accessToken, signal, url: recipeInfoView.mainImage!.url }) diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index d03e616..fb328df 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -8,11 +8,11 @@ import classes from './__root.module.css' import MainNav from '../components/main-nav/MainNav' const RootLayout = () => { - const { username } = useAuth() + const { accessToken } = useAuth() return ( <> -
+
diff --git a/src/routes/_auth.tsx b/src/routes/_auth.tsx index e75e6d5..dcf0a55 100644 --- a/src/routes/_auth.tsx +++ b/src/routes/_auth.tsx @@ -2,7 +2,7 @@ import { Outlet, createFileRoute, redirect } from '@tanstack/react-router' export const Route = createFileRoute('/_auth')({ beforeLoad({ context, location }) { - if (!context.auth.token) { + if (!context.auth.accessToken) { throw redirect({ to: '/login', search: { diff --git a/src/routes/login.tsx b/src/routes/login.tsx index 31ff63d..5f78dda 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -1,14 +1,12 @@ -import { createFileRoute, redirect, useNavigate, useRouter, useSearch } from '@tanstack/react-router' +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' const Login = () => { - const auth = useAuth() + const { putToken } = useAuth() const [error, setError] = useState(null) - - const router = useRouter() const navigate = useNavigate() const { redirect, reason } = useSearch({ from: '/login' }) @@ -19,12 +17,15 @@ const Login = () => { const password = (formData.get('password') as string | null) ?? '' const loginResult = await login(username, password) if (loginResult._tag === 'success') { - auth.putToken(loginResult.loginView.accessToken, loginResult.loginView.username, async () => { - await router.invalidate() - await navigate({ - to: redirect ?? '/recipes', - search: {} - }) + const { accessToken: token, expires, username } = loginResult.loginView + putToken({ + token, + expires, + username + }) + navigate({ + to: redirect ?? '/recipes', + search: {} }) } else { setError(loginResult.error) @@ -72,10 +73,5 @@ export const Route = createFileRoute('/login')({ .optional(), redirect: z.string().optional().catch('') }), - beforeLoad({ context, search }) { - if (search.reason === undefined && context.auth.token !== null) { - throw redirect({ to: '/recipes' }) - } - }, component: Login }) diff --git a/src/types/AccessToken.ts b/src/types/AccessToken.ts new file mode 100644 index 0000000..11bc1f9 --- /dev/null +++ b/src/types/AccessToken.ts @@ -0,0 +1,7 @@ +interface AccessToken { + token: string + username: string + expires: Date +} + +export default AccessToken