diff --git a/src/AuthAwareQueryClientProvider.tsx b/src/AuthAwareQueryClientProvider.tsx index 8998382..d387f86 100644 --- a/src/AuthAwareQueryClientProvider.tsx +++ b/src/AuthAwareQueryClientProvider.tsx @@ -3,35 +3,49 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' +import { useLocation, useNavigate } from '@tanstack/react-router' import React, { useState } from 'react' import ExpiredTokenError from './api/ExpiredTokenError' -import { ReactQueryDevtools } from '@tanstack/react-query-devtools' +import refresh, { ExpiredRefreshTokenError } from './api/refresh' +import LoginView from './api/types/LoginView' import { useAuth } from './auth' -import refresh from './api/refresh' -import { useNavigate } from '@tanstack/react-router' const AuthAwareQueryClientProvider = ({ children }: React.PropsWithChildren) => { - const { putToken } = useAuth() + const { putToken, clearToken } = useAuth() const navigate = useNavigate() const [currentlyRefreshing, setCurrentlyRefreshing] = useState(false) + const { href } = useLocation() 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 + let refreshResult: LoginView + try { + refreshResult = await refresh() + } catch (error) { + if (error instanceof ExpiredRefreshTokenError) { + console.log('refresh-token expired') + setCurrentlyRefreshing(false) + clearToken() + await navigate({ + to: '/login', + search: { + expired: true, + redirect: href + } + }) + console.log('post-navigate') + return + } else { + setCurrentlyRefreshing(false) + throw error + } } + putToken(refreshResult.accessToken, refreshResult.username) setCurrentlyRefreshing(false) console.log('refresh done') } diff --git a/src/api/getRecipe.ts b/src/api/getRecipe.ts index cffdc32..6dd82db 100644 --- a/src/api/getRecipe.ts +++ b/src/api/getRecipe.ts @@ -1,12 +1,11 @@ -import { notFound, redirect } from '@tanstack/react-router' +import { notFound } from '@tanstack/react-router' import { AuthContextType } from '../auth' import { ApiError } from './ApiError' +import ExpiredTokenError from './ExpiredTokenError' import FullRecipeView, { RawFullRecipeView, toFullRecipeView } from './types/FullRecipeView' -import LoginView from './types/LoginView' -import SecurityExceptionView from './types/SecurityExceptionView' export interface GetRecipeDeps { authContext: AuthContextType @@ -15,10 +14,12 @@ export interface GetRecipeDeps { abortSignal: AbortSignal } -const getRecipe = async ( - { authContext, username, slug, abortSignal }: GetRecipeDeps, - isRetry: boolean = false -): Promise => { +const getRecipe = async ({ + authContext, + username, + slug, + abortSignal +}: GetRecipeDeps): Promise => { const headers = new Headers() if (authContext.token !== null) { headers.set('Authorization', `Bearer ${authContext.token}`) @@ -33,42 +34,8 @@ const getRecipe = async ( ) if (response.ok) { return toFullRecipeView((await response.json()) as RawFullRecipeView) - } else if (response.status === 401 && !isRetry) { - // must be logged in to view this resource - const securityExceptionView = - (await response.json()) as SecurityExceptionView - if (securityExceptionView.action === 'REFRESH') { - // do refresh - const refreshResponse = await fetch( - import.meta.env.VITE_MME_API_URL + '/auth/refresh', - { - signal: abortSignal, - method: 'POST' - } - ) - if (refreshResponse.ok) { - const { accessToken, username } = - (await refreshResponse.json()) as LoginView - authContext.putToken(accessToken, username) - return getRecipe( - { - abortSignal, - authContext, - slug, - username - }, - true - ) - } else { - throw new ApiError( - refreshResponse.status, - refreshResponse.statusText - ) - } - } else { - // do login - throw redirect({ to: '/login' }) - } + } else if (response.status === 401) { + throw new ExpiredTokenError() } else if (response.status === 404) { // no such resource throw notFound() diff --git a/src/api/refresh.ts b/src/api/refresh.ts index e2a25eb..9787f8e 100644 --- a/src/api/refresh.ts +++ b/src/api/refresh.ts @@ -1,8 +1,17 @@ -import { LoginResult, RawLoginView } from './types/LoginView' +import { ApiError } from './ApiError' +import LoginView, { RawLoginView } from './types/LoginView' -const refresh = async (): Promise => { +export class ExpiredRefreshTokenError extends ApiError { + constructor() { + super(401, 'Expired refresh token.') + Object.setPrototypeOf(this, ExpiredRefreshTokenError.prototype) + } +} + +const refresh = async (): Promise => { + let response: Response try { - const response = await fetch( + response = await fetch( import.meta.env.VITE_MME_API_URL + '/auth/refresh', { credentials: 'include', @@ -10,45 +19,29 @@ const refresh = async (): Promise => { 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.' + if (fetchError instanceof TypeError) { + throw fetchError // rethrow network issues + } else { + throw new Error(`Unknown fetch error: ${fetchError}`) } } + if (response.ok) { + const { + username, + accessToken, + expires: rawExpires + } = (await response.json()) as RawLoginView + return { + username, + accessToken, + expires: new Date(rawExpires) + } + } else if (response.status === 401) { + throw new ExpiredRefreshTokenError() + } else { + throw new ApiError(response.status, response.statusText) + } } export default refresh diff --git a/src/routes/login.tsx b/src/routes/login.tsx index 72f4ab0..5dee68d 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -16,7 +16,7 @@ const Login = () => { const router = useRouter() const navigate = useNavigate() - const search = useSearch({ from: '/login' }) + const { redirect, expired } = useSearch({ from: '/login' }) const onSubmit = async (event: FormEvent) => { event.preventDefault() @@ -30,7 +30,10 @@ const Login = () => { loginResult.loginView.username, async () => { await router.invalidate() - await navigate({ to: search.redirect ?? '/recipes' }) + await navigate({ + to: redirect ?? '/recipes', + search: {} + }) } ) } else { @@ -41,6 +44,9 @@ const Login = () => { return (

Login Page

+ {expired ? ( +

Your session has expired. Please login again.

+ ) : null}
@@ -58,11 +64,12 @@ const Login = () => { export const Route = createFileRoute('/login')({ validateSearch: z.object({ + expired: z.boolean().optional().catch(false), redirect: z.string().optional().catch('') }), beforeLoad({ context, search }) { - if (context.auth.token) { - throw redirect({ to: search.redirect || '/recipes' }) + if (!(search.expired || context.auth.token === null)) { + throw redirect({ to: '/recipes' }) } }, component: Login