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 */ /* eslint-disable react-hooks/exhaustive-deps */
import DashboardError from "@/features/dashboard/errors/DashboardError"; import DashboardError from "@/features/dashboard/errors/DashboardError";
import getAllPermissions from "@/features/dashboard/permissions/actions/getAllPermissions";
import getRoleById from "@/features/dashboard/roles/actions/getRoleById"; import getRoleById from "@/features/dashboard/roles/actions/getRoleById";
import upsertRole from "@/features/dashboard/roles/actions/upsertRole"; import upsertRole from "@/features/dashboard/roles/actions/upsertRole";
import roleFormDataSchema, { import roleFormDataSchema, {
@ -20,11 +21,13 @@ import {
Skeleton, Skeleton,
Fieldset, Fieldset,
Alert, Alert,
Chip,
} from "@mantine/core"; } from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form"; import { useForm, zodResolver } from "@mantine/form";
import { useRouter } from "next/navigation"; 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 { TbDeviceFloppy } from "react-icons/tb";
import { string } from "zod";
export interface ModalProps { export interface ModalProps {
title: string; title: string;
@ -45,6 +48,9 @@ export default function FormModal(props: ModalProps) {
const [isSubmitting, setSubmitting] = useState(false); const [isSubmitting, setSubmitting] = useState(false);
const [isFetching, setFetching] = useState(false); const [isFetching, setFetching] = useState(false);
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [allPermissions, setAllPermissions] = useState<
{ code: string; name: string }[] | undefined
>(undefined);
const form = useForm<RoleFormData>({ const form = useForm<RoleFormData>({
initialValues: { initialValues: {
@ -53,6 +59,7 @@ export default function FormModal(props: ModalProps) {
id: "", id: "",
isActive: false, isActive: false,
name: "", name: "",
permissions: [],
}, },
validate: zodResolver(roleFormDataSchema), validate: zodResolver(roleFormDataSchema),
validateInputOnChange: false, 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. * 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); setFetching(true);
getRoleById(props.id) withErrorHandling(getRoleById, props.id)
.then((response) => { .then((response) => {
if (response.success) { const data = response.data;
const data = response.data; form.setValues({
form.setValues({ code: data.code,
code: data.code, description: data.description,
description: data.description, id: data.id,
id: data.id, isActive: data.isActive,
isActive: data.isActive, name: data.name,
name: data.name, permissions: data.permissions.map(
}); (permission) => permission.code
} ),
});
}) })
.catch((e) => { .catch((e) => {
//TODO: Handle error if (e instanceof DashboardError) {
console.log(e); 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(() => { .finally(() => {
setFetching(false); setFetching(false);
@ -94,11 +132,12 @@ export default function FormModal(props: ModalProps) {
const closeModal = () => { const closeModal = () => {
props.onClose ? props.onClose() : router.replace("?"); props.onClose ? props.onClose() : router.replace("?");
form.reset();
}; };
const handleSubmit = (values: RoleFormData) => { const handleSubmit = (values: RoleFormData) => {
setSubmitting(true); setSubmitting(true);
withErrorHandling(() => upsertRole(values)) withErrorHandling(upsertRole, values)
.then((response) => { .then((response) => {
showNotification(response.message!, "success"); showNotification(response.message!, "success");
closeModal(); closeModal();
@ -187,6 +226,30 @@ export default function FormModal(props: ModalProps) {
/> />
</Skeleton> </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 */} {/* Buttons */}
<Flex justify="flex-end" align="center" gap="lg" mt="lg"> <Flex justify="flex-end" align="center" gap="lg" mt="lg">
<Button <Button

View File

@ -5,6 +5,8 @@ import prisma from "@/db";
import AuthError, { AuthErrorCode } from "./AuthError"; import AuthError, { AuthErrorCode } from "./AuthError";
import authConfig from "@/config/auth"; import authConfig from "@/config/auth";
import UserClaims from "./types/UserClaims"; import UserClaims from "./types/UserClaims";
import { cache } from "react";
import BaseError from "@/BaseError";
/** /**
* Hashes a plain text password using bcrypt. * 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 { * Retrieves user data from the database based on the provided JWT token.
id: string; *
iat: number; * 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({ const user = await prisma.user.findFirst({
include:{ include: {
photoProfile: true, photoProfile: true,
roles: true, roles: true,
directPermissions: true directPermissions: true
}, },
where: { where: {
id: decodedToken.id, id: decodedToken.id,
}, },
}); });
return user; return user;
} })

View File

@ -3,15 +3,26 @@ import "server-only"
import { getUserFromToken } from "../authUtils" import { getUserFromToken } from "../authUtils"
import { cookies } from "next/headers" 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; const token = cookies().get("token")?.value;
// If no token is found, return null (no current user)
if(!token) return null; if(!token) return null;
// Use the token to get the user from the database
const user = await getUserFromToken(token); const user = await getUserFromToken(token);
if (!user) return null; // Return the user if found, otherwise return null
return user ? user : null;
return user; }
})
export default getCurrentUser; export default getCurrentUser;

View File

@ -1,5 +1,6 @@
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
// Use TypeScript enum for error codes to provide better autocompletion and error handling
export const DashboardErrorCodes = [ export const DashboardErrorCodes = [
"UNAUTHORIZED", "UNAUTHORIZED",
"NOT_FOUND", "NOT_FOUND",
@ -10,22 +11,26 @@ export const DashboardErrorCodes = [
interface ErrorOptions { interface ErrorOptions {
message?: string, message?: string,
errorCode?: typeof DashboardErrorCodes[number] | 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 { export default class DashboardError extends Error {
public readonly errorCode: typeof DashboardErrorCodes[number] | string & {}; public readonly errorCode: typeof DashboardErrorCodes[number] | string & {};
public readonly formErrors?: {[k: string]: string} public readonly formErrors?: Record<string, string>
// public readonly data: object;
constructor(options: ErrorOptions) { constructor(options: ErrorOptions) {
super(options.message ?? "Undetermined Error"); // Pass message to the Error parent class super(options.message ?? "Undetermined Error"); // Pass message to the Error parent class
this.errorCode = options.errorCode ?? "UNKNOWN_ERROR"; this.errorCode = options.errorCode ?? "UNKNOWN_ERROR";
this.formErrors = options.formErrors; this.formErrors = options.formErrors;
// this.data = data;
Object.setPrototypeOf(this, new.target.prototype); // restore prototype chain Object.setPrototypeOf(this, new.target.prototype); // restore prototype chain
} }
/**
* Returns a structured error response object.
*/
getErrorReponseObject(){ getErrorReponseObject(){
return { return {
success: false, success: false,
@ -35,10 +40,14 @@ export default class DashboardError extends Error {
errorCode: this.errorCode, errorCode: this.errorCode,
errors: this.formErrors ?? undefined 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) => { export const handleCatch = (e: unknown) => {
if (e instanceof DashboardError){ if (e instanceof DashboardError){
return e.getErrorReponseObject() return e.getErrorReponseObject()
@ -65,9 +74,23 @@ export const handleCatch = (e: unknown) => {
} }
} }
/**
* Throws a 'UNAUTHORIZED' DashboardError.
*/
export const unauthorized = () => { export const unauthorized = () => {
throw new DashboardError({ throw new DashboardError({
errorCode: "UNAUTHORIZED", errorCode: "UNAUTHORIZED",
message: "You are unauthorized to do this action" 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() { export default async function getPermissions() {
// Authorization check // Authorization check
if (!(await checkPermission("permissions.getAll"))) { if (!(await checkPermission("permissions.readAll"))) {
return unauthorized(); return unauthorized();
} }

View File

@ -1,40 +1,56 @@
"use server"; "use server";
import { unauthorized } from "@/BaseError";
import prisma from "@/db"; import prisma from "@/db";
import checkPermission from "@/features/auth/tools/checkPermission"; 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) { type RoleData = {
if (!(await checkPermission("role.read"))) unauthorized(); id: string;
code: string;
name: string;
description: string;
isActive: boolean;
permissions: {
id: string;
code: string;
name: string;
}[]
}
const role = await prisma.role.findFirst({ export default async function getRoleById(id: string): Promise<ServerResponse<RoleData>>{
where: { id }, try{
select: {
code: true, if (!(await checkPermission("role.read"))) return unauthorized();
description: true,
id: true, const role = await prisma.role.findFirst({
isActive: true, where: { id },
name: true, select: {
permissions: { code: true,
select: { description: true,
id: true, id: true,
code: true, isActive: true,
name: true, name: true,
permissions: {
select: {
id: true,
code: true,
name: true,
},
}, },
}, },
}, });
});
if (!role) {
throw new Error("Permission not found")
}
if (!role) {
return { return {
success: false, success: true,
message: "Role not found", message: "Role fetched successfully",
} as const; 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 prisma from "@/db";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import ServerResponse from "@/types/Action"; 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. * Upserts a role based on the provided RoleFormData.
@ -32,9 +35,11 @@ export default async function upsertRole(
const validatedFields = roleFormDataSchema.safeParse(data); const validatedFields = roleFormDataSchema.safeParse(data);
if (!validatedFields.success) { if (!validatedFields.success) {
throw new DashboardError({ throw new DashboardError({
errorCode: "INVALID_FORM_DATA", errorCode: "INVALID_FORM_DATA",
formErrors: mapObjectToFirstValue(validatedFields.error.flatten().fieldErrors) formErrors: mapObjectToFirstValue(
}) validatedFields.error.flatten().fieldErrors
),
});
} }
const roleData = { const roleData = {
code: validatedFields.data.code, code: validatedFields.data.code,
@ -43,25 +48,38 @@ export default async function upsertRole(
isActive: validatedFields.data.isActive, isActive: validatedFields.data.isActive,
}; };
const permissionIds = validatedFields.data.permissions.map(
(permission) => ({ code: permission })
);
// Database operation // Database operation
if (isInsert) { if (isInsert) {
if (await prisma.role.findFirst({ if (
where: { await prisma.role.findFirst({
code: roleData.code where: {
} code: roleData.code,
})){ },
throw new DashboardError({ })
errorCode: "INVALID_FORM_DATA", ) {
formErrors: { throw new DashboardError({
code: "The code is already exists" errorCode: "INVALID_FORM_DATA",
} formErrors: {
}) code: "The code is already exists",
} },
await prisma.role.create({ data: roleData }); });
}
await prisma.role.create({
data: {
...roleData,
permissions: {
connect: permissionIds,
},
},
});
} else { } else {
await prisma.role.update({ await prisma.role.update({
where: { id: validatedFields.data.id! }, where: { id: validatedFields.data.id! },
data: roleData, data: { ...roleData, permissions: { connect: permissionIds } },
}); });
} }
@ -76,6 +94,6 @@ export default async function upsertRole(
}.`, }.`,
}; };
} catch (error) { } catch (error) {
return handleCatch(error) return handleCatch(error);
} }
} }

View File

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

View File

@ -6,6 +6,7 @@ export interface RoleFormData {
code: string; code: string;
description: string; description: string;
isActive: boolean; isActive: boolean;
permissions: string[]
} }
const roleFormDataSchema = z.object({ const roleFormDataSchema = z.object({
@ -14,6 +15,7 @@ const roleFormDataSchema = z.object({
code: z.string().min(1), code: z.string().min(1),
description: z.string(), description: z.string(),
isActive: z.boolean(), isActive: z.boolean(),
permissions: z.array(z.string()).optional().default([]),
}) })
export default roleFormDataSchema; 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"; import DashboardError from "../errors/DashboardError";
async function withErrorHandling<T extends ServerResponse>( /**
asyncFunction: () => Promise<T> * A higher-order function that wraps an async function and provides structured error handling.
): Promise<T> { * If the wrapped function returns a successful response, it's returned directly.
const result = await asyncFunction(); * If an error occurs, it throws a DashboardError for dashboard-related errors or a generic Error otherwise.
if (result.success) { *
* @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; return result;
} else { } else {
if (result.dashboardError && result.error) { 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> = type ServerResponse<T = undefined> =
| { | ErrorResponse
success: false; | SuccessResponse<T>
dashboardError?: boolean;
error?: {
message?: string;
errorCode?: string;
errors?: { [k: string]: string };
};
message?: string;
}
| {
success: true;
message?: string;
data?: T;
};
export default ServerResponse; export default ServerResponse;