Optimize dashboard and user managemnt
This commit is contained in:
parent
c6034de16a
commit
4fbcd5581d
|
|
@ -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`;
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
0
src/_features/auth/index.ts
Normal file
0
src/_features/auth/index.ts
Normal file
0
src/_features/auth/types/CrudPermissions.d.ts
vendored
Normal file
0
src/_features/auth/types/CrudPermissions.d.ts
vendored
Normal file
45
src/_features/dashboard/errors/DashboardError.ts
Normal file
45
src/_features/dashboard/errors/DashboardError.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
0
src/_features/dashboard/utils/withServerAction.ts
Normal file
0
src/_features/dashboard/utils/withServerAction.ts
Normal 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
redirect("/login");
|
||||||
if (!user){
|
|
||||||
redirect("/login")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <DashboardLayout>{props.children}</DashboardLayout>;
|
||||||
<DashboardLayout>
|
|
||||||
{props.children}
|
|
||||||
</DashboardLayout>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"] });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import AppHeader from "./Header"
|
|
||||||
|
|
||||||
export default AppHeader
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import ChildMenu from "./ChildMenu";
|
|
||||||
|
|
||||||
export default ChildMenu;
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import MenuItem from "./MenuItem";
|
|
||||||
|
|
||||||
export default MenuItem;
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import AppNavbar from "./Navbar";
|
|
||||||
|
|
||||||
export default AppNavbar
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import DashboardLayout from "./DashboardLayout";
|
|
||||||
|
|
||||||
export default DashboardLayout;
|
|
||||||
|
|
@ -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."
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -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 = () => {
|
||||||
44
src/modules/auth/utils/checkPermission.ts
Normal file
44
src/modules/auth/utils/checkPermission.ts
Normal 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);
|
||||||
|
}
|
||||||
28
src/modules/auth/utils/getCurrentUser.ts
Normal file
28
src/modules/auth/utils/getCurrentUser.ts
Normal 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;
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
22
src/modules/dashboard/actions/getUserMenus.ts
Normal file
22
src/modules/dashboard/actions/getUserMenus.ts
Normal 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;
|
||||||
|
|
@ -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} />
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
@ -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">
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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">
|
||||||
|
|
@ -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}>
|
||||||
25
src/modules/dashboard/components/UserMenuItem.tsx
Normal file
25
src/modules/dashboard/components/UserMenuItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/modules/dashboard/errors/DashboardError.ts
Normal file
45
src/modules/dashboard/errors/DashboardError.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
42
src/modules/dashboard/services/checkPermission.ts
Normal file
42
src/modules/dashboard/services/checkPermission.ts
Normal 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);
|
||||||
|
}
|
||||||
22
src/modules/dashboard/types/ServerResponseAction.d.ts
vendored
Normal file
22
src/modules/dashboard/types/ServerResponseAction.d.ts
vendored
Normal 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;
|
||||||
9
src/modules/dashboard/types/SidebarMenu.d.ts
vendored
Normal file
9
src/modules/dashboard/types/SidebarMenu.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export default interface SidebarMenu {
|
||||||
|
label: string;
|
||||||
|
icon: React.FC<any>;
|
||||||
|
children?: {
|
||||||
|
label: string;
|
||||||
|
link: string;
|
||||||
|
}[];
|
||||||
|
color?: ThemeIconProps["color"];
|
||||||
|
}
|
||||||
6
src/modules/dashboard/types/UserMenuItem.d.ts
vendored
Normal file
6
src/modules/dashboard/types/UserMenuItem.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export interface UserMenuItem {
|
||||||
|
label: string;
|
||||||
|
icon: React.FC<any>;
|
||||||
|
color?: ThemeIconProps["color"];
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
40
src/modules/dashboard/utils/handleCatch.ts
Normal file
40
src/modules/dashboard/utils/handleCatch.ts
Normal 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;
|
||||||
16
src/modules/dashboard/utils/notFound.ts
Normal file
16
src/modules/dashboard/utils/notFound.ts
Normal 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;
|
||||||
13
src/modules/dashboard/utils/unauthorized.ts
Normal file
13
src/modules/dashboard/utils/unauthorized.ts
Normal 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;
|
||||||
|
|
@ -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;
|
||||||
31
src/modules/userManagement/actions/deleteUser.ts
Normal file
31
src/modules/userManagement/actions/deleteUser.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/modules/userManagement/actions/getAllUsers.ts
Normal file
32
src/modules/userManagement/actions/getAllUsers.ts
Normal 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;
|
||||||
56
src/modules/userManagement/actions/getUserDetailById.ts
Normal file
56
src/modules/userManagement/actions/getUserDetailById.ts
Normal 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;
|
||||||
|
}
|
||||||
83
src/modules/userManagement/actions/upsertUser.ts
Normal file
83
src/modules/userManagement/actions/upsertUser.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/modules/userManagement/formSchemas/userFormSchema.ts
Normal file
17
src/modules/userManagement/formSchemas/userFormSchema.ts
Normal 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;
|
||||||
|
|
@ -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>
|
||||||
205
src/modules/userManagement/modals/UserFormModal.tsx
Normal file
205
src/modules/userManagement/modals/UserFormModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
src/modules/userManagement/tables/UsersTable/UsersTable.tsx
Normal file
125
src/modules/userManagement/tables/UsersTable/UsersTable.tsx
Normal 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({})}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
src/modules/userManagement/tables/UsersTable/columns.tsx
Normal file
119
src/modules/userManagement/tables/UsersTable/columns.tsx
Normal 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
22
src/types/Action.d.ts
vendored
|
|
@ -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;
|
|
||||||
Loading…
Reference in New Issue
Block a user