Organization switcher
A menu of the user's organizations that switches the active one and refreshes the session for its tenant claim.
Both sides call useOrganizations from better-supabase/blocks/organizations/react; the organizations block must be installed (npx better-supabase add organizations). Its page on bettersupabase.com is /recipes/org-switcher.
Web, with shadcn components:
npx shadcn add dropdown-menu sidebar"use client";
import { DbException } from "better-supabase";
import { useOrganizations } from "better-supabase/blocks/organizations/react";
import { CheckIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react";
import { useExtracted } from "next-intl";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SidebarMenuButton } from "@/components/ui/sidebar";
import { blockConfig } from "@/lib/supabase/blocks.generated";
import { useErrorMessage } from "@/lib/use-error-message";
import type { OrganizationSummary } from "../organization-queries";
import { loadMyOrganizations } from "../organization-actions";
import { useRefreshSession } from "../use-refresh-session";
import { useRoleLabel } from "../use-role-label";
import { CreateOrganizationDialog } from "./create-organization-dialog";
function Mark({ name }: { name: string }) {
return (
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sm font-semibold text-sidebar-primary-foreground">
{name.slice(0, 1).toUpperCase()}
</div>
);
}
async function loadOrganizations(): Promise<readonly OrganizationSummary[]> {
const result = await loadMyOrganizations({});
if (!result.ok) {
throw new DbException(result.error);
}
return result.data;
}
/**
* The organization menu. The server renders it with its rows; switching goes
* through `switch_organization`, refreshes the session for the new tenant
* claim, then re-renders the server tree.
*/
export function OrganizationSwitcherMenu({
initial,
activeId: serverActiveId,
}: {
initial: readonly OrganizationSummary[];
activeId: string | null;
}) {
const t = useExtracted("organization");
const roleLabel = useRoleLabel();
const errorMessage = useErrorMessage();
const refreshSession = useRefreshSession();
const [pending, startTransition] = useTransition();
const [creating, setCreating] = useState(false);
const { organizations, active, activeId, switchTo } = useOrganizations({
load: loadOrganizations,
initial,
activeId: serverActiveId,
schema: blockConfig.organizations.api,
onSwitched: () => refreshSession("/"),
});
const choose = (organization: OrganizationSummary) => {
startTransition(async () => {
const result = await switchTo(organization.id);
if (!result.ok) {
toast.error(errorMessage(result.error));
return;
}
toast.success(t("Switched to {name}", { name: organization.name }));
});
};
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
disabled={pending}
render={
<SidebarMenuButton
size="lg"
className="data-popup-open:bg-sidebar-accent"
aria-label={t("Switch organization")}
/>
}
>
<Mark name={active?.name ?? "?"} />
<div className="grid flex-1 text-left text-sm leading-tight">
<span
className="truncate font-medium"
data-testid="organization-name"
>
{active?.name ?? t("No organization")}
</span>
<span className="truncate text-xs text-muted-foreground capitalize">
{active ? active.plan : t("Create one to start")}
</span>
</div>
<ChevronsUpDownIcon className="ml-auto" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-64" align="start" side="bottom">
<DropdownMenuGroup>
<DropdownMenuLabel>{t("Organizations")}</DropdownMenuLabel>
{(organizations ?? initial).map((organization) => (
<DropdownMenuItem
key={organization.id}
className="gap-2"
onClick={() => {
if (organization.id !== activeId) {
choose(organization);
}
}}
>
<Mark name={organization.name} />
<div className="grid flex-1 leading-tight">
<span className="truncate">{organization.name}</span>
<span className="text-xs text-muted-foreground">
{roleLabel(organization.role)}
</span>
</div>
{organization.id === activeId ? <CheckIcon /> : null}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setCreating(true);
}}
>
<PlusIcon />
{t("Create organization")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<CreateOrganizationDialog open={creating} onOpenChange={setCreating} />
</>
);
}React Native, with ActivityIndicator, Pressable, Text, View from react-native:
import { useOrganizations } from "better-supabase/blocks/organizations/react";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { API_SCHEMA } from "../lib/blocks";
/**
* The caller's organizations. Pressing one switches to it, refreshes the
* session so the token carries the new tenant, and loads the list again.
*/
export function OrganizationSwitcher() {
const { organizations, activeId, switching, error, switchTo } =
useOrganizations({ schema: API_SCHEMA });
if (organizations === undefined) {
return <ActivityIndicator />;
}
return (
<View style={{ gap: 4 }}>
<Text style={{ fontSize: 17, fontWeight: "600" }}>Organizations</Text>
{organizations.map((organization) => (
<Pressable
key={organization.id}
accessibilityRole="button"
accessibilityState={{
selected: organization.id === activeId,
busy: organization.id === switching,
}}
disabled={switching !== undefined || organization.id === activeId}
onPress={() => void switchTo(organization.id)}
style={{ flexDirection: "row", gap: 8, paddingVertical: 8 }}
>
<Text style={{ width: 16 }}>
{organization.id === activeId ? "✓" : ""}
</Text>
<Text style={{ flex: 1 }}>{organization.name}</Text>
<Text style={{ color: "#737373" }}>{organization.role}</Text>
</Pressable>
))}
{error ? <Text style={{ color: "#b91c1c" }}>{error.message}</Text> : null}
</View>
);
}Last updated on
My work
What waits on the caller across the installed blocks, from `loadMyWork`: assigned work, approvals, due reminders, mentions and SLA warnings, each linked to its record.
Settings layout
Settings pages generated from the settings definition: a section per scope and a control per key, picked by the kind its schema declares.