From 454c77e653ddf0fdb21c4bfb9c033e2414835bf4 Mon Sep 17 00:00:00 2001 From: "Hugo P.Brito" Date: Mon, 6 Jul 2026 13:26:59 +0100 Subject: [PATCH 1/7] fix(ui): clarify unlimited visibility in RBAC forms --- .../workflow/forms/add-role-form.test.tsx | 153 ++++++++++++ .../roles/workflow/forms/add-role-form.tsx | 77 +++--- .../workflow/forms/edit-role-form.test.tsx | 227 ++++++++++++++++++ .../roles/workflow/forms/edit-role-form.tsx | 77 +++--- .../roles/workflow/forms/role-permissions.ts | 92 +++++++ .../forms/unlimited-visibility-section.tsx | 52 ++++ ui/lib/helper.ts | 2 +- 7 files changed, 625 insertions(+), 55 deletions(-) create mode 100644 ui/components/roles/workflow/forms/edit-role-form.test.tsx create mode 100644 ui/components/roles/workflow/forms/role-permissions.ts create mode 100644 ui/components/roles/workflow/forms/unlimited-visibility-section.tsx 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..4c5c2628838 100644 --- a/ui/components/roles/workflow/forms/add-role-form.test.tsx +++ b/ui/components/roles/workflow/forms/add-role-form.test.tsx @@ -117,4 +117,157 @@ describe("AddRoleForm", () => { // Then expect(routerMocks.push).toHaveBeenCalledWith("/roles"); }); + + it("explains Unlimited Visibility independently from admin permissions", () => { + // Given / When + render(); + + // Then + expect( + screen.getByRole("heading", { name: "Unlimited Visibility" }), + ).toBeInTheDocument(); + expect( + screen.getByText( + /grants visibility into every provider, account, resource, finding, scan, and compliance result/i, + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + /does not grant admin actions such as managing users, providers, scans, integrations, billing, or alerts/i, + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + /enable it only for roles that need tenant-wide security visibility/i, + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + /manage providers enables unlimited visibility in this form because provider administration needs tenant-wide provider-group context/i, + ), + ).toBeInTheDocument(); + }); + + it("enables 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", + }), + ).toBeChecked(); + expect( + screen.getByText( + /Manage Providers is selected, so Unlimited Visibility stays enabled in this form/i, + ), + ).toBeInTheDocument(); + }); + + it("enables Unlimited Visibility through Manage Providers 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", + }), + ).toBeChecked(); + }); + + it("clears Unlimited Visibility when all admin permissions are toggled off after only auto-enabling it", async () => { + // Given + const user = userEvent.setup(); + render(); + + // When + 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", + }), + ).not.toBeChecked(); + }); + + 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 describe clearing Manage Providers as removing 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.getByText( + /Manage Providers is selected, so Unlimited Visibility stays enabled in this form/i, + ), + ).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..d5c06efc73b 100644 --- a/ui/components/roles/workflow/forms/add-role-form.tsx +++ b/ui/components/roles/workflow/forms/add-role-form.tsx @@ -7,7 +7,6 @@ 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"; @@ -16,9 +15,16 @@ import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-s import { useToast } from "@/components/ui"; import { CustomInput } from "@/components/ui/custom"; import { Form, FormButtons } from "@/components/ui/form"; -import { getErrorMessage, permissionFormFields } from "@/lib"; +import { getErrorMessage } from "@/lib"; import { addRoleFormSchema, ApiError } from "@/types"; +import { + getUnlimitedVisibilityField, + getVisiblePermissionFormFields, + useManageProvidersUnlimitedVisibility, +} from "./role-permissions"; +import { UnlimitedVisibilitySection } from "./unlimited-visibility-section"; + type FormValues = z.input; export const AddRoleForm = ({ @@ -29,11 +35,9 @@ export const AddRoleForm = ({ 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 visiblePermissionFormFields = + getVisiblePermissionFormFields(isCloudEnvironment); + const unlimitedVisibilityField = getUnlimitedVisibilityField(); const form = useForm({ resolver: zodResolver(addRoleFormSchema), @@ -52,30 +56,18 @@ export const AddRoleForm = ({ }, }); - 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 { + isUnlimitedVisibilityRequiredByManageProviders, + setPermissionValue, + setUnlimitedVisibility, + } = useManageProvidersUnlimitedVisibility(form); + const unlimitedVisibility = form.watch("unlimited_visibility"); 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, - }); + setPermissionValue(field, checked); }); }; @@ -160,6 +152,34 @@ export const AddRoleForm = ({ isRequired /> + {unlimitedVisibilityField && ( + + + Enable Unlimited Visibility for this role + + {isUnlimitedVisibilityRequiredByManageProviders && ( +

+ Manage Providers is selected, so Unlimited Visibility stays + enabled in this form. If Manage Providers enabled it + automatically, clearing Manage Providers also clears that + automatic selection. If Unlimited Visibility was already + enabled, clearing Manage Providers only lets you edit it + separately. +

+ )} +
+ )} +
Admin Permissions @@ -168,7 +188,7 @@ export const AddRoleForm = ({ isSelected={visiblePermissionFormFields.every((perm) => form.watch(perm.field as keyof FormValues), )} - onChange={(e) => onSelectAllChange(e.target.checked)} + onValueChange={onSelectAllChange} classNames={{ label: "text-small", wrapper: "checkbox-update", @@ -186,6 +206,9 @@ export const AddRoleForm = ({ + setPermissionValue(field, checked) + } classNames={{ label: "text-small", wrapper: "checkbox-update", 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..aa027567051 --- /dev/null +++ b/ui/components/roles/workflow/forms/edit-role-form.test.tsx @@ -0,0 +1,227 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, 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() }), +})); + +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("enables 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", + }), + ).toBeChecked(); + expect( + screen.getByText( + /Manage Providers is selected, so Unlimited Visibility stays enabled in this form/i, + ), + ).toBeInTheDocument(); + }); + + it("enables Unlimited Visibility through Manage Providers 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", + }), + ).toBeChecked(); + }); + + it("clears Unlimited Visibility when all admin permissions are toggled off after only auto-enabling it", async () => { + // Given + const user = userEvent.setup(); + renderEditRoleForm(); + + // When + 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", + }), + ).not.toBeChecked(); + }); + + 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 describe clearing Manage Providers as removing existing Unlimited Visibility", async () => { + // Given / When + renderEditRoleForm({ manageProviders: true, unlimitedVisibility: true }); + + // Then + expect( + await screen.findByText( + /Manage Providers is selected, so Unlimited Visibility stays enabled in this form/i, + ), + ).toBeInTheDocument(); + expect( + screen.queryByText(/remove this automatic visibility grant/i), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/roles/workflow/forms/edit-role-form.tsx b/ui/components/roles/workflow/forms/edit-role-form.tsx index e3e1a3bde86..0f0aaca7cb8 100644 --- a/ui/components/roles/workflow/forms/edit-role-form.tsx +++ b/ui/components/roles/workflow/forms/edit-role-form.tsx @@ -7,7 +7,6 @@ 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"; @@ -16,9 +15,16 @@ import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-s import { useToast } from "@/components/ui"; import { CustomInput } from "@/components/ui/custom"; import { Form, FormButtons } from "@/components/ui/form"; -import { getErrorMessage, permissionFormFields } from "@/lib"; +import { getErrorMessage } from "@/lib"; import { ApiError, editRoleFormSchema } from "@/types"; +import { + getUnlimitedVisibilityField, + getVisiblePermissionFormFields, + useManageProvidersUnlimitedVisibility, +} from "./role-permissions"; +import { UnlimitedVisibilitySection } from "./unlimited-visibility-section"; + type FormValues = z.input; export const EditRoleForm = ({ @@ -42,11 +48,9 @@ export const EditRoleForm = ({ 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 visiblePermissionFormFields = + getVisiblePermissionFormFields(isCloudEnvironment); + const unlimitedVisibilityField = getUnlimitedVisibilityField(); const form = useForm({ resolver: zodResolver(editRoleFormSchema), @@ -58,30 +62,18 @@ export const EditRoleForm = ({ }, }); - 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 { + isUnlimitedVisibilityRequiredByManageProviders, + setPermissionValue, + setUnlimitedVisibility, + } = useManageProvidersUnlimitedVisibility(form); + const unlimitedVisibility = form.watch("unlimited_visibility"); 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, - }); + setPermissionValue(field, checked); }); }; @@ -180,6 +172,34 @@ export const EditRoleForm = ({ isRequired /> + {unlimitedVisibilityField && ( + + + Enable Unlimited Visibility for this role + + {isUnlimitedVisibilityRequiredByManageProviders && ( +

+ Manage Providers is selected, so Unlimited Visibility stays + enabled in this form. If Manage Providers enabled it + automatically, clearing Manage Providers also clears that + automatic selection. If Unlimited Visibility was already + enabled, clearing Manage Providers only lets you edit it + separately. +

+ )} +
+ )} +
Admin Permissions @@ -188,7 +208,7 @@ export const EditRoleForm = ({ isSelected={visiblePermissionFormFields.every((perm) => form.watch(perm.field as keyof FormValues), )} - onChange={(e) => onSelectAllChange(e.target.checked)} + onValueChange={onSelectAllChange} classNames={{ label: "text-small", wrapper: "checkbox-update", @@ -206,6 +226,9 @@ export const EditRoleForm = ({ + setPermissionValue(field, checked) + } classNames={{ label: "text-small", wrapper: "checkbox-update", diff --git a/ui/components/roles/workflow/forms/role-permissions.ts b/ui/components/roles/workflow/forms/role-permissions.ts new file mode 100644 index 00000000000..39cd289a0d6 --- /dev/null +++ b/ui/components/roles/workflow/forms/role-permissions.ts @@ -0,0 +1,92 @@ +import { useEffect, useRef } from "react"; +import type { + FieldValues, + Path, + PathValue, + UseFormReturn, +} from "react-hook-form"; + +import { permissionFormFields } from "@/lib"; + +type RolePermissionValues = { + manage_providers?: boolean; + unlimited_visibility?: boolean; +}; + +const hiddenOutsideCloudFields = ["manage_billing", "manage_alerts"]; + +const setBooleanFormValue = ( + form: Pick, "setValue">, + field: Path, + value: boolean, +) => { + form.setValue(field, value as PathValue>, { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }); +}; + +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"); + +export const useManageProvidersUnlimitedVisibility = < + T extends FieldValues & RolePermissionValues, +>( + form: Pick, "setValue" | "watch">, +) => { + const autoEnabledUnlimitedVisibility = useRef(false); + + const manageProviders = form.watch("manage_providers" as Path); + const unlimitedVisibility = form.watch("unlimited_visibility" as Path); + + const setUnlimitedVisibility = (checked: boolean) => { + if (manageProviders && !checked) { + autoEnabledUnlimitedVisibility.current = true; + setBooleanFormValue(form, "unlimited_visibility" as Path, true); + return; + } + + autoEnabledUnlimitedVisibility.current = false; + setBooleanFormValue(form, "unlimited_visibility" as Path, checked); + }; + + const setPermissionValue = (field: string, checked: boolean) => { + setBooleanFormValue(form, field as Path, checked); + + if (field !== "manage_providers") { + return; + } + + if (checked && unlimitedVisibility === false) { + autoEnabledUnlimitedVisibility.current = true; + setBooleanFormValue(form, "unlimited_visibility" as Path, true); + } + + if (!checked && autoEnabledUnlimitedVisibility.current) { + autoEnabledUnlimitedVisibility.current = false; + setBooleanFormValue(form, "unlimited_visibility" as Path, false); + } + }; + + useEffect(() => { + if (manageProviders && unlimitedVisibility === false) { + autoEnabledUnlimitedVisibility.current = true; + setBooleanFormValue(form, "unlimited_visibility" as Path, true); + } + }, [form, manageProviders, unlimitedVisibility]); + + return { + isUnlimitedVisibilityRequiredByManageProviders: !!manageProviders, + setPermissionValue, + setUnlimitedVisibility, + }; +}; 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..c6b05ea78e3 --- /dev/null +++ b/ui/components/roles/workflow/forms/unlimited-visibility-section.tsx @@ -0,0 +1,52 @@ +import { Eye } from "lucide-react"; +import { ReactNode } from "react"; + +export const UnlimitedVisibilitySection = ({ + children, +}: { + children: ReactNode; +}) => { + return ( +
+
+
+ +
+

+ Unlimited Visibility +

+

+ This is a tenant-wide visibility setting. It grants visibility + into every provider, account, resource, finding, scan, and + compliance result, regardless of the groups selected below. +

+
+
+ +
+

+ What it does not grant: Unlimited Visibility does + not grant admin actions such as managing users, providers, scans, + integrations, billing, or alerts. Those actions still require their + own admin permissions. +

+

+ When to enable it: Enable it only for roles that + need tenant-wide security visibility, such as security leadership, + audit, or global operations roles. Do not enable it for teams that + should remain limited to specific groups or accounts. +

+

+ Manage Providers dependency: Manage Providers + enables Unlimited Visibility in this form because provider + administration needs tenant-wide provider-group context. Selecting + Manage Providers, or Grant all admin permissions, enables this + visibility setting automatically. +

+
+ +
{children}
+
+
+ ); +}; 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", From 08abb3c27b9ce6e86cdef6b3282636ee9e373891 Mon Sep 17 00:00:00 2001 From: "Hugo P.Brito" Date: Mon, 6 Jul 2026 14:41:44 +0100 Subject: [PATCH 2/7] docs(ui): add RBAC visibility changelog entry --- ui/CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 7a0a7de47a2..15f32c1c1c0 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler UI** are documented in this file. +## [1.32.1] (Prowler UNRELEASED) + +### 🔄 Changed + +- RBAC role forms now explain Unlimited Visibility in a dedicated section and preserve explicit visibility choices when admin permissions are toggled [(#11851)](https://github.com/prowler-cloud/prowler/pull/11851) + +--- + ## [1.32.0] (Prowler v5.32.0) ### 🚀 Added From f6cc3298e5f260386cf3d6ad305e39726072dd14 Mon Sep 17 00:00:00 2001 From: "Hugo P.Brito" Date: Mon, 6 Jul 2026 14:47:32 +0100 Subject: [PATCH 3/7] docs(ui): move RBAC changelog entry to 1.32.2 --- ui/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 15f32c1c1c0..9312b35f11a 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to the **Prowler UI** are documented in this file. -## [1.32.1] (Prowler UNRELEASED) +## [1.32.2] (Prowler UNRELEASED) ### 🔄 Changed From 2f06f9052dfaf04651fe877c2f25c28ec11bbae3 Mon Sep 17 00:00:00 2001 From: "Hugo P.Brito" Date: Mon, 6 Jul 2026 15:14:00 +0100 Subject: [PATCH 4/7] refactor(ui): address RBAC visibility review --- .../roles/workflow/forms/add-role-form.tsx | 40 +++++-------------- .../workflow/forms/edit-role-form.test.tsx | 15 +++++++ .../roles/workflow/forms/edit-role-form.tsx | 40 +++++-------------- .../forms/unlimited-visibility-section.tsx | 38 ++++++++++++++++++ ...-manage-providers-unlimited-visibility.ts} | 27 ++----------- ui/lib/role-permissions.ts | 14 +++++++ 6 files changed, 90 insertions(+), 84 deletions(-) rename ui/{components/roles/workflow/forms/role-permissions.ts => hooks/use-manage-providers-unlimited-visibility.ts} (67%) create mode 100644 ui/lib/role-permissions.ts diff --git a/ui/components/roles/workflow/forms/add-role-form.tsx b/ui/components/roles/workflow/forms/add-role-form.tsx index d5c06efc73b..bc0f22b9677 100644 --- a/ui/components/roles/workflow/forms/add-role-form.tsx +++ b/ui/components/roles/workflow/forms/add-role-form.tsx @@ -15,15 +15,15 @@ import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-s import { useToast } from "@/components/ui"; import { CustomInput } from "@/components/ui/custom"; import { Form, FormButtons } from "@/components/ui/form"; +import { useManageProvidersUnlimitedVisibility } from "@/hooks/use-manage-providers-unlimited-visibility"; import { getErrorMessage } from "@/lib"; -import { addRoleFormSchema, ApiError } from "@/types"; - import { getUnlimitedVisibilityField, getVisiblePermissionFormFields, - useManageProvidersUnlimitedVisibility, -} from "./role-permissions"; -import { UnlimitedVisibilitySection } from "./unlimited-visibility-section"; +} from "@/lib/role-permissions"; +import { addRoleFormSchema, ApiError } from "@/types"; + +import { UnlimitedVisibilityField } from "./unlimited-visibility-section"; type FormValues = z.input; @@ -153,31 +153,11 @@ export const AddRoleForm = ({ /> {unlimitedVisibilityField && ( - - - Enable Unlimited Visibility for this role - - {isUnlimitedVisibilityRequiredByManageProviders && ( -

- Manage Providers is selected, so Unlimited Visibility stays - enabled in this form. If Manage Providers enabled it - automatically, clearing Manage Providers also clears that - automatic selection. If Unlimited Visibility was already - enabled, clearing Manage Providers only lets you edit it - separately. -

- )} -
+ )}
diff --git a/ui/components/roles/workflow/forms/edit-role-form.test.tsx b/ui/components/roles/workflow/forms/edit-role-form.test.tsx index aa027567051..35badf18055 100644 --- a/ui/components/roles/workflow/forms/edit-role-form.test.tsx +++ b/ui/components/roles/workflow/forms/edit-role-form.test.tsx @@ -224,4 +224,19 @@ describe("EditRoleForm", () => { 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 0f0aaca7cb8..5f4ab4f3cbb 100644 --- a/ui/components/roles/workflow/forms/edit-role-form.tsx +++ b/ui/components/roles/workflow/forms/edit-role-form.tsx @@ -15,15 +15,15 @@ import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-s import { useToast } from "@/components/ui"; import { CustomInput } from "@/components/ui/custom"; import { Form, FormButtons } from "@/components/ui/form"; +import { useManageProvidersUnlimitedVisibility } from "@/hooks/use-manage-providers-unlimited-visibility"; import { getErrorMessage } from "@/lib"; -import { ApiError, editRoleFormSchema } from "@/types"; - import { getUnlimitedVisibilityField, getVisiblePermissionFormFields, - useManageProvidersUnlimitedVisibility, -} from "./role-permissions"; -import { UnlimitedVisibilitySection } from "./unlimited-visibility-section"; +} from "@/lib/role-permissions"; +import { ApiError, editRoleFormSchema } from "@/types"; + +import { UnlimitedVisibilityField } from "./unlimited-visibility-section"; type FormValues = z.input; @@ -173,31 +173,11 @@ export const EditRoleForm = ({ /> {unlimitedVisibilityField && ( - - - Enable Unlimited Visibility for this role - - {isUnlimitedVisibilityRequiredByManageProviders && ( -

- Manage Providers is selected, so Unlimited Visibility stays - enabled in this form. If Manage Providers enabled it - automatically, clearing Manage Providers also clears that - automatic selection. If Unlimited Visibility was already - enabled, clearing Manage Providers only lets you edit it - separately. -

- )} -
+ )}
diff --git a/ui/components/roles/workflow/forms/unlimited-visibility-section.tsx b/ui/components/roles/workflow/forms/unlimited-visibility-section.tsx index c6b05ea78e3..7a2e6dcff70 100644 --- a/ui/components/roles/workflow/forms/unlimited-visibility-section.tsx +++ b/ui/components/roles/workflow/forms/unlimited-visibility-section.tsx @@ -1,3 +1,4 @@ +import { Checkbox } from "@heroui/checkbox"; import { Eye } from "lucide-react"; import { ReactNode } from "react"; @@ -50,3 +51,40 @@ export const UnlimitedVisibilitySection = ({ ); }; + +export const UnlimitedVisibilityField = ({ + isSelected, + isDisabled, + onValueChange, +}: { + isSelected: boolean; + isDisabled: boolean; + onValueChange: (checked: boolean) => void; +}) => { + return ( + + + Enable Unlimited Visibility for this role + + {isDisabled && ( +

+ Manage Providers is selected, so Unlimited Visibility stays enabled in + this form. If Manage Providers enabled it automatically, clearing + Manage Providers also clears that automatic selection. If Unlimited + Visibility was already enabled, clearing Manage Providers only lets + you edit it separately. +

+ )} +
+ ); +}; diff --git a/ui/components/roles/workflow/forms/role-permissions.ts b/ui/hooks/use-manage-providers-unlimited-visibility.ts similarity index 67% rename from ui/components/roles/workflow/forms/role-permissions.ts rename to ui/hooks/use-manage-providers-unlimited-visibility.ts index 39cd289a0d6..27b96a5d699 100644 --- a/ui/components/roles/workflow/forms/role-permissions.ts +++ b/ui/hooks/use-manage-providers-unlimited-visibility.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useRef } from "react"; import type { FieldValues, Path, @@ -6,15 +6,11 @@ import type { UseFormReturn, } from "react-hook-form"; -import { permissionFormFields } from "@/lib"; - type RolePermissionValues = { manage_providers?: boolean; unlimited_visibility?: boolean; }; -const hiddenOutsideCloudFields = ["manage_billing", "manage_alerts"]; - const setBooleanFormValue = ( form: Pick, "setValue">, field: Path, @@ -27,17 +23,6 @@ const setBooleanFormValue = ( }); }; -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"); - export const useManageProvidersUnlimitedVisibility = < T extends FieldValues & RolePermissionValues, >( @@ -77,15 +62,9 @@ export const useManageProvidersUnlimitedVisibility = < } }; - useEffect(() => { - if (manageProviders && unlimitedVisibility === false) { - autoEnabledUnlimitedVisibility.current = true; - setBooleanFormValue(form, "unlimited_visibility" as Path, true); - } - }, [form, manageProviders, unlimitedVisibility]); - return { - isUnlimitedVisibilityRequiredByManageProviders: !!manageProviders, + isUnlimitedVisibilityRequiredByManageProviders: + !!manageProviders && !!unlimitedVisibility, setPermissionValue, setUnlimitedVisibility, }; 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"); From 940ee679cc4080a2619aab1d094966a29ca89da9 Mon Sep 17 00:00:00 2001 From: "Hugo P.Brito" Date: Tue, 7 Jul 2026 12:08:51 +0100 Subject: [PATCH 5/7] fix(ui): refine RBAC visibility guidance --- .../workflow/forms/add-role-form.test.tsx | 76 +++++++++++---- .../roles/workflow/forms/add-role-form.tsx | 92 ++++++++++--------- .../workflow/forms/edit-role-form.test.tsx | 75 ++++++++++++++- .../roles/workflow/forms/edit-role-form.tsx | 89 +++++++++--------- .../forms/unlimited-visibility-section.tsx | 63 +++---------- 5 files changed, 236 insertions(+), 159 deletions(-) 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 4c5c2628838..f3fc9f843a4 100644 --- a/ui/components/roles/workflow/forms/add-role-form.test.tsx +++ b/ui/components/roles/workflow/forms/add-role-form.test.tsx @@ -118,34 +118,76 @@ describe("AddRoleForm", () => { expect(routerMocks.push).toHaveBeenCalledWith("/roles"); }); - it("explains Unlimited Visibility independently from admin permissions", () => { + it("shows a subtle inline Unlimited Visibility description", () => { // Given / When render(); // Then + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect( - screen.getByRole("heading", { name: "Unlimited Visibility" }), - ).toBeInTheDocument(); + 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( - /grants visibility into every provider, account, resource, finding, scan, and compliance result/i, - ), - ).toBeInTheDocument(); + screen.getByText(/required to use the Jira integration/i), + ).toHaveProperty("tagName", "STRONG"); + expect( + screen.queryByRole("heading", { name: "Unlimited Visibility" }), + ).not.toBeInTheDocument(); expect( - screen.getByText( + screen.queryByText( /does not grant admin actions such as managing users, providers, scans, integrations, billing, or alerts/i, ), - ).toBeInTheDocument(); + ).not.toBeInTheDocument(); expect( - screen.getByText( + screen.queryByText( /enable it only for roles that need tenant-wide security visibility/i, ), - ).toBeInTheDocument(); + ).not.toBeInTheDocument(); expect( - screen.getByText( + 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("enables Unlimited Visibility when Manage Providers is selected", async () => { @@ -165,10 +207,10 @@ describe("AddRoleForm", () => { }), ).toBeChecked(); expect( - screen.getByText( + screen.queryByText( /Manage Providers is selected, so Unlimited Visibility stays enabled in this form/i, ), - ).toBeInTheDocument(); + ).not.toBeInTheDocument(); }); it("enables Unlimited Visibility through Manage Providers when granting all admin permissions", async () => { @@ -245,7 +287,7 @@ describe("AddRoleForm", () => { ).toBeChecked(); }); - it("does not describe clearing Manage Providers as removing explicitly enabled Unlimited Visibility", async () => { + it("does not show extra Manage Providers guidance for explicitly enabled Unlimited Visibility", async () => { // Given const user = userEvent.setup(); render(); @@ -262,10 +304,10 @@ describe("AddRoleForm", () => { // Then expect( - screen.getByText( + screen.queryByText( /Manage Providers is selected, so Unlimited Visibility stays enabled in this form/i, ), - ).toBeInTheDocument(); + ).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 bc0f22b9677..99bee2d852e 100644 --- a/ui/components/roles/workflow/forms/add-role-form.tsx +++ b/ui/components/roles/workflow/forms/add-role-form.tsx @@ -152,14 +152,6 @@ export const AddRoleForm = ({ isRequired /> - {unlimitedVisibilityField && ( - - )} -
Admin Permissions @@ -213,48 +205,58 @@ export const AddRoleForm = ({ )}
- - {!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. -

+
+ Visibility - ( -
- ({ - 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} - /> -
- )} + {unlimitedVisibilityField && ( + - {form.formState.errors.groups && ( -

- {form.formState.errors.groups.message} + )} + + {!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} +

+ )} + + )} +
{ 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("enables Unlimited Visibility when Manage Providers is selected", async () => { // Given const user = userEvent.setup(); @@ -130,10 +195,10 @@ describe("EditRoleForm", () => { }), ).toBeChecked(); expect( - screen.getByText( + screen.queryByText( /Manage Providers is selected, so Unlimited Visibility stays enabled in this form/i, ), - ).toBeInTheDocument(); + ).not.toBeInTheDocument(); }); it("enables Unlimited Visibility through Manage Providers when granting all admin permissions", async () => { @@ -210,16 +275,16 @@ describe("EditRoleForm", () => { ).toBeChecked(); }); - it("does not describe clearing Manage Providers as removing existing Unlimited Visibility", async () => { + it("does not show extra Manage Providers guidance", () => { // Given / When renderEditRoleForm({ manageProviders: true, unlimitedVisibility: true }); // Then expect( - await screen.findByText( + screen.queryByText( /Manage Providers is selected, so Unlimited Visibility stays enabled in this form/i, ), - ).toBeInTheDocument(); + ).not.toBeInTheDocument(); expect( screen.queryByText(/remove this automatic visibility grant/i), ).not.toBeInTheDocument(); diff --git a/ui/components/roles/workflow/forms/edit-role-form.tsx b/ui/components/roles/workflow/forms/edit-role-form.tsx index 5f4ab4f3cbb..c62a07d6f68 100644 --- a/ui/components/roles/workflow/forms/edit-role-form.tsx +++ b/ui/components/roles/workflow/forms/edit-role-form.tsx @@ -172,14 +172,6 @@ export const EditRoleForm = ({ isRequired /> - {unlimitedVisibilityField && ( - - )} -
Admin Permissions @@ -233,47 +225,58 @@ export const EditRoleForm = ({ )}
- - {!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. -

+
+ Visibility - ( -
- ({ - 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} - /> -
- )} + {unlimitedVisibilityField && ( + + )} - {form.formState.errors.groups && ( -

- {form.formState.errors.groups.message} + {!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} +

+ )} + + )} +
{ return ( -
-
-
- -
-

- Unlimited Visibility -

-

- This is a tenant-wide visibility setting. It grants visibility - into every provider, account, resource, finding, scan, and - compliance result, regardless of the groups selected below. -

-
-
- -
-

- What it does not grant: Unlimited Visibility does - not grant admin actions such as managing users, providers, scans, - integrations, billing, or alerts. Those actions still require their - own admin permissions. -

-

- When to enable it: Enable it only for roles that - need tenant-wide security visibility, such as security leadership, - audit, or global operations roles. Do not enable it for teams that - should remain limited to specific groups or accounts. -

-

- Manage Providers dependency: Manage Providers - enables Unlimited Visibility in this form because provider - administration needs tenant-wide provider-group context. Selecting - Manage Providers, or Grant all admin permissions, enables this - visibility setting automatically. -

-
- -
{children}
+
+
+
+
{children}
); }; @@ -76,15 +50,6 @@ export const UnlimitedVisibilityField = ({ > Enable Unlimited Visibility for this role - {isDisabled && ( -

- Manage Providers is selected, so Unlimited Visibility stays enabled in - this form. If Manage Providers enabled it automatically, clearing - Manage Providers also clears that automatic selection. If Unlimited - Visibility was already enabled, clearing Manage Providers only lets - you edit it separately. -

- )} ); }; From af105339d53048edfbd27ada061ff00c294052a8 Mon Sep 17 00:00:00 2001 From: "Hugo P.Brito" Date: Tue, 7 Jul 2026 12:44:26 +0100 Subject: [PATCH 6/7] refactor(ui): share RBAC role form layout --- .../workflow/forms/add-role-form.test.tsx | 37 +++- .../roles/workflow/forms/add-role-form.tsx | 168 ++-------------- .../workflow/forms/edit-role-form.test.tsx | 37 +++- .../roles/workflow/forms/edit-role-form.tsx | 172 +++-------------- .../roles/workflow/forms/role-form.tsx | 180 ++++++++++++++++++ .../forms/unlimited-visibility-section.tsx | 33 ++-- ...e-manage-providers-unlimited-visibility.ts | 29 --- 7 files changed, 295 insertions(+), 361 deletions(-) create mode 100644 ui/components/roles/workflow/forms/role-form.tsx 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 f3fc9f843a4..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(); @@ -190,7 +201,7 @@ describe("AddRoleForm", () => { ).not.toBeInTheDocument(); }); - it("enables Unlimited Visibility when Manage Providers is selected", async () => { + it("does not force Unlimited Visibility when Manage Providers is selected", async () => { // Given const user = userEvent.setup(); render(); @@ -205,7 +216,7 @@ describe("AddRoleForm", () => { screen.getByRole("checkbox", { name: "Enable Unlimited Visibility for this role", }), - ).toBeChecked(); + ).not.toBeChecked(); expect( screen.queryByText( /Manage Providers is selected, so Unlimited Visibility stays enabled in this form/i, @@ -213,7 +224,7 @@ describe("AddRoleForm", () => { ).not.toBeInTheDocument(); }); - it("enables Unlimited Visibility through Manage Providers when granting all admin permissions", async () => { + it("does not force Unlimited Visibility when granting all admin permissions", async () => { // Given const user = userEvent.setup(); render(); @@ -231,31 +242,39 @@ describe("AddRoleForm", () => { screen.getByRole("checkbox", { name: "Enable Unlimited Visibility for this role", }), - ).toBeChecked(); + ).not.toBeChecked(); }); - it("clears Unlimited Visibility when all admin permissions are toggled off after only auto-enabling it", async () => { + 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: "Grant all admin permissions" }), + screen.getByRole("checkbox", { name: "Manage Providers" }), ); await user.click( - screen.getByRole("checkbox", { name: "Grant all admin permissions" }), + 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" }), - ).not.toBeChecked(); + ).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 () => { diff --git a/ui/components/roles/workflow/forms/add-role-form.tsx b/ui/components/roles/workflow/forms/add-role-form.tsx index 99bee2d852e..e91a772fdd5 100644 --- a/ui/components/roles/workflow/forms/add-role-form.tsx +++ b/ui/components/roles/workflow/forms/add-role-form.tsx @@ -1,20 +1,11 @@ "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 { Controller, useForm } from "react-hook-form"; -import { z } from "zod"; +import { useForm } 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 { useManageProvidersUnlimitedVisibility } from "@/hooks/use-manage-providers-unlimited-visibility"; import { getErrorMessage } from "@/lib"; import { @@ -23,9 +14,7 @@ import { } from "@/lib/role-permissions"; import { addRoleFormSchema, ApiError } from "@/types"; -import { UnlimitedVisibilityField } from "./unlimited-visibility-section"; - -type FormValues = z.input; +import { RoleForm, RoleFormValues } from "./role-form"; export const AddRoleForm = ({ groups, @@ -39,7 +28,7 @@ export const AddRoleForm = ({ getVisiblePermissionFormFields(isCloudEnvironment); const unlimitedVisibilityField = getUnlimitedVisibilityField(); - const form = useForm({ + const form = useForm({ resolver: zodResolver(addRoleFormSchema), defaultValues: { name: "", @@ -56,11 +45,8 @@ export const AddRoleForm = ({ }, }); - const { - isUnlimitedVisibilityRequiredByManageProviders, - setPermissionValue, - setUnlimitedVisibility, - } = useManageProvidersUnlimitedVisibility(form); + const { setPermissionValue, setUnlimitedVisibility } = + useManageProvidersUnlimitedVisibility(form); const unlimitedVisibility = form.watch("unlimited_visibility"); const isLoading = form.formState.isSubmitting; @@ -71,7 +57,7 @@ export const AddRoleForm = ({ }); }; - const onSubmitClient = async (values: FormValues) => { + const onSubmitClient = async (values: RoleFormValues) => { const formData = new FormData(); formData.append("name", values.name); @@ -136,133 +122,19 @@ export const AddRoleForm = ({ }; return ( -
- - - -
- Admin Permissions - - {/* Select All Checkbox */} - - form.watch(perm.field as keyof FormValues), - )} - onValueChange={onSelectAllChange} - classNames={{ - label: "text-small", - wrapper: "checkbox-update", - }} - color="default" - > - Grant all admin permissions - - - {/* Permissions Grid */} -
- {visiblePermissionFormFields.map( - ({ field, label, description }) => ( -
- - setPermissionValue(field, checked) - } - classNames={{ - label: "text-small", - wrapper: "checkbox-update", - }} - color="default" - > - {label} - - -
- -
-
-
- ), - )} -
-
- - - -
- Visibility - - {unlimitedVisibilityField && ( - - )} - - {!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} -

- )} - - )} -
- router.push("/roles")} - /> - - + router.push("/roles")} + onSubmit={onSubmitClient} + onSelectAllChange={onSelectAllChange} + setPermissionValue={setPermissionValue} + setUnlimitedVisibility={setUnlimitedVisibility} + /> ); }; diff --git a/ui/components/roles/workflow/forms/edit-role-form.test.tsx b/ui/components/roles/workflow/forms/edit-role-form.test.tsx index 0c5cec6ad2b..fe1dd23639c 100644 --- a/ui/components/roles/workflow/forms/edit-role-form.test.tsx +++ b/ui/components/roles/workflow/forms/edit-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 { EditRoleForm } from "./edit-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; +}); + const roleData = ({ manageProviders = false, unlimitedVisibility = false, @@ -178,7 +189,7 @@ describe("EditRoleForm", () => { ).not.toBeInTheDocument(); }); - it("enables Unlimited Visibility when Manage Providers is selected", async () => { + it("does not force Unlimited Visibility when Manage Providers is selected", async () => { // Given const user = userEvent.setup(); renderEditRoleForm(); @@ -193,7 +204,7 @@ describe("EditRoleForm", () => { screen.getByRole("checkbox", { name: "Enable Unlimited Visibility for this role", }), - ).toBeChecked(); + ).not.toBeChecked(); expect( screen.queryByText( /Manage Providers is selected, so Unlimited Visibility stays enabled in this form/i, @@ -201,7 +212,7 @@ describe("EditRoleForm", () => { ).not.toBeInTheDocument(); }); - it("enables Unlimited Visibility through Manage Providers when granting all admin permissions", async () => { + it("does not force Unlimited Visibility when granting all admin permissions", async () => { // Given const user = userEvent.setup(); renderEditRoleForm(); @@ -219,31 +230,39 @@ describe("EditRoleForm", () => { screen.getByRole("checkbox", { name: "Enable Unlimited Visibility for this role", }), - ).toBeChecked(); + ).not.toBeChecked(); }); - it("clears Unlimited Visibility when all admin permissions are toggled off after only auto-enabling it", async () => { + 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: "Grant all admin permissions" }), + screen.getByRole("checkbox", { name: "Manage Providers" }), ); await user.click( - screen.getByRole("checkbox", { name: "Grant all admin permissions" }), + 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" }), - ).not.toBeChecked(); + ).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 () => { diff --git a/ui/components/roles/workflow/forms/edit-role-form.tsx b/ui/components/roles/workflow/forms/edit-role-form.tsx index c62a07d6f68..12833af742c 100644 --- a/ui/components/roles/workflow/forms/edit-role-form.tsx +++ b/ui/components/roles/workflow/forms/edit-role-form.tsx @@ -1,20 +1,11 @@ "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 { Controller, useForm } from "react-hook-form"; -import { z } from "zod"; +import { useForm } 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 { useManageProvidersUnlimitedVisibility } from "@/hooks/use-manage-providers-unlimited-visibility"; import { getErrorMessage } from "@/lib"; import { @@ -23,9 +14,7 @@ import { } from "@/lib/role-permissions"; import { ApiError, editRoleFormSchema } from "@/types"; -import { UnlimitedVisibilityField } from "./unlimited-visibility-section"; - -type FormValues = z.input; +import { RoleForm, RoleFormValues } from "./role-form"; export const EditRoleForm = ({ roleId, @@ -35,7 +24,7 @@ export const EditRoleForm = ({ roleId: string; roleData: { data: { - attributes: FormValues; + attributes: RoleFormValues; relationships?: { provider_groups?: { data: Array<{ id: string; type: string }>; @@ -52,7 +41,7 @@ export const EditRoleForm = ({ getVisiblePermissionFormFields(isCloudEnvironment); const unlimitedVisibilityField = getUnlimitedVisibilityField(); - const form = useForm({ + const form = useForm({ resolver: zodResolver(editRoleFormSchema), defaultValues: { ...roleData.data.attributes, @@ -62,11 +51,8 @@ export const EditRoleForm = ({ }, }); - const { - isUnlimitedVisibilityRequiredByManageProviders, - setPermissionValue, - setUnlimitedVisibility, - } = useManageProvidersUnlimitedVisibility(form); + const { setPermissionValue, setUnlimitedVisibility } = + useManageProvidersUnlimitedVisibility(form); const unlimitedVisibility = form.watch("unlimited_visibility"); const isLoading = form.formState.isSubmitting; @@ -77,9 +63,9 @@ export const EditRoleForm = ({ }); }; - const onSubmitClient = async (values: FormValues) => { + const onSubmitClient = async (values: RoleFormValues) => { try { - const updatedFields: Partial = {}; + const updatedFields: Partial = {}; if (values.name !== roleData.data.attributes.name) { updatedFields.name = values.name; @@ -156,133 +142,19 @@ export const EditRoleForm = ({ }; return ( -
- - - -
- Admin Permissions - - {/* Select All Checkbox */} - - form.watch(perm.field as keyof FormValues), - )} - onValueChange={onSelectAllChange} - classNames={{ - label: "text-small", - wrapper: "checkbox-update", - }} - color="default" - > - Grant all admin permissions - - - {/* Permissions Grid */} -
- {visiblePermissionFormFields.map( - ({ field, label, description }) => ( -
- - setPermissionValue(field, checked) - } - classNames={{ - label: "text-small", - wrapper: "checkbox-update", - }} - color="default" - > - {label} - - -
- -
-
-
- ), - )} -
-
- - - -
- Visibility - - {unlimitedVisibilityField && ( - - )} - - {!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} -

