Optimize dashboard and user managemnt

This commit is contained in:
sianida26 2024-02-14 12:46:14 +07:00
parent c6034de16a
commit 4fbcd5581d
100 changed files with 1131 additions and 363 deletions

View File

@ -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`;

View File

@ -13,18 +13,11 @@ model User {
email String? @unique email String? @unique
emailVerified DateTime? emailVerified DateTime?
passwordHash String? passwordHash String?
photoProfile UserPhotoProfiles? photoProfile String?
directPermissions Permission[] @relation("PermissionToUser") directPermissions Permission[] @relation("PermissionToUser")
roles Role[] @relation("RoleToUser") 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 { model Role {
id String @id @default(cuid()) id String @id @default(cuid())
code String @unique code String @unique

View File

@ -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)
}

View File

View File

View File

@ -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<string, string>
}
/**
* 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<string, string>
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;
}
}

View File

@ -1,59 +1,32 @@
import { Card, Stack, Title } from "@mantine/core"; import { Card, Stack, Title } from "@mantine/core";
import React from "react"; import React from "react";
import UsersTable from "./_tables/UsersTable/UsersTable"; import getUsers from "@/modules/userManagement/actions/getAllUsers";
import checkPermission from "@/features/auth/tools/checkPermission"; import { Metadata } from "next";
import { redirect } from "next/navigation"; import checkMultiplePermissions from "@/modules/dashboard/services/checkMultiplePermissions";
import getUsers from "@/features/dashboard/users/data/getUsers"; import UsersTable from "@/modules/userManagement/tables/UsersTable/UsersTable";
import { DeleteModal, DetailModal, EditModal } from "./_modals";
import getUserDetailById from "@/features/dashboard/users/data/getUserDetailById";
interface Props { export const metadata: Metadata = {
searchParams: { title: "Users - Dashboard",
detail?: string; };
edit?: string;
delete?: string;
}
}
export default async function UsersPage({searchParams}: Props) {
export default async function UsersPage() {
// Check for permission and return error component if not permitted // Check for permission and return error component if not permitted
if (!await checkPermission("authenticated-only")) return <div>Error</div> const permissions = await checkMultiplePermissions({
create: "users.create",
readAll: "users.readAll",
read: "permission.read",
update: "permission.update",
delete: "permission.delete",
});
const users = await getUsers() 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 <DetailModal data={userDetail} />
}
if (searchParams.edit){
const userDetail = await getUserDetailById(searchParams.edit)
return <EditModal data={userDetail} />
}
if (searchParams.delete){
const userDetail = await getUserDetailById(searchParams.delete)
return <DeleteModal data={userDetail} />
}
return null;
}
return ( return (
<Stack className="flex flex-col"> <Stack>
<Title order={1}>Users</Title> <Title order={1}>Users</Title>
<Card> <Card>
<UsersTable users={users} /> <UsersTable permissions={permissions} userData={users} />
</Card> </Card>
{await renderModal()}
</Stack> </Stack>
); );
} }

View File

@ -1,29 +1,22 @@
import { AppShell, AppShellHeader, Burger } from '@mantine/core' import { AppShell, AppShellHeader, Burger } from "@mantine/core";
import { useDisclosure } from '@mantine/hooks' import { useDisclosure } from "@mantine/hooks";
import Image from 'next/image' import Image from "next/image";
import React from 'react' import React from "react";
import logo from "@/assets/logos/logo.png" import logo from "@/assets/logos/logo.png";
import AppHeader from '../../components/AppHeader' import DashboardLayout from "@/modules/dashboard/components/DashboardLayout";
import AppNavbar from '../../components/AppNavbar' import getUser from "@/modules/auth/actions/getUser";
import DashboardLayout from '@/components/DashboardLayout' import { redirect } from "next/navigation";
import getUser from '@/features/auth/actions/getUser'
import { redirect } from 'next/navigation'
interface Props { interface Props {
children: React.ReactNode children: React.ReactNode;
} }
export default async function Layout(props: Props) { export default async function Layout(props: Props) {
const user = await getUser();
const user = await getUser()
if (!user) { if (!user) {
redirect("/login") redirect("/login");
} }
return ( return <DashboardLayout>{props.children}</DashboardLayout>;
<DashboardLayout>
{props.children}
</DashboardLayout>
)
} }

View File

@ -1,15 +1,6 @@
import React from 'react' import React from 'react'
export default async function Dashboard() { export default async function Dashboard() {
// const session = await auth();
// const user = session?.user;
// console.log("session", session);
// console.log("user", user);
return ( return (
<div> <div>
<h1>Dashboard</h1> <h1>Dashboard</h1>

View File

@ -6,8 +6,8 @@ import "@mantine/core/styles.css";
import '@mantine/notifications/styles.css'; import '@mantine/notifications/styles.css';
import { ColorSchemeScript, MantineProvider } from "@mantine/core"; import { ColorSchemeScript, MantineProvider } from "@mantine/core";
import { AuthContextProvider } from "@/features/auth/contexts/AuthContext";
import { Notifications } from "@mantine/notifications"; import { Notifications } from "@mantine/notifications";
import { AuthContextProvider } from "@/modules/auth/contexts/AuthContext";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });

View File

@ -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 (
<Menu.Item
color={item.color}
component='a'
leftSection={
<item.icon
style={{ width: rem(16), height: rem(16) }}
strokeWidth={1.5}
/>
}
href={item.href}
>
{item.label}
</Menu.Item>
)
}

View File

@ -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<any>,
color?: ThemeIconProps['color'],
href?: string,
}
const userMenuItems: UserMenuItem[] = [
{
label: "Account Settings",
icon: TbSettings
},
{
label: "Logout",
icon: TbLogout,
color: "red",
href: "/logout"
}
];
export default userMenuItems;

View File

@ -1,3 +0,0 @@
import AppHeader from "./Header"
export default AppHeader

View File

@ -1,3 +0,0 @@
import ChildMenu from "./ChildMenu";
export default ChildMenu;

View File

@ -1,3 +0,0 @@
import MenuItem from "./MenuItem";
export default MenuItem;

View File

@ -1,3 +0,0 @@
import AppNavbar from "./Navbar";
export default AppNavbar

View File

@ -1,3 +0,0 @@
import DashboardLayout from "./DashboardLayout";
export default DashboardLayout;

View File

@ -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<string, string>
}
/**
* 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<string, string>
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."
})
}

View File

@ -13,7 +13,7 @@ import getUser from "../actions/getUser";
interface UserData { interface UserData {
name: string; name: string;
email: string email: string;
photoUrl: string | null; photoUrl: string | null;
// Add additional user fields as needed // Add additional user fields as needed
} }
@ -36,14 +36,16 @@ export const AuthContextProvider = ({ children }: Props) => {
const fetchUserData = useCallback(() => { const fetchUserData = useCallback(() => {
const getUserData = async () => { const getUserData = async () => {
const user = await getUser(); const user = await getUser();
setUser(user) setUser(user);
} };
getUserData().then() getUserData()
}, []) .then(() => {})
.catch(() => {});
}, []);
useEffect(() => { useEffect(() => {
fetchUserData() fetchUserData();
}, [fetchUserData]); }, [fetchUserData]);
const logout = () => { const logout = () => {

View File

@ -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<ReturnType<typeof getCurrentUser>>
): Promise<boolean> {
// 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<string>([
...user.roles.map((role) => role.code),
...user.directPermissions.map((dp) => dp.code),
]);
// Check if the user has the required permission.
return permissions.has(permission);
}

View File

@ -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;

View File

@ -19,7 +19,6 @@ const getUserFromToken = cache(async (token: string) => {
// Fetch the user from the database // Fetch the user from the database
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
include: { include: {
photoProfile: true,
roles: true, roles: true,
directPermissions: true, directPermissions: true,
}, },

View File

@ -7,18 +7,9 @@ import {
TbShoppingBag, TbShoppingBag,
TbPhotoFilled, TbPhotoFilled,
} from "react-icons/tb"; } from "react-icons/tb";
import SidebarMenu from "../types/SidebarMenu";
export interface MenuItem { const sidebarMenus: SidebarMenu[] = [
label: string;
icon: React.FC<any>;
children?: {
label: string;
link: string;
}[];
color?: ThemeIconProps["color"];
}
const allMenu: MenuItem[] = [
{ {
label: "Dashboard", label: "Dashboard",
icon: TbLayoutDashboard, icon: TbLayoutDashboard,
@ -55,4 +46,7 @@ const allMenu: MenuItem[] = [
}, },
]; ];
export default allMenu; //TODO: Change into server actions
const getSidebarMenus = () => sidebarMenus;
export default getSidebarMenus;

View File

@ -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;

View File

@ -13,11 +13,11 @@ import { useDisclosure } from "@mantine/hooks";
import Image from "next/image"; import Image from "next/image";
import logo from "@/assets/logos/logo-dsg.png"; import logo from "@/assets/logos/logo-dsg.png";
import cx from "clsx"; 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 { TbChevronDown, TbLogout, TbSettings } from "react-icons/tb";
import userMenuItems from "./_data/userMenuItems"; import getUserMenus from "../actions/getUserMenus";
import UserMenuItem from "./_components/UserMenuItem/UserMenuItem"; import { useAuth } from "@/modules/auth/contexts/AuthContext";
import { useAuth } from "@/features/auth/contexts/AuthContext"; import UserMenuItem from "./UserMenuItem";
interface Props { interface Props {
openNavbar: boolean; openNavbar: boolean;
@ -33,9 +33,9 @@ interface Props {
export default function AppHeader(props: Props) { export default function AppHeader(props: Props) {
const [userMenuOpened, setUserMenuOpened] = useState(false); const [userMenuOpened, setUserMenuOpened] = useState(false);
const { user } = useAuth() const { user } = useAuth();
const userMenus = userMenuItems.map((item, i) => ( const userMenus = getUserMenus().map((item, i) => (
<UserMenuItem item={item} key={i} /> <UserMenuItem item={item} key={i} />
)); ));

View File

@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import { AppShell, ScrollArea } from '@mantine/core'; import { AppShell, ScrollArea } from '@mantine/core';
import allMenu from "./_data/allMenu"; import MenuItem from './SidebarMenuItem';
import MenuItem from "./_components/MenuItem"; import getSidebarMenus from '../actions/getSidebarMenus';
/** /**
* `AppNavbar` is a React functional component that renders the application's navigation bar. * `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() { export default function AppNavbar() {
// Mapping all menu items to MenuItem components // Mapping all menu items to MenuItem components
const menus = allMenu.map((menu, i) => <MenuItem menu={menu} key={i} />); const menus = getSidebarMenus().map((menu, i) => <MenuItem menu={menu} key={i} />);
return ( return (
<AppShell.Navbar p="md"> <AppShell.Navbar p="md">

View File

@ -3,9 +3,9 @@ import React, { useEffect } from "react";
import { AppShell } from "@mantine/core"; import { AppShell } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import AppHeader from "../AppHeader"; import AppHeader from "./AppHeader";
import AppNavbar from "../AppNavbar"; import AppNavbar from "./AppNavbar";
import { useAuth } from "@/features/auth/contexts/AuthContext"; import { useAuth } from "@/modules/auth/contexts/AuthContext";
interface Props { interface Props {
children: React.ReactNode; children: React.ReactNode;

View File

@ -2,11 +2,11 @@ import React from "react";
import { Text } from "@mantine/core"; import { Text } from "@mantine/core";
import classNames from "./childMenu.module.css"; import classNames from "./styles/sidebarChildMenu.module.css";
import { MenuItem } from "../../_data/allMenu"; import SidebarMenu from "../types/SidebarMenu";
interface Props { interface Props {
item: NonNullable<MenuItem["children"]>[number]; item: NonNullable<SidebarMenu["children"]>[number];
} }
/** /**
@ -18,8 +18,9 @@ interface Props {
* @returns A React element representing a child menu item. * @returns A React element representing a child menu item.
*/ */
export default function ChildMenu(props: Props) { export default function ChildMenu(props: Props) {
const linkPath = props.item.link.startsWith("/")
const linkPath = props.item.link.startsWith('/') ? props.item.link : `/${props.item.link}`; ? props.item.link
: `/${props.item.link}`;
return ( return (
<Text<"a"> <Text<"a">

View File

@ -10,12 +10,12 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { TbChevronRight } from "react-icons/tb"; import { TbChevronRight } from "react-icons/tb";
import { MenuItem } from "../../_data/allMenu"; import ChildMenu from "./SidebarChildMenu";
import ChildMenu from "../ChildMenu/ChildMenu"; import classNames from "./styles/sidebarMenuItem.module.css";
import classNames from "./menuItem.module.css"; import SidebarMenu from "../types/SidebarMenu";
interface Props { interface Props {
menu: MenuItem; menu: SidebarMenu;
} }
/** /**
@ -48,7 +48,6 @@ export default function MenuItem({ menu }: Props) {
className={classNames.control} className={classNames.control}
> >
<Group justify="space-between" gap={0}> <Group justify="space-between" gap={0}>
{/* Icon and Label */} {/* Icon and Label */}
<Box style={{ display: "flex", alignItems: "center" }}> <Box style={{ display: "flex", alignItems: "center" }}>
<ThemeIcon variant="light" size={30} color={menu.color}> <ThemeIcon variant="light" size={30} color={menu.color}>

View File

@ -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 (
<Menu.Item
color={item.color}
component="a"
leftSection={
<item.icon
style={{ width: rem(16), height: rem(16) }}
strokeWidth={1.5}
/>
}
href={item.href}
>
{item.label}
</Menu.Item>
);
}

View File

@ -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<string, string>
}
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;
}
}

View File

@ -1,5 +1,6 @@
import checkPermission from "./checkPermission"; import "server-only";
import getCurrentUser from "./getCurrentUser"; 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 * Checks multiple permissions for the current user and returns an object indicating

View File

@ -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<ReturnType<typeof getCurrentUser>>
): Promise<boolean> {
// 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<string>([
...user.roles.map((role) => role.code),
...user.directPermissions.map((dp) => dp.code),
]);
// Check if the user has the required permission.
return permissions.has(permission);
}

View File

@ -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 = undefined> = T extends undefined
? { success: true; message?: string }
: {
success: true;
message?: string;
data: T;
};
type ServerResponseAction<T = undefined> = ErrorResponse | SuccessResponse<T>;
export default ServerResponseAction;

View File

@ -0,0 +1,9 @@
export default interface SidebarMenu {
label: string;
icon: React.FC<any>;
children?: {
label: string;
link: string;
}[];
color?: ThemeIconProps["color"];
}

View File

@ -0,0 +1,6 @@
export interface UserMenuItem {
label: string;
icon: React.FC<any>;
color?: ThemeIconProps["color"];
href?: string;
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -1,5 +1,5 @@
import ServerResponse, { SuccessResponse } from "@/types/Action";
import DashboardError from "../errors/DashboardError"; import DashboardError from "../errors/DashboardError";
import ServerResponseAction from "../types/ServerResponseAction";
/** /**
* A higher-order function that wraps an async function and provides structured error handling. * 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. * @returns The successful response from the async function.
* @throws DashboardError for dashboard-related errors or Error for other errors. * @throws DashboardError for dashboard-related errors or Error for other errors.
*/ */
async function withErrorHandling<T, Args extends unknown[] = []>( async function withServerAction<T, Args extends unknown[] = []>(
asyncFunction: (...args: Args) => Promise<ServerResponse<T>>, asyncFunction: (...args: Args) => Promise<ServerResponseAction<T>>,
...args: Args ...args: Args
){ ){
const result = await asyncFunction(...args); const result = await asyncFunction(...args);
@ -33,4 +33,4 @@ async function withErrorHandling<T, Args extends unknown[] = []>(
} }
} }
export default withErrorHandling; export default withServerAction;

View File

@ -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<ServerResponseAction> {
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);
}
}

