Optimize role

This commit is contained in:
sianida26 2024-02-14 14:48:40 +07:00
parent 9ba63c2ec9
commit caab669d51
49 changed files with 77 additions and 1295 deletions

View File

@ -1,16 +0,0 @@
import BaseError from "@/BaseError";
export enum AuthErrorCode {
EMAIL_NOT_FOUND = "EMAIL_NOT_FOUND",
EMPTY_USER_HASH = "EMPTY_USER_HASH",
INVALID_CREDENTIALS = "INVALID_CREDENTIALS",
INVALID_JWT_TOKEN = "INVALID_JWT_TOKEN",
JWT_SECRET_EMPTY = "JWT_SECRET_NOT_EMPTY",
USER_ALREADY_EXISTS = "USER_ALREADY_EXISTS",
}
export default class AuthError extends BaseError {
constructor(errorCode: AuthErrorCode, {statusCode = 500, message, data}: Partial<{statusCode: number, message: string, data: object}> = {}) {
super(message, errorCode, statusCode, data);
}
}

View File

@ -1,104 +0,0 @@
"use server"
import { z } from "zod";
import prisma from "@/db";
import AuthError, { AuthErrorCode } from "../AuthError";
import BaseError, { BaseErrorCodes } from "@/BaseError";
import { createJwtToken, hashPassword } from "../authUtils";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
/**
* Interface for the schema of a new user.
*/
interface CreateUserSchema {
name: string;
email: string;
password: string;
}
/**
* Validation schema for creating a user.
*/
const createUserSchema = z.object({
name: z.string(),
email: z.string().email(),
password: z.string().min(6),
passwordConfirmation: z.string().optional(),
}).refine(
(data) => data.password === data.passwordConfirmation,
{
message: "Password confirmation must match the password",
path: ["passwordConfirmation"],
}
);
/**
* Creates a new user in the system.
*
* @param formData - The form data containing user details.
* @returns An object indicating the result of the operation.
*/
export default async function createUser(formData: FormData){
//TODO: Add Throttling
//TODO: Add validation check if the user is already logged in
try {
const parsedData = {
email: formData.get("email")?.toString() ?? '',
name: formData.get("name")?.toString() ?? '',
password: formData.get("password")?.toString() ?? '',
passwordConfirmation: formData.get("passwordConfirmation")?.toString()
};
const validatedFields = createUserSchema.safeParse(parsedData);
if (!validatedFields.success) {
return {
success: false,
error: {
message: "",
errors: validatedFields.error.flatten().fieldErrors
}
}
}
const existingUser = await prisma.user.findUnique({
where: { email: validatedFields.data.email },
});
if (existingUser){
return {
success: false,
error: {
message: "",
errors: {
email: ["Email already exists"]
}
}
}
}
const user = await prisma.user.create({
data: {
name: validatedFields.data.name,
email: validatedFields.data.email,
passwordHash: await hashPassword(validatedFields.data.password),
},
});
const token = createJwtToken({ id: user.id });
cookies().set("token", token);
} catch (e: unknown) {
// Handle unexpected errors
console.error(e)
//@ts-ignore
console.log(e.message)
return {
success: false,
error: {
message: "An unexpected error occurred on the server. Please try again or contact the administrator.",
},
};
}
redirect("/dashboard");
}

View File

@ -1,31 +0,0 @@
"use server"
import { cookies } from "next/headers"
import "server-only"
import { decodeJwtToken, getUserFromToken } from "../authUtils";
import prisma from "@/db";
import AuthError, { AuthErrorCode } from "../AuthError";
import logout from "./logout";
export default async function getUser(){
try {
const token = cookies().get('token');
if (!token) return null;
const user = await getUserFromToken(token.value);
if (!user) return null;
return {
name: user.name ?? "",
email: user.email ?? "",
photoUrl: user.photoProfile?.path ?? null
}
} catch (e: unknown){
if (e instanceof AuthError && e.errorCode === AuthErrorCode.INVALID_JWT_TOKEN){
return null;
}
throw e;
}
}

View File

@ -1,13 +0,0 @@
"use server"
import { redirect } from "next/navigation";
import getUser from "./getUser"
export default async function guestOnly(){
const user = await getUser();
if (user){
redirect("dashboard")
}
}

