Skip to content

improvement(policies): policies UI update#5524

Merged
victorvhs017 merged 31 commits intomainfrom
feature/policies-ui-update
Mar 4, 2026
Merged

improvement(policies): policies UI update#5524
victorvhs017 merged 31 commits intomainfrom
feature/policies-ui-update

Conversation

@victorvhs017
Copy link
Contributor

@victorvhs017 victorvhs017 commented Feb 20, 2026

Context

This PR updates the policies/permissions UI and improves permission condition handling across role management, project templates, and member/identity privilege sections.

  • v3 components and UnstableAccordion for policy management in roles, project templates, and privilege sections
  • Action descriptions in permission selectors
  • Action-aware condition validation (e.g. importSecret, duplicateSecret) with per-action condition restrictions
  • Accordion expansion on validation errors
  • useWatch and optimized useMemo for permission handling
  • menuPortalContainerRef for correct modal positioning in privilege sections
  • Backend checkForInvalidPermissionCombination for invalid permission combinations
  • Documentation update

Test actions: importSecret and duplicateSecret are temporary test actions added to simplify API and UI testing for reviewers. They will be removed before merge.


Screenshots

Project role:
image

Project role validation:
image

Read-only:
image

Additional privileges:
image

Project template:
image


Steps to verify the change

UI

  1. Role permissions – Project → Roles → select role → add/edit permissions. Confirm action descriptions, condition validation per action, and accordion expansion on validation errors.
  2. Project templates – Organization Settings → Project Templates → edit template → modify role permissions. Same checks as above.
  3. Member/Identity privileges – Add/edit additional privileges. Confirm modals render correctly and validation errors expand accordion items.

Dynamic conditions (using test actions):

  • Add Import Secret or Duplicate Secret to a role’s secret permissions.
  • Confirm the condition dropdown only shows conditions allowed for that action (e.g. environment for importSecret; secretPath is not allowed).
  • Change selected actions and verify the allowed conditions update accordingly.
  • Add a condition that becomes invalid for another selected action and confirm the UI shows validation errors and disallows save.
  • Confirm the accordion expands to show the validation error when a condition conflicts with an action.

API

  • Valid action-condition combinations – Create/update roles with importSecret or duplicateSecret and allowed conditions (e.g. environment only) → 201.
  • Invalid action-condition combinations – Create/update roles with disallowed conditions for those actions → 400 with error.
  • Role CRUD – Create, update, and delete roles with valid and invalid permission payloads and confirm expected responses.

Documentation

  • Review the updated documentation at internals/permissions/project-permissions

Type

  • Fix
  • Feature
  • Improvement
  • Breaking
  • Docs
  • Chore

Checklist

@maidul98
Copy link
Collaborator

maidul98 commented Feb 20, 2026

Snyk checks have passed. No issues have been found so far.

Status Scanner Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

Victor Santos added 5 commits February 20, 2026 16:43
@victorvhs017 victorvhs017 marked this pull request as ready for review February 27, 2026 16:07
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Feb 27, 2026

Greptile Summary

This PR updates the permissions/policies UI across role management, project templates, and member/identity privilege sections — migrating to v3 accordion components, replacing checkboxes with a multi-select, adding per-action condition validation, and improving validation error messaging. The overall direction is sound, but several issues need to be resolved before merging:

  • Test actions must be removed: importSecret and duplicateSecret are present in both backend (ProjectPermissionSecretActions enum, ActionAllowedConditions) and frontend (types.ts, schema, PROJECT_PERMISSION_OBJECT). The PR description acknowledges these are temporary, but they are currently part of the public API surface. Any external consumer (Terraform, SDK) that uses these actions before they're removed would be broken by their deletion — a breaking API change.
  • computeAllowedConditions intersection bug: The reduce-based intersection in ConditionsFields.tsx uses acc.length === 0 as an initializer sentinel. This fails silently when a 3+ action set produces an empty intermediate intersection, incorrectly treating the empty result as "not yet seeded" and replacing it with the next action's allowed list.
  • "Discard" button regression across three forms: RolePermissionsSection, ProjectTemplateEditRoleForm, and MembershipProjectAdditionalPrivilegeModifySection all migrated from the reactive values prop (where reset() restores the loaded data) to a defaultValues + useEffect pattern (where reset() reverts to the static empty defaults). Clicking "Discard" on any of these forms now clears all data instead of restoring the previously saved state. In ProjectTemplateEditRoleForm the old behavior was onGoBack (navigate away), which is a direct UX regression.
  • MultiValueWithTooltip renders empty tooltips: The tooltip wrapper fires for every selected action chip even when data.description is absent, potentially producing empty tooltip popups on hover.

