Major refactoring of auth, Header, and overall login/logout experience.
This commit is contained in:
parent
1c02c11e90
commit
c35ef2f60b
@ -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 FullRecipeView, { RawFullRecipeView } from './types/FullRecipeView'
|
||||
import { toImageView } from './types/ImageView'
|
||||
import FullRecipeView, {
|
||||
RawFullRecipeView,
|
||||
toFullRecipeView
|
||||
} from './types/FullRecipeView'
|
||||
import LoginView from './types/LoginView'
|
||||
import SecurityExceptionView from './types/SecurityExceptionView'
|
||||
|
||||
export interface GetRecipeDeps {
|
||||
token: string | null
|
||||
authContext: AuthContextType
|
||||
username: string
|
||||
slug: string
|
||||
abortSignal: AbortSignal
|
||||
}
|
||||
|
||||
const getRecipe = async ({
|
||||
token,
|
||||
username,
|
||||
slug,
|
||||
abortSignal
|
||||
}: GetRecipeDeps): Promise<FullRecipeView> => {
|
||||
const getRecipe = async (
|
||||
{ authContext, username, slug, abortSignal }: GetRecipeDeps,
|
||||
isRetry: boolean = false
|
||||
): Promise<FullRecipeView> => {
|
||||
const headers = new Headers()
|
||||
if (token !== null) {
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
if (authContext.token !== null) {
|
||||
headers.set('Authorization', `Bearer ${authContext.token}`)
|
||||
}
|
||||
const response = await fetch(
|
||||
import.meta.env.VITE_MME_API_URL + `/recipes/${username}/${slug}`,
|
||||
@ -29,35 +32,45 @@ const getRecipe = async ({
|
||||
}
|
||||
)
|
||||
if (response.ok) {
|
||||
const {
|
||||
id,
|
||||
created: rawCreated,
|
||||
modified: rawModified,
|
||||
slug,
|
||||
title,
|
||||
text,
|
||||
ownerId,
|
||||
ownerUsername,
|
||||
starCount,
|
||||
viewerCount,
|
||||
mainImage: rawMainImage,
|
||||
isPublic
|
||||
} = (await response.json()) as RawFullRecipeView
|
||||
return {
|
||||
id,
|
||||
created: new Date(rawCreated),
|
||||
modified: rawModified ? new Date(rawModified) : null,
|
||||
slug,
|
||||
title,
|
||||
text,
|
||||
ownerId,
|
||||
ownerUsername,
|
||||
starCount,
|
||||
viewerCount,
|
||||
mainImage: toImageView(rawMainImage),
|
||||
isPublic
|
||||
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 === 404) {
|
||||
// no such resource
|
||||
throw notFound()
|
||||
} else {
|
||||
throw new ApiError(response.status, response.statusText)
|
||||
|
@ -1,18 +1,4 @@
|
||||
export type LoginResult = LoginSuccess | LoginFailure
|
||||
|
||||
export interface LoginSuccess {
|
||||
_tag: 'success'
|
||||
loginView: LoginView
|
||||
}
|
||||
export interface LoginFailure {
|
||||
_tag: 'failure'
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface LoginView {
|
||||
username: string
|
||||
accessToken: string
|
||||
}
|
||||
import LoginView, { LoginResult } from './types/LoginView'
|
||||
|
||||
const login = async (
|
||||
username: string,
|
||||
|
@ -1,4 +1,4 @@
|
||||
import ImageView, { RawImageView } from './ImageView'
|
||||
import ImageView, { RawImageView, toImageView } from './ImageView'
|
||||
|
||||
export interface RawFullRecipeView {
|
||||
id: number
|
||||
@ -30,4 +30,32 @@ interface FullRecipeView {
|
||||
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
|
||||
|
17
src/api/types/LoginView.ts
Normal file
17
src/api/types/LoginView.ts
Normal 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
|
7
src/api/types/SecurityExceptionView.ts
Normal file
7
src/api/types/SecurityExceptionView.ts
Normal file
@ -0,0 +1,7 @@
|
||||
interface SecurityExceptionView {
|
||||
status: number
|
||||
action: 'REFRESH' | 'LOGIN'
|
||||
message: string
|
||||
}
|
||||
|
||||
export default SecurityExceptionView
|
14
src/auth.tsx
14
src/auth.tsx
@ -2,12 +2,14 @@ import React, { createContext, useContext, useEffect, useState } from 'react'
|
||||
|
||||
export interface AuthContextType {
|
||||
token: string | null
|
||||
putToken(token: string, cb?: () => void): void
|
||||
username: string | null
|
||||
putToken(token: string, username: string, cb?: () => void): void
|
||||
clearToken(cb?: () => void): void
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
username: string | null
|
||||
putCb?: () => void
|
||||
clearCb?: () => void
|
||||
}
|
||||
@ -15,7 +17,10 @@ interface AuthState {
|
||||
const AuthContext = createContext<AuthContextType | null>(null)
|
||||
|
||||
export const AuthProvider = ({ children }: React.PropsWithChildren) => {
|
||||
const [authState, setAuthState] = useState<AuthState>({ token: null })
|
||||
const [authState, setAuthState] = useState<AuthState>({
|
||||
token: null,
|
||||
username: null
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (authState.token === null && authState.clearCb !== undefined) {
|
||||
@ -31,15 +36,18 @@ export const AuthProvider = ({ children }: React.PropsWithChildren) => {
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
token: authState.token,
|
||||
putToken(token, cb) {
|
||||
username: authState.username,
|
||||
putToken(token, username, cb) {
|
||||
setAuthState({
|
||||
token,
|
||||
username,
|
||||
putCb: cb
|
||||
})
|
||||
},
|
||||
clearToken(cb) {
|
||||
setAuthState({
|
||||
token: null,
|
||||
username: null,
|
||||
clearCb: cb
|
||||
})
|
||||
}
|
||||
|
@ -1,11 +1,20 @@
|
||||
import { useNavigate, useRouter } from '@tanstack/react-router'
|
||||
import { useLocation, useNavigate, useRouter } from '@tanstack/react-router'
|
||||
import { useAuth } from '../../auth'
|
||||
import classes from './header.module.css'
|
||||
|
||||
const Header = () => {
|
||||
export interface HeaderProps {
|
||||
username?: string
|
||||
}
|
||||
|
||||
const Header = ({ username }: HeaderProps) => {
|
||||
const auth = useAuth()
|
||||
const router = useRouter()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const onLogin = async () => {
|
||||
navigate({ to: '/login', search: { redirect: location.href } })
|
||||
}
|
||||
|
||||
const onLogout = async () => {
|
||||
auth.clearToken(async () => {
|
||||
@ -17,9 +26,19 @@ const Header = () => {
|
||||
return (
|
||||
<header>
|
||||
<h1 className={classes.mealsMadeEasy}>Meals Made Easy</h1>
|
||||
<nav>
|
||||
<button onClick={onLogout}>Logout</button>
|
||||
</nav>
|
||||
<div className={classes.right}>
|
||||
{username !== undefined ? (
|
||||
<>
|
||||
<p>Logged in as: {username}</p>
|
||||
<button onClick={onLogout}>Logout</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>Not logged in.</p>
|
||||
<button onClick={onLogin}>Login</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
@ -6,6 +6,13 @@ header {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.right {
|
||||
color: var(--primary-white);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.meals-made-easy {
|
||||
color: var(--primary-white);
|
||||
}
|
||||
|
@ -1,14 +1,17 @@
|
||||
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
|
||||
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
|
||||
import RouterContext from '../RouterContext'
|
||||
import Header from '../components/header/Header'
|
||||
import { useAuth } from '../auth'
|
||||
import Footer from '../components/footer/Footer'
|
||||
import Header from '../components/header/Header'
|
||||
import './__root.module.css'
|
||||
|
||||
const RootLayout = () => {
|
||||
const { username } = useAuth()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Header username={username ?? undefined} />
|
||||
<main>
|
||||
<Outlet />
|
||||
</main>
|
||||
|
@ -25,10 +25,14 @@ 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, async () => {
|
||||
await router.invalidate()
|
||||
await navigate({ to: search.redirect ?? '/recipes' })
|
||||
})
|
||||
auth.putToken(
|
||||
loginResult.loginView.accessToken,
|
||||
loginResult.loginView.username,
|
||||
async () => {
|
||||
await router.invalidate()
|
||||
await navigate({ to: search.redirect ?? '/recipes' })
|
||||
}
|
||||
)
|
||||
} else {
|
||||
setError(loginResult.error)
|
||||
}
|
||||
|
@ -15,7 +15,7 @@ export const Route = createFileRoute('/recipes/$username/$slug')({
|
||||
queryFn: () =>
|
||||
getRecipe({
|
||||
abortSignal: abortController.signal,
|
||||
token: context.auth.token,
|
||||
authContext: context.auth,
|
||||
username: params.username,
|
||||
slug: params.slug
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user