Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/d8-invitation-placement-ux.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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, unknown>) => 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 <select> so the
// test can drive it without Radix portal machinery.
vi.mock('@object-ui/components', () => ({
Dialog: ({ open, children }: any) => (open ? <div>{children}</div> : null),
DialogContent: (p: any) => <div {...p} />,
DialogDescription: (p: any) => <p {...p} />,
DialogFooter: (p: any) => <div {...p} />,
DialogHeader: (p: any) => <div {...p} />,
DialogTitle: (p: any) => <h2 {...p} />,
Button: ({ children, ...rest }: any) => <button {...rest}>{children}</button>,
Input: (p: any) => <input {...p} />,
Label: (p: any) => <label {...p} />,
Select: ({ value, onValueChange, children }: any) => (
<select data-testid="select" value={value} onChange={(e) => onValueChange(e.target.value)}>
<option value="" />
{children}
</select>
),
SelectContent: (p: any) => <>{p.children}</>,
SelectItem: ({ value, children }: any) => <option value={value}>{children}</option>,
SelectTrigger: (p: any) => <>{p.children}</>,
SelectValue: () => null,
}));
vi.mock('lucide-react', () => ({
Loader2: () => <span />,
Copy: () => <span />,
Check: () => <span />,
}));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));

import { InviteMemberDialog } from '../manage/InviteMemberDialog';

const DELEGABLE = {
isTenantAdmin: false,
placeableBusinessUnitIds: ['bu_plant_a', 'bu_plant_a_qc'],
assignablePositions: ['qc_inspector'],
scopes: [],
};

const renderDialog = () =>
render(
<InviteMemberDialog organizationId="org-42" open onOpenChange={() => {}} />,
);

const fillEmail = () =>
fireEvent.change(screen.getByTestId('invite-email-input'), {
target: { value: 'p@x.test' },
});

beforeEach(() => {
vi.clearAllMocks();
inviteMember.mockResolvedValue({ id: 'inv-1', email: 'p@x.test', role: 'member' });
});

