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 { 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)
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
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 {
|
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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
})
|
})
|
||||||
|
Loading…
Reference in New Issue
Block a user