Confidence Score: 2/5

  • Not safe to merge — test actions in the public API and multiple "Discard" regressions need resolution first.
  • Score is low due to: (1) test-only enum values (importSecret/duplicateSecret) committed to the production backend API — removing them later is a breaking change for any consumer who adopted them; (2) the "Discard" button regression across three form components that would wipe user data; and (3) the computeAllowedConditions intersection logic bug that will silently produce wrong results as soon as a third restricted action is added. The UI/component improvements themselves are well-structured, but these blockers must be resolved first.
  • backend/src/ee/services/permission/project-permission.ts, frontend/src/context/ProjectPermissionContext/types.ts, frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx, frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx, frontend/src/pages/project/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx

Important Files Changed

Filename Overview
backend/src/ee/services/permission/project-permission.ts Adds ImportSecret and DuplicateSecret test actions to ProjectPermissionSecretActions enum and ActionAllowedConditions map — these are self-described as temporary test actions that must be removed before merging, but currently present a breaking-API-change risk if released to production.
frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx Major changes: adds ACTION_ALLOWED_CONDITIONS, createPolicySchemaWithConditions, and getActionLabelsForSubject; migrates ConditionSchema from .refine to .superRefine for per-field errors; adds descriptions to all permission actions. Contains the computeAllowedConditions intersection bug and also includes test action stubs that must be removed.
frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx Migrates from reactive values prop to defaultValues + useEffect reset, adds accordion-expansion-on-error logic, and switches to useWatch. The "Discard" button's reset() call now resets to empty defaults rather than the loaded role data — a UX regression from the old values-prop behaviour.
frontend/src/pages/project/RoleDetailsBySlugPage/components/ConditionsFields.tsx Significantly refactored to support per-action condition restrictions, prevent duplicate condition types in a single rule, and show disabled state for restricted conditions with tooltip explanations. Contains the computeAllowedConditions intersection bug in the exported helper.
frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionPolicies.tsx Replaces manual accordion toggle with UnstableAccordionItem and checkboxes with ActionsMultiSelect (react-select multi). Adds descriptions to accordion trigger and or/and visual separators. MultiValueWithTooltip renders tooltip content unconditionally even when description is absent.
frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx Migrates from values prop to defaultValues + useEffect reset. The "Cancel/Discard" button changed from navigating away (onGoBack) to calling reset(), which resets to empty defaults and loses previously loaded role data.
frontend/src/pages/project/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx Migrates from values prop to defaultValues + useEffect reset, adds accordion error expansion, and updates UI components to v3. The "Discard" button's reset() resets to empty defaultValues rather than the loaded privilege data.
frontend/src/hooks/api/reactQuery.tsx Extracts requestBody from error.config?.data (with try/catch guard) and passes it to ValidationErrorModal to display human-readable permission subject names for validation errors. Safe — only uses client-originated data.
frontend/src/lib/fn/permission.ts Adds PERMISSION_DISPLAY_NAMES map and formatValidationErrorPath to improve validation error messages. Some display names don't match the titles in PROJECT_PERMISSION_OBJECT (e.g., Groups vs "Group Management", Environments vs "Environment Management"), which may cause minor UX inconsistency in error messages.
frontend/src/context/ProjectPermissionContext/types.ts Adds test ImportSecret and DuplicateSecret to the frontend ProjectPermissionSecretActions enum. These must be removed before merging.

Last reviewed commit: 51fb482

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

21 files reviewed, 10 comments

Edit Code Review Agent Settings | Greptile

@victorvhs017 victorvhs017 force-pushed the feature/policies-ui-update branch from 08ed617 to 022b663 Compare March 2, 2026 23:17
@victorvhs017
Copy link
Contributor Author

@greptileai re-review this PR and update the summary

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 3, 2026

Additional Comments (6)

backend/src/ee/services/permission/project-permission.ts
Test actions committed to production backend

importSecret and duplicateSecret are added directly to the ProjectPermissionSecretActions enum and the ActionAllowedConditions map. The PR description acknowledges these are temporary test actions, but once merged to main, external API users — including Terraform/Crossplane providers or any SDK consumers — could start using these actions in their permission payloads. Removing them later would be a breaking API change that breaks those integrations.

These test actions must be removed from the backend before merging, or the feature needs to be hidden behind a feature flag.


frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx
computeAllowedConditions intersection bug with 3+ restricted actions

The reduce uses acc.length === 0 as an initializer sentinel. This breaks when there are 3+ restricted actions and one intermediate intersection produces an empty array — the empty acc in the next iteration is misinterpreted as "not yet initialized" and replaced with the next action's full allowed list instead of returning [].

Example: actions A allows [environment], B allows [secretPath] (intersection = []), C allows [environment, secretPath]. The result would incorrectly be [environment, secretPath] instead of [].

