From 12f802a300f8d85979be1b4beed4ad664a040fe2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 14:16:53 +0000 Subject: [PATCH] =?UTF-8?q?feat(console):=20scoped-invitation=20placement?= =?UTF-8?q?=20=E2=80=94=20invite=20straight=20into=20a=20unit=20and=20posi?= =?UTF-8?q?tions=20(framework=20ADR-0105=20D8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An invitation may now carry placement intent (business unit + positions), applied when it is accepted, so a plant admin's invitee arrives already in the right unit and role instead of waiting on a platform admin. - @object-ui/auth: inviteMember accepts optional businessUnitId/positions (better-auth invitation additionalFields); new describeDelegableScope() reads GET /api/v1/security/my-delegable-scope. - InviteMemberDialog: 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 server-side, so offering it first would mislead). The narrowing is convenience, not the boundary — the server authorizes the pair against the ISSUER's adminScope at issuance and rejects the whole invitation when out of scope. So the section is HIDDEN when the caller has no delegable authority or the deployment exposes no delegated-administration runtime (501 => null): never a form the server would refuse. An ordinary invitation is byte-identical to before. Tests: hidden with no authority, hidden with no surface, options are exactly the delegable ones, a complete placement reaches inviteMember, a half-chosen one does not. auth + app-shell 1955/1955; lint clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL --- .changeset/d8-invitation-placement-ux.md | 29 +++ .../InviteMemberDialog.placement.test.tsx | 165 ++++++++++++++++++ .../manage/InviteMemberDialog.tsx | 121 ++++++++++++- packages/auth/src/AuthContext.ts | 22 ++- packages/auth/src/AuthProvider.tsx | 22 ++- packages/auth/src/createAuthClient.ts | 44 ++++- packages/auth/src/index.ts | 1 + packages/auth/src/types.ts | 51 +++++- packages/auth/src/useAuth.ts | 1 + 9 files changed, 445 insertions(+), 11 deletions(-) create mode 100644 .changeset/d8-invitation-placement-ux.md create mode 100644 packages/app-shell/src/console/organizations/__tests__/InviteMemberDialog.placement.test.tsx 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) =>