diff --git a/prisma/migrations/20240214052727_move_photo_profile_into_table/migration.sql b/prisma/migrations/20240214052727_move_photo_profile_into_table/migration.sql new file mode 100644 index 0000000..0d9aae9 --- /dev/null +++ b/prisma/migrations/20240214052727_move_photo_profile_into_table/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - You are about to drop the `UserPhotoProfiles` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE `UserPhotoProfiles` DROP FOREIGN KEY `UserPhotoProfiles_userId_fkey`; + +-- AlterTable +ALTER TABLE `User` ADD COLUMN `photoProfile` VARCHAR(191) NULL; + +-- DropTable +DROP TABLE `UserPhotoProfiles`; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5a80f1a..05bd2c1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,18 +13,11 @@ model User { email String? @unique emailVerified DateTime? passwordHash String? - photoProfile UserPhotoProfiles? + photoProfile String? 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) -} - model Role { id String @id @default(cuid()) code String @unique diff --git a/src/BaseError.ts b/src/BaseError.ts deleted file mode 100644 index 5634423..0000000 --- a/src/BaseError.ts +++ /dev/null @@ -1,32 +0,0 @@ -export enum BaseErrorCodes { - INVALID_FORM_DATA = "INVALID_FORM_DATA", - UNAUTHORIZED = "UNAUTHORIZED", -} - -export default class BaseError extends Error { - public readonly errorCode: string; - public readonly statusCode: number; - public readonly data: object; - - constructor(message: string = "An unexpected error occurred", errorCode: string = "GENERIC_ERROR", statusCode: number = 500, data: object = {}) { - super(message); // Pass message to the Error parent class - this.errorCode = errorCode; - this.statusCode = statusCode; - this.data = data; - Object.setPrototypeOf(this, new.target.prototype); // restore prototype chain - } - - getErrorReponseObject(){ - return { - success: false, - error: { - message: this.message, - errorCode: this.errorCode, - } - } as const - } -} - -export const unauthorized = () => { - throw new BaseError("Unauthorized", BaseErrorCodes.UNAUTHORIZED) -} \ No newline at end of file diff --git a/src/features/auth/AuthError.ts b/src/_features/auth/AuthError.ts similarity index 100% rename from src/features/auth/AuthError.ts rename to src/_features/auth/AuthError.ts diff --git a/src/features/auth/actions/createUser.ts b/src/_features/auth/actions/createUser.ts similarity index 100% rename from src/features/auth/actions/createUser.ts rename to src/_features/auth/actions/createUser.ts diff --git a/src/features/auth/actions/getUser.ts b/src/_features/auth/actions/getUser.ts similarity index 100% rename from src/features/auth/actions/getUser.ts rename to src/_features/auth/actions/getUser.ts diff --git a/src/features/auth/actions/guestOnly.ts b/src/_features/auth/actions/guestOnly.ts similarity index 100% rename from src/features/auth/actions/guestOnly.ts rename to src/_features/auth/actions/guestOnly.ts diff --git a/src/features/auth/actions/logout.ts b/src/_features/auth/actions/logout.ts similarity index 100% rename from src/features/auth/actions/logout.ts rename to src/_features/auth/actions/logout.ts diff --git a/src/features/auth/actions/signIn.ts b/src/_features/auth/actions/signIn.ts similarity index 100% rename from src/features/auth/actions/signIn.ts rename to src/_features/auth/actions/signIn.ts diff --git a/src/features/auth/authUtils.ts b/src/_features/auth/authUtils.ts similarity index 100% rename from src/features/auth/authUtils.ts rename to src/_features/auth/authUtils.ts diff --git a/src/features/auth/index.ts b/src/_features/auth/contexts/AuthContext.tsx similarity index 100% rename from src/features/auth/index.ts rename to src/_features/auth/contexts/AuthContext.tsx diff --git a/src/_features/auth/index.ts b/src/_features/auth/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/_features/auth/tools/checkMultiplePermissions.ts b/src/_features/auth/tools/checkMultiplePermissions.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/features/auth/tools/checkPermission.ts b/src/_features/auth/tools/checkPermission.ts similarity index 100% rename from src/features/auth/tools/checkPermission.ts rename to src/_features/auth/tools/checkPermission.ts diff --git a/src/features/auth/tools/getCurrentUser.ts b/src/_features/auth/tools/getCurrentUser.ts similarity index 100% rename from src/features/auth/tools/getCurrentUser.ts rename to src/_features/auth/tools/getCurrentUser.ts diff --git a/src/features/auth/tools/hashPassword.ts b/src/_features/auth/tools/hashPassword.ts similarity index 100% rename from src/features/auth/tools/hashPassword.ts rename to src/_features/auth/tools/hashPassword.ts diff --git a/src/_features/auth/types/CrudPermissions.d.ts b/src/_features/auth/types/CrudPermissions.d.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/features/auth/types/UserClaims.d.ts b/src/_features/auth/types/UserClaims.d.ts similarity index 100% rename from src/features/auth/types/UserClaims.d.ts rename to src/_features/auth/types/UserClaims.d.ts diff --git a/src/_features/dashboard/components/DashboardTable/DashboardTable.tsx b/src/_features/dashboard/components/DashboardTable/DashboardTable.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/features/dashboard/components/DashboardTable/index.ts b/src/_features/dashboard/components/DashboardTable/index.ts similarity index 100% rename from src/features/dashboard/components/DashboardTable/index.ts rename to src/_features/dashboard/components/DashboardTable/index.ts diff --git a/src/features/dashboard/components/index.ts b/src/_features/dashboard/components/index.ts similarity index 100% rename from src/features/dashboard/components/index.ts rename to src/_features/dashboard/components/index.ts diff --git a/src/_features/dashboard/errors/DashboardError.ts b/src/_features/dashboard/errors/DashboardError.ts new file mode 100644 index 0000000..3c11707 --- /dev/null +++ b/src/_features/dashboard/errors/DashboardError.ts @@ -0,0 +1,45 @@ +import { Prisma } from "@prisma/client"; + +// Use TypeScript enum for error codes to provide better autocompletion and error handling +export const DashboardErrorCodes = [ + "UNAUTHORIZED", + "NOT_FOUND", + "UNKNOWN_ERROR", + "INVALID_FORM_DATA", +] as const; + +interface ErrorOptions { + message?: string, + errorCode?: typeof DashboardErrorCodes[number] | string & {}, + formErrors?: Record +} + +/** + * Custom error class for handling errors specific to the dashboard application. + */ +export default class DashboardError extends Error { + public readonly errorCode: typeof DashboardErrorCodes[number] | string & {}; + public readonly formErrors?: Record + + constructor(options: ErrorOptions) { + super(options.message ?? "Undetermined Error"); // Pass message to the Error parent class + this.errorCode = options.errorCode ?? "UNKNOWN_ERROR"; + this.formErrors = options.formErrors; + Object.setPrototypeOf(this, new.target.prototype); // restore prototype chain + } + + /** + * Returns a structured error response object. + */ + getErrorReponseObject(){ + return { + success: false, + dashboardError: true, + error: { + message: `${this.message}`, + errorCode: this.errorCode, + errors: this.formErrors ?? undefined + } + } as const; + } +} diff --git a/src/features/dashboard/permissions/actions/deletePermission.ts b/src/_features/dashboard/permissions/actions/deletePermission.ts similarity index 100% rename from src/features/dashboard/permissions/actions/deletePermission.ts rename to src/_features/dashboard/permissions/actions/deletePermission.ts diff --git a/src/features/dashboard/permissions/actions/getAllPermissions.ts b/src/_features/dashboard/permissions/actions/getAllPermissions.ts similarity index 100% rename from src/features/dashboard/permissions/actions/getAllPermissions.ts rename to src/_features/dashboard/permissions/actions/getAllPermissions.ts diff --git a/src/features/dashboard/permissions/actions/getPermissionById.ts b/src/_features/dashboard/permissions/actions/getPermissionById.ts similarity index 100% rename from src/features/dashboard/permissions/actions/getPermissionById.ts rename to src/_features/dashboard/permissions/actions/getPermissionById.ts diff --git a/src/features/dashboard/permissions/actions/upsertPermission.ts b/src/_features/dashboard/permissions/actions/upsertPermission.ts similarity index 100% rename from src/features/dashboard/permissions/actions/upsertPermission.ts rename to src/_features/dashboard/permissions/actions/upsertPermission.ts diff --git a/src/features/dashboard/permissions/data/getPermissions.ts b/src/_features/dashboard/permissions/data/getPermissions.ts similarity index 100% rename from src/features/dashboard/permissions/data/getPermissions.ts rename to src/_features/dashboard/permissions/data/getPermissions.ts diff --git a/src/features/dashboard/permissions/formSchemas/PermissionFormData.ts b/src/_features/dashboard/permissions/formSchemas/PermissionFormData.ts similarity index 100% rename from src/features/dashboard/permissions/formSchemas/PermissionFormData.ts rename to src/_features/dashboard/permissions/formSchemas/PermissionFormData.ts diff --git a/src/_features/dashboard/permissions/modals/DeleteModal/DeleteModal.tsx b/src/_features/dashboard/permissions/modals/DeleteModal/DeleteModal.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/features/dashboard/permissions/modals/DeleteModal/index.ts b/src/_features/dashboard/permissions/modals/DeleteModal/index.ts similarity index 100% rename from src/features/dashboard/permissions/modals/DeleteModal/index.ts rename to src/_features/dashboard/permissions/modals/DeleteModal/index.ts diff --git a/src/features/dashboard/permissions/modals/FormModal/FormModal.tsx b/src/_features/dashboard/permissions/modals/FormModal/FormModal.tsx similarity index 100% rename from src/features/dashboard/permissions/modals/FormModal/FormModal.tsx rename to src/_features/dashboard/permissions/modals/FormModal/FormModal.tsx diff --git a/src/features/dashboard/permissions/modals/FormModal/index.ts b/src/_features/dashboard/permissions/modals/FormModal/index.ts similarity index 100% rename from src/features/dashboard/permissions/modals/FormModal/index.ts rename to src/_features/dashboard/permissions/modals/FormModal/index.ts diff --git a/src/features/dashboard/permissions/modals/index.ts b/src/_features/dashboard/permissions/modals/index.ts similarity index 100% rename from src/features/dashboard/permissions/modals/index.ts rename to src/_features/dashboard/permissions/modals/index.ts diff --git a/src/features/dashboard/permissions/tables/PermissionTable/PermissionTable.tsx b/src/_features/dashboard/permissions/tables/PermissionTable/PermissionTable.tsx similarity index 100% rename from src/features/dashboard/permissions/tables/PermissionTable/PermissionTable.tsx rename to src/_features/dashboard/permissions/tables/PermissionTable/PermissionTable.tsx diff --git a/src/features/dashboard/permissions/tables/PermissionTable/_columns.tsx b/src/_features/dashboard/permissions/tables/PermissionTable/_columns.tsx similarity index 100% rename from src/features/dashboard/permissions/tables/PermissionTable/_columns.tsx rename to src/_features/dashboard/permissions/tables/PermissionTable/_columns.tsx diff --git a/src/features/dashboard/permissions/tables/PermissionTable/index.ts b/src/_features/dashboard/permissions/tables/PermissionTable/index.ts similarity index 100% rename from src/features/dashboard/permissions/tables/PermissionTable/index.ts rename to src/_features/dashboard/permissions/tables/PermissionTable/index.ts diff --git a/src/features/dashboard/permissions/tables/index.ts b/src/_features/dashboard/permissions/tables/index.ts similarity index 100% rename from src/features/dashboard/permissions/tables/index.ts rename to src/_features/dashboard/permissions/tables/index.ts diff --git a/src/features/dashboard/roles/actions/deleteRole.ts b/src/_features/dashboard/roles/actions/deleteRole.ts similarity index 100% rename from src/features/dashboard/roles/actions/deleteRole.ts rename to src/_features/dashboard/roles/actions/deleteRole.ts diff --git a/src/features/dashboard/roles/actions/getRoleById.ts b/src/_features/dashboard/roles/actions/getRoleById.ts similarity index 100% rename from src/features/dashboard/roles/actions/getRoleById.ts rename to src/_features/dashboard/roles/actions/getRoleById.ts diff --git a/src/features/dashboard/roles/actions/upsertRole.ts b/src/_features/dashboard/roles/actions/upsertRole.ts similarity index 100% rename from src/features/dashboard/roles/actions/upsertRole.ts rename to src/_features/dashboard/roles/actions/upsertRole.ts diff --git a/src/features/dashboard/roles/data/getRoles.ts b/src/_features/dashboard/roles/data/getRoles.ts similarity index 100% rename from src/features/dashboard/roles/data/getRoles.ts rename to src/_features/dashboard/roles/data/getRoles.ts diff --git a/src/features/dashboard/roles/formSchemas/RoleFormData.ts b/src/_features/dashboard/roles/formSchemas/RoleFormData.ts similarity index 100% rename from src/features/dashboard/roles/formSchemas/RoleFormData.ts rename to src/_features/dashboard/roles/formSchemas/RoleFormData.ts diff --git a/src/features/dashboard/users/actions/deleteUser.ts b/src/_features/dashboard/users/actions/deleteUser.ts similarity index 100% rename from src/features/dashboard/users/actions/deleteUser.ts rename to src/_features/dashboard/users/actions/deleteUser.ts diff --git a/src/features/dashboard/users/actions/editUser.ts b/src/_features/dashboard/users/actions/editUser.ts similarity index 100% rename from src/features/dashboard/users/actions/editUser.ts rename to src/_features/dashboard/users/actions/editUser.ts diff --git a/src/features/dashboard/users/data/getUserDetailById.ts b/src/_features/dashboard/users/data/getUserDetailById.ts similarity index 100% rename from src/features/dashboard/users/data/getUserDetailById.ts rename to src/_features/dashboard/users/data/getUserDetailById.ts diff --git a/src/features/dashboard/users/data/getUsers.ts b/src/_features/dashboard/users/data/getUsers.ts similarity index 100% rename from src/features/dashboard/users/data/getUsers.ts rename to src/_features/dashboard/users/data/getUsers.ts diff --git a/src/features/dashboard/users/formSchemas/userFormDataSchema.ts b/src/_features/dashboard/users/formSchemas/userFormDataSchema.ts similarity index 100% rename from src/features/dashboard/users/formSchemas/userFormDataSchema.ts rename to src/_features/dashboard/users/formSchemas/userFormDataSchema.ts diff --git a/src/_features/dashboard/utils/createActionButtons.tsx b/src/_features/dashboard/utils/createActionButtons.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/_features/dashboard/utils/withServerAction.ts b/src/_features/dashboard/utils/withServerAction.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/app/dashboard/(auth)/users/page.tsx b/src/app/dashboard/(auth)/users/page.tsx index 606126a..fe4c556 100644 --- a/src/app/dashboard/(auth)/users/page.tsx +++ b/src/app/dashboard/(auth)/users/page.tsx @@ -1,59 +1,32 @@ import { Card, Stack, Title } from "@mantine/core"; import React from "react"; -import UsersTable from "./_tables/UsersTable/UsersTable"; -import checkPermission from "@/features/auth/tools/checkPermission"; -import { redirect } from "next/navigation"; -import getUsers from "@/features/dashboard/users/data/getUsers"; -import { DeleteModal, DetailModal, EditModal } from "./_modals"; -import getUserDetailById from "@/features/dashboard/users/data/getUserDetailById"; +import getUsers from "@/modules/userManagement/actions/getAllUsers"; +import { Metadata } from "next"; +import checkMultiplePermissions from "@/modules/dashboard/services/checkMultiplePermissions"; +import UsersTable from "@/modules/userManagement/tables/UsersTable/UsersTable"; -interface Props { - searchParams: { - detail?: string; - edit?: string; - delete?: string; - } -} - -export default async function UsersPage({searchParams}: Props) { +export const metadata: Metadata = { + title: "Users - Dashboard", +}; +export default async function UsersPage() { // Check for permission and return error component if not permitted - if (!await checkPermission("authenticated-only")) return
Error
+ const permissions = await checkMultiplePermissions({ + create: "users.create", + readAll: "users.readAll", + read: "permission.read", + update: "permission.update", + delete: "permission.delete", + }); - const users = await getUsers() - - /** - * Renders the appropriate modal based on the search parameters. - * - * @returns A modal component or null. - */ - const renderModal = async () => { - if (searchParams.detail){ - const userDetail = await getUserDetailById(searchParams.detail) - return - } - - if (searchParams.edit){ - const userDetail = await getUserDetailById(searchParams.edit) - return - } - - if (searchParams.delete){ - const userDetail = await getUserDetailById(searchParams.delete) - return - } - - return null; - } + const users = await getUsers(); return ( - + Users - + - - {await renderModal()} ); } diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 09738ec..06295d2 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -1,29 +1,22 @@ -import { AppShell, AppShellHeader, Burger } from '@mantine/core' -import { useDisclosure } from '@mantine/hooks' -import Image from 'next/image' -import React from 'react' -import logo from "@/assets/logos/logo.png" -import AppHeader from '../../components/AppHeader' -import AppNavbar from '../../components/AppNavbar' -import DashboardLayout from '@/components/DashboardLayout' -import getUser from '@/features/auth/actions/getUser' -import { redirect } from 'next/navigation' +import { AppShell, AppShellHeader, Burger } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +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 { redirect } from "next/navigation"; interface Props { - children: React.ReactNode + children: React.ReactNode; } export default async function Layout(props: Props) { + const user = await getUser(); - const user = await getUser() + if (!user) { + redirect("/login"); + } - if (!user){ - redirect("/login") - } - - return ( - - {props.children} - - ) + return {props.children}; } diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 51fa92a..82f1cfb 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,15 +1,6 @@ import React from 'react' export default async function Dashboard() { - - // const session = await auth(); - - // const user = session?.user; - - // console.log("session", session); - - // console.log("user", user); - return (

Dashboard

diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ceca4e6..89e0dec 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,8 +6,8 @@ import "@mantine/core/styles.css"; import '@mantine/notifications/styles.css'; import { ColorSchemeScript, MantineProvider } from "@mantine/core"; -import { AuthContextProvider } from "@/features/auth/contexts/AuthContext"; import { Notifications } from "@mantine/notifications"; +import { AuthContextProvider } from "@/modules/auth/contexts/AuthContext"; const inter = Inter({ subsets: ["latin"] }); diff --git a/src/components/AppHeader/_components/UserMenuItem/UserMenuItem.tsx b/src/components/AppHeader/_components/UserMenuItem/UserMenuItem.tsx deleted file mode 100644 index c97ac9a..0000000 --- a/src/components/AppHeader/_components/UserMenuItem/UserMenuItem.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Menu, rem } from '@mantine/core' -import React from 'react' -import { UserMenuItem } from '../../_data/userMenuItems' - -interface Props { - item: UserMenuItem -} - -export default function UserMenuItem({item}: Props) { - return ( - - } - href={item.href} - > - {item.label} - - ) -} diff --git a/src/components/AppHeader/_data/userMenuItems.ts b/src/components/AppHeader/_data/userMenuItems.ts deleted file mode 100644 index f8bae79..0000000 --- a/src/components/AppHeader/_data/userMenuItems.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ThemeIconProps } from "@mantine/core" -import React from "react" -import { TbLogout, TbSettings } from "react-icons/tb" - -export interface UserMenuItem { - label: string, - icon: React.FC, - color?: ThemeIconProps['color'], - href?: string, -} - -const userMenuItems: UserMenuItem[] = [ - { - label: "Account Settings", - icon: TbSettings - }, - { - label: "Logout", - icon: TbLogout, - color: "red", - href: "/logout" - } -]; - -export default userMenuItems; diff --git a/src/components/AppHeader/index.ts b/src/components/AppHeader/index.ts deleted file mode 100644 index 582f4b6..0000000 --- a/src/components/AppHeader/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import AppHeader from "./Header" - -export default AppHeader \ No newline at end of file diff --git a/src/components/AppNavbar/_components/ChildMenu/index.ts b/src/components/AppNavbar/_components/ChildMenu/index.ts deleted file mode 100644 index e3205b7..0000000 --- a/src/components/AppNavbar/_components/ChildMenu/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import ChildMenu from "./ChildMenu"; - -export default ChildMenu; diff --git a/src/components/AppNavbar/_components/MenuItem/index.ts b/src/components/AppNavbar/_components/MenuItem/index.ts deleted file mode 100644 index 442115f..0000000 --- a/src/components/AppNavbar/_components/MenuItem/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import MenuItem from "./MenuItem"; - -export default MenuItem; diff --git a/src/components/AppNavbar/index.ts b/src/components/AppNavbar/index.ts deleted file mode 100644 index 0df22a2..0000000 --- a/src/components/AppNavbar/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import AppNavbar from "./Navbar"; - -export default AppNavbar \ No newline at end of file diff --git a/src/components/DashboardLayout/index.ts b/src/components/DashboardLayout/index.ts deleted file mode 100644 index 66b1c0a..0000000 --- a/src/components/DashboardLayout/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import DashboardLayout from "./DashboardLayout"; - -export default DashboardLayout; diff --git a/src/utils/stringToColorHex.ts b/src/core/utils/stringToColorHex.ts similarity index 100% rename from src/utils/stringToColorHex.ts rename to src/core/utils/stringToColorHex.ts diff --git a/src/features/dashboard/errors/DashboardError.ts b/src/features/dashboard/errors/DashboardError.ts deleted file mode 100644 index 8bcf0dd..0000000 --- a/src/features/dashboard/errors/DashboardError.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { Prisma } from "@prisma/client"; - -// Use TypeScript enum for error codes to provide better autocompletion and error handling -export const DashboardErrorCodes = [ - "UNAUTHORIZED", - "NOT_FOUND", - "UNKNOWN_ERROR", - "INVALID_FORM_DATA", -] as const; - -interface ErrorOptions { - message?: string, - errorCode?: typeof DashboardErrorCodes[number] | string & {}, - formErrors?: Record -} - -/** - * Custom error class for handling errors specific to the dashboard application. - */ -export default class DashboardError extends Error { - public readonly errorCode: typeof DashboardErrorCodes[number] | string & {}; - public readonly formErrors?: Record - - constructor(options: ErrorOptions) { - super(options.message ?? "Undetermined Error"); // Pass message to the Error parent class - this.errorCode = options.errorCode ?? "UNKNOWN_ERROR"; - this.formErrors = options.formErrors; - Object.setPrototypeOf(this, new.target.prototype); // restore prototype chain - } - - /** - * Returns a structured error response object. - */ - getErrorReponseObject(){ - return { - success: false, - dashboardError: true, - error: { - message: `${this.message}`, - errorCode: this.errorCode, - errors: this.formErrors ?? undefined - } - } as const; - } -} - -/** - * Handles exceptions and converts them into a structured error response. - * @param e The caught error or exception. - */ -export const handleCatch = (e: unknown) => { - if (e instanceof DashboardError){ - return e.getErrorReponseObject() - } - if (e instanceof Prisma.PrismaClientKnownRequestError){ - //Not found - if (e.code === "P2025"){ - const error = new DashboardError({errorCode: "NOT_FOUND", message: "The requested data could not be located. It may have been deleted or relocated. Please verify the information or try a different request."}) - return error.getErrorReponseObject() - } - } - if (e instanceof Error) { - return { - success: false, - dashboardError: false, - message: e.message - } as const; - } else { - return { - success: false, - dashboardError: false, - message: "Unkown error" - } as const - } -} - -/** - * Throws a 'UNAUTHORIZED' DashboardError. - */ -export const unauthorized = () => { - throw new DashboardError({ - errorCode: "UNAUTHORIZED", - message: "You are unauthorized to do this action" - }) -} - -/** - * Throws a 'NOT_FOUND' DashboardError with a custom or default message. - * @param message Optional custom message for the error. - */ -export const notFound = ({message}: {message?: string}) => { - throw new DashboardError({ - errorCode: "NOT_FOUND", - message: message ?? "The requested data could not be located. It may have been deleted or relocated. Please verify the information or try a different request." - }) -} \ No newline at end of file diff --git a/src/features/auth/contexts/AuthContext.tsx b/src/modules/auth/contexts/AuthContext.tsx similarity index 77% rename from src/features/auth/contexts/AuthContext.tsx rename to src/modules/auth/contexts/AuthContext.tsx index 11a3415..0875164 100644 --- a/src/features/auth/contexts/AuthContext.tsx +++ b/src/modules/auth/contexts/AuthContext.tsx @@ -13,7 +13,7 @@ import getUser from "../actions/getUser"; interface UserData { name: string; - email: string + email: string; photoUrl: string | null; // Add additional user fields as needed } @@ -33,18 +33,20 @@ const AuthContext = createContext(undefined); export const AuthContextProvider = ({ children }: Props) => { const [user, setUser] = useState(null); - const fetchUserData = useCallback(() => { - const getUserData = async () => { - const user = await getUser(); - setUser(user) - } + const fetchUserData = useCallback(() => { + const getUserData = async () => { + const user = await getUser(); + setUser(user); + }; - getUserData().then() - }, []) + getUserData() + .then(() => {}) + .catch(() => {}); + }, []); - useEffect(() => { - fetchUserData() - }, [fetchUserData]); + useEffect(() => { + fetchUserData(); + }, [fetchUserData]); const logout = () => { setUser(null); diff --git a/src/modules/auth/utils/checkPermission.ts b/src/modules/auth/utils/checkPermission.ts new file mode 100644 index 0000000..74c50b2 --- /dev/null +++ b/src/modules/auth/utils/checkPermission.ts @@ -0,0 +1,44 @@ +import getCurrentUser from "./getCurrentUser"; +import "server-only"; + +/** + * Deprecated. Use dashboard service instead + * Checks if the current user has the specified permissions. + * + * @deprecated + * @param permission - The specific permission to check. If it's "guest-only", the function returns true if the user is not authenticated. If it's "authenticated-only", it returns true if the user is authenticated. For other permissions, it checks against the user's roles and direct permissions. + * @param currentUser - Optional. The current user object. If not provided, the function retrieves the current user. + * @returns true if the user has the required permission, otherwise false. + */ +export default async function checkPermission( + permission?: "guest-only" | "authenticated-only" | (string & {}), + currentUser?: Awaited> +): Promise { + // Allow if no specific permission is required. + if (!permission) return true; + + // Retrieve current user if not provided. + const user = currentUser ?? (await getCurrentUser()); + + // Handle non-authenticated users. + if (!user) { + return permission === "guest-only"; + } + + // Allow authenticated users if the permission is 'authenticated-only'. + if (permission === "authenticated-only") { + return true; + } + + // Short-circuit for super-admin role to allow all permissions. + if (user.roles.some((role) => role.code === "super-admin")) return true; + + // Aggregate all role codes and direct permissions into a set for efficient lookup. + const permissions = new Set([ + ...user.roles.map((role) => role.code), + ...user.directPermissions.map((dp) => dp.code), + ]); + + // Check if the user has the required permission. + return permissions.has(permission); +} diff --git a/src/modules/auth/utils/getCurrentUser.ts b/src/modules/auth/utils/getCurrentUser.ts new file mode 100644 index 0000000..2d3d279 --- /dev/null +++ b/src/modules/auth/utils/getCurrentUser.ts @@ -0,0 +1,28 @@ +import { cache } from "react" +import "server-only" +import getUserFromToken from "./getUserFromToken" +import { cookies } from "next/headers" + +/** + * Retrieves the current user based on the JWT token stored in cookies. + * This function is intended to run on the server side in a Next.js application. + * It reads the JWT token from the cookies, decodes it to get the user ID, + * and then fetches the corresponding user data from the database. + * + * @returns The current user's data if the user is authenticated and found in the database, otherwise null. + */ +const getCurrentUser = async () => { + // Retrieve the token from cookies + const token = cookies().get("token")?.value; + + // If no token is found, return null (no current user) + if(!token) return null; + + // Use the token to get the user from the database + const user = await getUserFromToken(token); + + // Return the user if found, otherwise return null + return user ? user : null; +} + +export default getCurrentUser; diff --git a/src/modules/auth/utils/getUserFromToken.ts b/src/modules/auth/utils/getUserFromToken.ts index a29a8b7..d44f55e 100644 --- a/src/modules/auth/utils/getUserFromToken.ts +++ b/src/modules/auth/utils/getUserFromToken.ts @@ -19,7 +19,6 @@ const getUserFromToken = cache(async (token: string) => { // Fetch the user from the database const user = await prisma.user.findFirst({ include: { - photoProfile: true, roles: true, directPermissions: true, }, diff --git a/src/components/AppNavbar/_data/allMenu.ts b/src/modules/dashboard/actions/getSidebarMenus.ts similarity index 78% rename from src/components/AppNavbar/_data/allMenu.ts rename to src/modules/dashboard/actions/getSidebarMenus.ts index d4751e6..c6dc717 100644 --- a/src/components/AppNavbar/_data/allMenu.ts +++ b/src/modules/dashboard/actions/getSidebarMenus.ts @@ -7,18 +7,9 @@ import { TbShoppingBag, TbPhotoFilled, } from "react-icons/tb"; +import SidebarMenu from "../types/SidebarMenu"; -export interface MenuItem { - label: string; - icon: React.FC; - children?: { - label: string; - link: string; - }[]; - color?: ThemeIconProps["color"]; -} - -const allMenu: MenuItem[] = [ +const sidebarMenus: SidebarMenu[] = [ { label: "Dashboard", icon: TbLayoutDashboard, @@ -55,4 +46,7 @@ const allMenu: MenuItem[] = [ }, ]; -export default allMenu; +//TODO: Change into server actions +const getSidebarMenus = () => sidebarMenus; + +export default getSidebarMenus; diff --git a/src/modules/dashboard/actions/getUserMenus.ts b/src/modules/dashboard/actions/getUserMenus.ts new file mode 100644 index 0000000..fdc649e --- /dev/null +++ b/src/modules/dashboard/actions/getUserMenus.ts @@ -0,0 +1,22 @@ +//TODO: Change into server action +import { ThemeIconProps } from "@mantine/core"; +import React from "react"; +import { TbLogout, TbSettings } from "react-icons/tb"; +import { UserMenuItem } from "../types/UserMenuItem"; + +// This function retrieves the list of user menu items for use in the application's header. +const userMenuItems: UserMenuItem[] = [ + { + label: "Account Settings", + icon: TbSettings, + }, + { + label: "Logout", + icon: TbLogout, + color: "red", + href: "/logout", + }, +]; + +const getUserMenus = () => userMenuItems; +export default getUserMenus; diff --git a/src/components/AppHeader/Header.tsx b/src/modules/dashboard/components/AppHeader.tsx similarity index 86% rename from src/components/AppHeader/Header.tsx rename to src/modules/dashboard/components/AppHeader.tsx index 41b8d35..540969a 100644 --- a/src/components/AppHeader/Header.tsx +++ b/src/modules/dashboard/components/AppHeader.tsx @@ -13,11 +13,11 @@ import { useDisclosure } from "@mantine/hooks"; import Image from "next/image"; import logo from "@/assets/logos/logo-dsg.png"; import cx from "clsx"; -import classNames from "./styles.module.css"; +import classNames from "./styles/appHeader.module.css"; import { TbChevronDown, TbLogout, TbSettings } from "react-icons/tb"; -import userMenuItems from "./_data/userMenuItems"; -import UserMenuItem from "./_components/UserMenuItem/UserMenuItem"; -import { useAuth } from "@/features/auth/contexts/AuthContext"; +import getUserMenus from "../actions/getUserMenus"; +import { useAuth } from "@/modules/auth/contexts/AuthContext"; +import UserMenuItem from "./UserMenuItem"; interface Props { openNavbar: boolean; @@ -33,9 +33,9 @@ interface Props { export default function AppHeader(props: Props) { const [userMenuOpened, setUserMenuOpened] = useState(false); - const { user } = useAuth() + const { user } = useAuth(); - const userMenus = userMenuItems.map((item, i) => ( + const userMenus = getUserMenus().map((item, i) => ( )); diff --git a/src/components/AppNavbar/Navbar.tsx b/src/modules/dashboard/components/AppNavbar.tsx similarity index 75% rename from src/components/AppNavbar/Navbar.tsx rename to src/modules/dashboard/components/AppNavbar.tsx index acc5705..adf1577 100644 --- a/src/components/AppNavbar/Navbar.tsx +++ b/src/modules/dashboard/components/AppNavbar.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { AppShell, ScrollArea } from '@mantine/core'; -import allMenu from "./_data/allMenu"; -import MenuItem from "./_components/MenuItem"; +import MenuItem from './SidebarMenuItem'; +import getSidebarMenus from '../actions/getSidebarMenus'; /** * `AppNavbar` is a React functional component that renders the application's navigation bar. @@ -13,7 +13,7 @@ import MenuItem from "./_components/MenuItem"; export default function AppNavbar() { // Mapping all menu items to MenuItem components - const menus = allMenu.map((menu, i) => ); + const menus = getSidebarMenus().map((menu, i) => ); return ( diff --git a/src/components/DashboardLayout/DashboardLayout.tsx b/src/modules/dashboard/components/DashboardLayout.tsx similarity index 89% rename from src/components/DashboardLayout/DashboardLayout.tsx rename to src/modules/dashboard/components/DashboardLayout.tsx index e84e471..4b8c320 100644 --- a/src/components/DashboardLayout/DashboardLayout.tsx +++ b/src/modules/dashboard/components/DashboardLayout.tsx @@ -3,9 +3,9 @@ import React, { useEffect } from "react"; import { AppShell } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; -import AppHeader from "../AppHeader"; -import AppNavbar from "../AppNavbar"; -import { useAuth } from "@/features/auth/contexts/AuthContext"; +import AppHeader from "./AppHeader"; +import AppNavbar from "./AppNavbar"; +import { useAuth } from "@/modules/auth/contexts/AuthContext"; interface Props { children: React.ReactNode; diff --git a/src/features/dashboard/components/DashboardTable/DashboardTable.tsx b/src/modules/dashboard/components/DashboardTable.tsx similarity index 100% rename from src/features/dashboard/components/DashboardTable/DashboardTable.tsx rename to src/modules/dashboard/components/DashboardTable.tsx diff --git a/src/components/AppNavbar/_components/ChildMenu/ChildMenu.tsx b/src/modules/dashboard/components/SidebarChildMenu.tsx similarity index 64% rename from src/components/AppNavbar/_components/ChildMenu/ChildMenu.tsx rename to src/modules/dashboard/components/SidebarChildMenu.tsx index e163896..5f2f53d 100644 --- a/src/components/AppNavbar/_components/ChildMenu/ChildMenu.tsx +++ b/src/modules/dashboard/components/SidebarChildMenu.tsx @@ -2,11 +2,11 @@ import React from "react"; import { Text } from "@mantine/core"; -import classNames from "./childMenu.module.css"; -import { MenuItem } from "../../_data/allMenu"; +import classNames from "./styles/sidebarChildMenu.module.css"; +import SidebarMenu from "../types/SidebarMenu"; interface Props { - item: NonNullable[number]; + item: NonNullable[number]; } /** @@ -18,8 +18,9 @@ interface Props { * @returns A React element representing a child menu item. */ export default function ChildMenu(props: Props) { - - const linkPath = props.item.link.startsWith('/') ? props.item.link : `/${props.item.link}`; + const linkPath = props.item.link.startsWith("/") + ? props.item.link + : `/${props.item.link}`; return ( @@ -27,7 +28,7 @@ export default function ChildMenu(props: Props) { className={classNames.link} href={`/dashboard${linkPath}`} > - {props.item.label} - + {props.item.label} + ); } diff --git a/src/components/AppNavbar/_components/MenuItem/MenuItem.tsx b/src/modules/dashboard/components/SidebarMenuItem.tsx similarity index 91% rename from src/components/AppNavbar/_components/MenuItem/MenuItem.tsx rename to src/modules/dashboard/components/SidebarMenuItem.tsx index 591770d..e581d26 100644 --- a/src/components/AppNavbar/_components/MenuItem/MenuItem.tsx +++ b/src/modules/dashboard/components/SidebarMenuItem.tsx @@ -10,12 +10,12 @@ import { } from "@mantine/core"; import { TbChevronRight } from "react-icons/tb"; -import { MenuItem } from "../../_data/allMenu"; -import ChildMenu from "../ChildMenu/ChildMenu"; -import classNames from "./menuItem.module.css"; +import ChildMenu from "./SidebarChildMenu"; +import classNames from "./styles/sidebarMenuItem.module.css"; +import SidebarMenu from "../types/SidebarMenu"; interface Props { - menu: MenuItem; + menu: SidebarMenu; } /** @@ -48,7 +48,6 @@ export default function MenuItem({ menu }: Props) { className={classNames.control} > - {/* Icon and Label */} diff --git a/src/modules/dashboard/components/UserMenuItem.tsx b/src/modules/dashboard/components/UserMenuItem.tsx new file mode 100644 index 0000000..b12cd46 --- /dev/null +++ b/src/modules/dashboard/components/UserMenuItem.tsx @@ -0,0 +1,25 @@ +import { Menu, rem } from "@mantine/core"; +import React from "react"; +import { UserMenuItem } from "../types/UserMenuItem"; + +interface Props { + item: UserMenuItem; +} + +export default function UserMenuItem({ item }: Props) { + return ( + + } + href={item.href} + > + {item.label} + + ); +} diff --git a/src/components/AppHeader/styles.module.css b/src/modules/dashboard/components/styles/appHeader.module.css similarity index 100% rename from src/components/AppHeader/styles.module.css rename to src/modules/dashboard/components/styles/appHeader.module.css diff --git a/src/components/AppNavbar/_components/ChildMenu/childMenu.module.css b/src/modules/dashboard/components/styles/sidebarChildMenu.module.css similarity index 100% rename from src/components/AppNavbar/_components/ChildMenu/childMenu.module.css rename to src/modules/dashboard/components/styles/sidebarChildMenu.module.css diff --git a/src/components/AppNavbar/_components/MenuItem/menuItem.module.css b/src/modules/dashboard/components/styles/sidebarMenuItem.module.css similarity index 100% rename from src/components/AppNavbar/_components/MenuItem/menuItem.module.css rename to src/modules/dashboard/components/styles/sidebarMenuItem.module.css diff --git a/src/modules/dashboard/errors/DashboardError.ts b/src/modules/dashboard/errors/DashboardError.ts new file mode 100644 index 0000000..070d506 --- /dev/null +++ b/src/modules/dashboard/errors/DashboardError.ts @@ -0,0 +1,45 @@ +import BaseError from "@/core/error/BaseError"; + +export const DashboardErrorCodes = [ + "EMAIL_NOT_FOUND", + "EMPTY_USER_HASH", + "INVALID_CREDENTIALS", + "INVALID_JWT_TOKEN", + "JWT_SECRET_EMPTY", + "USER_ALREADY_EXISTS", +] as const; + +interface DashboardErrorOptions { + message?: string; + errorCode: (typeof DashboardErrorCodes)[number] | (string & {}); + formErrors?: Record +} + +export default class DashboardError extends BaseError { + public readonly errorCode: DashboardErrorOptions['errorCode']; + public readonly formErrors?: DashboardErrorOptions['formErrors'] + + constructor(options: DashboardErrorOptions) { + super({ + errorCode: options.errorCode, + message: options.message, + }); + + this.errorCode = options.errorCode; + } + + /** + * Returns a structured error response object. + */ + getErrorReponseObject(){ + return { + success: false, + dashboardError: true, + error: { + message: `${this.message}`, + errorCode: this.errorCode, + errors: this.formErrors ?? undefined + } + } as const; + } +} diff --git a/src/features/auth/tools/checkMultiplePermissions.ts b/src/modules/dashboard/services/checkMultiplePermissions.ts similarity index 86% rename from src/features/auth/tools/checkMultiplePermissions.ts rename to src/modules/dashboard/services/checkMultiplePermissions.ts index e783df0..15e1598 100644 --- a/src/features/auth/tools/checkMultiplePermissions.ts +++ b/src/modules/dashboard/services/checkMultiplePermissions.ts @@ -1,5 +1,6 @@ -import checkPermission from "./checkPermission"; -import getCurrentUser from "./getCurrentUser"; +import "server-only"; +import checkPermission from "@/modules/auth/utils/checkPermission"; +import getCurrentUser from "@/modules/auth/utils/getCurrentUser"; /** * Checks multiple permissions for the current user and returns an object indicating diff --git a/src/modules/dashboard/services/checkPermission.ts b/src/modules/dashboard/services/checkPermission.ts new file mode 100644 index 0000000..026c0d1 --- /dev/null +++ b/src/modules/dashboard/services/checkPermission.ts @@ -0,0 +1,42 @@ +import getCurrentUser from "@/modules/auth/utils/getCurrentUser"; +import "server-only"; + +/** + * Checks if the current user has the specified permissions. + * + * @param permission - The specific permission to check. If it's "guest-only", the function returns true if the user is not authenticated. If it's "authenticated-only", it returns true if the user is authenticated. For other permissions, it checks against the user's roles and direct permissions. + * @param currentUser - Optional. The current user object. If not provided, the function retrieves the current user. + * @returns true if the user has the required permission, otherwise false. + */ +export default async function checkPermission( + permission?: "guest-only" | "authenticated-only" | (string & {}), + currentUser?: Awaited> +): Promise { + // Allow if no specific permission is required. + if (!permission) return true; + + // Retrieve current user if not provided. + const user = currentUser ?? (await getCurrentUser()); + + // Handle non-authenticated users. + if (!user) { + return permission === "guest-only"; + } + + // Allow authenticated users if the permission is 'authenticated-only'. + if (permission === "authenticated-only") { + return true; + } + + // Short-circuit for super-admin role to allow all permissions. + if (user.roles.some((role) => role.code === "super-admin")) return true; + + // Aggregate all role codes and direct permissions into a set for efficient lookup. + const permissions = new Set([ + ...user.roles.map((role) => role.code), + ...user.directPermissions.map((dp) => dp.code), + ]); + + // Check if the user has the required permission. + return permissions.has(permission); +} diff --git a/src/features/auth/types/CrudPermissions.d.ts b/src/modules/dashboard/types/CrudPermissions.d.ts similarity index 100% rename from src/features/auth/types/CrudPermissions.d.ts rename to src/modules/dashboard/types/CrudPermissions.d.ts diff --git a/src/modules/dashboard/types/ServerResponseAction.d.ts b/src/modules/dashboard/types/ServerResponseAction.d.ts new file mode 100644 index 0000000..913d779 --- /dev/null +++ b/src/modules/dashboard/types/ServerResponseAction.d.ts @@ -0,0 +1,22 @@ +export type ErrorResponse = { + success: false; + dashboardError?: boolean; + error?: { + message?: string; + errorCode: string; + errors?: { [k: string]: string }; + }; + message?: string; +}; + +export type SuccessResponse = T extends undefined + ? { success: true; message?: string } + : { + success: true; + message?: string; + data: T; + }; + +type ServerResponseAction = ErrorResponse | SuccessResponse; + +export default ServerResponseAction; diff --git a/src/modules/dashboard/types/SidebarMenu.d.ts b/src/modules/dashboard/types/SidebarMenu.d.ts new file mode 100644 index 0000000..8603520 --- /dev/null +++ b/src/modules/dashboard/types/SidebarMenu.d.ts @@ -0,0 +1,9 @@ +export default interface SidebarMenu { + label: string; + icon: React.FC; + children?: { + label: string; + link: string; + }[]; + color?: ThemeIconProps["color"]; +} \ No newline at end of file diff --git a/src/modules/dashboard/types/UserMenuItem.d.ts b/src/modules/dashboard/types/UserMenuItem.d.ts new file mode 100644 index 0000000..b6fc6c8 --- /dev/null +++ b/src/modules/dashboard/types/UserMenuItem.d.ts @@ -0,0 +1,6 @@ +export interface UserMenuItem { + label: string; + icon: React.FC; + color?: ThemeIconProps["color"]; + href?: string; +} diff --git a/src/features/dashboard/utils/createActionButtons.tsx b/src/modules/dashboard/utils/createActionButton.tsx similarity index 100% rename from src/features/dashboard/utils/createActionButtons.tsx rename to src/modules/dashboard/utils/createActionButton.tsx diff --git a/src/modules/dashboard/utils/handleCatch.ts b/src/modules/dashboard/utils/handleCatch.ts new file mode 100644 index 0000000..277c48d --- /dev/null +++ b/src/modules/dashboard/utils/handleCatch.ts @@ -0,0 +1,40 @@ +import { Prisma } from "@prisma/client"; +import DashboardError from "../errors/DashboardError"; +import "server-only" + +/** + * Handles exceptions and converts them into a structured error response. + * @param e The caught error or exception. + */ +const handleCatch = (e: unknown) => { + console.error(e) + if (e instanceof DashboardError) { + return e.getErrorReponseObject(); + } + if (e instanceof Prisma.PrismaClientKnownRequestError) { + //Not found + if (e.code === "P2025") { + const error = new DashboardError({ + errorCode: "NOT_FOUND", + message: + "The requested data could not be located. It may have been deleted or relocated. Please verify the information or try a different request.", + }); + return error.getErrorReponseObject(); + } + } + if (e instanceof Error) { + return { + success: false, + dashboardError: false, + message: e.message, + } as const; + } else { + return { + success: false, + dashboardError: false, + message: "Unkown error", + } as const; + } +}; + +export default handleCatch; diff --git a/src/modules/dashboard/utils/notFound.ts b/src/modules/dashboard/utils/notFound.ts new file mode 100644 index 0000000..3fa9b71 --- /dev/null +++ b/src/modules/dashboard/utils/notFound.ts @@ -0,0 +1,16 @@ +import DashboardError from "../errors/DashboardError"; + +/** + * Throws a 'NOT_FOUND' DashboardError with a custom or default message. + * @param message Optional custom message for the error. + */ +const notFound = ({ message }: { message?: string }) => { + throw new DashboardError({ + errorCode: "NOT_FOUND", + message: + message ?? + "The requested data could not be located. It may have been deleted or relocated. Please verify the information or try a different request.", + }); +}; + +export default notFound; diff --git a/src/modules/dashboard/utils/unauthorized.ts b/src/modules/dashboard/utils/unauthorized.ts new file mode 100644 index 0000000..d806aef --- /dev/null +++ b/src/modules/dashboard/utils/unauthorized.ts @@ -0,0 +1,13 @@ +import DashboardError from "../errors/DashboardError"; + +/** + * Throws a 'UNAUTHORIZED' DashboardError. + */ +const unauthorized = () => { + throw new DashboardError({ + errorCode: "UNAUTHORIZED", + message: "You are unauthorized to do this action", + }); +}; + +export default unauthorized; diff --git a/src/features/dashboard/utils/withServerAction.ts b/src/modules/dashboard/utils/withServerAction.ts similarity index 82% rename from src/features/dashboard/utils/withServerAction.ts rename to src/modules/dashboard/utils/withServerAction.ts index 81fede9..6d7f76b 100644 --- a/src/features/dashboard/utils/withServerAction.ts +++ b/src/modules/dashboard/utils/withServerAction.ts @@ -1,5 +1,5 @@ -import ServerResponse, { SuccessResponse } from "@/types/Action"; import DashboardError from "../errors/DashboardError"; +import ServerResponseAction from "../types/ServerResponseAction"; /** * A higher-order function that wraps an async function and provides structured error handling. @@ -11,8 +11,8 @@ import DashboardError from "../errors/DashboardError"; * @returns The successful response from the async function. * @throws DashboardError for dashboard-related errors or Error for other errors. */ -async function withErrorHandling( - asyncFunction: (...args: Args) => Promise>, +async function withServerAction( + asyncFunction: (...args: Args) => Promise>, ...args: Args ){ const result = await asyncFunction(...args); @@ -33,4 +33,4 @@ async function withErrorHandling( } } -export default withErrorHandling; +export default withServerAction; diff --git a/src/modules/userManagement/actions/deleteUser.ts b/src/modules/userManagement/actions/deleteUser.ts new file mode 100644 index 0000000..0f8f150 --- /dev/null +++ b/src/modules/userManagement/actions/deleteUser.ts @@ -0,0 +1,31 @@ +"use server"; + +import prisma from "@/db"; +import checkPermission from "@/modules/dashboard/services/checkPermission"; +import ServerResponseAction from "@/modules/dashboard/types/ServerResponseAction"; +import handleCatch from "@/modules/dashboard/utils/handleCatch"; +import notFound from "@/modules/dashboard/utils/notFound"; +import unauthorized from "@/modules/dashboard/utils/unauthorized"; +import { revalidatePath } from "next/cache"; + +export default async function deleteUser( + id: string +): Promise { + try { + if (!(await checkPermission("users.delete"))) return unauthorized(); + const user = await prisma.user.delete({ + where: { id }, + }); + + if (!user) notFound({message: "The user does not exists"}); + + revalidatePath("."); + + return { + success: true, + message: "The user has been deleted successfully", + }; + } catch (e: unknown) { + return handleCatch(e); + } +} diff --git a/src/modules/userManagement/actions/getAllUsers.ts b/src/modules/userManagement/actions/getAllUsers.ts new file mode 100644 index 0000000..1fe2c10 --- /dev/null +++ b/src/modules/userManagement/actions/getAllUsers.ts @@ -0,0 +1,32 @@ +import prisma from "@/db"; +import checkPermission from "@/modules/dashboard/services/checkPermission"; +import unauthorized from "@/modules/dashboard/utils/unauthorized"; +import "server-only"; + +const getAllUsers = async () => { + if (!(await checkPermission("users.readAll"))) unauthorized(); + + try { + + const users = await prisma.user.findMany({ + select: { + id: true, + email: true, + photoProfile: true, + name: true, + }, + }); + + const result = users.map((user) => ({ + ...user, + photoUrl: user.photoProfile ?? null, + photoProfile: undefined, + })); + + return result; + } catch (e){ + throw e; + } +}; + +export default getAllUsers; diff --git a/src/modules/userManagement/actions/getUserDetailById.ts b/src/modules/userManagement/actions/getUserDetailById.ts new file mode 100644 index 0000000..a02780e --- /dev/null +++ b/src/modules/userManagement/actions/getUserDetailById.ts @@ -0,0 +1,56 @@ +"use server" +import "server-only"; +import prisma from "@/db"; +import checkPermission from "@/modules/dashboard/services/checkPermission"; +import unauthorized from "@/modules/dashboard/utils/unauthorized"; +import ServerResponseAction from "@/modules/dashboard/types/ServerResponseAction"; + +type UserData = { + id: string, + email: string, + name: string, + photoProfileUrl: string +} + +/** + * Retrieves detailed information of a user by their ID. + * + * @param id The unique identifier of the user. + * @returns The user's detailed information or an error response. + */ +export default async function getUserDetailById(id: string): Promise> { + // Check user permission + if (!checkPermission("users.read")) return unauthorized(); + + // Retrieve user data from the database + const user = await prisma.user.findFirst({ + where: { id }, + select: { + id: true, + email: true, + name: true, + photoProfile: true, + }, + }); + + // Check if user exists + if (!user) + return { + success: false, + message: "User not found", + } as const; + + // Format user data + const formattedUser = { + id: user.id, + email: user.email ?? "", + name: user.name ?? "", + photoProfileUrl: user.photoProfile ?? "", + }; + + return { + success: true, + message: "Permission fetched successfully", + data: formattedUser, + } as const; +} diff --git a/src/modules/userManagement/actions/upsertUser.ts b/src/modules/userManagement/actions/upsertUser.ts new file mode 100644 index 0000000..f1e8911 --- /dev/null +++ b/src/modules/userManagement/actions/upsertUser.ts @@ -0,0 +1,83 @@ +"use server"; + +import mapObjectToFirstValue from "@/utils/mapObjectToFirstValue"; +import prisma from "@/db"; +import { revalidatePath } from "next/cache"; +import userFormDataSchema, { UserFormData } from "../formSchemas/userFormSchema"; +import checkPermission from "@/modules/dashboard/services/checkPermission"; +import unauthorized from "@/modules/dashboard/utils/unauthorized"; +import DashboardError from "@/modules/dashboard/errors/DashboardError"; +import handleCatch from "@/modules/dashboard/utils/handleCatch"; +import ServerResponseAction from "@/modules/dashboard/types/ServerResponseAction"; + +/** + * Upserts a user based on the provided UserFormData. + * If the user already exists (determined by `id`), it updates the user; otherwise, it creates a new user. + * Authorization checks are performed based on whether it's a create or update operation. + * + * @param data - The data for creating or updating the user. + * @returns An object containing the success status, message, and any errors. + */ +export default async function upsertUser( + data: UserFormData +): Promise { + try { + const isInsert = !data.id; + + // Authorization check + const permissionType = isInsert ? "users.create" : "users.update"; + if (!(await checkPermission(permissionType))) { + return unauthorized(); + } + + // Validate form data + const validatedFields = userFormDataSchema.safeParse(data); + if (!validatedFields.success) { + throw new DashboardError({ + errorCode: "INVALID_FORM_DATA", + formErrors: mapObjectToFirstValue(validatedFields.error.flatten().fieldErrors) + }) + } + const userData = { + id: validatedFields.data.id ? validatedFields.data.id : undefined, + name: validatedFields.data.name, + photoProfile: validatedFields.data.photoProfileUrl ?? "", + email: validatedFields.data.email + }; + + // Database operation + if (isInsert) { + if (await prisma.user.findFirst({ + where: { + email: userData.email + } + })){ + throw new DashboardError({ + errorCode: "INVALID_FORM_DATA", + formErrors: { + email: "The user is already exists" + } + }) + } + await prisma.user.create({ data: userData }); + } else { + await prisma.user.update({ + where: { id: validatedFields.data.id! }, + data: userData, + }); + } + + // Revalidate the cache + revalidatePath("."); + + // Return success message + return { + success: true, + message: `User ${validatedFields.data.name} has been successfully ${ + isInsert ? "created" : "updated" + }.`, + }; + } catch (error) { + return handleCatch(error) + } +} diff --git a/src/modules/userManagement/formSchemas/userFormSchema.ts b/src/modules/userManagement/formSchemas/userFormSchema.ts new file mode 100644 index 0000000..24b1f0e --- /dev/null +++ b/src/modules/userManagement/formSchemas/userFormSchema.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export interface UserFormData { + id: string; + name: string; + photoProfileUrl: string; + email: string; +} + +const userFormDataSchema = z.object({ + id: z.string().nullable(), + name: z.string(), + photoProfileUrl: z.union([z.string(), z.null()]), + email: z.string().email(), +}); + +export default userFormDataSchema; diff --git a/src/features/dashboard/permissions/modals/DeleteModal/DeleteModal.tsx b/src/modules/userManagement/modals/UserDeleteModal.tsx similarity index 78% rename from src/features/dashboard/permissions/modals/DeleteModal/DeleteModal.tsx rename to src/modules/userManagement/modals/UserDeleteModal.tsx index c2dd4ec..c781a4f 100644 --- a/src/features/dashboard/permissions/modals/DeleteModal/DeleteModal.tsx +++ b/src/modules/userManagement/modals/UserDeleteModal.tsx @@ -15,11 +15,9 @@ import { Alert, } from "@mantine/core"; import { showNotification } from "@/utils/notifications"; -import deletePermission from "@/features/dashboard/permissions/actions/deletePermission"; -import withErrorHandling from "@/features/dashboard/utils/withServerAction"; -import { error } from "console"; -import DashboardError from "@/features/dashboard/errors/DashboardError"; -import { revalidatePath } from "next/cache"; +import withServerAction from "@/modules/dashboard/utils/withServerAction"; +import deleteUser from "../actions/deleteUser"; +import DashboardError from "@/modules/dashboard/errors/DashboardError"; export interface DeleteModalProps { data?: { @@ -29,7 +27,7 @@ export interface DeleteModalProps { onClose: () => void; } -export default function DeleteModal(props: DeleteModalProps) { +export default function UserDeleteModal(props: DeleteModalProps) { const router = useRouter(); const [isSubmitting, setSubmitting] = useState(false); @@ -48,10 +46,10 @@ export default function DeleteModal(props: DeleteModalProps) { if (!props.data?.id) return; setSubmitting(true); - withErrorHandling(() => deletePermission(props.data!.id)) + withServerAction(() => deleteUser(props.data!.id)) .then((response) => { showNotification( - response.message ?? "Permission deleted successfully" + response.message ?? "User deleted successfully" ); setSubmitting(false); props.onClose() @@ -78,7 +76,7 @@ export default function DeleteModal(props: DeleteModalProps) { title={`Delete confirmation`} > - Are you sure you want to delete permission{" "} + Are you sure you want to delete user{" "} {props.data?.name} @@ -103,7 +101,7 @@ export default function DeleteModal(props: DeleteModalProps) { loading={isSubmitting} onClick={confirmAction} > - Delete Permission + Delete User diff --git a/src/modules/userManagement/modals/UserFormModal.tsx b/src/modules/userManagement/modals/UserFormModal.tsx new file mode 100644 index 0000000..bfe1090 --- /dev/null +++ b/src/modules/userManagement/modals/UserFormModal.tsx @@ -0,0 +1,205 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { showNotification } from "@/utils/notifications"; +import { + Flex, + Modal, + Stack, + Switch, + TextInput, + Textarea, + Button, + ScrollArea, + Checkbox, + Skeleton, + Fieldset, + Alert, + Center, + Avatar, +} from "@mantine/core"; +import { useForm, zodResolver } from "@mantine/form"; +import { useRouter } from "next/navigation"; +import React, { useCallback, useEffect, useState } from "react"; +import { TbDeviceFloppy } from "react-icons/tb"; +import userFormDataSchema, { + UserFormData, +} from "../formSchemas/userFormSchema"; +import getUserDetailById from "../actions/getUserDetailById"; +import withServerAction from "@/modules/dashboard/utils/withServerAction"; +import upsertUser from "../actions/upsertUser"; +import DashboardError from "@/modules/dashboard/errors/DashboardError"; +import stringToColorHex from "@/core/utils/stringToColorHex"; + +export interface ModalProps { + title: string; + readonly?: boolean; + id?: string; + opened: boolean; + onClose?: () => void; +} + +/** + * A component for rendering a modal with a form to create or edit a permission. + * + * @param props - The props for the component. + * @returns The rendered element. + */ +export default function UserFormModal(props: ModalProps) { + const router = useRouter(); + const [isSubmitting, setSubmitting] = useState(false); + const [isFetching, setFetching] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + const form = useForm({ + initialValues: { + id: "", + email: "", + name: "", + photoProfileUrl: "", + }, + validate: zodResolver(userFormDataSchema), + validateInputOnChange: false, + }); + + /** + * Fetches permission data by ID and populates the form if the modal is opened and an ID is provided. + */ + useEffect(() => { + if (!props.opened || !props.id) { + return; + } + + setFetching(true); + withServerAction(getUserDetailById, props.id) + .then((response) => { + if (response.success) { + const data = response.data; + form.setValues({ + email: data.email, + id: data.id, + name: data.name, + photoProfileUrl: data.photoProfileUrl, + }); + } + }) + .catch((e) => { + //TODO: Handle error + console.log(e); + }) + .finally(() => { + setFetching(false); + }); + }, [props.opened, props.id]); + + const closeModal = () => { + form.reset(); + props.onClose ? props.onClose() : router.replace("?"); + }; + + const handleSubmit = (values: UserFormData) => { + setSubmitting(true); + withServerAction(upsertUser, values) + .then((response) => { + showNotification(response.message!, "success"); + closeModal(); + }) + .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` + ); + } + }) + .finally(() => { + setSubmitting(false); + }); + }; + + return ( + +
+ + {errorMessage && {errorMessage}} + {/* Avatar */} + +
+ + {form.values.name?.[0]?.toUpperCase()} + +
+
+ + {/* ID */} + {form.values.id && ( + + )} + + {/* Name */} + + + + + {/* Email */} + + + + + {/* Buttons */} + + + {!props.readonly && ( + + )} + +
+
+
+ ); +} diff --git a/src/modules/userManagement/tables/UsersTable/UsersTable.tsx b/src/modules/userManagement/tables/UsersTable/UsersTable.tsx new file mode 100644 index 0000000..6869b2e --- /dev/null +++ b/src/modules/userManagement/tables/UsersTable/UsersTable.tsx @@ -0,0 +1,125 @@ +"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 { + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import React, { useState } from "react"; +import { TbPlus } from "react-icons/tb"; +import UserFormModal, { ModalProps } from "../../modals/UserFormModal"; +import UserDeleteModal, { DeleteModalProps } from "../../modals/UserDeleteModal"; +import createColumns from "./columns"; +import getAllUsers from "../../actions/getAllUsers"; +import DashboardTable from "@/modules/dashboard/components/DashboardTable"; + +interface Props { + permissions: Partial; + userData: Awaited>; +} + +export default function UsersTable(props: Props) { + const [modalProps, setModalProps] = useState({ + opened: false, + title: "", + }); + + const [deleteModalProps, setDeleteModalProps] = useState< + Omit + >({ + data: undefined, + }); + + const table = useReactTable({ + data: props.userData, + columns: createColumns({ + permissions: props.permissions, + actions: { + detail: (id: string) => openFormModal("detail", id), + edit: (id: string) => openFormModal("edit", id), + delete: (id: string, name: string) => openDeleteModal(id, name), + }, + }), + getCoreRowModel: getCoreRowModel(), + defaultColumn: { + cell: (props) => {props.getValue() as React.ReactNode}, + }, + }); + + const openFormModal = (type: "create" | "edit" | "detail", id?: string) => { + const openCreateModal = () => { + setModalProps({ + id, + opened: true, + title: "Create new user", + }); + }; + + const openDetailModal = () => { + setModalProps({ + id, + opened: true, + title: "User detail", + readonly: true, + }); + }; + + const openEditModal = () => { + setModalProps({ + id, + opened: true, + title: "Edit user", + }); + }; + + type === "create" + ? openCreateModal() + : type === "detail" + ? openDetailModal() + : openEditModal(); + }; + + const openDeleteModal = (id: string, name: string) => { + setDeleteModalProps({ + data: { + id, + name, + }, + }); + }; + + const closeModal = () => { + setModalProps({ + id: "", + opened: false, + title: "", + }); + }; + + // TODO: Add view when data is empty + + return ( + <> + + {props.permissions.create && ( + + )} + + + + + + setDeleteModalProps({})} + /> + + ); +} diff --git a/src/modules/userManagement/tables/UsersTable/columns.tsx b/src/modules/userManagement/tables/UsersTable/columns.tsx new file mode 100644 index 0000000..b20c506 --- /dev/null +++ b/src/modules/userManagement/tables/UsersTable/columns.tsx @@ -0,0 +1,119 @@ +import { createColumnHelper } from "@tanstack/react-table"; +import { Badge, Flex, Group, Avatar, Text, Anchor } from "@mantine/core"; +import { TbEye, TbPencil, TbTrash } from "react-icons/tb"; +import CrudPermissions from "@/modules/dashboard/types/CrudPermissions"; +import stringToColorHex from "@/core/utils/stringToColorHex"; +import Link from "next/link"; +import createActionButtons from "@/modules/dashboard/utils/createActionButton"; + +export interface UserRow { + id: string; + name: string | null; + email: string | null; + photoUrl: string | null; +} + +interface ColumnOptions { + permissions: Partial; + actions: { + detail: (id: string) => void; + edit: (id: string) => void; + delete: (id: string, name: string) => void; + }; +} + +const createColumns = (options: ColumnOptions) => { + const columnHelper = createColumnHelper(); + + const columns = [ + columnHelper.display({ + id: "sequence", + header: "#", + cell: (props) => props.row.index + 1, + size: 1, + }), + + columnHelper.accessor("name", { + header: "Name", + cell: (props) => ( + + + {props.getValue()?.[0].toUpperCase()} + + + {props.getValue()} + + + ), + }), + + columnHelper.accessor("email", { + header: "Email", + cell: (props) => ( + + {props.getValue()} + + ), + }), + + columnHelper.display({ + id: "status", + header: "Status", + cell: (props) => Active, + }), + + columnHelper.display({ + id: "actions", + header: "Actions", + size: 10, + meta: { + className: "w-fit", + }, + cell: (props) => ( + + {createActionButtons([ + { + label: "Detail", + permission: options.permissions.read, + action: () => + options.actions.detail(props.row.original.id), + color: "green", + icon: , + }, + { + label: "Edit", + permission: options.permissions.update, + action: () => + options.actions.edit(props.row.original.id), + color: "yellow", + icon: , + }, + { + label: "Delete", + permission: options.permissions.delete, + action: () => + options.actions.delete( + props.row.original.id, + props.row.original.name ?? "" + ), + color: "red", + icon: , + }, + ])} + + ), + }), + ]; + + return columns; +}; + +export default createColumns; diff --git a/src/types/Action.d.ts b/src/types/Action.d.ts deleted file mode 100644 index 91640da..0000000 --- a/src/types/Action.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -export type ErrorResponse = { - success: false; - dashboardError?: boolean; - error?: { - message?: string; - errorCode?: string; - errors?: { [k: string]: string }; - }; - message?: string; -} - -export type SuccessResponse = T extends undefined ? {success: true; message?: string} : { - success: true; - message?: string; - data: T; -} - -type ServerResponse = - | ErrorResponse - | SuccessResponse - -export default ServerResponse;