// Current (buggy):
return actionsWithRestrictions.reduce<string[]>((acc, action) => {
  const allowed = actionConditionsMap[action];
  if (!allowed) return acc;
  if (acc.length === 0) return allowed;  // ← incorrectly fires after an empty intersection too
  return acc.filter((cond) => allowed.includes(cond));
}, []);

// Fix: use a separate "initialized" flag or a null sentinel
let result: string[] | null = null;
for (const action of actionsWithRestrictions) {
  const allowed = actionConditionsMap[action];
  if (!allowed) continue;
  result = result === null ? allowed : result.filter((cond) => allowed.includes(cond));
}
return result ?? allConditions;

While only two test actions exist today, this function is designed to be general-purpose and will break as soon as a third restricted action is introduced.


frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx
"Discard" button now resets to empty defaults instead of original data

The previous implementation used values: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : undefined in useForm. In that mode, reset() without arguments restores to the currently tracked values — i.e., the role's loaded permissions.

The new implementation moves to defaultValues + a useEffect calling reset(roleData). In this pattern, reset() without arguments resets to the original defaultValues passed to useForm (which is undefined/empty since none were specified), not to the last reset(roleData) call.

The net effect is: clicking "Discard" wipes the entire form instead of restoring the saved permissions. This same regression exists in ProjectTemplateEditRoleForm.tsx and MembershipProjectAdditionalPrivilegeModifySection.tsx.

The fix is to reset to the loaded data explicitly:

onClick={() => {
  if (role) reset({ ...role, permissions: rolePermission2Form(role.permissions) });
}}

frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionPolicies.tsx
MultiValueWithTooltip renders TooltipContent unconditionally

The tooltip wrapper is rendered for every selected action chip even when data.description is empty/undefined. While TooltipContent is only visible when the tooltip triggers, mounting empty tooltip content for every chip is wasteful and may cause accessibility noise (empty tooltip text).

const MultiValueWithTooltip = <T extends ActionOption>(props: MultiValueProps<T>) => {
  const { data } = props;
  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <div>
          <components.MultiValue {...props} />
        </div>
      </TooltipTrigger>
-     {data.description && <TooltipContent>{data.description}</TooltipContent>}
+     {data.description ? <TooltipContent>{data.description}</TooltipContent> : null}
    </Tooltip>
  );
};

Also note: If data.description is absent, the tooltip fires but has no content, which may produce a flickering empty popup on hover.


frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx
"Cancel" / "Discard" now resets to empty defaults instead of navigating away

The old "Cancel" button called onClick={onGoBack} — it navigated the user back without touching the form. The new code calls onClick={() => reset()}. Because the form is initialized with explicit defaultValues (name: "", slug: "", permissions: {}), reset() clears the form back to empty values instead of the previously loaded role data.

This means clicking "Discard" on an existing role template completely wipes the displayed permissions rather than discarding the unsaved edits.

The fix should either restore onGoBack or reset to the currently loaded role values:

onClick={() => {
  if (role) reset({ ...role, permissions: rolePermission2Form(role.permissions) });
  else onGoBack();
}}

frontend/src/pages/project/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx
"Discard" resets to empty defaults, not to loaded privilege data

Same pattern as the other forms. The form is initialized with:

defaultValues: {
  slug: "",
  temporaryAccess: { isTemporary: false },
  permissions: {}
}

Calling reset() on the "Discard" button click resets to these empty defaults, not to the loaded privilegeDetails. When editing an existing privilege, clicking "Discard" would appear to wipe all fields — the useEffect would re-fire only when privilegeDetails changes, not on discard.

Fix:

onClick={() => {
  if (privilegeDetails) {
    reset({
      ...privilegeDetails,
      permissions: rolePermission2Form(privilegeDetails.permissions),
      temporaryAccess: privilegeDetails.isTemporary ? { ... } : { isTemporary: false }
    });
  }
}}

…privilege sections to include role and privilege details
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 3, 2026

Additional Comments (6)

backend/src/ee/services/permission/project-permission.ts
Test actions committed to production backend

importSecret and duplicateSecret are added directly to the ProjectPermissionSecretActions enum and the ActionAllowedConditions map. The PR description acknowledges these are temporary test actions, but once merged to main, external API users — including Terraform/Crossplane providers or any SDK consumers — could start using these actions in their permission payloads. Removing them later would be a breaking API change that breaks those integrations.

These test actions must be removed from the backend before merging, or the feature needs to be hidden behind a feature flag.


frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx
computeAllowedConditions intersection bug with 3+ restricted actions

The reduce uses acc.length === 0 as an initializer sentinel. This breaks when there are 3+ restricted actions and one intermediate intersection produces an empty array — the empty acc in the next iteration is misinterpreted as "not yet initialized" and replaced with the next action's full allowed list instead of returning [].

