Skip to content

Commit 54886ca

Browse files
authored
feat(console): make delegated_admin reachable and narrow both role pickers (framework#3697) (#2891)
Follow-on to objectstack-ai/objectstack#3722, which registered a fourth organization role. Companion to #2868, which shipped the placement half. #3722 gave ADR-0105 D8's scope-bounded issuance gate its missing caller: `delegated_admin`, the grade that may reach `/organization/invite-member` without being an org admin. But the console could not select the role at all — `MembersPage` and `InviteMemberDialog` each inlined `type Role = 'owner' | 'admin' | 'member'` — so the capability the framework grew was unreachable from either screen. One vocabulary, not two: role names, labels and narrowing rules move into `@object-ui/auth`'s new `org-roles` module and both screens consume it. The list still MIRRORS the server rather than deriving from it (`/auth/config` publishes feature flags but no role vocabulary); the module says so and points at objectstack-ai/objectstack#3723. Both pickers now narrow to what the server will accept, mirroring DIFFERENT gates: - invite role ← `beforeCreateInvitation`'s role cap: never above the issuer's own grade; a below-admin issuer may invite as `member` only. A `delegated_admin` picking "Admin" would have 403'd. - change role ← better-auth's `update-member-role`: needs `member:["update"]` (owner/admin only), and only an owner may set `owner` or re-role an existing owner. An actor who may re-role nobody gets no items rather than three that would 403. Narrowing is convenience, not the boundary — the server re-checks — and it fails toward less. An ordinary invitation's request body is byte-identical to before. Verified: 654 files / 7684 tests passing; lint 0 errors on both packages; tsc 29/29.
1 parent c6fd752 commit 54886ca

8 files changed

Lines changed: 507 additions & 46 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@object-ui/auth": minor
3+
"@object-ui/app-shell": minor
4+
---
5+
6+
feat(console): make the `delegated_admin` org role reachable, and narrow both role pickers to what the server will accept (framework#3697)
7+
8+
The framework registered a fourth organization role — `delegated_admin`, the
9+
grade that may reach `/organization/invite-member` **without** being an org
10+
admin, which is what finally gives ADR-0105 D8's scope-bounded issuance gate a
11+
caller. objectui#2868 already shipped the placement half of that UX (units and
12+
positions narrowed by `describeDelegableScope()`), but the console could not
13+
select the role in the first place: `MembersPage` and `InviteMemberDialog` each
14+
inlined `type Role = 'owner' | 'admin' | 'member'`, so the capability the
15+
framework grew was unreachable from either screen.
16+
17+
**One vocabulary, not two.** The role names, labels and narrowing rules now live
18+
in `@object-ui/auth`'s new `org-roles` module (`ORG_ROLES`, `ORG_ROLE_LABELS`,
19+
`orgRoleGrade`, `invitableOrgRoles`, `assignableOrgRoles`) and both screens
20+
consume it. Note this list still **mirrors** the server rather than deriving
21+
from it — `/auth/config` publishes feature flags but no role vocabulary, so
22+
there is no surface to read; objectstack-ai/objectstack#3723 tracks making one
23+
list the source for all of them. Until then a server-side role addition means
24+
one console edit instead of two.
25+
26+
**The pickers now narrow, the way the placement picker already does.** Both
27+
mirror a *different* server gate, and offering an option the server would refuse
28+
is the failure they prevent:
29+
30+
- **Invite role** ← the framework's `beforeCreateInvitation` role cap: never
31+
above the issuer's own grade, and an issuer below admin grade may invite as
32+
`member` only. A `delegated_admin` who picked "Admin" would have been refused
33+
with a 403; that option is simply no longer offered.
34+
- **Change role** ← better-auth's `update-member-role` route: it requires the
35+
`member:["update"]` permission (owner/admin only — `delegated_admin` is built
36+
from `memberAc` and holds `member: []`), and only an owner may set `owner` or
37+
re-role an existing owner. An actor who may re-role nobody now gets no items
38+
instead of three that would 403.
39+
40+
Narrowing is convenience, not the boundary — the server re-checks every one of
41+
these — and it fails toward *less*: an unresolved membership offers `member`
42+
alone on invite, and nothing on re-role.
43+
44+
An ordinary invitation is unchanged: with the default role and no placement, the
45+
request body is byte-identical to before.
46+
47+
Note for translators: `organization.roles.*` has never been defined in any
48+
locale bundle — all four labels (owner/admin/member included) resolve through
49+
their `defaultValue` English fallback. The new role follows the same pattern
50+
rather than being the only localized one.

