RegistryUI recipes
Members table
An organization's members with their roles, where a role change shows at once and a member can be removed.
Both sides call useMembers from better-supabase/blocks/organizations/react; the organizations block must be installed (npx better-supabase add organizations). Its page on bettersupabase.com is /recipes/members-table.
Web, with shadcn components:
npx shadcn add avatar button select table"use client";
import { DbException } from "better-supabase";
import { useMembers } from "better-supabase/blocks/organizations/react";
import { UserMinusIcon } from "lucide-react";
import { useExtracted, useFormatter } from "next-intl";
import { toast } from "sonner";
import * as v from "valibot";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { RoleBadge } from "@/features/user/components/role-badge";
import { assignableRoles } from "@/features/user/user-permissions";
import { Role } from "@/lib/claims";
import { initials } from "@/lib/initials";
import { blockConfig } from "@/lib/supabase/blocks.generated";
import { useErrorMessage } from "@/lib/use-error-message";
import type { Member } from "../organization-queries";
import { loadMembers, memberChanged } from "../organization-actions";
import { useRoleLabel } from "../use-role-label";
async function loadOrganizationMembers(): Promise<readonly Member[]> {
const result = await loadMembers({});
if (!result.ok) {
throw new DbException(result.error);
}
return result.data;
}
/**
* The organization's members. The server renders the rows; a role change shows
* at once and goes through `update_member_role` in the browser.
*/
export function MemberTable({
organizationId,
initial,
currentUserId,
myRole,
canUpdateRole,
canRemove,
}: {
organizationId: string;
initial: readonly Member[];
currentUserId: string;
myRole: Role;
canUpdateRole: boolean;
canRemove: boolean;
}) {
const t = useExtracted("organization");
const roleLabel = useRoleLabel();
const errorMessage = useErrorMessage();
const format = useFormatter();
const { members, pending, setRole, remove } = useMembers({
organizationId,
load: loadOrganizationMembers,
initial,
schema: blockConfig.organizations.api,
onChanged: async ({ userId }) => {
await memberChanged({ userId });
},
});
const assignable = assignableRoles(myRole);
const roleItems = assignable.map((role) => ({
value: role,
label: roleLabel(role),
}));
// Nobody changes their own role or an owner's here; ownership moves by transfer.
const editable = (member: Member) =>
member.userId !== currentUserId &&
member.role !== "owner" &&
assignable.includes(member.role);
const busy = (member: Member) => pending.includes(member.userId);
const changeRole = async (member: Member, role: Role) => {
const result = await setRole(member.userId, role);
if (result.ok) {
toast.success(t("Role updated"));
} else {
toast.error(errorMessage(result.error));
}
};
const removeMember = async (member: Member) => {
const result = await remove(member.userId);
if (result.ok) {
toast.success(t("Member removed"));
} else {
toast.error(errorMessage(result.error));
}
};
return (
<Table data-testid="members">
<TableHeader>
<TableRow>
<TableHead>{t("Member")}</TableHead>
<TableHead>{t("Role")}</TableHead>
<TableHead className="hidden sm:table-cell">{t("Joined")}</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{(members ?? initial).map((member) => (
<TableRow
key={member.userId}
aria-busy={busy(member) ? true : undefined}
className={busy(member) ? "opacity-50" : undefined}
>
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="size-8">
{member.avatarUrl ? (
<AvatarImage src={member.avatarUrl} alt="" />
) : null}
<AvatarFallback>
{initials(member.name, member.email)}
</AvatarFallback>
</Avatar>
<div className="grid leading-tight">
<span className="font-medium">
{member.name ?? member.email}
{member.userId === currentUserId ? (
<span className="font-normal text-muted-foreground">
{" "}
({t("you")})
</span>
) : null}
</span>
<span className="text-xs text-muted-foreground">
{member.email}
</span>
</div>
</div>
</TableCell>
<TableCell>
{canUpdateRole && editable(member) ? (
<Select
value={member.role}
items={roleItems}
disabled={busy(member)}
onValueChange={(value) => {
if (v.is(Role, value)) {
void changeRole(member, value);
}
}}
>
<SelectTrigger
size="sm"
className="w-32"
aria-label={t("Role")}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{roleItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<RoleBadge role={member.role} />
)}
</TableCell>
<TableCell className="hidden text-muted-foreground sm:table-cell">
{member.joinedAt
? format.dateTime(new Date(member.joinedAt), {
dateStyle: "medium",
})
: "–"}
</TableCell>
<TableCell>
{canRemove && editable(member) ? (
<Button
variant="ghost"
size="icon-sm"
disabled={busy(member)}
aria-label={t("Remove {name}", {
name: member.name ?? member.email ?? "",
})}
onClick={() => {
void removeMember(member);
}}
>
<UserMinusIcon />
</Button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}React Native, with ActivityIndicator, Pressable, Text, View from react-native:
import { useMembers } from "better-supabase/blocks/organizations/react";
import { tenantOf, useAuth } from "better-supabase/react";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { API_SCHEMA } from "../lib/blocks";
/**
* The active organization's members. Pressing a role switches it between member
* and admin at once; the organizations module refuses a role above the
* caller's, and the row shows the old role again.
*/
export function MembersTable() {
const auth = useAuth();
const { members, pending, error, setRole, remove } = useMembers({
organizationId: tenantOf(auth),
schema: API_SCHEMA,
});
if (members === undefined) {
return <ActivityIndicator />;
}
return (
<View style={{ gap: 4 }}>
<Text style={{ fontSize: 17, fontWeight: "600" }}>Members</Text>
{members.map((member) => {
const busy = pending.includes(member.userId);
const editable =
member.userId !== auth.user?.id && member.role !== "owner";
const next = member.role === "admin" ? "member" : "admin";
return (
<View
key={member.userId}
accessibilityState={{ busy }}
style={{
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingVertical: 8,
opacity: busy ? 0.5 : 1,
}}
>
<Text style={{ flex: 1 }} numberOfLines={1}>
{member.userId === auth.user?.id ? "You" : member.userId}
</Text>
<Pressable
accessibilityRole="button"
accessibilityHint={editable ? `Changes the role to ${next}` : ""}
disabled={!editable || busy}
onPress={() => void setRole(member.userId, next)}
>
<Text style={{ color: editable ? "#2563eb" : "#737373" }}>
{member.role}
</Text>
</Pressable>
{editable ? (
<Pressable
accessibilityRole="button"
disabled={busy}
onPress={() => void remove(member.userId)}
>
<Text style={{ color: "#b91c1c" }}>Remove</Text>
</Pressable>
) : null}
</View>
);
})}
{error ? <Text style={{ color: "#b91c1c" }}>{error.message}</Text> : null}
</View>
);
}Last updated on