diff --git a/actions/invitations/invitation.ts b/actions/invitations/invitation.ts index 1a11900422..7fc99ce33c 100644 --- a/actions/invitations/invitation.ts +++ b/actions/invitations/invitation.ts @@ -1,7 +1,51 @@ "use server"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; + import { auth } from "@/auth.config"; -import { getErrorMessage, parseStringify } from "@/lib"; +import { getErrorMessage, parseStringify, wait } from "@/lib"; + +export const getInvitations = async ({ + page = 1, + query = "", + sort = "", + filters = {}, +}) => { + const session = await auth(); + + if (isNaN(Number(page)) || page < 1) redirect("/invitations"); + + const keyServer = process.env.API_BASE_URL; + const url = new URL(`${keyServer}/tenants/invitations`); + + 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("/invitations"); + return parsedData; + } catch (error) { + console.error("Error fetching invitations:", error); + return undefined; + } +}; export const sendInvite = async (formData: FormData) => { const session = await auth(); @@ -40,6 +84,46 @@ export const sendInvite = async (formData: FormData) => { } }; +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: "Invitation", + id: invitationId, + attributes: { + email: invitationEmail, + ...(expiresAt && { expires_at: expiresAt }), + }, + }, + }), + }); + const data = await response.json(); + revalidatePath("/invitations"); + return parseStringify(data); + } catch (error) { + 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; @@ -62,3 +146,27 @@ export const getInvitationInfoById = async (invitationId: string) => { }; } }; + +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), + }; + } +}; diff --git a/app/(prowler)/invitations/page.tsx b/app/(prowler)/invitations/page.tsx index 6f0df7bc4b..b32573cd45 100644 --- a/app/(prowler)/invitations/page.tsx +++ b/app/(prowler)/invitations/page.tsx @@ -1,13 +1,16 @@ import { Spacer } from "@nextui-org/react"; import { Suspense } from "react"; -import { getUsers } from "@/actions/users/users"; +import { getInvitations } from "@/actions/invitations/invitation"; import { FilterControls } from "@/components/filters"; -import { filterUsers } from "@/components/filters/data-filters"; +import { filterInvitations } from "@/components/filters/data-filters"; import { SendInvitationButton } from "@/components/invitations"; +import { + ColumnsInvitation, + SkeletonTableInvitation, +} from "@/components/invitations/table"; import { Header } from "@/components/ui"; import { DataTable } from "@/components/ui/table"; -import { ColumnsUser, SkeletonTableUser } from "@/components/users/table"; import { SearchParamsProps } from "@/types"; export default async function Invitations({ @@ -26,7 +29,7 @@ export default async function Invitations({ - }> + }> @@ -49,14 +52,14 @@ const SSRDataTable = async ({ // Extract query from filters const query = (filters["filter[search]"] as string) || ""; - const usersData = await getUsers({ query, page, sort, filters }); + const invitationsData = await getInvitations({ query, page, sort, filters }); return ( ); }; diff --git a/components/filters/data-filters.ts b/components/filters/data-filters.ts index 21691b2c5c..91a039605d 100644 --- a/components/filters/data-filters.ts +++ b/components/filters/data-filters.ts @@ -59,3 +59,11 @@ export const filterUsers = [ values: ["true", "false"], }, ]; + +export const filterInvitations = [ + { + key: "state", + labelCheckboxGroup: "State", + values: ["pending", "accepted", "expired", "revoked"], + }, +]; diff --git a/components/invitations/forms/delete-form.tsx b/components/invitations/forms/delete-form.tsx new file mode 100644 index 0000000000..35f81bbaad --- /dev/null +++ b/components/invitations/forms/delete-form.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import React, { Dispatch, SetStateAction } from "react"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +import { revokeInvite } from "@/actions/invitations/invitation"; +import { DeleteIcon } from "@/components/icons"; +import { useToast } from "@/components/ui"; +import { CustomButton } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; + +const formSchema = z.object({ + invitationId: z.string(), +}); + +export const DeleteForm = ({ + invitationId, + setIsOpen, +}: { + invitationId: string; + setIsOpen: Dispatch>; +}) => { + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + invitationId, + }, + }); + const { toast } = useToast(); + const isLoading = form.formState.isSubmitting; + + async function onSubmitClient(values: z.infer) { + const formData = new FormData(); + + Object.entries(values).forEach( + ([key, value]) => value !== undefined && formData.append(key, value), + ); + // client-side validation + const data = await revokeInvite(formData); + + if (data?.errors && data.errors.length > 0) { + const error = data.errors[0]; + const errorMessage = `${error.detail}`; + // show error + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } else { + toast({ + title: "Success!", + description: "The invitation was revoked successfully.", + }); + } + setIsOpen(false); // Close the modal on success + } + + return ( +
+ + +
+ setIsOpen(false)} + isDisabled={isLoading} + > + Cancel + + + } + > + {isLoading ? <>Loading : Delete} + +
+
+ + ); +}; diff --git a/components/invitations/forms/edit-form.tsx b/components/invitations/forms/edit-form.tsx new file mode 100644 index 0000000000..17924cea97 --- /dev/null +++ b/components/invitations/forms/edit-form.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Dispatch, SetStateAction } from "react"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +import { updateInvite } from "@/actions/invitations/invitation"; +import { SaveIcon } from "@/components/icons"; +import { useToast } from "@/components/ui"; +import { CustomButton, CustomInput } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; +import { editInviteFormSchema } from "@/types"; + +export const EditForm = ({ + invitationId, + invitationEmail, + setIsOpen, +}: { + invitationId: string; + invitationEmail?: string; + setIsOpen: Dispatch>; +}) => { + const formSchema = editInviteFormSchema; + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + invitationId, + invitationEmail: invitationEmail, + }, + }); + + const { toast } = useToast(); + + const isLoading = form.formState.isSubmitting; + + const onSubmitClient = async (values: z.infer) => { + const formData = new FormData(); + console.log(values); + + Object.entries(values).forEach( + ([key, value]) => value !== undefined && formData.append(key, value), + ); + + const data = await updateInvite(formData); + + if (data?.error) { + const errorMessage = `${data.error}`; + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } else { + toast({ + title: "Success!", + description: "The invitation was updated successfully.", + }); + setIsOpen(false); // Close the modal on success + } + }; + + return ( +
+ +
+ Current email: {invitationEmail} +
+
+ +
+ + +
+ setIsOpen(false)} + isDisabled={isLoading} + > + Cancel + + + } + > + {isLoading ? <>Loading : Save} + +
+
+ + ); +}; diff --git a/components/invitations/forms/index.ts b/components/invitations/forms/index.ts new file mode 100644 index 0000000000..a081952ade --- /dev/null +++ b/components/invitations/forms/index.ts @@ -0,0 +1,2 @@ +export * from "./delete-form"; +export * from "./edit-form"; diff --git a/components/invitations/table/column-invitations.tsx b/components/invitations/table/column-invitations.tsx new file mode 100644 index 0000000000..9e30eb8024 --- /dev/null +++ b/components/invitations/table/column-invitations.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; + +import { DateWithTime } from "@/components/ui/entities"; +import { DataTableColumnHeader } from "@/components/ui/table"; +import { InvitationProps } from "@/types"; + +import { DataTableRowActions } from "./data-table-row-actions"; + +const getInvitationData = (row: { original: InvitationProps }) => { + return row.original.attributes; +}; + +export const ColumnsInvitation: ColumnDef[] = [ + { + accessorKey: "email", + header: () =>
Email
, + cell: ({ row }) => { + const data = getInvitationData(row); + return

{data?.email || "N/A"}

; + }, + }, + { + accessorKey: "state", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const { state } = getInvitationData(row); + return

{state}

; + }, + }, + { + accessorKey: "inserted_at", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const { inserted_at } = getInvitationData(row); + return ; + }, + }, + + { + accessorKey: "expires_at", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const { expires_at } = getInvitationData(row); + return ; + }, + }, + + { + accessorKey: "actions", + header: () =>
Actions
, + id: "actions", + cell: ({ row }) => { + return ; + }, + }, +]; diff --git a/components/invitations/table/data-table-row-actions.tsx b/components/invitations/table/data-table-row-actions.tsx new file mode 100644 index 0000000000..580ffaeb0c --- /dev/null +++ b/components/invitations/table/data-table-row-actions.tsx @@ -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 { useState } from "react"; + +import { VerticalDotsIcon } from "@/components/icons"; +import { CustomAlertModal } from "@/components/ui/custom"; + +import { DeleteForm, EditForm } from "../forms"; + +interface DataTableRowActionsProps { + row: Row; +} +const iconClasses = + "text-2xl text-default-500 pointer-events-none flex-shrink-0"; + +export function DataTableRowActions({ + row, +}: DataTableRowActionsProps) { + const [isEditOpen, setIsEditOpen] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const invitationId = (row.original as { id: string }).id; + const invitationEmail = (row.original as any).attributes?.email; + return ( + <> + + + + + + + +
+ + + + + + + } + > + Check Details + + + } + onClick={() => setIsEditOpen(true)} + > + Edit Invitation + + + + + } + onClick={() => setIsDeleteOpen(true)} + > + Delete Invitation + + + + +
+ + ); +} diff --git a/components/invitations/table/index.ts b/components/invitations/table/index.ts new file mode 100644 index 0000000000..177edf2bb9 --- /dev/null +++ b/components/invitations/table/index.ts @@ -0,0 +1,3 @@ +export * from "./column-invitations"; +export * from "./data-table-row-actions"; +export * from "./skeleton-table-invitations"; diff --git a/components/invitations/table/skeleton-table-invitations.tsx b/components/invitations/table/skeleton-table-invitations.tsx new file mode 100644 index 0000000000..6d18313fe4 --- /dev/null +++ b/components/invitations/table/skeleton-table-invitations.tsx @@ -0,0 +1,59 @@ +import { Card, Skeleton } from "@nextui-org/react"; +import React from "react"; + +export const SkeletonTableInvitation = () => { + return ( + + {/* Table headers */} +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + {/* Table body */} +
+ {[...Array(10)].map((_, index) => ( +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ ))} +
+
+ ); +}; diff --git a/types/formSchemas.ts b/types/formSchemas.ts index 333064cfa4..4238df3ef2 100644 --- a/types/formSchemas.ts +++ b/types/formSchemas.ts @@ -151,6 +151,12 @@ export const editProviderFormSchema = (currentAlias: string) => providerId: z.string(), }); +export const editInviteFormSchema = z.object({ + invitationId: z.string().uuid(), + invitationEmail: z.string().email(), + expires_at: z.string().optional(), +}); + export const editUserFormSchema = ( currentName: string, currentEmail: string,