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
39 changes: 39 additions & 0 deletions .changeset/members-invitations-role-gating-4475.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

Members & invitations tabs gate their affordances by org role instead of letting the server's 403 be the UI (#4475)

A user whose organization role is `member` opened the workspace members page and
was shown an enabled **Invite member** button plus a per-row **Member actions**
menu carrying **Remove member** — on every row, the workspace Owner's included.
Nothing was hidden or disabled; the action only failed after the user had
committed to it. The Settings tab of the same page already gated correctly; the
members and invitations tabs never got the same treatment.

The affordances are now narrowed to the roles that can actually use them, keyed
on the active member's role — the same source the role-change menu on this page
already reads. Which roles those are is **measured against the routes that
enforce them**, not assumed to be "owner":

| affordance | route | permission | roles |
|---------------------|-----------------------------------|-------------------------|-------------------------------|
| Invite member | `/organization/invite-member` | `invitation:["create"]` | owner, admin, delegated_admin |
| Remove member | `/organization/remove-member` | `member:["delete"]` | owner, admin |
| Cancel invitation | `/organization/cancel-invitation` | `invitation:["cancel"]` | owner, admin |

Three different gates, because `delegated_admin` holds `invitation:["create"]`
without `member:["delete"]` and deliberately without `cancel` — so it keeps the
invite button and the copy-link action while losing remove and cancel. A single
owner check could not express that.

An actor left with no row action at all gets no menu rather than a trigger that
opens onto nothing, and the members page explains the absence where the Invite
button used to sit, in the Settings tab's own voice. An unresolved role is
treated as the least privileged, so nothing privileged is offered to a viewer
whose membership could not be read.

