All recipes
Recipe
Members table
An organization's members with their roles, where a role change shows at once and a member can be removed. Web and native call useMembers from better-supabase/blocks/organizations/react.
1.What you build
No screenshot yet. Run the Next.js example (pnpm dev:example) to see it.
2.Add the components
Web: shadcn
npx shadcn add avatar button select tableNative: React Native
The snippet uses ActivityIndicator, Pressable, Text, View from react-native, so it needs no styling library. For shadcn-style native components, React Native Reusables serves them from a registry the shadcn CLI reads.
3.Install the glue
Install the block with npx better-supabase sql add organizations. The glue items are copied into your app, so edit them as you need.
Web
None: the hook calls the block from the client.
Native
None: the hook calls the block from the client.
4.Write the component
Web: React and shadcn
View in the example"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="text-muted-foreground font-normal">
{" "}
({t("you")})
</span>
) : null}
</span>
<span className="text-muted-foreground text-xs">
{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="text-muted-foreground hidden 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>
);
}
Native: React Native
View in the exampleimport { 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>
);
}