diff --git a/.changeset/d8-invitation-placement-ux.md b/.changeset/d8-invitation-placement-ux.md new file mode 100644 index 000000000..68a5d9e07 --- /dev/null +++ b/.changeset/d8-invitation-placement-ux.md @@ -0,0 +1,29 @@ +--- +"@object-ui/auth": minor +"@object-ui/app-shell": minor +--- + +feat(console): scoped-invitation placement — invite someone straight into a +business unit and positions (framework ADR-0105 D8) + +An invitation may now carry PLACEMENT INTENT: the business unit the invitee +lands in and the positions they are assigned when they accept. A plant admin's +invitee arrives already in the right unit and role instead of waiting on a +platform admin to finish the job by hand. + +- `@object-ui/auth`: `inviteMember` accepts optional `businessUnitId` / + `positions` (passed through better-auth's invitation `additionalFields`), and + a new `describeDelegableScope()` reads + `GET /api/v1/security/my-delegable-scope`. +- `InviteMemberDialog`: an optional "Placement" section listing **only** the + units the issuer may place into and the positions they may hand out. + Positions appear once a unit is chosen — an unanchored assignment is refused + by the server, so offering it first would mislead. + +The narrowing is convenience, not the boundary: the server authorizes the pair +against the ISSUER's `adminScope` (ADR-0090 D12) at issuance and rejects the +whole invitation when it is out of scope. Accordingly the section is **hidden** +whenever the caller has no delegable authority, or the deployment exposes no +delegated-administration runtime at all (the endpoint answers 501 ⇒ `null`) — +never a form the server would refuse. An ordinary invitation is unchanged: with +no placement chosen, the request body is byte-identical to before. diff --git a/packages/app-shell/src/console/organizations/__tests__/InviteMemberDialog.placement.test.tsx b/packages/app-shell/src/console/organizations/__tests__/InviteMemberDialog.placement.test.tsx new file mode 100644 index 000000000..5080d3083 --- /dev/null +++ b/packages/app-shell/src/console/organizations/__tests__/InviteMemberDialog.placement.test.tsx @@ -0,0 +1,165 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * InviteMemberDialog — scoped-invitation placement (framework ADR-0105 D8). + * + * The placement section NARROWS to what the issuer may actually delegate + * (`security/my-delegable-scope`); the server still authorizes the pair + * against the issuer's `adminScope`. So the behaviours worth pinning are the + * ones a user would notice going wrong: + * + * 1. no delegated authority (or no runtime exposing it) ⇒ no placement UI at + * all — never a form the server would refuse; + * 2. the options offered are exactly the delegable ones; + * 3. a chosen placement reaches `inviteMember`, and a HALF-chosen one does + * not (a unit with no positions is not a placement). + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; + +vi.mock('@object-ui/i18n', () => ({ + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + }), +})); + +const inviteMember = vi.fn(); +const describeDelegableScope = vi.fn(); +vi.mock('@object-ui/auth', () => ({ + useAuth: () => ({ inviteMember, describeDelegableScope }), +})); + +// Passthrough primitives — the Select is rendered as a native , + Label: (p: any) =>