Member row with inline role control
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
Admin
JOJordan Leejordan@versable.ai
Member
TAtaylor@versable.aiInvited, not yet accepted
Invited
Member
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 rowwalmart-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 statespeedway/app/routes/account/accounts.tsx:888-947ConfirmModal<MemberRow> trio: reset password, toggle access, remove member, each its own typed modalspeedway/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.