Skip to content

fix(app-shell): members and invitations tabs gate their affordances by org role (#4475) - #4505

Draft
yinlianghui wants to merge 1 commit into
mainfrom
claude/issue-4475-members-role-gating
Draft

fix(app-shell): members and invitations tabs gate their affordances by org role (#4475)#4505
yinlianghui wants to merge 1 commit into
mainfrom
claude/issue-4475-members-role-gating

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Closes #4475

The defect

On /_console/organizations/<slug>/members, a user whose org role is member was shown an enabled Invite member button and a per-row Member actions menu carrying Remove member — on every row, the workspace Owner's included. Nothing was hidden or disabled; the action only failed after the user committed to it. The Settings tab of the same page already gates correctly (form replaced by explanatory copy, Delete disabled, Leave enabled); the members and invitations tabs never got the same treatment.

The measured role rule (what I keyed on)

Not role === 'owner' — that is wrong in both directions, and the server says so. The three affordances on these two tabs sit behind three different better-auth permissions:

affordance route permission roles holding it
Invite member /organization/invite-member invitation:["create"] owner, admin, delegated_admin
Remove member /organization/remove-member member:["delete"] owner, admin
Cancel invitation /organization/cancel-invitation invitation:["cancel"] owner, admin

Evidence, read from the code that enforces it:

  • better-auth 1.6.26, plugins/organization/access/statement.mjsownerAc and adminAc both carry invitation: ["create","cancel"] and member: ["create","update","delete"]; memberAc carries invitation: [] and member: [].
  • Route checks: routes/crud-invites.mjs asserts { invitation: ["create"] } for invite and { invitation: ["cancel"] } for cancel; routes/crud-members.mjs asserts { member: ["delete"] } for remove.
  • objectstack packages/plugins/plugin-auth/src/auth-manager.ts registers the fourth role on top of those defaults:
built[MEMBERSHIP_ROLE_DELEGATED_ADMIN] = defaultAc.newRole({
  ...memberAc.statements,          // member: [] — may NOT remove anyone
  invitation: ['create'],          // …but MAY invite
});

delegated_admin is the row a single boolean cannot express: it may invite, may not remove, and deliberately has no cancel — the framework's own comment records why (better-auth's cancel route checks only the permission and never invitation attribution, so granting it would mean "cancel anyone's pending invitation in the org"). That asymmetry is mirrored exactly rather than flattened.

The actor's role comes from activeMember.role — the same source the role-change narrowing (assignableOrgRoles, framework #3697) on this page already reads, so the screen keeps one role source. The new orgCapabilities module composes @object-ui/auth's orgRoleGrade ladder and role names rather than restating them, so the closed ADR-0108 vocabulary keeps exactly one definition. An unresolved role grades below a plain member (orgRoleGrade's documented floor), so nothing privileged is offered to a viewer whose membership could not be read.

Copy-link on the invitations tab is gated with the invite predicate, not the cancel one: the link is the invitation, so handing it out is the issuing capability finishing its job — which is why a delegated_admin keeps it and still loses cancel.

remove-member server verdict (the card's open probe — measured read-only)

The card noted that remove-member's gating was unverified from the client because a probe with a non-existent member returned 400 MEMBER_NOT_FOUND. Measured by reading the route, not by exercising it: the server does gate it. In crud-members.mjs the target lookup runs first —

if (!toBeRemovedMember) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND);

— and only afterwards:

if (!await hasPermission({ role: member.role, permissions: { member: ["delete"] }, … }))
  throw APIError.from("UNAUTHORIZED", ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER);

So the ordering explains the maintainer's 400 exactly — the probe never reached the gate — and any real member id does reach it. No server-side defect; nothing to escalate to the objectstack lane. One observation, not filed as a defect: the ordering lets a non-permitted caller distinguish "member exists" (401) from "no such member" (400). It is upstream better-auth behaviour and reveals nothing a member cannot already read from the members list they are shown.

Red-first

Verbatim DOM for a member viewer, captured from a throwaway probe test at both ends.

Pre-fix — members page header and the Owner's row:

<div class="flex items-center justify-between"><h2 class="text-lg font-semibold">Members (2)</h2><button data-testid="invite-member-btn">Invite member</button></div>

<div … data-testid="member-row-m-1">…<span … data-testid="member-role-badge-m-1">Owner</span><div><button variant="ghost" class="h-8 w-8 shrink-0" aria-label="Member actions"><span></span></button><div align="end"><button class="text-destructive focus:text-destructive"><span></span>Remove member</button></div></div></div>

inviteBtn=PRESENT  inviteBtnDisabled=false  removeItems=2  memberActionsMenus=2
copyLink=PRESENT   cancelInvitation=PRESENT

Post-fix — same viewer:

<div class="flex items-center justify-between"><h2 class="text-lg font-semibold">Members (2)</h2><p class="text-sm text-muted-foreground" data-testid="invite-restricted-note">Only organization admins can invite members.</p></div>

<div … data-testid="member-row-m-1">…<span … data-testid="member-role-badge-m-1">Owner</span></div>

inviteBtn=ABSENT   removeItems=0   memberActionsMenus=0
copyLink=ABSENT    cancelInvitation=ABSENT

The whole suite run against the unfixed tree: 12 failed | 12 passed — the 12 passing being exactly the must-not-change half. After the fix: 24 passed.

Targeted reverse verification, direction predicted before running: widening canInviteMembers back to a plain grade >= admin reds precisely the two delegated_admin cases (invite button, copy-link) and nothing else — 2 failed | 22 passed. Restored, green again.

Must-not-change (green on both sides)

  • owner and admin keep the Invite button, Remove on every row, and the row menu (removeItems=2, memberActionsMenus=2).
  • admin still cannot re-role an Owner (better-auth's creatorRole protection) yet keeps Remove on that row — pinned so the new gate cannot be confused with the role-item gate.
  • The member list and the invitation ledger still render in full for a member; only write affordances go. Whether org_member should read the ledger at all is objectstack#8095 and is not coupled here.
  • Leave organization is untouched, and SettingsPage.tsx is byte-identical (git diff origin/main -- SettingsPage.tsx is empty).
  • The console: organization & invitation UI ships untranslated English in a zh locale (6 sites, incl. icon-only aria-labels) #4474 i18n pins stay green.

Presentation

Where the Invite button was, the members page now puts the explanation, in the Settings tab's own text-sm text-muted-foreground voice — the same convention (copy takes the place of the affordance it replaces), sized for a header slot rather than a form. Not a disabled button: a control that exists only to refuse is still an invitation to try. An actor left with no row action gets no menu, rather than a trigger that opens onto nothing. The invitations tab has no header affordance to replace, so it gets no copy — its per-row icons simply do not render.

One new string, organization.members.inviteRestrictedNote, added through the channel #4474 established (PR #4496): inline useObjectTranslation default plus all ten packs. resolveOrgRoleLabel and the error mapper are reused untouched.

Verification

  • npx vitest run packages/app-shell/src/console/organizations packages/i18n/src/__tests__55 files, 892 passed
  • tsc --noEmit -p packages/app-shell/tsconfig.json → clean; tsc -p packages/app-shell/tsconfig.test.json → clean
  • eslint packages/app-shell/src/console/organizations packages/i18n/src/locales0 errors (109 pre-existing warnings, all react-hooks/set-state-in-effect on untouched fetch effects)
  • check:i18n-keys, check:i18n-drift, check-control-bytes → all green (drift reports 1 key added, 0 en values changed)
  • .d.ts-checked: MembersPage(): JSX.Element and InvitationsPage(): JSX.Element are unchanged, and orgCapabilities is internal — not re-exported from index.d.ts. Patch, never major.

Scope

packages/app-shell/src/console/organizations/manage/** + one test + one changeset, plus the ten locale packs the #4474 parity convention requires (PR #4496 set that precedent and touched the same ten). Nothing in shell chrome, the session hook, the plugin packages, the test-support extraction, or content/docs/releases/.


Generated by Claude Code

…y org role (#4475)

A user whose org role is `member` was shown an enabled **Invite member** button
and a per-row **Member actions** menu carrying **Remove member** — on every row,
the workspace Owner's included. Nothing was hidden or disabled; the action only
failed after the user committed to it. The Settings tab of the same page already
gated correctly; these two tabs never got the same treatment.

The roles are MEASURED against the routes that enforce them, not assumed to be
"owner". better-auth 1.6.26's `organization/access/statement.mjs` plus the
`delegated_admin` role the framework registers on top of it
(objectstack `plugin-auth/src/auth-manager.ts`) give three DIFFERENT gates:

  invite  -> `invitation:["create"]` -> owner, admin, delegated_admin
  remove  -> `member:["delete"]`     -> owner, admin
  cancel  -> `invitation:["cancel"]` -> owner, admin

`delegated_admin` is the row a single `isOwner` boolean cannot express: built
from `memberAc.statements` (`member: []`) with `invitation: ['create']` added
and `cancel` deliberately withheld. `orgCapabilities` composes the existing
`orgRoleGrade` ladder rather than restating the closed ADR-0108 vocabulary.

An actor left with no row action gets no menu rather than a trigger opening onto
nothing, and the members page explains the absence where the Invite button sat,
in the Settings tab's own `text-sm text-muted-foreground` voice. An unresolved
role grades below a plain member, so nothing privileged is offered to a viewer
whose membership could not be read.

Reading is untouched: the member list and the invitation ledger still render.
Whether `org_member` should read the ledger at all is objectstack#8095 and is
deliberately not coupled here.

Server verdict on the card's open probe: `remove-member` IS gated. The target
lookup runs BEFORE the permission check, which is why the maintainer's
non-existent-member probe returned `400 MEMBER_NOT_FOUND` and said nothing about
gating — any real member id reaches
`hasPermission({ member: ["delete"] })` and 401s. No server-side defect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 12, 2026 10:36pm

Request Review

@github-actions github-actions Bot added the tests label Aug 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-_dRx9hm-.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 36.76KB 9.60KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 489.32KB 108.45KB
core (index.js) 3.37KB 1.34KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 153.79KB 41.35KB
fields (index.js) 230.07KB 57.07KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.33KB 1.20KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.86KB 12.91KB
plugin-charts (index.js) 62.07KB 17.65KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 120.86KB 31.50KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.03KB 59.77KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.14KB 39.98KB
plugin-grid (index.js) 188.13KB 50.00KB
plugin-kanban (index.js) 48.62KB 13.42KB
plugin-list (index.js) 111.07KB 27.08KB
plugin-map (index.js) 18.16KB 5.81KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.16KB 10.96KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.08KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.73KB 7.96KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

console: workspace members page offers Invite / Remove member to the member role; only the server 403 stops it

1 participant