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 (
);
};
+
export default AuthForm;
diff --git a/src/components/auth/greeting.tsx b/src/components/auth/greeting.tsx
new file mode 100644
index 0000000..2f558b7
--- /dev/null
+++ b/src/components/auth/greeting.tsx
@@ -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 (
+ //
+ // Good {greeting},
+ //
+ // );
+
+ return (
+
+
+
+ Good {greeting},
+
+ Please login to continue!
+
+ );
+};
+export default Greeting;
diff --git a/src/components/auth/oauth-provider.tsx b/src/components/auth/oauth-provider.tsx
index 28ac623..ae5020d 100644
--- a/src/components/auth/oauth-provider.tsx
+++ b/src/components/auth/oauth-provider.tsx
@@ -12,23 +12,19 @@ type OAuthProviderProps = {
*/
name: string;
- /**
- * The icon of this provider.
- */
- icon: string;
-
/**
* The link to login with this provider.
*/
link: string;
};
-const OAuthProvider = ({
- name,
- icon,
- link,
-}: OAuthProviderProps): ReactElement => (
-
+/**
+ * A button to login with an OAuth provider.
+ *
+ * @return the provider jsx
+ */
+const OAuthProvider = ({ name, link }: OAuthProviderProps): ReactElement => (
+
{name}
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";