All recipes

Recipe

API keys table

The organization's API keys, with a form that creates one and shows its token once, and a revoke button per key. Web and native call useApiKeys from better-supabase/blocks/api-keys/react.

The api-keys 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 alert badge button dialog empty field input skeleton table

Native: React Native

The snippet uses ActivityIndicator, Button, 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 api-keys. 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
api-key-table.tsx
"use client";

import { useApiKeys } from "better-supabase/blocks/api-keys/react";
import { KeyRoundIcon } from "lucide-react";
import { useExtracted, useFormatter } from "next-intl";
import { toast } from "sonner";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { blockConfig } from "@/lib/supabase/blocks.generated";
import { useErrorMessage } from "@/lib/use-error-message";

import { apiKeyCreated } from "../api-key-actions";
import { CreateApiKeyDialog } from "./create-api-key-dialog";

/**
 * The organization's API keys through the browser client, with a button
 * that creates one and shows its token once. Members without
 * `api_keys.manage` create `personal` keys, which act as them.
 */
export function ApiKeyTable({
  organizationId,
  personal,
}: {
  organizationId: string;
  personal: boolean;
}) {
  const t = useExtracted("apiKeys");
  const errorMessage = useErrorMessage();
  const format = useFormatter();
  const { keys, created, creating, revoking, error, create, revoke, dismiss } =
    useApiKeys({
      organizationId,
      schema: blockConfig["api-keys"].api,
      onChanged: async ({ kind }) => {
        if (kind === "created") await apiKeyCreated({});
      },
    });

  const revokeKey = async (id: string) => {
    const result = await revoke(id);
    if (result.ok) toast.success(t("Key revoked"));
    else toast.error(errorMessage(result.error));
  };
  const date = (value: Temporal.Instant | undefined) =>
    value
      ? format.dateTime(new Date(value.epochMilliseconds), {
          dateStyle: "medium",
        })
      : "–";

  const dialog = (
    <CreateApiKeyDialog
      token={created?.token}
      creating={creating}
      error={error}
      onCreate={async (name) => {
        await create({ name, personal });
      }}
      onClose={dismiss}
    />
  );
  if (keys === undefined) {
    return (
      <div className="grid gap-4">
        <div className="flex justify-end">{dialog}</div>
        <Skeleton className="h-32" />
      </div>
    );
  }
  return (
    <div className="grid gap-4">
      <div className="flex justify-end">{dialog}</div>
      {keys.length === 0 ? (
        <Empty>
          <EmptyHeader>
            <EmptyMedia variant="icon">
              <KeyRoundIcon />
            </EmptyMedia>
            <EmptyTitle>{t("No API keys yet")}</EmptyTitle>
            <EmptyDescription>
              {t("Create one to call the API from a script.")}
            </EmptyDescription>
          </EmptyHeader>
        </Empty>
      ) : (
        <Table data-testid="api-keys">
          <TableHeader>
            <TableRow>
              <TableHead>{t("Name")}</TableHead>
              <TableHead>{t("Key")}</TableHead>
              <TableHead className="hidden md:table-cell">
                {t("Created")}
              </TableHead>
              <TableHead className="hidden md:table-cell">
                {t("Last used")}
              </TableHead>
              <TableHead className="w-24" />
            </TableRow>
          </TableHeader>
          <TableBody>
            {keys.map((key) => (
              <TableRow
                key={key.id}
                aria-busy={revoking.includes(key.id) ? true : undefined}
                className={revoking.includes(key.id) ? "opacity-50" : undefined}
              >
                <TableCell className="font-medium">
                  {key.name}{" "}
                  {key.userId ? (
                    <Badge variant="outline">{t("Personal")}</Badge>
                  ) : null}
                </TableCell>
                <TableCell className="font-mono text-xs">
                  {key.prefix}_{key.publicId}…
                </TableCell>
                <TableCell className="text-muted-foreground hidden md:table-cell">
                  {date(key.createdAt)}
                </TableCell>
                <TableCell className="text-muted-foreground hidden md:table-cell">
                  {date(key.lastUsedAt)}
                </TableCell>
                <TableCell className="text-right">
                  {key.state === "revoked" || key.state === "expired" ? (
                    <Badge variant="secondary">{t("Revoked")}</Badge>
                  ) : (
                    <Button
                      variant="ghost"
                      size="sm"
                      disabled={revoking.includes(key.id)}
                      onClick={() => {
                        void revokeKey(key.id);
                      }}
                    >
                      {t("Revoke")}
                    </Button>
                  )}
                </TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      )}
    </div>
  );
}

Native: React Native

View in the example
api-keys-table.tsx
import { useApiKeys } from "better-supabase/blocks/api-keys/react";
import { tenantOf, useAuth } from "better-supabase/react";
import { useState } from "react";
import {
  ActivityIndicator,
  Button,
  Pressable,
  Text,
  TextInput,
  View,
} from "react-native";

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

/**
 * The organization's API keys with a field that creates one, whose token
 * shows only until it is dismissed, and a revoke button per key. Members
 * who can't manage the organization's keys create personal ones.
 */
export function ApiKeysTable({ personal = false }: { personal?: boolean }) {
  const [name, setName] = useState("");
  const { keys, created, creating, revoking, error, create, revoke, dismiss } =
    useApiKeys({ organizationId: tenantOf(useAuth()), schema: API_SCHEMA });

  const submit = async () => {
    if ((await create({ name: name.trim(), personal })).ok) setName("");
  };

  if (keys === undefined) return <ActivityIndicator />;
  return (
    <View style={{ gap: 8 }}>
      <Text style={{ fontSize: 17, fontWeight: "600" }}>API keys</Text>
      {keys.map((key) => {
        const revoked = key.state === "revoked" || key.state === "expired";
        return (
          <View
            key={key.id}
            style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
          >
            <View style={{ flex: 1 }}>
              <Text>{key.name}</Text>
              <Text style={{ color: "#737373", fontFamily: "monospace" }}>
                {key.prefix}_{key.publicId}…
              </Text>
            </View>
            {revoked ? (
              <Text style={{ color: "#737373" }}>Revoked</Text>
            ) : (
              <Pressable
                accessibilityRole="button"
                disabled={revoking.includes(key.id)}
                onPress={() => void revoke(key.id)}
              >
                <Text style={{ color: "#b91c1c" }}>Revoke</Text>
              </Pressable>
            )}
          </View>
        );
      })}
      {created ? (
        <View style={{ gap: 4, padding: 8, backgroundColor: "#f5f5f5" }}>
          <Text>Copy the key now. It is not shown again.</Text>
          <Text selectable style={{ fontFamily: "monospace" }}>
            {created.token}
          </Text>
          <Button title="Done" onPress={dismiss} />
        </View>
      ) : null}
      {error ? <Text style={{ color: "#b91c1c" }}>{error.message}</Text> : null}
      <TextInput
        value={name}
        onChangeText={setName}
        placeholder="Key name, such as CI"
        maxLength={80}
        style={{ borderWidth: 1, borderRadius: 8, padding: 8 }}
      />
      <Button
        title="Create key"
        disabled={creating || !name.trim()}
        onPress={() => void submit()}
      />
    </View>
  );
}