Skip to content

Commit f44c1bd

Browse files
os-zhuangclaude
andauthored
fix(platform-objects): hide org/membership surfaces in single-org mode (#2348)
* fix(platform-objects): hide org/membership surfaces in single-org mode Apply the platform's existing multi-org gating convention consistently across the org/membership surface, which had only been wired in a handful of spots: - nav entries -> requiresService: 'org-scoping' - object actions -> visible: 'features.multiOrgEnabled != false' Nav (P1): - account.app "My Organizations" (sys_member/mine): was gated on requiresObject: 'sys_member', which never fires (system object always registered) and left the always-empty view visible in single-org. Re-gated on requiresService: 'org-scoping'. - setup-nav "Teams" (sys_team): had no gate while sibling Organizations/ Invitations were service-gated. Added requiresService: 'org-scoping'. Actions (P0) — gated on features.multiOrgEnabled != false: - sys_user.invite_user (most exposed: Users list always reachable) - sys_member add_member/update_member_role/remove_member + transfer_ownership - sys_team create_team/update_team/remove_team - sys_team_member add_team_member/remove_team_member - sys_invitation invite_user/resend_invitation/cancel_invitation Recipient-side invitation accept/reject stay record-gated (unreachable in single-org anyway). No behavior change in multi-org. Metadata-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(platform-objects): tidy remaining single-org rough edges (P2) - Gate the remaining sys_organization admin actions (update/delete/ set_active/leave/change_slug) on features.multiOrgEnabled != false, so every org action matches the already-gated create_organization. - Stop rendering a null organization in titleFormat: - sys_member: '{user_id} in {organization_id}' -> '{user_id} ({role})' - sys_invitation: 'Invitation to {organization_id}' -> 'Invitation for {email}' organization_id is null in single-org mode, so the old formats read "... null"; the new fields identify the record in both modes. sys_team's by_org view was left as-is: the teams list is empty in single-org (create is gated) so the grouping never degenerates in practice, and the nav entry is hidden anyway. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bc22a89 commit f44c1bd

9 files changed

Lines changed: 111 additions & 5 deletions
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
---
4+
5+
fix(platform-objects): hide org/membership surfaces in single-org mode
6+
7+
The platform gates multi-org features two ways — nav entries on
8+
`requiresService: 'org-scoping'` (e.g. setup-nav Organizations/Invitations)
9+
and object actions on `visible: 'features.multiOrgEnabled != false'` (e.g.
10+
`sys_organization.create_organization`). That convention had only been applied
11+
to a handful of spots, so a wide band of org/membership surface leaked into
12+
single-org deployments where it is pure noise or a broken affordance:
13+
14+
- The Account app's "My Organizations" entry (`sys_member` / `mine` view) was
15+
gated on `requiresObject: 'sys_member'` — but `sys_member` is a system object
16+
that is always registered, so the gate never fired. In single-org there are
17+
no `sys_organization` rows and no auto-stamped memberships, so the view is
18+
always empty for every user. Re-gated on `requiresService: 'org-scoping'`.
19+
- The setup-nav "Teams" entry had no gate at all, while its sibling
20+
Organizations/Invitations entries were correctly service-gated. Added
21+
`requiresService: 'org-scoping'`.
22+
- Org/membership mutation actions rendered (and on toolbars, were clickable)
23+
in single-org but hit better-auth endpoints that resolve an active org that
24+
does not exist, failing at the API. Gated each on
25+
`features.multiOrgEnabled != false`:
26+
- `sys_user.invite_user` (the most exposed — the Users list is always
27+
reachable in single-org)
28+
- `sys_member.add_member` / `update_member_role` / `remove_member`, and
29+
`transfer_ownership` (combined with its existing `record.role != 'owner'`
30+
condition)
31+
- `sys_team.create_team` / `update_team` / `remove_team`
32+
- `sys_team_member.add_team_member` / `remove_team_member`
33+
- `sys_invitation.invite_user` / `resend_invitation` / `cancel_invitation`
34+
(recipient-side accept/reject stay record-gated; they are unreachable in
35+
single-org anyway since no invitation rows exist)
36+
37+
Also tightened the remaining single-org rough edges on these objects:
38+
39+
- `sys_organization` admin actions (`update` / `delete` / `set_active` /
40+
`leave` / `change_slug`) are now all gated on
41+
`features.multiOrgEnabled != false`, joining the already-gated
42+
`create_organization` — previously only create was gated.
43+
- `titleFormat` no longer renders a null organization: `sys_member` is titled
44+
`'{user_id} ({role})'` (was `'… in {organization_id}'`) and `sys_invitation`
45+
is titled `'Invitation for {email}'` (was `'Invitation to {organization_id}'`).
46+
In single-org `organization_id` is null, so the old formats read "… in null".
47+
The new fields are more useful identifiers in both modes.
48+
49+
No behavior change in multi-org deployments (`OS_MULTI_ORG_ENABLED=true`):
50+
`features.multiOrgEnabled` is true and the `org-scoping` service is present, so
51+
every gate evaluates to visible exactly as before. This is metadata-only — no
52+
schema, API, or runtime changes.

packages/platform-objects/src/apps/account.app.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,13 @@ export const ACCOUNT_APP: App = {
109109
objectName: 'sys_member',
110110
viewName: 'mine',
111111
icon: 'building-2',
112-
requiresObject: 'sys_member',
112+
// Membership is a multi-org concept: in single-org mode the
113+
// `mine` view is always empty (no sys_organization rows, no
114+
// auto-stamped memberships). Gate on the org-scoping service so
115+
// this entry disappears entirely — matching Organizations/
116+
// Invitations in setup-nav. `requiresObject: 'sys_member'` was
117+
// the wrong gate (the system object is always registered).
118+
requiresService: 'org-scoping',
113119
},
114120
],
115121
},

