feat: manage permission set assignments from the permissions manager - #1898
Conversation
There was a problem hiding this comment.
Pull request overview
Adds end-to-end support for managing Permission Set assignments (who a permission set is assigned to) directly from the permissions manager UI, without needing to jump to Salesforce Setup.
Changes:
- Introduces a new Manage Assignments modal that stages add/remove changes locally and applies them via
PermissionSetAssignmentcreate/delete (chunked,allOrNone: false, partial failure handling). - Extends the existing Profile/Permission Set popover to optionally expose a “Manage Assignments” action for standalone Permission Sets (not profile-owned).
- Adds a column-group header popover trigger in the permissions editor table so assignments can be managed from within the editor experience.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| libs/ui/src/lib/widgets/ProfileOrPermSetPopover.tsx | Adds optional “Manage Assignments” footer action and opens the new modal. |
| libs/ui/src/lib/widgets/permission-set-assignment-utils.ts | New shared SOQL builders + ID guard/escaping utilities to avoid cycles. |
| libs/ui/src/lib/widgets/ManagePermissionSetAssignmentsModal.tsx | New modal UI and save logic for PermissionSetAssignment diffs (delete then create). |
| libs/ui/src/lib/widgets/tests/permission-set-assignment-utils.spec.ts | Unit tests for SOQL builders and escaping. |
| libs/ui/src/lib/widgets/tests/ManagePermissionSetAssignmentsModal.spec.tsx | Component tests for staging/saving behavior and failure handling. |
| libs/ui/src/index.ts | Exports the new modal and shared utilities from @jetstream/ui. |
| libs/types/src/lib/salesforce/record.types.ts | Adds a PermissionSetAssignmentRecord type used by the modal’s query. |
| libs/shared/constants/src/lib/shared-constants.ts | Adds Amplitude analytics keys for opened/saved assignment flows. |
| libs/features/manage-permissions/src/utils/permission-manager-table-utils.tsx | Adds a group header renderer (non-sortable) for the first column of each profile/permset group. |
| libs/features/manage-permissions/src/utils/tests/permission-manager-columns.spec.ts | Tests for the group-header column behavior (renderHeaderCell/sortable/filters/name). |
| libs/features/manage-permissions/src/PermissionColumnGroupHeader.tsx | New header component hosting the popover trigger and wiring org/state + analytics. |
| libs/features/manage-permissions/src/ManagePermissionsSelection.tsx | Enables Manage Assignments action from the existing selection-page popover + wires analytics. |
16fb4a9 to
65dcec9
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
libs/ui/src/lib/widgets/ProfileOrPermSetPopover.tsx:210
canManageAssignmentscurrently allows opening the assignments modal whenmetais missing/undefined because it checksmeta?.IsOwnedByProfile !== true. That bypasses the intended "IsOwnedByProfile" gate (and could allow attempts to assign profile-owned permission sets, depending on call site). Make the guard require an explicitfalsevalue.
// Profile-owned permission sets are granted through the profile, so they are never directly assignable.
const canManageAssignments =
allowManageAssignments && recordType === 'PermissionSet' && meta?.IsOwnedByProfile !== true && ID_ALLOWED.test(effectiveRecordId);
libs/types/src/lib/salesforce/record.types.ts:197
PermissionSetAssignmentRecord.Assigneeis typed as always-present, but relationship sub-objects are often omitted (or can be null) depending on the SOQL SELECT and field-level access. The modal code already treatsAssigneeas optional via optional chaining, so the type should reflect that to prevent unsafe assumptions in other callers.
Id: string;
PermissionSetId: string;
AssigneeId: string;
Assignee: { Id: string; Name: string; Username: string; IsActive: boolean };
}
65dcec9 to
1c07202
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (2)
libs/ui/src/lib/widgets/permission-set-assignment-utils.ts:8
ID_ALLOWEDcurrently allows 16- and 17-character strings ({15,18}), but the comment (and Salesforce ID format) is specifically 15 or 18 characters. This can cause invalid IDs to be treated as safe/valid and end up in SOQL builders (and ingetUserSearchQueryit can also incorrectly switch into the “ID” search path). Tighten the regex to only match 15 or 18 characters.
/** Salesforce ids are 15 or 18 alphanumeric characters. Anything else must never reach a SOQL string. */
export const ID_ALLOWED = /^[a-zA-Z0-9]{15,18}$/;
libs/features/manage-permissions/src/utils/permission-manager-table-utils.tsx:648
- Importing
PermissionColumnGroupHeaderintopermission-manager-table-utilsmakes any unit test that imports the table utils also evaluatePermissionColumnGroupHeader’s module graph. That header imports@jetstream/ui/app-state, which initializesuserProfileStateby callingfetchUserProfile()at module-evaluation time (see libs/shared/ui-app-state/src/lib/ui-app-state.ts:236) — and other tests already have to mock@jetstream/ui/app-stateto avoid unhandled rejections in jsdom (see libs/ui/src/lib/popover/tests/Popover.spec.tsx:1-8). As a result, the existing manage-permissions util tests may start failing unless this project’s Vitest setup mocks@jetstream/ui/app-state(with the named exports thatPermissionColumnGroupHeaderimports) or the header import is otherwise isolated from test-only code paths.
// Only the first column of each group renders a header cell — the rest are covered by its colSpan.
// It must be non-sortable because a sortable header wraps its label in a <button>, and the group header
// hosts a popover trigger (also a button). Nothing is lost: sorting reads row["<id>-<action>"], which
// never exists on these rows, so it was already a no-op. Filtering uses `getValue` and is unaffected.
...(isFirstItem
? {
sortable: false,
renderHeaderCell: () => <PermissionColumnGroupHeader id={id} label={label} type={type} />,
}
1c07202 to
f6968fe
Compare
Users could edit object/field/tab/system permissions but had no way to control who a permission set was assigned to. The only assignment affordance was a read-only popover on the selection page. Adds a "Manage Assignments" modal that assigns and unassigns users via PermissionSetAssignment records, reachable from two places: - the existing popover on the selection page list - the permissions editor table, via a new popover trigger in the column group header that spans each profile / permission set Permission sets only. Profiles stay read-only — assigning a profile means mutating User.ProfileId, which is out of scope and far more destructive. Gated three ways: the popover's canManageAssignments, the header's allowManageAssignments, and the modal's own IsOwnedByProfile check. The modal stages changes locally (left panel unassigns, right panel is a type-ahead over User that never loads the full org roster) and applies the diff on save — deletes before inserts so a remove-then-re-add can't hit DUPLICATE_VALUE, chunked at 200 with allOrNone: false. Partial failures keep the modal open with the Salesforce message inline and reload from the server. Nothing on the editor page refetches; only the popover's own list refreshes.
f6968fe to
ba4833f
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (1)
libs/ui/src/lib/widgets/ManagePermissionSetAssignmentsModal.tsx:126
isAssignablecurrently treatsisOwnedByProfile: undefinedas assignable (!undefined === true). That means a caller who omitsisOwnedByProfilecan manage assignments even though the prop docs (and PR description) frame the modal as self-gating on the profile-owned check. Consider requiring an explicitisOwnedByProfile === false(or making the prop required) so “unknown ownership” fails closed, matching the popover’s explicitmeta?.IsOwnedByProfile === falsegating.
const [saving, setSaving] = useState(false);
const [saveErrorMessage, setSaveErrorMessage] = useState<string | null>(null);
const [saveFailures, setSaveFailures] = useState<SaveFailure[]>([]);
const isAssignable = !isOwnedByProfile && ID_ALLOWED.test(permissionSetId);
Users could edit object/field/tab/system permissions but had no way to control who a permission set was assigned to. The only assignment affordance was a read-only popover on the selection page.
Adds a "Manage Assignments" modal that assigns and unassigns users via PermissionSetAssignment records, reachable from two places:
Permission sets only. Profiles stay read-only — assigning a profile means mutating User.ProfileId, which is out of scope and far more destructive. Gated three ways: the popover's canManageAssignments, the header's allowManageAssignments, and the modal's own IsOwnedByProfile check.
The modal stages changes locally (left panel unassigns, right panel is a type-ahead over User that never loads the full org roster) and applies the diff on save — deletes before inserts so a remove-then-re-add can't hit DUPLICATE_VALUE, chunked at 200 with allOrNone: false. Partial failures keep the modal open with the Salesforce message inline and reload from the server. Nothing on the editor page refetches; only the popover's own list refreshes.