feat: add roles page

This commit is contained in:
Pablo Lara
2024-12-05 15:25:07 +01:00
parent 381aa93f55
commit 29bc697487
10 changed files with 600 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
export * from "./roles";
+174
View File
@@ -0,0 +1,174 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/auth.config";
import { parseStringify } from "@/lib";
export const getRoles = async ({
page = 1,
query = "",
sort = "",
filters = {},
}) => {
const session = await auth();
if (isNaN(Number(page)) || page < 1) redirect("/roles");
const keyServer = process.env.API_BASE_URL;
const url = new URL(`${keyServer}/roles`);
if (page) url.searchParams.append("page[number]", page.toString());
if (query) url.searchParams.append("filter[search]", query);
if (sort) url.searchParams.append("sort", sort);
// Handle multiple filters
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]") {
url.searchParams.append(key, String(value));
}
});
try {
const invitations = await fetch(url.toString(), {
headers: {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
});
const data = await invitations.json();
const parsedData = parseStringify(data);
revalidatePath("/roles");
return parsedData;
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching roles:", error);
return undefined;
}
};
// export const sendInvite = async (formData: FormData) => {
// const session = await auth();
// const keyServer = process.env.API_BASE_URL;
// const email = formData.get("email");
// const url = new URL(`${keyServer}/tenants/invitations`);
// const body = JSON.stringify({
// data: {
// type: "invitations",
// attributes: {
// email,
// },
// relationships: {},
// },
// });
// try {
// const response = await fetch(url.toString(), {
// method: "POST",
// headers: {
// "Content-Type": "application/vnd.api+json",
// Accept: "application/vnd.api+json",
// Authorization: `Bearer ${session?.accessToken}`,
// },
// body,
// });
// const data = await response.json();
// return parseStringify(data);
// } catch (error) {
// return {
// error: getErrorMessage(error),
// };
// }
// };
// export const updateInvite = async (formData: FormData) => {
// const session = await auth();
// const keyServer = process.env.API_BASE_URL;
// const invitationId = formData.get("invitationId");
// const invitationEmail = formData.get("invitationEmail");
// const expiresAt = formData.get("expires_at");
// const url = new URL(`${keyServer}/tenants/invitations/${invitationId}`);
// try {
// const response = await fetch(url.toString(), {
// method: "PATCH",
// headers: {
// "Content-Type": "application/vnd.api+json",
// Accept: "application/vnd.api+json",
// Authorization: `Bearer ${session?.accessToken}`,
// },
// body: JSON.stringify({
// data: {
// type: "invitations",
// id: invitationId,
// attributes: {
// email: invitationEmail,
// ...(expiresAt && { expires_at: expiresAt }),
// },
// },
// }),
// });
// const data = await response.json();
// revalidatePath("/invitations");
// return parseStringify(data);
// } catch (error) {
// // eslint-disable-next-line no-console
// console.error("Error updating invitation:", error);
// return {
// error: getErrorMessage(error),
// };
// }
// };
// export const getInvitationInfoById = async (invitationId: string) => {
// const session = await auth();
// const keyServer = process.env.API_BASE_URL;
// const url = new URL(`${keyServer}/tenants/invitations/${invitationId}`);
// try {
// const response = await fetch(url.toString(), {
// method: "GET",
// headers: {
// Accept: "application/vnd.api+json",
// Authorization: `Bearer ${session?.accessToken}`,
// },
// });
// const data = await response.json();
// return parseStringify(data);
// } catch (error) {
// return {
// error: getErrorMessage(error),
// };
// }
// };
// export const revokeInvite = async (formData: FormData) => {
// const session = await auth();
// const keyServer = process.env.API_BASE_URL;
// const invitationId = formData.get("invitationId");
// const url = new URL(`${keyServer}/tenants/invitations/${invitationId}`);
// try {
// const response = await fetch(url.toString(), {
// method: "DELETE",
// headers: {
// Authorization: `Bearer ${session?.accessToken}`,
// },
// });
// const data = await response.json();
// await wait(1000);
// revalidatePath("/invitations");
// return parseStringify(data);
// } catch (error) {
// return {
// error: getErrorMessage(error),
// };
// }
// };
+64
View File
@@ -0,0 +1,64 @@
import { Spacer } from "@nextui-org/react";
import { Suspense } from "react";
import { getRoles } from "@/actions/roles";
import { FilterControls } from "@/components/filters";
import { filterInvitations } from "@/components/filters/data-filters";
import { AddRoleButton } from "@/components/roles";
import { ColumnsRoles } from "@/components/roles/table";
import { SkeletonTableRoles } from "@/components/roles/table";
import { Header } from "@/components/ui";
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
import { SearchParamsProps } from "@/types";
export default async function Roles({
searchParams,
}: {
searchParams: SearchParamsProps;
}) {
const searchParamsKey = JSON.stringify(searchParams || {});
return (
<>
<Header title="Roles" icon="mdi:account-key-outline" />
<Spacer y={4} />
<FilterControls search />
<Spacer y={8} />
<AddRoleButton />
<Spacer y={4} />
<DataTableFilterCustom filters={filterInvitations || []} />
<Spacer y={8} />
<Suspense key={searchParamsKey} fallback={<SkeletonTableRoles />}>
<SSRDataTable searchParams={searchParams} />
</Suspense>
</>
);
}
const SSRDataTable = async ({
searchParams,
}: {
searchParams: SearchParamsProps;
}) => {
const page = parseInt(searchParams.page?.toString() || "1", 10);
const sort = searchParams.sort?.toString();
// Extract all filter parameters
const filters = Object.fromEntries(
Object.entries(searchParams).filter(([key]) => key.startsWith("filter[")),
);
// Extract query from filters
const query = (filters["filter[search]"] as string) || "";
const rolesData = await getRoles({ query, page, sort, filters });
return (
<DataTable
columns={ColumnsRoles}
data={rolesData?.data || []}
metadata={rolesData?.meta}
/>
);
};
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { AddIcon } from "../icons";
import { CustomButton } from "../ui/custom";
export const AddRoleButton = () => {
return (
<div className="flex w-full items-center justify-end">
<CustomButton
asLink="/roles/new"
ariaLabel="Add Role"
variant="solid"
color="action"
size="md"
endContent={<AddIcon size={20} />}
>
Add Role
</CustomButton>
</div>
);
};
+1
View File
@@ -0,0 +1 @@
export * from "./add-role-button";
@@ -0,0 +1,95 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DateWithTime } from "@/components/ui/entities";
import { DataTableColumnHeader } from "@/components/ui/table";
import { RolesProps } from "@/types";
const getRoleAttributes = (row: { original: RolesProps["data"][number] }) => {
return row.original.attributes;
};
const getRoleRelationships = (row: {
original: RolesProps["data"][number];
}) => {
return row.original.relationships;
};
export const ColumnsRoles: ColumnDef<RolesProps["data"][number]>[] = [
{
accessorKey: "role",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={"Role"} param="name" />
),
cell: ({ row }) => {
const data = getRoleAttributes(row);
return (
<p className="font-semibold">
{data.name[0].toUpperCase() + data.name.slice(1).toLowerCase()}
</p>
);
},
},
{
accessorKey: "users",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={"Users"} param="users" />
),
cell: ({ row }) => {
const relationships = getRoleRelationships(row);
const count = relationships.users.meta.count;
return (
<p className="font-semibold">
{count === 0
? "No Users"
: `${count} ${count === 1 ? "User" : "Users"}`}
</p>
);
},
},
{
accessorKey: "invitations",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={"Invitations"}
param="invitations"
/>
),
cell: ({ row }) => {
const relationships = getRoleRelationships(row);
return (
<p className="font-semibold">{relationships.invitations.meta.count}</p>
);
},
},
{
accessorKey: "updated_at",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={"Updated At"}
param="updated_at"
/>
),
cell: ({ row }) => {
const { updated_at } = getRoleAttributes(row);
return <DateWithTime dateTime={updated_at} showTime={false} />;
},
},
{
accessorKey: "inserted_at",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={"Added At"}
param="inserted_at"
/>
),
cell: ({ row }) => {
const { inserted_at } = getRoleAttributes(row);
return <DateWithTime dateTime={inserted_at} showTime={false} />;
},
},
];
@@ -0,0 +1,117 @@
"use client";
import {
Button,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownSection,
DropdownTrigger,
} from "@nextui-org/react";
import {
AddNoteBulkIcon,
DeleteDocumentBulkIcon,
EditDocumentBulkIcon,
} from "@nextui-org/shared-icons";
import { Row } from "@tanstack/react-table";
import clsx from "clsx";
import { VerticalDotsIcon } from "@/components/icons";
// import { DeleteForm, EditForm } from "../forms";
interface DataTableRowActionsProps<InvitationProps> {
row: Row<InvitationProps>;
}
const iconClasses =
"text-2xl text-default-500 pointer-events-none flex-shrink-0";
export function DataTableRowActions<InvitationProps>({
row,
}: DataTableRowActionsProps<InvitationProps>) {
// const [isEditOpen, setIsEditOpen] = useState(false);
// const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const invitationId = (row.original as { id: string }).id;
return (
<>
{/* <CustomAlertModal
isOpen={isEditOpen}
onOpenChange={setIsEditOpen}
title="Edit Invitation"
description={"Edit the invitation details"}
>
<EditForm
invitationId={invitationId}
invitationEmail={invitationEmail}
setIsOpen={setIsEditOpen}
/>
</CustomAlertModal> */}
{/* <CustomAlertModal
isOpen={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
title="Are you absolutely sure?"
description="This action cannot be undone. This will permanently revoke your invitation."
>
<DeleteForm invitationId={invitationId} setIsOpen={setIsDeleteOpen} />
</CustomAlertModal> */}
<div className="relative flex items-center justify-end gap-2">
<Dropdown
className="shadow-xl dark:bg-prowler-blue-800"
placement="bottom"
>
<DropdownTrigger>
<Button isIconOnly radius="full" size="sm" variant="light">
<VerticalDotsIcon className="text-default-400" />
</Button>
</DropdownTrigger>
<DropdownMenu
closeOnSelect
aria-label="Actions"
color="default"
variant="flat"
>
<DropdownSection title="Actions">
<DropdownItem
href={`/invitations/check-details?id=${invitationId}`}
key="check-details"
description="View invitation details"
textValue="Check Details"
startContent={<AddNoteBulkIcon className={iconClasses} />}
>
Check Details
</DropdownItem>
<DropdownItem
key="edit"
description="Allows you to edit the invitation"
textValue="Edit Invitation"
startContent={<EditDocumentBulkIcon className={iconClasses} />}
// onClick={() => setIsEditOpen(true)}
>
Edit Invitation
</DropdownItem>
</DropdownSection>
<DropdownSection title="Danger zone">
<DropdownItem
key="delete"
className="text-danger"
color="danger"
description="Delete the invitation permanently"
textValue="Delete Invitation"
startContent={
<DeleteDocumentBulkIcon
className={clsx(iconClasses, "!text-danger")}
/>
}
// onClick={() => setIsDeleteOpen(true)}
>
Revoke Invitation
</DropdownItem>
</DropdownSection>
</DropdownMenu>
</Dropdown>
</div>
</>
);
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./column-roles";
export * from "./data-table-row-actions";
export * from "./skeleton-table-roles";
@@ -0,0 +1,59 @@
import { Card, Skeleton } from "@nextui-org/react";
import React from "react";
export const SkeletonTableRoles = () => {
return (
<Card className="h-full w-full space-y-5 p-4" radius="sm">
{/* Table headers */}
<div className="hidden justify-between md:flex">
<Skeleton className="w-2/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-2/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-2/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-2/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-2/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-1/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
</div>
{/* Table body */}
<div className="space-y-3">
{[...Array(10)].map((_, index) => (
<div
key={index}
className="flex flex-col items-center justify-between space-x-0 md:flex-row md:space-x-4"
>
<Skeleton className="mb-2 w-full rounded-lg md:mb-0 md:w-2/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 w-full rounded-lg md:mb-0 md:w-2/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-1/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
</div>
))}
</div>
</Card>
);
};
+65
View File
@@ -227,6 +227,71 @@ export interface InvitationProps {
self: string;
};
}
export interface RolesProps {
links: {
first: string;
last: string;
next: string | null;
prev: string | null;
};
data: {
type: "role";
id: string;
attributes: {
name: string;
manage_users: boolean;
manage_account: boolean;
manage_billing: boolean;
manage_providers: boolean;
manage_integrations: boolean;
manage_scans: boolean;
unlimited_visibility: boolean;
inserted_at: string;
updated_at: string;
};
relationships: {
provider_groups: {
meta: {
count: number;
};
data: {
type: string;
id: string;
}[];
};
users: {
meta: {
count: number;
};
data: {
type: string;
id: string;
}[];
};
invitations: {
meta: {
count: number;
};
data: {
type: string;
id: string;
}[];
};
};
links: {
self: string;
};
}[];
meta: {
pagination: {
page: number;
pages: number;
count: number;
};
version: string;
};
}
export interface UserProfileProps {
data: {
type: "users";