View File

@ -1,16 +0,0 @@
"use server"
import { cookies } from "next/headers"
import { redirect } from "next/navigation";
import "server-only"
/**
* Handles user logout by deleting the authentication token and redirecting to the login page.
* This function is intended to be used on the server side.
*
* @returns A promise that resolves when the logout process is complete.
*/
export default async function logout(){
cookies().delete("token");
redirect("/login")
}

View File

@ -1,91 +0,0 @@
"use server";
import prisma from "@/db";
import { User } from "@prisma/client";
import AuthError, { AuthErrorCode } from "../AuthError";
import { comparePassword, createJwtToken } from "../authUtils";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import BaseError from "@/BaseError";
import { revalidatePath } from "next/cache";
/**
* Handles the sign-in process for a user.
*
* This function validates a user's credentials (email and password), checks against the database,
* and on successful validation, redirects the user to the dashboard and sets a cookie with a JWT token.
* If the validation fails at any stage, it throws a custom AuthError.
*
* @param prevState - The previous state of the application, not currently used.
* @param rawFormData - The raw form data containing the user's email and password.
* @returns A promise that resolves to a redirect to the dashboard on successful authentication,
* or an object containing error details on failure.
* @throws {AuthError} - Specific authentication error based on the failure stage.
*/
export default async function signIn(prevState: any, rawFormData: FormData) {
//TODO: Add Throttling
//TODO: Add validation check if the user is already logged in
try {
const formData = {
email: rawFormData.get("email") as string,
password: rawFormData.get("password") as string,
};
// Retrieve user from the database by email
const user = await prisma.user.findUnique({
where: { email: formData.email },
});
// Throw if user not found
if (!user) throw new AuthError(AuthErrorCode.EMAIL_NOT_FOUND);
// Throw if user has no password hash
// TODO: Add check if the user uses another provider
if (!user.passwordHash)
throw new AuthError(AuthErrorCode.EMPTY_USER_HASH);
// Compare the provided password with the user's stored password hash
const isMatch = await comparePassword(
formData.password,
user.passwordHash
);
if (!isMatch) throw new AuthError(AuthErrorCode.INVALID_CREDENTIALS);
//Set cookie
//TODO: Auth: Add expiry
const token = createJwtToken({ id: user.id });
cookies().set("token", token);
} catch (e: unknown) {
// Custom error handling for authentication errors
if (e instanceof BaseError) {
// Specific error handling for known authentication errors
switch (e.errorCode) {
case AuthErrorCode.EMAIL_NOT_FOUND:
case AuthErrorCode.INVALID_CREDENTIALS:
return {
errors: {
message:
"Email/Password combination is incorrect. Please try again.",
},
};
default:
// Handle other types of authentication errors
return {
errors: {
message: e.message,
},
};
}
}
// Generic error handling for unexpected server errors
return {
errors: {
message:
"An unexpected error occurred on the server. Please try again or contact the administrator.",
},
};
}
redirect("/dashboard");
}

View File

