Add a full API-keys UI and Bearer-token authentication for the PocketBase API. Authenticated users get an /api-keys page to list, create, and revoke keys; new secrets are hashed with argon2id and shown once in a copy dialog. Keys are issued in the form <id>.<secret>, and PocketBase requests made with Authorization: Bearer <key> authenticate as the owning user. Requires auth and api to be enabled first.
import { z } from "zod";
import type { Schemas } from "@velastack/pocketbase";
export const apiKeySchema = z.object({
label: z.string(),
}) satisfies Schemas["api_keys"];
import { redirect } from "@sveltejs/kit";
export const actions = {
delete: async ({ locals, params }) => {
await locals.pb.collection("api_keys").delete(params.id);
redirect(303, "/api-keys");
},
};
export const load = async ({ locals }) => {
const apiKeys = await locals.pb.collection("api_keys").getFullList();
return { apiKeys };
};
<script lang="ts">
import { getFlash } from "sveltekit-flash-message";
import { page } from "$app/state";
import { toast } from "svelte-sonner";
import * as AlertDialog from "$lib/components/ui/alert-dialog";
import { Button } from "$lib/components/ui/button";
import { Badge } from "$lib/components/ui/badge";
import { timeAgo } from "$lib/utils";
import PlusIcon from "@lucide/svelte/icons/plus";
import SquareDashedMousePointerIcon from "@lucide/svelte/icons/square-dashed-mouse-pointer";
const flash = getFlash(page);
let open = $state(false);
let message = $state("");
let title = $state("");
let apiKey = $state("");
$effect(() => {
if (!$flash) return;
if ($flash.type === "success") {
message = $flash.message;
title = $flash.title;
apiKey = $flash.apiKey;
open = true;
}
$flash = undefined;
});
async function handleAction() {
open = false;
await navigator.clipboard.writeText(apiKey);
toast.success("API key copied to clipboard");
}
let { data, children } = $props();
</script>
<section data-role="content">
<div class="flex justify-between items-center mb-4">
<h1 class="text-3xl font-bold tracking-tight">API keys</h1>
<Button href="/api-keys/new" variant="outline">
<PlusIcon class="w-4 h-4" />
New API key
</Button>
</div>
<AlertDialog.Root bind:open>
<AlertDialog.Content class="max-w-120!">
<AlertDialog.Header>
<AlertDialog.Title>{title}</AlertDialog.Title>
<AlertDialog.Description class="text-wrap">
<p>{@html message}</p>
<Badge variant="outline" class="font-mono my-3 text-base"
>{apiKey}</Badge
>
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Close</AlertDialog.Cancel>
<AlertDialog.Action onclick={handleAction}>Copy</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<div class="rounded-lg border bg-card overflow-hidden">
<div class="relative w-full overflow-auto">
{#if data.apiKeys.length === 0}
<div class="p-4 text-center text-muted-foreground space-y-4 py-12">
<SquareDashedMousePointerIcon
strokeWidth={1.2}
class="w-8 h-8 mx-auto mb-2"
/>
<p>
Seems like there are no API keys yet, but you can create the first
one
</p>
<p class="text-sm text-muted-foreground">
<Button href="/api-keys/new" variant="outline">
<PlusIcon class="w-4 h-4" />
New API key
</Button>
</p>
</div>
{:else}
<table class="w-full caption-bottom text-sm">
<thead>
<tr class="border-b">
<th
class="h-12 px-4 text-left align-middle font-medium text-muted-foreground"
>Label</th
>
<th
class="h-12 px-4 text-left align-middle font-medium text-muted-foreground"
>API key</th
>
<th
class="h-12 px-4 text-left align-middle font-medium text-muted-foreground"
>Created</th
>
<th
class="h-12 px-4 text-left align-middle font-medium text-muted-foreground"
>Last used</th
>
<th
class="h-12 px-4 text-right align-middle font-medium text-muted-foreground"
></th>
</tr>
</thead>
<tbody>
{#each data.apiKeys as apiKey}
<tr
class="border-t transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted"
>
<td class="p-4 align-middle font-medium">{apiKey.label}</td>
<td class="p-4 align-middle font-medium">
<Badge variant="outline" class="font-mono">
{apiKey.id}.***************
</Badge>
</td>
<td class="p-4 align-middle font-medium">
{#if apiKey.created}
<span title={new Date(apiKey.created).toLocaleString()}>
{timeAgo(apiKey.created)}
</span>
{/if}
</td>
<td class="p-4 align-middle font-medium">
{#if apiKey.last_used}
<span title={new Date(apiKey.last_used).toLocaleString()}>
{timeAgo(apiKey.last_used)}
</span>
{:else}
Never
{/if}
</td>
<td class="p-4 align-middle text-right">
<form
method="POST"
action="/api-keys/{apiKey.id}?/delete"
class="contents"
>
<Button
variant="ghost"
class="h-8 px-2 text-destructive hover:text-destructive"
type="submit">Delete</Button
>
</form>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
</section>
{@render children?.()}
import { definePageMetaTags } from "svelte-meta-tags";
export const load = async ({ parent }) => {
await parent();
const { pageMetaTags } = definePageMetaTags({
title: "API keys",
});
return { pageMetaTags };
};
import { fail, superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { redirect } from "sveltekit-flash-message/server";
import { apiKeySchema } from "$lib/schemas/apiKey";
import { setPocketbaseErrors } from "@velastack/pocketbase";
import { createApiKey } from "./create-api-key";
export const load = async () => {
return { form: await superValidate(zod4(apiKeySchema)) };
};
export const actions = {
default: async ({ locals, request, cookies }) => {
const form = await superValidate(request, zod4(apiKeySchema));
if (!form.valid) {
return fail(400, { form });
}
let apiKey: string;
try {
apiKey = await createApiKey(
locals.pb,
locals.pb.authStore.record?.id,
form.data.label,
);
} catch (error) {
setPocketbaseErrors(form, error);
return fail(400, { form });
}
return redirect(
"/api-keys",
{
type: "success",
title: "You've created a new API key",
message: `Copy the following API key in a safe location. It will only be shown once.`,
apiKey,
},
cookies,
);
},
};
<script lang="ts">
import { untrack } from "svelte";
import { superForm } from "sveltekit-superforms";
import { zod4Client } from "sveltekit-superforms/adapters";
import { apiKeySchema } from "$lib/schemas/apiKey";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { goto } from "$app/navigation";
let { data } = $props();
const form = superForm(
untrack(() => data.form),
{
validators: zod4Client(apiKeySchema),
},
);
const { form: formData } = form;
function onOpenChange(open: boolean) {
if (!open) {
form.reset();
goto("/api-keys");
}
}
</script>
<Dialog.Root open={true} {onOpenChange}>
<Dialog.Content>
<form method="POST">
<div class="grid grid-cols-1 gap-4">
<Form.Field {form} name="label" class="col-span-1">
<Form.Control>
{#snippet children({ props })}
<Form.Label>API key label</Form.Label>
<Input
{...props}
type="text"
bind:value={$formData.label}
autofocus
/>
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
</div>
<div class="mt-4 flex gap-2 border-t pt-4 -mx-4 px-4">
<Form.Button>Save</Form.Button>
<Button href="/api-keys" variant="outline">Cancel</Button>
</div>
</form>
</Dialog.Content>
</Dialog.Root>
import { definePageMetaTags } from "svelte-meta-tags";
export const load = async ({ parent, data }) => {
await parent();
const { pageMetaTags } = definePageMetaTags({
title: "New API key",
});
return { ...data, pageMetaTags };
};
import PocketBase from "pocketbase-sveltekit";
import argon2 from "argon2";
import { randomBytes } from "node:crypto";
export const createApiKey = async (
pb: PocketBase,
userId: string | undefined,
label: string,
) => {
const keySecret = randomBytes(10).toString("hex");
const keyHash = await argon2.hash(keySecret, { type: argon2.argon2id });
const apiKey = await pb
.collection("api_keys")
.create({ key_hash: keyHash, user: userId, label });
return `${apiKey.id}.${keySecret}`;
};
import { describe, it, expect, beforeEach } from "vitest";
import type { Match } from "@velastack/pocketbase";
import { createApiKey } from "./create-api-key";
import type { RouteId } from "./$types";
describe("GET /api-keys/new", () => {
beforeEach(async (context) => {
await context.agent.authenticateUser();
});
it("should return a 200 status code", async (context) => {
const response = await context.agent.get(
"/api-keys/new" satisfies Match<RouteId>,
);
expect(response.status).toBe(200);
});
});
describe("POST /api-keys/new", () => {
beforeEach(async (context) => {
await context.agent.authenticateUser();
});
it("should create a new API key", async (context) => {
const response = await context.agent
.post("/api-keys/new")
.type("form")
.send({ label: "Test API Key" });
expect(response.body.status).toBe(303);
expect(response.body.location).toBe("/api-keys");
});
});
describe("api keys authenticate as a user", () => {
it("should create a new api key", async (context) => {
const apiKey = await createApiKey(
context.pb,
context.user.id,
"Test API Key",
);
expect(apiKey).toBeDefined();
});
it("should throw an error when creating for a non-existent user", async (context) => {
await expect(async () => {
await createApiKey(context.pb, "non-existent-user-id", "Test API Key");
}).rejects.toThrow();
});
it("should give access to the api", async (context) => {
const response = await context.request.get(
`/api/collections/users/records/${context.user.id}`,
);
expect(response.status).toBe(404);
const apiKey = await createApiKey(
context.pb,
context.user.id,
"Test API Key",
);
const response2 = await context.request
.get(`/api/collections/users/records/${context.user.id}`)
.set("Authorization", `Bearer ${apiKey}`);
expect(response2.status).toBe(200);
});
});
import { describe, it, expect, beforeEach } from "vitest";
import type { Match } from "@velastack/pocketbase";
import type { RouteId } from "./$types";
describe("GET /api-keys", () => {
beforeEach(async (context) => {
await context.agent.authenticateUser();
});
it("should return a 200 status code", async (context) => {
const response = await context.agent.get(
"/api-keys" satisfies Match<RouteId>,
);
expect(response.status).toBe(200);
});
});
import {
POCKETBASE_URL,
POCKETBASE_SUPERUSER_EMAIL,
POCKETBASE_SUPERUSER_PASSWORD,
} from "$env/static/private";
import { handlePocketbase } from "@velastack/pocketbase";
export const handle = handlePocketbase({
pocketbaseUrl: POCKETBASE_URL,
superuserEmail: POCKETBASE_SUPERUSER_EMAIL,
superuserPassword: POCKETBASE_SUPERUSER_PASSWORD,
api: {
enabled: true,
apiKeys: { enabled: true },
},
});
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any }
? Omit<T, "children">
: T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
ref?: U | null;
};
const TIME_AGO_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
["year", 60 * 60 * 24 * 365],
["month", 60 * 60 * 24 * 30],
["day", 60 * 60 * 24],
["hour", 60 * 60],
["minute", 60],
["second", 1],
];
export function timeAgo(date: Date | string | number, locale?: string) {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
const diff = (new Date(date).getTime() - Date.now()) / 1000;
for (const [unit, secs] of TIME_AGO_UNITS) {
if (Math.abs(diff) >= secs || unit === "second") {
return rtf.format(Math.round(diff / secs), unit);
}
}
}
api_keys