packages/platform-objects/src/apps/setup-nav.contributions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
4848
items: [
4949
{ id: 'nav_users', type: 'object', label: 'Users', objectName: 'sys_user', icon: 'user' },
5050
{ id: 'nav_business_units', type: 'object', label: 'Business Units', objectName: 'sys_business_unit', icon: 'building', requiresObject: 'sys_business_unit' },
51-
{ id: 'nav_teams', type: 'object', label: 'Teams', objectName: 'sys_team', icon: 'users-round' },
51+
{ id: 'nav_teams', type: 'object', label: 'Teams', objectName: 'sys_team', icon: 'users-round', requiresService: 'org-scoping' },
5252
{ id: 'nav_organizations', type: 'object', label: 'Organizations', objectName: 'sys_organization', icon: 'building-2', requiresService: 'org-scoping' },
5353
{ id: 'nav_invitations', type: 'object', label: 'Invitations', objectName: 'sys_invitation', icon: 'mail', requiresService: 'org-scoping' },
5454
],

packages/platform-objects/src/identity/sys-invitation.object.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ export const SysInvitation = ObjectSchema.create({
2626
docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection',
2727
},
2828
description: 'Organization invitations for user onboarding',
29-
titleFormat: 'Invitation to {organization_id}',
29+
// Title by invitee email rather than organization_id: the latter is null in
30+
// single-org mode (renders "Invitation to null"), and the recipient email is
31+
// the more useful identifier in both modes anyway.
32+
titleFormat: 'Invitation for {email}',
3033
compactLayout: ['email', 'organization_id', 'status'],
3134

3235
// Custom actions — generic CRUD is suppressed (better-auth-managed).
@@ -41,6 +44,13 @@ export const SysInvitation = ObjectSchema.create({
4144
locations: ['list_toolbar'],
4245
type: 'api',
4346
target: '/api/v1/auth/organization/invite-member',
47+
// Inviting/managing invitations is a multi-org-only flow (the
48+
// endpoint resolves an active org absent in single-org mode). Gate
49+
// the admin-side actions on the multi-org flag (mirrors
50+
// sys_organization.create_organization). The recipient-side
51+
// accept/reject actions below stay record-gated — they are
52+
// unreachable in single-org anyway (no invitation rows exist).
53+
visible: 'features.multiOrgEnabled != false',
4454
successMessage: 'Invitation sent',
4555
refreshAfter: true,
4656
params: [
@@ -58,6 +68,7 @@ export const SysInvitation = ObjectSchema.create({
5868
type: 'api',
5969
target: '/api/v1/auth/organization/cancel-invitation',
6070
recordIdParam: 'invitationId',
71+
visible: 'features.multiOrgEnabled != false',
6172
confirmText: 'Cancel this invitation? The recipient will no longer be able to accept it.',
6273
successMessage: 'Invitation canceled',
6374
refreshAfter: true,
@@ -71,6 +82,7 @@ export const SysInvitation = ObjectSchema.create({
7182
type: 'api',
7283
target: '/api/v1/auth/organization/invite-member',
7384
bodyExtra: { resend: true },
85+
visible: 'features.multiOrgEnabled != false',
7486
successMessage: 'Invitation resent',
7587
refreshAfter: true,
7688
params: [

packages/platform-objects/src/identity/sys-member.object.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ export const SysMember = ObjectSchema.create({
2626
docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection',
2727
},
2828
description: 'Organization membership records',
29-
titleFormat: '{user_id} in {organization_id}',
29+
// Org-independent title: organization_id is null in single-org mode, so a
30+
// '{user_id} in {organization_id}' format renders "… in null". User + role
31+
// identifies the membership in both single- and multi-org deployments.
32+
titleFormat: '{user_id} ({role})',
3033
compactLayout: ['user_id', 'organization_id', 'role'],
3134

3235
// Row-level actions: better-auth `organization/update-member-role` and
@@ -48,6 +51,11 @@ export const SysMember = ObjectSchema.create({
4851
locations: ['list_toolbar'],
4952
type: 'api',
5053
target: '/api/v1/auth/organization/add-member',
54+
// Org-membership mutations are multi-org-only: the better-auth
55+
// endpoints resolve an active org that does not exist in single-org
56+
// mode, so these actions would fail at the API. Gate every one on
57+
// the multi-org flag (mirrors sys_organization.create_organization).
58+
visible: 'features.multiOrgEnabled != false',
5159
successMessage: 'Member added',
5260
refreshAfter: true,
5361
params: [
@@ -65,6 +73,7 @@ export const SysMember = ObjectSchema.create({
6573
type: 'api',
6674
target: '/api/v1/auth/organization/update-member-role',
6775
recordIdParam: 'memberId',
76+
visible: 'features.multiOrgEnabled != false',
6877
successMessage: 'Member role updated',
6978
refreshAfter: true,
7079
params: [
@@ -81,6 +90,7 @@ export const SysMember = ObjectSchema.create({
8190
type: 'api',
8291
target: '/api/v1/auth/organization/remove-member',
8392
recordIdParam: 'memberIdOrEmail',
93+
visible: 'features.multiOrgEnabled != false',
8494
confirmText: 'Remove this member from the organization? They will lose access to all org resources.',
8595
successMessage: 'Member removed',
8696
refreshAfter: true,
@@ -102,7 +112,7 @@ export const SysMember = ObjectSchema.create({
102112
target: '/api/v1/auth/organization/update-member-role',
103113
recordIdParam: 'memberId',
104114
bodyExtra: { role: 'owner' },
105-
visible: "record.role != 'owner'",
115+
visible: "record.role != 'owner' && features.multiOrgEnabled != false",
106116
confirmText: 'Transfer ownership of this organization to the selected member? You will be demoted to admin and lose owner-only privileges.',
107117
successMessage: 'Ownership transferred',
108118
refreshAfter: true,

packages/platform-objects/src/identity/sys-organization.object.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ export const SysOrganization = ObjectSchema.create({
6666
recordIdParam: 'organizationId',
6767
// better-auth `organization/update` nests editable fields under `data`.
6868
bodyShape: { wrap: 'data' },
69+
// Org-admin actions are multi-org-only; hide them in single-org for
70+
// consistency with `create_organization` (the org list is empty there,
71+
// but this also guards direct record-URL access).
72+
visible: 'features.multiOrgEnabled != false',
6973
successMessage: 'Organization updated',
7074
refreshAfter: true,
7175
params: [
@@ -84,6 +88,7 @@ export const SysOrganization = ObjectSchema.create({
8488
type: 'api',
8589
target: '/api/v1/auth/organization/delete',
8690
recordIdParam: 'organizationId',
91+
visible: 'features.multiOrgEnabled != false',
8792
confirmText: 'Delete this organization? All members will lose access immediately. This cannot be undone.',
8893
successMessage: 'Organization deleted',
8994
refreshAfter: true,
@@ -102,6 +107,7 @@ export const SysOrganization = ObjectSchema.create({
102107
type: 'api',
103108
target: '/api/v1/auth/organization/set-active',
104109
recordIdParam: 'organizationId',
110+
visible: 'features.multiOrgEnabled != false',
105111
successMessage: 'Active organization switched',
106112
refreshAfter: true,
107113
},
@@ -118,6 +124,7 @@ export const SysOrganization = ObjectSchema.create({
118124
type: 'api',
119125
target: '/api/v1/auth/organization/leave',
120126
recordIdParam: 'organizationId',
127+
visible: 'features.multiOrgEnabled != false',
121128
confirmText: 'Leave this organization? You will lose access to all of its resources.',
122129
successMessage: 'You have left the organization',
123130
refreshAfter: true,
@@ -139,6 +146,7 @@ export const SysOrganization = ObjectSchema.create({
139146
type: 'api',
140147
target: '/api/v1/cloud/organizations/{id}/change-slug',
141148
method: 'POST',
149+
visible: 'features.multiOrgEnabled != false',
142150
confirmText: 'Renaming the slug rewrites every platform subdomain for this org and parks the old slug for 90 days. Continue?',
143151
successMessage: 'Organization slug changed',
144152
refreshAfter: true,

packages/platform-objects/src/identity/sys-team-member.object.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ export const SysTeamMember = ObjectSchema.create({
4242
locations: ['list_toolbar'],
4343
type: 'api',
4444
target: '/api/v1/auth/organization/add-team-member',
45+
// Team membership lives under organizations — multi-org-only. Gate
46+
// both mutations so they vanish in single-org (mirrors
47+
// sys_organization.create_organization).
48+
visible: 'features.multiOrgEnabled != false',
4549
successMessage: 'Team member added',
4650
refreshAfter: true,
4751
params: [
@@ -62,6 +66,7 @@ export const SysTeamMember = ObjectSchema.create({
6266
locations: ['list_item'],
6367
type: 'api',
6468
target: '/api/v1/auth/organization/remove-team-member',
69+
visible: 'features.multiOrgEnabled != false',
6570
confirmText: 'Remove this user from the team? They will lose any team-scoped access.',
6671
successMessage: 'Team member removed',
6772
refreshAfter: true,

packages/platform-objects/src/identity/sys-team.object.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ export const SysTeam = ObjectSchema.create({
4444
locations: ['list_toolbar'],
4545
type: 'api',
4646
target: '/api/v1/auth/organization/create-team',
47+
// Teams are nested inside organizations — a multi-org-only concept.
48+
// Gate every team mutation on the multi-org flag so the affordances
49+
// disappear in single-org (mirrors sys_organization.create_organization).
50+
visible: 'features.multiOrgEnabled != false',
4751
successMessage: 'Team created',
4852
refreshAfter: true,
4953
params: [
@@ -64,6 +68,7 @@ export const SysTeam = ObjectSchema.create({
6468
target: '/api/v1/auth/organization/update-team',
6569
recordIdParam: 'teamId',
6670
bodyShape: { wrap: 'data' },
71+
visible: 'features.multiOrgEnabled != false',
6772
successMessage: 'Team updated',
6873
refreshAfter: true,
6974
params: [
@@ -82,6 +87,7 @@ export const SysTeam = ObjectSchema.create({
8287
type: 'api',
8388
target: '/api/v1/auth/organization/remove-team',
8489
recordIdParam: 'teamId',
90+
visible: 'features.multiOrgEnabled != false',
8591
confirmText: 'Delete this team? Members will lose any team-scoped access. This cannot be undone.',
8692
successMessage: 'Team deleted',
8793
refreshAfter: true,

packages/platform-objects/src/identity/sys-user.object.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ export const SysUser = ObjectSchema.create({
4646
locations: ['list_toolbar'],
4747
type: 'api',
4848
target: '/api/v1/auth/organization/invite-member',
49+
// Org invitations are a multi-org-only flow (the endpoint resolves
50+
// an active org that does not exist in single-org mode). Hide the
51+
// affordance unless multi-org is enabled — matching the
52+
// `create_organization` gate on sys_organization. This action is the
53+
// most exposed of the set because the Users list is always reachable
54+
// in single-org, unlike the org/membership lists.
55+
visible: 'features.multiOrgEnabled != false',
4956
successMessage: 'Invitation sent',
5057
refreshAfter: true,
5158
params: [

0 commit comments

Comments
 (0)