Skip to content

Commit b5609cb

Browse files
os-zhuangclaude
andauthored
feat(console): scoped-invitation placement — invite straight into a unit and positions (framework ADR-0105 D8) (#2868)
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. Claude-Session: https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL Co-authored-by: Claude <noreply@anthropic.com>
1 parent 70941e8 commit b5609cb

9 files changed

Lines changed: 445 additions & 11 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@object-ui/auth": minor
3+
"@object-ui/app-shell": minor
4+
---
5+
6+
feat(console): scoped-invitation placement — invite someone straight into a
7+
business unit and positions (framework ADR-0105 D8)
8+
9+
An invitation may now carry PLACEMENT INTENT: the business unit the invitee
10+
lands in and the positions they are assigned when they accept. A plant admin's
11+
invitee arrives already in the right unit and role instead of waiting on a
12+
platform admin to finish the job by hand.
13+
14+
- `@object-ui/auth`: `inviteMember` accepts optional `businessUnitId` /
15+
`positions` (passed through better-auth's invitation `additionalFields`), and
16+
a new `describeDelegableScope()` reads
17+
`GET /api/v1/security/my-delegable-scope`.
18+
- `InviteMemberDialog`: an optional "Placement" section listing **only** the
19+
units the issuer may place into and the positions they may hand out.
20+
Positions appear once a unit is chosen — an unanchored assignment is refused
21+
by the server, so offering it first would mislead.
22+
23+
The narrowing is convenience, not the boundary: the server authorizes the pair
24+
against the ISSUER's `adminScope` (ADR-0090 D12) at issuance and rejects the
25+
whole invitation when it is out of scope. Accordingly the section is **hidden**
26+
whenever the caller has no delegable authority, or the deployment exposes no
27+
delegated-administration runtime at all (the endpoint answers 501 ⇒ `null`) —
28+
never a form the server would refuse. An ordinary invitation is unchanged: with
29+
no placement chosen, the request body is byte-identical to before.
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* InviteMemberDialog — scoped-invitation placement (framework ADR-0105 D8).
5+
*
6+
* The placement section NARROWS to what the issuer may actually delegate
7+
* (`security/my-delegable-scope`); the server still authorizes the pair
8+
* against the issuer's `adminScope`. So the behaviours worth pinning are the
9+
* ones a user would notice going wrong:
10+
*
11+
* 1. no delegated authority (or no runtime exposing it) ⇒ no placement UI at
12+
* all — never a form the server would refuse;
13+
* 2. the options offered are exactly the delegable ones;
14+
* 3. a chosen placement reaches `inviteMember`, and a HALF-chosen one does
15+
* not (a unit with no positions is not a placement).
16+
*/
17+
18+
import '@testing-library/jest-dom/vitest';
19+
import { describe, it, expect, vi, beforeEach } from 'vitest';
20+
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
21+
22+
vi.mock('@object-ui/i18n', () => ({
23+
useObjectTranslation: () => ({
24+
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
25+
}),
26+
}));
27+
28+
const inviteMember = vi.fn();
29+
const describeDelegableScope = vi.fn();
30+
vi.mock('@object-ui/auth', () => ({
31+
useAuth: () => ({ inviteMember, describeDelegableScope }),
32+
}));
33+
34+
// Passthrough primitives — the Select is rendered as a native <select> so the
35+
// test can drive it without Radix portal machinery.
36+
vi.mock('@object-ui/components', () => ({
37+
Dialog: ({ open, children }: any) => (open ? <div>{children}</div> : null),
38+
DialogContent: (p: any) => <div {...p} />,
39+
DialogDescription: (p: any) => <p {...p} />,
40+
DialogFooter: (p: any) => <div {...p} />,
41+
DialogHeader: (p: any) => <div {...p} />,
42+
DialogTitle: (p: any) => <h2 {...p} />,
43+
Button: ({ children, ...rest }: any) => <button {...rest}>{children}</button>,
44+
Input: (p: any) => <input {...p} />,
45+
Label: (p: any) => <label {...p} />,
46+
Select: ({ value, onValueChange, children }: any) => (
47+
<select data-testid="select" value={value} onChange={(e) => onValueChange(e.target.value)}>
48+
<option value="" />
49+
{children}
50+
</select>
51+
),
52+
SelectContent: (p: any) => <>{p.children}</>,
53+
SelectItem: ({ value, children }: any) => <option value={value}>{children}</option>,
54+
SelectTrigger: (p: any) => <>{p.children}</>,
55+
SelectValue: () => null,
56+
}));
57+
vi.mock('lucide-react', () => ({
58+
Loader2: () => <span />,
59+
Copy: () => <span />,
60+
Check: () => <span />,
61+
}));
62+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
63+
64+
import { InviteMemberDialog } from '../manage/InviteMemberDialog';
65+
66+
const DELEGABLE = {
67+
isTenantAdmin: false,
68+
placeableBusinessUnitIds: ['bu_plant_a', 'bu_plant_a_qc'],
69+
assignablePositions: ['qc_inspector'],
70+
scopes: [],
71+
};
72+
73+
const renderDialog = () =>
74+
render(
75+
<InviteMemberDialog organizationId="org-42" open onOpenChange={() => {}} />,
76+
);
77+
78+
const fillEmail = () =>
79+
fireEvent.change(screen.getByTestId('invite-email-input'), {
80+
target: { value: 'p@x.test' },
81+
});
82+
83+
beforeEach(() => {
84+
vi.clearAllMocks();
85+
inviteMember.mockResolvedValue({ id: 'inv-1', email: 'p@x.test', role: 'member' });
86+
});
87+
88+
describe('InviteMemberDialog — placement (ADR-0105 D8)', () => {
89+
it('hides placement entirely when the caller may delegate nothing', async () => {
90+
describeDelegableScope.mockResolvedValue({
91+
isTenantAdmin: false,
92+
placeableBusinessUnitIds: [],
93+
assignablePositions: [],
94+
scopes: [],
95+
});
96+
renderDialog();
97+
await waitFor(() => expect(describeDelegableScope).toHaveBeenCalled());
98+
expect(screen.queryByTestId('invite-placement-section')).not.toBeInTheDocument();
99+
});
100+
101+
it('hides placement when the deployment does not expose the surface at all', async () => {
102+
describeDelegableScope.mockResolvedValue(null);
103+
renderDialog();
104+
await waitFor(() => expect(describeDelegableScope).toHaveBeenCalled());
105+
expect(screen.queryByTestId('invite-placement-section')).not.toBeInTheDocument();
106+
});
107+
108+
it('offers exactly the delegable units, and positions only once a unit is chosen', async () => {
109+
describeDelegableScope.mockResolvedValue(DELEGABLE);
110+
renderDialog();
111+
await screen.findByTestId('invite-placement-section');
112+
113+
const unitSelect = within(screen.getByTestId('invite-placement-section')).getByTestId('select');
114+
expect(unitSelect).toHaveTextContent('bu_plant_a');
115+
expect(unitSelect).toHaveTextContent('bu_plant_a_qc');
116+
// Positions appear only after a unit is picked — an unanchored assignment
117+
// is refused by the gate, so offering it first would be misleading.
118+
expect(screen.queryByTestId('invite-positions')).not.toBeInTheDocument();
119+
120+
fireEvent.change(unitSelect, { target: { value: 'bu_plant_a' } });
121+
await screen.findByTestId('invite-positions');
122+
expect(screen.getByTestId('invite-position-qc_inspector')).toBeInTheDocument();
123+
});
124+
125+
it('sends a complete placement with the invitation', async () => {
126+
describeDelegableScope.mockResolvedValue(DELEGABLE);
127+
const { container } = renderDialog();
128+
await screen.findByTestId('invite-placement-section');
129+
130+
fillEmail();
131+
fireEvent.change(within(screen.getByTestId('invite-placement-section')).getByTestId('select'), {
132+
target: { value: 'bu_plant_a' },
133+
});
134+
fireEvent.click(await screen.findByTestId('invite-position-qc_inspector'));
135+
fireEvent.submit(container.querySelector('form')!);
136+
137+
await waitFor(() => expect(inviteMember).toHaveBeenCalledTimes(1));
138+
expect(inviteMember).toHaveBeenCalledWith({
139+
organizationId: 'org-42',
140+
email: 'p@x.test',
141+
role: 'member',
142+
businessUnitId: 'bu_plant_a',
143+
positions: ['qc_inspector'],
144+
});
145+
});
146+
147+
it('a unit with no positions is NOT a placement — the invite goes out plain', async () => {
148+
describeDelegableScope.mockResolvedValue(DELEGABLE);
149+
const { container } = renderDialog();
150+
await screen.findByTestId('invite-placement-section');
151+
152+
fillEmail();
153+
fireEvent.change(within(screen.getByTestId('invite-placement-section')).getByTestId('select'), {
154+
target: { value: 'bu_plant_a' },
155+
});
156+
fireEvent.submit(container.querySelector('form')!);
157+
158+
await waitFor(() => expect(inviteMember).toHaveBeenCalledTimes(1));
159+
expect(inviteMember).toHaveBeenCalledWith({
160+
organizationId: 'org-42',
161+
email: 'p@x.test',
162+
role: 'member',
163+
});
164+
});
165+
});

packages/app-shell/src/console/organizations/manage/InviteMemberDialog.tsx

Lines changed: 118 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
SelectValue,
2525
} from '@object-ui/components';
2626
import { useAuth } from '@object-ui/auth';
27-
import type { AuthInvitation } from '@object-ui/auth';
27+
import type { AuthInvitation, DelegableScope } from '@object-ui/auth';
2828
import { useObjectTranslation } from '@object-ui/i18n';
2929
import { Loader2, Copy, Check } from 'lucide-react';
3030
import { toast } from 'sonner';
@@ -51,7 +51,7 @@ export function InviteMemberDialog({
5151
onInvited,
5252
}: InviteMemberDialogProps) {
5353
const { t } = useObjectTranslation();
54-
const { inviteMember } = useAuth();
54+
const { inviteMember, describeDelegableScope } = useAuth();
5555

5656
const [email, setEmail] = useState('');
5757
const [role, setRole] = useState<Role>('member');
@@ -60,16 +60,49 @@ export function InviteMemberDialog({
6060
const [createdInvitation, setCreatedInvitation] = useState<AuthInvitation | null>(null);
6161
const [copied, setCopied] = useState(false);
6262

63+
// ── [framework ADR-0105 D8] Placement intent ────────────────────────────
64+
// The invitation may carry the unit the invitee lands in and the positions
65+
// they get on acceptance. The options are NARROWED to what this issuer may
66+
// actually delegate (`my-delegable-scope`); the server still authorizes the
67+
// pair against their adminScope, so this is convenience, not the boundary.
68+
// No delegable authority (or no runtime exposing it) ⇒ the whole section is
69+
// hidden rather than offering a form the server would refuse.
70+
const [delegable, setDelegable] = useState<DelegableScope | null>(null);
71+
const [businessUnitId, setBusinessUnitId] = useState<string>('');
72+
const [positions, setPositions] = useState<string[]>([]);
73+
6374
useEffect(() => {
6475
if (open) {
6576
setEmail('');
6677
setRole('member');
6778
setError(null);
6879
setCreatedInvitation(null);
6980
setCopied(false);
81+
setBusinessUnitId('');
82+
setPositions([]);
7083
}
7184
}, [open]);
7285

86+
useEffect(() => {
87+
if (!open) return;
88+
let cancelled = false;
89+
describeDelegableScope?.()
90+
.then((scope) => {
91+
if (!cancelled) setDelegable(scope);
92+
})
93+
.catch(() => {
94+
/* absent surface — placement stays hidden */
95+
});
96+
return () => {
97+
cancelled = true;
98+
};
99+
}, [open, describeDelegableScope]);
100+
101+
const canPlace =
102+
!!delegable &&
103+
delegable.placeableBusinessUnitIds.length > 0 &&
104+
delegable.assignablePositions.length > 0;
105+
73106
const handleSubmit = useCallback(
74107
async (e: React.FormEvent) => {
75108
e.preventDefault();
@@ -81,6 +114,12 @@ export function InviteMemberDialog({
81114
organizationId,
82115
email: email.trim(),
83116
role,
117+
// Placement travels only when BOTH halves are chosen — a unit with
118+
// no positions (or the reverse) is not a placement, and sending a
119+
// half-intent would have the server reject an otherwise fine invite.
120+
...(canPlace && businessUnitId && positions.length > 0
121+
? { businessUnitId, positions }
122+
: {}),
84123
});
85124
setCreatedInvitation(inv);
86125
onInvited?.(inv);
@@ -90,7 +129,7 @@ export function InviteMemberDialog({
90129
setIsSubmitting(false);
91130
}
92131
},
93-
[email, role, organizationId, inviteMember, onInvited],
132+
[email, role, organizationId, inviteMember, onInvited, canPlace, businessUnitId, positions],
94133
);
95134

96135
const handleCopy = useCallback(async () => {
@@ -197,6 +236,82 @@ export function InviteMemberDialog({
197236
</SelectContent>
198237
</Select>
199238
</div>
239+
{/* [framework ADR-0105 D8] Placement — only for issuers who
240+
actually hold delegated authority. Options come from
241+
`my-delegable-scope`, so a delegate sees their own subtree and
242+
the positions they may hand out; the server re-checks. */}
243+
{canPlace && (
244+
<div
245+
className="grid gap-3 rounded-md border border-dashed p-3"
246+
data-testid="invite-placement-section"
247+
>
248+
<div className="grid gap-1">
249+
<Label className="text-sm">
250+
{t('organization.invitations.placementLabel', {
251+
defaultValue: 'Placement (optional)',
252+
})}
253+
</Label>
254+
<p className="text-xs text-muted-foreground">
255+
{t('organization.invitations.placementDescription', {
256+
defaultValue:
257+
'Applied when the invitation is accepted. Only units and positions you may delegate are listed.',
258+
})}
259+
</p>
260+
</div>
261+
262+
<div className="grid gap-2">
263+
<Label htmlFor="invite-business-unit" className="text-xs">
264+
{t('organization.invitations.businessUnitLabel', { defaultValue: 'Business unit' })}
265+
</Label>
266+
<Select value={businessUnitId} onValueChange={setBusinessUnitId}>
267+
<SelectTrigger id="invite-business-unit" data-testid="invite-business-unit-select">
268+
<SelectValue
269+
placeholder={t('organization.invitations.businessUnitPlaceholder', {
270+
defaultValue: 'No placement',
271+
})}
272+
/>
273+
</SelectTrigger>
274+
<SelectContent>
275+
{delegable!.placeableBusinessUnitIds.map((id) => (
276+
<SelectItem key={id} value={id}>
277+
{id}
278+
</SelectItem>
279+
))}
280+
</SelectContent>
281+
</Select>
282+
</div>
283+
284+
{businessUnitId && (
285+
<div className="grid gap-2" data-testid="invite-positions">
286+
<Label className="text-xs">
287+
{t('organization.invitations.positionsLabel', { defaultValue: 'Positions' })}
288+
</Label>
289+
<div className="flex flex-wrap gap-2">
290+
{delegable!.assignablePositions.map((name) => {
291+
const selected = positions.includes(name);
292+
return (
293+
<Button
294+
key={name}
295+
type="button"
296+
size="sm"
297+
variant={selected ? 'default' : 'outline'}
298+
data-testid={`invite-position-${name}`}
299+
aria-pressed={selected}
300+
onClick={() =>
301+
setPositions((prev) =>
302+
prev.includes(name) ? prev.filter((p) => p !== name) : [...prev, name],
303+
)
304+
}
305+
>
306+
{name}
307+
</Button>
308+
);
309+
})}
310+
</div>
311+
</div>
312+
)}
313+
</div>
314+
)}
200315
{error && (
201316
<p className="text-sm text-destructive" data-testid="invite-error">{error}</p>
202317
)}

0 commit comments

Comments
 (0)