Skip to content

Commit 8b4bc94

Browse files
os-zhuangclaude
andauthored
feat(console): group tenancy posture affordances — org switcher as write context + org attribution (ADR-0105 Phase 1) (#2858)
Under the group posture the server widens reads to the member's whole organization set while writes land in the ACTIVE organization, so the console must stop presenting the active org as a read boundary: - @object-ui/auth: features.tenancyPosture ('single'|'group'|'isolated', exported as TenancyPosture) mirrors the server's public auth config key. - useTenancyPosture() (app-shell): posture from the cached config fetch; undefined/unrecognized keeps every group affordance off, so non-group deployments render pixel-identical to today. - WorkspaceSwitcher: under group, labels the active org 'Working organization' and explains the read/write split. - RecordFormPage (create): org-walled objects show a 'Creates in <org>' badge naming the D5 write target. - Default list columns (ObjectView / InterfaceListPage / ObjectDataPage): under group, org-walled objects get a TRAILING organization_id attribution column — render-time only, never persisted into saved view/page metadata. Claude-Session: https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1ddc3d2 commit 8b4bc94

13 files changed

Lines changed: 379 additions & 21 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@object-ui/auth": minor
3+
"@object-ui/app-shell": minor
4+
---
5+
6+
feat(console): group tenancy posture affordances — org switcher as write
7+
context + org attribution in read views (framework ADR-0105 Phase 1)
8+
9+
Under the new `group` tenancy posture the server widens reads to every
10+
organization the member belongs to (`organization_id IN accessible_org_ids`)
11+
while writes land in the ACTIVE organization — so the console's existing
12+
"which org am I in = which org's data I see" presentation becomes wrong the
13+
moment a deployment switches postures. The ADR requires these affordances to
14+
land WITH Phase 1, not after.
15+
16+
- `@object-ui/auth`: `AuthPublicConfig.features.tenancyPosture`
17+
(`'single' | 'group' | 'isolated'`, exported as `TenancyPosture`) mirrors
18+
the server's public auth config key. It gates nothing — `multiOrgEnabled`
19+
stays the capability flag; this only tells the console how to render org
20+
context.
21+
- `useTenancyPosture()` (app-shell): reads the posture from the cached auth
22+
config fetch; `undefined` (older server, unrecognized value, fetch failure)
23+
keeps every group affordance off, so non-group deployments render
24+
pixel-identical to today.
25+
- `WorkspaceSwitcher`: under `group` the dropdown labels the active org
26+
"Working organization" and explains the split — new records are created
27+
here, views show data from all your organizations.
28+
- `RecordFormPage` (create mode): org-walled objects show a "Creates in
29+
<active org>" badge naming the engine's write target (ADR-0105 D5 stamps
30+
`organization_id` from the active org).
31+
- Default list columns (`ObjectView`, `InterfaceListPage`, `ObjectDataPage`):
32+
under `group`, org-walled objects get a TRAILING `organization_id`
33+
attribution column so cross-org rows are attributable at a glance.
34+
Render-time only — never persisted into saved view/page metadata, and
35+
business fields still lead.