@ -1,96 +0,0 @@
import bcrypt from "bcrypt";
import jwt, { SignOptions, JwtPayload } from "jsonwebtoken";
import { User } from "@prisma/client";
import prisma from "@/db";
import AuthError, { AuthErrorCode } from "./AuthError";
import authConfig from "@/config/auth";
import UserClaims from "./types/UserClaims";
import { cache } from "react";
import BaseError from "@/BaseError";
/**
* Hashes a plain text password using bcrypt.
*
* @deprecated
* @param password - The plain text password to hash.
* @returns The hashed password.
*/
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, authConfig.saltRounds);
}
/**
* Compares a plain text password with a hashed password.
*
* @param password - The plain text password to compare.
* @param hash - The hashed password to compare against.
* @returns True if the passwords match, false otherwise.
*/
export async function comparePassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash);
}
/**
* Creates a JWT token based on user claims.
*
* @param userClaims - The user claims to encode in the JWT.
* @param options - Optional signing options.
* @returns The generated JWT token.
*/
export function createJwtToken(
userClaims: UserClaims,
options?: SignOptions
): string {
const secret = process.env.JWT_SECRET;
if (!secret) throw new AuthError(AuthErrorCode.JWT_SECRET_EMPTY);
return jwt.sign(userClaims, secret, options);
}
/**
* Decodes a JWT token and retrieves the payload.
*
* @param token - The JWT token to decode.
* @returns The decoded payload.
*/
export function decodeJwtToken(token: string): JwtPayload | string {
const secret = process.env.JWT_SECRET;
if (!secret) throw new AuthError(AuthErrorCode.JWT_SECRET_EMPTY);
try {
return jwt.verify(token, secret) as JwtPayload;
} catch (error) {
throw new AuthError(AuthErrorCode.INVALID_JWT_TOKEN);
}
}
/**
* Retrieves user data from the database based on the provided JWT token.
*
* This function decodes the JWT token to extract the user ID, and then queries the database using Prisma
* to fetch the user's details, including the profile photo, roles, and direct permissions.
*
* @param token - The JWT token containing the user's ID.
* @returns The user's data if the user exists, or null if no user is found.
* Throws an error if the token is invalid or the database query fails.
*/
export const getUserFromToken = cache(async (token: string) => {
// Decode the JWT token to extract the user ID
const decodedToken = decodeJwtToken(token) as { id: string; iat: number; };
// Fetch the user from the database
const user = await prisma.user.findFirst({
include: {
photoProfile: true,
roles: true,
directPermissions: true
},
where: {
id: decodedToken.id,
},
});
return user;
})

View File

@ -1,42 +0,0 @@
import getCurrentUser from "./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

@ -1,28 +0,0 @@
import { cache } from "react"
import "server-only"
import { getUserFromToken } from "../authUtils"
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

@ -1,12 +0,0 @@
import bcrypt from "bcrypt";
import authConfig from "../../../config/auth";
/**
* Hashes a plain text password using bcrypt.
*
* @param password - The plain text password to hash.
* @returns The hashed password.
*/
export default async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, authConfig.saltRounds);
}

View File

@ -1,7 +0,0 @@
import { User } from "@prisma/client"
type UserClaims = {
id: User["id"]
}
export default UserClaims

View File

@ -1 +0,0 @@
export { default } from "./DashboardTable";

View File

@ -1,3 +0,0 @@
import DashboardTable from "./DashboardTable";
export { DashboardTable };

View File

@ -1,45 +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;
}
}

View File

@ -1,26 +0,0 @@
"use server";
import prisma from "@/db";
import checkPermission from "@/features/auth/tools/checkPermission";
import { handleCatch, unauthorized } from "../../errors/DashboardError";
import ServerResponse from "@/types/Action";
import { revalidatePath } from "next/cache";
export default async function deletePermission(id: string): Promise<ServerResponse> {
try {
if (!(await checkPermission("permission.delete"))) return unauthorized();
const permission = await prisma.permission.delete({
where: { id },
});
revalidatePath(".")
return {
success: true,
message: "The permission has been deleted successfully",
};
} catch (e: unknown) {
return handleCatch(e)
}
}

View File

@ -1,48 +0,0 @@
"use server"
import checkPermission from "@/features/auth/tools/checkPermission"
import { handleCatch, notFound, unauthorized } from "../../errors/DashboardError"
import ServerResponse from "@/types/Action";
type PermissionData = {
code: string,
name: string,
}
/**
* Retrieves all active permissions from the database if the user has the 'permissions.readAll' permission.
*
* @returns A structured server response containing the list of permissions or an error message.
*/
export default async function getAllPermissions(): Promise<ServerResponse<PermissionData[]>>{
try {
// Check if the user has the required permission
if (!await checkPermission("permissions.readAll")) return unauthorized()
// Fetch active permissions from the database
const permissions = await prisma?.permission.findMany({
where: {
isActive: true
},
select: {
code: true,
name: true,
}
});
// If no permissions are found, throw a custom 'not found' error
if (!permissions || permissions.length === 0) {
return notFound({ message: "No active permissions found." });
}
// Return the permissions in a structured server response
return {
success: true,
message: "Permissions fetched",
data: permissions
}
} catch (e){
return handleCatch(e)
}
}

View File

