From e2a8b208bc137073a7262baa4ee69e550d9cc37b Mon Sep 17 00:00:00 2001 From: sianida26 Date: Tue, 27 Feb 2024 00:40:52 +0700 Subject: [PATCH 01/10] Move create user to service --- prisma/seeds/permissionSeed.ts | 2 +- src/modules/auth/actions/createUser.ts | 76 ++----------------- .../auth/formSchemas/CreateUserFormSchema.ts | 23 ++++++ src/modules/auth/services/createUser.ts | 54 +++++++++++++ 4 files changed, 85 insertions(+), 70 deletions(-) create mode 100644 src/modules/auth/formSchemas/CreateUserFormSchema.ts create mode 100644 src/modules/auth/services/createUser.ts diff --git a/prisma/seeds/permissionSeed.ts b/prisma/seeds/permissionSeed.ts index 2050aeb..167add4 100644 --- a/prisma/seeds/permissionSeed.ts +++ b/prisma/seeds/permissionSeed.ts @@ -94,7 +94,7 @@ export default async function permissionSeed(prisma: PrismaClient) { isActive: true } ]; - + await Promise.all( permissionData.map(async (permission) => { await prisma.permission.upsert({ diff --git a/src/modules/auth/actions/createUser.ts b/src/modules/auth/actions/createUser.ts index 2df4d5d..e52b685 100644 --- a/src/modules/auth/actions/createUser.ts +++ b/src/modules/auth/actions/createUser.ts @@ -5,30 +5,7 @@ import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import { hashPassword } from "../utils/hashPassword"; import { createJwtToken } from "../utils/createJwtToken"; - -/** - * Interface for the schema of a new user. - */ -interface CreateUserSchema { - name: string; - email: string; - password: string; -} - -/** - * Validation schema for creating a user. - */ -const createUserSchema = z - .object({ - name: z.string(), - email: z.string().email(), - password: z.string().min(6), - passwordConfirmation: z.string().optional(), - }) - .refine((data) => data.password === data.passwordConfirmation, { - message: "Password confirmation must match the password", - path: ["passwordConfirmation"], - }); +import createUser from "../services/createUser"; /** * Creates a new user in the system. @@ -36,7 +13,7 @@ const createUserSchema = z * @param formData - The form data containing user details. * @returns An object indicating the result of the operation. */ -export default async function createUser(formData: FormData) { +export default async function createUserAction(formData: FormData) { //TODO: Add Throttling //TODO: Add validation check if the user is already logged in @@ -44,49 +21,12 @@ export default async function createUser(formData: FormData) { const parsedData = { email: formData.get("email")?.toString() ?? "", name: formData.get("name")?.toString() ?? "", - password: formData.get("password")?.toString() ?? "", - passwordConfirmation: formData - .get("passwordConfirmation") - ?.toString(), + plainPassword: formData.get("password")?.toString() ?? "", + plainPasswordConfirmation: + formData.get("passwordConfirmation")?.toString() ?? "", }; - const validatedFields = createUserSchema.safeParse(parsedData); - - if (!validatedFields.success) { - return { - success: false, - error: { - message: "", - errors: validatedFields.error.flatten().fieldErrors, - }, - }; - } - - const existingUser = await prisma.user.findUnique({ - where: { email: validatedFields.data.email }, - }); - - if (existingUser) { - return { - success: false, - error: { - message: "", - errors: { - email: ["Email already exists"], - }, - }, - }; - } - - const user = await prisma.user.create({ - data: { - name: validatedFields.data.name, - email: validatedFields.data.email, - passwordHash: await hashPassword(validatedFields.data.password), - }, - }); - - const token = createJwtToken({ id: user.id }); - cookies().set("token", token); + await createUser(parsedData); + redirect("/dashboard"); } catch (e: unknown) { // Handle unexpected errors console.error(e); @@ -100,6 +40,4 @@ export default async function createUser(formData: FormData) { }, }; } - - redirect("/dashboard"); } diff --git a/src/modules/auth/formSchemas/CreateUserFormSchema.ts b/src/modules/auth/formSchemas/CreateUserFormSchema.ts new file mode 100644 index 0000000..0885c2c --- /dev/null +++ b/src/modules/auth/formSchemas/CreateUserFormSchema.ts @@ -0,0 +1,23 @@ +import {z} from "zod" + +/** + * Interface for the schema of a new user. + */ +export interface CreateUserSchema { + name: string; + email: string; + plainPassword: string; + plainPasswordConfirmation: string; +} + +export const createUserSchema = z + .object({ + name: z.string(), + email: z.string().email(), + password: z.string().min(6), + passwordConfirmation: z.string().optional(), + }) + .refine((data) => data.password === data.passwordConfirmation, { + message: "Password confirmation must match the password", + path: ["passwordConfirmation"], + }); \ No newline at end of file diff --git a/src/modules/auth/services/createUser.ts b/src/modules/auth/services/createUser.ts new file mode 100644 index 0000000..a903658 --- /dev/null +++ b/src/modules/auth/services/createUser.ts @@ -0,0 +1,54 @@ +import DashboardError from "@/modules/dashboard/errors/DashboardError"; +import { + CreateUserSchema, + createUserSchema, +} from "../formSchemas/CreateUserFormSchema"; +import mapObjectToFirstValue from "@/utils/mapObjectToFirstValue"; +import db from "@/core/db"; +import AuthError from "../error/AuthError"; +import hashPassword from "../utils/hashPassword"; +import { createJwtToken } from "../utils/createJwtToken"; +import { cookies } from "next/headers"; + +export default async function createUser(userData: CreateUserSchema) { + const validatedFields = createUserSchema.safeParse(userData); + + //Validate form input + if (!validatedFields.success) { + throw new DashboardError({ + errorCode: "INVALID_FORM_DATA", + formErrors: mapObjectToFirstValue( + validatedFields.error.flatten().fieldErrors + ), + }); + } + + //Check email exists + if ( + await db.user.findFirst({ + where: { email: validatedFields.data.email }, + }) + ) { + throw new AuthError({ + errorCode: "USER_ALREADY_EXISTS", + message: "This email already exists", + }); + } + + //Create user + const user = await db.user.create({ + data: { + name: validatedFields.data.name, + email: validatedFields.data.email, + passwordHash: await hashPassword(validatedFields.data.password), + }, + }); + + //Set token + const token = createJwtToken({ id: user.id }); + cookies().set("token", token); + + return { + token, + }; +} From 5d8afc7c0f8eb46400dca2a1075b298867507451 Mon Sep 17 00:00:00 2001 From: sianida26 Date: Tue, 27 Feb 2024 10:31:27 +0700 Subject: [PATCH 02/10] Fix bug on register --- src/app/(auth)/register/page.tsx | 76 +++++++++----------- src/modules/auth/actions/createUser.ts | 43 ----------- src/modules/auth/actions/createUserAction.ts | 29 ++++++++ 3 files changed, 63 insertions(+), 85 deletions(-) delete mode 100644 src/modules/auth/actions/createUser.ts create mode 100644 src/modules/auth/actions/createUserAction.ts diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx index f0544db..82450fb 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -1,6 +1,10 @@ "use client"; -import createUser from "@/modules/auth/actions/createUser"; +import createUserAction from "@/modules/auth/actions/createUserAction"; +import createUser from "@/modules/auth/actions/createUserAction"; +import { CreateUserSchema } from "@/modules/auth/formSchemas/CreateUserFormSchema"; +import DashboardError from "@/modules/dashboard/errors/DashboardError"; +import withServerAction from "@/modules/dashboard/utils/withServerAction"; import { Paper, PasswordInput, @@ -12,65 +16,53 @@ import { Button, } from "@mantine/core"; import { useForm } from "@mantine/form"; +import { showNotification } from "@mantine/notifications"; import React, { useEffect, useState } from "react"; -export interface RegisterFormSchema { - email: string; - password: string; - passwordConfirmation: string; - name: string; -} - export default function RegisterPage() { const [errorMessage, setErrorMessage] = useState(""); - const form = useForm({ + const form = useForm({ initialValues: { email: "", - password: "", - passwordConfirmation: "", + plainPassword: "", + plainPasswordConfirmation: "", name: "", }, validate: { email: (value: string) => /^\S+@\S+$/.test(value) ? null : "Invalid email", - password: (value: string) => + plainPassword: (value: string) => value.length >= 6 ? null : "Password should be at least 6 characters", - passwordConfirmation: (value: string, values: RegisterFormSchema) => - value === values.password ? null : "Passwords should match", + plainPasswordConfirmation: (value: string, values: CreateUserSchema) => + value === values.plainPassword ? null : "Passwords should match", name: (value: string) => value.length > 0 ? null : "Name is required", }, }); - const handleSubmit = async (values: RegisterFormSchema) => { - const formData = new FormData(); - Object.entries(values).forEach(([key, value]) => { - formData.append(key, value); - }); - - const response = await createUser(formData); - - if (!response.success) { - setErrorMessage(response.error.message); - - if (response.error.errors) { - const errors = Object.entries(response.error.errors).reduce( - (prev, [k, v]) => { - prev[k] = v[0]; - return prev; - }, - {} as { [k: string]: string } - ); - - form.setErrors(errors); - console.log(form.errors); - } else { - form.clearErrors(); - } - } + const handleSubmit = async (values: CreateUserSchema) => { + withServerAction(createUserAction, form.values) + .then((response) => { + showNotification({message: "Register Success", color: "green"}) + }) + .catch((e) => { + if (e instanceof DashboardError) { + if (e.errorCode === "INVALID_FORM_DATA") { + form.setErrors(e.formErrors ?? {}); + } else { + setErrorMessage(`ERROR: ${e.message} (${e.errorCode})`); + } + } else if (e instanceof Error) { + setErrorMessage(`ERROR: ${e.message}`); + } else { + setErrorMessage( + `Unkown error is occured. Please contact administrator` + ); + } + }) }; return ( @@ -102,14 +94,14 @@ export default function RegisterPage() { placeholder="Your password" name="password" autoComplete="new-password" - {...form.getInputProps("password")} + {...form.getInputProps("plainPassword")} /> diff --git a/src/modules/auth/actions/createUser.ts b/src/modules/auth/actions/createUser.ts deleted file mode 100644 index e52b685..0000000 --- a/src/modules/auth/actions/createUser.ts +++ /dev/null @@ -1,43 +0,0 @@ -"use server"; -import { z } from "zod"; -import prisma from "@/core/db"; -import { cookies } from "next/headers"; -import { redirect } from "next/navigation"; -import { hashPassword } from "../utils/hashPassword"; -import { createJwtToken } from "../utils/createJwtToken"; -import createUser from "../services/createUser"; - -/** - * Creates a new user in the system. - * - * @param formData - The form data containing user details. - * @returns An object indicating the result of the operation. - */ -export default async function createUserAction(formData: FormData) { - //TODO: Add Throttling - //TODO: Add validation check if the user is already logged in - - try { - const parsedData = { - email: formData.get("email")?.toString() ?? "", - name: formData.get("name")?.toString() ?? "", - plainPassword: formData.get("password")?.toString() ?? "", - plainPasswordConfirmation: - formData.get("passwordConfirmation")?.toString() ?? "", - }; - await createUser(parsedData); - redirect("/dashboard"); - } catch (e: unknown) { - // Handle unexpected errors - console.error(e); - //@ts-ignore - console.log(e.message); - return { - success: false, - error: { - message: - "An unexpected error occurred on the server. Please try again or contact the administrator.", - }, - }; - } -} diff --git a/src/modules/auth/actions/createUserAction.ts b/src/modules/auth/actions/createUserAction.ts new file mode 100644 index 0000000..5fe52e0 --- /dev/null +++ b/src/modules/auth/actions/createUserAction.ts @@ -0,0 +1,29 @@ +"use server"; +import { z } from "zod"; +import prisma from "@/core/db"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { hashPassword } from "../utils/hashPassword"; +import { createJwtToken } from "../utils/createJwtToken"; +import createUser from "../services/createUser"; +import ServerResponseAction from "@/modules/dashboard/types/ServerResponseAction"; +import handleCatch from "@/modules/dashboard/utils/handleCatch"; +import { CreateUserSchema } from "../formSchemas/CreateUserFormSchema"; + +/** + * Creates a new user in the system. + * + * @param formData - The form data containing user details. + * @returns An object indicating the result of the operation. + */ +export default async function createUserAction(formData: CreateUserSchema): Promise { + //TODO: Add Throttling + //TODO: Add validation check if the user is already logged in + + try { + await createUser(formData); + redirect("/dashboard"); + } catch (e) { + return handleCatch(e) + } +} From e6ffe4112d187ad29828b26b86c553fd9979c0a9 Mon Sep 17 00:00:00 2001 From: sianida26 Date: Tue, 27 Feb 2024 23:13:50 +0700 Subject: [PATCH 03/10] Change get user detail to service --- src/app/dashboard/layout.tsx | 2 +- .../{getUser.ts => getMyDetailAction.ts} | 39 ++++++++++--------- src/modules/auth/actions/guestOnly.ts | 2 +- src/modules/auth/contexts/AuthContext.tsx | 21 +++++----- src/modules/auth/services/createUser.ts | 2 +- src/modules/auth/services/getMyDetail.ts | 18 +++++++++ .../tables/UsersTable/UsersTable.tsx | 1 - 7 files changed, 54 insertions(+), 31 deletions(-) rename src/modules/auth/actions/{getUser.ts => getMyDetailAction.ts} (50%) create mode 100644 src/modules/auth/services/getMyDetail.ts diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 5ca4783..e8f1116 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -4,7 +4,7 @@ import Image from "next/image"; import React from "react"; import logo from "@/assets/logos/logo.png"; import DashboardLayout from "@/modules/dashboard/components/DashboardLayout"; -import getUser from "@/modules/auth/actions/getUser"; +import getUser from "@/modules/auth/actions/getMyDetailAction"; import { redirect } from "next/navigation"; import { Notifications } from "@mantine/notifications"; diff --git a/src/modules/auth/actions/getUser.ts b/src/modules/auth/actions/getMyDetailAction.ts similarity index 50% rename from src/modules/auth/actions/getUser.ts rename to src/modules/auth/actions/getMyDetailAction.ts index 7af6a47..fa7407e 100644 --- a/src/modules/auth/actions/getUser.ts +++ b/src/modules/auth/actions/getMyDetailAction.ts @@ -1,9 +1,11 @@ "use server"; -import { cookies } from "next/headers"; import "server-only"; -import getUserFromToken from "../utils/getUserFromToken"; import AuthError from "../error/AuthError"; +import getMyDetail from "../services/getMyDetail"; +import ServerResponseAction from "@/modules/dashboard/types/ServerResponseAction"; +import handleCatch from "@/modules/dashboard/utils/handleCatch"; +import BaseError from "@/core/error/BaseError"; /** * Retrieves the user details based on the JWT token from cookies. @@ -14,26 +16,27 @@ import AuthError from "../error/AuthError"; * @returns A promise that resolves to the user's details object or null if the user cannot be authenticated or an error occurs. * @throws an error if an unexpected error occurs during execution. */ -export default async function getUser() { +export default async function getMyDetailAction(): Promise< + ServerResponseAction>> +> { try { - const token = cookies().get("token"); - - if (!token) return null; - - const user = await getUserFromToken(token.value); - - if (!user) return null; - return { - name: user.name ?? "", - email: user.email ?? "", - photoUrl: user.photoProfile ?? null, + success: true, + data: await getMyDetail(), }; } catch (e: unknown) { - // Handle specific authentication errors gracefully - if (e instanceof AuthError && e.errorCode === "INVALID_JWT_TOKEN") { - return null; + if ( + e instanceof AuthError && + ["INVALID_JWT_TOKEN"].includes(e.errorCode) + ) { + return { + success: false, + error: new BaseError({ + errorCode: e.errorCode, + message: "You are not authenticated", + }), + }; } - throw e; + return handleCatch(e); } } diff --git a/src/modules/auth/actions/guestOnly.ts b/src/modules/auth/actions/guestOnly.ts index 86d5011..473e25f 100644 --- a/src/modules/auth/actions/guestOnly.ts +++ b/src/modules/auth/actions/guestOnly.ts @@ -1,7 +1,7 @@ "use server"; import { redirect } from "next/navigation"; -import getUser from "./getUser"; +import getUser from "./getMyDetailAction"; export default async function guestOnly() { const user = await getUser(); diff --git a/src/modules/auth/contexts/AuthContext.tsx b/src/modules/auth/contexts/AuthContext.tsx index 0875164..98534d8 100644 --- a/src/modules/auth/contexts/AuthContext.tsx +++ b/src/modules/auth/contexts/AuthContext.tsx @@ -9,7 +9,10 @@ import React, { useMemo, useState, } from "react"; -import getUser from "../actions/getUser"; +import getUser from "../actions/getMyDetailAction"; +import withServerAction from "@/modules/dashboard/utils/withServerAction"; +import getMyDetailAction from "../actions/getMyDetailAction"; +import { notifications } from "@mantine/notifications"; interface UserData { name: string; @@ -34,14 +37,14 @@ export const AuthContextProvider = ({ children }: Props) => { const [user, setUser] = useState(null); const fetchUserData = useCallback(() => { - const getUserData = async () => { - const user = await getUser(); - setUser(user); - }; - - getUserData() - .then(() => {}) - .catch(() => {}); + + withServerAction(getMyDetailAction) + .then((response) => { + setUser(response.data); + }) + .catch((error) => { + console.error("Error while retrieving user data") + }) }, []); useEffect(() => { diff --git a/src/modules/auth/services/createUser.ts b/src/modules/auth/services/createUser.ts index a903658..39445f2 100644 --- a/src/modules/auth/services/createUser.ts +++ b/src/modules/auth/services/createUser.ts @@ -23,7 +23,7 @@ export default async function createUser(userData: CreateUserSchema) { }); } - //Check email exists + //Check email exists if ( await db.user.findFirst({ where: { email: validatedFields.data.email }, diff --git a/src/modules/auth/services/getMyDetail.ts b/src/modules/auth/services/getMyDetail.ts new file mode 100644 index 0000000..295989d --- /dev/null +++ b/src/modules/auth/services/getMyDetail.ts @@ -0,0 +1,18 @@ +import { cookies } from "next/headers"; +import getUserFromToken from "../utils/getUserFromToken"; + +export default async function getMyDetail() { + const token = cookies().get("token"); + + if (!token) return null; + + const user = await getUserFromToken(token.value); + + if (!user) return null; + + return { + name: user.name ?? "", + email: user.email ?? "", + photoUrl: user.photoProfile ?? null, + }; +} diff --git a/src/modules/userManagement/tables/UsersTable/UsersTable.tsx b/src/modules/userManagement/tables/UsersTable/UsersTable.tsx index 7f44662..b947cbf 100644 --- a/src/modules/userManagement/tables/UsersTable/UsersTable.tsx +++ b/src/modules/userManagement/tables/UsersTable/UsersTable.tsx @@ -1,5 +1,4 @@ "use client"; -import getUser from "@/modules/auth/actions/getUser"; import CrudPermissions from "@/modules/dashboard/types/CrudPermissions"; import { Table, Text, Flex, Button, Center } from "@mantine/core"; import { From cb3969ac408e3c9fe8e8be59e0018969ae234ab7 Mon Sep 17 00:00:00 2001 From: sianida26 Date: Tue, 27 Feb 2024 23:35:56 +0700 Subject: [PATCH 04/10] Added documentation --- src/modules/auth/actions/getMyDetailAction.ts | 32 +++-- src/modules/auth/actions/guestOnly.ts | 16 ++- src/modules/auth/contexts/AuthContext.tsx | 119 ++++++++++-------- src/modules/auth/services/createUser.ts | 10 ++ src/modules/auth/services/getMyDetail.ts | 10 ++ 5 files changed, 116 insertions(+), 71 deletions(-) diff --git a/src/modules/auth/actions/getMyDetailAction.ts b/src/modules/auth/actions/getMyDetailAction.ts index fa7407e..b30b264 100644 --- a/src/modules/auth/actions/getMyDetailAction.ts +++ b/src/modules/auth/actions/getMyDetailAction.ts @@ -1,34 +1,31 @@ "use server"; -import "server-only"; -import AuthError from "../error/AuthError"; import getMyDetail from "../services/getMyDetail"; +import AuthError from "../error/AuthError"; +import BaseError from "@/core/error/BaseError"; import ServerResponseAction from "@/modules/dashboard/types/ServerResponseAction"; import handleCatch from "@/modules/dashboard/utils/handleCatch"; -import BaseError from "@/core/error/BaseError"; +import "server-only"; /** - * Retrieves the user details based on the JWT token from cookies. - * This function is designed to be used in a server-side context within a Next.js application. - * It attempts to parse the user's token, fetch the user's details, and format the response. - * If the token is invalid or the user cannot be found, it gracefully handles these cases. + * Asynchronously retrieves the authenticated user's details from a server-side context in a Next.js application. + * This function uses a JWT token obtained from cookies to authenticate the user and fetch their details. + * If the authentication fails due to an invalid JWT token, or if any other error occurs, the function handles these errors gracefully. * - * @returns A promise that resolves to the user's details object or null if the user cannot be authenticated or an error occurs. - * @throws an error if an unexpected error occurs during execution. + * @returns A promise that resolves to a `ServerResponseAction` object. This object includes a `success` flag indicating the operation's outcome, the user's details in the `data` field if successful, or an error object in the `error` field if an error occurs. + * @throws an unhandled error if an unexpected error occurs during the function execution. */ -export default async function getMyDetailAction(): Promise< - ServerResponseAction>> -> { +export default async function getMyDetailAction(): Promise>>> { try { + // Attempt to fetch and return the user's details. + const userDetails = await getMyDetail(); return { success: true, - data: await getMyDetail(), + data: userDetails, }; } catch (e: unknown) { - if ( - e instanceof AuthError && - ["INVALID_JWT_TOKEN"].includes(e.errorCode) - ) { + // Check if the error is an instance of AuthError and handle it. + if (e instanceof AuthError && e.errorCode === "INVALID_JWT_TOKEN") { return { success: false, error: new BaseError({ @@ -37,6 +34,7 @@ export default async function getMyDetailAction(): Promise< }), }; } + // Handle other types of errors. return handleCatch(e); } } diff --git a/src/modules/auth/actions/guestOnly.ts b/src/modules/auth/actions/guestOnly.ts index 473e25f..9336aee 100644 --- a/src/modules/auth/actions/guestOnly.ts +++ b/src/modules/auth/actions/guestOnly.ts @@ -1,12 +1,20 @@ "use server"; import { redirect } from "next/navigation"; -import getUser from "./getMyDetailAction"; +import getMyDetail from "../services/getMyDetail"; -export default async function guestOnly() { - const user = await getUser(); +/** + * Enforces a guest-only access policy by redirecting authenticated users to the dashboard. + * This function asynchronously checks if the user is authenticated by attempting to retrieve user details. + * If the user is authenticated, they are redirected to the dashboard page. + * + * @returns A promise that resolves when the operation completes. The function itself does not return a value. + */ +export default async function guestOnly(): Promise { + const user = await getMyDetail(); + // If an authenticated user is detected, redirect them to the dashboard. if (user) { - redirect("dashboard"); + redirect("/dashboard"); } } diff --git a/src/modules/auth/contexts/AuthContext.tsx b/src/modules/auth/contexts/AuthContext.tsx index 98534d8..ada908b 100644 --- a/src/modules/auth/contexts/AuthContext.tsx +++ b/src/modules/auth/contexts/AuthContext.tsx @@ -1,71 +1,90 @@ +// Directive to enforce client-side operation in a Next.js application. "use client"; -import React, { - ReactElement, - ReactNode, - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "react"; -import getUser from "../actions/getMyDetailAction"; -import withServerAction from "@/modules/dashboard/utils/withServerAction"; -import getMyDetailAction from "../actions/getMyDetailAction"; + +// Importing React functionalities and required components. +import React, { ReactElement, ReactNode, createContext, useCallback, useContext, useEffect, useState } from "react"; import { notifications } from "@mantine/notifications"; +import getMyDetailAction from "../actions/getMyDetailAction"; +import withServerAction from "@/modules/dashboard/utils/withServerAction"; +// Defining the structure for user data within the authentication context. interface UserData { - name: string; - email: string; - photoUrl: string | null; - // Add additional user fields as needed + name: string; + email: string; + photoUrl: string | null; + // Additional user fields can be added here. } +// State structure for the authentication context. interface AuthContextState { - user: UserData | null; - fetchUserData: () => void; - logout: () => void; + user: UserData | null; + fetchUserData: () => void; + logout: () => void; } +// Props type definition for the AuthContextProvider component. interface Props { - children: ReactNode; + children: ReactNode; } +// Creating the authentication context with an undefined initial value. const AuthContext = createContext(undefined); -export const AuthContextProvider = ({ children }: Props) => { - const [user, setUser] = useState(null); +/** + * Provides an authentication context to wrap around components that require authentication data. + * This component initializes user data state, fetches user data upon component mount, and provides + * a logout function to clear the user data. + * + * @param {Props} props - Component props containing children to be rendered within the provider. + * @returns {ReactElement} A provider component wrapping children with access to authentication context. + */ +export const AuthContextProvider = ({ children }: Props): ReactElement => { + const [user, setUser] = useState(null); - const fetchUserData = useCallback(() => { - - withServerAction(getMyDetailAction) - .then((response) => { - setUser(response.data); - }) - .catch((error) => { - console.error("Error while retrieving user data") - }) - }, []); + // Function to fetch user data and update state accordingly. + const fetchUserData = useCallback(() => { + withServerAction(getMyDetailAction) + .then((response) => { + setUser(response.data); + }) + .catch((error) => { + notifications.show({ + title: 'Error', + message: 'Error while retrieving user data', + color: 'red', + }); + console.error("Error while retrieving user data", error); + }); + }, []); - useEffect(() => { - fetchUserData(); - }, [fetchUserData]); + // Fetch user data on component mount. + useEffect(() => { + fetchUserData(); + }, [fetchUserData]); - const logout = () => { - setUser(null); - }; + // Function to clear user data, effectively logging the user out. + const logout = () => { + setUser(null); + }; - return ( - - {children} - - ); + // Providing authentication state and functions to the context consumers. + return ( + + {children} + + ); }; -export const useAuth = () => { - const context = useContext(AuthContext); - if (!context) { - throw new Error("useAuth must be used within an AuthContextProvider"); - } - return context; +/** + * Custom hook to consume the authentication context. This hook ensures the context is used within a provider. + * + * @returns {AuthContextState} The authentication context state including user data and auth functions. + * @throws {Error} Throws an error if the hook is used outside of an AuthContextProvider. + */ +export const useAuth = (): AuthContextState => { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthContextProvider"); + } + return context; }; diff --git a/src/modules/auth/services/createUser.ts b/src/modules/auth/services/createUser.ts index 39445f2..fdcab21 100644 --- a/src/modules/auth/services/createUser.ts +++ b/src/modules/auth/services/createUser.ts @@ -10,6 +10,16 @@ import hashPassword from "../utils/hashPassword"; import { createJwtToken } from "../utils/createJwtToken"; import { cookies } from "next/headers"; +/** + * Creates a new user in the database after validating the input data. + * It throws errors if the input data is invalid or if the user already exists. + * On successful creation, it returns a token for the created user. + * + * @param userData - The user data to create a new user. Must conform to CreateUserSchema. + * @returns An object containing the JWT token for the newly created user. + * @throws If the input validation fails. + * @throws If the user already exists in the database. + */ export default async function createUser(userData: CreateUserSchema) { const validatedFields = createUserSchema.safeParse(userData); diff --git a/src/modules/auth/services/getMyDetail.ts b/src/modules/auth/services/getMyDetail.ts index 295989d..b7a9f4c 100644 --- a/src/modules/auth/services/getMyDetail.ts +++ b/src/modules/auth/services/getMyDetail.ts @@ -1,15 +1,25 @@ import { cookies } from "next/headers"; import getUserFromToken from "../utils/getUserFromToken"; +/** + * Retrieves the details of the currently authenticated user based on the JWT token. + * If the token is not present or the user cannot be found, it returns null. + * Otherwise, it returns the user's name, email, and photo URL. + * + * @returns An object containing the user's name, email, and photo URL, or null if the user cannot be authenticated. + */ export default async function getMyDetail() { const token = cookies().get("token"); + // Return null if token is not present if (!token) return null; const user = await getUserFromToken(token.value); + // Return null if user is not found if (!user) return null; + // Return user details return { name: user.name ?? "", email: user.email ?? "", From a9728d74f7eef8299027ec923dc0f1cde9e69a0f Mon Sep 17 00:00:00 2001 From: sianida26 Date: Wed, 28 Feb 2024 09:29:14 +0700 Subject: [PATCH 05/10] Move cookie check outside services --- src/modules/auth/actions/getMyDetailAction.ts | 9 ++++++++- src/modules/auth/actions/guestOnly.ts | 7 ++++++- src/modules/auth/services/getMyDetail.ts | 16 +++++++++------- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/modules/auth/actions/getMyDetailAction.ts b/src/modules/auth/actions/getMyDetailAction.ts index b30b264..66ef024 100644 --- a/src/modules/auth/actions/getMyDetailAction.ts +++ b/src/modules/auth/actions/getMyDetailAction.ts @@ -6,6 +6,8 @@ import BaseError from "@/core/error/BaseError"; import ServerResponseAction from "@/modules/dashboard/types/ServerResponseAction"; import handleCatch from "@/modules/dashboard/utils/handleCatch"; import "server-only"; +import { cookies } from "next/headers"; +import getUserFromToken from "../utils/getUserFromToken"; /** * Asynchronously retrieves the authenticated user's details from a server-side context in a Next.js application. @@ -17,8 +19,13 @@ import "server-only"; */ export default async function getMyDetailAction(): Promise>>> { try { + const token = cookies().get("token"); + + // Return null if token is not present + if (!token) throw new AuthError({errorCode: "INVALID_JWT_TOKEN"}); + // Attempt to fetch and return the user's details. - const userDetails = await getMyDetail(); + const userDetails = await getMyDetail(token.value); return { success: true, data: userDetails, diff --git a/src/modules/auth/actions/guestOnly.ts b/src/modules/auth/actions/guestOnly.ts index 9336aee..ad1535f 100644 --- a/src/modules/auth/actions/guestOnly.ts +++ b/src/modules/auth/actions/guestOnly.ts @@ -2,6 +2,7 @@ import { redirect } from "next/navigation"; import getMyDetail from "../services/getMyDetail"; +import { cookies } from "next/headers"; /** * Enforces a guest-only access policy by redirecting authenticated users to the dashboard. @@ -11,7 +12,11 @@ import getMyDetail from "../services/getMyDetail"; * @returns A promise that resolves when the operation completes. The function itself does not return a value. */ export default async function guestOnly(): Promise { - const user = await getMyDetail(); + const token = cookies().get("token"); + + if (!token) return; + + const user = await getMyDetail(token.value); // If an authenticated user is detected, redirect them to the dashboard. if (user) { diff --git a/src/modules/auth/services/getMyDetail.ts b/src/modules/auth/services/getMyDetail.ts index b7a9f4c..4b3b9d0 100644 --- a/src/modules/auth/services/getMyDetail.ts +++ b/src/modules/auth/services/getMyDetail.ts @@ -1,20 +1,22 @@ import { cookies } from "next/headers"; import getUserFromToken from "../utils/getUserFromToken"; +import AuthError from "../error/AuthError"; /** * Retrieves the details of the currently authenticated user based on the JWT token. * If the token is not present or the user cannot be found, it returns null. * Otherwise, it returns the user's name, email, and photo URL. - * + * * @returns An object containing the user's name, email, and photo URL, or null if the user cannot be authenticated. */ -export default async function getMyDetail() { - const token = cookies().get("token"); +export default async function getMyDetail(token?: string) { + if (!token) + throw new AuthError({ + errorCode: "INVALID_JWT_TOKEN", + message: "You are not authenticated", + }); - // Return null if token is not present - if (!token) return null; - - const user = await getUserFromToken(token.value); + const user = await getUserFromToken(token); // Return null if user is not found if (!user) return null; From c775757314ea2a9236014e0d9ddefe1f8fa72c17 Mon Sep 17 00:00:00 2001 From: sianida26 Date: Wed, 28 Feb 2024 10:02:06 +0700 Subject: [PATCH 06/10] Move cookie check outside services --- src/modules/auth/actions/createUserAction.ts | 3 ++- src/modules/auth/services/createUser.ts | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/auth/actions/createUserAction.ts b/src/modules/auth/actions/createUserAction.ts index 5fe52e0..6e92ab3 100644 --- a/src/modules/auth/actions/createUserAction.ts +++ b/src/modules/auth/actions/createUserAction.ts @@ -21,7 +21,8 @@ export default async function createUserAction(formData: CreateUserSchema): Prom //TODO: Add validation check if the user is already logged in try { - await createUser(formData); + const result = await createUser(formData); + cookies().set("token", result.token); redirect("/dashboard"); } catch (e) { return handleCatch(e) diff --git a/src/modules/auth/services/createUser.ts b/src/modules/auth/services/createUser.ts index fdcab21..e777464 100644 --- a/src/modules/auth/services/createUser.ts +++ b/src/modules/auth/services/createUser.ts @@ -56,7 +56,6 @@ export default async function createUser(userData: CreateUserSchema) { //Set token const token = createJwtToken({ id: user.id }); - cookies().set("token", token); return { token, From 8ceaca5f46e1a97420c6b46a1b2d50c0b1bcf874 Mon Sep 17 00:00:00 2001 From: sianida26 Date: Wed, 28 Feb 2024 10:05:11 +0700 Subject: [PATCH 07/10] Rename Logout into LogoutAction --- src/app/(auth)/logout/page.tsx | 8 ++++---- src/modules/auth/actions/{logout.ts => logoutAction.ts} | 0 2 files changed, 4 insertions(+), 4 deletions(-) rename src/modules/auth/actions/{logout.ts => logoutAction.ts} (100%) diff --git a/src/app/(auth)/logout/page.tsx b/src/app/(auth)/logout/page.tsx index 6971ebb..99b7a27 100644 --- a/src/app/(auth)/logout/page.tsx +++ b/src/app/(auth)/logout/page.tsx @@ -1,6 +1,6 @@ "use client"; -import logout from "@/modules/auth/actions/logout"; +import logoutAction from "@/modules/auth/actions/logoutAction"; import { useEffect } from "react"; /** @@ -9,9 +9,9 @@ import { useEffect } from "react"; */ export default function LogoutPage() { useEffect(() => { - const logoutAction = async () => await logout(); - - logoutAction(); + (async () => await logoutAction())() + .then(() => {}) + .catch(() => {}); }, []); return
; diff --git a/src/modules/auth/actions/logout.ts b/src/modules/auth/actions/logoutAction.ts similarity index 100% rename from src/modules/auth/actions/logout.ts rename to src/modules/auth/actions/logoutAction.ts From c64199d15a16a0676389947932832eae1f9e87e1 Mon Sep 17 00:00:00 2001 From: sianida26 Date: Wed, 28 Feb 2024 10:07:38 +0700 Subject: [PATCH 08/10] Rename sign in into action --- src/app/(auth)/login/page.tsx | 2 +- src/modules/auth/actions/{signIn.ts => signInAction.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/modules/auth/actions/{signIn.ts => signInAction.ts} (100%) diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 5941cd0..7b4fcca 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,5 +1,5 @@ "use client"; -import signIn from "@/modules/auth/actions/signIn"; +import signIn from "@/modules/auth/actions/signInAction"; import { Paper, PasswordInput, diff --git a/src/modules/auth/actions/signIn.ts b/src/modules/auth/actions/signInAction.ts similarity index 100% rename from src/modules/auth/actions/signIn.ts rename to src/modules/auth/actions/signInAction.ts From d0870419f00c796b59bc75dca0c8213cc615cd2c Mon Sep 17 00:00:00 2001 From: sianida26 Date: Wed, 28 Feb 2024 10:09:03 +0700 Subject: [PATCH 09/10] Add no unused vars --- .eslintrc.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.eslintrc.json b/.eslintrc.json index b2bcd8c..3e8ce47 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -5,6 +5,7 @@ "project": "./tsconfig.json" }, "rules": { - "@typescript-eslint/no-floating-promises": "warn" + "@typescript-eslint/no-floating-promises": "warn", + "@typescript-eslint/no-unused-vars": "warn" } } From ed9a0baa47296c8cdc7ce387f92c0b09fe6609f1 Mon Sep 17 00:00:00 2001 From: sianida26 Date: Wed, 28 Feb 2024 11:14:18 +0700 Subject: [PATCH 10/10] Move sign in to service --- src/app/(auth)/login/page.tsx | 4 +- src/modules/auth/actions/signInAction.ts | 71 +++++++--------------- src/modules/auth/services/signIn.ts | 54 ++++++++++++++++ src/modules/auth/types/SignInFormData.d.ts | 21 +++++++ 4 files changed, 97 insertions(+), 53 deletions(-) create mode 100644 src/modules/auth/services/signIn.ts create mode 100644 src/modules/auth/types/SignInFormData.d.ts diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 7b4fcca..ed42d9d 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -11,9 +11,7 @@ import { Button, Alert, } from "@mantine/core"; -import { redirect } from "next/navigation"; -import { useRouter } from "next/navigation"; -import React, { useEffect, useState } from "react"; +import React from "react"; import { useFormState } from "react-dom"; const initialState = { diff --git a/src/modules/auth/actions/signInAction.ts b/src/modules/auth/actions/signInAction.ts index 0b85a4c..3a11733 100644 --- a/src/modules/auth/actions/signInAction.ts +++ b/src/modules/auth/actions/signInAction.ts @@ -1,68 +1,41 @@ "use server"; -import prisma from "@/core/db"; -import { User } from "@prisma/client"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; -import { revalidatePath } from "next/cache"; import AuthError from "../error/AuthError"; -import comparePassword from "../utils/comparePassword"; -import { createJwtToken } from "../utils/createJwtToken"; +import signIn from "../services/signIn"; /** - * Handles the sign-in process for a user. - * - * This function validates a user's credentials (email and password), checks against the database, - * and on successful validation, redirects the user to the dashboard and sets a cookie with a JWT token. - * If the validation fails at any stage, it throws a custom AuthError. - * - * @param prevState - The previous state of the application, not currently used. - * @param rawFormData - The raw form data containing the user's email and password. - * @returns A promise that resolves to a redirect to the dashboard on successful authentication, - * or an object containing error details on failure. - * @throws Specific authentication error based on the failure stage. + * Asynchronously handles the sign-in process for a user by validating their credentials against the database. + * Upon successful validation, the user is redirected to the dashboard, and a JWT token is set as a cookie. + * If validation fails, a custom `AuthError` is thrown, and detailed error information is provided to the caller. + * + * Note: Future enhancements should include throttling to prevent brute force attacks and a check to prevent + * sign-in attempts if the user is already logged in. + * + * @param prevState - The previous state of the application. Currently not utilized but may be used for future enhancements. + * @param rawFormData - The raw form data obtained from the sign-in form, containing the user's email and password. + * @returns A promise that, upon successful authentication, resolves to a redirection to the dashboard. If authentication fails, + * it resolves to an object containing error details. + * @throws {AuthError} - Throws a custom `AuthError` with specific error codes for different stages of the authentication failure. */ -export default async function signIn(prevState: any, rawFormData: FormData) { +export default async function signInAction(prevState: any, rawFormData: FormData) { //TODO: Add Throttling //TODO: Add validation check if the user is already logged in try { + // Extract email and password from the raw form data. const formData = { email: rawFormData.get("email") as string, password: rawFormData.get("password") as string, }; - // Retrieve user from the database by email - const user = await prisma.user.findUnique({ - where: { email: formData.email }, - }); + // Attempt to sign in with the provided credentials. + const result = await signIn(formData) - // Throw if user not found - if (!user) - throw new AuthError({ - errorCode: "EMAIL_NOT_FOUND", - message: "Email or Password does not match", - }); + // Set the JWT token in cookies upon successful sign-in. + cookies().set("token", result.token); - // Throw if user has no password hash - // TODO: Add check if the user uses another provider - if (!user.passwordHash) - throw new AuthError({ errorCode: "EMPTY_USER_HASH" }); - - // Compare the provided password with the user's stored password hash - const isMatch = await comparePassword( - formData.password, - user.passwordHash - ); - if (!isMatch) - throw new AuthError({ - errorCode: "INVALID_CREDENTIALS", - message: "Email or Password does not match", - }); - - //Set cookie - //TODO: Auth: Add expiry - const token = createJwtToken({ id: user.id }); - - cookies().set("token", token); + // Redirect to the dashboard after successful sign-in. + redirect("/dashboard"); } catch (e: unknown) { // Custom error handling for authentication errors if (e instanceof AuthError) { @@ -94,6 +67,4 @@ export default async function signIn(prevState: any, rawFormData: FormData) { }, }; } - - redirect("/dashboard"); } diff --git a/src/modules/auth/services/signIn.ts b/src/modules/auth/services/signIn.ts new file mode 100644 index 0000000..e62eca9 --- /dev/null +++ b/src/modules/auth/services/signIn.ts @@ -0,0 +1,54 @@ +import "server-only"; +import SignInFormData from "../types/SignInFormData"; +import db from "@/core/db"; +import AuthError from "../error/AuthError"; +import comparePassword from "../utils/comparePassword"; +import { createJwtToken } from "../utils/createJwtToken"; + +/** + * Authenticates a user with email and password credentials. + * + * This function looks up the user in the database by email. If the user exists and the password matches + * the hashed password in the database, a JWT token is created and returned. If any step of this process fails, + * an `AuthError` with a specific error code and message is thrown. + * + * @param rawCredential - Contains the email and password provided by the user. + * @returns An object containing a JWT token if authentication is successful. + * @throws {AuthError} - Throws an `AuthError` with an appropriate error code and message for various failure scenarios. + */ +export default async function signIn(rawCredential: SignInFormData) { + const user = await db.user.findUnique({ + where: { email: rawCredential.email }, + }); + + if (!user) + throw new AuthError({ + errorCode: "EMAIL_NOT_FOUND", + message: "Email or Password does not match", + }); + + //TODO: Add handle for empty password hash + // Ensure there is a password hash to compare against. + if (!user.passwordHash) + throw new AuthError({ + errorCode: "EMPTY_USER_HASH", + message: "Something wrong. Please contact your administrator", + }); + + // Compare the provided password with the stored hash. + const isMatch = await comparePassword( + rawCredential.password, + user.passwordHash + ); + + // Create a JWT token upon successful authentication. + if (!isMatch) + throw new AuthError({ + errorCode: "INVALID_CREDENTIALS", + message: "Email or Password does not match", + }); + + const token = createJwtToken({ id: user.id }); + + return { token }; +} diff --git a/src/modules/auth/types/SignInFormData.d.ts b/src/modules/auth/types/SignInFormData.d.ts new file mode 100644 index 0000000..95ae8a2 --- /dev/null +++ b/src/modules/auth/types/SignInFormData.d.ts @@ -0,0 +1,21 @@ +/** + * Defines the structure for sign-in form data. + * + * This interface is utilized to type-check the data received from the sign-in form, ensuring that it contains + * both an `email` and a `password` field of type `string`. The `email` field represents the user's email address, + * and the `password` field represents the user's password. Both fields are required for the sign-in process. + */ +export default interface SignInFormData { + /** + * The user's email address. + * Must be a valid email format. + */ + email: string; + + /** + * The user's password. + * There are no specific constraints defined here for the password's format or strength, but it is expected + * to comply with the application's password policy. + */ + password: string; +}