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
50 changes: 50 additions & 0 deletions .changeset/console-delegated-admin-role.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
"@object-ui/auth": minor
"@object-ui/app-shell": minor
---

feat(console): make the `delegated_admin` org role reachable, and narrow both role pickers to what the server will accept (framework#3697)

The framework registered a fourth organization role — `delegated_admin`, the
grade that may reach `/organization/invite-member` **without** being an org
admin, which is what finally gives ADR-0105 D8's scope-bounded issuance gate a
caller. objectui#2868 already shipped the placement half of that UX (units and
positions narrowed by `describeDelegableScope()`), but the console could not
select the role in the first place: `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.** The role names, labels and narrowing rules now live
in `@object-ui/auth`'s new `org-roles` module (`ORG_ROLES`, `ORG_ROLE_LABELS`,
`orgRoleGrade`, `invitableOrgRoles`, `assignableOrgRoles`) and both screens
consume it. Note this list still **mirrors** the server rather than deriving
from it — `/auth/config` publishes feature flags but no role vocabulary, so
there is no surface to read; objectstack-ai/objectstack#3723 tracks making one
list the source for all of them. Until then a server-side role addition means
one console edit instead of two.

**The pickers now narrow, the way the placement picker already does.** Both
mirror a *different* server gate, and offering an option the server would refuse
is the failure they prevent:

- **Invite role** ← the framework's `beforeCreateInvitation` role cap: never
above the issuer's own grade, and an issuer below admin grade may invite as
`member` only. A `delegated_admin` who picked "Admin" would have been refused
with a 403; that option is simply no longer offered.
- **Change role** ← better-auth's `update-member-role` route: it requires the
`member:["update"]` permission (owner/admin only — `delegated_admin` is built
from `memberAc` and holds `member: []`), and only an owner may set `owner` or
re-role an existing owner. An actor who may re-role nobody now gets no items
instead of three that would 403.

Narrowing is convenience, not the boundary — the server re-checks every one of
these — and it fails toward *less*: an unresolved membership offers `member`
alone on invite, and nothing on re-role.

An ordinary invitation is unchanged: with the default role and no placement, the
request body is byte-identical to before.

Note for translators: `organization.roles.*` has never been defined in any
locale bundle — all four labels (owner/admin/member included) resolve through
their `defaultValue` English fallback. The new role follows the same pattern
rather than being the only localized one.
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,12 @@ vi.mock('@object-ui/i18n', () => ({

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

// Passthrough primitives — the Select is rendered as a native <select> so the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* InviteMemberDialog — the role select is narrowed by the ISSUER's own grade
* (framework#3697).
*
* Before the framework registered `delegated_admin`, this select was a
* hardcoded owner/admin/member list. Two things had to change together:
*
* 1. the new role must be offerable at all — otherwise the capability the
* framework grew is unreachable from the console;
* 2. the list must narrow to what the issuer may actually confer — a
* `delegated_admin` who picks "Admin" gets a 403 from the server's role
* cap, so offering it is a trap, not a feature.
*
* The server remains the boundary; this only stops the console from proposing
* something it knows will be refused.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } 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();
// Mutable so each case can be a different kind of issuer. Only `useAuth` is
// faked — the narrowing helpers stay real.
const auth: { role: string | null } = { role: 'owner' };
vi.mock('@object-ui/auth', async (importActual) => ({
...(await importActual<typeof import('@object-ui/auth')>()),
useAuth: () => ({
inviteMember,
describeDelegableScope,
activeMember: auth.role === null ? null : { role: auth.role },
}),
}));

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, ...rest }: any) => (
<option value={value} {...rest}>
{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';

// `describeDelegableScope` resolves null in every case here, so the placement
// section stays hidden and the role select is the only one on screen. (The
// Select mock renders a native <select data-testid="select">; the component's
// own testid lives on SelectTrigger, which the mock flattens away.)
const renderAs = async (role: string | null) => {
auth.role = role;
render(<InviteMemberDialog organizationId="org-42" open onOpenChange={() => {}} />);
await waitFor(() => expect(screen.getByTestId('select')).toBeInTheDocument());
};

const offered = () =>
Array.from(screen.getByTestId('select').querySelectorAll('option'))
.map((o) => o.getAttribute('value'))
.filter(Boolean);

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

describe('InviteMemberDialog — role cap (framework#3697)', () => {
it('an owner is offered every role, delegated_admin included', async () => {
await renderAs('owner');
expect(offered()).toEqual(['owner', 'admin', 'delegated_admin', 'member']);
});

it('an admin may provision a delegate but may not invite an owner', async () => {
await renderAs('admin');
const roles = offered();
expect(roles).toContain('delegated_admin');
expect(roles).not.toContain('owner');
});

it('a DELEGATE is offered member only — the escalation chain has no UI entry', async () => {
// Picking "Admin" here would 403 on the server's role cap; the console must
// not propose it. This is the whole reason the select stopped being a
// hardcoded three-item list.
await renderAs('delegated_admin');
expect(offered()).toEqual(['member']);
expect(screen.queryByTestId('invite-role-admin')).not.toBeInTheDocument();
});

it('an unloaded membership still allows the ordinary member invite', async () => {
await renderAs(null);
expect(offered()).toEqual(['member']);
});

it('the narrowed default still submits a plain, unchanged invitation', async () => {
// A delegate's ordinary invite must be byte-identical to what any other
// caller sends — narrowing the picker changed the options, not the payload.
await renderAs('delegated_admin');
fireEvent.change(screen.getByTestId('invite-email-input'), {
target: { value: 'p@x.test' },
});
fireEvent.submit(document.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 @@ -23,14 +23,12 @@ import {
SelectTrigger,
SelectValue,
} from '@object-ui/components';
import { useAuth } from '@object-ui/auth';
import type { AuthInvitation, DelegableScope } from '@object-ui/auth';
import { useAuth, invitableOrgRoles, ORG_ROLE_LABELS, ORG_ROLE_MEMBER } from '@object-ui/auth';
import type { AuthInvitation, DelegableScope, OrgRole } from '@object-ui/auth';
import { useObjectTranslation } from '@object-ui/i18n';
import { Loader2, Copy, Check } from 'lucide-react';
import { toast } from 'sonner';

type Role = 'owner' | 'admin' | 'member';

interface InviteMemberDialogProps {
organizationId: string;
open: boolean;
Expand All @@ -51,10 +49,18 @@ export function InviteMemberDialog({
onInvited,
}: InviteMemberDialogProps) {
const { t } = useObjectTranslation();
const { inviteMember, describeDelegableScope } = useAuth();
const { inviteMember, describeDelegableScope, activeMember } = useAuth();

// [framework #3697] The roles this issuer may actually confer. Mirrors the
// server's invitation role cap — a below-admin issuer (e.g. a
// `delegated_admin`) may invite as `member` only, and nobody may invite above
// their own grade. Same property as the placement picker below: it NARROWS,
// it does not decide; the server re-checks and rejects the whole invitation
// when it is out of cap.
const roleOptions = invitableOrgRoles(activeMember?.role);

const [email, setEmail] = useState('');
const [role, setRole] = useState<Role>('member');
const [role, setRole] = useState<OrgRole>(ORG_ROLE_MEMBER);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [createdInvitation, setCreatedInvitation] = useState<AuthInvitation | null>(null);
Expand All @@ -74,7 +80,7 @@ export function InviteMemberDialog({
useEffect(() => {
if (open) {
setEmail('');
setRole('member');
setRole(ORG_ROLE_MEMBER);
setError(null);
setCreatedInvitation(null);
setCopied(false);
Expand Down Expand Up @@ -219,20 +225,16 @@ export function InviteMemberDialog({
<Label htmlFor="invite-role">
{t('organization.invitations.roleLabel', { defaultValue: 'Role' })}
</Label>
<Select value={role} onValueChange={(v) => setRole(v as Role)}>
<Select value={role} onValueChange={(v) => setRole(v as OrgRole)}>
<SelectTrigger id="invite-role" data-testid="invite-role-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">
{t('organization.roles.member', { defaultValue: 'Member' })}
</SelectItem>
<SelectItem value="admin">
{t('organization.roles.admin', { defaultValue: 'Admin' })}
</SelectItem>
<SelectItem value="owner">
{t('organization.roles.owner', { defaultValue: 'Owner' })}
</SelectItem>
{roleOptions.map((r) => (
<SelectItem key={r} value={r} data-testid={`invite-role-${r}`}>
{t(ORG_ROLE_LABELS[r].key, { defaultValue: ORG_ROLE_LABELS[r].defaultValue })}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
Expand Down
51 changes: 24 additions & 27 deletions packages/app-shell/src/console/organizations/manage/MembersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ import {
DropdownMenuTrigger,
Separator,
} from '@object-ui/components';
import { useAuth } from '@object-ui/auth';
import type { AuthOrganizationMember } from '@object-ui/auth';
import { useAuth, assignableOrgRoles, ORG_ROLE_LABELS } from '@object-ui/auth';
import type { AuthOrganizationMember, OrgRole } from '@object-ui/auth';
import { useObjectTranslation } from '@object-ui/i18n';
import { Loader2, MoreHorizontal, UserMinus, ShieldCheck } from 'lucide-react';
import { toast } from 'sonner';
Expand All @@ -43,12 +43,10 @@ function getMemberInitials(name?: string): string {
.slice(0, 2);
}

type Role = 'owner' | 'admin' | 'member';

export function MembersPage() {
const { t } = useObjectTranslation();
const { org } = useOrgContext();
const { getMembers, removeMember, updateMemberRole } = useAuth();
const { getMembers, removeMember, updateMemberRole, activeMember } = useAuth();

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

const handleChangeRole = async (member: AuthOrganizationMember, role: Role) => {
const handleChangeRole = async (member: AuthOrganizationMember, role: OrgRole) => {
try {
await updateMemberRole({ organizationId: org.id, memberId: member.id, role });
toast.success(t('organization.members.roleUpdated', { defaultValue: 'Role updated' }));
Expand Down Expand Up @@ -175,27 +173,26 @@ export function MembersPage() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handleChangeRole(member, 'owner')}
disabled={member.role === 'owner'}
>
<ShieldCheck className="mr-2 h-4 w-4" />
{t('organization.roles.owner', { defaultValue: 'Owner' })}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleChangeRole(member, 'admin')}
disabled={member.role === 'admin'}
>
<ShieldCheck className="mr-2 h-4 w-4" />
{t('organization.roles.admin', { defaultValue: 'Admin' })}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleChangeRole(member, 'member')}
disabled={member.role === 'member'}
>
<ShieldCheck className="mr-2 h-4 w-4" />
{t('organization.roles.member', { defaultValue: 'Member' })}
</DropdownMenuItem>
{/* [framework #3697] Roles this actor may SET on THIS member.
Mirrors better-auth's `update-member-role` route: it needs
the `member:["update"]` permission (owner/admin only — a
`delegated_admin` is built from `memberAc` and holds
`member: []`), and only an owner may set `owner` or re-role
someone who already is one. An actor who may re-role nobody
gets no items rather than three that would 403. */}
{assignableOrgRoles(activeMember?.role, member.role).map((role) => (
<DropdownMenuItem
key={role}
onClick={() => handleChangeRole(member, role)}
disabled={member.role === role}
data-testid={`member-role-${role}`}
>
<ShieldCheck className="mr-2 h-4 w-4" />
{t(ORG_ROLE_LABELS[role].key, {
defaultValue: ORG_ROLE_LABELS[role].defaultValue,
})}
</DropdownMenuItem>
))}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setRemovingMember(member)}
Expand Down
Loading
Loading