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" } } 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/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 5941cd0..ed42d9d 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, @@ -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/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/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/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/createUser.ts b/src/modules/auth/actions/createUser.ts deleted file mode 100644 index 2df4d5d..0000000 --- a/src/modules/auth/actions/createUser.ts +++ /dev/null @@ -1,105 +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"; - -/** - * 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"], - }); - -/** - * 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 createUser(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() ?? "", - password: formData.get("password")?.toString() ?? "", - passwordConfirmation: 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); - } 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.", - }, - }; - } - - redirect("/dashboard"); -} diff --git a/src/modules/auth/actions/createUserAction.ts b/src/modules/auth/actions/createUserAction.ts new file mode 100644 index 0000000..6e92ab3 --- /dev/null +++ b/src/modules/auth/actions/createUserAction.ts @@ -0,0 +1,30 @@ +"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 { + const result = await createUser(formData); + cookies().set("token", result.token); + redirect("/dashboard"); + } catch (e) { + return handleCatch(e) + } +} diff --git a/src/modules/auth/actions/getMyDetailAction.ts b/src/modules/auth/actions/getMyDetailAction.ts new file mode 100644 index 0000000..66ef024 --- /dev/null +++ b/src/modules/auth/actions/getMyDetailAction.ts @@ -0,0 +1,47 @@ +"use server"; + +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 "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. + * 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 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>>> { + 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(token.value); + return { + success: true, + data: userDetails, + }; + } catch (e: unknown) { + // 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({ + errorCode: e.errorCode, + message: "You are not authenticated", + }), + }; + } + // Handle other types of errors. + return handleCatch(e); + } +} diff --git a/src/modules/auth/actions/getUser.ts b/src/modules/auth/actions/getUser.ts deleted file mode 100644 index 7af6a47..0000000 --- a/src/modules/auth/actions/getUser.ts +++ /dev/null @@ -1,39 +0,0 @@ -"use server"; - -import { cookies } from "next/headers"; -import "server-only"; -import getUserFromToken from "../utils/getUserFromToken"; -import AuthError from "../error/AuthError"; - -/** - * 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. - * - * @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() { - 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, - }; - } catch (e: unknown) { - // Handle specific authentication errors gracefully - if (e instanceof AuthError && e.errorCode === "INVALID_JWT_TOKEN") { - return null; - } - throw e; - } -} diff --git a/src/modules/auth/actions/guestOnly.ts b/src/modules/auth/actions/guestOnly.ts index 86d5011..ad1535f 100644 --- a/src/modules/auth/actions/guestOnly.ts +++ b/src/modules/auth/actions/guestOnly.ts @@ -1,12 +1,25 @@ "use server"; import { redirect } from "next/navigation"; -import getUser from "./getUser"; +import getMyDetail from "../services/getMyDetail"; +import { cookies } from "next/headers"; -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 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) { - redirect("dashboard"); + redirect("/dashboard"); } } 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 diff --git a/src/modules/auth/actions/signIn.ts b/src/modules/auth/actions/signIn.ts deleted file mode 100644 index 0b85a4c..0000000 --- a/src/modules/auth/actions/signIn.ts +++ /dev/null @@ -1,99 +0,0 @@ -"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"; - -/** - * 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. - */ -export default async function signIn(prevState: any, rawFormData: FormData) { - //TODO: Add Throttling - //TODO: Add validation check if the user is already logged in - try { - 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 }, - }); - - // Throw if user not found - if (!user) - throw new AuthError({ - errorCode: "EMAIL_NOT_FOUND", - message: "Email or Password does not match", - }); - - // 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); - } catch (e: unknown) { - // Custom error handling for authentication errors - if (e instanceof AuthError) { - // Specific error handling for known authentication errors - switch (e.errorCode) { - case "EMAIL_NOT_FOUND": - case "INVALID_CREDENTIALS": - return { - errors: { - message: - "Email/Password combination is incorrect. Please try again.", - }, - }; - default: - // Handle other types of authentication errors - return { - errors: { - message: e.message, - }, - }; - } - } - - // Generic error handling for unexpected server errors - return { - errors: { - message: - "An unexpected error occurred on the server. Please try again or contact the administrator.", - }, - }; - } - - redirect("/dashboard"); -} diff --git a/src/modules/auth/actions/signInAction.ts b/src/modules/auth/actions/signInAction.ts new file mode 100644 index 0000000..3a11733 --- /dev/null +++ b/src/modules/auth/actions/signInAction.ts @@ -0,0 +1,70 @@ +"use server"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import AuthError from "../error/AuthError"; +import signIn from "../services/signIn"; + +/** + * 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 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, + }; + + // Attempt to sign in with the provided credentials. + const result = await signIn(formData) + + // Set the JWT token in cookies upon successful sign-in. + cookies().set("token", result.token); + + // Redirect to the dashboard after successful sign-in. + redirect("/dashboard"); + } catch (e: unknown) { + // Custom error handling for authentication errors + if (e instanceof AuthError) { + // Specific error handling for known authentication errors + switch (e.errorCode) { + case "EMAIL_NOT_FOUND": + case "INVALID_CREDENTIALS": + return { + errors: { + message: + "Email/Password combination is incorrect. Please try again.", + }, + }; + default: + // Handle other types of authentication errors + return { + errors: { + message: e.message, + }, + }; + } + } + + // Generic error handling for unexpected server errors + return { + errors: { + message: + "An unexpected error occurred on the server. Please try again or contact the administrator.", + }, + }; + } +} diff --git a/src/modules/auth/contexts/AuthContext.tsx b/src/modules/auth/contexts/AuthContext.tsx index 0875164..ada908b 100644 --- a/src/modules/auth/contexts/AuthContext.tsx +++ b/src/modules/auth/contexts/AuthContext.tsx @@ -1,68 +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/getUser"; +// 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(() => { - const getUserData = async () => { - const user = await getUser(); - setUser(user); - }; + // 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); + }); + }, []); - getUserData() - .then(() => {}) - .catch(() => {}); - }, []); + // Fetch user data on component mount. + useEffect(() => { + fetchUserData(); + }, [fetchUserData]); - useEffect(() => { - fetchUserData(); - }, [fetchUserData]); + // Function to clear user data, effectively logging the user out. + const logout = () => { + setUser(null); + }; - 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/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..e777464 --- /dev/null +++ b/src/modules/auth/services/createUser.ts @@ -0,0 +1,63 @@ +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"; + +/** + * 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); + + //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 }); + + return { + token, + }; +} diff --git a/src/modules/auth/services/getMyDetail.ts b/src/modules/auth/services/getMyDetail.ts new file mode 100644 index 0000000..4b3b9d0 --- /dev/null +++ b/src/modules/auth/services/getMyDetail.ts @@ -0,0 +1,30 @@ +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(token?: string) { + if (!token) + throw new AuthError({ + errorCode: "INVALID_JWT_TOKEN", + message: "You are not authenticated", + }); + + const user = await getUserFromToken(token); + + // Return null if user is not found + if (!user) return null; + + // Return user details + return { + name: user.name ?? "", + email: user.email ?? "", + photoUrl: user.photoProfile ?? null, + }; +} 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; +} 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 {