diff --git a/bun.lockb b/bun.lockb index ac23f82..5122c5d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 3be7249..737d1ea 100644 --- a/package.json +++ b/package.json @@ -1,60 +1,62 @@ { - "name": "dashboard-template", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint" - }, - "prisma": { - "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts" - }, - "dependencies": { - "@auth/prisma-adapter": "^1.1.0", - "@mantine/core": "^7.5.0", - "@mantine/form": "^7.5.0", - "@mantine/hooks": "^7.5.0", - "@mantine/notifications": "^7.5.0", - "@prisma/client": "5.8.1", - "@tanstack/react-query": "^4.36.1", - "@tanstack/react-query-devtools": "^4.36.1", - "@tanstack/react-table": "^8.11.7", - "@trpc/client": "^10.45.0", - "@trpc/next": "^10.45.0", - "@trpc/react-query": "^10.45.0", - "@trpc/server": "^10.45.0", - "@types/bcrypt": "^5.0.2", - "@types/jsonwebtoken": "^9.0.5", - "@typescript-eslint/eslint-plugin": "^6.19.1", - "bcrypt": "^5.1.1", - "client-only": "^0.0.1", - "clsx": "^2.1.0", - "jsonwebtoken": "^9.0.2", - "mantine-form-zod-resolver": "^1.1.0", - "next": "14.1.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-icons": "^5.0.1", - "sass": "^1.70.0", - "server-only": "^0.0.1", - "superjson": "^2.2.1", - "ts-node": "^10.9.2", - "zod": "^3.22.4" - }, - "devDependencies": { - "@types/node": "^20.11.7", - "@types/react": "^18.2.48", - "@types/react-dom": "^18.2.18", - "autoprefixer": "^10.4.17", - "eslint": "^8.56.0", - "eslint-config-next": "14.0.4", - "postcss": "^8.4.33", - "postcss-preset-mantine": "^1.12.3", - "postcss-simple-vars": "^7.0.1", - "prisma": "^5.8.1", - "tailwindcss": "^3.4.1", - "typescript": "^5.3.3" - } + "name": "dashboard-template", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "key:generate": "bun run src/core/utils/generateJwtSecret.ts" + }, + "prisma": { + "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts" + }, + "dependencies": { + "@auth/prisma-adapter": "^1.1.0", + "@mantine/core": "^7.5.2", + "@mantine/form": "^7.5.2", + "@mantine/hooks": "^7.5.2", + "@mantine/notifications": "^7.5.2", + "@prisma/client": "5.8.1", + "@tanstack/react-query": "^4.36.1", + "@tanstack/react-query-devtools": "^4.36.1", + "@tanstack/react-table": "^8.11.7", + "@trpc/client": "^10.45.0", + "@trpc/next": "^10.45.0", + "@trpc/react-query": "^10.45.0", + "@trpc/server": "^10.45.0", + "@types/bcrypt": "^5.0.2", + "@types/jsonwebtoken": "^9.0.5", + "@typescript-eslint/eslint-plugin": "^6.19.1", + "bcrypt": "^5.1.1", + "client-only": "^0.0.1", + "clsx": "^2.1.0", + "jsonwebtoken": "^9.0.2", + "mantine-form-zod-resolver": "^1.1.0", + "next": "14.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-icons": "^5.0.1", + "sass": "^1.70.0", + "server-only": "^0.0.1", + "superjson": "^2.2.1", + "ts-node": "^10.9.2", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/bun": "^1.0.5", + "@types/node": "^20.11.7", + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "autoprefixer": "^10.4.17", + "eslint": "^8.56.0", + "eslint-config-next": "14.0.4", + "postcss": "^8.4.33", + "postcss-preset-mantine": "^1.12.3", + "postcss-simple-vars": "^7.0.1", + "prisma": "^5.8.1", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3" + } } \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fc0f2a2..5a80f1a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,6 +1,3 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - generator client { provider = "prisma-client-js" } @@ -11,39 +8,39 @@ datasource db { } model User { - id String @id @default(cuid()) + id String @id @default(cuid()) name String? - email String? @unique + email String? @unique emailVerified DateTime? passwordHash String? photoProfile UserPhotoProfiles? - roles Role[] - directPermissions Permission[] + directPermissions Permission[] @relation("PermissionToUser") + roles Role[] @relation("RoleToUser") } model UserPhotoProfiles { - id String @id @default(cuid()) - userId String @unique - path String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + userId String @unique + path String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model Role { - id String @id @default(cuid()) - code String @unique - name String - description String @default("") - isActive Boolean @default(false) - users User[] - permissions Permission[] + id String @id @default(cuid()) + code String @unique + name String + description String @default("") + isActive Boolean @default(false) + permissions Permission[] @relation("PermissionToRole") + users User[] @relation("RoleToUser") } model Permission { - id String @id @default(cuid()) - code String @unique - name String - description String @default("") - isActive Boolean @default(false) - roles Role[] - directUsers User[] -} \ No newline at end of file + id String @id @default(cuid()) + code String @unique + name String + description String @default("") + isActive Boolean @default(false) + roles Role[] @relation("PermissionToRole") + directUsers User[] @relation("PermissionToUser") +} diff --git a/prisma/seeds/userSeed.ts b/prisma/seeds/userSeed.ts index b18b605..4a4fbb0 100644 --- a/prisma/seeds/userSeed.ts +++ b/prisma/seeds/userSeed.ts @@ -1,4 +1,4 @@ -import hashPassword from "../../src/features/auth/tools/hashPassword"; +import hashPassword from "../../src/modules/auth/utils/hashPassword"; import { User, PrismaClient, Prisma } from "@prisma/client"; import { DefaultArgs } from "@prisma/client/runtime/library"; import { log } from "console"; diff --git a/src/app/(auth)/login/layout.tsx b/src/app/(auth)/login/layout.tsx index 1c704d6..c7b4606 100644 --- a/src/app/(auth)/login/layout.tsx +++ b/src/app/(auth)/login/layout.tsx @@ -1,7 +1,6 @@ +import guestOnly from "@/modules/auth/actions/guestOnly"; import React from "react"; -import guestOnly from "@/features/auth/actions/guestOnly"; - interface Props { children: React.ReactNode; } @@ -11,4 +10,4 @@ export default async function LoginLayout({ children }: Props) { await guestOnly() return <>{children}; -} +} \ No newline at end of file diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 197026f..5941cd0 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,7 +1,5 @@ "use client"; - -import getUser from "@/features/auth/actions/getUser"; -import signIn from "@/features/auth/actions/signIn"; +import signIn from "@/modules/auth/actions/signIn"; import { Paper, PasswordInput, @@ -40,7 +38,7 @@ export default function LoginPage() { variant="filled" color="pink" title="" - // icon={icon} + // icon={icon} > {state.errors.message} diff --git a/src/app/(auth)/logout/page.tsx b/src/app/(auth)/logout/page.tsx index a918218..6971ebb 100644 --- a/src/app/(auth)/logout/page.tsx +++ b/src/app/(auth)/logout/page.tsx @@ -1,21 +1,18 @@ -"use client" -import getUser from "@//features/auth/actions/getUser"; -import logout from "@/features/auth/actions/logout"; -import { redirect } from "next/navigation"; -import React, { useEffect } from "react"; +"use client"; + +import logout from "@/modules/auth/actions/logout"; +import { useEffect } from "react"; /** * LogoutPage component handles the logout process. * It checks if a user is logged in, logs them out, and redirects to the login page. */ export default function LogoutPage() { + useEffect(() => { + const logoutAction = async () => await logout(); - useEffect(() => { - - const logoutAction = async () => await logout() - - logoutAction() - }, []) + logoutAction(); + }, []); return
; } diff --git a/src/app/(auth)/register/layout.tsx b/src/app/(auth)/register/layout.tsx index 5a0925c..c26712e 100644 --- a/src/app/(auth)/register/layout.tsx +++ b/src/app/(auth)/register/layout.tsx @@ -1,14 +1,12 @@ +import guestOnly from "@/modules/auth/actions/guestOnly"; import React from "react"; -import guestOnly from "@/features/auth/actions/guestOnly"; - interface Props { children: React.ReactNode; } export default async function RegisterLayout({ children }: Props) { - - await guestOnly() + await guestOnly(); return <>{children}; } diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx index 2424684..f0544db 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -1,6 +1,6 @@ "use client"; -import createUser from "@/features/auth/actions/createUser"; +import createUser from "@/modules/auth/actions/createUser"; import { Paper, PasswordInput, @@ -9,63 +9,69 @@ import { TextInput, Group, Anchor, - Button, + Button, } from "@mantine/core"; import { useForm } from "@mantine/form"; import React, { useEffect, useState } from "react"; export interface RegisterFormSchema { - email: string, - password: string, - passwordConfirmation: string, - name: string, + email: string; + password: string; + passwordConfirmation: string; + name: string; } export default function RegisterPage() { - - const [errorMessage, setErrorMessage] = useState("") + const [errorMessage, setErrorMessage] = useState(""); const form = useForm({ initialValues: { email: "", password: "", - passwordConfirmation: "", - name: "" + passwordConfirmation: "", + name: "", + }, + validate: { + email: (value: string) => + /^\S+@\S+$/.test(value) ? null : "Invalid email", + password: (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", + name: (value: string) => + value.length > 0 ? null : "Name is required", }, - validate: { - email: (value: string) => (/^\S+@\S+$/.test(value) ? null : 'Invalid email'), - password: (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', - 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) - }); + Object.entries(values).forEach(([key, value]) => { + formData.append(key, value); + }); const response = await createUser(formData); - if (!response.success){ + 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] + 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}) + }, + {} as { [k: string]: string } + ); - form.setErrors(errors) - console.log(form.errors) + form.setErrors(errors); + console.log(form.errors); } else { - form.clearErrors() + form.clearErrors(); } } - } + }; return (
@@ -73,9 +79,11 @@ export default function RegisterPage() { Register -
handleSubmit(values))}> + handleSubmit(values))} + > - - toggle()} size="xs" - href="/login" + href="/login" > Already have an account? Login - + diff --git a/src/app/globals.css b/src/app/globals.css index bd6213e..7d76737 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,3 +1,5 @@ -@tailwind base; +@layer tailwind{ + @tailwind base; +} @tailwind components; @tailwind utilities; \ No newline at end of file diff --git a/src/core/db/index.ts b/src/core/db/index.ts new file mode 100644 index 0000000..0273c94 --- /dev/null +++ b/src/core/db/index.ts @@ -0,0 +1,15 @@ +import { PrismaClient } from "@prisma/client"; + +const prismaClientSingleton = () => { + return new PrismaClient(); +}; + +declare global { + var prisma: undefined | ReturnType; +} + +const prisma = globalThis.prisma ?? prismaClientSingleton(); + +export default prisma; + +if (process.env.NODE_ENV !== "production") globalThis.prisma = prisma; diff --git a/src/core/error/BaseError.ts b/src/core/error/BaseError.ts new file mode 100644 index 0000000..9c82306 --- /dev/null +++ b/src/core/error/BaseError.ts @@ -0,0 +1,31 @@ +export const BaseErrorCodes = ["UNKOWN_ERROR"] as const; + +interface ErrorOptions { + message?: string; + errorCode: (typeof BaseErrorCodes)[number] | (string & {}); +} + +class BaseError extends Error { + public readonly errorCode: (typeof BaseErrorCodes)[number] | (string & {}); + + constructor(options: ErrorOptions) { + super(options.message ?? "Undetermined Error"); + this.errorCode = options.errorCode ?? "UNKOWN_ERROR"; + + Object.setPrototypeOf(this, new.target.prototype); + + console.error("error:", options) + } + + getActionResponseObject() { + return { + success: false, + error: { + message: this.message, + errorCode: this.errorCode, + }, + } as const; + } +} + +export default BaseError; diff --git a/src/modules/auth/actions/createUser.ts b/src/modules/auth/actions/createUser.ts new file mode 100644 index 0000000..2df4d5d --- /dev/null +++ b/src/modules/auth/actions/createUser.ts @@ -0,0 +1,105 @@ +"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/getUser.ts b/src/modules/auth/actions/getUser.ts new file mode 100644 index 0000000..6708827 --- /dev/null +++ b/src/modules/auth/actions/getUser.ts @@ -0,0 +1,39 @@ +"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?.path ?? 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 new file mode 100644 index 0000000..86d5011 --- /dev/null +++ b/src/modules/auth/actions/guestOnly.ts @@ -0,0 +1,12 @@ +"use server"; + +import { redirect } from "next/navigation"; +import getUser from "./getUser"; + +export default async function guestOnly() { + const user = await getUser(); + + if (user) { + redirect("dashboard"); + } +} diff --git a/src/modules/auth/actions/logout.ts b/src/modules/auth/actions/logout.ts new file mode 100644 index 0000000..96cd9f8 --- /dev/null +++ b/src/modules/auth/actions/logout.ts @@ -0,0 +1,16 @@ +"use server"; + +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import "server-only"; + +/** + * Handles user logout by deleting the authentication token and redirecting to the login page. + * This function is intended to be used on the server side. + * + * @returns A promise that resolves when the logout process is complete. + */ +export default async function logout() { + cookies().delete("token"); + redirect("/login"); +} diff --git a/src/modules/auth/actions/signIn.ts b/src/modules/auth/actions/signIn.ts new file mode 100644 index 0000000..0b85a4c --- /dev/null +++ b/src/modules/auth/actions/signIn.ts @@ -0,0 +1,99 @@ +"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/authConfig.ts b/src/modules/auth/authConfig.ts new file mode 100644 index 0000000..ddc99f0 --- /dev/null +++ b/src/modules/auth/authConfig.ts @@ -0,0 +1,5 @@ +const authConfig = { + saltRounds: 10, +}; + +export default authConfig; diff --git a/src/modules/auth/error/AuthError.ts b/src/modules/auth/error/AuthError.ts new file mode 100644 index 0000000..beee2b2 --- /dev/null +++ b/src/modules/auth/error/AuthError.ts @@ -0,0 +1,28 @@ +import BaseError from "@/core/error/BaseError"; + +export const AuthErrorCodes = [ + "EMAIL_NOT_FOUND", + "EMPTY_USER_HASH", + "INVALID_CREDENTIALS", + "INVALID_JWT_TOKEN", + "JWT_SECRET_EMPTY", + "USER_ALREADY_EXISTS", +] as const; + +interface AuthErrorOptions { + message?: string; + errorCode: (typeof AuthErrorCodes)[number] | (string & {}); +} + +export default class AuthError extends BaseError { + errorCode: (typeof AuthErrorCodes)[number] | (string & {}); + + constructor(options: AuthErrorOptions) { + super({ + errorCode: options.errorCode, + message: options.message, + }); + + this.errorCode = options.errorCode; + } +} diff --git a/src/modules/auth/types/UserClaims.d.ts b/src/modules/auth/types/UserClaims.d.ts new file mode 100644 index 0000000..7d3e21a --- /dev/null +++ b/src/modules/auth/types/UserClaims.d.ts @@ -0,0 +1,7 @@ +import { User } from "@prisma/client"; + +type UserClaims = { + id: User["id"]; +}; + +export default UserClaims; diff --git a/src/modules/auth/utils/comparePassword.ts b/src/modules/auth/utils/comparePassword.ts new file mode 100644 index 0000000..218192e --- /dev/null +++ b/src/modules/auth/utils/comparePassword.ts @@ -0,0 +1,17 @@ +import bcrypt from "bcrypt"; + +/** + * Compares a plain text password with a hashed password. + * + * @param password - The plain text password to compare. + * @param hash - The hashed password to compare against. + * @returns True if the passwords match, false otherwise. + */ +async function comparePassword( + password: string, + hash: string +): Promise { + return bcrypt.compare(password, hash); +} + +export default comparePassword; diff --git a/src/modules/auth/utils/createJwtToken.ts b/src/modules/auth/utils/createJwtToken.ts new file mode 100644 index 0000000..abb1656 --- /dev/null +++ b/src/modules/auth/utils/createJwtToken.ts @@ -0,0 +1,20 @@ +import { SignOptions } from "jsonwebtoken"; +import UserClaims from "../types/UserClaims"; +import AuthError from "../error/AuthError"; +import jwt from "jsonwebtoken"; + +/** + * Creates a JWT token based on user claims. + * + * @param userClaims - The user claims to encode in the JWT. + * @param options - Optional signing options. + * @returns The generated JWT token. + */ +export function createJwtToken( + userClaims: UserClaims, + options?: SignOptions +): string { + const secret = process.env.JWT_SECRET; + if (!secret) throw new AuthError({ errorCode: "JWT_SECRET_EMPTY" }); + return jwt.sign(userClaims, secret, options); +} diff --git a/src/modules/auth/utils/decodeJwtToken.ts b/src/modules/auth/utils/decodeJwtToken.ts new file mode 100644 index 0000000..f2b3883 --- /dev/null +++ b/src/modules/auth/utils/decodeJwtToken.ts @@ -0,0 +1,21 @@ +import jwt, { JwtPayload } from "jsonwebtoken"; +import AuthError from "../error/AuthError"; + +/** + * Decodes a JWT token and retrieves the payload. + * + * @param token - The JWT token to decode. + * @returns The decoded payload. + */ +function decodeJwtToken(token: string): JwtPayload | string { + const secret = process.env.JWT_SECRET; + if (!secret) throw new AuthError({ errorCode: "JWT_SECRET_NOT_EMPTY" }); + + try { + return jwt.verify(token, secret) as JwtPayload; + } catch (error) { + throw new AuthError({ errorCode: "INVALID_JWT_TOKEN" }); + } +} + +export default decodeJwtToken; diff --git a/src/modules/auth/utils/getUserFromToken.ts b/src/modules/auth/utils/getUserFromToken.ts new file mode 100644 index 0000000..a29a8b7 --- /dev/null +++ b/src/modules/auth/utils/getUserFromToken.ts @@ -0,0 +1,34 @@ +import { cache } from "react"; +import decodeJwtToken from "./decodeJwtToken"; +import prisma from "@/core/db"; + +/** + * Retrieves user data from the database based on the provided JWT token. + * + * This function decodes the JWT token to extract the user ID, and then queries the database using Prisma + * to fetch the user's details, including the profile photo, roles, and direct permissions. + * + * @param token - The JWT token containing the user's ID. + * @returns The user's data if the user exists, or null if no user is found. + * Throws an error if the token is invalid or the database query fails. + */ +const getUserFromToken = cache(async (token: string) => { + // Decode the JWT token to extract the user ID + const decodedToken = decodeJwtToken(token) as { id: string; iat: number }; + + // Fetch the user from the database + const user = await prisma.user.findFirst({ + include: { + photoProfile: true, + roles: true, + directPermissions: true, + }, + where: { + id: decodedToken.id, + }, + }); + + return user; +}); + +export default getUserFromToken; diff --git a/src/modules/auth/utils/hashPassword.ts b/src/modules/auth/utils/hashPassword.ts new file mode 100644 index 0000000..3323eb6 --- /dev/null +++ b/src/modules/auth/utils/hashPassword.ts @@ -0,0 +1,14 @@ +import bcrypt from "bcrypt"; +import authConfig from "../authConfig"; + +/** + * Hashes a plain text password using bcrypt. + * + * @param password - The plain text password to hash. + * @returns The hashed password. + */ +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, authConfig.saltRounds); +} + +export default hashPassword;