packages/app-shell/src/console/organizations/__tests__/InviteMemberDialog.placement.test.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,12 @@ vi.mock('@object-ui/i18n', () => ({
2727

2828
const inviteMember = vi.fn();
2929
const describeDelegableScope = vi.fn();
30-
vi.mock('@object-ui/auth', () => ({
31-
useAuth: () => ({ inviteMember, describeDelegableScope }),
30+
// Only `useAuth` is faked — the role vocabulary / narrowing helpers stay REAL,
31+
// so a change to the cap surfaces here instead of being mocked away. These
32+
// cases are about placement, so the caller is an owner (widest role list).
33+
vi.mock('@object-ui/auth', async (importActual) => ({
34+
...(await importActual<typeof import('@object-ui/auth')>()),
35+
useAuth: () => ({ inviteMember, describeDelegableScope, activeMember: { role: 'owner' } }),
3236
}));
3337

3438
// Passthrough primitives — the Select is rendered as a native <select> so the
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* InviteMemberDialog — the role select is narrowed by the ISSUER's own grade
5+
* (framework#3697).
6+
*
7+
* Before the framework registered `delegated_admin`, this select was a
8+
* hardcoded owner/admin/member list. Two things had to change together:
9+
*
10+
* 1. the new role must be offerable at all — otherwise the capability the
11+
* framework grew is unreachable from the console;
12+
* 2. the list must narrow to what the issuer may actually confer — a
13+
* `delegated_admin` who picks "Admin" gets a 403 from the server's role
14+
* cap, so offering it is a trap, not a feature.
15+
*
16+
* The server remains the boundary; this only stops the console from proposing
17+
* something it knows will be refused.
18+
*/
19+
20+
import '@testing-library/jest-dom/vitest';
21+
import { describe, it, expect, vi, beforeEach } from 'vitest';
22+
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
23+
24+
vi.mock('@object-ui/i18n', () => ({
25+
useObjectTranslation: () => ({
26+
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
27+
}),
28+
}));
29+
30+
const inviteMember = vi.fn();
31+
const describeDelegableScope = vi.fn();
32+
// Mutable so each case can be a different kind of issuer. Only `useAuth` is
33+
// faked — the narrowing helpers stay real.
34+
const auth: { role: string | null } = { role: 'owner' };
35+
vi.mock('@object-ui/auth', async (importActual) => ({
36+
...(await importActual<typeof import('@object-ui/auth')>()),
37+
useAuth: () => ({
38+
inviteMember,
39+
describeDelegableScope,
40+
activeMember: auth.role === null ? null : { role: auth.role },
41+
}),
42+
}));
43+
44+
vi.mock('@object-ui/components', () => ({
45+
Dialog: ({ open, children }: any) => (open ? <div>{children}</div> : null),
46+
DialogContent: (p: any) => <div {...p} />,
47+
DialogDescription: (p: any) => <p {...p} />,
48+
DialogFooter: (p: any) => <div {...p} />,
49+
DialogHeader: (p: any) => <div {...p} />,
50+
DialogTitle: (p: any) => <h2 {...p} />,
51+
Button: ({ children, ...rest }: any) => <button {...rest}>{children}</button>,
52+
Input: (p: any) => <input {...p} />,
53+
Label: (p: any) => <label {...p} />,
54+
Select: ({ value, onValueChange, children }: any) => (
55+
<select data-testid="select" value={value} onChange={(e) => onValueChange(e.target.value)}>
56+
<option value="" />
57+
{children}
58+
</select>
59+
),
60+
SelectContent: (p: any) => <>{p.children}</>,
61+
SelectItem: ({ value, children, ...rest }: any) => (
62+
<option value={value} {...rest}>
63+
{children}
64+
</option>
65+
),
66+
SelectTrigger: (p: any) => <>{p.children}</>,
67+
SelectValue: () => null,
68+
}));
69+
vi.mock('lucide-react', () => ({
70+
Loader2: () => <span />,
71+
Copy: () => <span />,
72+
Check: () => <span />,
73+
}));
74+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
75+
76+
import { InviteMemberDialog } from '../manage/InviteMemberDialog';
77+
78+
// `describeDelegableScope` resolves null in every case here, so the placement
79+
// section stays hidden and the role select is the only one on screen. (The
80+
// Select mock renders a native <select data-testid="select">; the component's
81+
// own testid lives on SelectTrigger, which the mock flattens away.)
82+
const renderAs = async (role: string | null) => {
83+
auth.role = role;
84+
render(<InviteMemberDialog organizationId="org-42" open onOpenChange={() => {}} />);
85+
await waitFor(() => expect(screen.getByTestId('select')).toBeInTheDocument());
86+
};
87+
88+
const offered = () =>
89+
Array.from(screen.getByTestId('select').querySelectorAll('option'))
90+
.map((o) => o.getAttribute('value'))
91+
.filter(Boolean);
92+
93+
beforeEach(() => {
94+
vi.clearAllMocks();
95+
describeDelegableScope.mockResolvedValue(null);
96+
inviteMember.mockResolvedValue({ id: 'inv-1', email: 'p@x.test', role: 'member' });
97+
});
98+
99+
describe('InviteMemberDialog — role cap (framework#3697)', () => {
100+
it('an owner is offered every role, delegated_admin included', async () => {
101+
await renderAs('owner');
102+
expect(offered()).toEqual(['owner', 'admin', 'delegated_admin', 'member']);
103+
});
104+
105+
it('an admin may provision a delegate but may not invite an owner', async () => {
106+
await renderAs('admin');
107+
const roles = offered();
108+
expect(roles).toContain('delegated_admin');
109+
expect(roles).not.toContain('owner');
110+
});
111+
112+
it('a DELEGATE is offered member only — the escalation chain has no UI entry', async () => {
113+
// Picking "Admin" here would 403 on the server's role cap; the console must
114+
// not propose it. This is the whole reason the select stopped being a
115+
// hardcoded three-item list.
116+
await renderAs('delegated_admin');
117+
expect(offered()).toEqual(['member']);
118+
expect(screen.queryByTestId('invite-role-admin')).not.toBeInTheDocument();
119+
});
120+
121+
it('an unloaded membership still allows the ordinary member invite', async () => {
122+
await renderAs(null);
123+
expect(offered()).toEqual(['member']);
124+
});
125+
126+
it('the narrowed default still submits a plain, unchanged invitation', async () => {
127+
// A delegate's ordinary invite must be byte-identical to what any other
128+
// caller sends — narrowing the picker changed the options, not the payload.
129+
await renderAs('delegated_admin');
130+
fireEvent.change(screen.getByTestId('invite-email-input'), {
131+
target: { value: 'p@x.test' },
132+
});
133+
fireEvent.submit(document.querySelector('form')!);
134+
await waitFor(() => expect(inviteMember).toHaveBeenCalledTimes(1));
135+
expect(inviteMember).toHaveBeenCalledWith({
136+
organizationId: 'org-42',
137+
email: 'p@x.test',
138+
role: 'member',
139+
});
140+
});
141+
});

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

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,12 @@ import {
2323
SelectTrigger,
2424
SelectValue,
2525
} from '@object-ui/components';
26-
import { useAuth } from '@object-ui/auth';
27-
import type { AuthInvitation, DelegableScope } from '@object-ui/auth';
26+
import { useAuth, invitableOrgRoles, ORG_ROLE_LABELS, ORG_ROLE_MEMBER } from '@object-ui/auth';
27+
import type { AuthInvitation, DelegableScope, OrgRole } from '@object-ui/auth';
2828
import { useObjectTranslation } from '@object-ui/i18n';
2929
import { Loader2, Copy, Check } from 'lucide-react';
3030
import { toast } from 'sonner';
3131

32-
type Role = 'owner' | 'admin' | 'member';
33-
3432
interface InviteMemberDialogProps {
3533
organizationId: string;
3634
open: boolean;
@@ -51,10 +49,18 @@ export function InviteMemberDialog({
5149
onInvited,
5250
}: InviteMemberDialogProps) {
5351
const { t } = useObjectTranslation();
54-
const { inviteMember, describeDelegableScope } = useAuth();
52+
const { inviteMember, describeDelegableScope, activeMember } = useAuth();
53+
54+
// [framework #3697] The roles this issuer may actually confer. Mirrors the
55+
// server's invitation role cap — a below-admin issuer (e.g. a
56+
// `delegated_admin`) may invite as `member` only, and nobody may invite above
57+
// their own grade. Same property as the placement picker below: it NARROWS,
58+
// it does not decide; the server re-checks and rejects the whole invitation
59+
// when it is out of cap.
60+
const roleOptions = invitableOrgRoles(activeMember?.role);
5561

5662
const [email, setEmail] = useState('');
57-
const [role, setRole] = useState<Role>('member');
63+
const [role, setRole] = useState<OrgRole>(ORG_ROLE_MEMBER);
5864
const [isSubmitting, setIsSubmitting] = useState(false);
5965
const [error, setError] = useState<string | null>(null);
6066
const [createdInvitation, setCreatedInvitation] = useState<AuthInvitation | null>(null);
@@ -74,7 +80,7 @@ export function InviteMemberDialog({
7480
useEffect(() => {
7581
if (open) {
7682
setEmail('');
77-
setRole('member');
83+
setRole(ORG_ROLE_MEMBER);
7884
setError(null);
7985
setCreatedInvitation(null);
8086
setCopied(false);
@@ -219,20 +225,16 @@ export function InviteMemberDialog({
219225
<Label htmlFor="invite-role">
220226
{t('organization.invitations.roleLabel', { defaultValue: 'Role' })}
221227
</Label>
222-
<Select value={role} onValueChange={(v) => setRole(v as Role)}>
228+
<Select value={role} onValueChange={(v) => setRole(v as OrgRole)}>
223229
<SelectTrigger id="invite-role" data-testid="invite-role-select">
224230
<SelectValue />
225231
</SelectTrigger>
226232
<SelectContent>
227-
<SelectItem value="member">
228-
{t('organization.roles.member', { defaultValue: 'Member' })}
229-
</SelectItem>
230-
<SelectItem value="admin">
231-
{t('organization.roles.admin', { defaultValue: 'Admin' })}
232-
</SelectItem>
233-
<SelectItem value="owner">
234-
{t('organization.roles.owner', { defaultValue: 'Owner' })}
235-
</SelectItem>
233+
{roleOptions.map((r) => (
234+
<SelectItem key={r} value={r} data-testid={`invite-role-${r}`}>
235+
{t(ORG_ROLE_LABELS[r].key, { defaultValue: ORG_ROLE_LABELS[r].defaultValue })}
236+
</SelectItem>
237+
))}
236238
</SelectContent>
237239
</Select>
238240
</div>

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

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ import {
2525
DropdownMenuTrigger,
2626
Separator,
2727
} from '@object-ui/components';
28-
import { useAuth } from '@object-ui/auth';
29-
import type { AuthOrganizationMember } from '@object-ui/auth';
28+
import { useAuth, assignableOrgRoles, ORG_ROLE_LABELS } from '@object-ui/auth';
29+
import type { AuthOrganizationMember, OrgRole } from '@object-ui/auth';
3030
import { useObjectTranslation } from '@object-ui/i18n';
3131
import { Loader2, MoreHorizontal, UserMinus, ShieldCheck } from 'lucide-react';
3232
import { toast } from 'sonner';
@@ -43,12 +43,10 @@ function getMemberInitials(name?: string): string {
4343
.slice(0, 2);
4444
}
4545

46-
type Role = 'owner' | 'admin' | 'member';
47-
4846
export function MembersPage() {
4947
const { t } = useObjectTranslation();
5048
const { org } = useOrgContext();
51-
const { getMembers, removeMember, updateMemberRole } = useAuth();
49+
const { getMembers, removeMember, updateMemberRole, activeMember } = useAuth();
5250

5351
const [members, setMembers] = useState<AuthOrganizationMember[]>([]);
5452
const [isLoading, setIsLoading] = useState(true);
@@ -75,7 +73,7 @@ export function MembersPage() {
7573
fetchMembers();
7674
}, [fetchMembers]);
7775

78-
const handleChangeRole = async (member: AuthOrganizationMember, role: Role) => {
76+
const handleChangeRole = async (member: AuthOrganizationMember, role: OrgRole) => {
7977
try {
8078
await updateMemberRole({ organizationId: org.id, memberId: member.id, role });
8179
toast.success(t('organization.members.roleUpdated', { defaultValue: 'Role updated' }));
@@ -175,27 +173,26 @@ export function MembersPage() {
175173
</Button>
176174
</DropdownMenuTrigger>
177175
<DropdownMenuContent align="end">
178-
<DropdownMenuItem
179-
onClick={() => handleChangeRole(member, 'owner')}
180-
disabled={member.role === 'owner'}
181-
>
182-
<ShieldCheck className="mr-2 h-4 w-4" />
183-
{t('organization.roles.owner', { defaultValue: 'Owner' })}
184-
</DropdownMenuItem>
185-
<DropdownMenuItem
186-
onClick={() => handleChangeRole(member, 'admin')}
187-
disabled={member.role === 'admin'}
188-
>
189-
<ShieldCheck className="mr-2 h-4 w-4" />
190-
{t('organization.roles.admin', { defaultValue: 'Admin' })}
191-
</DropdownMenuItem>
192-
<DropdownMenuItem
193-
onClick={() => handleChangeRole(member, 'member')}
194-
disabled={member.role === 'member'}
195-
>
196-
<ShieldCheck className="mr-2 h-4 w-4" />
197-
{t('organization.roles.member', { defaultValue: 'Member' })}
198-
</DropdownMenuItem>
176+
{/* [framework #3697] Roles this actor may SET on THIS member.
177+
Mirrors better-auth's `update-member-role` route: it needs
178+
the `member:["update"]` permission (owner/admin only — a
179+
`delegated_admin` is built from `memberAc` and holds
180+
`member: []`), and only an owner may set `owner` or re-role
181+
someone who already is one. An actor who may re-role nobody
182+
gets no items rather than three that would 403. */}
183+
{assignableOrgRoles(activeMember?.role, member.role).map((role) => (
184+
<DropdownMenuItem
185+
key={role}
186+
onClick={() => handleChangeRole(member, role)}
187+
disabled={member.role === role}
188+
data-testid={`member-role-${role}`}
189+
>
190+
<ShieldCheck className="mr-2 h-4 w-4" />
191+
{t(ORG_ROLE_LABELS[role].key, {
192+
defaultValue: ORG_ROLE_LABELS[role].defaultValue,
193+
})}
194+
</DropdownMenuItem>
195+
))}
199196
<DropdownMenuItem
200197
className="text-destructive focus:text-destructive"
201198
onClick={() => setRemovingMember(member)}

0 commit comments

Comments
 (0)