Example: actions A allows [environment], B allows [secretPath] (intersection = []), C allows [environment, secretPath]. The result would incorrectly be [environment, secretPath] instead of [].

// Current (buggy):
return actionsWithRestrictions.reduce<string[]>((acc, action) => {
  const allowed = actionConditionsMap[action];
  if (!allowed) return acc;
  if (acc.length === 0) return allowed;  // ← incorrectly fires after an empty intersection too
  return acc.filter((cond) => allowed.includes(cond));
}, []);

// Fix: use a separate "initialized" flag or a null sentinel
let result: string[] | null = null;
for (const action of actionsWithRestrictions) {
  const allowed = actionConditionsMap[action];
  if (!allowed) continue;
  result = result === null ? allowed : result.filter((cond) => allowed.includes(cond));
}
return result ?? allConditions;

While only two test actions exist today, this function is designed to be general-purpose and will break as soon as a third restricted action is introduced.


frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx
"Discard" button now resets to empty defaults instead of original data

The previous implementation used values: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : undefined in useForm. In that mode, reset() without arguments restores to the currently tracked values — i.e., the role's loaded permissions.

The new implementation moves to defaultValues + a useEffect calling reset(roleData). In this pattern, reset() without arguments resets to the original defaultValues passed to useForm (which is undefined/empty since none were specified), not to the last reset(roleData) call.

The net effect is: clicking "Discard" wipes the entire form instead of restoring the saved permissions. This same regression exists in ProjectTemplateEditRoleForm.tsx and MembershipProjectAdditionalPrivilegeModifySection.tsx.

The fix is to reset to the loaded data explicitly:

onClick={() => {
  if (role) reset({ ...role, permissions: rolePermission2Form(role.permissions) });
}}

frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionPolicies.tsx
MultiValueWithTooltip renders TooltipContent unconditionally

The tooltip wrapper is rendered for every selected action chip even when data.description is empty/undefined. While TooltipContent is only visible when the tooltip triggers, mounting empty tooltip content for every chip is wasteful and may cause accessibility noise (empty tooltip text).

const MultiValueWithTooltip = <T extends ActionOption>(props: MultiValueProps<T>) => {
  const { data } = props;
  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <div>
          <components.MultiValue {...props} />
        </div>
      </TooltipTrigger>
-     {data.description && <TooltipContent>{data.description}</TooltipContent>}
+     {data.description ? <TooltipContent>{data.description}</TooltipContent> : null}
    </Tooltip>
  );
};

Also note: If data.description is absent, the tooltip fires but has no content, which may produce a flickering empty popup on hover.


frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx
"Cancel" / "Discard" now resets to empty defaults instead of navigating away

The old "Cancel" button called onClick={onGoBack} — it navigated the user back without touching the form. The new code calls onClick={() => reset()}. Because the form is initialized with explicit defaultValues (name: "", slug: "", permissions: {}), reset() clears the form back to empty values instead of the previously loaded role data.

This means clicking "Discard" on an existing role template completely wipes the displayed permissions rather than discarding the unsaved edits.

The fix should either restore onGoBack or reset to the currently loaded role values:

onClick={() => {
  if (role) reset({ ...role, permissions: rolePermission2Form(role.permissions) });
  else onGoBack();
}}

frontend/src/pages/project/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx
"Discard" resets to empty defaults, not to loaded privilege data

Same pattern as the other forms. The form is initialized with:

defaultValues: {
  slug: "",
  temporaryAccess: { isTemporary: false },
  permissions: {}
}

Calling reset() on the "Discard" button click resets to these empty defaults, not to the loaded privilegeDetails. When editing an existing privilege, clicking "Discard" would appear to wipe all fields — the useEffect would re-fire only when privilegeDetails changes, not on discard.

Fix:

onClick={() => {
  if (privilegeDetails) {
    reset({
      ...privilegeDetails,
      permissions: rolePermission2Form(privilegeDetails.permissions),
      temporaryAccess: privilegeDetails.isTemporary ? { ... } : { isTemporary: false }
    });
  }
}}

Victor Santos and others added 7 commits March 3, 2026 02:31
…privilege sections to include role and privilege details
…to handle disallowed actions and improve error messaging
…on keys for actions across various subjects
…nal subjects and condition keys for enhanced clarity
@linear
Copy link

linear bot commented Mar 3, 2026

Copy link
Contributor

@scott-ray-wilson scott-ray-wilson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks great overall! some comments and reminders for testing code

Copy link
Contributor

@scott-ray-wilson scott-ray-wilson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

last comments

Victor Santos added 2 commits March 3, 2026 22:53
Copy link
Contributor

@scott-ray-wilson scott-ray-wilson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@victorvhs017 victorvhs017 merged commit dc04157 into main Mar 4, 2026
11 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants