Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/12406-retire-admin-switches.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/app-shell': patch
---

Studio's permission-set editor no longer offers the two delegated-admin switches that `@objectstack/spec` 18 retired (objectui#12406, the objectui half of objectstack#23488).

`adminScope.manageBindings` and `adminScope.authorEnvironmentSets` are now retired tombstones in the spec: the protocol refuses either key whatever it holds, `false` included, because no door ever honoured them. The Delegated Admin Scope facet drew a "Manage bindings" and an "Author env sets" switch that wrote them, so once a business unit was named, flipping either one made the whole permission set unsaveable. Both switches are gone, with their English and Chinese captions, and the facet's help text no longer offers to "manage bindings". "Manage assignments", "Include subtree", the business unit and the assignable permission sets are unchanged.

A permission set stored with either key is cleaned by the server, not by this editor: the server reads stored and draft rows through the spec's stored-row conversion, which drops both keys before the body reaches Studio, and a re-save then stores the clean shape.

**Clause-②: no.** Nothing on `@object-ui/app-shell`'s published entry changes: the facet component and the metadata-admin dictionary are internal to the package. The change removes two controls that wrote keys the spec already refuses, so no accepted input narrows and nothing widens.
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
* framework spec refuses, and that a draft already carrying one says so on its
* own collapsed header (objectui#9464).
*
* `AdminScopeSchema` makes `businessUnit` the one REQUIRED key — the other five
* carry defaults — so `{ includeSubtree: true }` is refused wholesale. The facet
* `AdminScopeSchema` makes `businessUnit` the one REQUIRED key — the other live
* keys carry defaults — so `{ includeSubtree: true }` is refused wholesale. The facet
* patched the draft key by key from every control, so flipping any switch
* BEFORE naming a business unit wrote exactly that object, and the section's own
* badge counted only `businessUnit` / `assignablePermissionSets` and so read
Expand Down Expand Up @@ -39,7 +39,15 @@ type Draft = Record<string, unknown>;
* second interaction sees what the first one wrote. */
let latest: Draft = {};

function Harness({ initial, sets }: { initial: Draft; sets: string[] }) {
function Harness({
initial,
sets,
only,
}: {
initial: Draft;
sets: string[];
only?: "admin";
}) {
const [draft, setDraft] = React.useState<Draft>(initial);
// In an effect, not during render: `react-hooks/globals` bans the latter, and
// `userEvent` flushes effects before the assertion reads this back.
Expand All @@ -53,6 +61,7 @@ function Harness({ initial, sets }: { initial: Draft; sets: string[] }) {
writable
allSetNames={sets}
t={t}
only={only}
/>
);
}
Expand Down Expand Up @@ -103,15 +112,9 @@ describe("PermissionAdvancedFacets · delegated admin scope (objectui#9464)", ()
expect(written).toBeUndefined();
});

it("the three manage-* switches are inert for the same reason", async () => {
it("the manage-assignments switch is inert for the same reason", async () => {
const user = await mountFacet({});
for (const key of [
"perm.admin.manageAssignments",
"perm.admin.manageBindings",
"perm.admin.authorEnvironmentSets",
]) {
await user.click(switchFor(key));
}
await user.click(switchFor("perm.admin.manageAssignments"));
expect(latest.adminScope).toBeUndefined();
});

Expand Down Expand Up @@ -154,3 +157,68 @@ describe("PermissionAdvancedFacets · delegated admin scope (objectui#9464)", ()
expect(within(adminHeader()).queryByText("1")).toBeNull();
});
});

