Pattern gallery

Member row with inline role control

Identity, a role picker that waits for the server, and a destructive action behind a typed confirm.

What this solves

Managing who can act in an org is one row repeated at scale: an identity, a role that must never look changed until the server confirms it, and a removal that cannot be a stray click. Wiring these three together correctly is the whole difficulty; the row itself is plain.
Use it when
  • An org or team's member list, where roles determine what a person can dowalmart-mvp Settings.tsx: RoleSelect wrapped in Tooltip, changeRole and removeConfirmed both promise-toasted
  • An admin roster with reset password, toggle access, and remove, all typed and confirmedspeedway accounts.tsx and admin/team.tsx: the same ConfirmModal<MemberRow> trio, independently built twice

Rendered

Fixture data; check both themes.
SASam Riverasam@versable.ai
Owner
PRPriya Shahpriya@versable.ai
JOJordan Leejordan@versable.ai
TAtaylor@versable.aiInvited, not yet accepted
Invited
patterns/member-row-inline-role/example.tsxCopy this into a fresh route and it renders as above.
"use client";
import { Button, ConfirmModal, IdentityRow, Select, StatusPill, Tooltip, pushAlert, useModal } from "@versable-git/ui";
import { useState } from "react";
// The confirm-and-report composite grows into this once a role picker joins
// it: identity, a role Select that only ever shows server-confirmed state,
// and a destructive action behind a typed ConfirmModal. Four real states: an
// owner nobody can demote or remove, an editable admin, an editable member,
// and a pending invite whose role is locked until it is accepted.
type Role = "admin" | "member";
type MemberStatus = "active" | "pending";
interface MemberFixture {
id: string;
name: string;
email: string;
role: Role | "owner";
status: MemberStatus;
}
const INITIAL_MEMBERS: MemberFixture[] = [
{ id: "u1", name: "Sam Rivera", email: "sam@versable.ai", role: "owner", status: "active" },
{ id: "u2", name: "Priya Shah", email: "priya@versable.ai", role: "admin", status: "active" },
{ id: "u3", name: "Jordan Lee", email: "jordan@versable.ai", role: "member", status: "active" },
{ id: "u4", name: "", email: "taylor@versable.ai", role: "member", status: "pending" },
];
const ROLE_OPTIONS = [
{ value: "admin", label: "Admin" },
{ value: "member", label: "Member" },
] as const;
const REMOVE_MODAL_ID = "member-row-inline-role-remove";
const CANCEL_INVITE_MODAL_ID = "member-row-inline-role-cancel-invite";
interface TargetArgs {
id: string;
label: string;
}
// Stands in for the request every real caller wraps in pushAlert.promise;
// the fixture still has to keep the pending state honest.
function fakeRequest<T>(result: T, ms = 650): Promise<T> {
return new Promise((resolve) => setTimeout(() => resolve(result), ms));
}
export function MemberRowInlineRole() {
const [members, setMembers] = useState(INITIAL_MEMBERS);
const { openModal: openRemove } = useModal<TargetArgs>();
const { openModal: openCancelInvite } = useModal<TargetArgs>();
// After walmart-mvp Settings.tsx:374-388 (changeRole): the select shows
// only server-confirmed state, so nothing moves until the promise toast
// settles and the fixture writes the new role back.
const changeRole = async (id: string, label: string, role: Role) => {
await pushAlert.promise(fakeRequest(role), {
pending: `Making ${label} ${role}`,
success: `${label} is now ${role}.`,
error: "Could not change the role.",
});
setMembers((prev) => prev.map((m) => (m.id === id ? { ...m, role } : m)));
};
const removeMember = async ({ id, label }: TargetArgs, close: () => void) => {
close();
await pushAlert.promise(fakeRequest(null), {
pending: `Removing ${label}`,
success: `${label} removed from the organization.`,
error: "That did not go through.",
});
setMembers((prev) => prev.filter((m) => m.id !== id));
};
const cancelInvite = async ({ id, label }: TargetArgs, close: () => void) => {
close();
await pushAlert.promise(fakeRequest(null), {
pending: `Cancelling the invite to ${label}`,
success: `Invite to ${label} cancelled.`,
error: "That did not go through.",
});
setMembers((prev) => prev.filter((m) => m.id !== id));
};
return (
<div className="divide-base-200 flex flex-col divide-y">
{members.map((m) => {
const label = m.name || m.email;
const isOwner = m.role === "owner";
const isInvite = m.status === "pending";
return (
<div key={m.id} className="flex items-center justify-between gap-3 py-2.5">
<IdentityRow
size="sm"
className="min-w-0 flex-1 px-0"
avatar={label.slice(0, 2).toUpperCase()}
title={isInvite ? m.email : m.name}
subtitle={isInvite ? "Invited, not yet accepted" : m.email}
/>
<div className="flex shrink-0 items-center gap-2">
{isInvite && (
<StatusPill kind="info" size="sm">
Invited
</StatusPill>
)}
{isOwner ? (
<Tooltip content="The org's last owner can't be demoted.">
<span className="text-base-content/45 px-2 text-xs">Owner</span>
</Tooltip>
) : (
<Tooltip content={isInvite ? "Role locks once the invite is accepted." : `Role for ${label}`}>
<Select
size="sm"
aria-label={`Role for ${label}`}
value={m.role as Role}
disabled={isInvite}
onChange={(v) => v && changeRole(m.id, label, v as Role)}
options={ROLE_OPTIONS}
/>
</Tooltip>
)}
<Button
size="sm"
variant="ghost"
circle
Icon="Trash"
bricked={isOwner}
tooltip={
isOwner ? "The org's last owner can't be removed." : isInvite ? "Cancel invite" : "Remove member"
}
onClick={() =>
isInvite ? openCancelInvite(CANCEL_INVITE_MODAL_ID, undefined, { id: m.id, label }) : openRemove(REMOVE_MODAL_ID, undefined, { id: m.id, label })
}
/>
</div>
</div>
);
})}
<ConfirmModal<TargetArgs>
elementId={REMOVE_MODAL_ID}
preset="delete"
title="Remove member"
message={(args) => (
<span>
Remove <b>{args?.label}</b>? They lose access to every workspace in this organization immediately.
</span>
)}
onConfirm={removeMember}
/>
<ConfirmModal<TargetArgs>
elementId={CANCEL_INVITE_MODAL_ID}
preset="delete"
title="Cancel invite"
message={(args) => (
<span>
Cancel the invite to <b>{args?.label}</b>? The link in their email stops working.
</span>
)}
onConfirm={cancelInvite}
/>
</div>
);
}

Where it ships

  • walmart-mvp/frontend/src/pages/Settings.tsx:95-110RoleSelect: a Tooltip-wrapped Select over ROLES, one definition shared by the invite row and every member row
  • walmart-mvp/frontend/src/pages/Settings.tsx:353-388removeConfirmed and changeRole: both promise-toasted, both call load() after, so the row only ever shows server-confirmed state
  • speedway/app/routes/account/accounts.tsx:888-947ConfirmModal<MemberRow> trio: reset password, toggle access, remove member, each its own typed modal
  • speedway/app/routes/admin/team.tsx:507-566the same trio again, near-identical code in a second admin surface

App-specific: Real rows also carry a reset-password action (the confirm-and-report pattern on its own, one modal earlier in this composite's evolution) and a disable/enable toggle. This fixture keeps the role picker and the remove action, the two states that actually change who is in the room, and adds the pending-invite state neither cited file shows in the same row: an invite has no confirmed role yet, so its Select stays locked until acceptance.

@versable-git/ui · composites proven in the apps