Major refactoring of auth, Header, and overall login/logout experience.

This commit is contained in:
Jesse Brault 2024-08-06 11:05:48 -05:00
parent 1c02c11e90
commit c35ef2f60b
11 changed files with 162 additions and 70 deletions

View File

@ -1,24 +1,27 @@
import { notFound } from '@tanstack/react-router' import { notFound, redirect } from '@tanstack/react-router'
import { AuthContextType } from '../auth'
import { ApiError } from './ApiError' import { ApiError } from './ApiError'
import FullRecipeView, { RawFullRecipeView } from './types/FullRecipeView' import FullRecipeView, {
import { toImageView } from './types/ImageView' RawFullRecipeView,
toFullRecipeView
} from './types/FullRecipeView'
import LoginView from './types/LoginView'
import SecurityExceptionView from './types/SecurityExceptionView'
export interface GetRecipeDeps { export interface GetRecipeDeps {
token: string | null authContext: AuthContextType
username: string username: string
slug: string slug: string
abortSignal: AbortSignal abortSignal: AbortSignal
} }
const getRecipe = async ({ const getRecipe = async (
token, { authContext, username, slug, abortSignal }: GetRecipeDeps,
username, isRetry: boolean = false
slug, ): Promise<FullRecipeView> => {
abortSignal
}: GetRecipeDeps): Promise<FullRecipeView> => {
const headers = new Headers() const headers = new Headers()
if (token !== null) { if (authContext.token !== null) {
headers.set('Authorization', `Bearer ${token}`) headers.set('Authorization', `Bearer ${authContext.token}`)
} }
const response = await fetch( const response = await fetch(
import.meta.env.VITE_MME_API_URL + `/recipes/${username}/${slug}`, import.meta.env.VITE_MME_API_URL + `/recipes/${username}/${slug}`,
@ -29,35 +32,45 @@ const getRecipe = async ({
} }
) )
if (response.ok) { if (response.ok) {
const { return toFullRecipeView((await response.json()) as RawFullRecipeView)
id, } else if (response.status === 401 && !isRetry) {
created: rawCreated, // must be logged in to view this resource
modified: rawModified, 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, slug,
title, username
text, },
ownerId, true
ownerUsername, )
starCount, } else {
viewerCount, throw new ApiError(
mainImage: rawMainImage, refreshResponse.status,
isPublic refreshResponse.statusText
} = (await response.json()) as RawFullRecipeView )
return { }
id, } else {
created: new Date(rawCreated), // do login
modified: rawModified ? new Date(rawModified) : null, throw redirect({ to: '/login' })
slug,
title,
text,
ownerId,
ownerUsername,
starCount,
viewerCount,
mainImage: toImageView(rawMainImage),
isPublic
} }
} else if (response.status === 404) { } else if (response.status === 404) {
// no such resource
throw notFound() throw notFound()
} else { } else {
throw new ApiError(response.status, response.statusText) throw new ApiError(response.status, response.statusText)

View File

@ -1,18 +1,4 @@
export type LoginResult = LoginSuccess | LoginFailure import LoginView, { LoginResult } from './types/LoginView'
export interface LoginSuccess {
_tag: 'success'
loginView: LoginView
}
export interface LoginFailure {
_tag: 'failure'
error: string
}
export interface LoginView {
username: string
accessToken: string
}
const login = async ( const login = async (
username: string, username: string,

View File

@ -1,4 +1,4 @@
import ImageView, { RawImageView } from './ImageView' import ImageView, { RawImageView, toImageView } from './ImageView'
export interface RawFullRecipeView { export interface RawFullRecipeView {
id: number id: number
@ -30,4 +30,32 @@ interface FullRecipeView {
isPublic: boolean isPublic: boolean
} }
export const toFullRecipeView = ({
id,
created: rawCreated,
modified: rawModified,
slug,
title,
text,
ownerId,
ownerUsername,
starCount,
viewerCount,
mainImage: rawMainImage,
isPublic
}: RawFullRecipeView) => ({
id,
created: new Date(rawCreated),
modified: rawModified ? new Date(rawModified) : null,
slug,
title,
text,
ownerId,
ownerUsername,
starCount,
viewerCount,
mainImage: toImageView(rawMainImage),
isPublic
})
export default FullRecipeView export default FullRecipeView

View File

@ -0,0 +1,17 @@
export type LoginResult = LoginSuccess | LoginFailure
export interface LoginSuccess {
_tag: 'success'
loginView: LoginView
}
export interface LoginFailure {
_tag: 'failure'
error: string
}
interface LoginView {
username: string
accessToken: string
}
export default LoginView

View File

@ -0,0 +1,7 @@
interface SecurityExceptionView {
status: number
action: 'REFRESH' | 'LOGIN'
message: string
}
export default SecurityExceptionView

View File

@ -2,12 +2,14 @@ import React, { createContext, useContext, useEffect, useState } from 'react'
export interface AuthContextType { export interface AuthContextType {
token: string | null token: string | null
putToken(token: string, cb?: () => void): void username: string | null
putToken(token: string, username: string, cb?: () => void): void
clearToken(cb?: () => void): void clearToken(cb?: () => void): void
} }
interface AuthState { interface AuthState {
token: string | null token: string | null
username: string | null
putCb?: () => void putCb?: () => void
clearCb?: () => void clearCb?: () => void
} }
@ -15,7 +17,10 @@ interface AuthState {
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>({ token: null }) const [authState, setAuthState] = useState<AuthState>({
token: null,
username: null
})
useEffect(() => { useEffect(() => {
if (authState.token === null && authState.clearCb !== undefined) { if (authState.token === null && authState.clearCb !== undefined) {
@ -31,15 +36,18 @@ export const AuthProvider = ({ children }: React.PropsWithChildren) => {
<AuthContext.Provider <AuthContext.Provider
value={{ value={{
token: authState.token, token: authState.token,
putToken(token, cb) { username: authState.username,
putToken(token, username, cb) {
setAuthState({ setAuthState({
token, token,
username,
putCb: cb putCb: cb
}) })
}, },
clearToken(cb) { clearToken(cb) {
setAuthState({ setAuthState({
token: null, token: null,
username: null,
clearCb: cb clearCb: cb
}) })
} }

View File

@ -1,11 +1,20 @@
import { useNavigate, useRouter } from '@tanstack/react-router' import { useLocation, useNavigate, useRouter } from '@tanstack/react-router'
import { useAuth } from '../../auth' import { useAuth } from '../../auth'
import classes from './header.module.css' import classes from './header.module.css'
const Header = () => { export interface HeaderProps {
username?: string
}
const Header = ({ username }: HeaderProps) => {
const auth = useAuth() const auth = useAuth()
const router = useRouter() const router = useRouter()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const onLogin = async () => {
navigate({ to: '/login', search: { redirect: location.href } })
}
const onLogout = async () => { const onLogout = async () => {
auth.clearToken(async () => { auth.clearToken(async () => {
@ -17,9 +26,19 @@ const Header = () => {
return ( return (
<header> <header>
<h1 className={classes.mealsMadeEasy}>Meals Made Easy</h1> <h1 className={classes.mealsMadeEasy}>Meals Made Easy</h1>
<nav> <div className={classes.right}>
{username !== undefined ? (
<>
<p>Logged in as: {username}</p>
<button onClick={onLogout}>Logout</button> <button onClick={onLogout}>Logout</button>
</nav> </>
) : (
<>
<p>Not logged in.</p>
<button onClick={onLogin}>Login</button>
</>
)}
</div>
</header> </header>
) )
} }

View File

@ -6,6 +6,13 @@ header {
padding: 20px; padding: 20px;
} }
.right {
color: var(--primary-white);
display: flex;
flex-direction: column;
align-items: end;
}
.meals-made-easy { .meals-made-easy {
color: var(--primary-white); color: var(--primary-white);
} }

View File

@ -1,14 +1,17 @@
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router' import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/router-devtools' import { TanStackRouterDevtools } from '@tanstack/router-devtools'
import RouterContext from '../RouterContext' import RouterContext from '../RouterContext'
import Header from '../components/header/Header' import { useAuth } from '../auth'
import Footer from '../components/footer/Footer' import Footer from '../components/footer/Footer'
import Header from '../components/header/Header'
import './__root.module.css' import './__root.module.css'
const RootLayout = () => { const RootLayout = () => {
const { username } = useAuth()
return ( return (
<> <>
<Header /> <Header username={username ?? undefined} />
<main> <main>
<Outlet /> <Outlet />
</main> </main>

View File

@ -25,10 +25,14 @@ 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, async () => { auth.putToken(
loginResult.loginView.accessToken,
loginResult.loginView.username,
async () => {
await router.invalidate() await router.invalidate()
await navigate({ to: search.redirect ?? '/recipes' }) await navigate({ to: search.redirect ?? '/recipes' })
}) }
)
} else { } else {
setError(loginResult.error) setError(loginResult.error)
} }

View File

@ -15,7 +15,7 @@ export const Route = createFileRoute('/recipes/$username/$slug')({
queryFn: () => queryFn: () =>
getRecipe({ getRecipe({
abortSignal: abortController.signal, abortSignal: abortController.signal,
token: context.auth.token, authContext: context.auth,
username: params.username, username: params.username,
slug: params.slug slug: params.slug
}) })