diff --git a/ui/changelog.d/rbac-unlimited-visibility.changed.md b/ui/changelog.d/rbac-unlimited-visibility.changed.md new file mode 100644 index 00000000000..b8f5bc57250 --- /dev/null +++ b/ui/changelog.d/rbac-unlimited-visibility.changed.md @@ -0,0 +1 @@ +RBAC role forms now explain Unlimited Visibility inside the Visibility section and keep the setting visible while group selection is hidden diff --git a/ui/components/roles/workflow/forms/add-role-form.test.tsx b/ui/components/roles/workflow/forms/add-role-form.test.tsx index 80a9c523b39..561bf9bddf6 100644 --- a/ui/components/roles/workflow/forms/add-role-form.test.tsx +++ b/ui/components/roles/workflow/forms/add-role-form.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { AddRoleForm } from "./add-role-form"; @@ -76,6 +76,17 @@ vi.mock("@/components/ui", () => ({ useToast: () => ({ toast: vi.fn() }), })); +beforeAll(() => { + class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} + } + + globalThis.ResizeObserver = ResizeObserverMock; + window.ResizeObserver = ResizeObserverMock; +}); + describe("AddRoleForm", () => { afterEach(() => { routerMocks.push.mockClear(); @@ -117,4 +128,207 @@ describe("AddRoleForm", () => { // Then expect(routerMocks.push).toHaveBeenCalledWith("/roles"); }); + + it("shows a subtle inline Unlimited Visibility description", () => { + // Given / When + render(); + + // Then + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect( + screen.getByText(/tenant-wide visibility setting/i), + ).toHaveTextContent( + /grants visibility into every provider, account, resource, finding, scan, and compliance result.*required to use the Jira integration/i, + ); + expect( + screen.getByText(/required to use the Jira integration/i), + ).toHaveProperty("tagName", "STRONG"); + expect( + screen.queryByRole("heading", { name: "Unlimited Visibility" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByText( + /does not grant admin actions such as managing users, providers, scans, integrations, billing, or alerts/i, + ), + ).not.toBeInTheDocument(); + expect( + screen.queryByText( + /enable it only for roles that need tenant-wide security visibility/i, + ), + ).not.toBeInTheDocument(); + expect( + screen.queryByText( + /manage providers enables unlimited visibility in this form because provider administration needs tenant-wide provider-group context/i, + ), + ).not.toBeInTheDocument(); + + const visibilityHeading = screen.getByText("Visibility"); + const unlimitedVisibilityCheckbox = screen.getByRole("checkbox", { + name: "Enable Unlimited Visibility for this role", + }); + + expect( + visibilityHeading.compareDocumentPosition(unlimitedVisibilityCheckbox) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it("keeps the Visibility section and hides only groups when Unlimited Visibility is enabled", async () => { + // Given + const user = userEvent.setup(); + render(); + + // When + await user.click( + screen.getByRole("checkbox", { + name: "Enable Unlimited Visibility for this role", + }), + ); + + // Then + expect(screen.getByText("Visibility")).toBeInTheDocument(); + expect( + screen.getByRole("checkbox", { + name: "Enable Unlimited Visibility for this role", + }), + ).toBeChecked(); + expect( + screen.getByText(/tenant-wide visibility setting/i), + ).toBeInTheDocument(); + expect(screen.queryByTestId("group-select")).not.toBeInTheDocument(); + expect( + screen.queryByText(/select the groups this role will have access to/i), + ).not.toBeInTheDocument(); + }); + + it("does not force Unlimited Visibility when Manage Providers is selected", async () => { + // Given + const user = userEvent.setup(); + render(); + + // When + await user.click( + screen.getByRole("checkbox", { name: "Manage Providers" }), + ); + + // Then + expect( + screen.getByRole("checkbox", { + name: "Enable Unlimited Visibility for this role", + }), + ).not.toBeChecked(); + expect( + screen.queryByText( + /Manage Providers is selected, so Unlimited Visibility stays enabled in this form/i, + ), + ).not.toBeInTheDocument(); + }); + + it("does not force Unlimited Visibility when granting all admin permissions", async () => { + // Given + const user = userEvent.setup(); + render(); + + // When + await user.click( + screen.getByRole("checkbox", { name: "Grant all admin permissions" }), + ); + + // Then + expect( + screen.getByRole("checkbox", { name: "Manage Providers" }), + ).toBeChecked(); + expect( + screen.getByRole("checkbox", { + name: "Enable Unlimited Visibility for this role", + }), + ).not.toBeChecked(); + }); + + it("keeps Unlimited Visibility user-controlled when Manage Providers is selected", async () => { + // Given + const user = userEvent.setup(); + render(); + + // When + await user.click( + screen.getByRole("checkbox", { name: "Manage Providers" }), + ); + await user.click( + screen.getByRole("checkbox", { + name: "Enable Unlimited Visibility for this role", + }), + ); + await user.click( + screen.getByRole("checkbox", { + name: "Enable Unlimited Visibility for this role", + }), + ); + + // Then + expect( + screen.getByRole("checkbox", { name: "Manage Providers" }), + ).toBeChecked(); + expect( + screen.getByRole("checkbox", { + name: "Enable Unlimited Visibility for this role", + }), + ).not.toBeChecked(); + expect(screen.getByTestId("group-select")).toBeInTheDocument(); + }); + + it("keeps explicitly enabled Unlimited Visibility when all admin permissions are toggled off", async () => { + // Given + const user = userEvent.setup(); + render(); + + // When + await user.click( + screen.getByRole("checkbox", { + name: "Enable Unlimited Visibility for this role", + }), + ); + await user.click( + screen.getByRole("checkbox", { name: "Grant all admin permissions" }), + ); + await user.click( + screen.getByRole("checkbox", { name: "Grant all admin permissions" }), + ); + + // Then + expect( + screen.getByRole("checkbox", { name: "Manage Providers" }), + ).not.toBeChecked(); + expect( + screen.getByRole("checkbox", { + name: "Enable Unlimited Visibility for this role", + }), + ).toBeChecked(); + }); + + it("does not show extra Manage Providers guidance for explicitly enabled Unlimited Visibility", async () => { + // Given + const user = userEvent.setup(); + render(); + + // When + await user.click( + screen.getByRole("checkbox", { + name: "Enable Unlimited Visibility for this role", + }), + ); + await user.click( + screen.getByRole("checkbox", { name: "Manage Providers" }), + ); + + // Then + expect( + screen.queryByText( + /Manage Providers is selected, so Unlimited Visibility stays enabled in this form/i, + ), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/remove this automatic visibility grant/i), + ).not.toBeInTheDocument(); + }); }); diff --git a/ui/components/roles/workflow/forms/add-role-form.tsx b/ui/components/roles/workflow/forms/add-role-form.tsx index 2ae5db944d1..c02311c0f25 100644 --- a/ui/components/roles/workflow/forms/add-role-form.tsx +++ b/ui/components/roles/workflow/forms/add-role-form.tsx @@ -1,85 +1,38 @@ "use client"; -import { Checkbox } from "@heroui/checkbox"; -import { Divider } from "@heroui/divider"; -import { Tooltip } from "@heroui/tooltip"; -import { zodResolver } from "@hookform/resolvers/zod"; -import clsx from "clsx"; -import { InfoIcon } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { z } from "zod"; +import { DefaultValues } from "react-hook-form"; import { addRole } from "@/actions/roles/roles"; -import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select"; import { useToast } from "@/components/ui"; -import { CustomInput } from "@/components/ui/custom"; -import { Form, FormButtons } from "@/components/ui/form"; -import { getErrorMessage, permissionFormFields } from "@/lib"; -import { addRoleFormSchema, ApiError } from "@/types"; +import { getErrorMessage } from "@/lib"; +import { RoleFormValues } from "@/types"; -type FormValues = z.input; +import { RoleForm, RoleFormSubmitContext, RoleGroupOption } from "./role-form"; -export const AddRoleForm = ({ - groups, -}: { - groups: { id: string; name: string }[]; -}) => { +export const AddRoleForm = ({ groups }: { groups: RoleGroupOption[] }) => { const { toast } = useToast(); const router = useRouter(); const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; - const visiblePermissionFormFields = permissionFormFields.filter( - (permission) => - !["manage_billing", "manage_alerts"].includes(permission.field) || - isCloudEnvironment, - ); - - const form = useForm({ - resolver: zodResolver(addRoleFormSchema), - defaultValues: { - name: "", - manage_users: false, - manage_providers: false, - manage_integrations: false, - manage_scans: false, - unlimited_visibility: false, - groups: [], - ...(isCloudEnvironment && { - manage_billing: false, - manage_alerts: false, - }), - }, - }); - - 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) => { - visiblePermissionFormFields.forEach(({ field }) => { - form.setValue(field as keyof FormValues, checked, { - shouldValidate: true, - shouldDirty: true, - shouldTouch: true, - }); - }); + const defaultValues: DefaultValues = { + name: "", + manage_users: false, + manage_providers: false, + manage_integrations: false, + manage_scans: false, + unlimited_visibility: false, + groups: [], + ...(isCloudEnvironment && { + manage_billing: false, + manage_alerts: false, + }), }; - const onSubmitClient = async (values: FormValues) => { + const onSubmit = async ( + values: RoleFormValues, + { handleServerResponse }: RoleFormSubmitContext, + ) => { const formData = new FormData(); formData.append("name", values.name); @@ -107,33 +60,13 @@ export const AddRoleForm = ({ try { const data = await addRole(formData); + if (!handleServerResponse(data)) return; - if (data?.errors && data.errors.length > 0) { - data.errors.forEach((error: ApiError) => { - const errorMessage = error.detail; - const pointer = error.source?.pointer; - switch (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 Added", - description: "The role was added successfully.", - }); - router.push("/roles"); - } + toast({ + title: "Role Added", + description: "The role was added successfully.", + }); + router.push("/roles"); } catch (error) { toast({ variant: "destructive", @@ -144,120 +77,11 @@ export const AddRoleForm = ({ }; return ( -
- - - -
- Admin Permissions - - {/* Select All Checkbox */} - - form.watch(perm.field as keyof FormValues), - )} - onChange={(e) => onSelectAllChange(e.target.checked)} - classNames={{ - label: "text-small", - wrapper: "checkbox-update", - }} - color="default" - > - Grant all admin permissions - - - {/* Permissions Grid */} -
- {visiblePermissionFormFields.map( - ({ field, label, description }) => ( -
- - {label} - - -
- -
-
-
- ), - )} -
-
- - - {!unlimitedVisibility && ( -
- - Groups and Account Visibility - - -

- Select the groups this role will have access to. If no groups are - selected and unlimited visibility is not enabled, the role will - not have access to any accounts. -

- - ( -
- ({ - label: group.name, - value: group.id, - }))} - onValueChange={field.onChange} - defaultValue={field.value || []} - placeholder="Select groups" - searchable={true} - hideSelectAll={true} - emptyIndicator="No results found" - resetOnDefaultValueChange={true} - /> -
- )} - /> - {form.formState.errors.groups && ( -

- {form.formState.errors.groups.message} -

- )} -
- )} - router.push("/roles")} - /> - - + ); }; diff --git a/ui/components/roles/workflow/forms/edit-role-form.test.tsx b/ui/components/roles/workflow/forms/edit-role-form.test.tsx new file mode 100644 index 00000000000..fe1dd23639c --- /dev/null +++ b/ui/components/roles/workflow/forms/edit-role-form.test.tsx @@ -0,0 +1,326 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +import { EditRoleForm } from "./edit-role-form"; + +const routerMocks = vi.hoisted(() => ({ + push: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => routerMocks, +})); + +vi.mock("@/actions/roles/roles", () => ({ + updateRole: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + cn: (...classes: Array) => + classes.filter(Boolean).join(" "), + getErrorMessage: (error: unknown) => String(error), + permissionFormFields: [ + { + field: "manage_users", + label: "Invite and Manage Users", + description: + "Allows inviting new users and managing existing user details", + }, + { + field: "manage_account", + label: "Manage Account", + description: "Provides access to account settings and RBAC configuration", + }, + { + field: "unlimited_visibility", + label: "Unlimited Visibility", + description: + "Provides complete visibility across all the providers and its related resources", + }, + { + field: "manage_providers", + label: "Manage Providers", + description: + "Allows configuration and management of provider connections", + }, + { + field: "manage_integrations", + label: "Manage Integrations", + description: + "Allows configuration and management of third-party integrations", + }, + { + field: "manage_scans", + label: "Manage Scans", + description: "Allows launching and configuring scans security scans", + }, + { + field: "manage_alerts", + label: "Manage Alerts", + description: "Allows creating and managing custom alerts", + }, + { + field: "manage_billing", + label: "Manage Billing", + description: "Provides access to billing settings and invoices", + }, + ], +})); + +vi.mock("@/components/shadcn/select/enhanced-multi-select", () => ({ + EnhancedMultiSelect: () =>
, +})); + +vi.mock("@/components/ui", () => ({ + useToast: () => ({ toast: vi.fn() }), +})); + +beforeAll(() => { + class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} + } + + globalThis.ResizeObserver = ResizeObserverMock; + window.ResizeObserver = ResizeObserverMock; +}); + +const roleData = ({ + manageProviders = false, + unlimitedVisibility = false, +}: { + manageProviders?: boolean; + unlimitedVisibility?: boolean; +} = {}) => ({ + data: { + attributes: { + name: "Existing role", + manage_users: false, + manage_account: false, + manage_providers: manageProviders, + manage_integrations: false, + manage_scans: false, + unlimited_visibility: unlimitedVisibility, + groups: [], + }, + relationships: { + provider_groups: { + data: [], + }, + }, + }, +}); + +const renderEditRoleForm = (options?: Parameters[0]) => + render( + , + ); + +describe("EditRoleForm", () => { + afterEach(() => { + routerMocks.push.mockClear(); + vi.unstubAllEnvs(); + }); + + it("shows the subtle Unlimited Visibility description inside Visibility", () => { + // Given / When + renderEditRoleForm(); + + // Then + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect( + screen.getByText(/tenant-wide visibility setting/i), + ).toHaveTextContent( + /grants visibility into every provider, account, resource, finding, scan, and compliance result.*required to use the Jira integration/i, + ); + expect( + screen.getByText(/required to use the Jira integration/i), + ).toHaveProperty("tagName", "STRONG"); + expect( + screen.queryByText( + /manage providers enables unlimited visibility in this form because provider administration needs tenant-wide provider-group context/i, + ), + ).not.toBeInTheDocument(); + + const visibilityHeading = screen.getByText("Visibility"); + const unlimitedVisibilityCheckbox = screen.getByRole("checkbox", { + name: "Enable Unlimited Visibility for this role", + }); + + expect( + visibilityHeading.compareDocumentPosition(unlimitedVisibilityCheckbox) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it("keeps the Visibility section and hides only groups when Unlimited Visibility is enabled", async () => { + // Given + const user = userEvent.setup(); + render( + , + ); + + // When + await user.click( + screen.getByRole("checkbox", { + name: "Enable Unlimited Visibility for this role", + }), + ); + + // Then + expect(screen.getByText("Visibility")).toBeInTheDocument(); + expect( + screen.getByRole("checkbox", { + name: "Enable Unlimited Visibility for this role", + }), + ).toBeChecked(); + expect( + screen.getByText(/tenant-wide visibility setting/i), + ).toBeInTheDocument(); + expect(screen.queryByTestId("group-select")).not.toBeInTheDocument(); + expect( + screen.queryByText(/select the groups this role will have access to/i), + ).not.toBeInTheDocument(); + }); + + it("does not force Unlimited Visibility when Manage Providers is selected", async () => { + // Given + const user = userEvent.setup(); + renderEditRoleForm(); + + // When + await user.click( + screen.getByRole("checkbox", { name: "Manage Providers" }), + ); + + // Then + expect( + screen.getByRole("checkbox", { + name: "Enable Unlimited Visibility for this role", + }), + ).not.toBeChecked(); + expect( + screen.queryByText( + /Manage Providers is selected, so Unlimited Visibility stays enabled in this form/i, + ), + ).not.toBeInTheDocument(); + }); + + it("does not force Unlimited Visibility when granting all admin permissions", async () => { + // Given + const user = userEvent.setup(); + renderEditRoleForm(); + + // When + await user.click( + screen.getByRole("checkbox", { name: "Grant all admin permissions" }), + ); + + // Then + expect( + screen.getByRole("checkbox", { name: "Manage Providers" }), + ).toBeChecked(); + expect( + screen.getByRole("checkbox", { + name: "Enable Unlimited Visibility for this role", + }), + ).not.toBeChecked(); + }); + + it("keeps Unlimited Visibility user-controlled when Manage Providers is selected", async () => { + // Given + const user = userEvent.setup(); + renderEditRoleForm(); + + // When + await user.click( + screen.getByRole("checkbox", { name: "Manage Providers" }), + ); + await user.click( + screen.getByRole("checkbox", { + name: "Enable Unlimited Visibility for this role", + }), + ); + await user.click( + screen.getByRole("checkbox", { + name: "Enable Unlimited Visibility for this role", + }), + ); + + // Then + expect( + screen.getByRole("checkbox", { name: "Manage Providers" }), + ).toBeChecked(); + expect( + screen.getByRole("checkbox", { + name: "Enable Unlimited Visibility for this role", + }), + ).not.toBeChecked(); + expect(screen.getByTestId("group-select")).toBeInTheDocument(); + }); + + it("keeps explicitly enabled Unlimited Visibility when all admin permissions are toggled off", async () => { + // Given + const user = userEvent.setup(); + renderEditRoleForm(); + + // When + await user.click( + screen.getByRole("checkbox", { + name: "Enable Unlimited Visibility for this role", + }), + ); + await user.click( + screen.getByRole("checkbox", { name: "Grant all admin permissions" }), + ); + await user.click( + screen.getByRole("checkbox", { name: "Grant all admin permissions" }), + ); + + // Then + expect( + screen.getByRole("checkbox", { name: "Manage Providers" }), + ).not.toBeChecked(); + expect( + screen.getByRole("checkbox", { + name: "Enable Unlimited Visibility for this role", + }), + ).toBeChecked(); + }); + + it("does not show extra Manage Providers guidance", () => { + // Given / When + renderEditRoleForm({ manageProviders: true, unlimitedVisibility: true }); + + // Then + expect( + screen.queryByText( + /Manage Providers is selected, so Unlimited Visibility stays enabled in this form/i, + ), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/remove this automatic visibility grant/i), + ).not.toBeInTheDocument(); + }); + + it("keeps existing inconsistent Manage Providers and Unlimited Visibility values on initial render", () => { + // Given / When + renderEditRoleForm({ manageProviders: true, unlimitedVisibility: false }); + + // Then + expect( + screen.getByRole("checkbox", { name: "Manage Providers" }), + ).toBeChecked(); + const unlimitedVisibilityCheckbox = screen.getByRole("checkbox", { + name: "Enable Unlimited Visibility for this role", + }); + expect(unlimitedVisibilityCheckbox).not.toBeChecked(); + expect(unlimitedVisibilityCheckbox).toBeEnabled(); + }); +}); diff --git a/ui/components/roles/workflow/forms/edit-role-form.tsx b/ui/components/roles/workflow/forms/edit-role-form.tsx index e3e1a3bde86..4ba474b9434 100644 --- a/ui/components/roles/workflow/forms/edit-role-form.tsx +++ b/ui/components/roles/workflow/forms/edit-role-form.tsx @@ -1,25 +1,14 @@ "use client"; -import { Checkbox } from "@heroui/checkbox"; -import { Divider } from "@heroui/divider"; -import { Tooltip } from "@heroui/tooltip"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { clsx } from "clsx"; -import { InfoIcon } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { z } from "zod"; +import { DefaultValues } from "react-hook-form"; import { updateRole } from "@/actions/roles/roles"; -import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select"; import { useToast } from "@/components/ui"; -import { CustomInput } from "@/components/ui/custom"; -import { Form, FormButtons } from "@/components/ui/form"; -import { getErrorMessage, permissionFormFields } from "@/lib"; -import { ApiError, editRoleFormSchema } from "@/types"; +import { getErrorMessage } from "@/lib"; +import { RoleFormValues } from "@/types"; -type FormValues = z.input; +import { RoleForm, RoleFormSubmitContext, RoleGroupOption } from "./role-form"; export const EditRoleForm = ({ roleId, @@ -29,7 +18,7 @@ export const EditRoleForm = ({ roleId: string; roleData: { data: { - attributes: FormValues; + attributes: RoleFormValues; relationships?: { provider_groups?: { data: Array<{ id: string; type: string }>; @@ -37,57 +26,24 @@ export const EditRoleForm = ({ }; }; }; - groups: { id: string; name: string }[]; + groups: RoleGroupOption[]; }) => { const { toast } = useToast(); const router = useRouter(); const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; - const visiblePermissionFormFields = permissionFormFields.filter( - (permission) => - !["manage_billing", "manage_alerts"].includes(permission.field) || - isCloudEnvironment, - ); - - const form = useForm({ - resolver: zodResolver(editRoleFormSchema), - defaultValues: { - ...roleData.data.attributes, - groups: - roleData.data.relationships?.provider_groups?.data.map((g) => g.id) || - [], - }, - }); - - 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) => { - visiblePermissionFormFields.forEach(({ field }) => { - form.setValue(field as keyof FormValues, checked, { - shouldValidate: true, - shouldDirty: true, - shouldTouch: true, - }); - }); + const defaultValues: DefaultValues = { + ...roleData.data.attributes, + groups: + roleData.data.relationships?.provider_groups?.data.map((g) => g.id) || [], }; - const onSubmitClient = async (values: FormValues) => { + const onSubmit = async ( + values: RoleFormValues, + { handleServerResponse }: RoleFormSubmitContext, + ) => { try { - const updatedFields: Partial = {}; + const updatedFields: Partial = {}; if (values.name !== roleData.data.attributes.name) { updatedFields.name = values.name; @@ -127,33 +83,13 @@ export const EditRoleForm = ({ }); const data = await updateRole(formData, roleId); + if (!handleServerResponse(data)) return; - if (data?.errors && data.errors.length > 0) { - data.errors.forEach((error: ApiError) => { - const errorMessage = error.detail; - const pointer = error.source?.pointer; - switch (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"); - } + toast({ + title: "Role Updated", + description: "The role was updated successfully.", + }); + router.push("/roles"); } catch (error) { toast({ variant: "destructive", @@ -164,119 +100,11 @@ export const EditRoleForm = ({ }; return ( -
- - - -
- Admin Permissions - - {/* Select All Checkbox */} - - form.watch(perm.field as keyof FormValues), - )} - onChange={(e) => onSelectAllChange(e.target.checked)} - classNames={{ - label: "text-small", - wrapper: "checkbox-update", - }} - color="default" - > - Grant all admin permissions - - - {/* Permissions Grid */} -
- {visiblePermissionFormFields.map( - ({ field, label, description }) => ( -
- - {label} - - -
- -
-
-
- ), - )} -
-
- - - {!unlimitedVisibility && ( -
- Groups visibility - -

- Select the groups this role will have access to. If no groups are - selected and unlimited visibility is not enabled, the role will - not have access to any accounts. -

- - ( -
- ({ - label: group.name, - value: group.id, - }))} - onValueChange={field.onChange} - defaultValue={field.value || []} - placeholder="Select groups" - searchable={true} - hideSelectAll={true} - emptyIndicator="No results found" - resetOnDefaultValueChange={true} - /> -
- )} - /> - - {form.formState.errors.groups && ( -

- {form.formState.errors.groups.message} -

- )} -
- )} - router.push("/roles")} - /> - - + ); }; diff --git a/ui/components/roles/workflow/forms/role-form.tsx b/ui/components/roles/workflow/forms/role-form.tsx new file mode 100644 index 00000000000..6704cc7b2bf --- /dev/null +++ b/ui/components/roles/workflow/forms/role-form.tsx @@ -0,0 +1,236 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { InfoIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { + Controller, + DefaultValues, + useForm, + UseFormReturn, +} from "react-hook-form"; + +import { Checkbox } from "@/components/shadcn/checkbox/checkbox"; +import { Input } from "@/components/shadcn/input/input"; +import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select"; +import { Separator } from "@/components/shadcn/separator/separator"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; +import { + Form, + FormButtons, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { useFormServerErrors } from "@/hooks/use-form-server-errors"; +import { useManageProvidersUnlimitedVisibility } from "@/hooks/use-manage-providers-unlimited-visibility"; +import { + getUnlimitedVisibilityField, + getVisiblePermissionFormFields, +} from "@/lib/role-permissions"; +import { roleFormSchema, RoleFormValues } from "@/types"; + +import { UnlimitedVisibilityField } from "./unlimited-visibility-section"; + +export interface RoleGroupOption { + id: string; + name: string; +} + +export interface RoleFormSubmitContext { + form: UseFormReturn; + handleServerResponse: (data: unknown) => boolean; +} + +interface RoleFormProps { + groups: RoleGroupOption[]; + defaultValues: DefaultValues; + submitText: string; + onSubmit: ( + values: RoleFormValues, + ctx: RoleFormSubmitContext, + ) => void | Promise; + onCancel?: () => void; +} + +export const RoleForm = ({ + groups, + defaultValues, + submitText, + onSubmit, + onCancel, +}: RoleFormProps) => { + const router = useRouter(); + + const form = useForm({ + resolver: zodResolver(roleFormSchema), + defaultValues, + }); + + const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + const visiblePermissionFormFields = + getVisiblePermissionFormFields(isCloudEnvironment); + const showUnlimitedVisibilityField = !!getUnlimitedVisibilityField(); + + const { setPermissionValue, setUnlimitedVisibility } = + useManageProvidersUnlimitedVisibility(form); + const { handleServerResponse } = useFormServerErrors(form, { + "/data/attributes/name": "name", + }); + + const unlimitedVisibility = !!form.watch("unlimited_visibility"); + const isLoading = form.formState.isSubmitting; + + const onSelectAllChange = (checked: boolean) => { + visiblePermissionFormFields.forEach(({ field }) => { + setPermissionValue(field, checked); + }); + }; + + const handleCancel = onCancel ?? (() => router.push("/roles")); + + return ( +
+ + onSubmit(values, { form, handleServerResponse }), + )} + className="flex flex-col gap-6" + > + ( + + + Role Name * + + + + + + + )} + /> + +
+ Admin Permissions + + {/* Select All Checkbox */} +
+ + form.watch(perm.field as keyof RoleFormValues), + )} + onCheckedChange={(checked) => onSelectAllChange(Boolean(checked))} + /> + +
+ + {/* Permissions Grid */} +
+ {visiblePermissionFormFields.map( + ({ field, label, description }) => ( +
+ + setPermissionValue(field, Boolean(checked)) + } + /> + + + +
+
+
+ {description} +
+
+ ), + )} +
+
+ + + +
+ Visibility + + {showUnlimitedVisibilityField && ( + + )} + + {!unlimitedVisibility && ( + <> +

+ Select the groups this role will have access to. If no groups + are selected and unlimited visibility is not enabled, the role + will not have access to any accounts. +

+ + ( +
+ ({ + label: group.name, + value: group.id, + }))} + onValueChange={field.onChange} + defaultValue={field.value || []} + placeholder="Select groups" + searchable={true} + hideSelectAll={true} + emptyIndicator="No results found" + resetOnDefaultValueChange={true} + /> +
+ )} + /> + + {form.formState.errors.groups && ( +

+ {form.formState.errors.groups.message} +

+ )} + + )} +
+ + + + ); +}; diff --git a/ui/components/roles/workflow/forms/unlimited-visibility-section.tsx b/ui/components/roles/workflow/forms/unlimited-visibility-section.tsx new file mode 100644 index 00000000000..50a2e2292d7 --- /dev/null +++ b/ui/components/roles/workflow/forms/unlimited-visibility-section.tsx @@ -0,0 +1,56 @@ +import { InfoIcon } from "lucide-react"; +import { ReactNode } from "react"; + +import { Checkbox } from "@/components/shadcn/checkbox/checkbox"; + +export const UnlimitedVisibilitySection = ({ + children, +}: { + children: ReactNode; +}) => { + return ( +
+
+
+
{children}
+
+ ); +}; + +export const UnlimitedVisibilityField = ({ + isSelected, + onValueChange, +}: { + isSelected: boolean; + onValueChange: (checked: boolean) => void; +}) => { + return ( + +
+ onValueChange(Boolean(checked))} + size="sm" + /> + +
+
+ ); +}; diff --git a/ui/hooks/use-manage-providers-unlimited-visibility.ts b/ui/hooks/use-manage-providers-unlimited-visibility.ts new file mode 100644 index 00000000000..6c4990e6c5c --- /dev/null +++ b/ui/hooks/use-manage-providers-unlimited-visibility.ts @@ -0,0 +1,42 @@ +import type { + FieldValues, + Path, + PathValue, + UseFormReturn, +} from "react-hook-form"; + +type RolePermissionValues = { + manage_providers?: boolean; + unlimited_visibility?: boolean; +}; + +const setBooleanFormValue = ( + form: Pick, "setValue">, + field: Path, + value: boolean, +) => { + form.setValue(field, value as PathValue>, { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }); +}; + +export const useManageProvidersUnlimitedVisibility = < + T extends FieldValues & RolePermissionValues, +>( + form: Pick, "setValue" | "watch">, +) => { + const setUnlimitedVisibility = (checked: boolean) => { + setBooleanFormValue(form, "unlimited_visibility" as Path, checked); + }; + + const setPermissionValue = (field: string, checked: boolean) => { + setBooleanFormValue(form, field as Path, checked); + }; + + return { + setPermissionValue, + setUnlimitedVisibility, + }; +}; diff --git a/ui/lib/helper.ts b/ui/lib/helper.ts index dd61b238c33..07c40431604 100644 --- a/ui/lib/helper.ts +++ b/ui/lib/helper.ts @@ -433,7 +433,7 @@ export const permissionFormFields: PermissionInfo[] = [ field: "unlimited_visibility", label: "Unlimited Visibility", description: - "Provides complete visibility across all the providers and its related resources", + "Grants tenant-wide visibility across all providers, accounts, resources, findings, scans, and compliance results without granting admin actions.", }, { field: "manage_providers", diff --git a/ui/lib/role-permissions.ts b/ui/lib/role-permissions.ts new file mode 100644 index 00000000000..87b024ebee5 --- /dev/null +++ b/ui/lib/role-permissions.ts @@ -0,0 +1,14 @@ +import { permissionFormFields } from "@/lib"; + +const hiddenOutsideCloudFields = ["manage_billing", "manage_alerts"]; + +export const getVisiblePermissionFormFields = (isCloudEnvironment: boolean) => + permissionFormFields.filter( + (permission) => + permission.field !== "unlimited_visibility" && + (!hiddenOutsideCloudFields.includes(permission.field) || + isCloudEnvironment), + ); + +export const getUnlimitedVisibilityField = () => + permissionFormFields.find(({ field }) => field === "unlimited_visibility"); diff --git a/ui/types/formSchemas.ts b/ui/types/formSchemas.ts index bba10275850..79c8ddc5748 100644 --- a/ui/types/formSchemas.ts +++ b/ui/types/formSchemas.ts @@ -35,7 +35,8 @@ export const kubeconfigContainsExecAuthentication = ( } }; -export const addRoleFormSchema = z.object({ +// Create and edit share the same shape, so a single schema backs both flows. +export const roleFormSchema = z.object({ name: z.string().min(1, "Name is required"), manage_users: z.boolean().default(false), manage_account: z.boolean().default(false), @@ -48,18 +49,7 @@ export const addRoleFormSchema = z.object({ groups: z.array(z.string()).optional(), }); -export const editRoleFormSchema = 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), - manage_alerts: z.boolean().default(false), - unlimited_visibility: z.boolean().default(false), - groups: z.array(z.string()).optional(), -}); +export type RoleFormValues = z.input; export const onDemandScanFormSchema = () => z.object({