/**
* objectui#12406 — `manageBindings` and `authorEnvironmentSets` are retired
* tombstones in `@objectstack/spec` (objectstack#23488): the spec refuses
* either key whatever it holds, so the facet draws no switch for either and
* every write it can make is a scope the spec accepts.
*
* Driven through the facet as the Studio Access pillar mounts it (`only`), so
* the section is located by its own test id and every switch inside it is
* found by ROLE, not by caption: a retired switch drawn back under any caption
* is still a switch this sweep clicks.
*/
describe("PermissionAdvancedFacets · the retired delegated-admin keys (objectui#12406)", () => {
const RETIRED = ["manageBindings", "authorEnvironmentSets"] as const;

async function mountAdmin(initial: Draft, sets: string[] = []) {
const user = userEvent.setup();
latest = initial;
render(<Harness initial={initial} sets={sets} only="admin" />);
return { user, section: screen.getByTestId("perm-facet-admin") };
}

it("CONTROL — the installed spec refuses each retired key at its own path, false included", () => {
for (const key of RETIRED) {
for (const value of [true, false]) {
const verdict = AdminScopeSchema.safeParse({ businessUnit: "bu_sales", [key]: value });
expect(verdict.success, `${key}: ${value}`).toBe(false);
expect(verdict.error?.issues.some((i) => i.path[0] === key)).toBe(true);
}
}
});

it("draws the manage-assignments switch and neither retired switch", async () => {
const { section } = await mountAdmin({ adminScope: { businessUnit: "bu_sales" } });
const captions = within(section)
.getAllByRole("switch")
.map((s) => s.closest("label")?.textContent);
expect(captions).toEqual(["perm.admin.includeSubtree", "perm.admin.manageAssignments"]);
expect(within(section).queryByText("perm.admin.manageBindings")).toBeNull();
expect(within(section).queryByText("perm.admin.authorEnvironmentSets")).toBeNull();
});

it("flipping every switch it draws writes a scope the spec accepts, carrying neither retired key", async () => {
const { user, section } = await mountAdmin({ adminScope: { businessUnit: "bu_sales" } }, [
"ps_support",
]);
for (const control of within(section).getAllByRole("switch")) {
await user.click(control);
}
await user.click(within(section).getByRole("button", { name: "ps_support" }));

const written = latest.adminScope as Record<string, unknown>;
expect(RETIRED.filter((key) => key in written)).toEqual([]);
expect(
AdminScopeSchema.safeParse(written).success,
`the editor wrote ${JSON.stringify(written)}, which the spec refuses`,
).toBe(true);
expect(written).toEqual({
businessUnit: "bu_sales",
includeSubtree: true,
manageAssignments: true,
assignablePermissionSets: ["ps_support"],
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,7 @@ import type {
* of the framework spec this editor authors, checked against the spec schemas
* rather than sampled from live data (objectstack#7130): RLS policies
* `{name,object,operation,using,check,enabled}`; admin scope
* `{businessUnit,includeSubtree,manage*,authorEnvironmentSets,
* assignablePermissionSets[]}`.
* `{businessUnit,includeSubtree,manageAssignments,assignablePermissionSets[]}`.
*/

/**
Expand Down Expand Up @@ -102,7 +101,7 @@ function stripRetiredRlsKeys(policy: RlsPolicy): RlsPolicy {
/**
* The delegated-admin scope AS THIS EDITOR HOLDS IT MID-EDIT.
*
* The six keys are `@objectstack/spec/security`'s `AdminScope` — the AUTHORING
* The keys are `@objectstack/spec/security`'s `AdminScope` — the AUTHORING
* side (`z.input`), which is what this editor writes — and they are TAKEN from
* it rather than restated (objectui#7265). The header above already said these
* shapes were "checked against the spec schemas"; a hand-written copy can only
Expand All @@ -119,6 +118,17 @@ function stripRetiredRlsKeys(policy: RlsPolicy): RlsPolicy {
*
* ⚠️ Requiredness is the ONLY thing relaxed. A draft that reaches Save still has
* to satisfy the server's `AdminScopeSchema`, which this type does not promise.
*
* `manageBindings` and `authorEnvironmentSets` are retired tombstones in the
* spec (objectstack#23488): still keys of the type, typed so that no value can
* be written to them, and refused at parse whatever they hold. This editor
* draws no control for either and writes each live key by name, so the
* tombstone's type is what refuses a write of either here. It strips neither
* from a loaded scope: the server serves stored and draft rows through the
* spec's stored-row conversion `permission-admin-scope-binding-authoring-removed`,
* which drops both before a body reaches this editor; the spec's retirement
* test pins that strip in its case "a STORED `permission` row carrying both
* keys replays clean through the rehydration seam" (objectui#12406).
*/
type AdminScope = Partial<SpecAdminScope>;

Expand Down Expand Up @@ -386,8 +396,8 @@ export function PermissionAdvancedFacets({
* Delegated admin scope: the boundary has to exist before anything can be
* scoped to it (objectui#9464).
*
* `businessUnit` is the ONE key `AdminScopeSchema` requires — the other five
* carry defaults — so a scope written before one is named is an object the
* `businessUnit` is the ONE key `AdminScopeSchema` requires — the other live
* keys carry defaults — so a scope written before one is named is an object the
* framework refuses WHOLESALE, and what it blocks is the author's next
* whole-record Save, not just this section. Every DEPENDENT control therefore
* writes through `setScopeDetail` instead of `setScope`, and carries the same
Expand Down Expand Up @@ -683,22 +693,14 @@ export function PermissionAdvancedFacets({
</p>
)}
<div className="flex flex-wrap gap-x-6 gap-y-2">
{(
[
['manageAssignments', 'perm.admin.manageAssignments'],
['manageBindings', 'perm.admin.manageBindings'],
['authorEnvironmentSets', 'perm.admin.authorEnvironmentSets'],
] as const
).map(([key, label]) => (
<label key={key} className="flex items-center gap-1.5 text-xs">
<Switch
checked={!!scope[key]}
disabled={!writable || !scopeAnchored}
onCheckedChange={(v) => setScopeDetail({ [key]: !!v } as Partial<AdminScope>)}
/>
{t(label)}
</label>
))}
<label className="flex items-center gap-1.5 text-xs">
<Switch
checked={!!scope.manageAssignments}
disabled={!writable || !scopeAnchored}
onCheckedChange={(v) => setScopeDetail({ manageAssignments: !!v })}
/>
{t('perm.admin.manageAssignments')}
</label>
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t('perm.admin.assignableSets')}</Label>
Expand Down
8 changes: 2 additions & 6 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2473,7 +2473,7 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'perm.tabs.vis.default_off': 'Default off',
'perm.admin.title': 'Delegated Admin Scope',
'perm.admin.help':
'Lets holders administer a business-unit subtree (assign users, manage bindings) without full admin.',
'Lets holders administer a business-unit subtree (assign users) without full admin.',
'perm.admin.businessUnit': 'Business unit',
// The dependent controls' disabled reason (objectui#9464): `businessUnit` is
// the one key the framework's admin-scope shape requires, so a scope written
Expand All @@ -2482,8 +2482,6 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'Name a business unit first — a scope without one is refused when the record is saved.',
'perm.admin.includeSubtree': 'Include subtree',
'perm.admin.manageAssignments': 'Manage assignments',
'perm.admin.manageBindings': 'Manage bindings',
'perm.admin.authorEnvironmentSets': 'Author env sets',
'perm.admin.assignableSets': 'Assignable permission sets',
'perm.admin.noSets': 'No permission sets loaded.',
'perm.loading': 'Loading permission set {name}…',
Expand Down Expand Up @@ -5856,15 +5854,13 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'perm.tabs.vis.default_on': '默认开启',
'perm.tabs.vis.default_off': '默认关闭',
'perm.admin.title': '委派管理范围',
'perm.admin.help': '允许持有者管理某业务单元子树(分配用户、管理绑定),无需完全管理员权限。',
'perm.admin.help': '允许持有者管理某业务单元子树(分配用户),无需完全管理员权限。',
'perm.admin.businessUnit': '业务单元',
// objectui#9464 — 见 EN 表同键注释。
'perm.admin.businessUnitRequired':
'请先填写业务单元 — 没有业务单元的范围在保存记录时会被拒绝。',
'perm.admin.includeSubtree': '包含子树',
'perm.admin.manageAssignments': '管理分配',
'perm.admin.manageBindings': '管理绑定',
'perm.admin.authorEnvironmentSets': '编辑环境权限集',
'perm.admin.assignableSets': '可分配权限集',
'perm.admin.noSets': '未加载权限集。',
'perm.loading': '加载权限集 {name}…',
Expand Down
Loading