@ -1,33 +0,0 @@
"use server";
import { unauthorized } from "@/BaseError";
import prisma from "@/db";
import checkPermission from "@/features/auth/tools/checkPermission";
export default async function getPermissionById(id: string) {
if (!(await checkPermission("permission.read"))) unauthorized();
const permission = await prisma.permission.findFirst({
where: { id },
select: {
code: true,
description: true,
id: true,
isActive: true,
name: true,
},
});
if (!permission) {
return {
success: false,
message: "Permission not found",
} as const;
}
return {
success: true,
message: "Permission fetched successfully",
data: permission,
} as const;
}

View File

@ -1,81 +0,0 @@
"use server";
import checkPermission from "@/features/auth/tools/checkPermission";
import permissionFormDataSchema, { PermissionFormData } from "../../../../modules/permission/formSchemas/PermissionFormData";
import mapObjectToFirstValue from "@/utils/mapObjectToFirstValue";
import prisma from "@/db";
import { revalidatePath } from "next/cache";
import ServerResponse from "@/types/Action";
import DashboardError, { handleCatch, unauthorized } from "../../errors/DashboardError";
/**
* Upserts a permission based on the provided PermissionFormData.
* If the permission already exists (determined by `id`), it updates the permission; otherwise, it creates a new permission.
* Authorization checks are performed based on whether it's a create or update operation.
*
* @param data - The data for creating or updating the permission.
* @returns An object containing the success status, message, and any errors.
*/
export default async function upsertPermission(
data: PermissionFormData
): Promise<ServerResponse> {
try {
const isInsert = !data.id;
// Authorization check
const permissionType = isInsert ? "permission.create" : "permission.update";
if (!(await checkPermission(permissionType))) {
return unauthorized();
}
// Validate form data
const validatedFields = permissionFormDataSchema.safeParse(data);
if (!validatedFields.success) {
throw new DashboardError({
errorCode: "INVALID_FORM_DATA",
formErrors: mapObjectToFirstValue(validatedFields.error.flatten().fieldErrors)
})
}
const permissionData = {
code: validatedFields.data.code,
description: validatedFields.data.description,
name: validatedFields.data.name,
isActive: validatedFields.data.isActive,
};
// Database operation
if (isInsert) {
if (await prisma.permission.findFirst({
where: {
code: permissionData.code
}
})){
throw new DashboardError({
errorCode: "INVALID_FORM_DATA",
formErrors: {
code: "The code is already exists"
}
})
}
await prisma.permission.create({ data: permissionData });
} else {
await prisma.permission.update({
where: { id: validatedFields.data.id! },
data: permissionData,
});
}
// Revalidate the cache
revalidatePath(".");
// Return success message
return {
success: true,
message: `Permission ${validatedFields.data.name} has been successfully ${
isInsert ? "created" : "updated"
}.`,
};
} catch (error) {
return handleCatch(error)
}
}

View File

@ -1,48 +0,0 @@
import { unauthorized } from "@/BaseError";
import prisma from "@/db";
import checkPermission from "@/features/auth/tools/checkPermission";
import "server-only";
/**
* Retrieves all permissions along with the count of associated permissions and users.
* Authorization check is performed for the operation.
*
* @returns An array of permission objects each including details and counts of related permissions and users.
*/
export default async function getPermissions() {
// Authorization check
if (!(await checkPermission("permissions.readAll"))) {
return unauthorized();
}
try {
// Fetch permissions from the database
const permissions = await prisma.permission.findMany({
include: {
_count: {
select: {
roles: true,
directUsers: true,
},
},
},
});
// Transform the data into the desired format
return permissions.map(
({ id, code, name, description, isActive, _count }) => ({
id,
code,
name,
description,
isActive,
roleCount: _count.roles,
//User count counts only direct user
userCount: _count.directUsers,
})
);
} catch (error) {
console.error("Error retrieving permissions", error);
throw error;
}
}

View File

@ -1 +0,0 @@
export { default } from "./DeleteModal";

View File

