From 130fddae1e4af1970eff6331b2ecbeb49c7bbd08 Mon Sep 17 00:00:00 2001 From: Pablo Lara Date: Tue, 10 Dec 2024 09:08:25 +0100 Subject: [PATCH] feat: edit role feature --- ui/actions/roles/roles.ts | 223 +++++++++--------- .../(prowler)/roles/(add-role)/edit/page.tsx | 17 ++ ui/components/roles/table/column-roles.tsx | 10 + .../roles/table/data-table-row-actions.tsx | 66 ++---- .../roles/workflow/forms/add-role-form.tsx | 88 +++++-- .../roles/workflow/forms/delete-role-form.tsx | 87 +++++++ .../roles/workflow/forms/edit-role-form.tsx | 206 ++++++++++++++++ ui/components/roles/workflow/forms/index.ts | 2 + .../roles/workflow/workflow-add-edit-role.tsx | 2 +- ui/types/formSchemas.ts | 12 + 10 files changed, 537 insertions(+), 176 deletions(-) create mode 100644 ui/app/(prowler)/roles/(add-role)/edit/page.tsx create mode 100644 ui/components/roles/workflow/forms/delete-role-form.tsx create mode 100644 ui/components/roles/workflow/forms/edit-role-form.tsx diff --git a/ui/actions/roles/roles.ts b/ui/actions/roles/roles.ts index 39aac321eb..2c1c26abd0 100644 --- a/ui/actions/roles/roles.ts +++ b/ui/actions/roles/roles.ts @@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { auth } from "@/auth.config"; -import { parseStringify } from "@/lib"; +import { getErrorMessage, parseStringify } from "@/lib"; export const getRoles = async ({ page = 1, @@ -48,127 +48,118 @@ export const getRoles = async ({ } }; -// export const sendInvite = async (formData: FormData) => { -// const session = await auth(); -// const keyServer = process.env.API_BASE_URL; +export const addRole = 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 url = new URL(`${keyServer}/roles`); + const body = JSON.stringify({ + data: { + type: "role", + attributes: { + name: formData.get("name"), + manage_users: formData.get("manage_users") === "true", + manage_account: formData.get("manage_account") === "true", + manage_billing: formData.get("manage_billing") === "true", + manage_providers: formData.get("manage_providers") === "true", + manage_integrations: formData.get("manage_integrations") === "true", + manage_scans: formData.get("manage_scans") === "true", + unlimited_visibility: formData.get("unlimited_visibility") === "true", + }, + relationships: { + provider_groups: { + data: [], + }, + }, + }, + }); -// const body = JSON.stringify({ -// data: { -// type: "invitations", -// attributes: { -// email, -// }, -// relationships: {}, -// }, -// }); + try { + const response = await fetch(url.toString(), { + method: "POST", + headers: { + "Content-Type": "application/vnd.api+json", + Accept: "application/vnd.api+json", + Authorization: `Bearer ${session?.accessToken}`, + }, + body, + }); + const data = await response.json(); + revalidatePath("/roles"); + return data; + } catch (error) { + return { + error: getErrorMessage(error), + }; + } +}; -// 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(); +export const updateRole = async (formData: FormData, roleId: string) => { + const session = await auth(); + const keyServer = process.env.API_BASE_URL; -// return parseStringify(data); -// } catch (error) { -// return { -// error: getErrorMessage(error), -// }; -// } -// }; + const url = new URL(`${keyServer}/roles/${roleId}`); + const body = JSON.stringify({ + data: { + type: "role", + id: roleId, + attributes: { + name: formData.get("name"), + manage_users: formData.get("manage_users") === "true", + manage_account: formData.get("manage_account") === "true", + manage_billing: formData.get("manage_billing") === "true", + manage_providers: formData.get("manage_providers") === "true", + manage_integrations: formData.get("manage_integrations") === "true", + manage_scans: formData.get("manage_scans") === "true", + unlimited_visibility: formData.get("unlimited_visibility") === "true", + }, + }, + }); -// export const updateInvite = async (formData: FormData) => { -// const session = await auth(); -// const keyServer = process.env.API_BASE_URL; + 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, + }); + const data = await response.json(); + revalidatePath("/roles"); + return data; + } catch (error) { + return { + error: getErrorMessage(error), + }; + } +}; -// const invitationId = formData.get("invitationId"); -// const invitationEmail = formData.get("invitationEmail"); -// const expiresAt = formData.get("expires_at"); +export const deleteRole = async (roleId: string) => { + const session = await auth(); + const keyServer = process.env.API_BASE_URL; -// const url = new URL(`${keyServer}/tenants/invitations/${invitationId}`); + const url = new URL(`${keyServer}/roles/${roleId}`); + try { + const response = await fetch(url.toString(), { + method: "DELETE", + headers: { + Authorization: `Bearer ${session?.accessToken}`, + }, + }); -// try { -// const response = await fetch(url.toString(), { -// method: "PATCH", -// headers: { -// "Content-Type": "application/vnd.api+json", -// Accept: "application/vnd.api+json", -// Authorization: `Bearer ${session?.accessToken}`, -// }, -// body: JSON.stringify({ -// data: { -// type: "invitations", -// id: invitationId, -// attributes: { -// email: invitationEmail, -// ...(expiresAt && { expires_at: expiresAt }), -// }, -// }, -// }), -// }); -// const data = await response.json(); -// revalidatePath("/invitations"); -// return parseStringify(data); -// } catch (error) { -// // eslint-disable-next-line no-console -// console.error("Error updating invitation:", error); -// return { -// error: getErrorMessage(error), -// }; -// } -// }; + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData?.message || "Failed to delete the role"); + } -// 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), -// }; -// } -// }; + const data = await response.json(); + revalidatePath("/roles"); + return data; + } catch (error) { + return { + error: getErrorMessage(error), + }; + } +}; diff --git a/ui/app/(prowler)/roles/(add-role)/edit/page.tsx b/ui/app/(prowler)/roles/(add-role)/edit/page.tsx new file mode 100644 index 0000000000..34dbd7857c --- /dev/null +++ b/ui/app/(prowler)/roles/(add-role)/edit/page.tsx @@ -0,0 +1,17 @@ +import { redirect } from "next/navigation"; +import React from "react"; + +import { EditRoleForm } from "@/components/roles/workflow/forms/edit-role-form"; +import { SearchParamsProps } from "@/types"; + +export default function EditRolePage({ + searchParams, +}: { + searchParams: SearchParamsProps; +}) { + if (!searchParams.roleId || Array.isArray(searchParams.roleId)) { + redirect("/roles"); + } + + return ; +} diff --git a/ui/components/roles/table/column-roles.tsx b/ui/components/roles/table/column-roles.tsx index ff1a8a8e03..28d886e2fb 100644 --- a/ui/components/roles/table/column-roles.tsx +++ b/ui/components/roles/table/column-roles.tsx @@ -6,6 +6,8 @@ import { DateWithTime } from "@/components/ui/entities"; import { DataTableColumnHeader } from "@/components/ui/table"; import { RolesProps } from "@/types"; +import { DataTableRowActions } from "./data-table-row-actions"; + const getRoleAttributes = (row: { original: RolesProps["data"][number] }) => { return row.original.attributes; }; @@ -100,4 +102,12 @@ export const ColumnsRoles: ColumnDef[] = [ return ; }, }, + { + accessorKey: "actions", + header: () =>
Actions
, + id: "actions", + cell: ({ row }) => { + return ; + }, + }, ]; diff --git a/ui/components/roles/table/data-table-row-actions.tsx b/ui/components/roles/table/data-table-row-actions.tsx index 4c7280cbfc..ff9d98884b 100644 --- a/ui/components/roles/table/data-table-row-actions.tsx +++ b/ui/components/roles/table/data-table-row-actions.tsx @@ -9,52 +9,38 @@ import { 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/custom-alert-modal"; -// import { DeleteForm, EditForm } from "../forms"; - -interface DataTableRowActionsProps { - row: Row; +import { DeleteRoleForm } from "../workflow/forms"; +interface DataTableRowActionsProps { + row: Row; } const iconClasses = "text-2xl text-default-500 pointer-events-none flex-shrink-0"; -export function DataTableRowActions({ +export function DataTableRowActions({ row, -}: DataTableRowActionsProps) { - // const [isEditOpen, setIsEditOpen] = useState(false); - // const [isDeleteOpen, setIsDeleteOpen] = useState(false); - const invitationId = (row.original as { id: string }).id; +}: DataTableRowActionsProps) { + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const roleId = (row.original as { id: string }).id; return ( <> - {/* - - */} - {/* - - */} - + +
({ > } - > - Check Details - - - } - // onClick={() => setIsEditOpen(true)} > - Edit Invitation + Edit Role @@ -97,16 +73,16 @@ export function DataTableRowActions({ key="delete" className="text-danger" color="danger" - description="Delete the invitation permanently" - textValue="Delete Invitation" + description="Delete the role permanently" + textValue="Delete Role" startContent={ } - // onClick={() => setIsDeleteOpen(true)} + onClick={() => setIsDeleteOpen(true)} > - Revoke Invitation + Delete Role diff --git a/ui/components/roles/workflow/forms/add-role-form.tsx b/ui/components/roles/workflow/forms/add-role-form.tsx index d1afd99a78..49533122b4 100644 --- a/ui/components/roles/workflow/forms/add-role-form.tsx +++ b/ui/components/roles/workflow/forms/add-role-form.tsx @@ -4,6 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Checkbox } from "@nextui-org/react"; import { SaveIcon } from "lucide-react"; import { useRouter } from "next/navigation"; +import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -33,8 +34,39 @@ export const AddRoleForm = () => { }, }); + const manageProviders = form.watch("manage_providers"); + + useEffect(() => { + if (manageProviders) { + form.setValue("unlimited_visibility", true, { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }); + } + }, [manageProviders, form]); + const isLoading = form.formState.isSubmitting; + const onSelectAllChange = (checked: boolean) => { + const permissions = [ + "manage_users", + "manage_account", + "manage_billing", + "manage_providers", + "manage_integrations", + "manage_scans", + "unlimited_visibility", + ]; + permissions.forEach((permission) => { + form.setValue(permission as keyof FormValues, checked, { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }); + }); + }; + const onSubmitClient = async (values: FormValues) => { const formData = new FormData(); formData.append("name", values.name); @@ -72,7 +104,6 @@ export const AddRoleForm = () => { }); } else { toast({ - variant: "success", title: "Role Added", description: "The role was added successfully.", }); @@ -87,11 +118,21 @@ export const AddRoleForm = () => { } }; + const permissions = [ + { field: "manage_users", label: "Invite and Manage Users" }, + { field: "manage_account", label: "Manage SaaS Account" }, + { field: "manage_billing", label: "Manage Billing" }, + { field: "manage_providers", label: "Manage Cloud Accounts" }, + { field: "manage_integrations", label: "Manage Integrations" }, + { field: "manage_scans", label: "Manage Scans" }, + { field: "unlimited_visibility", label: "Unlimited Visibility" }, + ]; + return (
{ isInvalid={!!form.formState.errors.name} /> - {[ - "manage_users", - "manage_account", - "manage_billing", - "manage_providers", - "manage_integrations", - "manage_scans", - "unlimited_visibility", - ].map((field) => ( - - {field.replace("_", " ")} +
+ Admin Permissions + + {/* Select All Checkbox */} + + form.watch(perm.field as keyof FormValues), + )} + onChange={(e) => onSelectAllChange(e.target.checked)} + classNames={{ + label: "text-small", + }} + > + Grant all admin permissions - ))} + + {/* Permissions Grid */} +
+ {permissions.map(({ field, label }) => ( + + {label} + + ))} +
+
>; +}) => { + const form = useForm>({ + resolver: zodResolver(formSchema), + }); + const { toast } = useToast(); + const isLoading = form.formState.isSubmitting; + + async function onSubmitClient(formData: FormData) { + const roleId = formData.get("id") as string; + const data = await deleteRole(roleId); + + 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 role was removed successfully.", + }); + } + setIsOpen(false); // Close the modal on success + } + + return ( + + + +
+ setIsOpen(false)} + isDisabled={isLoading} + > + Cancel + + + } + > + {isLoading ? <>Loading : Delete} + +
+ + + ); +}; diff --git a/ui/components/roles/workflow/forms/edit-role-form.tsx b/ui/components/roles/workflow/forms/edit-role-form.tsx new file mode 100644 index 0000000000..5e5c0203fe --- /dev/null +++ b/ui/components/roles/workflow/forms/edit-role-form.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Checkbox } from "@nextui-org/react"; +import { SaveIcon } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { updateRole } from "@/actions/roles/roles"; +import { useToast } from "@/components/ui"; +import { CustomButton, CustomInput } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; +import { ApiError, editRoleFormSchema } from "@/types"; + +export type FormValues = z.infer; + +export const EditRoleForm = ({ roleId }: { roleId: string }) => { + const { toast } = useToast(); + const router = useRouter(); + const searchParams = useSearchParams(); + const searchRoleId = searchParams.get("roleId") || roleId; + + const form = useForm({ + resolver: zodResolver(editRoleFormSchema), + defaultValues: { + roleId: roleId, + }, + }); + + const { watch, setValue } = form; + + const manageProviders = watch("manage_providers"); + const unlimitedVisibility = watch("unlimited_visibility"); + + useEffect(() => { + if (manageProviders && !unlimitedVisibility) { + setValue("unlimited_visibility", true, { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }); + } + }, [manageProviders, unlimitedVisibility, setValue]); + + const isLoading = form.formState.isSubmitting; + + const onSelectAllChange = (checked: boolean) => { + const permissions = [ + "manage_users", + "manage_account", + "manage_billing", + "manage_providers", + "manage_integrations", + "manage_scans", + "unlimited_visibility", + ]; + permissions.forEach((permission) => { + form.setValue(permission as keyof FormValues, checked, { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }); + }); + }; + + const onSubmitClient = async (values: FormValues) => { + if (!searchRoleId) { + toast({ + variant: "destructive", + title: "Error", + description: "Role ID is missing.", + }); + return; + } + + 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 updateRole(formData, searchRoleId); + + 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({ + title: "Role Updated", + description: "The role was updated successfully.", + }); + router.push("/roles"); + } + } catch (error) { + toast({ + variant: "destructive", + title: "Error", + description: "An unexpected error occurred. Please try again.", + }); + } + }; + + const permissions = [ + { field: "manage_users", label: "Invite and Manage Users" }, + { field: "manage_account", label: "Manage SaaS Account" }, + { field: "manage_billing", label: "Manage Billing" }, + { field: "manage_providers", label: "Manage Cloud Accounts" }, + { field: "manage_integrations", label: "Manage Integrations" }, + { field: "manage_scans", label: "Manage Scans" }, + { field: "unlimited_visibility", label: "Unlimited Visibility" }, + ]; + + return ( +
+ + + +
+ Admin Permissions + + {/* Select All Checkbox */} + + form.watch(perm.field as keyof FormValues), + )} + onChange={(e) => onSelectAllChange(e.target.checked)} + classNames={{ + label: "text-small", + }} + > + Grant all admin permissions + + + {/* Permissions Grid */} +
+ {permissions.map(({ field, label }) => ( + + {label} + + ))} +
+
+ +
+ } + > + {isLoading ? <>Loading : Update Role} + +
+ + + ); +}; diff --git a/ui/components/roles/workflow/forms/index.ts b/ui/components/roles/workflow/forms/index.ts index c50e093cc0..450289433a 100644 --- a/ui/components/roles/workflow/forms/index.ts +++ b/ui/components/roles/workflow/forms/index.ts @@ -1 +1,3 @@ export * from "./add-role-form"; +export * from "./delete-role-form"; +export * from "./edit-role-form"; diff --git a/ui/components/roles/workflow/workflow-add-edit-role.tsx b/ui/components/roles/workflow/workflow-add-edit-role.tsx index 319a027c17..d7abfcfb4b 100644 --- a/ui/components/roles/workflow/workflow-add-edit-role.tsx +++ b/ui/components/roles/workflow/workflow-add-edit-role.tsx @@ -15,7 +15,7 @@ const steps = [ { title: "Edit a existing role", description: - "Review the role details and share the information required for the role.", + "Update the role's details, including its name and permissions.", href: "/roles/edit", }, ]; diff --git a/ui/types/formSchemas.ts b/ui/types/formSchemas.ts index fcb725eef0..d67fda5c1b 100644 --- a/ui/types/formSchemas.ts +++ b/ui/types/formSchemas.ts @@ -11,6 +11,18 @@ export const addRoleFormSchema = z.object({ unlimited_visibility: z.boolean().default(false), }); +export const editRoleFormSchema = z.object({ + roleId: z.string().uuid(), + 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