Major refactor of auth and login logic.

This commit is contained in:
Jesse Brault 2024-08-19 21:21:06 -05:00
parent af683d9ee9
commit 9cc05d0a7a
17 changed files with 146 additions and 129 deletions

View File

@ -1,51 +1,47 @@
import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { useRouter } from '@tanstack/react-router' import { useLocation, useNavigate } from '@tanstack/react-router'
import React, { useState } from 'react' import React, { useCallback, useMemo, useRef } from 'react'
import { ApiError } from './api/ApiError' import { ApiError } from './api/ApiError'
import ExpiredTokenError from './api/ExpiredTokenError' import ExpiredTokenError from './api/ExpiredTokenError'
import refresh, { RefreshTokenError } from './api/refresh' import refresh, { RefreshTokenError } from './api/refresh'
import LoginView from './api/types/LoginView'
import { useAuth } from './auth' import { useAuth } from './auth'
const AuthAwareQueryClientProvider = ({ children }: React.PropsWithChildren) => { const AuthAwareQueryClientProvider = ({ children }: React.PropsWithChildren) => {
const { putToken, clearToken } = useAuth() const { putToken } = useAuth()
const router = useRouter() const navigate = useNavigate()
const [currentlyRefreshing, setCurrentlyRefreshing] = useState(false) const location = useLocation()
const refreshing = useRef(false)
const doRefresh = async () => { const doRefresh = useCallback(async () => {
if (!currentlyRefreshing) { putToken(null)
console.log('starting refresh') if (!refreshing.current) {
setCurrentlyRefreshing(true) refreshing.current = true
let refreshResult: LoginView
try { try {
refreshResult = await refresh() const { accessToken: token, expires, username } = await refresh()
putToken({
token,
expires,
username
})
} catch (error) { } catch (error) {
if (error instanceof RefreshTokenError) { if (error instanceof RefreshTokenError) {
console.log(`RefreshTokenError: ${error.reason}`) navigate({
setCurrentlyRefreshing(false)
clearToken()
await router.navigate({
to: '/login', to: '/login',
search: { search: {
reason: error.reason, reason: error.reason,
redirect: router.state.location.href redirect: location.href
} }
}) })
console.log('post-navigate') } else if (error instanceof ApiError) {
return console.error(error)
} else {
setCurrentlyRefreshing(false)
throw error
} }
} }
putToken(refreshResult.accessToken, refreshResult.username) refreshing.current = false
setCurrentlyRefreshing(false)
console.log('refresh done')
} }
} }, [putToken, navigate, location])
const [queryClient] = useState<QueryClient>( const queryClient = useMemo(
() => () =>
new QueryClient({ new QueryClient({
defaultOptions: { defaultOptions: {
@ -83,7 +79,8 @@ const AuthAwareQueryClientProvider = ({ children }: React.PropsWithChildren) =>
} }
} }
}) })
}) }),
[doRefresh]
) )
return ( return (

View File

@ -1,15 +1,17 @@
import AccessToken from '../types/AccessToken'
import { ApiError } from './ApiError' import { ApiError } from './ApiError'
import ExpiredTokenError from './ExpiredTokenError' import ExpiredTokenError from './ExpiredTokenError'
import { addBearer } from './util'
export interface AddStarDeps { export interface AddStarDeps {
token: string accessToken: AccessToken
username: string username: string
slug: string slug: string
} }
const addStar = async ({ slug, token, username }: AddStarDeps): Promise<void> => { const addStar = async ({ slug, accessToken, username }: AddStarDeps): Promise<void> => {
const headers = new Headers() 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`, { const response = await fetch(import.meta.env.VITE_MME_API_URL + `/recipes/${username}/${slug}/star`, {
headers, headers,
method: 'POST', method: 'POST',

View File

@ -1,8 +1,10 @@
import AccessToken from '../types/AccessToken'
import { ApiError } from './ApiError' import { ApiError } from './ApiError'
import ExpiredTokenError from './ExpiredTokenError' import ExpiredTokenError from './ExpiredTokenError'
import { addBearer } from './util'
export interface GetImageDeps { export interface GetImageDeps {
accessToken: string | null accessToken: AccessToken | null
signal: AbortSignal signal: AbortSignal
url: string url: string
} }
@ -10,7 +12,7 @@ export interface GetImageDeps {
const getImage = async ({ accessToken, signal, url }: GetImageDeps): Promise<string> => { const getImage = async ({ accessToken, signal, url }: GetImageDeps): Promise<string> => {
const headers = new Headers() const headers = new Headers()
if (accessToken !== null) { if (accessToken !== null) {
headers.set('Authorization', `Bearer ${accessToken}`) addBearer(headers, accessToken)
} }
const response = await fetch(url, { const response = await fetch(url, {
headers, headers,

View File

@ -1,4 +1,4 @@
import { AuthContextType } from '../auth' import AccessToken from '../types/AccessToken'
import { ApiError } from './ApiError' import { ApiError } from './ApiError'
import ExpiredTokenError from './ExpiredTokenError' import ExpiredTokenError from './ExpiredTokenError'
import GetRecipeView, { import GetRecipeView, {
@ -8,9 +8,10 @@ import GetRecipeView, {
toGetRecipeView, toGetRecipeView,
toGetRecipeViewWithRawText toGetRecipeViewWithRawText
} from './types/GetRecipeView' } from './types/GetRecipeView'
import { addBearer } from './util'
export interface GetRecipeCommonDeps { export interface GetRecipeCommonDeps {
authContext: AuthContextType accessToken: AccessToken | null
username: string username: string
slug: string slug: string
abortSignal: AbortSignal abortSignal: AbortSignal
@ -30,15 +31,15 @@ export interface GetRecipe {
} }
const getRecipe = (async ({ const getRecipe = (async ({
authContext, accessToken,
username, username,
slug, slug,
abortSignal, abortSignal,
includeRawText includeRawText
}: GetRecipeDeps | GetRecipeDepsIncludeRawText): Promise<GetRecipeView | GetRecipeViewWithRawText> => { }: GetRecipeDeps | GetRecipeDepsIncludeRawText): Promise<GetRecipeView | GetRecipeViewWithRawText> => {
const headers = new Headers() const headers = new Headers()
if (authContext.token !== null) { if (accessToken !== null) {
headers.set('Authorization', `Bearer ${authContext.token}`) addBearer(headers, accessToken)
} }
const query = includeRawText ? '?includeRawText=true' : '' const query = includeRawText ? '?includeRawText=true' : ''
const response = await fetch(import.meta.env.VITE_MME_API_URL + `/recipes/${username}/${slug}${query}`, { const response = await fetch(import.meta.env.VITE_MME_API_URL + `/recipes/${username}/${slug}${query}`, {

View File

@ -1,24 +1,26 @@
import AccessToken from '../types/AccessToken'
import { ApiError } from './ApiError' import { ApiError } from './ApiError'
import ExpiredTokenError from './ExpiredTokenError' 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'
import { addBearer } from './util'
export interface GetRecipeInfosDeps { export interface GetRecipeInfosDeps {
abortSignal: AbortSignal abortSignal: AbortSignal
token: string | null accessToken: AccessToken | null
pageNumber: number pageNumber: number
pageSize: number pageSize: number
} }
const getRecipeInfos = async ({ const getRecipeInfos = async ({
abortSignal, abortSignal,
token, accessToken,
pageNumber, pageNumber,
pageSize pageSize
}: GetRecipeInfosDeps): Promise<RecipeInfosView> => { }: GetRecipeInfosDeps): Promise<RecipeInfosView> => {
const headers = new Headers() const headers = new Headers()
if (token !== null) { if (accessToken !== null) {
headers.set('Authorization', `Bearer ${token}`) addBearer(headers, accessToken)
} }
const response = await fetch(import.meta.env.VITE_MME_API_URL + `/recipes?page=${pageNumber}&size=${pageSize}`, { const response = await fetch(import.meta.env.VITE_MME_API_URL + `/recipes?page=${pageNumber}&size=${pageSize}`, {
signal: abortSignal, signal: abortSignal,

View File

@ -1,15 +1,17 @@
import AccessToken from '../types/AccessToken'
import { ApiError } from './ApiError' import { ApiError } from './ApiError'
import ExpiredTokenError from './ExpiredTokenError' import ExpiredTokenError from './ExpiredTokenError'
import { addBearer } from './util'
export interface RemoveStarDeps { export interface RemoveStarDeps {
token: string accessToken: AccessToken
username: string username: string
slug: string slug: string
} }
const removeStar = async ({ token, username, slug }: RemoveStarDeps) => { const removeStar = async ({ accessToken, username, slug }: RemoveStarDeps) => {
const headers = new Headers() 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`, { const response = await fetch(import.meta.env.VITE_MME_API_URL + `/recipes/${username}/${slug}/star`, {
headers, headers,
method: 'DELETE', method: 'DELETE',

View File

@ -1,3 +1,4 @@
import AccessToken from '../types/AccessToken'
import { ApiError } from './ApiError' import { ApiError } from './ApiError'
import ExpiredTokenError from './ExpiredTokenError' import ExpiredTokenError from './ExpiredTokenError'
import { import {
@ -6,17 +7,23 @@ import {
toGetRecipeViewWithRawText toGetRecipeViewWithRawText
} from './types/GetRecipeView' } from './types/GetRecipeView'
import UpdateRecipeSpec from './types/UpdateRecipeSpec' import UpdateRecipeSpec from './types/UpdateRecipeSpec'
import { addBearer } from './util'
export interface UpdateRecipeDeps { export interface UpdateRecipeDeps {
spec: UpdateRecipeSpec spec: UpdateRecipeSpec
token: string accessToken: AccessToken
username: string username: string
slug: string slug: string
} }
const updateRecipe = async ({ spec, token, username, slug }: UpdateRecipeDeps): Promise<GetRecipeViewWithRawText> => { const updateRecipe = async ({
spec,
accessToken,
username,
slug
}: UpdateRecipeDeps): Promise<GetRecipeViewWithRawText> => {
const headers = new Headers() const headers = new Headers()
headers.set('Authorization', `Bearer ${token}`) addBearer(headers, accessToken)
headers.set('Content-type', 'application/json') headers.set('Content-type', 'application/json')
const response = await fetch(import.meta.env.VITE_MME_API_URL + `/recipes/${username}/${slug}`, { const response = await fetch(import.meta.env.VITE_MME_API_URL + `/recipes/${username}/${slug}`, {
headers, headers,

5
src/api/util.ts Normal file
View File

@ -0,0 +1,5 @@
import AccessToken from '../types/AccessToken'
export const addBearer = (headers: Headers, accessToken: AccessToken) => {
headers.set('Authorization', `Bearer ${accessToken.token}`)
}

View File

@ -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 { export interface AuthContextType {
token: string | null accessToken: AccessToken | null
username: string | null putToken: (token: AccessToken | null) => void
putToken(token: string, username: string, cb?: () => void): void
clearToken(cb?: () => void): void
} }
interface AuthState { interface AuthReducerState {
token: string | null accessToken: AccessToken | null
username: string | null }
putCb?: () => void
clearCb?: () => void 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<AuthContextType | null>(null) const AuthContext = createContext<AuthContextType | null>(null)
export const AuthProvider = ({ children }: React.PropsWithChildren) => { export const AuthProvider = ({ children }: React.PropsWithChildren) => {
const [authState, setAuthState] = useState<AuthState>({ const [state, dispatch] = useReducer(authReducer, initialState)
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])
return ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ value={{
token: authState.token, accessToken: state.accessToken,
username: authState.username, putToken: token => {
putToken(token, username, cb) { if (token === null) {
setAuthState({ dispatch({ tag: 'clearToken' })
token, } else {
username, dispatch({ tag: 'putToken', accessToken: token })
putCb: cb }
})
},
clearToken(cb) {
setAuthState({
token: null,
username: null,
clearCb: cb
})
} }
}} }}
> >

View File

@ -1,4 +1,4 @@
import { useLocation, useNavigate, useRouter } from '@tanstack/react-router' import { useLocation, useNavigate } from '@tanstack/react-router'
import { useAuth } from '../../auth' import { useAuth } from '../../auth'
import classes from './header.module.css' import classes from './header.module.css'
@ -7,20 +7,17 @@ export interface HeaderProps {
} }
const Header = ({ username }: HeaderProps) => { const Header = ({ username }: HeaderProps) => {
const auth = useAuth() const { putToken } = useAuth()
const router = useRouter()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const onLogin = async () => { const onLogin = () => {
navigate({ to: '/login', search: { redirect: location.href } }) navigate({ to: '/login', search: { redirect: location.href } })
} }
const onLogout = async () => { const onLogout = () => {
auth.clearToken(async () => { putToken(null)
await router.invalidate() navigate({ to: '/login' })
await navigate({ to: '/login' })
})
} }
return ( return (

View File

@ -86,7 +86,7 @@ export interface EditRecipeProps {
} }
const EditRecipe = ({ username, slug }: EditRecipeProps) => { const EditRecipe = ({ username, slug }: EditRecipeProps) => {
const auth = useAuth() const { accessToken } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
// useEffect(() => { // useEffect(() => {
@ -105,7 +105,7 @@ const EditRecipe = ({ username, slug }: EditRecipeProps) => {
queryKey: ['recipes', username, slug], queryKey: ['recipes', username, slug],
queryFn: ({ signal }) => queryFn: ({ signal }) =>
getRecipe({ getRecipe({
authContext: auth, accessToken,
username, username,
slug, slug,
abortSignal: signal, abortSignal: signal,
@ -140,10 +140,10 @@ const EditRecipe = ({ username, slug }: EditRecipeProps) => {
const mutation = useMutation( const mutation = useMutation(
{ {
mutationFn: () => { mutationFn: () => {
if (auth.token !== null) { if (accessToken !== null) {
return updateRecipe({ return updateRecipe({
spec, spec,
token: auth.token, accessToken,
username, username,
slug slug
}) })

View File

@ -51,14 +51,14 @@ interface RecipeStarButtonProps {
} }
const RecipeStarButton = ({ username, slug, isStarred, starCount }: RecipeStarButtonProps) => { const RecipeStarButton = ({ username, slug, isStarred, starCount }: RecipeStarButtonProps) => {
const authContext = useAuth() const { accessToken } = useAuth()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const addStarMutation = useMutation({ const addStarMutation = useMutation({
mutationFn: () => { mutationFn: () => {
if (authContext.token !== null) { if (accessToken !== null) {
return addStar({ return addStar({
token: authContext.token, accessToken,
slug, slug,
username username
}) })
@ -73,9 +73,9 @@ const RecipeStarButton = ({ username, slug, isStarred, starCount }: RecipeStarBu
const removeStarMutation = useMutation({ const removeStarMutation = useMutation({
mutationFn: () => { mutationFn: () => {
if (authContext.token !== null) { if (accessToken !== null) {
return removeStar({ return removeStar({
token: authContext.token, accessToken,
slug, slug,
username username
}) })
@ -111,7 +111,7 @@ export interface RecipeProps {
} }
const Recipe = ({ username, slug }: RecipeProps) => { const Recipe = ({ username, slug }: RecipeProps) => {
const authContext = useAuth() const { accessToken } = useAuth()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const recipeQuery = useQuery( const recipeQuery = useQuery(
@ -120,7 +120,7 @@ const Recipe = ({ username, slug }: RecipeProps) => {
queryFn: ({ signal: abortSignal }) => queryFn: ({ signal: abortSignal }) =>
getRecipe({ getRecipe({
abortSignal, abortSignal,
authContext, accessToken,
username, username,
slug slug
}) })
@ -138,7 +138,7 @@ const Recipe = ({ username, slug }: RecipeProps) => {
], ],
queryFn: ({ signal }) => queryFn: ({ signal }) =>
getImage({ getImage({
accessToken: authContext.token, accessToken,
signal, signal,
url: recipeQuery.data!.recipe.mainImage!.url url: recipeQuery.data!.recipe.mainImage!.url
}) })

View File

@ -11,7 +11,7 @@ const Recipes = () => {
const [pageNumber, setPageNumber] = useState(0) const [pageNumber, setPageNumber] = useState(0)
const [pageSize, setPageSize] = useState(20) const [pageSize, setPageSize] = useState(20)
const { token } = useAuth() const { accessToken } = useAuth()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data, isPending, error } = useQuery( const { data, isPending, error } = useQuery(
@ -22,7 +22,7 @@ const Recipes = () => {
abortSignal: signal, abortSignal: signal,
pageNumber, pageNumber,
pageSize, pageSize,
token accessToken
}) })
}, },
queryClient queryClient
@ -42,7 +42,7 @@ const Recipes = () => {
queryFn: async ({ signal }: any) => { queryFn: async ({ signal }: any) => {
// any needed in the params // any needed in the params
const imgUrl = await getImage({ const imgUrl = await getImage({
accessToken: token, accessToken,
signal, signal,
url: recipeInfoView.mainImage!.url url: recipeInfoView.mainImage!.url
}) })

View File

@ -8,11 +8,11 @@ import classes from './__root.module.css'
import MainNav from '../components/main-nav/MainNav' import MainNav from '../components/main-nav/MainNav'
const RootLayout = () => { const RootLayout = () => {
const { username } = useAuth() const { accessToken } = useAuth()
return ( return (
<> <>
<Header username={username ?? undefined} /> <Header username={accessToken?.username} />
<div className={classes.mainWrapper}> <div className={classes.mainWrapper}>
<MainNav /> <MainNav />
<main> <main>

View File

@ -2,7 +2,7 @@ import { Outlet, createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth')({ export const Route = createFileRoute('/_auth')({
beforeLoad({ context, location }) { beforeLoad({ context, location }) {
if (!context.auth.token) { if (!context.auth.accessToken) {
throw redirect({ throw redirect({
to: '/login', to: '/login',
search: { search: {

View File

@ -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 { FormEvent, useState } from 'react'
import { z } from 'zod' import { z } from 'zod'
import login from '../api/login' import login from '../api/login'
import { useAuth } from '../auth' import { useAuth } from '../auth'
const Login = () => { const Login = () => {
const auth = useAuth() const { putToken } = useAuth()
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const router = useRouter()
const navigate = useNavigate() const navigate = useNavigate()
const { redirect, reason } = useSearch({ from: '/login' }) const { redirect, reason } = useSearch({ from: '/login' })
@ -19,12 +17,15 @@ const Login = () => {
const password = (formData.get('password') as string | null) ?? '' const password = (formData.get('password') as string | null) ?? ''
const loginResult = await login(username, password) const loginResult = await login(username, password)
if (loginResult._tag === 'success') { if (loginResult._tag === 'success') {
auth.putToken(loginResult.loginView.accessToken, loginResult.loginView.username, async () => { const { accessToken: token, expires, username } = loginResult.loginView
await router.invalidate() putToken({
await navigate({ token,
to: redirect ?? '/recipes', expires,
search: {} username
}) })
navigate({
to: redirect ?? '/recipes',
search: {}
}) })
} else { } else {
setError(loginResult.error) setError(loginResult.error)
@ -72,10 +73,5 @@ export const Route = createFileRoute('/login')({
.optional(), .optional(),
redirect: z.string().optional().catch('') redirect: z.string().optional().catch('')
}), }),
beforeLoad({ context, search }) {
if (search.reason === undefined && context.auth.token !== null) {
throw redirect({ to: '/recipes' })
}
},
component: Login component: Login
}) })

7
src/types/AccessToken.ts Normal file
View File

@ -0,0 +1,7 @@
interface AccessToken {
token: string
username: string
expires: Date
}
export default AccessToken