Added permission

This commit is contained in:
Sianida26 2024-01-28 23:12:51 +07:00
parent bae8a2aa3e
commit dc3a707bb0
21 changed files with 894 additions and 55 deletions

View File

@ -0,0 +1,42 @@
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 { PermissionTable } from "@/features/dashboard/permissions/tables";
import getPermissions from "@/features/dashboard/permissions/data/getPermissions";
interface Props {
searchParams: {
detail?: string;
edit?: string;
delete?: string;
create?: string;
};
}
export const metadata: Metadata = {
title: "Permissions - Dashboard",
};
export default async function RolesPage({ searchParams }: Props) {
const permissions = await checkMultiplePermissions({
create: "permission.create",
readAll: "permission.readAll",
read: "permission.read",
update: "permission.update",
delete: "permission.delete",
});
const permissionData = await getPermissions()
return (
<Stack>
<Title order={1}>Permissions</Title>
<Card>
<PermissionTable permissions={permissions} permissionData={permissionData} />
</Card>
</Stack>
);
}

View File

@ -16,6 +16,7 @@ import { RoleFormData } from "@/features/dashboard/roles/formSchemas/RoleFormDat
import { string } from "zod"; import { string } from "zod";
import { DeleteModal } from "../../_modals"; import { DeleteModal } from "../../_modals";
import { DeleteModalProps } from "../../_modals/DeleteModal/DeleteModal"; import { DeleteModalProps } from "../../_modals/DeleteModal/DeleteModal";
import { DashboardTable } from "@/features/dashboard/components";
interface Props { interface Props {
permissions: Partial<CrudPermissions>; permissions: Partial<CrudPermissions>;
@ -114,60 +115,8 @@ export default function RolesTable(props: Props) {
</Button> </Button>
)} )}
</Flex> </Flex>
<Table verticalSpacing="xs" horizontalSpacing="xs">
{/* Thead */}
<Table.Thead>
{table.getHeaderGroups().map((headerGroup) => (
<Table.Tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<Table.Th
key={header.id}
style={{
maxWidth: `${header.column.columnDef.maxSize}px`,
width: `${header.getSize()}`,
}}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.Th>
))}
</Table.Tr>
))}
</Table.Thead>
{/* Tbody */} <DashboardTable table={table} />
<Table.Tbody>
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => (
<Table.Tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<Table.Td
key={cell.id}
style={{
maxWidth: `${cell.column.columnDef.maxSize}px`,
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Td>
))}
</Table.Tr>
))
) : (
<Table.Tr>
<Table.Td colSpan={table.getAllColumns().length}>
<Center>- No Data -</Center>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
<FormModal {...modalProps} onClose={closeModal} /> <FormModal {...modalProps} onClose={closeModal} />
<DeleteModal <DeleteModal

View File

@ -4,6 +4,7 @@ import React from "react";
import RolesTable from "./_tables/RolesTable/RolesTable"; import RolesTable from "./_tables/RolesTable/RolesTable";
import getRoles from "@/features/dashboard/roles/data/getRoles"; import getRoles from "@/features/dashboard/roles/data/getRoles";
import checkMultiplePermissions from "@/features/auth/tools/checkMultiplePermissions"; import checkMultiplePermissions from "@/features/auth/tools/checkMultiplePermissions";
import { unauthorized } from "@/features/dashboard/errors/DashboardError";
interface Props { interface Props {
searchParams: { searchParams: {
@ -27,6 +28,8 @@ export default async function RolesPage({ searchParams }: Props) {
delete: "role.delete", delete: "role.delete",
}); });
if (!permissions.readAll) unauthorized()
const roles = await getRoles(); const roles = await getRoles();
return ( return (

View File

@ -30,7 +30,7 @@ const allMenu: MenuItem[] = [
children: [ children: [
{ label: "Users", link: "/users" }, { label: "Users", link: "/users" },
{ label: "Roles", link: "/roles" }, { label: "Roles", link: "/roles" },
{ label: "Permissions", link: "#" }, { label: "Permissions", link: "/permissions" },
], ],
}, },
{ {

View File

@ -0,0 +1,68 @@
"use client";
import React from "react";
import { Table, Center } from "@mantine/core"
import { Table as ReactTable, flexRender } from "@tanstack/react-table";
interface Props<TData> {
table: ReactTable<TData>
}
export default function DashboardTable<T>({table}: Props<T>) {
return (
<Table verticalSpacing="xs" horizontalSpacing="xs">
{/* Thead */}
<Table.Thead>
{table.getHeaderGroups().map((headerGroup) => (
<Table.Tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<Table.Th
key={header.id}
style={{
maxWidth: `${header.column.columnDef.maxSize}px`,
width: `${header.getSize()}`,
}}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.Th>
))}
</Table.Tr>
))}
</Table.Thead>
{/* Tbody */}
<Table.Tbody>
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => (
<Table.Tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<Table.Td
key={cell.id}
style={{
maxWidth: `${cell.column.columnDef.maxSize}px`,
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Td>
))}
</Table.Tr>
))
) : (
<Table.Tr>
<Table.Td colSpan={table.getAllColumns().length}>
<Center>- No Data -</Center>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
);
}

