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 ( + <> + + +
+
+ +
+
+ {children} +
+
+ + ); +} 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 ( +
+ + +
+ setIsOpen(false)} + isDisabled={isLoading} + > + Cancel + + + } + > + {isLoading ? <>Loading : Revoke} + +
+
+ + ); +}; 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/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 ( +
+ + + +
+ } + > + {isLoading ? <>Loading : Send Invitation} + +
+ + + ); +}; 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,