impl the user store
All checks were successful
Deploy / deploy (ubuntu-latest, 2.44.0) (push) Successful in 1m11s

This commit is contained in:
Braydon 2024-09-17 17:04:04 -04:00
parent 5c305d9629
commit 673bfb6fe7
12 changed files with 401 additions and 100 deletions

@ -1,12 +1,17 @@
"use client"; "use client";
import { ReactElement } from "react"; import { ReactElement } from "react";
import Branding from "@/components/branding";
import OAuthProvider from "@/components/auth/oauth-provider"; import OAuthProvider from "@/components/auth/oauth-provider";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import AuthForm from "@/components/auth/auth-form"; import AuthForm from "@/components/auth/auth-form";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import Greeting from "@/components/auth/greeting";
/**
* The page to authenticate with.
*
* @return the auth page
*/
const AuthPage = (): ReactElement => ( const AuthPage = (): ReactElement => (
<main className="min-h-screen flex justify-center items-center"> <main className="min-h-screen flex justify-center items-center">
<motion.div <motion.div
@ -15,26 +20,27 @@ const AuthPage = (): ReactElement => (
animate={{ x: 0, opacity: 1 }} animate={{ x: 0, opacity: 1 }}
transition={{ duration: 0.4 }} transition={{ duration: 0.4 }}
> >
<h1 className="text-3xl font-bold select-none pointer-events-none"> <Greeting />
Good Evening,
</h1>
<OAuthProviders /> <OAuthProviders />
<Separator className="-mb-1" /> <div className="mx-auto mb-1 flex gap-3 items-center select-none pointer-events-none">
<Separator className="w-28" />
<h1 className="opacity-65 leading-none">or</h1>
<Separator className="w-28" />
</div>
<AuthForm /> <AuthForm />
</motion.div> </motion.div>
<Branding className="absolute left-5 bottom-5 opacity-60" size="sm" />
</main> </main>
); );
/**
* The OAuth providers to login with.
*
* @return the providers jsx
*/
const OAuthProviders = (): ReactElement => ( const OAuthProviders = (): ReactElement => (
<div className="flex flex-col gap-2"> <div className="mt-1 flex gap-2.5">
<p className="opacity-50 select-none pointer-events-none"> <OAuthProvider name="GitHub" link="#" />
Continue with a third party... <OAuthProvider name="Google" link="#" />
</p>
<div className="flex gap-2.5">
<OAuthProvider name="GitHub" icon="github" link="#" />
<OAuthProvider name="Google" icon="google" link="#" />
</div>
</div> </div>
); );

@ -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 => <UserProvider>{children}</UserProvider>;
export default DashboardLayout;

@ -1,6 +1,18 @@
import { ReactElement } from "react"; "use client";
const DashboardPage = (): ReactElement => ( import { ReactElement } from "react";
<main className="min-h-screen">PulseApp Dashboard</main> 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 (
<main className="min-h-screen">
PulseApp Dashboard, hello {user?.email}
</main>
);
};
export default DashboardPage; export default DashboardPage;

@ -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<UserStore>();
const [authorized, setAuthorized] = useState<boolean>(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<User>({
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 (
<UserContext.Provider value={storeRef.current}>
{authorized ? children : <DashboardLoader />}
</UserContext.Provider>
);
};
/**
* Use the user context.
*
* @param selector the state selector to use
* @return the value returned by the selector
*/
export function useUserContext<T>(selector: (state: UserState) => T): T {
const store: StoreApi<UserState> | null = useContext(UserContext);
if (!store) {
throw new Error("Missing UserContext.Provider in the tree");
}
return useStore(store, selector);
}
export default UserProvider;

@ -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<UserStore | null>(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<typeof createUserStore>;
/**
* Create a new user store.
*/
const createUserStore = () => {
const defaultProps: UserStoreProps = {
session: undefined,
user: undefined,
};
return createStore<UserState>()((set) => ({
...defaultProps,
authorize: (session: Session, user: User) =>
set(() => ({ session, user })),
}));
};
export default createUserStore;

@ -1,11 +0,0 @@
import { create } from "zustand";
import { User } from "@/app/types/user";
export type UserStore = {
user: User | undefined;
};
const useUserStore = create<UserStore>((set) => ({
user: undefined,
}));
export default useUserStore;

@ -7,13 +7,19 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import AnimatedRightChevron from "@/components/animated-right-chevron"; 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 { apiRequest } from "@/lib/api";
import { Session } from "@/app/types/session"; import { Session } from "@/app/types/session";
import { Cookies, useCookies } from "next-client-cookies"; import { Cookies, useCookies } from "next-client-cookies";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"; import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import Turnstile from "react-turnstile";
/** /**
* Define the form schemas for the various stages. * Define the form schemas for the various stages.
@ -27,7 +33,7 @@ const RegisterSchema = z.object({
username: z.string(), username: z.string(),
password: z.string(), password: z.string(),
passwordConfirmation: z.string(), passwordConfirmation: z.string(),
captchaResponse: z.string(), // captchaResponse: z.string(),
}); });
const LoginSchema = z.object({ const LoginSchema = z.object({
@ -39,14 +45,25 @@ const LoginSchema = z.object({
// captchaResponse: z.string(), // captchaResponse: z.string(),
}); });
const inputVariants = { /**
* The animation variants for the inputs.
*/
const inputAnimationVariants = {
hidden: { x: -50, opacity: 0 }, hidden: { x: -50, opacity: 0 },
visible: { x: 0, opacity: 1, transition: { duration: 0.15 } }, visible: { x: 0, opacity: 1, transition: { duration: 0.15 } },
}; };
/**
* The form used to authenticate.
*
* @return the form jsx
*/
const AuthForm = (): ReactElement => { const AuthForm = (): ReactElement => {
const [stage, setStage] = useState<"email" | "register" | "login">("email"); const [stage, setStage] = useState<"email" | "register" | "login">("email");
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [captchaResponse, setCaptchaResponse] = useState<string | undefined>(
undefined
);
const [error, setError] = useState<string | undefined>(undefined); const [error, setError] = useState<string | undefined>(undefined);
const cookies: Cookies = useCookies(); const cookies: Cookies = useCookies();
const router: AppRouterInstance = useRouter(); const router: AppRouterInstance = useRouter();
@ -74,27 +91,34 @@ const AuthForm = (): ReactElement => {
username, username,
password, password,
passwordConfirmation, passwordConfirmation,
captchaResponse,
}: any) => { }: any) => {
setLoading(true); setLoading(true);
if (stage === "email") { if (stage === "email") {
const { data, error } = await apiRequest<{ exists: boolean }>( const { data, error } = await apiRequest<{ exists: boolean }>({
`/user/exists?email=${email}` endpoint: `/user/exists?email=${email}`,
); });
setStage(data?.exists ? "login" : "register"); setStage(data?.exists ? "login" : "register");
} else if (stage === "login") { } else {
const { data, error } = await apiRequest<Session>( const registering: boolean = stage === "register";
`/auth/login`, const { data, error } = await apiRequest<Session>({
"POST", endpoint: `/auth/${registering ? "register" : "login"}`,
{ method: "POST",
email, body: registering
password, ? {
captchaResponse, email,
} username,
); password,
if (error) { passwordConfirmation,
setError(error.message); captchaResponse,
} else { }
: {
email,
password,
captchaResponse,
},
});
setError(error?.message ?? undefined);
if (!error) {
cookies.set("session", JSON.stringify(data), { cookies.set("session", JSON.stringify(data), {
expires: expires:
((data?.expires as number) - Date.now()) / 86_400_000, ((data?.expires as number) - Date.now()) / 86_400_000,
@ -111,38 +135,49 @@ const AuthForm = (): ReactElement => {
// Render the form // Render the form
return ( return (
<form className="flex flex-col gap-2" onSubmit={handleSubmit(onSubmit)}> <form className="flex flex-col gap-2" onSubmit={handleSubmit(onSubmit)}>
<p className="opacity-50 select-none pointer-events-none">
Or use your email address...
</p>
{/* Email Address */} {/* Email Address */}
<Input <div className="relative">
className="rounded-lg" <EnvelopeIcon className="absolute left-2 top-[0.6rem] w-[1.15rem] h-[1.15rem]" />
type="email" <Input
placeholder="bob@example.com" className="pl-8 rounded-lg"
{...register("email")} type="email"
/> placeholder="bob@example.com"
{...register("email")}
/>
</div>
{/* Username */} {/* Username */}
{stage === "register" && ( {stage === "register" && (
<Input <motion.div
className="rounded-lg" key="username"
placeholder="@username" className="relative"
{...register("username")} initial="hidden"
/> animate="visible"
exit="exit"
variants={inputAnimationVariants}
>
<AtSymbolIcon className="absolute left-2 top-[0.6rem] w-[1.15rem] h-[1.15rem]" />
<Input
className="pl-8 rounded-lg"
placeholder="Username"
{...register("username")}
/>
</motion.div>
)} )}
{/* Password */} {/* Password */}
{stage !== "email" && ( {stage !== "email" && (
<motion.div <motion.div
key="password" key="password"
className="relative"
initial="hidden" initial="hidden"
animate="visible" animate="visible"
exit="exit" exit="exit"
variants={inputVariants} variants={inputAnimationVariants}
> >
<LockClosedIcon className="absolute left-2 top-[0.6rem] w-[1.15rem] h-[1.15rem]" />
<Input <Input
className="rounded-lg" className="pl-8 rounded-lg"
type="password" type="password"
placeholder="Password" placeholder="Password"
{...register("password")} {...register("password")}
@ -152,25 +187,37 @@ const AuthForm = (): ReactElement => {
{/* Password Confirmation */} {/* Password Confirmation */}
{stage === "register" && ( {stage === "register" && (
<Input <motion.div
className="rounded-lg" key="passwordConfirmation"
type="password" className="relative"
placeholder="Confirm Password" initial="hidden"
{...register("passwordConfirmation")} animate="visible"
/> exit="exit"
variants={inputAnimationVariants}
>
<LockClosedIcon className="absolute left-2 top-[0.6rem] w-[1.15rem] h-[1.15rem]" />
<Input
className="pl-8 rounded-lg"
type="password"
placeholder="Confirm Password"
{...register("passwordConfirmation")}
/>
</motion.div>
)} )}
{/*{stage !== "email" && (*/} {/* Captcha */}
{/* <Turnstile*/} <Turnstile
{/* responseFieldName="captchaResponse"*/} sitekey={process.env.NEXT_PUBLIC_CAPTCHA_SITE_KEY as string}
{/* sitekey="0x4AAAAAAAj8DEQFLe1isCek"*/} responseField={false}
{/* {...register("captchaResponse")}*/} onVerify={(token: string) => setCaptchaResponse(token)}
{/* />*/} />
{/*)}*/}
{/* Errors */}
{error && <p className="text-red-500">{error}</p>}
{/* Submit Form */} {/* Submit Form */}
<Button <Button
className="h-11 mt-2 flex gap-2 items-center bg-zinc-800/75 text-white hover:bg-zinc-800/75 hover:opacity-75 transition-all transform-gpu group" className="h-11 flex gap-2 items-center bg-zinc-800/75 text-white border border-zinc-700/35 hover:bg-zinc-800/75 hover:opacity-75 transition-all transform-gpu group"
type="submit" type="submit"
disabled={loading} disabled={loading}
> >
@ -185,4 +232,5 @@ const AuthForm = (): ReactElement => {
</form> </form>
); );
}; };
export default AuthForm; export default AuthForm;

@ -0,0 +1,35 @@
"use client";
import { ReactElement } from "react";
import Branding from "@/components/branding";
/**
* The greetings for the auth page.
*
* @return the greeting jsx
*/
const Greeting = (): ReactElement => {
const currentHour: number = new Date().getHours();
const greeting: string =
currentHour < 12
? "Morning"
: currentHour < 18
? "Afternoon"
: "Evening";
// return (
// <h1 className="text-3xl font-bold select-none pointer-events-none">
// Good {greeting},
// </h1>
// );
return (
<div className="flex flex-col gap-1.5 justify-center items-center select-none pointer-events-none">
<Branding />
<h1 className="text-3xl font-bold leading-none">
Good {greeting},
</h1>
<h2 className="opacity-65">Please login to continue!</h2>
</div>
);
};
export default Greeting;

@ -12,23 +12,19 @@ type OAuthProviderProps = {
*/ */
name: string; name: string;
/**
* The icon of this provider.
*/
icon: string;
/** /**
* The link to login with this provider. * The link to login with this provider.
*/ */
link: string; link: string;
}; };
const OAuthProvider = ({ /**
name, * A button to login with an OAuth provider.
icon, *
link, * @return the provider jsx
}: OAuthProviderProps): ReactElement => ( */
<Link href={link}> const OAuthProvider = ({ name, link }: OAuthProviderProps): ReactElement => (
<Link className="cursor-not-allowed" href={link}>
<Button <Button
className="h-12 bg-zinc-800/85 text-white border border-zinc-700/35 hover:bg-muted hover:opacity-75 transition-all transform-gpu" className="h-12 bg-zinc-800/85 text-white border border-zinc-700/35 hover:bg-muted hover:opacity-75 transition-all transform-gpu"
disabled disabled
@ -37,7 +33,7 @@ const OAuthProvider = ({
className="w-10 h-10" className="w-10 h-10"
as="div" as="div"
bgColor="transparent" bgColor="transparent"
network={icon} network={name.toLowerCase()}
/> />
<span>{name}</span> <span>{name}</span>
</Button> </Button>

@ -4,7 +4,7 @@ import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const brandingVariants = cva( const brandingVariants = cva(
"relative hover:opacity-75 transition-all transform-gpu", "relative hover:opacity-75 select-none transition-all transform-gpu",
{ {
variants: { variants: {
size: { size: {

@ -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 => (
<div
className="absolute w-screen h-screen flex flex-col gap-1.5 animate-pulse justify-center items-center"
style={{
background:
"linear-gradient(to top, hsla(240, 6%, 10%, 0.7), hsl(var(--background)))",
}}
>
<Branding />
<h1 className="text-2xl font-semibold opacity-75">Loading</h1>
</div>
);
export default DashboardLoader;

@ -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. * Send a request to the API.
* *
* @param endpoint the endpoint to request * @param endpoint the endpoint to request
* @param session the session to auth with
* @param body the optional request body to send * @param body the optional request body to send
* @param method the request method to use * @param method the request method to use
* @return the api response
*/ */
export const apiRequest = async <T>( export const apiRequest = async <T>({
endpoint: string, endpoint,
method?: string | undefined, method,
body?: any | undefined session,
): Promise<{ data: T | undefined; error: ApiError | undefined }> => { body,
}: ApiRequestProps): Promise<{
data: T | undefined;
error: ApiError | undefined;
}> => {
const response: Response = await fetch( const response: Response = await fetch(
`${process.env.NEXT_PUBLIC_API_ENDPOINT}${endpoint}`, `${process.env.NEXT_PUBLIC_API_ENDPOINT}${endpoint}`,
{ {
@ -20,6 +51,11 @@ export const apiRequest = async <T>(
: undefined, : undefined,
headers: { headers: {
"Content-Type": `application/${method === "POST" ? "x-www-form-urlencoded" : "json"}`, "Content-Type": `application/${method === "POST" ? "x-www-form-urlencoded" : "json"}`,
...(session
? {
Authorization: `Bearer ${session.accessToken}`,
}
: {}),
}, },
} }
); );
@ -29,5 +65,3 @@ export const apiRequest = async <T>(
} }
return { data: data as T, error: undefined }; return { data: data as T, error: undefined };
}; };
import { ApiError } from "@/app/types/api-error";