View File

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

View File

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

View File

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

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

@ -0,0 +1,81 @@
"use server";
import checkPermission from "@/features/auth/tools/checkPermission";
import permissionFormDataSchema, { PermissionFormData } from "../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

@ -0,0 +1,48 @@
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.getAll"))) {
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

@ -0,0 +1,19 @@
import { z } from "zod"
export interface PermissionFormData {
id: string;
name: string;
code: string;
description: string;
isActive: boolean;
}
const permissionFormDataSchema = z.object({
id: z.string().nullable(),
name: z.string().min(1),
code: z.string().min(1),
description: z.string(),
isActive: z.boolean(),
})
export default permissionFormDataSchema;

View File

@ -0,0 +1,111 @@
"use client";
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 deletePermission from "@/features/dashboard/permissions/actions/deletePermission";
import withErrorHandling from "@/features/dashboard/utils/withServerAction";
import { error } from "console";
import DashboardError from "@/features/dashboard/errors/DashboardError";
import { revalidatePath } from "next/cache";
export interface DeleteModalProps {
data?: {
id: string;
name: string;
};
onClose: () => void;
}
export default function DeleteModal(props: DeleteModalProps) {
const router = useRouter();
const [isSubmitting, setSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
/**
* Closes the modal. It won't close if a submission is in progress.
*/
const closeModal = () => {
if (isSubmitting) return;
setErrorMessage("")
props.onClose();
};
const confirmAction = () => {
if (!props.data?.id) return;
setSubmitting(true);
withErrorHandling(() => deletePermission(props.data!.id))
.then((response) => {
showNotification(
response.message ?? "Permission deleted successfully"
);
setSubmitting(false);
props.onClose()
})
.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(() => {
setSubmitting(false)
});
};
return (
<Modal
opened={!!props.data}
onClose={closeModal}
title={`Delete confirmation`}
>
<Text size="sm">
Are you sure you want to delete permission{" "}
<Text span fw={700}>
{props.data?.name}
</Text>
? This action is irreversible.
</Text>
{errorMessage && <Alert color="red">{errorMessage}</Alert>}
{/* Buttons */}
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
<Button
variant="outline"
onClick={closeModal}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
variant="subtle"
// leftSection={<TbDeviceFloppy size={20} />}
type="submit"
color="red"
loading={isSubmitting}
onClick={confirmAction}
>
Delete Permission
</Button>
</Flex>
</Modal>
);
}

View File

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

View File

@ -0,0 +1,214 @@
/* 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 = () => {
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

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

View File

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

View File

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

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

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

View File

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