All recipes

Recipe

Invite dialog

A form that invites a teammate by email with a role, then shows the token for the accept link. Web and native call useInvitations from better-supabase/blocks/organizations/react.

The invitations blockWebReact Native

1.What you build

No screenshot yet. Run the Next.js example (pnpm dev:example) to see it.

2.Add the components

Web: shadcn

terminal
npx shadcn add button dialog field input select

Native: React Native

The snippet uses Button, Modal, Pressable, Text, TextInput, 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 invitations. 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
invite-member-dialog.tsx
"use client";

import { useInvitations } from "better-supabase/blocks/organizations/react";
import { CopyIcon, UserPlusIcon } from "lucide-react";
import { useExtracted } from "next-intl";
import { type SubmitEvent, useId, useState } from "react";
import { toast } from "sonner";
import * as v from "valibot";

import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import {
  Field,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { assignableRoles } from "@/features/user/user-permissions";
import { Role } from "@/lib/claims";
import { blockConfig } from "@/lib/supabase/blocks.generated";
import { useErrorMessage } from "@/lib/use-error-message";

import { invitationSent } from "../organization-actions";
import { useInviteLink } from "../use-invite-link";
import { useRoleLabel } from "../use-role-label";

/**
 * Invites a teammate through `invite_member` in the browser, then shows the
 * accept link: the example has no mailer.
 */
export function InviteMemberDialog({
  organizationId,
  myRole,
}: {
  organizationId: string;
  myRole: Role;
}) {
  const t = useExtracted("organization");
  const fieldId = useId();
  const roleLabel = useRoleLabel();
  const errorMessage = useErrorMessage();
  const inviteLink = useInviteLink();
  const [open, setOpen] = useState(false);
  const [email, setEmail] = useState("");
  const [role, setRole] = useState<Role>("member");
  const roles = assignableRoles(myRole).map((each) => ({
    value: each,
    label: roleLabel(each),
  }));
  // The server renders the pending list; `invitationSent` renders it again.
  const { invite, inviting, sent, error, clear } = useInvitations({
    organizationId,
    schema: blockConfig.organizations.api,
    onChanged: async ({ kind }) => {
      if (kind === "invited") await invitationSent({});
    },
  });
  const link = sent ? inviteLink(sent.token) : null;
  const message =
    error === undefined
      ? null
      : error.kind === "conflict"
        ? t("That person is already a member or invited.")
        : errorMessage(error);

  const submit = async (event: SubmitEvent<HTMLFormElement>) => {
    event.preventDefault();
    await invite({ email: email.trim().toLowerCase(), role });
  };

  return (
    <Dialog
      open={open}
      onOpenChange={(next) => {
        setOpen(next);
        setEmail("");
        if (!next) clear();
      }}
    >
      <DialogTrigger render={<Button size="sm" />}>
        <UserPlusIcon />
        {t("Invite")}
      </DialogTrigger>
      <DialogContent>
        {link ? (
          <div className="grid gap-6">
            <DialogHeader>
              <DialogTitle>{t("Invitation created")}</DialogTitle>
              <DialogDescription>
                {t("Send this link. It works once and expires in seven days.")}
              </DialogDescription>
            </DialogHeader>
            <div className="flex gap-2">
              <Input
                value={link}
                readOnly
                aria-label={t("Invitation link")}
                data-testid="invite-link"
              />
              <Button
                variant="outline"
                size="icon"
                aria-label={t("Copy link")}
                onClick={() => {
                  void navigator.clipboard
                    .writeText(link)
                    .then(() => toast.success(t("Link copied")));
                }}
              >
                <CopyIcon />
              </Button>
            </div>
            <DialogFooter>
              <DialogClose render={<Button />}>{t("Done")}</DialogClose>
            </DialogFooter>
          </div>
        ) : (
          <form onSubmit={(event) => void submit(event)} className="grid gap-6">
            <DialogHeader>
              <DialogTitle>{t("Invite a teammate")}</DialogTitle>
              <DialogDescription>
                {t(
                  "They join with the role you pick. You can't give a role above your own.",
                )}
              </DialogDescription>
            </DialogHeader>
            <FieldGroup>
              <Field data-invalid={message ? true : undefined}>
                <FieldLabel htmlFor={`${fieldId}-invite-email`}>
                  {t("Email")}
                </FieldLabel>
                <Input
                  id={`${fieldId}-invite-email`}
                  name="email"
                  type="email"
                  value={email}
                  onChange={(event) => {
                    setEmail(event.target.value);
                  }}
                  required
                  autoComplete="off"
                />
                {message ? <FieldError>{message}</FieldError> : null}
              </Field>
              <Field>
                <FieldLabel htmlFor={`${fieldId}-invite-role`}>
                  {t("Role")}
                </FieldLabel>
                <Select
                  value={role}
                  items={roles}
                  onValueChange={(value) => {
                    if (v.is(Role, value)) setRole(value);
                  }}
                >
                  <SelectTrigger id={`${fieldId}-invite-role`} className="w-40">
                    <SelectValue />
                  </SelectTrigger>
                  <SelectContent>
                    {roles.map((each) => (
                      <SelectItem key={each.value} value={each.value}>
                        {each.label}
                      </SelectItem>
                    ))}
                  </SelectContent>
                </Select>
              </Field>
            </FieldGroup>
            <DialogFooter>
              <DialogClose render={<Button type="button" variant="outline" />}>
                {t("Cancel")}
              </DialogClose>
              <Button type="submit" disabled={inviting}>
                {t("Send invitation")}
              </Button>
            </DialogFooter>
          </form>
        )}
      </DialogContent>
    </Dialog>
  );
}

Native: React Native

View in the example
invite-dialog.tsx
import { useInvitations } from "better-supabase/blocks/organizations/react";
import { tenantOf, useAuth } from "better-supabase/react";
import { useState } from "react";
import { Button, Modal, Pressable, Text, TextInput, View } from "react-native";

import { API_SCHEMA } from "../lib/blocks";

const ROLES = ["member", "admin"] as const;

/**
 * A button that opens a form to invite a teammate by email with a role,
 * then shows the token for the accept link (the example has no mailer).
 */
export function InviteDialog() {
  const [open, setOpen] = useState(false);
  const [email, setEmail] = useState("");
  const [role, setRole] = useState<(typeof ROLES)[number]>("member");
  const { invitations, invite, inviting, sent, error, clear } = useInvitations({
    organizationId: tenantOf(useAuth()),
    schema: API_SCHEMA,
  });

  const close = () => {
    setOpen(false);
    setEmail("");
    clear();
  };

  return (
    <View style={{ gap: 4 }}>
      <Text style={{ fontSize: 17, fontWeight: "600" }}>
        Pending invitations
      </Text>
      {invitations?.map((invitation) => (
        <Text key={invitation.id}>
          {invitation.email} ({invitation.role})
        </Text>
      ))}
      <Button
        title="Invite"
        onPress={() => {
          setOpen(true);
        }}
      />
      <Modal visible={open} animationType="slide" onRequestClose={close}>
        <View style={{ flex: 1, gap: 12, padding: 24 }}>
          {sent ? (
            <>
              <Text style={{ fontSize: 17, fontWeight: "600" }}>
                Invitation created
              </Text>
              <Text>Send this token in the accept link. It works once.</Text>
              <Text selectable style={{ fontFamily: "monospace" }}>
                {sent.token}
              </Text>
            </>
          ) : (
            <>
              <Text style={{ fontSize: 17, fontWeight: "600" }}>
                Invite a teammate
              </Text>
              <TextInput
                value={email}
                onChangeText={setEmail}
                placeholder="Email"
                keyboardType="email-address"
                autoCapitalize="none"
                autoComplete="off"
                style={{ borderWidth: 1, borderRadius: 8, padding: 8 }}
              />
              <View style={{ flexDirection: "row", gap: 8 }}>
                {ROLES.map((each) => (
                  <Pressable
                    key={each}
                    accessibilityRole="radio"
                    accessibilityState={{ checked: each === role }}
                    onPress={() => {
                      setRole(each);
                    }}
                    style={{
                      borderWidth: 1,
                      borderRadius: 16,
                      paddingHorizontal: 12,
                      paddingVertical: 4,
                      backgroundColor: each === role ? "#e5e5e5" : undefined,
                    }}
                  >
                    <Text>{each}</Text>
                  </Pressable>
                ))}
              </View>
              {error ? (
                <Text style={{ color: "#b91c1c" }}>{error.message}</Text>
              ) : null}
              <Button
                title="Send invitation"
                disabled={inviting || !email.trim()}
                onPress={() =>
                  void invite({ email: email.trim().toLowerCase(), role })
                }
              />
            </>
          )}
          <Button title={sent ? "Done" : "Cancel"} onPress={close} />
        </View>
      </Modal>
    </View>
  );
}