Reading the pages is unaffected: the member list and the invitation ledger still
render in full. Whether `org_member` should be able to read the invitation
ledger at all is a separate, server-side question.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { useObjectTranslation } from '@object-ui/i18n';
import { Loader2, Copy, Check, X, Mail } from 'lucide-react';
import { toast } from 'sonner';
import { useOrgContext } from './orgContext';
import { canCancelInvitations, canInviteMembers } from './orgCapabilities';
import { resolveConsoleUrl } from '../resolveHomeUrl';
import { resolveOrgRoleLabel } from '../orgRoleLabel';
import { resolveOrgErrorMessage } from '../orgErrorMessage';
Expand All @@ -49,7 +50,24 @@ function statusBadgeVariant(status: string): 'outline' | 'default' | 'destructiv
export function InvitationsPage() {
const { t } = useObjectTranslation();
const { org } = useOrgContext();
const { listInvitations, cancelInvitation } = useAuth();
const { listInvitations, cancelInvitation, activeMember } = useAuth();

/* objectui#4475 — the two pending-row affordances answer to two DIFFERENT
server gates, so they get two predicates rather than one "can administer
invitations" flag:

- cancel -> `invitation:["cancel"]`, which is owner/admin;
- copy link -> the delivery half of `invitation:["create"]`. The link IS
the invitation (anyone holding it can accept), so handing it out is the
issuing capability finishing its job — which is why a `delegated_admin`,
who may create invitations but deliberately may not cancel them, keeps
this one and loses the other.

What a `member` may READ here is a separate, server-side question and is
escalated as objectstack#8095 — deliberately NOT decided by this card. Only
the write affordances are gated; the ledger still renders. */
const canCancel = canCancelInvitations(activeMember?.role);
const canCopyLink = canInviteMembers(activeMember?.role);

const [invitations, setInvitations] = useState<AuthInvitation[]>([]);
const [isLoading, setIsLoading] = useState(true);
Expand Down Expand Up @@ -220,34 +238,38 @@ export function InvitationsPage() {
{t(`organization.invitations.status.${inv.status}`, { defaultValue: inv.status })}
</Badge>

{inv.status === 'pending' && (
{inv.status === 'pending' && (canCopyLink || canCancel) && (
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleCopyLink(inv)}
aria-label={t('organization.invitations.copyLinkLabel', {
defaultValue: 'Copy invitation link',
})}
>
{copiedId === inv.id ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => setCancelingInvitation(inv)}
aria-label={t('organization.invitations.cancelAction', {
defaultValue: 'Cancel invitation',
})}
>
<X className="h-4 w-4" />
</Button>
{canCopyLink && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleCopyLink(inv)}
aria-label={t('organization.invitations.copyLinkLabel', {
defaultValue: 'Copy invitation link',
})}
>
{copiedId === inv.id ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
)}
{canCancel && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => setCancelingInvitation(inv)}
aria-label={t('organization.invitations.cancelAction', {
defaultValue: 'Cancel invitation',
})}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
)}
</div>
Expand Down
157 changes: 101 additions & 56 deletions packages/app-shell/src/console/organizations/manage/MembersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { Loader2, MoreHorizontal, UserMinus, ShieldCheck } from 'lucide-react';
import { toast } from 'sonner';
import { useOrgContext } from './orgContext';
import { InviteMemberDialog } from './InviteMemberDialog';
import { canInviteMembers, canRemoveMembers } from './orgCapabilities';
import { resolveOrgRoleLabel } from '../orgRoleLabel';
import { resolveOrgErrorMessage } from '../orgErrorMessage';

Expand All @@ -50,6 +51,14 @@ export function MembersPage() {
const { org } = useOrgContext();
const { getMembers, removeMember, updateMemberRole, activeMember } = useAuth();

/* objectui#4475 — what this viewer may actually do here. Both read the SAME
`activeMember.role` the role-change narrowing below already keys on (one
role source per screen), and both are measured against the server gate they
mirror — see `orgCapabilities`. `canInvite` is the wider set: it includes
`delegated_admin`, which may invite and may not remove. */
const canInvite = canInviteMembers(activeMember?.role);
const canRemove = canRemoveMembers(activeMember?.role);

const [members, setMembers] = useState<AuthOrganizationMember[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -138,16 +147,38 @@ export function MembersPage() {
<h2 className="text-lg font-semibold">
{t('organization.members.title', { defaultValue: 'Members' })} ({members.length})
</h2>
<Button onClick={() => setIsInviteOpen(true)} data-testid="invite-member-btn">
{t('organization.members.inviteMember', { defaultValue: 'Invite member' })}
</Button>
{/* objectui#4475 — the Settings tab's convention, applied to this slot:
the explanatory copy takes the PLACE of the affordance it replaces
(there a form, here the button), in the same `text-sm
text-muted-foreground` voice, so the space says why instead of going
blank. It is not a disabled button: a control that exists only to
refuse is still an invitation to try. */}
{canInvite ? (
<Button onClick={() => setIsInviteOpen(true)} data-testid="invite-member-btn">
{t('organization.members.inviteMember', { defaultValue: 'Invite member' })}
</Button>
) : (
<p className="text-sm text-muted-foreground" data-testid="invite-restricted-note">
{t('organization.members.inviteRestrictedNote', {
defaultValue: 'Only organization admins can invite members.',
})}
</p>
)}
</div>

<Separator />

{/* Member list */}
<div className="space-y-2">
{members.map((member) => (
{members.map((member) => {
/* [framework #3697] Roles this actor may SET on THIS member — see the
menu below for what the list mirrors. Hoisted out of the JSX because
objectui#4475 needs its emptiness twice: an actor with no assignable
role AND no remove permission gets no menu at all, rather than a
trigger that opens onto nothing. */
const assignable = assignableOrgRoles(activeMember?.role, member.role);
const hasRowActions = assignable.length > 0 || canRemove;
return (
<div
key={member.id}
className="flex items-center gap-3 rounded-lg border bg-card p-3"
Expand Down Expand Up @@ -186,53 +217,62 @@ export function MembersPage() {
</span>
)}

<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* Icon-only: this aria-label is the ONLY name a screen reader
gets for the row's action menu (objectui#4474). */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
aria-label={t('organization.members.memberActions', {
defaultValue: 'Member actions',
})}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* [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,
{hasRowActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* Icon-only: this aria-label is the ONLY name a screen reader
gets for the row's action menu (objectui#4474). */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
aria-label={t('organization.members.memberActions', {
defaultValue: 'Member actions',
})}
</DropdownMenuItem>
))}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setRemovingMember(member)}
>
<UserMinus className="mr-2 h-4 w-4" />
{t('organization.members.removeMember', { defaultValue: 'Remove member' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* 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. */}
{assignable.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>
))}
{/* objectui#4475 — the card's headline: this item used to be
unconditional, so a `member` was offered Remove on every
row INCLUDING the Owner's, and only the server's 403 (or,
on the remove route, a 400 from the lookup that runs first)
told them otherwise. `member:["delete"]` is owner/admin. */}
{canRemove && (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setRemovingMember(member)}
>
<UserMinus className="mr-2 h-4 w-4" />
{t('organization.members.removeMember', { defaultValue: 'Remove member' })}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
))}
);
})}
</div>

{/* Remove confirmation dialog */}
Expand Down Expand Up @@ -262,13 +302,18 @@ export function MembersPage() {
</AlertDialogContent>
</AlertDialog>

{/* Invite dialog */}
<InviteMemberDialog
organizationId={org.id}
open={isInviteOpen}
onOpenChange={setIsInviteOpen}
onInvited={() => fetchMembers()}
/>
{/* Invite dialog — not mounted for an actor who may not invite. Nothing
can open it either way (the trigger is gone), but leaving it out keeps
its delegable-scope fetch from running for a viewer who has no use for
the answer. */}
{canInvite && (
<InviteMemberDialog
organizationId={org.id}
open={isInviteOpen}
onOpenChange={setIsInviteOpen}
onInvited={() => fetchMembers()}
/>
)}
</div>
);
}
Loading
Loading