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.
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 alert badge button dialog empty field input skeleton tableNative: 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"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 exampleimport { 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>
);
}