View File

@ -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;

View File

@ -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<ServerResponseAction<UserData>> {
// 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;
}

View File

@ -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<ServerResponseAction> {
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)
}
}

View File

@ -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;

View File

@ -15,11 +15,9 @@ import {
Alert, Alert,
} from "@mantine/core"; } from "@mantine/core";
import { showNotification } from "@/utils/notifications"; import { showNotification } from "@/utils/notifications";
import deletePermission from "@/features/dashboard/permissions/actions/deletePermission"; import withServerAction from "@/modules/dashboard/utils/withServerAction";
import withErrorHandling from "@/features/dashboard/utils/withServerAction"; import deleteUser from "../actions/deleteUser";
import { error } from "console"; import DashboardError from "@/modules/dashboard/errors/DashboardError";
import DashboardError from "@/features/dashboard/errors/DashboardError";
import { revalidatePath } from "next/cache";
export interface DeleteModalProps { export interface DeleteModalProps {
data?: { data?: {
@ -29,7 +27,7 @@ export interface DeleteModalProps {
onClose: () => void; onClose: () => void;
} }
export default function DeleteModal(props: DeleteModalProps) { export default function UserDeleteModal(props: DeleteModalProps) {
const router = useRouter(); const router = useRouter();
const [isSubmitting, setSubmitting] = useState(false); const [isSubmitting, setSubmitting] = useState(false);
@ -48,10 +46,10 @@ export default function DeleteModal(props: DeleteModalProps) {
if (!props.data?.id) return; if (!props.data?.id) return;
setSubmitting(true); setSubmitting(true);
withErrorHandling(() => deletePermission(props.data!.id)) withServerAction(() => deleteUser(props.data!.id))
.then((response) => { .then((response) => {
showNotification( showNotification(
response.message ?? "Permission deleted successfully" response.message ?? "User deleted successfully"
); );
setSubmitting(false); setSubmitting(false);
props.onClose() props.onClose()
@ -78,7 +76,7 @@ export default function DeleteModal(props: DeleteModalProps) {
title={`Delete confirmation`} title={`Delete confirmation`}
> >
<Text size="sm"> <Text size="sm">
Are you sure you want to delete permission{" "} Are you sure you want to delete user{" "}
<Text span fw={700}> <Text span fw={700}>
{props.data?.name} {props.data?.name}
</Text> </Text>
@ -103,7 +101,7 @@ export default function DeleteModal(props: DeleteModalProps) {
loading={isSubmitting} loading={isSubmitting}
onClick={confirmAction} onClick={confirmAction}
> >
Delete Permission Delete User
</Button> </Button>
</Flex> </Flex>
</Modal> </Modal>

View File

@ -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<UserFormData>({
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 (
<Modal
opened={props.opened}
onClose={closeModal}
title={props.title}
scrollAreaComponent={ScrollArea.Autosize}
size="xl"
>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack mt="sm" gap="lg" px="lg">
{errorMessage && <Alert>{errorMessage}</Alert>}
{/* Avatar */}
<Skeleton visible={isFetching}>
<Center>
<Avatar
color={stringToColorHex(form.values.id)}
src={form.values.photoProfileUrl}
size={120}
>
{form.values.name?.[0]?.toUpperCase()}
</Avatar>
</Center>
</Skeleton>
{/* ID */}
{form.values.id && (
<TextInput
label="ID"
readOnly
variant="filled"
disabled={isSubmitting}
{...form.getInputProps("id")}
/>
)}
{/* Name */}
<Skeleton visible={isFetching}>
<TextInput
data-autofocus
label="Name"
readOnly={props.readonly}
disabled={isSubmitting}
{...form.getInputProps("name")}
/>
</Skeleton>
{/* Email */}
<Skeleton visible={isFetching}>
<TextInput
label="Email"
readOnly={props.readonly}
disabled={isSubmitting}
{...form.getInputProps("email")}
/>
</Skeleton>
{/* Buttons */}
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
<Button
variant="outline"
onClick={closeModal}
disabled={isSubmitting}
>
Close
</Button>
{!props.readonly && (
<Button
variant="filled"
leftSection={<TbDeviceFloppy size={20} />}
type="submit"
loading={isSubmitting}
>
Save
</Button>
)}
</Flex>
</Stack>
</form>
</Modal>
);
}

View File

@ -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<CrudPermissions>;
userData: Awaited<ReturnType<typeof getAllUsers>>;
}
export default function UsersTable(props: Props) {
const [modalProps, setModalProps] = useState<ModalProps>({
opened: false,
title: "",
});
const [deleteModalProps, setDeleteModalProps] = useState<
Omit<DeleteModalProps, "onClose">
>({
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) => <Text>{props.getValue() as React.ReactNode}</Text>,
},
});
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 (
<>
<Flex justify="flex-end">
{props.permissions.create && (
<Button
leftSection={<TbPlus />}
onClick={() => openFormModal("create")}
>
New User
</Button>
)}
</Flex>
<DashboardTable table={table} />
<UserFormModal {...modalProps} onClose={closeModal} />
<UserDeleteModal
{...deleteModalProps}
onClose={() => setDeleteModalProps({})}
/>
</>
);
}

View File

@ -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<CrudPermissions>;
actions: {
detail: (id: string) => void;
edit: (id: string) => void;
delete: (id: string, name: string) => void;
};
}
const createColumns = (options: ColumnOptions) => {
const columnHelper = createColumnHelper<UserRow>();
const columns = [
columnHelper.display({
id: "sequence",
header: "#",
cell: (props) => props.row.index + 1,
size: 1,
}),
columnHelper.accessor("name", {
header: "Name",
cell: (props) => (
<Group>
<Avatar
color={stringToColorHex(props.row.original.id)}
src={props.row.original.photoUrl}
size={26}
>
{props.getValue()?.[0].toUpperCase()}
</Avatar>
<Text size="sm" fw={500}>
{props.getValue()}
</Text>
</Group>
),
}),
columnHelper.accessor("email", {
header: "Email",
cell: (props) => (
<Anchor
href={`mailto:${props.getValue()}`}
size="sm"
component={Link}
>
{props.getValue()}
</Anchor>
),
}),
columnHelper.display({
id: "status",
header: "Status",
cell: (props) => <Badge color="green">Active</Badge>,
}),
columnHelper.display({
id: "actions",
header: "Actions",
size: 10,
meta: {
className: "w-fit",
},
cell: (props) => (
<Flex gap="xs">
{createActionButtons([
{
label: "Detail",
permission: options.permissions.read,
action: () =>
options.actions.detail(props.row.original.id),
color: "green",
icon: <TbEye />,
},
{
label: "Edit",
permission: options.permissions.update,
action: () =>
options.actions.edit(props.row.original.id),
color: "yellow",
icon: <TbPencil />,
},
{
label: "Delete",
permission: options.permissions.delete,
action: () =>
options.actions.delete(
props.row.original.id,
props.row.original.name ?? ""
),
color: "red",
icon: <TbTrash />,
},
])}
</Flex>
),
}),
];
return columns;
};
export default createColumns;

22
src/types/Action.d.ts vendored
View File

@ -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 = undefined> = T extends undefined ? {success: true; message?: string} : {
success: true;
message?: string;
data: T;
}
type ServerResponse<T = undefined> =
| ErrorResponse
| SuccessResponse<T>
export default ServerResponse;