From 673bfb6fe7ecae839c3230682870f22643ffc341 Mon Sep 17 00:00:00 2001 From: Rainnny7 Date: Tue, 17 Sep 2024 17:04:04 -0400 Subject: [PATCH] impl the user store --- src/app/(pages)/auth/page.tsx | 34 +++--- src/app/(pages)/dashboard/layout.tsx | 15 +++ src/app/(pages)/dashboard/page.tsx | 20 +++- src/app/provider/user-provider.tsx | 90 +++++++++++++++ src/app/store/user-store-props.ts | 55 +++++++++ src/app/store/user-store.ts | 11 -- src/components/auth/auth-form.tsx | 150 ++++++++++++++++--------- src/components/auth/greeting.tsx | 35 ++++++ src/components/auth/oauth-provider.tsx | 20 ++-- src/components/branding.tsx | 2 +- src/components/dashboard/loader.tsx | 21 ++++ src/lib/api.ts | 48 ++++++-- 12 files changed, 401 insertions(+), 100 deletions(-) create mode 100644 src/app/(pages)/dashboard/layout.tsx create mode 100644 src/app/provider/user-provider.tsx create mode 100644 src/app/store/user-store-props.ts delete mode 100644 src/app/store/user-store.ts create mode 100644 src/components/auth/greeting.tsx create mode 100644 src/components/dashboard/loader.tsx diff --git a/src/app/(pages)/auth/page.tsx b/src/app/(pages)/auth/page.tsx index 991a397..e6847b8 100644 --- a/src/app/(pages)/auth/page.tsx +++ b/src/app/(pages)/auth/page.tsx @@ -1,12 +1,17 @@ "use client"; import { ReactElement } from "react"; -import Branding from "@/components/branding"; import OAuthProvider from "@/components/auth/oauth-provider"; import { Separator } from "@/components/ui/separator"; import AuthForm from "@/components/auth/auth-form"; import { motion } from "framer-motion"; +import Greeting from "@/components/auth/greeting"; +/** + * The page to authenticate with. + * + * @return the auth page + */ const AuthPage = (): ReactElement => (
( animate={{ x: 0, opacity: 1 }} transition={{ duration: 0.4 }} > -

- Good Evening, -

+ - +
+ +

or

+ +
-
); +/** + * The OAuth providers to login with. + * + * @return the providers jsx + */ const OAuthProviders = (): ReactElement => ( -
-

- Continue with a third party... -

-
- - -
+
+ +
); diff --git a/src/app/(pages)/dashboard/layout.tsx b/src/app/(pages)/dashboard/layout.tsx new file mode 100644 index 0000000..2892eef --- /dev/null +++ b/src/app/(pages)/dashboard/layout.tsx @@ -0,0 +1,15 @@ +import { ReactElement, ReactNode } from "react"; +import UserProvider from "@/app/provider/user-provider"; + +/** + * The layout for the dashboard pages. + * + * @param children the children to render + * @returns the layout jsx + */ +const DashboardLayout = ({ + children, +}: Readonly<{ + children: ReactNode; +}>): ReactElement => {children}; +export default DashboardLayout; diff --git a/src/app/(pages)/dashboard/page.tsx b/src/app/(pages)/dashboard/page.tsx index 7fb55e5..ea3e3bc 100644 --- a/src/app/(pages)/dashboard/page.tsx +++ b/src/app/(pages)/dashboard/page.tsx @@ -1,6 +1,18 @@ -import { ReactElement } from "react"; +"use client"; -const DashboardPage = (): ReactElement => ( -
PulseApp Dashboard
-); +import { ReactElement } from "react"; +import { UserState } from "@/app/store/user-store-props"; +import { User } from "@/app/types/user"; +import { useUserContext } from "@/app/provider/user-provider"; + +const DashboardPage = (): ReactElement => { + const user: User | undefined = useUserContext( + (state: UserState) => state.user + ); + return ( +
+ PulseApp Dashboard, hello {user?.email} +
+ ); +}; export default DashboardPage; diff --git a/src/app/provider/user-provider.tsx b/src/app/provider/user-provider.tsx new file mode 100644 index 0000000..28ad680 --- /dev/null +++ b/src/app/provider/user-provider.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { + ReactNode, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import createUserStore, { + UserContext, + UserState, + UserStore, +} from "@/app/store/user-store-props"; +import { User } from "@/app/types/user"; +import { Cookies, useCookies } from "next-client-cookies"; +import { Session } from "@/app/types/session"; +import { apiRequest } from "@/lib/api"; +import { StoreApi, useStore } from "zustand"; +import { useRouter } from "next/navigation"; +import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"; +import DashboardLoader from "@/components/dashboard/loader"; + +/** + * The provider that will provide user context to children. + * + * @param children the children to provide context to + * @return the provider + */ +const UserProvider = ({ children }: { children: ReactNode }) => { + const storeRef = useRef(); + const [authorized, setAuthorized] = useState(false); + const cookies: Cookies = useCookies(); + const router: AppRouterInstance = useRouter(); + if (!storeRef.current) { + storeRef.current = createUserStore(); + } + + /** + * Fetch the user from the stored session in their browser. + */ + const fetchUser = useCallback(async () => { + const rawSession: string | undefined = cookies.get("session"); + + // No session cookie, go back to auth + if (!rawSession) { + router.push("/auth"); + return; + } + const session: Session = JSON.parse(rawSession) as Session; + const { data, error } = await apiRequest({ + endpoint: "/user/@me", + session, + }); + // Failed to login (unauthorized?) + if (error) { + cookies.remove("session"); + router.push("/auth"); + return; + } + storeRef.current?.getState().authorize(session, data as User); + setAuthorized(true); + }, [cookies, router]); + useEffect(() => { + fetchUser(); + }, [fetchUser]); + + return ( + + {authorized ? children : } + + ); +}; + +/** + * Use the user context. + * + * @param selector the state selector to use + * @return the value returned by the selector + */ +export function useUserContext(selector: (state: UserState) => T): T { + const store: StoreApi | null = useContext(UserContext); + if (!store) { + throw new Error("Missing UserContext.Provider in the tree"); + } + return useStore(store, selector); +} + +export default UserProvider; diff --git a/src/app/store/user-store-props.ts b/src/app/store/user-store-props.ts new file mode 100644 index 0000000..3e831fd --- /dev/null +++ b/src/app/store/user-store-props.ts @@ -0,0 +1,55 @@ +import { createStore } from "zustand"; +import { User } from "@/app/types/user"; +import { createContext } from "react"; +import { Session } from "@/app/types/session"; + +export const UserContext = createContext(null); + +/** + * The props in the store. + */ +export type UserStoreProps = { + /** + * The user's session, if any. + */ + session: Session | undefined; + + /** + * The user obtained from the session, if any. + */ + user: User | undefined; +}; + +/** + * The user store state. + */ +export type UserState = UserStoreProps & { + /** + * Authorize the user. + * + * @param session the user's session + * @param user the user + */ + authorize: (session: Session, user: User) => void; +}; + +/** + * The type representing the user store. + */ +export type UserStore = ReturnType; + +/** + * Create a new user store. + */ +const createUserStore = () => { + const defaultProps: UserStoreProps = { + session: undefined, + user: undefined, + }; + return createStore()((set) => ({ + ...defaultProps, + authorize: (session: Session, user: User) => + set(() => ({ session, user })), + })); +}; +export default createUserStore; diff --git a/src/app/store/user-store.ts b/src/app/store/user-store.ts deleted file mode 100644 index 9c3ca72..0000000 --- a/src/app/store/user-store.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { create } from "zustand"; -import { User } from "@/app/types/user"; - -export type UserStore = { - user: User | undefined; -}; - -const useUserStore = create((set) => ({ - user: undefined, -})); -export default useUserStore; diff --git a/src/components/auth/auth-form.tsx b/src/components/auth/auth-form.tsx index 52bd036..59d3580 100644 --- a/src/components/auth/auth-form.tsx +++ b/src/components/auth/auth-form.tsx @@ -7,13 +7,19 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import AnimatedRightChevron from "@/components/animated-right-chevron"; -import { ArrowPathIcon } from "@heroicons/react/24/outline"; +import { + ArrowPathIcon, + AtSymbolIcon, + EnvelopeIcon, + LockClosedIcon, +} from "@heroicons/react/24/outline"; import { apiRequest } from "@/lib/api"; import { Session } from "@/app/types/session"; import { Cookies, useCookies } from "next-client-cookies"; import { useRouter } from "next/navigation"; import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"; import { motion } from "framer-motion"; +import Turnstile from "react-turnstile"; /** * Define the form schemas for the various stages. @@ -27,7 +33,7 @@ const RegisterSchema = z.object({ username: z.string(), password: z.string(), passwordConfirmation: z.string(), - captchaResponse: z.string(), + // captchaResponse: z.string(), }); const LoginSchema = z.object({ @@ -39,14 +45,25 @@ const LoginSchema = z.object({ // captchaResponse: z.string(), }); -const inputVariants = { +/** + * The animation variants for the inputs. + */ +const inputAnimationVariants = { hidden: { x: -50, opacity: 0 }, visible: { x: 0, opacity: 1, transition: { duration: 0.15 } }, }; +/** + * The form used to authenticate. + * + * @return the form jsx + */ const AuthForm = (): ReactElement => { const [stage, setStage] = useState<"email" | "register" | "login">("email"); const [loading, setLoading] = useState(false); + const [captchaResponse, setCaptchaResponse] = useState( + undefined + ); const [error, setError] = useState(undefined); const cookies: Cookies = useCookies(); const router: AppRouterInstance = useRouter(); @@ -74,27 +91,34 @@ const AuthForm = (): ReactElement => { username, password, passwordConfirmation, - captchaResponse, }: any) => { setLoading(true); if (stage === "email") { - const { data, error } = await apiRequest<{ exists: boolean }>( - `/user/exists?email=${email}` - ); + const { data, error } = await apiRequest<{ exists: boolean }>({ + endpoint: `/user/exists?email=${email}`, + }); setStage(data?.exists ? "login" : "register"); - } else if (stage === "login") { - const { data, error } = await apiRequest( - `/auth/login`, - "POST", - { - email, - password, - captchaResponse, - } - ); - if (error) { - setError(error.message); - } else { + } else { + const registering: boolean = stage === "register"; + const { data, error } = await apiRequest({ + endpoint: `/auth/${registering ? "register" : "login"}`, + method: "POST", + body: registering + ? { + email, + username, + password, + passwordConfirmation, + captchaResponse, + } + : { + email, + password, + captchaResponse, + }, + }); + setError(error?.message ?? undefined); + if (!error) { cookies.set("session", JSON.stringify(data), { expires: ((data?.expires as number) - Date.now()) / 86_400_000, @@ -111,38 +135,49 @@ const AuthForm = (): ReactElement => { // Render the form return (
-

- Or use your email address... -

- {/* Email Address */} - +
+ + +
{/* Username */} {stage === "register" && ( - + + + + )} {/* Password */} {stage !== "email" && ( + { {/* Password Confirmation */} {stage === "register" && ( - + + + + )} - {/*{stage !== "email" && (*/} - {/* */} - {/*)}*/} + {/* Captcha */} + setCaptchaResponse(token)} + /> + + {/* Errors */} + {error &&

{error}

} {/* Submit Form */} diff --git a/src/components/branding.tsx b/src/components/branding.tsx index f1b7616..7e02e68 100644 --- a/src/components/branding.tsx +++ b/src/components/branding.tsx @@ -4,7 +4,7 @@ import { cva } from "class-variance-authority"; import { cn } from "@/lib/utils"; const brandingVariants = cva( - "relative hover:opacity-75 transition-all transform-gpu", + "relative hover:opacity-75 select-none transition-all transform-gpu", { variants: { size: { diff --git a/src/components/dashboard/loader.tsx b/src/components/dashboard/loader.tsx new file mode 100644 index 0000000..dff01a5 --- /dev/null +++ b/src/components/dashboard/loader.tsx @@ -0,0 +1,21 @@ +import { ReactElement } from "react"; +import Branding from "@/components/branding"; + +/** + * The loader for the dashboard pages. + * + * @return the loader jsx + */ +const DashboardLoader = (): ReactElement => ( +
+ +

Loading

+
+); +export default DashboardLoader; diff --git a/src/lib/api.ts b/src/lib/api.ts index 73775bf..48411a2 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,15 +1,46 @@ +import { Session } from "@/app/types/session"; +import { ApiError } from "@/app/types/api-error"; + +type ApiRequestProps = { + /** + * The endpoint to send the request to. + */ + endpoint: string; + + /** + * The session to authenticate with, if any. + */ + session?: Session | undefined; + + /** + * The method of the request to make. + */ + method?: string | undefined; + + /** + * The optional body of the request. + */ + body?: any | undefined; +}; + /** * Send a request to the API. * * @param endpoint the endpoint to request + * @param session the session to auth with * @param body the optional request body to send * @param method the request method to use + * @return the api response */ -export const apiRequest = async ( - endpoint: string, - method?: string | undefined, - body?: any | undefined -): Promise<{ data: T | undefined; error: ApiError | undefined }> => { +export const apiRequest = async ({ + endpoint, + method, + session, + body, +}: ApiRequestProps): Promise<{ + data: T | undefined; + error: ApiError | undefined; +}> => { const response: Response = await fetch( `${process.env.NEXT_PUBLIC_API_ENDPOINT}${endpoint}`, { @@ -20,6 +51,11 @@ export const apiRequest = async ( : undefined, headers: { "Content-Type": `application/${method === "POST" ? "x-www-form-urlencoded" : "json"}`, + ...(session + ? { + Authorization: `Bearer ${session.accessToken}`, + } + : {}), }, } ); @@ -29,5 +65,3 @@ export const apiRequest = async ( } return { data: data as T, error: undefined }; }; - -import { ApiError } from "@/app/types/api-error";