From 0dbe9cc49cc44e3b2c2a35ef3dc296db5b7a3098 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 19:27:12 +0000 Subject: [PATCH 1/3] test(app-shell): pin the delegated-admin companion-grant hint (red) Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- ...ancedFacets.companionGrants-26191.test.tsx | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.companionGrants-26191.test.tsx diff --git a/ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.companionGrants-26191.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.companionGrants-26191.test.tsx new file mode 100644 index 00000000000..1a0e352d156 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.companionGrants-26191.test.tsx @@ -0,0 +1,236 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pins the companion-grant hint of the Delegated Admin Scope facet. + * + * A scope says WHAT its holder may administer; it does not let the holder in. + * A delegate also needs the `setup.access` capability to enter Setup, read on + * `sys_user` / `sys_business_unit` / `sys_business_unit_member` so the pickers + * can list people and units, and CRUD on `sys_user_position` for the + * assignment rows themselves. Without them the delegate meets "You don't have + * access to this app" — the server is right to refuse, and the editor said + * nothing about it. + * + * The facet now lists those five grants, marks each as granted or not by THIS + * set, and offers one action that adds only the missing ones to the same + * draft — never removing or narrowing a grant the set already carries. Under + * a package binding the list stays and the action does not: the package + * editor saves only the package's own objects, so the grants belong on an + * environment set. + * + * Driven through the editor with the draft held as the host holds it, so the + * assertions read what the facet actually wrote. + */ + +import * as React from "react"; +import { describe, it, expect, afterEach } from "vitest"; +import { render, screen, within, cleanup } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { PermissionSetSchema } from "@objectstack/spec/security"; +import { PermissionAdvancedFacets } from "./PermissionAdvancedFacets"; + +afterEach(cleanup); + +const t = (k: string) => k; + +type Draft = Record; + +let latest: Draft = {}; +let draftWrites = 0; + +function Harness({ + initial, + writable = true, + only, + packageBound, +}: { + initial: Draft; + writable?: boolean; + only?: "admin"; + packageBound?: boolean; +}) { + const [draft, setDraft] = React.useState(initial); + React.useEffect(() => { + latest = draft; + }, [draft]); + const countedSetDraft = React.useCallback((updater: (prev: any) => any) => { + draftWrites += 1; + setDraft(updater); + }, []); + return ( + + ); +} + +async function mount( + initial: Draft, + opts: { writable?: boolean; only?: "admin"; packageBound?: boolean } = {}, +) { + const user = userEvent.setup(); + latest = initial; + draftWrites = 0; + render(); + if (opts.only === undefined) { + // The stacked (Setup) presentation: the section starts collapsed. + await user.click(screen.getByText("perm.admin.title")); + } + return user; +} + +const ANCHORED = { businessUnit: "bu_field_ops", manageAssignments: true }; + +const COMPANIONS = [ + "setup.access", + "sys_user", + "sys_business_unit", + "sys_business_unit_member", + "sys_user_position", +] as const; + +const row = (id: string) => screen.getByTestId(`admin-companion-${id}`); +const grantButton = () => screen.queryByTestId("admin-companions-grant"); + +describe("PermissionAdvancedFacets · delegated admin companion grants", () => { + it("lists the five companion grants, each marked missing on an empty set", async () => { + await mount({ name: "field_ops_delegate", objects: {}, adminScope: ANCHORED }); + + expect(screen.getByTestId("admin-companions")).toBeTruthy(); + for (const id of COMPANIONS) { + expect(row(id).getAttribute("data-granted"), id).toBe("false"); + } + expect(grantButton()).toBeTruthy(); + }); + + it("marks each grant this set already carries as granted", async () => { + await mount({ + name: "field_ops_delegate", + systemPermissions: ["setup.access"], + objects: { + sys_user: { allowRead: true }, + // CRUD is all four; read alone does not cover the assignment rows. + sys_user_position: { allowRead: true }, + }, + adminScope: ANCHORED, + }); + + expect(row("setup.access").getAttribute("data-granted")).toBe("true"); + expect(row("sys_user").getAttribute("data-granted")).toBe("true"); + expect(row("sys_business_unit").getAttribute("data-granted")).toBe("false"); + expect(row("sys_business_unit_member").getAttribute("data-granted")).toBe("false"); + expect(row("sys_user_position").getAttribute("data-granted")).toBe("false"); + }); + + it("is not shown until the scope names a business unit, matching the block's gate", async () => { + await mount({ name: "field_ops_delegate", objects: {} }); + expect(screen.queryByTestId("admin-companions")).toBeNull(); + expect(grantButton()).toBeNull(); + }); + + it("one click adds only the missing grants, in one draft write, and narrows nothing", async () => { + const user = await mount({ + name: "field_ops_delegate", + systemPermissions: ["manage_org_users"], + objects: { + account: { allowRead: true, allowEdit: true }, + sys_user: { allowRead: true, allowEdit: true }, + sys_user_position: { allowRead: true, viewAllRecords: true }, + }, + adminScope: ANCHORED, + }); + const before = draftWrites; + + await user.click(grantButton() as HTMLElement); + + expect(draftWrites - before).toBe(1); + expect(latest.systemPermissions).toEqual(["manage_org_users", "setup.access"]); + expect(latest.objects).toEqual({ + // Untouched: not a companion. + account: { allowRead: true, allowEdit: true }, + // Untouched: already granted, and its extra Edit is kept. + sys_user: { allowRead: true, allowEdit: true }, + // Widened to CRUD; its existing View All is kept. + sys_user_position: { + allowRead: true, + viewAllRecords: true, + allowCreate: true, + allowEdit: true, + allowDelete: true, + }, + sys_business_unit: { allowRead: true }, + sys_business_unit_member: { allowRead: true }, + }); + // The scope itself is not touched by the grant. + expect(latest.adminScope).toEqual(ANCHORED); + // Every companion now reads as granted, so there is nothing left to add. + for (const id of COMPANIONS) { + expect(row(id).getAttribute("data-granted"), id).toBe("true"); + } + expect(grantButton()).toBeNull(); + }); + + it("writes a set the spec accepts, with no key the set did not already have a slot for", async () => { + const user = await mount({ name: "field_ops_delegate", objects: {}, adminScope: ANCHORED }); + await user.click(grantButton() as HTMLElement); + + expect(Object.keys(latest).sort()).toEqual( + ["adminScope", "name", "objects", "systemPermissions"].sort(), + ); + const verdict = PermissionSetSchema.safeParse(latest); + expect(verdict.success, JSON.stringify(verdict.error?.issues)).toBe(true); + }); + + it("offers no action when every grant is already on the set", async () => { + await mount({ + name: "field_ops_delegate", + systemPermissions: ["setup.access"], + objects: { + sys_user: { allowRead: true }, + sys_business_unit: { allowRead: true }, + sys_business_unit_member: { allowRead: true }, + sys_user_position: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, + }, + adminScope: ANCHORED, + }); + expect(screen.getByTestId("admin-companions")).toBeTruthy(); + expect(grantButton()).toBeNull(); + }); + + it("read-only: the hint stays, the action does not", async () => { + await mount({ name: "field_ops_delegate", objects: {}, adminScope: ANCHORED }, { writable: false }); + expect(screen.getByTestId("admin-companions")).toBeTruthy(); + expect(grantButton()).toBeNull(); + }); + + it("under a package binding: the hint stays, the action does not, and the note says where to grant", async () => { + await mount( + { name: "field_ops_delegate", objects: {}, adminScope: ANCHORED }, + { packageBound: true }, + ); + const hint = screen.getByTestId("admin-companions"); + for (const id of COMPANIONS) expect(row(id)).toBeTruthy(); + expect(grantButton()).toBeNull(); + expect(within(hint).getByText("perm.admin.companions.packageNote")).toBeTruthy(); + }); + + it("the Studio Access pillar mount (the package door's only host) is package-bound by default", async () => { + await mount({ name: "field_ops_delegate", objects: {}, adminScope: ANCHORED }, { only: "admin" }); + expect(screen.getByTestId("admin-companions")).toBeTruthy(); + expect(grantButton()).toBeNull(); + }); + + it("an explicit packageBound={false} on that mount keeps the action", async () => { + await mount( + { name: "field_ops_delegate", objects: {}, adminScope: ANCHORED }, + { only: "admin", packageBound: false }, + ); + expect(grantButton()).toBeTruthy(); + }); +}); From c740617917154631b45f9912e98ad61bb39f4651 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 20:18:14 +0000 Subject: [PATCH 2/3] docs,i18n: name setup.access among a delegate's companion grants; strings for the scope block's hint Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- .changeset/26191-delegate-companion-grants.md | 14 +++++++++++ .../permissions/delegated-administration.mdx | 13 ++++++++++ ...ancedFacets.companionGrants-26191.test.tsx | 12 ++++++++-- .../src/views/metadata-admin/i18n.ts | 24 +++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 .changeset/26191-delegate-companion-grants.md diff --git a/.changeset/26191-delegate-companion-grants.md b/.changeset/26191-delegate-companion-grants.md new file mode 100644 index 00000000000..2b025489020 --- /dev/null +++ b/.changeset/26191-delegate-companion-grants.md @@ -0,0 +1,14 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): the Delegated Admin Scope block lists the companion grants a delegate needs, and grants the missing ones in one click + +Clause-②: no +Metadata-protocol: none + +A permission set's admin scope says what its holder may administer; it does not let the holder in. A delegate also needs the `setup.access` capability to enter Setup, read on `sys_user`, `sys_business_unit` and `sys_business_unit_member` so the lookups can pick people and units, and create, read, edit and delete on `sys_user_position` for the assignment rows. The scope block in the permission set editor now lists these five once the scope names a business unit, and marks each one as granted or not by the set being edited. + +"Grant the missing ones" adds only what is missing to the same set, in one edit that the set's normal Save persists: `setup.access` joins `systemPermissions`, and each object row gains only the flags it lacks. No grant the set already carries is removed or narrowed, and no new key is written. Under a package (the Studio Access pillar) the list is shown without the action, with a note that these grants belong on an environment permission set in Setup: the package editor saves only the package's own objects. + +What to do: nothing. A set's stored shape is unchanged. diff --git a/content/docs/permissions/delegated-administration.mdx b/content/docs/permissions/delegated-administration.mdx index 75da60923be..b24a57dbe82 100644 --- a/content/docs/permissions/delegated-administration.mdx +++ b/content/docs/permissions/delegated-administration.mdx @@ -28,6 +28,8 @@ export const FieldOpsDelegate = definePermissionSet({ manageAssignments: true, // user ↔ position rows (sys_user_position) assignablePermissionSets: ['showcase_contributor', 'showcase_manager'], }, + // …the delegate has to be able to open Setup, where the assignment screens live… + systemPermissions: ['setup.access'], // …and plain CRUD on the RBAC link tables lets the requests through at all. // Both are required: table CRUD with NO scope is refused outright. objects: { @@ -43,6 +45,17 @@ export const FieldOpsDelegate = definePermissionSet({ misconfigured name resolves to an empty subtree and **approves nothing** (fail-closed). +The scope says *what* a delegate may administer; it does not let them in. Five +companion grants do that, and the example carries all of them: the +`setup.access` capability, without which the delegate is turned away at the +Setup app ("You don't have access to this app"); read on `sys_user`, +`sys_business_unit` and `sys_business_unit_member`, so the lookups can pick +people and units; and CRUD on `sys_user_position`, the assignment rows +themselves. They may sit on the scope's own set or on any other set the +delegate holds. In Setup's permission set editor, the Delegated Admin Scope +block lists the five, marks which ones the set already grants, and adds the +missing ones to the set in one click. + ## What the runtime gate enforces Writes to the governed tables (`sys_user_position`, `sys_user_permission_set`, diff --git a/ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.companionGrants-26191.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.companionGrants-26191.test.tsx index 1a0e352d156..72ca06858ff 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.companionGrants-26191.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.companionGrants-26191.test.tsx @@ -211,11 +211,19 @@ describe("PermissionAdvancedFacets · delegated admin companion grants", () => { it("under a package binding: the hint stays, the action does not, and the note says where to grant", async () => { await mount( - { name: "field_ops_delegate", objects: {}, adminScope: ANCHORED }, + { + name: "field_ops_delegate", + systemPermissions: ["setup.access"], + objects: {}, + adminScope: ANCHORED, + }, { packageBound: true }, ); const hint = screen.getByTestId("admin-companions"); - for (const id of COMPANIONS) expect(row(id)).toBeTruthy(); + // Listed, but not marked: the package door's draft holds only the + // package's own objects, so a `sys_*` row absent here says nothing about + // the stored set. + for (const id of COMPANIONS) expect(row(id).hasAttribute("data-granted"), id).toBe(false); expect(grantButton()).toBeNull(); expect(within(hint).getByText("perm.admin.companions.packageNote")).toBeTruthy(); }); diff --git a/ui/packages/app-shell/src/views/metadata-admin/i18n.ts b/ui/packages/app-shell/src/views/metadata-admin/i18n.ts index 70a5c224e96..81d9711c1da 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -2681,6 +2681,19 @@ const ENGINE_STRINGS_EN: Record = { 'perm.admin.manageAssignments': 'Manage assignments', 'perm.admin.assignableSets': 'Assignable permission sets', 'perm.admin.noSets': 'No permission sets loaded.', + // The grants a scope's holder needs beside the scope itself before they can + // act on it: enter Setup, pick people and units, write the assignment rows. + 'perm.admin.companions.title': 'What a delegate also needs', + 'perm.admin.companions.help': + 'The scope says what its holders may administer; it does not let them in. A delegate also needs to enter Setup, to read people and business units so the lookups can pick them, and to create, read, edit and delete position assignments. These grants may sit on this set or on another set the delegate holds.', + 'perm.admin.companions.setupAccess': 'Enter Setup', + 'perm.admin.companions.read': 'Read', + 'perm.admin.companions.crud': 'Create, read, edit and delete', + 'perm.admin.companions.granted': 'On this set', + 'perm.admin.companions.missing': 'Not on this set', + 'perm.admin.companions.grant': 'Grant the missing ones', + 'perm.admin.companions.packageNote': + 'Under a package, grant these on an environment permission set in Setup, one the delegate holds: this editor saves only the package’s own objects.', 'perm.loading': 'Loading permission set {name}…', // objectui#9420 — the package door refuses rather than PUTs when the // save-time layered re-read REJECTS: without it the merge base falls back @@ -6299,6 +6312,17 @@ const ENGINE_STRINGS_ZH: Record = { 'perm.admin.manageAssignments': '管理分配', 'perm.admin.assignableSets': '可分配权限集', 'perm.admin.noSets': '未加载权限集。', + 'perm.admin.companions.title': '委派管理员还需要的授权', + 'perm.admin.companions.help': + '范围只规定持有者可以管理什么,并不能让其进入。委派管理员还需要:进入 Setup,读取人员与业务单元(供查找字段选择),以及对职位分配的创建、读取、编辑和删除。这些授权可以放在本权限集上,也可以放在委派管理员持有的其他权限集上。', + 'perm.admin.companions.setupAccess': '进入 Setup', + 'perm.admin.companions.read': '读取', + 'perm.admin.companions.crud': '创建、读取、编辑和删除', + 'perm.admin.companions.granted': '本权限集已授予', + 'perm.admin.companions.missing': '本权限集未授予', + 'perm.admin.companions.grant': '授予缺少的项', + 'perm.admin.companions.packageNote': + '在包中时,请在 Setup 中把这些授权放到委派管理员持有的环境权限集上:此编辑器只保存本包自己的对象。', 'perm.loading': '加载权限集 {name}…', // objectui#9420 — 见 EN 表同键注释。 'perm.save.rereadFailed': From 484f6175af1c47a9e1776f969e261c96d91f5d65 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 20:47:18 +0000 Subject: [PATCH 3/3] fix(app-shell): the Delegated Admin Scope block lists a delegate's companion grants and grants the missing ones Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- .../PermissionAdvancedFacets.tsx | 175 +++++++++++++++++- 1 file changed, 174 insertions(+), 1 deletion(-) diff --git a/ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tsx b/ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tsx index c8d25d4d381..23b338ce6ec 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tsx @@ -23,7 +23,17 @@ import { SelectValue, cn, } from '@object-ui/components'; -import { ChevronRight, Plus, Trash2, Shield, Lock, PanelTop, FlaskConical } from 'lucide-react'; +import { + ChevronRight, + Plus, + Trash2, + Shield, + Lock, + PanelTop, + FlaskConical, + Check, + Circle, +} from 'lucide-react'; import { CelPredicateField } from './CelPredicateField.js'; import { CelTestRunDialog } from './CelTestRunDialog.js'; import type { CelLintIssue } from './celAuthoring.js'; @@ -178,6 +188,82 @@ function asObject>(v: unknown): T { return {} as T; } +/** The object-permission flags a companion grant can require. */ +type CompanionFlag = 'allowRead' | 'allowCreate' | 'allowEdit' | 'allowDelete'; + +/** + * One grant a delegated admin needs BESIDE the scope before they can act on it. + * + * A scope says what its holder may administer; it does not let the holder in. + * The holder is turned away at the Setup app without `setup.access`, the + * assignment form's lookups cannot list people and units without read on + * `sys_user` / `sys_business_unit` / `sys_business_unit_member`, and the + * assignment rows are `sys_user_position` writes. The server is right to refuse + * each of these; the scope block names them so an author does not have to find + * them one refusal at a time. The same five are the companion grants in the + * docs' authoring example (`permissions/delegated-administration`). + * + * Every grant lands in a key the set already has — `systemPermissions` or an + * `objects` row (`PermissionSetSchema`) — so nothing here is a new key. + */ +type CompanionGrant = + | { id: 'setup.access'; kind: 'capability'; labelKey: string } + | { id: string; kind: 'object'; flags: readonly CompanionFlag[]; labelKey: string }; + +const READ_ONLY_FLAGS: readonly CompanionFlag[] = ['allowRead']; +const CRUD_FLAGS: readonly CompanionFlag[] = ['allowCreate', 'allowRead', 'allowEdit', 'allowDelete']; + +const ADMIN_COMPANION_GRANTS: readonly CompanionGrant[] = [ + { id: 'setup.access', kind: 'capability', labelKey: 'perm.admin.companions.setupAccess' }, + { id: 'sys_user', kind: 'object', flags: READ_ONLY_FLAGS, labelKey: 'perm.admin.companions.read' }, + { id: 'sys_business_unit', kind: 'object', flags: READ_ONLY_FLAGS, labelKey: 'perm.admin.companions.read' }, + { + id: 'sys_business_unit_member', + kind: 'object', + flags: READ_ONLY_FLAGS, + labelKey: 'perm.admin.companions.read', + }, + { id: 'sys_user_position', kind: 'object', flags: CRUD_FLAGS, labelKey: 'perm.admin.companions.crud' }, +]; + +type ObjectRows = Record>; + +/** Whether the set as the draft holds it already carries `grant` — exactly, no inference. */ +function companionGranted(grant: CompanionGrant, systemPermissions: string[], objects: ObjectRows): boolean { + if (grant.kind === 'capability') return systemPermissions.includes(grant.id); + const row = asObject(objects[grant.id]); + return grant.flags.every((flag) => row[flag] === true); +} + +/** + * The set's `systemPermissions` and `objects` with every missing companion + * grant added and nothing else changed — `null` when nothing is missing. + * + * It only ever turns a flag ON and only ever appends a capability: an existing + * row keeps every key it has (an extra Edit, a View All), and a row that + * already covers its grant is the same object. + */ +function companionGrantsAdded( + draft: Record, +): { systemPermissions: string[]; objects: ObjectRows } | null { + let systemPermissions = asArray(draft.systemPermissions); + let objects = asObject(draft.objects); + let changed = false; + for (const grant of ADMIN_COMPANION_GRANTS) { + if (companionGranted(grant, systemPermissions, objects)) continue; + changed = true; + if (grant.kind === 'capability') { + systemPermissions = [...systemPermissions, grant.id]; + continue; + } + const row = asObject(objects[grant.id]); + const added: Record = {}; + for (const flag of grant.flags) if (row[flag] !== true) added[flag] = true; + objects = { ...objects, [grant.id]: { ...row, ...added } }; + } + return changed ? { systemPermissions, objects } : null; +} + /** The item a stored value none of a picker's options carries is shown by. */ const OUTSIDE_OPTIONS = 'outside'; @@ -313,6 +399,18 @@ export interface PermissionAdvancedFacetsProps { * gate while its tab is not the open one. Omitted: the stacked collapsibles. */ only?: 'rls' | 'tabs' | 'admin' | null; + /** + * Whether the set is edited under a package binding (the package door, + * ADR-0086 P0). There the draft holds, and Save writes, only the package's + * own objects, so the delegated-admin companion grants — Setup access and + * rows on platform objects — belong on an environment set: the scope block + * lists them without marking them and without the one-click grant. + * + * Omitted: read from `only`. The Studio Access pillar is the one host that + * sets `only`, and it always edits under a `packageId` (`AccessPillar`'s + * `packageId` is required); Setup's permission page sets neither. + */ + packageBound?: boolean; } export function PermissionAdvancedFacets({ @@ -324,6 +422,7 @@ export function PermissionAdvancedFacets({ onCelErrorsChange, t, only, + packageBound = only !== undefined, }: PermissionAdvancedFacetsProps) { const flatFor = (section: 'rls' | 'tabs' | 'admin') => only === undefined ? undefined : { shown: only === section, testId: `perm-facet-${section}` }; @@ -443,6 +542,32 @@ export function PermissionAdvancedFacets({ const scopeConfigured = Object.keys(scope).length > 0; /** The spec's own condition: keys present, `businessUnit` absent ⇒ refused. */ const scopeBlocksSave = scopeConfigured && typeof scope.businessUnit !== 'string'; + + /** + * The companion grants, each with whether THIS set carries it. Shown behind + * the same gate as the scope's dependent controls (`scopeAnchored`): until a + * business unit is named there is no scope to companion. + */ + const companions = React.useMemo(() => { + const systemPermissions = asArray(draft.systemPermissions); + const objects = asObject(draft.objects); + return ADMIN_COMPANION_GRANTS.map((grant) => ({ + grant, + granted: companionGranted(grant, systemPermissions, objects), + })); + }, [draft.systemPermissions, draft.objects]); + const companionsMissing = companions.some((c) => !c.granted); + /** + * Adds only the missing grants, as ONE draft edit the set's normal Save + * persists. Nothing is removed or narrowed (`companionGrantsAdded`). + */ + const grantCompanions = () => + setDraft((p) => { + const added = companionGrantsAdded(p); + if (!added) return p; + return { ...p, systemPermissions: added.systemPermissions, objects: added.objects }; + }); + const setTabs = (next: TabPerms) => setDraft((p) => ({ ...p, tabPermissions: next })); const tabEntries = Object.entries(tabs); @@ -754,6 +879,54 @@ export function PermissionAdvancedFacets({ })} + {scopeAnchored && ( +
+
{t('perm.admin.companions.title')}
+

{t('perm.admin.companions.help')}

+
    + {companions.map(({ grant, granted }) => ( +
  • + {!packageBound && + (granted ? ( + + ) : ( + + ))} + {grant.id} + {t(grant.labelKey)} + {!packageBound && ( + + {t(granted ? 'perm.admin.companions.granted' : 'perm.admin.companions.missing')} + + )} +
  • + ))} +
+ {packageBound ? ( +

{t('perm.admin.companions.packageNote')}

+ ) : ( + writable && + companionsMissing && ( + + ) + )} +
+ )}