@ -1,215 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
import DashboardError from "@/features/dashboard/errors/DashboardError";
import getPermissionById from "@/features/dashboard/permissions/actions/getPermissionById";
import upsertPermission from "@/features/dashboard/permissions/actions/upsertPermission";
import permissionFormDataSchema, {
PermissionFormData,
} from "@/features/dashboard/permissions/formSchemas/PermissionFormData";
import withErrorHandling from "@/features/dashboard/utils/withServerAction";
import { showNotification } from "@/utils/notifications";
import {
Flex,
Modal,
Stack,
Switch,
TextInput,
Textarea,
Button,
ScrollArea,
Checkbox,
Skeleton,
Fieldset,
Alert,
} 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";
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 FormModal(props: ModalProps) {
const router = useRouter();
const [isSubmitting, setSubmitting] = useState(false);
const [isFetching, setFetching] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const form = useForm<PermissionFormData>({
initialValues: {
code: "",
description: "",
id: "",
isActive: false,
name: "",
},
validate: zodResolver(permissionFormDataSchema),
validateInputOnChange: false,
onValuesChange: (values) => {
console.log(values);
},
});
/**
* 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);
getPermissionById(props.id)
.then((response) => {
if (response.success) {
const data = response.data;
form.setValues({
code: data.code,
description: data.description,
id: data.id,
isActive: data.isActive,
name: data.name,
});
}
})
.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: PermissionFormData) => {
setSubmitting(true);
withErrorHandling(() => upsertPermission(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 color="red">{errorMessage}</Alert>}
{/* ID */}
{form.values.id ? (
<TextInput
label="ID"
readOnly
variant="filled"
{...form.getInputProps("id")}
/>
) : (
<div></div>
)}
{/* Code */}
<Skeleton visible={isFetching}>
<TextInput
data-autofocus
label="Code"
readOnly={props.readonly}
disabled={isSubmitting}
{...form.getInputProps("code")}
/>
</Skeleton>
{/* Name */}
<Skeleton visible={isFetching}>
<TextInput
label="Name"
readOnly={props.readonly}
disabled={isSubmitting}
{...form.getInputProps("name")}
/>
</Skeleton>
{/* Description */}
<Skeleton visible={isFetching}>
<Textarea
label="Description"
readOnly={props.readonly}
disabled={isSubmitting}
{...form.getInputProps("description")}
/>
</Skeleton>
<Skeleton visible={isFetching}>
<Checkbox
label="Active"
labelPosition="right"
{...form.getInputProps("isActive", {
type: "checkbox",
})}
/>
</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

@ -1 +0,0 @@
export { default } from "./FormModal";

View File

@ -1,4 +0,0 @@
import DeleteModal from "./DeleteModal";
import FormModal from "./FormModal";
export { FormModal, DeleteModal };

View File

@ -1,126 +0,0 @@
"use client";
import { Table, Text, Flex, Button, Center } from "@mantine/core";
import {
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import React, { useState } from "react";
import CrudPermissions from "@/features/auth/types/CrudPermissions";
import { TbPlus } from "react-icons/tb";
import { PermissionFormData } from "@/features/dashboard/permissions/formSchemas/PermissionFormData";
import { string } from "zod";
import { DashboardTable } from "@/features/dashboard/components";
import getPermissions from "../../data/getPermissions";
import FormModal, { ModalProps } from "../../modals/FormModal/FormModal";
import DeleteModal, { DeleteModalProps } from "../../modals/DeleteModal/DeleteModal";
import createColumns from "./_columns";
interface Props {
permissions: Partial<CrudPermissions>;
permissionData: Awaited<ReturnType<typeof getPermissions>>;
}
export default function PermissionsTable(props: Props) {
const [modalProps, setModalProps] = useState<ModalProps>({
opened: false,
title: "",
});
const [deleteModalProps, setDeleteModalProps] = useState<
Omit<DeleteModalProps, "onClose">
>({
data: undefined,
});
const table = useReactTable({
data: props.permissionData,
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 permission",
});
};
const openDetailModal = () => {
setModalProps({
id,
opened: true,
title: "Permission detail",
readonly: true,
});
};
const openEditModal = () => {
setModalProps({
id,
opened: true,
title: "Edit permission",
});
};
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 Permission
</Button>
)}
</Flex>
<DashboardTable table={table} />
<FormModal {...modalProps} onClose={closeModal} />
<DeleteModal
{...deleteModalProps}
onClose={() => setDeleteModalProps({})}
/>
</>
);
}

