diff --git a/actions/auth/auth.ts b/actions/auth/auth.ts
index e0de4422b6..4973e3f8e2 100644
--- a/actions/auth/auth.ts
+++ b/actions/auth/auth.ts
@@ -56,6 +56,10 @@ export const createNewUser = async (
const keyServer = process.env.API_BASE_URL;
const url = new URL(`${keyServer}/users`);
+ if (formData.invitationToken) {
+ url.searchParams.append("invitation_token", formData.invitationToken);
+ }
+
const bodyData = {
data: {
type: "User",
@@ -79,7 +83,6 @@ export const createNewUser = async (
});
const parsedResponse = await response.json();
-
if (!response.ok) {
return parsedResponse;
}
diff --git a/actions/invitations/invitation.ts b/actions/invitations/invitation.ts
new file mode 100644
index 0000000000..7fc99ce33c
--- /dev/null
+++ b/actions/invitations/invitation.ts
@@ -0,0 +1,172 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+
+import { auth } from "@/auth.config";
+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();
+ 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: "Invitation",
+ 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: "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;
+ 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),
+ };
+ }
+};
diff --git a/app/(auth)/sign-up/page.tsx b/app/(auth)/sign-up/page.tsx
index 8e525507b8..28149bb200 100644
--- a/app/(auth)/sign-up/page.tsx
+++ b/app/(auth)/sign-up/page.tsx
@@ -1,7 +1,13 @@
import { AuthForm } from "@/components/auth/oss";
+import { SearchParamsProps } from "@/types";
-const SignUp = () => {
- return ;
+const SignUp = ({ searchParams }: { searchParams: SearchParamsProps }) => {
+ const invitationToken =
+ typeof searchParams?.invitation_token === "string"
+ ? searchParams.invitation_token
+ : null;
+
+ return ;
};
export default SignUp;
diff --git a/app/(prowler)/invitations/(send-invite)/check-details/page.tsx b/app/(prowler)/invitations/(send-invite)/check-details/page.tsx
new file mode 100644
index 0000000000..939667cdc1
--- /dev/null
+++ b/app/(prowler)/invitations/(send-invite)/check-details/page.tsx
@@ -0,0 +1,43 @@
+import { Suspense } from "react";
+
+import { getInvitationInfoById } from "@/actions/invitations/invitation";
+import { InvitationDetails } from "@/components/invitations";
+import { SkeletonInvitationInfo } from "@/components/invitations/workflow";
+import { SearchParamsProps } from "@/types";
+
+export default async function CheckDetailsPage({
+ searchParams,
+}: {
+ searchParams: SearchParamsProps;
+}) {
+ const searchParamsKey = JSON.stringify(searchParams || {});
+
+ return (
+ }>
+
+
+ );
+}
+
+const SSRDataInvitation = async ({
+ searchParams,
+}: {
+ searchParams: SearchParamsProps;
+}) => {
+ const invitationId = searchParams.id;
+
+ if (!invitationId) {
+ return
Invalid invitation ID
;
+ }
+
+ const invitationData = (await getInvitationInfoById(invitationId as string))
+ .data;
+
+ if (!invitationData) {
+ return Invitation not found
;
+ }
+
+ const { attributes, links } = invitationData;
+
+ return ;
+};
diff --git a/app/(prowler)/invitations/(send-invite)/layout.tsx b/app/(prowler)/invitations/(send-invite)/layout.tsx
new file mode 100644
index 0000000000..2af2be2af2
--- /dev/null
+++ b/app/(prowler)/invitations/(send-invite)/layout.tsx
@@ -0,0 +1,32 @@
+import "@/styles/globals.css";
+
+import { Spacer } from "@nextui-org/react";
+import React from "react";
+
+import { WorkflowSendInvite } from "@/components/invitations/workflow";
+import { NavigationHeader } from "@/components/ui";
+
+interface InvitationLayoutProps {
+ children: React.ReactNode;
+}
+
+export default function InvitationLayout({ children }: InvitationLayoutProps) {
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/app/(prowler)/invitations/(send-invite)/new/page.tsx b/app/(prowler)/invitations/(send-invite)/new/page.tsx
new file mode 100644
index 0000000000..87a2e33acd
--- /dev/null
+++ b/app/(prowler)/invitations/(send-invite)/new/page.tsx
@@ -0,0 +1,7 @@
+import React from "react";
+
+import { SendInvitationForm } from "@/components/invitations/workflow/forms/send-invitation-form";
+
+export default function SendInvitationPage() {
+ return ;
+}
diff --git a/app/(prowler)/invitations/page.tsx b/app/(prowler)/invitations/page.tsx
new file mode 100644
index 0000000000..b32573cd45
--- /dev/null
+++ b/app/(prowler)/invitations/page.tsx
@@ -0,0 +1,65 @@
+import { Spacer } from "@nextui-org/react";
+import { Suspense } from "react";
+
+import { getInvitations } from "@/actions/invitations/invitation";
+import { FilterControls } from "@/components/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 { SearchParamsProps } from "@/types";
+
+export default async function Invitations({
+ searchParams,
+}: {
+ searchParams: SearchParamsProps;
+}) {
+ const searchParamsKey = JSON.stringify(searchParams || {});
+
+ return (
+ <>
+
+
+
+
+
+
+
+ }>
+
+
+ >
+ );
+}
+
+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 invitationsData = await getInvitations({ query, page, sort, filters });
+
+ return (
+
+ );
+};
diff --git a/app/(prowler)/providers/(set-up-provider)/connect-account/page.tsx b/app/(prowler)/providers/(set-up-provider)/connect-account/page.tsx
index 67094101f5..eead6c2561 100644
--- a/app/(prowler)/providers/(set-up-provider)/connect-account/page.tsx
+++ b/app/(prowler)/providers/(set-up-provider)/connect-account/page.tsx
@@ -1,5 +1,3 @@
-"use client";
-
import React from "react";
import { ConnectAccountForm } from "@/components/providers/workflow/forms";
diff --git a/app/(prowler)/providers/(set-up-provider)/layout.tsx b/app/(prowler)/providers/(set-up-provider)/layout.tsx
index 788ce9cd96..85b78ee687 100644
--- a/app/(prowler)/providers/(set-up-provider)/layout.tsx
+++ b/app/(prowler)/providers/(set-up-provider)/layout.tsx
@@ -3,7 +3,7 @@ import "@/styles/globals.css";
import { Spacer } from "@nextui-org/react";
import React from "react";
-import { Workflow } from "@/components/providers/workflow";
+import { WorkflowAddProvider } from "@/components/providers/workflow";
import { NavigationHeader } from "@/components/ui";
interface ProviderLayoutProps {
@@ -21,7 +21,7 @@ export default function ProviderLayout({ children }: ProviderLayoutProps) {
-
+
{children}
diff --git a/app/(prowler)/users/page.tsx b/app/(prowler)/users/page.tsx
index fdb6f0de03..805c8271e6 100644
--- a/app/(prowler)/users/page.tsx
+++ b/app/(prowler)/users/page.tsx
@@ -6,6 +6,7 @@ import { FilterControls } from "@/components/filters";
import { filterUsers } from "@/components/filters/data-filters";
import { Header } from "@/components/ui";
import { DataTable } from "@/components/ui/table";
+import { AddUserButton } from "@/components/users";
import { ColumnsUser, SkeletonTableUser } from "@/components/users/table";
import { SearchParamsProps } from "@/types";
@@ -22,7 +23,7 @@ export default async function Users({
- {/*
*/}
+
}>
diff --git a/components/auth/oss/auth-form.tsx b/components/auth/oss/auth-form.tsx
index 2afe281f2a..1f1ec4384f 100644
--- a/components/auth/oss/auth-form.tsx
+++ b/components/auth/oss/auth-form.tsx
@@ -20,7 +20,13 @@ import {
} from "@/components/ui/form";
import { ApiError, authFormSchema } from "@/types";
-export const AuthForm = ({ type }: { type: string }) => {
+export const AuthForm = ({
+ type,
+ invitationToken,
+}: {
+ type: string;
+ invitationToken?: string | null;
+}) => {
const formSchema = authFormSchema(type);
const router = useRouter();
@@ -34,6 +40,7 @@ export const AuthForm = ({ type }: { type: string }) => {
company: "",
termsAndConditions: false,
confirmPassword: "",
+ ...(invitationToken && { invitationToken }),
}),
},
});
@@ -96,6 +103,12 @@ export const AuthForm = ({ type }: { type: string }) => {
message: errorMessage,
});
break;
+ case "/data":
+ form.setError("invitationToken", {
+ type: "server",
+ message: errorMessage,
+ });
+ break;
default:
toast({
variant: "destructive",
@@ -186,6 +199,18 @@ export const AuthForm = ({ type }: { type: string }) => {
name="confirmPassword"
confirmPassword
/>
+ {invitationToken && (
+
+ )}
>;
+}) => {
+ 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 (
+
+
+ );
+};
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 (
+
+
+ );
+};
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/index.ts b/components/invitations/index.ts
new file mode 100644
index 0000000000..e381a114df
--- /dev/null
+++ b/components/invitations/index.ts
@@ -0,0 +1,2 @@
+export * from "./invitation-details";
+export * from "./send-invitation-button";
diff --git a/components/invitations/invitation-details.tsx b/components/invitations/invitation-details.tsx
new file mode 100644
index 0000000000..7739cf8adb
--- /dev/null
+++ b/components/invitations/invitation-details.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+import { Card, CardBody, Divider, Snippet } from "@nextui-org/react";
+
+import { AddIcon } from "../icons";
+import { CustomButton } from "../ui/custom";
+import { DateWithTime } from "../ui/entities";
+
+interface InvitationDetailsProps {
+ attributes: {
+ email: string;
+ state: string;
+ token: string;
+ expires_at: string;
+ inserted_at: string;
+ updated_at: string;
+ };
+ relationships?: {
+ inviter: {
+ data: {
+ id: string;
+ };
+ };
+ };
+ selfLink: string;
+}
+
+export const InvitationDetails = ({ attributes }: InvitationDetailsProps) => {
+ const baseURL = process.env.SITE_URL || "http://localhost:3000";
+ const invitationLink = `${baseURL}/sign-up?invitation_token=${attributes.token}`;
+ return (
+
+
+
+
+ Invitation Details
+
+
+
+
+
+ Email:
+ {attributes.email}
+
+
+
+ State:
+ {attributes.state}
+
+
+
+ Token:
+ {attributes.token}
+
+
+
+ Expires At:
+
+
+
+
+ Inserted At:
+
+
+
+
+ Updated At:
+
+
+
+
+
+
+ Share this link with the user:
+
+
+
+
+
+ {invitationLink}
+
+
+
+
+
+
+ }
+ >
+ Back to Invitations
+
+
+
+ );
+};
diff --git a/components/invitations/send-invitation-button.tsx b/components/invitations/send-invitation-button.tsx
new file mode 100644
index 0000000000..ae13d33b1d
--- /dev/null
+++ b/components/invitations/send-invitation-button.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import { AddIcon } from "../icons";
+import { CustomButton } from "../ui/custom";
+
+export const SendInvitationButton = () => {
+ return (
+
+ }
+ >
+ Send Invitation
+
+
+ );
+};
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..2a17c944dc
--- /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)}
+ >
+ Revoke 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/components/invitations/workflow/forms/index.ts b/components/invitations/workflow/forms/index.ts
new file mode 100644
index 0000000000..3180c95b40
--- /dev/null
+++ b/components/invitations/workflow/forms/index.ts
@@ -0,0 +1 @@
+export * from "./send-invitation-form";
diff --git a/components/invitations/workflow/forms/send-invitation-form.tsx b/components/invitations/workflow/forms/send-invitation-form.tsx
new file mode 100644
index 0000000000..a75c6a60c6
--- /dev/null
+++ b/components/invitations/workflow/forms/send-invitation-form.tsx
@@ -0,0 +1,107 @@
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { SaveIcon } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useForm } from "react-hook-form";
+import * as z from "zod";
+
+import { sendInvite } from "@/actions/invitations/invitation";
+import { useToast } from "@/components/ui";
+import { CustomButton, CustomInput } from "@/components/ui/custom";
+import { Form } from "@/components/ui/form";
+import { ApiError } from "@/types";
+
+const sendInvitationFormSchema = z.object({
+ email: z.string().email("Please enter a valid email"),
+});
+
+export type FormValues = z.infer;
+
+export const SendInvitationForm = () => {
+ const { toast } = useToast();
+ const router = useRouter();
+
+ const form = useForm({
+ resolver: zodResolver(sendInvitationFormSchema),
+ defaultValues: {
+ email: "",
+ },
+ });
+
+ const isLoading = form.formState.isSubmitting;
+
+ const onSubmitClient = async (values: FormValues) => {
+ const formData = new FormData();
+ formData.append("email", values.email);
+
+ try {
+ const data = await sendInvite(formData);
+
+ if (data?.errors && data.errors.length > 0) {
+ data.errors.forEach((error: ApiError) => {
+ const errorMessage = error.detail;
+ switch (error.source.pointer) {
+ case "/data/attributes/email":
+ form.setError("email", {
+ type: "server",
+ message: errorMessage,
+ });
+ break;
+ default:
+ toast({
+ variant: "destructive",
+ title: "Oops! Something went wrong",
+ description: errorMessage,
+ });
+ }
+ });
+ } else {
+ const invitationId = data?.data?.id || "";
+ router.push(`/invitations/check-details/?id=${invitationId}`);
+ }
+ } catch (error) {
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: "An unexpected error occurred. Please try again.",
+ });
+ }
+ };
+
+ return (
+
+
+ );
+};
diff --git a/components/invitations/workflow/index.ts b/components/invitations/workflow/index.ts
new file mode 100644
index 0000000000..a9a860f213
--- /dev/null
+++ b/components/invitations/workflow/index.ts
@@ -0,0 +1,3 @@
+export * from "./skeleton-invitation-info";
+export * from "./vertical-steps";
+export * from "./workflow-send-invite";
diff --git a/components/invitations/workflow/skeleton-invitation-info.tsx b/components/invitations/workflow/skeleton-invitation-info.tsx
new file mode 100644
index 0000000000..12abeb9da7
--- /dev/null
+++ b/components/invitations/workflow/skeleton-invitation-info.tsx
@@ -0,0 +1,65 @@
+import { Card, Skeleton } from "@nextui-org/react";
+import React from "react";
+
+export const SkeletonInvitationInfo = () => {
+ return (
+
+ {/* Table headers */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Table body */}
+
+ {[...Array(3)].map((_, index) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/components/invitations/workflow/vertical-steps.tsx b/components/invitations/workflow/vertical-steps.tsx
new file mode 100644
index 0000000000..74eaa32747
--- /dev/null
+++ b/components/invitations/workflow/vertical-steps.tsx
@@ -0,0 +1,291 @@
+"use client";
+
+import type { ButtonProps } from "@nextui-org/react";
+import { cn } from "@nextui-org/react";
+import { useControlledState } from "@react-stately/utils";
+import { domAnimation, LazyMotion, m } from "framer-motion";
+import type { ComponentProps } from "react";
+import React from "react";
+
+export type VerticalStepProps = {
+ className?: string;
+ description?: React.ReactNode;
+ title?: React.ReactNode;
+};
+
+export interface VerticalStepsProps
+ extends React.HTMLAttributes {
+ /**
+ * An array of steps.
+ *
+ * @default []
+ */
+ steps?: VerticalStepProps[];
+ /**
+ * The color of the steps.
+ *
+ * @default "primary"
+ */
+ color?: ButtonProps["color"];
+ /**
+ * The current step index.
+ */
+ currentStep?: number;
+ /**
+ * The default step index.
+ *
+ * @default 0
+ */
+ defaultStep?: number;
+ /**
+ * Whether to hide the progress bars.
+ *
+ * @default false
+ */
+ hideProgressBars?: boolean;
+ /**
+ * The custom class for the steps wrapper.
+ */
+ className?: string;
+ /**
+ * The custom class for the step.
+ */
+ stepClassName?: string;
+ /**
+ * Callback function when the step index changes.
+ */
+ onStepChange?: (stepIndex: number) => void;
+}
+
+function CheckIcon(props: ComponentProps<"svg">) {
+ return (
+
+ );
+}
+
+export const VerticalSteps = React.forwardRef<
+ HTMLButtonElement,
+ VerticalStepsProps
+>(
+ (
+ {
+ color = "primary",
+ steps = [],
+ defaultStep = 0,
+ onStepChange,
+ currentStep: currentStepProp,
+ hideProgressBars = false,
+ stepClassName,
+ className,
+ ...props
+ },
+ ref,
+ ) => {
+ const [currentStep, setCurrentStep] = useControlledState(
+ currentStepProp,
+ defaultStep,
+ onStepChange,
+ );
+
+ const colors = React.useMemo(() => {
+ let userColor;
+ let fgColor;
+
+ const colorsVars = [
+ "[--active-fg-color:var(--step-fg-color)]",
+ "[--active-border-color:var(--step-color)]",
+ "[--active-color:var(--step-color)]",
+ "[--complete-background-color:var(--step-color)]",
+ "[--complete-border-color:var(--step-color)]",
+ "[--inactive-border-color:hsl(var(--nextui-default-300))]",
+ "[--inactive-color:hsl(var(--nextui-default-300))]",
+ ];
+
+ switch (color) {
+ case "primary":
+ userColor = "[--step-color:hsl(var(--nextui-primary))]";
+ fgColor = "[--step-fg-color:hsl(var(--nextui-primary-foreground))]";
+ break;
+ case "secondary":
+ userColor = "[--step-color:hsl(var(--nextui-secondary))]";
+ fgColor = "[--step-fg-color:hsl(var(--nextui-secondary-foreground))]";
+ break;
+ case "success":
+ userColor = "[--step-color:hsl(var(--nextui-success))]";
+ fgColor = "[--step-fg-color:hsl(var(--nextui-success-foreground))]";
+ break;
+ case "warning":
+ userColor = "[--step-color:hsl(var(--nextui-warning))]";
+ fgColor = "[--step-fg-color:hsl(var(--nextui-warning-foreground))]";
+ break;
+ case "danger":
+ userColor = "[--step-color:hsl(var(--nextui-error))]";
+ fgColor = "[--step-fg-color:hsl(var(--nextui-error-foreground))]";
+ break;
+ case "default":
+ userColor = "[--step-color:hsl(var(--nextui-default))]";
+ fgColor = "[--step-fg-color:hsl(var(--nextui-default-foreground))]";
+ break;
+ default:
+ userColor = "[--step-color:hsl(var(--nextui-primary))]";
+ fgColor = "[--step-fg-color:hsl(var(--nextui-primary-foreground))]";
+ break;
+ }
+
+ if (!className?.includes("--step-fg-color")) colorsVars.unshift(fgColor);
+ if (!className?.includes("--step-color")) colorsVars.unshift(userColor);
+ if (!className?.includes("--inactive-bar-color"))
+ colorsVars.push(
+ "[--inactive-bar-color:hsl(var(--nextui-default-300))]",
+ );
+
+ return colorsVars;
+ }, [color, className]);
+
+ return (
+
+ );
+ },
+);
+
+VerticalSteps.displayName = "VerticalSteps";
diff --git a/components/invitations/workflow/workflow-send-invite.tsx b/components/invitations/workflow/workflow-send-invite.tsx
new file mode 100644
index 0000000000..cf87d04f06
--- /dev/null
+++ b/components/invitations/workflow/workflow-send-invite.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { Progress, Spacer } from "@nextui-org/react";
+import { usePathname } from "next/navigation";
+import React from "react";
+
+import { VerticalSteps } from "./vertical-steps";
+
+const steps = [
+ {
+ title: "Send Invitation",
+ description:
+ "Enter the email address of the person you want to invite and send the invitation.",
+ href: "/invitations/new",
+ },
+ {
+ title: "Review Invitation Details",
+ description:
+ "Review the invitation details and share the information required for the person to accept the invitation.",
+ href: "/invitations/check-details",
+ },
+];
+
+export const WorkflowSendInvite = () => {
+ const pathname = usePathname();
+
+ // Calculate current step based on pathname
+ const currentStepIndex = steps.findIndex((step) =>
+ pathname.endsWith(step.href),
+ );
+ const currentStep = currentStepIndex === -1 ? 0 : currentStepIndex;
+
+ return (
+
+
+ Send invitation
+
+
+ Follow the steps to send an invitation to the users.
+
+
+
+
+
+ );
+};
diff --git a/components/providers/workflow/index.ts b/components/providers/workflow/index.ts
index f245196eab..fca9e92617 100644
--- a/components/providers/workflow/index.ts
+++ b/components/providers/workflow/index.ts
@@ -1,2 +1,2 @@
export * from "./vertical-steps";
-export * from "./workflow";
+export * from "./workflow-add-provider";
diff --git a/components/providers/workflow/workflow.tsx b/components/providers/workflow/workflow-add-provider.tsx
similarity index 98%
rename from components/providers/workflow/workflow.tsx
rename to components/providers/workflow/workflow-add-provider.tsx
index da5fa18f99..69bc885638 100644
--- a/components/providers/workflow/workflow.tsx
+++ b/components/providers/workflow/workflow-add-provider.tsx
@@ -32,7 +32,7 @@ const steps = [
},
];
-export const Workflow = () => {
+export const WorkflowAddProvider = () => {
const pathname = usePathname();
// Calculate current step based on pathname
diff --git a/components/ui/custom/custom-input.tsx b/components/ui/custom/custom-input.tsx
index a391142344..2b390b83c0 100644
--- a/components/ui/custom/custom-input.tsx
+++ b/components/ui/custom/custom-input.tsx
@@ -18,6 +18,7 @@ interface CustomInputProps {
placeholder?: string;
password?: boolean;
confirmPassword?: boolean;
+ defaultValue?: string;
isRequired?: boolean;
isInvalid?: boolean;
}
@@ -33,6 +34,7 @@ export const CustomInput = ({
size = "md",
confirmPassword = false,
password = false,
+ defaultValue,
isRequired = true,
isInvalid,
}: CustomInputProps) => {
@@ -99,6 +101,7 @@ export const CustomInput = ({
variant={variant}
size={size}
isInvalid={isInvalid}
+ defaultValue={defaultValue}
endContent={endContent}
{...field}
/>
diff --git a/components/ui/sidebar/sidebar-items.tsx b/components/ui/sidebar/sidebar-items.tsx
index 32043aadc9..a4fae9eab4 100644
--- a/components/ui/sidebar/sidebar-items.tsx
+++ b/components/ui/sidebar/sidebar-items.tsx
@@ -29,10 +29,10 @@ export const items: SidebarItem[] = [
),
},
{
- key: "tasks",
+ key: "invitations",
href: "#",
icon: "solar:checklist-minimalistic-outline",
- title: "Tasks",
+ title: "Invitations",
endContent: (
,
- // },
],
},
];
diff --git a/components/users/add-user-button.tsx b/components/users/add-user-button.tsx
new file mode 100644
index 0000000000..b4a63fc7a7
--- /dev/null
+++ b/components/users/add-user-button.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import { AddIcon } from "../icons";
+import { CustomButton } from "../ui/custom";
+
+export const AddUserButton = () => {
+ return (
+
+ }
+ >
+ Invite User
+
+
+ );
+};
diff --git a/components/users/index.ts b/components/users/index.ts
new file mode 100644
index 0000000000..014350e9a6
--- /dev/null
+++ b/components/users/index.ts
@@ -0,0 +1 @@
+export * from "./add-user-button";
diff --git a/types/authFormSchema.ts b/types/authFormSchema.ts
index 6f3ab5a087..a7219a69fa 100644
--- a/types/authFormSchema.ts
+++ b/types/authFormSchema.ts
@@ -21,6 +21,8 @@ export const authFormSchema = (type: string) =>
: z.string().min(12, {
message: "It must contain at least 12 characters.",
}),
+ invitationToken:
+ type === "sign-in" ? z.string().optional() : z.string().optional(),
termsAndConditions:
type === "sign-in"
? z.boolean().optional()
diff --git a/types/components.ts b/types/components.ts
index d753bac9d9..2299d0bffe 100644
--- a/types/components.ts
+++ b/types/components.ts
@@ -101,6 +101,30 @@ export interface ApiError {
code: string;
}
+export interface InvitationProps {
+ type: "Invitation";
+ id: string;
+ attributes: {
+ inserted_at: string;
+ updated_at: string;
+ email: string;
+ state: string;
+ token: string;
+ expires_at: string;
+ };
+ relationships: {
+ inviter: {
+ data: {
+ type: "User";
+ id: string;
+ };
+ };
+ };
+ links: {
+ self: string;
+ };
+}
+
export interface UserProps {
type: "User";
id: string;
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,