From b425d6bd5bbaae06bfc3959b3132117439fbb6dd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 02:40:40 +0000 Subject: [PATCH] feat(console): make `delegated_admin` reachable and narrow both role pickers (framework#3697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The framework registered a fourth organization role — `delegated_admin`, the grade that may reach `/organization/invite-member` WITHOUT being an org admin, which is what finally gives ADR-0105 D8's scope-bounded issuance gate a caller. #2868 already shipped the placement half of this UX, but the console could not select the role at all: `MembersPage` and `InviteMemberDialog` each inlined `type Role = 'owner' | 'admin' | 'member'`, so the capability the framework grew was unreachable from either screen. One vocabulary, not two. Role names, labels and narrowing rules now live in `@object-ui/auth`'s new `org-roles` module and both screens consume it. The list still MIRRORS the server rather than deriving from it — `/auth/config` publishes feature flags but no role vocabulary — so the module says so and points at objectstack-ai/objectstack#3723, which tracks making one list the source for all of them. Both pickers now narrow to what the server will accept, the way the placement picker already does. They mirror DIFFERENT gates: - invite role ← `beforeCreateInvitation`'s role cap: never above the issuer's own grade; a below-admin issuer may invite as `member` only. A `delegated_admin` picking "Admin" would have 403'd; it is no longer offered. - change role ← better-auth's `update-member-role`: needs `member:["update"]` (owner/admin only), and only an owner may set `owner` or re-role an existing owner. An actor who may re-role nobody gets no items rather than three that would 403. Narrowing is convenience, not the boundary — the server re-checks — and it fails toward less: an unresolved membership offers `member` alone on invite, nothing on re-role. An ordinary invitation's request body is byte-identical to before. The existing placement test mocked `@object-ui/auth` wholesale; it now spreads `importActual` so the narrowing helpers under test are the real ones. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015s4qPrCKBvVwmFeSsFHgNp --- .changeset/console-delegated-admin-role.md | 50 +++++++ .../InviteMemberDialog.placement.test.tsx | 8 +- .../InviteMemberDialog.roleCap.test.tsx | 141 ++++++++++++++++++ .../manage/InviteMemberDialog.tsx | 36 ++--- .../organizations/manage/MembersPage.tsx | 51 +++---- packages/auth/src/__tests__/org-roles.test.ts | 117 +++++++++++++++ packages/auth/src/index.ts | 15 ++ packages/auth/src/org-roles.ts | 135 +++++++++++++++++ 8 files changed, 507 insertions(+), 46 deletions(-) create mode 100644 .changeset/console-delegated-admin-role.md create mode 100644 packages/app-shell/src/console/organizations/__tests__/InviteMemberDialog.roleCap.test.tsx create mode 100644 packages/auth/src/__tests__/org-roles.test.ts create mode 100644 packages/auth/src/org-roles.ts diff --git a/.changeset/console-delegated-admin-role.md b/.changeset/console-delegated-admin-role.md new file mode 100644 index 000000000..5a9048744 --- /dev/null +++ b/.changeset/console-delegated-admin-role.md @@ -0,0 +1,50 @@ +--- +"@object-ui/auth": minor +"@object-ui/app-shell": minor +--- + +feat(console): make the `delegated_admin` org role reachable, and narrow both role pickers to what the server will accept (framework#3697) + +The framework registered a fourth organization role — `delegated_admin`, the +grade that may reach `/organization/invite-member` **without** being an org +admin, which is what finally gives ADR-0105 D8's scope-bounded issuance gate a +caller. objectui#2868 already shipped the placement half of that UX (units and +positions narrowed by `describeDelegableScope()`), but the console could not +select the role in the first place: `MembersPage` and `InviteMemberDialog` each +inlined `type Role = 'owner' | 'admin' | 'member'`, so the capability the +framework grew was unreachable from either screen. + +**One vocabulary, not two.** The role names, labels and narrowing rules now live +in `@object-ui/auth`'s new `org-roles` module (`ORG_ROLES`, `ORG_ROLE_LABELS`, +`orgRoleGrade`, `invitableOrgRoles`, `assignableOrgRoles`) and both screens +consume it. Note this list still **mirrors** the server rather than deriving +from it — `/auth/config` publishes feature flags but no role vocabulary, so +there is no surface to read; objectstack-ai/objectstack#3723 tracks making one +list the source for all of them. Until then a server-side role addition means +one console edit instead of two. + +**The pickers now narrow, the way the placement picker already does.** Both +mirror a *different* server gate, and offering an option the server would refuse +is the failure they prevent: + +- **Invite role** ← the framework's `beforeCreateInvitation` role cap: never + above the issuer's own grade, and an issuer below admin grade may invite as + `member` only. A `delegated_admin` who picked "Admin" would have been refused + with a 403; that option is simply no longer offered. +- **Change role** ← better-auth's `update-member-role` route: it requires the + `member:["update"]` permission (owner/admin only — `delegated_admin` is built + from `memberAc` and holds `member: []`), and only an owner may set `owner` or + re-role an existing owner. An actor who may re-role nobody now gets no items + instead of three that would 403. + +Narrowing is convenience, not the boundary — the server re-checks every one of +these — and it fails toward *less*: an unresolved membership offers `member` +alone on invite, and nothing on re-role. + +An ordinary invitation is unchanged: with the default role and no placement, the +request body is byte-identical to before. + +Note for translators: `organization.roles.*` has never been defined in any +locale bundle — all four labels (owner/admin/member included) resolve through +their `defaultValue` English fallback. The new role follows the same pattern +rather than being the only localized one. diff --git a/packages/app-shell/src/console/organizations/__tests__/InviteMemberDialog.placement.test.tsx b/packages/app-shell/src/console/organizations/__tests__/InviteMemberDialog.placement.test.tsx index 5080d3083..b83df298f 100644 --- a/packages/app-shell/src/console/organizations/__tests__/InviteMemberDialog.placement.test.tsx +++ b/packages/app-shell/src/console/organizations/__tests__/InviteMemberDialog.placement.test.tsx @@ -27,8 +27,12 @@ vi.mock('@object-ui/i18n', () => ({ const inviteMember = vi.fn(); const describeDelegableScope = vi.fn(); -vi.mock('@object-ui/auth', () => ({ - useAuth: () => ({ inviteMember, describeDelegableScope }), +// Only `useAuth` is faked — the role vocabulary / narrowing helpers stay REAL, +// so a change to the cap surfaces here instead of being mocked away. These +// cases are about placement, so the caller is an owner (widest role list). +vi.mock('@object-ui/auth', async (importActual) => ({ + ...(await importActual()), + useAuth: () => ({ inviteMember, describeDelegableScope, activeMember: { role: 'owner' } }), })); // Passthrough primitives — the Select is rendered as a native , + Label: (p: any) =>