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
35 changes: 35 additions & 0 deletions .changeset/adr-0105-group-posture-console.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
"@object-ui/auth": minor
"@object-ui/app-shell": minor
---

feat(console): group tenancy posture affordances — org switcher as write
context + org attribution in read views (framework ADR-0105 Phase 1)

Under the new `group` tenancy posture the server widens reads to every
organization the member belongs to (`organization_id IN accessible_org_ids`)
while writes land in the ACTIVE organization — so the console's existing
"which org am I in = which org's data I see" presentation becomes wrong the
moment a deployment switches postures. The ADR requires these affordances to
land WITH Phase 1, not after.

- `@object-ui/auth`: `AuthPublicConfig.features.tenancyPosture`
(`'single' | 'group' | 'isolated'`, exported as `TenancyPosture`) mirrors
the server's public auth config key. It gates nothing — `multiOrgEnabled`
stays the capability flag; this only tells the console how to render org
context.
- `useTenancyPosture()` (app-shell): reads the posture from the cached auth
config fetch; `undefined` (older server, unrecognized value, fetch failure)
keeps every group affordance off, so non-group deployments render
pixel-identical to today.
- `WorkspaceSwitcher`: under `group` the dropdown labels the active org
"Working organization" and explains the split — new records are created
here, views show data from all your organizations.
- `RecordFormPage` (create mode): org-walled objects show a "Creates in
<active org>" badge naming the engine's write target (ADR-0105 D5 stamps
`organization_id` from the active org).
- Default list columns (`ObjectView`, `InterfaceListPage`, `ObjectDataPage`):
under `group`, org-walled objects get a TRAILING `organization_id`
attribution column so cross-org rows are attributable at a glance.
Render-time only — never persisted into saved view/page metadata, and
business fields still lead.
1 change: 1 addition & 0 deletions packages/app-shell/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export {
type UseUrlOverlayOptions,
type UrlOverlayControls,
} from './useUrlOverlay';
export { useTenancyPosture } from './useTenancyPosture';
export { useTrackRouteAsRecent, type UseTrackRouteAsRecentOptions } from './useTrackRouteAsRecent';
export {
sanitizeChatMessagesForCache,
Expand Down
48 changes: 48 additions & 0 deletions packages/app-shell/src/hooks/useTenancyPosture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* useTenancyPosture — the deployment's tenancy posture (framework ADR-0105 D1),
* read from the public auth config's `features.tenancyPosture`.
*
* Under `group` posture the active organization is only the WRITE target —
* reads span every organization the member belongs to (the server enforces
* `organization_id IN accessible_org_ids`; the client never filters for
* security). Components use this hook to key the group-only affordances:
* write-context labeling on the org switcher, the create-form target-org
* badge, and org attribution columns in default list views.
*
* Returns `undefined` until the config resolves, and stays `undefined` when
* the server doesn't report a posture (older server) or reports an
* unrecognized value — so every group affordance fails toward today's
* rendering. The auth client caches the config fetch behind one promise, so
* concurrent mounts share a single request.
*/

import { useEffect, useState } from 'react';
import { useAuth } from '@object-ui/auth';
import type { TenancyPosture } from '@object-ui/auth';

const POSTURES: readonly TenancyPosture[] = ['single', 'group', 'isolated'];

export function useTenancyPosture(): TenancyPosture | undefined {
const { getAuthConfig } = useAuth();
const [posture, setPosture] = useState<TenancyPosture | undefined>(undefined);

useEffect(() => {
let cancelled = false;
getAuthConfig?.()
.then((cfg) => {
if (cancelled) return;
const reported = cfg?.features?.tenancyPosture;
if (reported && (POSTURES as readonly string[]).includes(reported)) {
setPosture(reported);
}
})
.catch(() => {
/* config unavailable — leave undefined, group affordances stay off */
});
return () => {
cancelled = true;
};
}, [getAuthConfig]);

return posture;
}
22 changes: 21 additions & 1 deletion packages/app-shell/src/layout/WorkspaceSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
* (full-page reload so the active-org context refreshes app-wide, mirroring
* OrganizationsPage), plus shortcuts to manage members / create a workspace.
* - No org context at all: renders nothing.
*
* Under `group` tenancy posture (ADR-0105) the switcher's meaning changes:
* the active organization is only the WRITE target — reads span every
* organization the member belongs to — so the dropdown labels it as the
* working organization instead of implying it bounds what the user sees.
*/

import { useEffect, useState } from 'react';
Expand All @@ -29,6 +34,7 @@ import {
} from '@object-ui/components';
import { ChevronsUpDown, Check, Plus, Users } from 'lucide-react';
import { resolveRootUrl } from '../console/organizations/resolveHomeUrl';
import { useTenancyPosture } from '../hooks/useTenancyPosture';

function getOrgInitials(name: string): string {
return name
Expand All @@ -52,6 +58,7 @@ export function WorkspaceSwitcher() {
const navigate = useNavigate();
const { organizations, activeOrganization, switchOrganization, getAuthConfig } = useAuth();
const [multiOrgDisabled, setMultiOrgDisabled] = useState(false);
const isGroupPosture = useTenancyPosture() === 'group';

useEffect(() => {
let cancelled = false;
Expand Down Expand Up @@ -105,8 +112,21 @@ export function WorkspaceSwitcher() {
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60">
<DropdownMenuLabel className="text-xs text-muted-foreground">
{t('organization.switcher.label', { defaultValue: 'Switch organization' })}
{isGroupPosture
? t('organization.switcher.groupLabel', { defaultValue: 'Working organization' })
: t('organization.switcher.label', { defaultValue: 'Switch organization' })}
</DropdownMenuLabel>
{isGroupPosture && (
<p
className="px-2 pb-1.5 text-xs font-normal leading-snug text-muted-foreground"
data-testid="workspace-switcher-group-hint"
>
{t('organization.switcher.groupHint', {
defaultValue:
'New records are created here. Views show data from all your organizations.',
})}
</p>
)}
{orgList.map((org) => (
<DropdownMenuItem
key={org.id}
Expand Down
102 changes: 102 additions & 0 deletions packages/app-shell/src/layout/__tests__/WorkspaceSwitcher.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* WorkspaceSwitcher — ADR-0105 group-posture semantics.
*
* Under `group` tenancy posture the active organization is only the WRITE
* target (reads span every organization the member belongs to), so the
* dropdown must label it "Working organization" and explain the split.
* Every other posture — isolated, single, absent, or an unrecognized value
* from a newer server — keeps today's "Switch organization" rendering.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';

vi.mock('@object-ui/i18n', () => ({
useObjectTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
}),
}));

const navigate = vi.fn();
vi.mock('react-router-dom', () => ({
useNavigate: () => navigate,
}));

let authState: Record<string, unknown>;
vi.mock('@object-ui/auth', () => ({ useAuth: () => authState }));

vi.mock('../../console/organizations/resolveHomeUrl', () => ({ resolveRootUrl: () => '/root' }));

// Passthrough dropdown primitives so label/hint render without interaction.
vi.mock('@object-ui/components', () => ({
DropdownMenu: (p: any) => <div>{p.children}</div>,
DropdownMenuTrigger: (p: any) => <button {...p} />,
DropdownMenuContent: (p: any) => <div>{p.children}</div>,
DropdownMenuItem: ({ onClick, children }: any) => <div onClick={onClick}>{children}</div>,
DropdownMenuLabel: (p: any) => <div {...p} />,
DropdownMenuSeparator: () => <hr />,
}));
vi.mock('lucide-react', () => ({
ChevronsUpDown: () => <span />,
Check: () => <span />,
Plus: () => <span />,
Users: () => <span />,
}));

import { WorkspaceSwitcher } from '../WorkspaceSwitcher';

const ORGS = [
{ id: 'org_a', name: 'Plant A', slug: 'plant-a' },
{ id: 'org_b', name: 'Plant B', slug: 'plant-b' },
];

function setAuthConfig(features: Record<string, unknown>) {
authState = {
organizations: ORGS,
activeOrganization: ORGS[0],
switchOrganization: vi.fn(),
getAuthConfig: vi.fn().mockResolvedValue({ features }),
};
}

beforeEach(() => {
vi.clearAllMocks();
});

describe('WorkspaceSwitcher (ADR-0105 tenancy posture)', () => {
it('group posture: labels the active org as the working organization and explains the read/write split', async () => {
setAuthConfig({ multiOrgEnabled: true, tenancyPosture: 'group' });
render(<WorkspaceSwitcher />);
await waitFor(() => {
expect(screen.getByText('Working organization')).toBeInTheDocument();
});
expect(screen.getByTestId('workspace-switcher-group-hint')).toHaveTextContent(
'New records are created here. Views show data from all your organizations.',
);
expect(screen.queryByText('Switch organization')).not.toBeInTheDocument();
});

it('isolated posture: keeps the plain switch label with no group hint', async () => {
setAuthConfig({ multiOrgEnabled: true, tenancyPosture: 'isolated' });
render(<WorkspaceSwitcher />);
// The posture fetch resolves async — assert the steady state.
await waitFor(() => {
expect((authState.getAuthConfig as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThan(0);
});
expect(screen.getByText('Switch organization')).toBeInTheDocument();
expect(screen.queryByTestId('workspace-switcher-group-hint')).not.toBeInTheDocument();
});

it('absent or unrecognized posture fails toward today\'s rendering', async () => {
setAuthConfig({ multiOrgEnabled: true, tenancyPosture: 'weird-future-value' });
render(<WorkspaceSwitcher />);
await waitFor(() => {
expect((authState.getAuthConfig as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThan(0);
});
expect(screen.getByText('Switch organization')).toBeInTheDocument();
expect(screen.queryByTestId('workspace-switcher-group-hint')).not.toBeInTheDocument();
});
});
23 changes: 23 additions & 0 deletions packages/app-shell/src/views/InterfaceListPage.defaults.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,27 @@ describe('defaultColumnsFromObject', () => {
expect(cols).not.toContain('owner_id');
expect(cols[0]).toBe('b_0');
});

// ADR-0105 group posture: organization_id is appended as a TRAILING
// attribution column for org-walled objects; business fields still lead.
describe('orgAttribution (ADR-0105 group posture)', () => {
it('appends organization_id last for an org-walled object', () => {
const cols = defaultColumnsFromObject(fieldZooLike, { orgAttribution: true });
expect(cols).toEqual(['name', 'f_email', 'f_number', 'organization_id']);
});

it('does not append for an object without organization_id', () => {
const cols = defaultColumnsFromObject(
{ fields: { title: { type: 'text' } } },
{ orgAttribution: true },
);
expect(cols).toEqual(['title']);
});

it('is a no-op when the flag is off (non-group postures unchanged)', () => {
expect(defaultColumnsFromObject(fieldZooLike, { orgAttribution: false })).toEqual(
defaultColumnsFromObject(fieldZooLike),
);
});
});
});
34 changes: 26 additions & 8 deletions packages/app-shell/src/views/InterfaceListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { Database } from 'lucide-react';
import { useObjectTranslation } from '@object-ui/i18n';
import { isSystemManagedField } from '@object-ui/types';
import { useMetadata } from '../providers/MetadataProvider';
import { useTenancyPosture } from '../hooks/useTenancyPosture';
import { parseUserFilterParams, applyUserFilterParams } from './userFilterUrlState';
import { RecordDetailView } from './RecordDetailView';

Expand Down Expand Up @@ -74,18 +75,32 @@ function resolveSourceView(objectDef: any, sourceView?: string): any | undefined
* (ADR-0085), else the first business fields — framework-managed
* system/audit/ownership columns (including the injected, editable `owner_id`)
* are excluded via the shared `isSystemManagedField` classifier.
*
* `opts.orgAttribution` (ADR-0105 group posture): reads span every
* organization the member belongs to, so cross-org rows need attribution —
* append `organization_id` as a TRAILING column when the object carries the
* field. Render-time only; never persisted into page/view metadata.
*/
export function defaultColumnsFromObject(objectDef: any): string[] {
export function defaultColumnsFromObject(
objectDef: any,
opts?: { orgAttribution?: boolean },
): string[] {
const withOrgAttribution = (cols: string[]): string[] =>
opts?.orgAttribution && objectDef?.fields?.organization_id && !cols.includes('organization_id')
? [...cols, 'organization_id']
: cols;
const curated = objectDef?.highlightFields;
if (Array.isArray(curated) && curated.length > 0) {
return curated.filter((n: string) => objectDef.fields?.[n]);
return withOrgAttribution(curated.filter((n: string) => objectDef.fields?.[n]));
}
const fields = objectDef?.fields;
if (fields && typeof fields === 'object') {
return Object.entries(fields)
.filter(([name, f]: [string, any]) => f && !f.hidden && !isSystemManagedField(name, f))
.map(([name]) => name)
.slice(0, 6);
return withOrgAttribution(
Object.entries(fields)
.filter(([name, f]: [string, any]) => f && !f.hidden && !isSystemManagedField(name, f))
.map(([name]) => name)
.slice(0, 6),
);
}
return [];
}
Expand Down Expand Up @@ -176,6 +191,9 @@ export function InterfaceListPage({ page, className, onConfigChange, reserveEdit
const dataSource = useAdapter();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
// ADR-0105: group posture appends a trailing organization_id attribution
// column to the object-derived default column set (reads span all orgs).
const orgAttribution = useTenancyPosture() === 'group';

// ADR-0047 filter persistence: restore `uf_*` URL params once at mount,
// mirror every selection change back (replace — no history spam).
Expand Down Expand Up @@ -337,7 +355,7 @@ export function InterfaceListPage({ page, className, onConfigChange, reserveEdit
? (cfg.columns as any)
: hasColumns(view)
? view.columns
: defaultColumnsFromObject(objectDef);
: defaultColumnsFromObject(objectDef, { orgAttribution });

// Sort: the page's own first, then the legacy view's.
const sort = Array.isArray(cfg.sort) && cfg.sort.length ? cfg.sort : view.sort;
Expand Down Expand Up @@ -388,7 +406,7 @@ export function InterfaceListPage({ page, className, onConfigChange, reserveEdit
inlineEdit: userActions.editInline === true,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [objectDefName, viewDefJson, cfg]);
}, [objectDefName, viewDefJson, cfg, orgAttribution]);

if (!objectDef || !schema) {
return (
Expand Down
8 changes: 6 additions & 2 deletions packages/app-shell/src/views/ObjectDataPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {
PREVIEW_QUERY_FLAG,
PREVIEW_QUERY_VALUE,
} from '../preview/PreviewModeContext';
import { useTenancyPosture } from '../hooks/useTenancyPosture';

/** Field types the auto-derived user-filter bar offers as dropdowns. */
const USER_FILTER_TYPES = new Set(['select', 'multiselect', 'radio', 'enum', 'boolean']);
Expand All @@ -81,6 +82,9 @@ export function ObjectDataPage({ dataSource, objects }: any) {
const { user } = useAuth();
const isAdmin = useIsWorkspaceAdmin();
const metadataClient = useMetadataClient();
// ADR-0105: group posture appends a trailing organization_id attribution
// column to the auto-derived columns (reads span all the user's orgs).
const orgAttribution = useTenancyPosture() === 'group';
// ADR-0037: enter draft-preview after "Save as view" so the fresh draft is
// visible; if already previewing, keep the flag off the suffix (it's sticky).
const previewDrafts = usePreviewDrafts();
Expand Down Expand Up @@ -141,8 +145,8 @@ export function ObjectDataPage({ dataSource, objects }: any) {

// Auto-derived columns + filter bar, both trimmed by field-level security.
const columns = React.useMemo(
() => defaultColumnsFromObject(objectDef).filter((f: string) => canRead(f)),
[objectDef, canRead],
() => defaultColumnsFromObject(objectDef, { orgAttribution }).filter((f: string) => canRead(f)),
[objectDef, canRead, orgAttribution],
);
const userFilters = React.useMemo(() => {
const fields = objectDef?.fields;
Expand Down
Loading
Loading