From c35ef2f60b7b3c75e4400a8ab24fff32c7788589 Mon Sep 17 00:00:00 2001 From: Jesse Brault Date: Tue, 6 Aug 2024 11:05:48 -0500 Subject: [PATCH] Major refactoring of auth, Header, and overall login/logout experience. --- src/api/getRecipe.ts | 91 ++++++++++++++----------- src/api/login.ts | 16 +---- src/api/types/FullRecipeView.ts | 30 +++++++- src/api/types/LoginView.ts | 17 +++++ src/api/types/SecurityExceptionView.ts | 7 ++ src/auth.tsx | 14 +++- src/components/header/Header.tsx | 29 ++++++-- src/components/header/header.module.css | 7 ++ src/routes/__root.tsx | 7 +- src/routes/login.tsx | 12 ++-- src/routes/recipes_/$username.$slug.tsx | 2 +- 11 files changed, 162 insertions(+), 70 deletions(-) create mode 100644 src/api/types/LoginView.ts create mode 100644 src/api/types/SecurityExceptionView.ts diff --git a/src/api/getRecipe.ts b/src/api/getRecipe.ts index 6a3b1a5..cffdc32 100644 --- a/src/api/getRecipe.ts +++ b/src/api/getRecipe.ts @@ -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 => { +const getRecipe = async ( + { authContext, username, slug, abortSignal }: GetRecipeDeps, + isRetry: boolean = false +): Promise => { 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) diff --git a/src/api/login.ts b/src/api/login.ts index d52bf70..d616785 100644 --- a/src/api/login.ts +++ b/src/api/login.ts @@ -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, diff --git a/src/api/types/FullRecipeView.ts b/src/api/types/FullRecipeView.ts index d5e62a3..cda9c2a 100644 --- a/src/api/types/FullRecipeView.ts +++ b/src/api/types/FullRecipeView.ts @@ -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 diff --git a/src/api/types/LoginView.ts b/src/api/types/LoginView.ts new file mode 100644 index 0000000..75eb7c5 --- /dev/null +++ b/src/api/types/LoginView.ts @@ -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 diff --git a/src/api/types/SecurityExceptionView.ts b/src/api/types/SecurityExceptionView.ts new file mode 100644 index 0000000..d53596d --- /dev/null +++ b/src/api/types/SecurityExceptionView.ts @@ -0,0 +1,7 @@ +interface SecurityExceptionView { + status: number + action: 'REFRESH' | 'LOGIN' + message: string +} + +export default SecurityExceptionView diff --git a/src/auth.tsx b/src/auth.tsx index 0ce75f9..14c8777 100644 --- a/src/auth.tsx +++ b/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(null) export const AuthProvider = ({ children }: React.PropsWithChildren) => { - const [authState, setAuthState] = useState({ token: null }) + const [authState, setAuthState] = useState({ + token: null, + username: null + }) useEffect(() => { if (authState.token === null && authState.clearCb !== undefined) { @@ -31,15 +36,18 @@ export const AuthProvider = ({ children }: React.PropsWithChildren) => { { +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 (

Meals Made Easy

- +
+ {username !== undefined ? ( + <> +

Logged in as: {username}

+ + + ) : ( + <> +

Not logged in.

+ + + )} +
) } diff --git a/src/components/header/header.module.css b/src/components/header/header.module.css index 67308f5..ee59d26 100644 --- a/src/components/header/header.module.css +++ b/src/components/header/header.module.css @@ -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); } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 044e0bb..f9f0f79 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -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 ( <> -
+
diff --git a/src/routes/login.tsx b/src/routes/login.tsx index 35f7dcd..72f4ab0 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -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) } diff --git a/src/routes/recipes_/$username.$slug.tsx b/src/routes/recipes_/$username.$slug.tsx index f77f5a8..03bfa72 100644 --- a/src/routes/recipes_/$username.$slug.tsx +++ b/src/routes/recipes_/$username.$slug.tsx @@ -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 })