packages/app-shell/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export {
3131
type UseUrlOverlayOptions,
3232
type UrlOverlayControls,
3333
} from './useUrlOverlay';
34+
export { useTenancyPosture } from './useTenancyPosture';
3435
export { useTrackRouteAsRecent, type UseTrackRouteAsRecentOptions } from './useTrackRouteAsRecent';
3536
export {
3637
sanitizeChatMessagesForCache,
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* useTenancyPosture — the deployment's tenancy posture (framework ADR-0105 D1),
3+
* read from the public auth config's `features.tenancyPosture`.
4+
*
5+
* Under `group` posture the active organization is only the WRITE target —
6+
* reads span every organization the member belongs to (the server enforces
7+
* `organization_id IN accessible_org_ids`; the client never filters for
8+
* security). Components use this hook to key the group-only affordances:
9+
* write-context labeling on the org switcher, the create-form target-org
10+
* badge, and org attribution columns in default list views.
11+
*
12+
* Returns `undefined` until the config resolves, and stays `undefined` when
13+
* the server doesn't report a posture (older server) or reports an
14+
* unrecognized value — so every group affordance fails toward today's
15+
* rendering. The auth client caches the config fetch behind one promise, so
16+
* concurrent mounts share a single request.
17+
*/
18+
19+
import { useEffect, useState } from 'react';
20+
import { useAuth } from '@object-ui/auth';
21+
import type { TenancyPosture } from '@object-ui/auth';
22+
23+
const POSTURES: readonly TenancyPosture[] = ['single', 'group', 'isolated'];
24+
25+
export function useTenancyPosture(): TenancyPosture | undefined {
26+
const { getAuthConfig } = useAuth();
27+
const [posture, setPosture] = useState<TenancyPosture | undefined>(undefined);
28+
29+
useEffect(() => {
30+
let cancelled = false;
31+
getAuthConfig?.()
32+
.then((cfg) => {
33+
if (cancelled) return;
34+
const reported = cfg?.features?.tenancyPosture;
35+
if (reported && (POSTURES as readonly string[]).includes(reported)) {
36+
setPosture(reported);
37+
}
38+
})
39+
.catch(() => {
40+
/* config unavailable — leave undefined, group affordances stay off */
41+
});
42+
return () => {
43+
cancelled = true;
44+
};
45+
}, [getAuthConfig]);
46+
47+
return posture;
48+
}

packages/app-shell/src/layout/WorkspaceSwitcher.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
* (full-page reload so the active-org context refreshes app-wide, mirroring
1313
* OrganizationsPage), plus shortcuts to manage members / create a workspace.
1414
* - No org context at all: renders nothing.
15+
*
16+
* Under `group` tenancy posture (ADR-0105) the switcher's meaning changes:
17+
* the active organization is only the WRITE target — reads span every
18+
* organization the member belongs to — so the dropdown labels it as the
19+
* working organization instead of implying it bounds what the user sees.
1520
*/
1621

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

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

5663
useEffect(() => {
5764
let cancelled = false;
@@ -105,8 +112,21 @@ export function WorkspaceSwitcher() {
105112
</DropdownMenuTrigger>
106113
<DropdownMenuContent align="start" className="w-60">
107114
<DropdownMenuLabel className="text-xs text-muted-foreground">
108-
{t('organization.switcher.label', { defaultValue: 'Switch organization' })}
115+
{isGroupPosture
116+
? t('organization.switcher.groupLabel', { defaultValue: 'Working organization' })
117+
: t('organization.switcher.label', { defaultValue: 'Switch organization' })}
109118
</DropdownMenuLabel>
119+
{isGroupPosture && (
120+
<p
121+
className="px-2 pb-1.5 text-xs font-normal leading-snug text-muted-foreground"
122+
data-testid="workspace-switcher-group-hint"
123+
>
124+
{t('organization.switcher.groupHint', {
125+
defaultValue:
126+
'New records are created here. Views show data from all your organizations.',
127+
})}
128+
</p>
129+
)}
110130
{orgList.map((org) => (
111131
<DropdownMenuItem
112132
key={org.id}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* WorkspaceSwitcher — ADR-0105 group-posture semantics.
5+
*
6+
* Under `group` tenancy posture the active organization is only the WRITE
7+
* target (reads span every organization the member belongs to), so the
8+
* dropdown must label it "Working organization" and explain the split.
9+
* Every other posture — isolated, single, absent, or an unrecognized value
10+
* from a newer server — keeps today's "Switch organization" rendering.
11+
*/
12+
13+
import '@testing-library/jest-dom/vitest';
14+
import { describe, it, expect, vi, beforeEach } from 'vitest';
15+
import { render, screen, waitFor } from '@testing-library/react';
16+
17+
vi.mock('@object-ui/i18n', () => ({
18+
useObjectTranslation: () => ({
19+
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
20+
}),
21+
}));
22+
23+
const navigate = vi.fn();
24+
vi.mock('react-router-dom', () => ({
25+
useNavigate: () => navigate,
26+
}));
27+
28+
let authState: Record<string, unknown>;
29+
vi.mock('@object-ui/auth', () => ({ useAuth: () => authState }));
30+
31+
vi.mock('../../console/organizations/resolveHomeUrl', () => ({ resolveRootUrl: () => '/root' }));
32+
33+
// Passthrough dropdown primitives so label/hint render without interaction.
34+
vi.mock('@object-ui/components', () => ({
35+
DropdownMenu: (p: any) => <div>{p.children}</div>,
36+
DropdownMenuTrigger: (p: any) => <button {...p} />,
37+
DropdownMenuContent: (p: any) => <div>{p.children}</div>,
38+
DropdownMenuItem: ({ onClick, children }: any) => <div onClick={onClick}>{children}</div>,
39+
DropdownMenuLabel: (p: any) => <div {...p} />,
40+
DropdownMenuSeparator: () => <hr />,
41+
}));
42+
vi.mock('lucide-react', () => ({
43+
ChevronsUpDown: () => <span />,
44+
Check: () => <span />,
45+
Plus: () => <span />,
46+
Users: () => <span />,
47+
}));
48+
49+
import { WorkspaceSwitcher } from '../WorkspaceSwitcher';
50+
51+
const ORGS = [
52+
{ id: 'org_a', name: 'Plant A', slug: 'plant-a' },
53+
{ id: 'org_b', name: 'Plant B', slug: 'plant-b' },
54+
];
55+
56+
function setAuthConfig(features: Record<string, unknown>) {
57+
authState = {
58+
organizations: ORGS,
59+
activeOrganization: ORGS[0],
60+
switchOrganization: vi.fn(),
61+
getAuthConfig: vi.fn().mockResolvedValue({ features }),
62+
};
63+
}
64+
65+
beforeEach(() => {
66+
vi.clearAllMocks();
67+
});
68+
69+
describe('WorkspaceSwitcher (ADR-0105 tenancy posture)', () => {
70+
it('group posture: labels the active org as the working organization and explains the read/write split', async () => {
71+
setAuthConfig({ multiOrgEnabled: true, tenancyPosture: 'group' });
72+
render(<WorkspaceSwitcher />);
73+
await waitFor(() => {
74+
expect(screen.getByText('Working organization')).toBeInTheDocument();
75+
});
76+
expect(screen.getByTestId('workspace-switcher-group-hint')).toHaveTextContent(
77+
'New records are created here. Views show data from all your organizations.',
78+
);
79+
expect(screen.queryByText('Switch organization')).not.toBeInTheDocument();
80+
});
81+
82+
it('isolated posture: keeps the plain switch label with no group hint', async () => {
83+
setAuthConfig({ multiOrgEnabled: true, tenancyPosture: 'isolated' });
84+
render(<WorkspaceSwitcher />);
85+
// The posture fetch resolves async — assert the steady state.
86+
await waitFor(() => {
87+
expect((authState.getAuthConfig as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThan(0);
88+
});
89+
expect(screen.getByText('Switch organization')).toBeInTheDocument();
90+
expect(screen.queryByTestId('workspace-switcher-group-hint')).not.toBeInTheDocument();
91+
});
92+
93+
it('absent or unrecognized posture fails toward today\'s rendering', async () => {
94+
setAuthConfig({ multiOrgEnabled: true, tenancyPosture: 'weird-future-value' });
95+
render(<WorkspaceSwitcher />);
96+
await waitFor(() => {
97+
expect((authState.getAuthConfig as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThan(0);
98+
});
99+
expect(screen.getByText('Switch organization')).toBeInTheDocument();
100+
expect(screen.queryByTestId('workspace-switcher-group-hint')).not.toBeInTheDocument();
101+
});
102+
});

packages/app-shell/src/views/InterfaceListPage.defaults.test.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,27 @@ describe('defaultColumnsFromObject', () => {
104104
expect(cols).not.toContain('owner_id');
105105
expect(cols[0]).toBe('b_0');
106106
});
107+
108+
// ADR-0105 group posture: organization_id is appended as a TRAILING
109+
// attribution column for org-walled objects; business fields still lead.
110+
describe('orgAttribution (ADR-0105 group posture)', () => {
111+
it('appends organization_id last for an org-walled object', () => {
112+
const cols = defaultColumnsFromObject(fieldZooLike, { orgAttribution: true });
113+
expect(cols).toEqual(['name', 'f_email', 'f_number', 'organization_id']);
114+
});
115+
116+
it('does not append for an object without organization_id', () => {
117+
const cols = defaultColumnsFromObject(
118+
{ fields: { title: { type: 'text' } } },
119+
{ orgAttribution: true },
120+
);
121+
expect(cols).toEqual(['title']);
122+
});
123+
124+
it('is a no-op when the flag is off (non-group postures unchanged)', () => {
125+
expect(defaultColumnsFromObject(fieldZooLike, { orgAttribution: false })).toEqual(
126+
defaultColumnsFromObject(fieldZooLike),
127+
);
128+
});
129+
});
107130
});

packages/app-shell/src/views/InterfaceListPage.tsx

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { Database } from 'lucide-react';
2424
import { useObjectTranslation } from '@object-ui/i18n';
2525
import { isSystemManagedField } from '@object-ui/types';
2626
import { useMetadata } from '../providers/MetadataProvider';
27+
import { useTenancyPosture } from '../hooks/useTenancyPosture';
2728
import { parseUserFilterParams, applyUserFilterParams } from './userFilterUrlState';
2829
import { RecordDetailView } from './RecordDetailView';
2930

@@ -74,18 +75,32 @@ function resolveSourceView(objectDef: any, sourceView?: string): any | undefined
7475
* (ADR-0085), else the first business fields — framework-managed
7576
* system/audit/ownership columns (including the injected, editable `owner_id`)
7677
* are excluded via the shared `isSystemManagedField` classifier.
78+
*
79+
* `opts.orgAttribution` (ADR-0105 group posture): reads span every
80+
* organization the member belongs to, so cross-org rows need attribution —
81+
* append `organization_id` as a TRAILING column when the object carries the
82+
* field. Render-time only; never persisted into page/view metadata.
7783
*/
78-
export function defaultColumnsFromObject(objectDef: any): string[] {
84+
export function defaultColumnsFromObject(
85+
objectDef: any,
86+
opts?: { orgAttribution?: boolean },
87+
): string[] {
88+
const withOrgAttribution = (cols: string[]): string[] =>
89+
opts?.orgAttribution && objectDef?.fields?.organization_id && !cols.includes('organization_id')
90+
? [...cols, 'organization_id']
91+
: cols;
7992
const curated = objectDef?.highlightFields;
8093
if (Array.isArray(curated) && curated.length > 0) {
81-
return curated.filter((n: string) => objectDef.fields?.[n]);
94+
return withOrgAttribution(curated.filter((n: string) => objectDef.fields?.[n]));
8295
}
8396
const fields = objectDef?.fields;
8497
if (fields && typeof fields === 'object') {
85-
return Object.entries(fields)
86-
.filter(([name, f]: [string, any]) => f && !f.hidden && !isSystemManagedField(name, f))
87-
.map(([name]) => name)
88-
.slice(0, 6);
98+
return withOrgAttribution(
99+
Object.entries(fields)
100+
.filter(([name, f]: [string, any]) => f && !f.hidden && !isSystemManagedField(name, f))
101+
.map(([name]) => name)
102+
.slice(0, 6),
103+
);
89104
}
90105
return [];
91106
}
@@ -176,6 +191,9 @@ export function InterfaceListPage({ page, className, onConfigChange, reserveEdit
176191
const dataSource = useAdapter();
177192
const navigate = useNavigate();
178193
const [searchParams, setSearchParams] = useSearchParams();
194+
// ADR-0105: group posture appends a trailing organization_id attribution
195+
// column to the object-derived default column set (reads span all orgs).
196+
const orgAttribution = useTenancyPosture() === 'group';
179197

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

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

393411
if (!objectDef || !schema) {
394412
return (

packages/app-shell/src/views/ObjectDataPage.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import {
6565
PREVIEW_QUERY_FLAG,
6666
PREVIEW_QUERY_VALUE,
6767
} from '../preview/PreviewModeContext';
68+
import { useTenancyPosture } from '../hooks/useTenancyPosture';
6869

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

142146
// Auto-derived columns + filter bar, both trimmed by field-level security.
143147
const columns = React.useMemo(
144-
() => defaultColumnsFromObject(objectDef).filter((f: string) => canRead(f)),
145-
[objectDef, canRead],
148+
() => defaultColumnsFromObject(objectDef, { orgAttribution }).filter((f: string) => canRead(f)),
149+
[objectDef, canRead, orgAttribution],
146150
);
147151
const userFilters = React.useMemo(() => {
148152
const fields = objectDef?.fields;

0 commit comments

Comments
 (0)