From 04b9f81e26b0321f38b5ed2e968384de535dbaa8 Mon Sep 17 00:00:00 2001 From: Pablo Lara Date: Tue, 10 Dec 2024 07:24:01 +0100 Subject: [PATCH] feat: add new role feature --- ui/app/(prowler)/roles/(add-role)/layout.tsx | 32 ++ .../(prowler)/roles/(add-role)/new/page.tsx | 7 + ui/components/roles/table/column-roles.tsx | 38 ++- .../roles/workflow/forms/add-role-form.tsx | 139 +++++++++ ui/components/roles/workflow/forms/index.ts | 1 + ui/components/roles/workflow/index.ts | 3 + .../roles/workflow/skeleton-role-form.tsx | 65 ++++ .../roles/workflow/vertical-steps.tsx | 291 ++++++++++++++++++ .../roles/workflow/workflow-add-edit-role.tsx | 64 ++++ ui/types/formSchemas.ts | 11 + 10 files changed, 636 insertions(+), 15 deletions(-) create mode 100644 ui/app/(prowler)/roles/(add-role)/layout.tsx create mode 100644 ui/app/(prowler)/roles/(add-role)/new/page.tsx create mode 100644 ui/components/roles/workflow/forms/add-role-form.tsx create mode 100644 ui/components/roles/workflow/forms/index.ts create mode 100644 ui/components/roles/workflow/index.ts create mode 100644 ui/components/roles/workflow/skeleton-role-form.tsx create mode 100644 ui/components/roles/workflow/vertical-steps.tsx create mode 100644 ui/components/roles/workflow/workflow-add-edit-role.tsx diff --git a/ui/app/(prowler)/roles/(add-role)/layout.tsx b/ui/app/(prowler)/roles/(add-role)/layout.tsx new file mode 100644 index 0000000000..fb96134552 --- /dev/null +++ b/ui/app/(prowler)/roles/(add-role)/layout.tsx @@ -0,0 +1,32 @@ +import "@/styles/globals.css"; + +import { Spacer } from "@nextui-org/react"; +import React from "react"; + +import { WorkflowAddEditRole } from "@/components/roles/workflow"; +import { NavigationHeader } from "@/components/ui"; + +interface RoleLayoutProps { + children: React.ReactNode; +} + +export default function RoleLayout({ children }: RoleLayoutProps) { + return ( + <> + + +
+
+ +
+
+ {children} +
+
+ + ); +} diff --git a/ui/app/(prowler)/roles/(add-role)/new/page.tsx b/ui/app/(prowler)/roles/(add-role)/new/page.tsx new file mode 100644 index 0000000000..d35fa0b719 --- /dev/null +++ b/ui/app/(prowler)/roles/(add-role)/new/page.tsx @@ -0,0 +1,7 @@ +import React from "react"; + +import { AddRoleForm } from "@/components/roles/workflow/forms/add-role-form"; + +export default function AddRolePage() { + return ; +} diff --git a/ui/components/roles/table/column-roles.tsx b/ui/components/roles/table/column-roles.tsx index ba044e05a2..ff1a8a8e03 100644 --- a/ui/components/roles/table/column-roles.tsx +++ b/ui/components/roles/table/column-roles.tsx @@ -60,24 +60,32 @@ export const ColumnsRoles: ColumnDef[] = [ cell: ({ row }) => { const relationships = getRoleRelationships(row); return ( -

{relationships.invitations.meta.count}

+

+ {relationships.invitations.meta.count === 0 + ? "No Invitations" + : `${relationships.invitations.meta.count} ${ + relationships.invitations.meta.count === 1 + ? "Invitation" + : "Invitations" + }`} +

); }, }, - { - accessorKey: "updated_at", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const { updated_at } = getRoleAttributes(row); - return ; - }, - }, + // { + // accessorKey: "updated_at", + // header: ({ column }) => ( + // + // ), + // cell: ({ row }) => { + // const { updated_at } = getRoleAttributes(row); + // return ; + // }, + // }, { accessorKey: "inserted_at", header: ({ column }) => ( diff --git a/ui/components/roles/workflow/forms/add-role-form.tsx b/ui/components/roles/workflow/forms/add-role-form.tsx new file mode 100644 index 0000000000..d1afd99a78 --- /dev/null +++ b/ui/components/roles/workflow/forms/add-role-form.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Checkbox } from "@nextui-org/react"; +import { SaveIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { addRole } from "@/actions/roles/roles"; +import { useToast } from "@/components/ui"; +import { CustomButton, CustomInput } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; +import { addRoleFormSchema, ApiError } from "@/types"; + +export type FormValues = z.infer; + +export const AddRoleForm = () => { + const { toast } = useToast(); + const router = useRouter(); + + const form = useForm({ + resolver: zodResolver(addRoleFormSchema), + defaultValues: { + name: "", + manage_users: false, + manage_account: false, + manage_billing: false, + manage_providers: false, + manage_integrations: false, + manage_scans: false, + unlimited_visibility: false, + }, + }); + + const isLoading = form.formState.isSubmitting; + + const onSubmitClient = async (values: FormValues) => { + const formData = new FormData(); + formData.append("name", values.name); + formData.append("manage_users", String(values.manage_users)); + formData.append("manage_account", String(values.manage_account)); + formData.append("manage_billing", String(values.manage_billing)); + formData.append("manage_providers", String(values.manage_providers)); + formData.append("manage_integrations", String(values.manage_integrations)); + formData.append("manage_scans", String(values.manage_scans)); + formData.append( + "unlimited_visibility", + String(values.unlimited_visibility), + ); + + try { + const data = await addRole(formData); + + if (data?.errors && data.errors.length > 0) { + data.errors.forEach((error: ApiError) => { + const errorMessage = error.detail; + switch (error.source.pointer) { + case "/data/attributes/name": + form.setError("name", { + type: "server", + message: errorMessage, + }); + break; + default: + toast({ + variant: "destructive", + title: "Error", + description: errorMessage, + }); + } + }); + } else { + toast({ + variant: "success", + title: "Role Added", + description: "The role was added successfully.", + }); + router.push("/roles"); + } + } catch (error) { + toast({ + variant: "destructive", + title: "Error", + description: "An unexpected error occurred. Please try again.", + }); + } + }; + + return ( +
+ + + + {[ + "manage_users", + "manage_account", + "manage_billing", + "manage_providers", + "manage_integrations", + "manage_scans", + "unlimited_visibility", + ].map((field) => ( + + {field.replace("_", " ")} + + ))} + +
+ } + > + {isLoading ? <>Loading : Add Role} + +
+ + + ); +}; diff --git a/ui/components/roles/workflow/forms/index.ts b/ui/components/roles/workflow/forms/index.ts new file mode 100644 index 0000000000..c50e093cc0 --- /dev/null +++ b/ui/components/roles/workflow/forms/index.ts @@ -0,0 +1 @@ +export * from "./add-role-form"; diff --git a/ui/components/roles/workflow/index.ts b/ui/components/roles/workflow/index.ts new file mode 100644 index 0000000000..4a4f199ff7 --- /dev/null +++ b/ui/components/roles/workflow/index.ts @@ -0,0 +1,3 @@ +export * from "./skeleton-role-form"; +export * from "./vertical-steps"; +export * from "./workflow-add-edit-role"; diff --git a/ui/components/roles/workflow/skeleton-role-form.tsx b/ui/components/roles/workflow/skeleton-role-form.tsx new file mode 100644 index 0000000000..839efb1ba0 --- /dev/null +++ b/ui/components/roles/workflow/skeleton-role-form.tsx @@ -0,0 +1,65 @@ +import { Card, Skeleton } from "@nextui-org/react"; +import React from "react"; + +export const SkeletonRoleForm = () => { + return ( + + {/* Table headers */} +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + {/* Table body */} +
+ {[...Array(3)].map((_, index) => ( +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ ))} +
+
+ ); +}; diff --git a/ui/components/roles/workflow/vertical-steps.tsx b/ui/components/roles/workflow/vertical-steps.tsx new file mode 100644 index 0000000000..74eaa32747 --- /dev/null +++ b/ui/components/roles/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/ui/components/roles/workflow/workflow-add-edit-role.tsx b/ui/components/roles/workflow/workflow-add-edit-role.tsx new file mode 100644 index 0000000000..319a027c17 --- /dev/null +++ b/ui/components/roles/workflow/workflow-add-edit-role.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: "Create a new role", + description: "Enter the name of the role you want to add.", + href: "/roles/new", + }, + { + title: "Edit a existing role", + description: + "Review the role details and share the information required for the role.", + href: "/roles/edit", + }, +]; + +export const WorkflowAddEditRole = () => { + 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 ( +
+

+ Manage Role Permissions +

+

+ Define a new role with customized permissions or modify an existing one + to meet your needs. +

+ + + +
+ ); +}; diff --git a/ui/types/formSchemas.ts b/ui/types/formSchemas.ts index cc0221add7..fcb725eef0 100644 --- a/ui/types/formSchemas.ts +++ b/ui/types/formSchemas.ts @@ -1,5 +1,16 @@ import { z } from "zod"; +export const addRoleFormSchema = z.object({ + name: z.string().min(1, "Name is required"), + manage_users: z.boolean().default(false), + manage_account: z.boolean().default(false), + manage_billing: z.boolean().default(false), + manage_providers: z.boolean().default(false), + manage_integrations: z.boolean().default(false), + manage_scans: z.boolean().default(false), + unlimited_visibility: z.boolean().default(false), +}); + export const editScanFormSchema = (currentName: string) => z.object({ scanName: z