View File

@ -1,105 +0,0 @@
import { createColumnHelper } from "@tanstack/react-table";
import { Badge, Flex } from "@mantine/core";
import createActionButtons from "@/features/dashboard/utils/createActionButtons";
import CrudPermissions from "@/features/auth/types/CrudPermissions";
import { TbEye, TbPencil, TbTrash } from "react-icons/tb";
export interface PermissionRow {
id: string;
code: string;
name: string;
description: string;
isActive: boolean;
roleCount: number;
userCount: number;
}
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<PermissionRow>();
const columns = [
columnHelper.accessor("id", {
id: "sequence",
header: "#",
cell: (props) => props.row.index + 1,
}),
columnHelper.accessor("code", {
header: "Code",
}),
columnHelper.accessor("name", {
header: "Name",
}),
columnHelper.accessor("isActive", {
header: "Status",
cell: (props) => {
props.getValue() ? (
<Badge color="green">Enabled</Badge>
) : (
<Badge color="orange">Disabled</Badge>
);
},
}),
columnHelper.accessor("roleCount", {
header: "Roles",
}),
columnHelper.accessor("userCount", {
header: "Users",
}),
columnHelper.display({
id: "Actions",
header: "Actions",
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;

View File

@ -1 +0,0 @@
export { default } from "./PermissionTable"

View File

@ -1,3 +0,0 @@
import PermissionTable from "./PermissionTable";
export { PermissionTable };

View File

@ -1,26 +0,0 @@
"use server";
import prisma from "@/db";
import checkPermission from "@/features/auth/tools/checkPermission";
import { handleCatch, unauthorized } from "../../errors/DashboardError";
import ServerResponse from "@/types/Action";
import { revalidatePath } from "next/cache";
export default async function deleteRole(id: string): Promise<ServerResponse> {
try {
if (!(await checkPermission("role.delete"))) return unauthorized();
const role = await prisma.role.delete({
where: { id },
});
revalidatePath(".")
return {
success: true,
message: "The role has been deleted successfully",
};
} catch (e: unknown) {
return handleCatch(e)
}
}

View File

@ -1 +0,0 @@
export { default } from "./DeleteModal";

View File

@ -1 +0,0 @@
export { default } from "./FormModal"

View File

@ -1,4 +0,0 @@
import FormModal from "./FormModal";
import DeleteModal from "./DeleteModal";
export { DeleteModal, FormModal };

View File

@ -1,10 +1,10 @@
import checkMultiplePermissions from "@/modules/dashboard/services/checkMultiplePermissions";
import unauthorized from "@/modules/dashboard/utils/unauthorized";
import getAllRoles from "@/modules/role/actions/getAllRoles";
import RolesTable from "@/modules/role/tables/RolesTable/RolesTable";
import { Card, Stack, Title } from "@mantine/core";
import { Metadata } from "next";
import React from "react";
import RolesTable from "./_tables/RolesTable/RolesTable";
import getRoles from "@/features/dashboard/roles/data/getRoles";
import checkMultiplePermissions from "@/features/auth/tools/checkMultiplePermissions";
import { unauthorized } from "@/features/dashboard/errors/DashboardError";
interface Props {
searchParams: {
@ -19,7 +19,7 @@ export const metadata: Metadata = {
title: "Roles - Dashboard",
};
export default async function RolesPage({ searchParams }: Props) {
export default async function RolesPage() {
const permissions = await checkMultiplePermissions({
create: "role.create",
readAll: "role.readAll",
@ -30,7 +30,7 @@ export default async function RolesPage({ searchParams }: Props) {
if (!permissions.readAll) unauthorized()
const roles = await getRoles();
const roles = await getAllRoles();
return (
<Stack>

View File

@ -0,0 +1,29 @@
"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 unauthorized from "@/modules/dashboard/utils/unauthorized";
import { revalidatePath } from "next/cache";
import { notFound } from "next/navigation";
export default async function deleteRole(
id: string
): Promise<ServerResponseAction> {
try {
if (!(await checkPermission("roles.delete"))) return unauthorized();
const role = await prisma.role.delete({
where: { id },
});
revalidatePath(".");
return {
success: true,
message: "The role has been deleted successfully",
};
} catch (e: unknown) {
return handleCatch(e);
}
}

View File

@ -1,7 +1,8 @@
"use server"
import prisma from "@/db";
import checkPermission from "@/features/auth/tools/checkPermission";
import checkPermission from "@/modules/dashboard/services/checkPermission";
import unauthorized from "@/modules/dashboard/utils/unauthorized";
import "server-only";
import { unauthorized } from "@/features/dashboard/errors/DashboardError";
/**
* Retrieves all roles along with the count of associated permissions and users.
@ -9,10 +10,10 @@ import { unauthorized } from "@/features/dashboard/errors/DashboardError";
*
* @returns An array of role objects each including details and counts of related permissions and users.
*/
export default async function getRoles() {
export default async function getAllRoles() {
// Authorization check
if (!await checkPermission("roles.getAll")) {
return unauthorized();
unauthorized();
}
try {

View File

@ -1,9 +1,10 @@
"use server";
import prisma from "@/db";
import checkPermission from "@/features/auth/tools/checkPermission";
import { handleCatch, notFound, unauthorized } from "../../errors/DashboardError";
import ServerResponse from "@/types/Action";
import checkPermission from "@/modules/dashboard/services/checkPermission";
import ServerResponseAction from "@/modules/dashboard/types/ServerResponseAction";
import handleCatch from "@/modules/dashboard/utils/handleCatch";
import unauthorized from "@/modules/dashboard/utils/unauthorized";
type RoleData = {
id: string;
@ -18,10 +19,10 @@ type RoleData = {
}[]
}
export default async function getRoleById(id: string): Promise<ServerResponse<RoleData>>{
export default async function getRoleById(id: string): Promise<ServerResponseAction<RoleData>>{
try{
if (!(await checkPermission("role.read"))) return unauthorized();
if (!(await checkPermission("roles.read"))) return unauthorized();
const role = await prisma.role.findFirst({
where: { id },

View File

@ -1,15 +1,14 @@
"use server";
import checkPermission from "@/features/auth/tools/checkPermission";
import roleFormDataSchema, { RoleFormData } from "../formSchemas/RoleFormData";
import mapObjectToFirstValue from "@/utils/mapObjectToFirstValue";
import prisma from "@/db";
import { revalidatePath } from "next/cache";
import ServerResponse from "@/types/Action";
import DashboardError, {
handleCatch,
unauthorized,
} from "../../errors/DashboardError";
import ServerResponseAction from "@/modules/dashboard/types/ServerResponseAction";
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";
/**
* Upserts a role based on the provided RoleFormData.
@ -21,7 +20,7 @@ import DashboardError, {
*/
export default async function upsertRole(
data: RoleFormData
): Promise<ServerResponse> {
): Promise<ServerResponseAction> {
try {
const isInsert = !data.id;

View File

@ -2,24 +2,16 @@
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import {
Avatar,
Button,
Center,
Flex,
Modal,
ScrollArea,
Text,
Stack,
TextInput,
Title,
Alert,
} from "@mantine/core";
import { showNotification } from "@/utils/notifications";
import deleteRole from "@/features/dashboard/roles/actions/deleteRole";
import withErrorHandling from "@/features/dashboard/utils/withServerAction";
import { error } from "console";
import DashboardError from "@/features/dashboard/errors/DashboardError";
import { revalidatePath } from "next/cache";
import withServerAction from "@/modules/dashboard/utils/withServerAction";
import deleteRole from "../actions/deleteRole";
import DashboardError from "@/modules/dashboard/errors/DashboardError";
export interface DeleteModalProps {
data?: {
@ -48,7 +40,7 @@ export default function DeleteModal(props: DeleteModalProps) {
if (!props.data?.id) return;
setSubmitting(true);
withErrorHandling(() => deleteRole(props.data!.id))
withServerAction(deleteRole, props.data!.id)
.then((response) => {
showNotification(
response.message ?? "Role deleted successfully"

View File

@ -1,12 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
import DashboardError from "@/features/dashboard/errors/DashboardError";
import getAllPermissions from "@/features/dashboard/permissions/actions/getAllPermissions";
import getRoleById from "@/features/dashboard/roles/actions/getRoleById";
import upsertRole from "@/features/dashboard/roles/actions/upsertRole";
import roleFormDataSchema, {
RoleFormData,
} from "@/features/dashboard/roles/formSchemas/RoleFormData";
import withErrorHandling from "@/features/dashboard/utils/withServerAction";
import { showNotification } from "@/utils/notifications";
import {
Flex,
@ -28,6 +20,12 @@ import { useRouter } from "next/navigation";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { TbDeviceFloppy } from "react-icons/tb";
import { string } from "zod";
import roleFormDataSchema, { RoleFormData } from "../formSchemas/RoleFormData";
import getAllPermissions from "@/modules/permission/actions/getAllPermissions";
import withServerAction from "@/modules/dashboard/utils/withServerAction";
import DashboardError from "@/modules/dashboard/errors/DashboardError";
import getRoleById from "../actions/getRoleById";
import upsertRole from "../actions/upsertRole";
export interface ModalProps {
title: string;
@ -71,7 +69,7 @@ export default function FormModal(props: ModalProps) {
//Fetch Permissions
useEffect(() => {
setFetching(true);
withErrorHandling(getAllPermissions)
withServerAction(getAllPermissions)
.then((response) => {
setAllPermissions(response.data);
})
@ -100,7 +98,7 @@ export default function FormModal(props: ModalProps) {
}
setFetching(true);
withErrorHandling(getRoleById, props.id)
withServerAction(getRoleById, props.id)
.then((response) => {
const data = response.data;
form.setValues({
@ -137,7 +135,7 @@ export default function FormModal(props: ModalProps) {
const handleSubmit = (values: RoleFormData) => {
setSubmitting(true);
withErrorHandling(upsertRole, values)
withServerAction(upsertRole, values)
.then((response) => {
showNotification(response.message!, "success");
closeModal();

View File

@ -1,26 +1,18 @@
"use client";
import { Table, Text, Flex, Button, Center } from "@mantine/core";
import {
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import CrudPermissions from "@/modules/dashboard/types/CrudPermissions";
import { Text, Flex, Button } from "@mantine/core";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import React, { useState } from "react";
import CrudPermissions from "@/features/auth/types/CrudPermissions";
import getRoles from "@/features/dashboard/roles/data/getRoles";
import createColumns from "./columns";
import FormModal from "../../_modals/FormModal";
import { ModalProps } from "../../_modals/FormModal/FormModal";
import { TbPlus } from "react-icons/tb";
import { RoleFormData } from "@/features/dashboard/roles/formSchemas/RoleFormData";
import { string } from "zod";
import { DeleteModal } from "../../_modals";
import { DeleteModalProps } from "../../_modals/DeleteModal/DeleteModal";
import { DashboardTable } from "@/features/dashboard/components";
import getAllRoles from "../../actions/getAllRoles";
import FormModal, { ModalProps } from "../../modals/FormModal";
import DeleteModal, { DeleteModalProps } from "../../modals/DeleteModal";
import createColumns from "./columns";
import DashboardTable from "@/modules/dashboard/components/DashboardTable";
interface Props {
permissions: Partial<CrudPermissions>;
roles: Awaited<ReturnType<typeof getRoles>>;
roles: Awaited<ReturnType<typeof getAllRoles>>;
}
export default function RolesTable(props: Props) {
@ -115,7 +107,7 @@ export default function RolesTable(props: Props) {
</Button>
)}
</Flex>
<DashboardTable table={table} />
<FormModal {...modalProps} onClose={closeModal} />

View File

@ -1,6 +1,5 @@
import CrudPermissions from "@/features/auth/types/CrudPermissions";
import { RoleFormData } from "@/features/dashboard/roles/formSchemas/RoleFormData";
import createActionButtons from "@/features/dashboard/utils/createActionButtons";
import CrudPermissions from "@/modules/dashboard/types/CrudPermissions";
import createActionButtons from "@/modules/dashboard/utils/createActionButton";
import { Badge, Flex, Tooltip, ActionIcon } from "@mantine/core";
import { createColumnHelper } from "@tanstack/react-table";
import Link from "next/link";