diff --git a/.changeset/single-org-gate-membership-surfaces.md b/.changeset/single-org-gate-membership-surfaces.md new file mode 100644 index 0000000000..7028503835 --- /dev/null +++ b/.changeset/single-org-gate-membership-surfaces.md @@ -0,0 +1,52 @@ +--- +"@objectstack/platform-objects": patch +--- + +fix(platform-objects): hide org/membership surfaces in single-org mode + +The platform gates multi-org features two ways — nav entries on +`requiresService: 'org-scoping'` (e.g. setup-nav Organizations/Invitations) +and object actions on `visible: 'features.multiOrgEnabled != false'` (e.g. +`sys_organization.create_organization`). That convention had only been applied +to a handful of spots, so a wide band of org/membership surface leaked into +single-org deployments where it is pure noise or a broken affordance: + +- The Account app's "My Organizations" entry (`sys_member` / `mine` view) was + gated on `requiresObject: 'sys_member'` — but `sys_member` is a system object + that is always registered, so the gate never fired. In single-org there are + no `sys_organization` rows and no auto-stamped memberships, so the view is + always empty for every user. Re-gated on `requiresService: 'org-scoping'`. +- The setup-nav "Teams" entry had no gate at all, while its sibling + Organizations/Invitations entries were correctly service-gated. Added + `requiresService: 'org-scoping'`. +- Org/membership mutation actions rendered (and on toolbars, were clickable) + in single-org but hit better-auth endpoints that resolve an active org that + does not exist, failing at the API. Gated each on + `features.multiOrgEnabled != false`: + - `sys_user.invite_user` (the most exposed — the Users list is always + reachable in single-org) + - `sys_member.add_member` / `update_member_role` / `remove_member`, and + `transfer_ownership` (combined with its existing `record.role != 'owner'` + condition) + - `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 accept/reject stay record-gated; they are unreachable in + single-org anyway since no invitation rows exist) + +Also tightened the remaining single-org rough edges on these objects: + +- `sys_organization` admin actions (`update` / `delete` / `set_active` / + `leave` / `change_slug`) are now all gated on + `features.multiOrgEnabled != false`, joining the already-gated + `create_organization` — previously only create was gated. +- `titleFormat` no longer renders a null organization: `sys_member` is titled + `'{user_id} ({role})'` (was `'… in {organization_id}'`) and `sys_invitation` + is titled `'Invitation for {email}'` (was `'Invitation to {organization_id}'`). + In single-org `organization_id` is null, so the old formats read "… in null". + The new fields are more useful identifiers in both modes. + +No behavior change in multi-org deployments (`OS_MULTI_ORG_ENABLED=true`): +`features.multiOrgEnabled` is true and the `org-scoping` service is present, so +every gate evaluates to visible exactly as before. This is metadata-only — no +schema, API, or runtime changes. diff --git a/packages/platform-objects/src/apps/account.app.ts b/packages/platform-objects/src/apps/account.app.ts index 8403097d72..8325c347a9 100644 --- a/packages/platform-objects/src/apps/account.app.ts +++ b/packages/platform-objects/src/apps/account.app.ts @@ -109,7 +109,13 @@ export const ACCOUNT_APP: App = { objectName: 'sys_member', viewName: 'mine', icon: 'building-2', - requiresObject: 'sys_member', + // Membership is a multi-org concept: in single-org mode the + // `mine` view is always empty (no sys_organization rows, no + // auto-stamped memberships). Gate on the org-scoping service so + // this entry disappears entirely — matching Organizations/ + // Invitations in setup-nav. `requiresObject: 'sys_member'` was + // the wrong gate (the system object is always registered). + requiresService: 'org-scoping', }, ], }, diff --git a/packages/platform-objects/src/apps/setup-nav.contributions.ts b/packages/platform-objects/src/apps/setup-nav.contributions.ts index 31685aaa56..adaeab8846 100644 --- a/packages/platform-objects/src/apps/setup-nav.contributions.ts +++ b/packages/platform-objects/src/apps/setup-nav.contributions.ts @@ -48,7 +48,7 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ items: [ { id: 'nav_users', type: 'object', label: 'Users', objectName: 'sys_user', icon: 'user' }, { id: 'nav_business_units', type: 'object', label: 'Business Units', objectName: 'sys_business_unit', icon: 'building', requiresObject: 'sys_business_unit' }, - { id: 'nav_teams', type: 'object', label: 'Teams', objectName: 'sys_team', icon: 'users-round' }, + { id: 'nav_teams', type: 'object', label: 'Teams', objectName: 'sys_team', icon: 'users-round', requiresService: 'org-scoping' }, { id: 'nav_organizations', type: 'object', label: 'Organizations', objectName: 'sys_organization', icon: 'building-2', requiresService: 'org-scoping' }, { id: 'nav_invitations', type: 'object', label: 'Invitations', objectName: 'sys_invitation', icon: 'mail', requiresService: 'org-scoping' }, ], diff --git a/packages/platform-objects/src/identity/sys-invitation.object.ts b/packages/platform-objects/src/identity/sys-invitation.object.ts index 6f6937d6e9..74e5d26ca1 100644 --- a/packages/platform-objects/src/identity/sys-invitation.object.ts +++ b/packages/platform-objects/src/identity/sys-invitation.object.ts @@ -26,7 +26,10 @@ export const SysInvitation = ObjectSchema.create({ docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection', }, description: 'Organization invitations for user onboarding', - titleFormat: 'Invitation to {organization_id}', + // Title by invitee email rather than organization_id: the latter is null in + // single-org mode (renders "Invitation to null"), and the recipient email is + // the more useful identifier in both modes anyway. + titleFormat: 'Invitation for {email}', compactLayout: ['email', 'organization_id', 'status'], // Custom actions — generic CRUD is suppressed (better-auth-managed). @@ -41,6 +44,13 @@ export const SysInvitation = ObjectSchema.create({ locations: ['list_toolbar'], type: 'api', target: '/api/v1/auth/organization/invite-member', + // Inviting/managing invitations is a multi-org-only flow (the + // endpoint resolves an active org absent in single-org mode). Gate + // the admin-side actions on the multi-org flag (mirrors + // sys_organization.create_organization). The recipient-side + // accept/reject actions below stay record-gated — they are + // unreachable in single-org anyway (no invitation rows exist). + visible: 'features.multiOrgEnabled != false', successMessage: 'Invitation sent', refreshAfter: true, params: [ @@ -58,6 +68,7 @@ export const SysInvitation = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/organization/cancel-invitation', recordIdParam: 'invitationId', + visible: 'features.multiOrgEnabled != false', confirmText: 'Cancel this invitation? The recipient will no longer be able to accept it.', successMessage: 'Invitation canceled', refreshAfter: true, @@ -71,6 +82,7 @@ export const SysInvitation = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/organization/invite-member', bodyExtra: { resend: true }, + visible: 'features.multiOrgEnabled != false', successMessage: 'Invitation resent', refreshAfter: true, params: [ diff --git a/packages/platform-objects/src/identity/sys-member.object.ts b/packages/platform-objects/src/identity/sys-member.object.ts index 16d070e4fd..a41be46d54 100644 --- a/packages/platform-objects/src/identity/sys-member.object.ts +++ b/packages/platform-objects/src/identity/sys-member.object.ts @@ -26,7 +26,10 @@ export const SysMember = ObjectSchema.create({ docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection', }, description: 'Organization membership records', - titleFormat: '{user_id} in {organization_id}', + // Org-independent title: organization_id is null in single-org mode, so a + // '{user_id} in {organization_id}' format renders "… in null". User + role + // identifies the membership in both single- and multi-org deployments. + titleFormat: '{user_id} ({role})', compactLayout: ['user_id', 'organization_id', 'role'], // Row-level actions: better-auth `organization/update-member-role` and @@ -48,6 +51,11 @@ export const SysMember = ObjectSchema.create({ locations: ['list_toolbar'], type: 'api', target: '/api/v1/auth/organization/add-member', + // Org-membership mutations are multi-org-only: the better-auth + // endpoints resolve an active org that does not exist in single-org + // mode, so these actions would fail at the API. Gate every one on + // the multi-org flag (mirrors sys_organization.create_organization). + visible: 'features.multiOrgEnabled != false', successMessage: 'Member added', refreshAfter: true, params: [ @@ -65,6 +73,7 @@ export const SysMember = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/organization/update-member-role', recordIdParam: 'memberId', + visible: 'features.multiOrgEnabled != false', successMessage: 'Member role updated', refreshAfter: true, params: [ @@ -81,6 +90,7 @@ export const SysMember = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/organization/remove-member', recordIdParam: 'memberIdOrEmail', + visible: 'features.multiOrgEnabled != false', confirmText: 'Remove this member from the organization? They will lose access to all org resources.', successMessage: 'Member removed', refreshAfter: true, @@ -102,7 +112,7 @@ export const SysMember = ObjectSchema.create({ target: '/api/v1/auth/organization/update-member-role', recordIdParam: 'memberId', bodyExtra: { role: 'owner' }, - visible: "record.role != 'owner'", + visible: "record.role != 'owner' && features.multiOrgEnabled != false", confirmText: 'Transfer ownership of this organization to the selected member? You will be demoted to admin and lose owner-only privileges.', successMessage: 'Ownership transferred', refreshAfter: true, diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index e49596f4d4..f8c4d127ba 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -66,6 +66,10 @@ export const SysOrganization = ObjectSchema.create({ recordIdParam: 'organizationId', // better-auth `organization/update` nests editable fields under `data`. bodyShape: { wrap: 'data' }, + // Org-admin actions are multi-org-only; hide them in single-org for + // consistency with `create_organization` (the org list is empty there, + // but this also guards direct record-URL access). + visible: 'features.multiOrgEnabled != false', successMessage: 'Organization updated', refreshAfter: true, params: [ @@ -84,6 +88,7 @@ export const SysOrganization = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/organization/delete', recordIdParam: 'organizationId', + visible: 'features.multiOrgEnabled != false', confirmText: 'Delete this organization? All members will lose access immediately. This cannot be undone.', successMessage: 'Organization deleted', refreshAfter: true, @@ -102,6 +107,7 @@ export const SysOrganization = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/organization/set-active', recordIdParam: 'organizationId', + visible: 'features.multiOrgEnabled != false', successMessage: 'Active organization switched', refreshAfter: true, }, @@ -118,6 +124,7 @@ export const SysOrganization = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/organization/leave', recordIdParam: 'organizationId', + visible: 'features.multiOrgEnabled != false', confirmText: 'Leave this organization? You will lose access to all of its resources.', successMessage: 'You have left the organization', refreshAfter: true, @@ -139,6 +146,7 @@ export const SysOrganization = ObjectSchema.create({ type: 'api', target: '/api/v1/cloud/organizations/{id}/change-slug', method: 'POST', + visible: 'features.multiOrgEnabled != false', confirmText: 'Renaming the slug rewrites every platform subdomain for this org and parks the old slug for 90 days. Continue?', successMessage: 'Organization slug changed', refreshAfter: true, diff --git a/packages/platform-objects/src/identity/sys-team-member.object.ts b/packages/platform-objects/src/identity/sys-team-member.object.ts index f132e5e493..0e23d7d71b 100644 --- a/packages/platform-objects/src/identity/sys-team-member.object.ts +++ b/packages/platform-objects/src/identity/sys-team-member.object.ts @@ -42,6 +42,10 @@ export const SysTeamMember = ObjectSchema.create({ locations: ['list_toolbar'], type: 'api', target: '/api/v1/auth/organization/add-team-member', + // Team membership lives under organizations — multi-org-only. Gate + // both mutations so they vanish in single-org (mirrors + // sys_organization.create_organization). + visible: 'features.multiOrgEnabled != false', successMessage: 'Team member added', refreshAfter: true, params: [ @@ -62,6 +66,7 @@ export const SysTeamMember = ObjectSchema.create({ locations: ['list_item'], type: 'api', target: '/api/v1/auth/organization/remove-team-member', + visible: 'features.multiOrgEnabled != false', confirmText: 'Remove this user from the team? They will lose any team-scoped access.', successMessage: 'Team member removed', refreshAfter: true, diff --git a/packages/platform-objects/src/identity/sys-team.object.ts b/packages/platform-objects/src/identity/sys-team.object.ts index 25dfb8c50e..2ae512dfcf 100644 --- a/packages/platform-objects/src/identity/sys-team.object.ts +++ b/packages/platform-objects/src/identity/sys-team.object.ts @@ -44,6 +44,10 @@ export const SysTeam = ObjectSchema.create({ locations: ['list_toolbar'], type: 'api', target: '/api/v1/auth/organization/create-team', + // Teams are nested inside organizations — a multi-org-only concept. + // Gate every team mutation on the multi-org flag so the affordances + // disappear in single-org (mirrors sys_organization.create_organization). + visible: 'features.multiOrgEnabled != false', successMessage: 'Team created', refreshAfter: true, params: [ @@ -64,6 +68,7 @@ export const SysTeam = ObjectSchema.create({ target: '/api/v1/auth/organization/update-team', recordIdParam: 'teamId', bodyShape: { wrap: 'data' }, + visible: 'features.multiOrgEnabled != false', successMessage: 'Team updated', refreshAfter: true, params: [ @@ -82,6 +87,7 @@ export const SysTeam = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/organization/remove-team', recordIdParam: 'teamId', + visible: 'features.multiOrgEnabled != false', confirmText: 'Delete this team? Members will lose any team-scoped access. This cannot be undone.', successMessage: 'Team deleted', refreshAfter: true, diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 850d58ff26..77482c6ba2 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -46,6 +46,13 @@ export const SysUser = ObjectSchema.create({ locations: ['list_toolbar'], type: 'api', target: '/api/v1/auth/organization/invite-member', + // Org invitations are a multi-org-only flow (the endpoint resolves + // an active org that does not exist in single-org mode). Hide the + // affordance unless multi-org is enabled — matching the + // `create_organization` gate on sys_organization. This action is the + // most exposed of the set because the Users list is always reachable + // in single-org, unlike the org/membership lists. + visible: 'features.multiOrgEnabled != false', successMessage: 'Invitation sent', refreshAfter: true, params: [