diff --git a/.changeset/adr-0105-group-posture-console.md b/.changeset/adr-0105-group-posture-console.md new file mode 100644 index 000000000..77161738f --- /dev/null +++ b/.changeset/adr-0105-group-posture-console.md @@ -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 + " 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. diff --git a/packages/app-shell/src/hooks/index.ts b/packages/app-shell/src/hooks/index.ts index b2aa038a5..3bd5664f0 100644 --- a/packages/app-shell/src/hooks/index.ts +++ b/packages/app-shell/src/hooks/index.ts @@ -31,6 +31,7 @@ export { type UseUrlOverlayOptions, type UrlOverlayControls, } from './useUrlOverlay'; +export { useTenancyPosture } from './useTenancyPosture'; export { useTrackRouteAsRecent, type UseTrackRouteAsRecentOptions } from './useTrackRouteAsRecent'; export { sanitizeChatMessagesForCache, diff --git a/packages/app-shell/src/hooks/useTenancyPosture.ts b/packages/app-shell/src/hooks/useTenancyPosture.ts new file mode 100644 index 000000000..1db797798 --- /dev/null +++ b/packages/app-shell/src/hooks/useTenancyPosture.ts @@ -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(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; +} diff --git a/packages/app-shell/src/layout/WorkspaceSwitcher.tsx b/packages/app-shell/src/layout/WorkspaceSwitcher.tsx index 90f75399b..c63dea332 100644 --- a/packages/app-shell/src/layout/WorkspaceSwitcher.tsx +++ b/packages/app-shell/src/layout/WorkspaceSwitcher.tsx @@ -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'; @@ -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 @@ -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; @@ -105,8 +112,21 @@ export function WorkspaceSwitcher() { - {t('organization.switcher.label', { defaultValue: 'Switch organization' })} + {isGroupPosture + ? t('organization.switcher.groupLabel', { defaultValue: 'Working organization' }) + : t('organization.switcher.label', { defaultValue: 'Switch organization' })} + {isGroupPosture && ( +

+ {t('organization.switcher.groupHint', { + defaultValue: + 'New records are created here. Views show data from all your organizations.', + })} +

+ )} {orgList.map((org) => ( ({ + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + }), +})); + +const navigate = vi.fn(); +vi.mock('react-router-dom', () => ({ + useNavigate: () => navigate, +})); + +let authState: Record; +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) =>
{p.children}
, + DropdownMenuTrigger: (p: any) =>