describe('InviteMemberDialog — placement (ADR-0105 D8)', () => {
it('hides placement entirely when the caller may delegate nothing', async () => {
describeDelegableScope.mockResolvedValue({
isTenantAdmin: false,
placeableBusinessUnitIds: [],
assignablePositions: [],
scopes: [],
});
renderDialog();
await waitFor(() => expect(describeDelegableScope).toHaveBeenCalled());
expect(screen.queryByTestId('invite-placement-section')).not.toBeInTheDocument();
});

it('hides placement when the deployment does not expose the surface at all', async () => {
describeDelegableScope.mockResolvedValue(null);
renderDialog();
await waitFor(() => expect(describeDelegableScope).toHaveBeenCalled());
expect(screen.queryByTestId('invite-placement-section')).not.toBeInTheDocument();
});

it('offers exactly the delegable units, and positions only once a unit is chosen', async () => {
describeDelegableScope.mockResolvedValue(DELEGABLE);
renderDialog();
await screen.findByTestId('invite-placement-section');

const unitSelect = within(screen.getByTestId('invite-placement-section')).getByTestId('select');
expect(unitSelect).toHaveTextContent('bu_plant_a');
expect(unitSelect).toHaveTextContent('bu_plant_a_qc');
// Positions appear only after a unit is picked — an unanchored assignment
// is refused by the gate, so offering it first would be misleading.
expect(screen.queryByTestId('invite-positions')).not.toBeInTheDocument();

fireEvent.change(unitSelect, { target: { value: 'bu_plant_a' } });
await screen.findByTestId('invite-positions');
expect(screen.getByTestId('invite-position-qc_inspector')).toBeInTheDocument();
});

it('sends a complete placement with the invitation', async () => {
describeDelegableScope.mockResolvedValue(DELEGABLE);
const { container } = renderDialog();
await screen.findByTestId('invite-placement-section');

fillEmail();
fireEvent.change(within(screen.getByTestId('invite-placement-section')).getByTestId('select'), {
target: { value: 'bu_plant_a' },
});
fireEvent.click(await screen.findByTestId('invite-position-qc_inspector'));
fireEvent.submit(container.querySelector('form')!);

await waitFor(() => expect(inviteMember).toHaveBeenCalledTimes(1));
expect(inviteMember).toHaveBeenCalledWith({
organizationId: 'org-42',
email: 'p@x.test',
role: 'member',
businessUnitId: 'bu_plant_a',
positions: ['qc_inspector'],
});
});

it('a unit with no positions is NOT a placement — the invite goes out plain', async () => {
describeDelegableScope.mockResolvedValue(DELEGABLE);
const { container } = renderDialog();
await screen.findByTestId('invite-placement-section');

fillEmail();
fireEvent.change(within(screen.getByTestId('invite-placement-section')).getByTestId('select'), {
target: { value: 'bu_plant_a' },
});
fireEvent.submit(container.querySelector('form')!);

await waitFor(() => expect(inviteMember).toHaveBeenCalledTimes(1));
expect(inviteMember).toHaveBeenCalledWith({
organizationId: 'org-42',
email: 'p@x.test',
role: 'member',
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
SelectValue,
} from '@object-ui/components';
import { useAuth } from '@object-ui/auth';
import type { AuthInvitation } from '@object-ui/auth';
import type { AuthInvitation, DelegableScope } from '@object-ui/auth';
import { useObjectTranslation } from '@object-ui/i18n';
import { Loader2, Copy, Check } from 'lucide-react';
import { toast } from 'sonner';
Expand All @@ -51,7 +51,7 @@ export function InviteMemberDialog({
onInvited,
}: InviteMemberDialogProps) {
const { t } = useObjectTranslation();
const { inviteMember } = useAuth();
const { inviteMember, describeDelegableScope } = useAuth();

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

// ── [framework ADR-0105 D8] Placement intent ────────────────────────────
// The invitation may carry the unit the invitee lands in and the positions
// they get on acceptance. The options are NARROWED to what this issuer may
// actually delegate (`my-delegable-scope`); the server still authorizes the
// pair against their adminScope, so this is convenience, not the boundary.
// No delegable authority (or no runtime exposing it) ⇒ the whole section is
// hidden rather than offering a form the server would refuse.
const [delegable, setDelegable] = useState<DelegableScope | null>(null);
const [businessUnitId, setBusinessUnitId] = useState<string>('');
const [positions, setPositions] = useState<string[]>([]);

useEffect(() => {
if (open) {
setEmail('');
setRole('member');
setError(null);
setCreatedInvitation(null);
setCopied(false);
setBusinessUnitId('');
setPositions([]);
}
}, [open]);

useEffect(() => {
if (!open) return;
let cancelled = false;
describeDelegableScope?.()
.then((scope) => {
if (!cancelled) setDelegable(scope);
})
.catch(() => {
/* absent surface — placement stays hidden */
});
return () => {
cancelled = true;
};
}, [open, describeDelegableScope]);

const canPlace =
!!delegable &&
delegable.placeableBusinessUnitIds.length > 0 &&
delegable.assignablePositions.length > 0;

const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
Expand All @@ -81,6 +114,12 @@ export function InviteMemberDialog({
organizationId,
email: email.trim(),
role,
// Placement travels only when BOTH halves are chosen — a unit with
// no positions (or the reverse) is not a placement, and sending a
// half-intent would have the server reject an otherwise fine invite.
...(canPlace && businessUnitId && positions.length > 0
? { businessUnitId, positions }
: {}),
});
setCreatedInvitation(inv);
onInvited?.(inv);
Expand All @@ -90,7 +129,7 @@ export function InviteMemberDialog({
setIsSubmitting(false);
}
},
[email, role, organizationId, inviteMember, onInvited],
[email, role, organizationId, inviteMember, onInvited, canPlace, businessUnitId, positions],
);

const handleCopy = useCallback(async () => {
Expand Down Expand Up @@ -197,6 +236,82 @@ export function InviteMemberDialog({
</SelectContent>
</Select>
</div>
{/* [framework ADR-0105 D8] Placement — only for issuers who
actually hold delegated authority. Options come from
`my-delegable-scope`, so a delegate sees their own subtree and
the positions they may hand out; the server re-checks. */}
{canPlace && (
<div
className="grid gap-3 rounded-md border border-dashed p-3"
data-testid="invite-placement-section"
>
<div className="grid gap-1">
<Label className="text-sm">
{t('organization.invitations.placementLabel', {
defaultValue: 'Placement (optional)',
})}
</Label>
<p className="text-xs text-muted-foreground">
{t('organization.invitations.placementDescription', {
defaultValue:
'Applied when the invitation is accepted. Only units and positions you may delegate are listed.',
})}
</p>
</div>

<div className="grid gap-2">
<Label htmlFor="invite-business-unit" className="text-xs">
{t('organization.invitations.businessUnitLabel', { defaultValue: 'Business unit' })}
</Label>
<Select value={businessUnitId} onValueChange={setBusinessUnitId}>
<SelectTrigger id="invite-business-unit" data-testid="invite-business-unit-select">
<SelectValue
placeholder={t('organization.invitations.businessUnitPlaceholder', {
defaultValue: 'No placement',
})}
/>
</SelectTrigger>
<SelectContent>
{delegable!.placeableBusinessUnitIds.map((id) => (
<SelectItem key={id} value={id}>
{id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>

{businessUnitId && (
<div className="grid gap-2" data-testid="invite-positions">
<Label className="text-xs">
{t('organization.invitations.positionsLabel', { defaultValue: 'Positions' })}
</Label>
<div className="flex flex-wrap gap-2">
{delegable!.assignablePositions.map((name) => {
const selected = positions.includes(name);
return (
<Button
key={name}
type="button"
size="sm"
variant={selected ? 'default' : 'outline'}
data-testid={`invite-position-${name}`}
aria-pressed={selected}
onClick={() =>
setPositions((prev) =>
prev.includes(name) ? prev.filter((p) => p !== name) : [...prev, name],
)
}
>
{name}
</Button>
);
})}
</div>
</div>
)}
</div>
)}
{error && (
<p className="text-sm text-destructive" data-testid="invite-error">{error}</p>
)}
Expand Down
Loading
Loading