- )} - - )} -
- router.push("/roles")} - /> - - + router.push("/roles")} + onSubmit={onSubmitClient} + onSelectAllChange={onSelectAllChange} + setPermissionValue={setPermissionValue} + setUnlimitedVisibility={setUnlimitedVisibility} + /> ); }; 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..ed18fd0a074 --- /dev/null +++ b/ui/components/roles/workflow/forms/role-form.tsx @@ -0,0 +1,180 @@ +import { Checkbox } from "@heroui/checkbox"; +import { Divider } from "@heroui/divider"; +import { Tooltip } from "@heroui/tooltip"; +import { clsx } from "clsx"; +import { InfoIcon } from "lucide-react"; +import { Controller, UseFormReturn } from "react-hook-form"; +import { z } from "zod"; + +import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select"; +import { CustomInput } from "@/components/ui/custom"; +import { Form, FormButtons } from "@/components/ui/form"; +import { addRoleFormSchema } from "@/types"; + +import { UnlimitedVisibilityField } from "./unlimited-visibility-section"; + +export type RoleFormValues = z.input; + +interface RoleFormProps { + form: UseFormReturn; + groups: { id: string; name: string }[]; + visiblePermissionFormFields: { + field: string; + label: string; + description: string; + }[]; + isLoading: boolean; + unlimitedVisibility: boolean; + showUnlimitedVisibilityField: boolean; + submitText: string; + onCancel: () => void; + onSubmit: (values: RoleFormValues) => void | Promise; + onSelectAllChange: (checked: boolean) => void; + setPermissionValue: (field: string, checked: boolean) => void; + setUnlimitedVisibility: (checked: boolean) => void; +} + +export const RoleForm = ({ + form, + groups, + visiblePermissionFormFields, + isLoading, + unlimitedVisibility, + showUnlimitedVisibilityField, + submitText, + onCancel, + onSubmit, + onSelectAllChange, + setPermissionValue, + setUnlimitedVisibility, +}: RoleFormProps) => { + return ( +
+ + + +
+ Admin Permissions + + {/* Select All Checkbox */} + + form.watch(perm.field as keyof RoleFormValues), + )} + onValueChange={onSelectAllChange} + classNames={{ + label: "text-small", + wrapper: "checkbox-update", + }} + color="default" + > + Grant all admin permissions + + + {/* Permissions Grid */} +
+ {visiblePermissionFormFields.map( + ({ field, label, description }) => ( +
+ + setPermissionValue(field, checked) + } + classNames={{ + label: "text-small", + wrapper: "checkbox-update", + }} + color="default" + > + {label} + + +
+
+
+
+ ), + )} +
+
+ + + +
+ 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 index 5abe596acdf..50a2e2292d7 100644 --- a/ui/components/roles/workflow/forms/unlimited-visibility-section.tsx +++ b/ui/components/roles/workflow/forms/unlimited-visibility-section.tsx @@ -1,7 +1,8 @@ -import { Checkbox } from "@heroui/checkbox"; import { InfoIcon } from "lucide-react"; import { ReactNode } from "react"; +import { Checkbox } from "@/components/shadcn/checkbox/checkbox"; + export const UnlimitedVisibilitySection = ({ children, }: { @@ -28,28 +29,28 @@ export const UnlimitedVisibilitySection = ({ export const UnlimitedVisibilityField = ({ isSelected, - isDisabled, onValueChange, }: { isSelected: boolean; - isDisabled: boolean; onValueChange: (checked: boolean) => void; }) => { return ( - - Enable Unlimited Visibility for this role - +
+ 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 index 27b96a5d699..6c4990e6c5c 100644 --- a/ui/hooks/use-manage-providers-unlimited-visibility.ts +++ b/ui/hooks/use-manage-providers-unlimited-visibility.ts @@ -1,4 +1,3 @@ -import { useRef } from "react"; import type { FieldValues, Path, @@ -28,43 +27,15 @@ export const useManageProvidersUnlimitedVisibility = < >( form: Pick, "setValue" | "watch">, ) => { - const autoEnabledUnlimitedVisibility = useRef(false); - - const manageProviders = form.watch("manage_providers" as Path); - const unlimitedVisibility = form.watch("unlimited_visibility" as Path); - const setUnlimitedVisibility = (checked: boolean) => { - if (manageProviders && !checked) { - autoEnabledUnlimitedVisibility.current = true; - setBooleanFormValue(form, "unlimited_visibility" as Path, true); - return; - } - - autoEnabledUnlimitedVisibility.current = false; setBooleanFormValue(form, "unlimited_visibility" as Path, checked); }; const setPermissionValue = (field: string, checked: boolean) => { setBooleanFormValue(form, field as Path, checked); - - if (field !== "manage_providers") { - return; - } - - if (checked && unlimitedVisibility === false) { - autoEnabledUnlimitedVisibility.current = true; - setBooleanFormValue(form, "unlimited_visibility" as Path, true); - } - - if (!checked && autoEnabledUnlimitedVisibility.current) { - autoEnabledUnlimitedVisibility.current = false; - setBooleanFormValue(form, "unlimited_visibility" as Path, false); - } }; return { - isUnlimitedVisibilityRequiredByManageProviders: - !!manageProviders && !!unlimitedVisibility, setPermissionValue, setUnlimitedVisibility, }; From 22f50d65d3c9e186b35bdc6d3e5c18ac07ee057c Mon Sep 17 00:00:00 2001 From: "Pablo F.G" Date: Tue, 7 Jul 2026 15:56:50 +0200 Subject: [PATCH 7/7] refactor(ui): consolidate RBAC role form logic and migrate to shadcn - Own useForm, visibility state and server-error handling inside RoleForm - Have add/edit containers pass only defaultValues, submitText and onSubmit - Merge the identical add/edit role schemas into a single roleFormSchema - Replace HeroUI checkbox, divider, tooltip and input with shadcn equivalents --- .../roles/workflow/forms/add-role-form.tsx | 109 +++------ .../roles/workflow/forms/edit-role-form.tsx | 90 ++------ .../roles/workflow/forms/role-form.tsx | 214 +++++++++++------- ui/types/formSchemas.ts | 16 +- 4 files changed, 186 insertions(+), 243 deletions(-) diff --git a/ui/components/roles/workflow/forms/add-role-form.tsx b/ui/components/roles/workflow/forms/add-role-form.tsx index e91a772fdd5..c02311c0f25 100644 --- a/ui/components/roles/workflow/forms/add-role-form.tsx +++ b/ui/components/roles/workflow/forms/add-role-form.tsx @@ -1,63 +1,38 @@ "use client"; -import { zodResolver } from "@hookform/resolvers/zod"; import { useRouter } from "next/navigation"; -import { useForm } from "react-hook-form"; +import { DefaultValues } from "react-hook-form"; import { addRole } from "@/actions/roles/roles"; import { useToast } from "@/components/ui"; -import { useManageProvidersUnlimitedVisibility } from "@/hooks/use-manage-providers-unlimited-visibility"; import { getErrorMessage } from "@/lib"; -import { - getUnlimitedVisibilityField, - getVisiblePermissionFormFields, -} from "@/lib/role-permissions"; -import { addRoleFormSchema, ApiError } from "@/types"; +import { RoleFormValues } from "@/types"; -import { RoleForm, RoleFormValues } from "./role-form"; +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 = - getVisiblePermissionFormFields(isCloudEnvironment); - const unlimitedVisibilityField = getUnlimitedVisibilityField(); - 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 { setPermissionValue, setUnlimitedVisibility } = - useManageProvidersUnlimitedVisibility(form); - const unlimitedVisibility = form.watch("unlimited_visibility"); - - const isLoading = form.formState.isSubmitting; - - const onSelectAllChange = (checked: boolean) => { - visiblePermissionFormFields.forEach(({ field }) => { - setPermissionValue(field, checked); - }); + 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: RoleFormValues) => { + const onSubmit = async ( + values: RoleFormValues, + { handleServerResponse }: RoleFormSubmitContext, + ) => { const formData = new FormData(); formData.append("name", values.name); @@ -85,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", @@ -123,18 +78,10 @@ export const AddRoleForm = ({ return ( router.push("/roles")} - onSubmit={onSubmitClient} - onSelectAllChange={onSelectAllChange} - setPermissionValue={setPermissionValue} - setUnlimitedVisibility={setUnlimitedVisibility} + onSubmit={onSubmit} /> ); }; diff --git a/ui/components/roles/workflow/forms/edit-role-form.tsx b/ui/components/roles/workflow/forms/edit-role-form.tsx index 12833af742c..4ba474b9434 100644 --- a/ui/components/roles/workflow/forms/edit-role-form.tsx +++ b/ui/components/roles/workflow/forms/edit-role-form.tsx @@ -1,20 +1,14 @@ "use client"; -import { zodResolver } from "@hookform/resolvers/zod"; import { useRouter } from "next/navigation"; -import { useForm } from "react-hook-form"; +import { DefaultValues } from "react-hook-form"; import { updateRole } from "@/actions/roles/roles"; import { useToast } from "@/components/ui"; -import { useManageProvidersUnlimitedVisibility } from "@/hooks/use-manage-providers-unlimited-visibility"; import { getErrorMessage } from "@/lib"; -import { - getUnlimitedVisibilityField, - getVisiblePermissionFormFields, -} from "@/lib/role-permissions"; -import { ApiError, editRoleFormSchema } from "@/types"; +import { RoleFormValues } from "@/types"; -import { RoleForm, RoleFormValues } from "./role-form"; +import { RoleForm, RoleFormSubmitContext, RoleGroupOption } from "./role-form"; export const EditRoleForm = ({ roleId, @@ -32,38 +26,22 @@ 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 = - getVisiblePermissionFormFields(isCloudEnvironment); - const unlimitedVisibilityField = getUnlimitedVisibilityField(); - const form = useForm({ - resolver: zodResolver(editRoleFormSchema), - defaultValues: { - ...roleData.data.attributes, - groups: - roleData.data.relationships?.provider_groups?.data.map((g) => g.id) || - [], - }, - }); - - const { setPermissionValue, setUnlimitedVisibility } = - useManageProvidersUnlimitedVisibility(form); - const unlimitedVisibility = form.watch("unlimited_visibility"); - - const isLoading = form.formState.isSubmitting; - - const onSelectAllChange = (checked: boolean) => { - visiblePermissionFormFields.forEach(({ field }) => { - setPermissionValue(field, checked); - }); + const defaultValues: DefaultValues = { + ...roleData.data.attributes, + groups: + roleData.data.relationships?.provider_groups?.data.map((g) => g.id) || [], }; - const onSubmitClient = async (values: RoleFormValues) => { + const onSubmit = async ( + values: RoleFormValues, + { handleServerResponse }: RoleFormSubmitContext, + ) => { try { const updatedFields: Partial = {}; @@ -105,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", @@ -143,18 +101,10 @@ export const EditRoleForm = ({ return ( router.push("/roles")} - onSubmit={onSubmitClient} - onSelectAllChange={onSelectAllChange} - setPermissionValue={setPermissionValue} - setUnlimitedVisibility={setUnlimitedVisibility} + onSubmit={onSubmit} /> ); }; diff --git a/ui/components/roles/workflow/forms/role-form.tsx b/ui/components/roles/workflow/forms/role-form.tsx index ed18fd0a074..6704cc7b2bf 100644 --- a/ui/components/roles/workflow/forms/role-form.tsx +++ b/ui/components/roles/workflow/forms/role-form.tsx @@ -1,87 +1,145 @@ -import { Checkbox } from "@heroui/checkbox"; -import { Divider } from "@heroui/divider"; -import { Tooltip } from "@heroui/tooltip"; -import { clsx } from "clsx"; +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; import { InfoIcon } from "lucide-react"; -import { Controller, UseFormReturn } from "react-hook-form"; -import { z } from "zod"; +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 { CustomInput } from "@/components/ui/custom"; -import { Form, FormButtons } from "@/components/ui/form"; -import { addRoleFormSchema } from "@/types"; +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 type RoleFormValues = z.input; +export interface RoleGroupOption { + id: string; + name: string; +} -interface RoleFormProps { +export interface RoleFormSubmitContext { form: UseFormReturn; - groups: { id: string; name: string }[]; - visiblePermissionFormFields: { - field: string; - label: string; - description: string; - }[]; - isLoading: boolean; - unlimitedVisibility: boolean; - showUnlimitedVisibilityField: boolean; + handleServerResponse: (data: unknown) => boolean; +} + +interface RoleFormProps { + groups: RoleGroupOption[]; + defaultValues: DefaultValues; submitText: string; - onCancel: () => void; - onSubmit: (values: RoleFormValues) => void | Promise; - onSelectAllChange: (checked: boolean) => void; - setPermissionValue: (field: string, checked: boolean) => void; - setUnlimitedVisibility: (checked: boolean) => void; + onSubmit: ( + values: RoleFormValues, + ctx: RoleFormSubmitContext, + ) => void | Promise; + onCancel?: () => void; } export const RoleForm = ({ - form, groups, - visiblePermissionFormFields, - isLoading, - unlimitedVisibility, - showUnlimitedVisibilityField, + defaultValues, submitText, - onCancel, onSubmit, - onSelectAllChange, - setPermissionValue, - setUnlimitedVisibility, + 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), - )} - onValueChange={onSelectAllChange} - classNames={{ - label: "text-small", - wrapper: "checkbox-update", - }} - color="default" - > - Grant all admin permissions - +
+ + form.watch(perm.field as keyof RoleFormValues), + )} + onCheckedChange={(checked) => onSelectAllChange(Boolean(checked))} + /> + +
{/* Permissions Grid */}
@@ -89,29 +147,27 @@ export const RoleForm = ({ ({ field, label, description }) => (
- setPermissionValue(field, checked) + id={field} + size="sm" + checked={!!form.watch(field as keyof RoleFormValues)} + onCheckedChange={(checked) => + setPermissionValue(field, Boolean(checked)) } - classNames={{ - label: "text-small", - wrapper: "checkbox-update", - }} - color="default" - > + /> + - -
-
+ + + +
+
+
+ {description}
), @@ -119,14 +175,14 @@ export const RoleForm = ({
- +
Visibility {showUnlimitedVisibilityField && ( )} @@ -172,7 +228,7 @@ export const RoleForm = ({ 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({