Added perission check and permission management

This commit is contained in:
Sianida26 2024-02-05 14:57:24 +07:00
parent 152d444067
commit 7800fb471c
12 changed files with 311 additions and 104 deletions

View File

@ -1,5 +1,6 @@
/* 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, {
@ -20,11 +21,13 @@ import {
Skeleton,
Fieldset,
Alert,
Chip,
} from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import { useRouter } from "next/navigation";
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { TbDeviceFloppy } from "react-icons/tb";
import { string } from "zod";
export interface ModalProps {
title: string;
@ -45,6 +48,9 @@ export default function FormModal(props: ModalProps) {
const [isSubmitting, setSubmitting] = useState(false);
const [isFetching, setFetching] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [allPermissions, setAllPermissions] = useState<
{ code: string; name: string }[] | undefined
>(undefined);
const form = useForm<RoleFormData>({
initialValues: {
@ -53,6 +59,7 @@ export default function FormModal(props: ModalProps) {
id: "",
isActive: false,
name: "",
permissions: [],
},
validate: zodResolver(roleFormDataSchema),
validateInputOnChange: false,
@ -61,6 +68,29 @@ export default function FormModal(props: ModalProps) {
},
});
//Fetch Permissions
useEffect(() => {
setFetching(true);
withErrorHandling(getAllPermissions)
.then((response) => {
setAllPermissions(response.data);
})
.catch((e) => {
if (e instanceof DashboardError) {
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(() => {
setFetching(false);
});
}, []);
/**
* Fetches role data by ID and populates the form if the modal is opened and an ID is provided.
*/
@ -70,22 +100,30 @@ export default function FormModal(props: ModalProps) {
}
setFetching(true);
getRoleById(props.id)
withErrorHandling(getRoleById, 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,
});
}
const data = response.data;
form.setValues({
code: data.code,
description: data.description,
id: data.id,
isActive: data.isActive,
name: data.name,
permissions: data.permissions.map(
(permission) => permission.code
),
});
})
.catch((e) => {
//TODO: Handle error
console.log(e);
if (e instanceof DashboardError) {
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(() => {
setFetching(false);
@ -94,11 +132,12 @@ export default function FormModal(props: ModalProps) {
const closeModal = () => {
props.onClose ? props.onClose() : router.replace("?");
form.reset();
};
const handleSubmit = (values: RoleFormData) => {
setSubmitting(true);
withErrorHandling(() => upsertRole(values))
withErrorHandling(upsertRole, values)
.then((response) => {
showNotification(response.message!, "success");
closeModal();
@ -187,6 +226,30 @@ export default function FormModal(props: ModalProps) {
/>
</Skeleton>
<Fieldset legend="Permissions">
<Chip.Group
multiple
value={form.values.permissions}
onChange={(values) =>
!props.readonly &&
form.setFieldValue("permissions", values)
}
>
<Flex wrap="wrap" gap="md">
{allPermissions?.map((permission) => (
<div key={permission.code}>
<Chip
disabled={isSubmitting}
value={permission.code}
>
{permission.code}
</Chip>
</div>
))}
</Flex>
</Chip.Group>
</Fieldset>
{/* Buttons */}
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
<Button

View File

@ -5,6 +5,8 @@ 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.
@ -64,22 +66,31 @@ export function decodeJwtToken(token: string): JwtPayload | string {
}
}
export async function getUserFromToken(token: string) {
const decodedToken = decodeJwtToken(token) as {
id: string;
iat: number;
};
/**
* 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,
include: {
photoProfile: true,
roles: true,
directPermissions: true
},
},
where: {
id: decodedToken.id,
},
});
return user;
}
})

View File

@ -3,15 +3,26 @@ import "server-only"
import { getUserFromToken } from "../authUtils"
import { cookies } from "next/headers"
const getCurrentUser = cache(async () => {
/**
* 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);
if (!user) return null;
return user;
})
// Return the user if found, otherwise return null
return user ? user : null;
}
export default getCurrentUser;

View File

@ -1,5 +1,6 @@
import { Prisma } from "@prisma/client";
// Use TypeScript enum for error codes to provide better autocompletion and error handling
export const DashboardErrorCodes = [
"UNAUTHORIZED",
"NOT_FOUND",
@ -10,22 +11,26 @@ export const DashboardErrorCodes = [
interface ErrorOptions {
message?: string,
errorCode?: typeof DashboardErrorCodes[number] | string & {},
formErrors?: {[k: string]: 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?: {[k: string]: string}
// public readonly data: object;
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;
// this.data = data;
Object.setPrototypeOf(this, new.target.prototype); // restore prototype chain
}
/**
* Returns a structured error response object.
*/
getErrorReponseObject(){
return {
success: false,
@ -35,10 +40,14 @@ export default class DashboardError extends Error {
errorCode: this.errorCode,
errors: this.formErrors ?? undefined
}
} as const
} 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()
@ -65,9 +74,23 @@ export const handleCatch = (e: unknown) => {
}
}
/**
* Throws a 'UNAUTHORIZED' DashboardError.
*/
export const unauthorized = () => {
throw new DashboardError({
errorCode: "UNAUTHORIZED",
message: "You are unauthorized to do this action"
})
}
/**
* Throws a 'NOT_FOUND' DashboardError with a custom or default message.
* @param message Optional custom message for the error.
*/
export const notFound = ({message}: {message?: string}) => {
throw new DashboardError({
errorCode: "NOT_FOUND",
message: message ?? "The requested data could not be located. It may have been deleted or relocated. Please verify the information or try a different request."
})
}

View File

@ -0,0 +1,48 @@
"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

@ -11,7 +11,7 @@ import "server-only";
*/
export default async function getPermissions() {
// Authorization check
if (!(await checkPermission("permissions.getAll"))) {
if (!(await checkPermission("permissions.readAll"))) {
return unauthorized();
}

View File

@ -1,40 +1,56 @@
"use server";
import { unauthorized } from "@/BaseError";
import prisma from "@/db";
import checkPermission from "@/features/auth/tools/checkPermission";
import { handleCatch, notFound, unauthorized } from "../../errors/DashboardError";
import ServerResponse from "@/types/Action";
export default async function getRoleById(id: string) {
if (!(await checkPermission("role.read"))) unauthorized();
type RoleData = {
id: string;
code: string;
name: string;
description: string;
isActive: boolean;
permissions: {
id: string;
code: string;
name: string;
}[]
}
const role = await prisma.role.findFirst({
where: { id },
select: {
code: true,
description: true,
id: true,
isActive: true,
name: true,
permissions: {
select: {
id: true,
code: true,
name: true,
export default async function getRoleById(id: string): Promise<ServerResponse<RoleData>>{
try{
if (!(await checkPermission("role.read"))) return unauthorized();
const role = await prisma.role.findFirst({
where: { id },
select: {
code: true,
description: true,
id: true,
isActive: true,
name: true,
permissions: {
select: {
id: true,
code: true,
name: true,
},
},
},
},
});
});
if (!role) {
throw new Error("Permission not found")
}
if (!role) {
return {
success: false,
message: "Role not found",
} as const;
success: true,
message: "Role fetched successfully",
data: role,
};
} catch (e){
return handleCatch(e)
}
return {
success: true,
message: "Role fetched successfully",
data: role,
} as const;
}

View File

@ -6,7 +6,10 @@ 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 DashboardError, {
handleCatch,
unauthorized,
} from "../../errors/DashboardError";
/**
* Upserts a role based on the provided RoleFormData.
@ -32,9 +35,11 @@ export default async function upsertRole(
const validatedFields = roleFormDataSchema.safeParse(data);
if (!validatedFields.success) {
throw new DashboardError({
errorCode: "INVALID_FORM_DATA",
formErrors: mapObjectToFirstValue(validatedFields.error.flatten().fieldErrors)
})
errorCode: "INVALID_FORM_DATA",
formErrors: mapObjectToFirstValue(
validatedFields.error.flatten().fieldErrors
),
});
}
const roleData = {
code: validatedFields.data.code,
@ -43,25 +48,38 @@ export default async function upsertRole(
isActive: validatedFields.data.isActive,
};
const permissionIds = validatedFields.data.permissions.map(
(permission) => ({ code: permission })
);
// Database operation
if (isInsert) {
if (await prisma.role.findFirst({
where: {
code: roleData.code
}
})){
throw new DashboardError({
errorCode: "INVALID_FORM_DATA",
formErrors: {
code: "The code is already exists"
}
})
}
await prisma.role.create({ data: roleData });
if (
await prisma.role.findFirst({
where: {
code: roleData.code,
},
})
) {
throw new DashboardError({
errorCode: "INVALID_FORM_DATA",
formErrors: {
code: "The code is already exists",
},
});
}
await prisma.role.create({
data: {
...roleData,
permissions: {
connect: permissionIds,
},
},
});
} else {
await prisma.role.update({
where: { id: validatedFields.data.id! },
data: roleData,
data: { ...roleData, permissions: { connect: permissionIds } },
});
}
@ -76,6 +94,6 @@ export default async function upsertRole(
}.`,
};
} catch (error) {
return handleCatch(error)
return handleCatch(error);
}
}

View File

@ -1,7 +1,7 @@
import { unauthorized } from "@/BaseError";
import prisma from "@/db";
import checkPermission from "@/features/auth/tools/checkPermission";
import "server-only";
import { unauthorized } from "@/features/dashboard/errors/DashboardError";
/**
* Retrieves all roles along with the count of associated permissions and users.

View File

@ -6,6 +6,7 @@ export interface RoleFormData {
code: string;
description: string;
isActive: boolean;
permissions: string[]
}
const roleFormDataSchema = z.object({
@ -14,6 +15,7 @@ const roleFormDataSchema = z.object({
code: z.string().min(1),
description: z.string(),
isActive: z.boolean(),
permissions: z.array(z.string()).optional().default([]),
})
export default roleFormDataSchema;

View File

@ -1,11 +1,22 @@
import ServerResponse from "@/types/Action";
import ServerResponse, { SuccessResponse } from "@/types/Action";
import DashboardError from "../errors/DashboardError";
async function withErrorHandling<T extends ServerResponse>(
asyncFunction: () => Promise<T>
): Promise<T> {
const result = await asyncFunction();
if (result.success) {
/**
* A higher-order function that wraps an async function and provides structured error handling.
* If the wrapped function returns a successful response, it's returned directly.
* If an error occurs, it throws a DashboardError for dashboard-related errors or a generic Error otherwise.
*
* @param asyncFunction - The async function to wrap.
* @param args - The arguments to pass to the async function.
* @returns The successful response from the async function.
* @throws DashboardError for dashboard-related errors or Error for other errors.
*/
async function withErrorHandling<T, Args extends unknown[] = []>(
asyncFunction: (...args: Args) => Promise<ServerResponse<T>>,
...args: Args
){
const result = await asyncFunction(...args);
if (result.success === true) {
return result;
} else {
if (result.dashboardError && result.error) {

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

@ -1,18 +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 ServerResponse<T = undefined> =
| {
success: false;
dashboardError?: boolean;
error?: {
message?: string;
errorCode?: string;
errors?: { [k: string]: string };
};
message?: string;
}
| {
success: true;
message?: string;
data?: T;
};
| ErrorResponse
| SuccessResponse<T>
export default ServerResponse;