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
52 changes: 52 additions & 0 deletions .changeset/single-org-gate-membership-surfaces.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 7 additions & 1 deletion packages/platform-objects/src/apps/account.app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
Expand Down
14 changes: 13 additions & 1 deletion packages/platform-objects/src/identity/sys-invitation.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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: [
Expand All @@ -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,
Expand All @@ -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: [
Expand Down
14 changes: 12 additions & 2 deletions packages/platform-objects/src/identity/sys-member.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: [
Expand All @@ -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: [
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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,
Expand All @@ -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,
},
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions packages/platform-objects/src/identity/sys-team.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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: [
Expand All @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions packages/platform-objects/src/identity/sys-user.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down