# 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.

Source: https://bettersupabase.com/docs/registry/ui-recipes/api-keys-table

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

Both sides call `useApiKeys` from `better-supabase/blocks/api-keys/react`; the [api-keys block](https://bettersupabase.com/blocks/api-keys) must be installed (`npx better-supabase add api-keys`). Its page on bettersupabase.com is [/recipes/api-keys-table](https://bettersupabase.com/recipes/api-keys-table).

Web, with shadcn components:

```bash
npx shadcn add alert badge button dialog empty field input skeleton table
```

```tsx title="apps/examples/nextjs/src/features/api-keys/components/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="hidden text-muted-foreground md:table-cell">
                  {date(key.createdAt)}
                </TableCell>
                <TableCell className="hidden text-muted-foreground 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>
  );
}
```

React Native, with `ActivityIndicator`, `Button`, `Pressable`, `Text`, `TextInput`, `View` from `react-native`:

```tsx title="apps/examples/expo-powersync/src/components/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>
  );
}
```