Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dashboard-api-keys-page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@anticapture/dashboard": minor
---

Add the API Keys page: signed-in users create, view, and revoke personal API keys for the Anticapture MCP server / public API. Includes the one-time key reveal on creation, a per-key list (name, status, created, last used), and a "Connect your AI agent" section with copy-paste install commands for Claude Code, Cursor, and Codex. Reached from the new "API" sidebar entry; gated behind sign-in.
36 changes: 36 additions & 0 deletions apps/dashboard/app/api-keys/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { Metadata } from "next";

import { ApiKeysManager } from "@/features/api-keys";
import { Footer } from "@/shared/components/design-system/footer/Footer";
import { HeaderSidebar } from "@/widgets";
import { HeaderMobile } from "@/widgets/HeaderMobile";

// Platform-account feature: keys belong to the signed-in user, not to a DAO,
// so this page lives at the root (main sidebar "API" entry), outside any
// /{daoId} context.
export const metadata: Metadata = {
title: "API Keys — Anticapture",
description:
"Create and manage API keys to query Anticapture from Claude, Cursor, or Codex.",
alternates: { canonical: "/api-keys" },
robots: { index: false, follow: false },
};

export default function ApiKeysPage() {
return (
<div className="bg-surface-background dark flex h-screen overflow-hidden">
<HeaderSidebar />
<main className="flex-1 overflow-auto">
<div className="lg:hidden">
<HeaderMobile className="fixed! top-0" />
</div>
<div className="flex min-h-screen w-full flex-col items-center">
<div className="mt-14 w-full flex-1 lg:mt-0">
<ApiKeysManager />
</div>
<Footer />
</div>
</main>
</div>
);
}
139 changes: 139 additions & 0 deletions apps/dashboard/features/api-keys/ApiKeysManager.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"use client";

import { Code, Plus } from "lucide-react";
import { useEffect, useRef, useState } from "react";

import { Button } from "@/shared/components";
import { DividerDefault } from "@/shared/components/design-system/divider/DividerDefault";
import { Modal } from "@/shared/components/design-system/modal/Modal";
import { SectionTitle } from "@/shared/components/design-system/section/section-title/SectionTitle";
import { useSession } from "@/shared/services/auth/client";
import { useLogin } from "@/shared/services/auth/LoginProvider";
import type { UserApiKey } from "@/shared/services/user-api/apiKeysClient";

import { ApiKeysTable } from "./components/ApiKeysTable";
import { ConnectAgentSection } from "./components/ConnectAgentSection";
import { CreateApiKeyModal } from "./components/CreateApiKeyModal";
import { SaveApiKeyModal } from "./components/SaveApiKeyModal";
import { useApiKeys } from "./hooks/useApiKeys";

export const ApiKeysManager = () => {
const { data: session, isPending } = useSession();
const { openLogin } = useLogin();
const isAuthed = !isPending && !!session;

const { keys, isLoading, create, revoke } = useApiKeys(isAuthed);

const [createOpen, setCreateOpen] = useState(false);
// Holds the just-created plaintext for the one-time reveal modal.
const [created, setCreated] = useState<{
token: string;
label: string;
} | null>(null);
// Plaintexts of keys created this session, so the connect command can
// embed the real key ("Your key is already in it"). Never persisted.
const [sessionTokens, setSessionTokens] = useState<Record<string, string>>(
{},
);
const [lastCreatedId, setLastCreatedId] = useState<string | null>(null);
const [toRevoke, setToRevoke] = useState<UserApiKey | null>(null);

const handleCreate = (label: string) => {
create.mutate(label, {
onSuccess: (key) => {
setCreateOpen(false);
setCreated({ token: key.token, label: key.label });
setSessionTokens((prev) => ({ ...prev, [key.id]: key.token }));
setLastCreatedId(key.id);
},
});
};

const handleRevoke = () => {
if (!toRevoke) return;
revoke.mutate(toRevoke.id, { onSuccess: () => setToRevoke(null) });
};

// Signed-out arrivals get the sign-in modal right away, over the page —
// no interstitial screen. The gate is consumed on the FIRST session
// resolution either way, so it neither re-pops after a dismissal nor when
// the user signs out while on the page; gated actions (Create key)
// re-open it.
const autoOpenedLogin = useRef(false);
useEffect(() => {
if (isPending || autoOpenedLogin.current) return;
autoOpenedLogin.current = true;
if (!session) openLogin();
}, [isPending, session, openLogin]);

return (
<div className="flex w-full flex-col gap-6 p-5">
<div className="flex flex-col gap-6">
<div className="flex items-start justify-between gap-4">
<SectionTitle
icon={<Code className="text-primary size-5" />}
title="API Keys"
description="Query Anticapture from Claude, Cursor, or Codex. Just ask in natural language."
/>
<Button
variant="primary"
size="md"
className="shrink-0"
onClick={() => (isAuthed ? setCreateOpen(true) : openLogin())}
>
<Plus className="size-3.5" />
Create key
</Button>
</div>

{isLoading ? (
<p className="text-secondary text-sm">Loading…</p>
) : (
<ApiKeysTable keys={keys} onRevoke={setToRevoke} />
)}
</div>

<DividerDefault />

<ConnectAgentSection
keys={keys}
sessionTokens={sessionTokens}
lastCreatedId={lastCreatedId}
/>

<CreateApiKeyModal
open={createOpen}
onOpenChange={setCreateOpen}
onCreate={handleCreate}
isCreating={create.isPending}
/>

{created && (
<SaveApiKeyModal
open={!!created}
onOpenChange={(open) => !open && setCreated(null)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve one-time keys until explicit confirmation

When the save dialog is dismissed via the modal's X button, overlay click, or Escape key, Radix calls onOpenChange(false), and this handler immediately clears created. Because this plaintext key is never retrievable again, a user who accidentally closes the dialog before copying it loses the only copy and has to revoke/recreate the key; clear this state only from the explicit Done path, or disable passive dismissal for this one-time secret.

Useful? React with 👍 / 👎.

token={created.token}
label={created.label}
/>
)}

<Modal
open={!!toRevoke}
onOpenChange={(open) => !open && setToRevoke(null)}
title="Revoke API key"
description={
toRevoke
? `This permanently revokes "${toRevoke.label}". Any agent using it stops working right away. This can't be undone.`
: ""
}
confirmLabel="Revoke"
cancelLabel="Cancel"
confirmVariant="destructive"
isConfirmLoading={revoke.isPending}
onConfirm={handleRevoke}
>
<div className="p-4" />
</Modal>
</div>
);
};
131 changes: 131 additions & 0 deletions apps/dashboard/features/api-keys/components/ApiKeysTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"use client";

import type { ColumnDef } from "@tanstack/react-table";
import { Ellipsis } from "lucide-react";
import { useMemo, useState } from "react";

import { Button } from "@/shared/components";
import { BadgeStatus } from "@/shared/components/design-system/badges/badge-status/BadgeStatus";
import { Table } from "@/shared/components/design-system/table/Table";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/components/ui/popover";
import type { UserApiKey } from "@/shared/services/user-api/apiKeysClient";
import { formatRelativeTime } from "@/shared/utils/formatRelativeTime";

const dateFmt = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});

// ⋯ options menu per row — revoke is the only action for now (rotate later).
const KeyOptions = ({
apiKey,
onRevoke,
}: {
apiKey: UserApiKey;
onRevoke: (key: UserApiKey) => void;
}) => {
const [open, setOpen] = useState(false);

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="p-2"
aria-label={`Options for ${apiKey.label}`}
>
<Ellipsis className="size-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-40 p-1">
<button
type="button"
onClick={() => {
setOpen(false);
onRevoke(apiKey);
}}
className="text-error hover:bg-surface-contrast rounded-base w-full px-3 py-2 text-left text-sm font-medium transition-colors"
>
Revoke key
</button>
</PopoverContent>
</Popover>
);
};

export const ApiKeysTable = ({
keys,
onRevoke,
}: {
keys: UserApiKey[];
onRevoke: (key: UserApiKey) => void;
}) => {
const columns = useMemo<ColumnDef<UserApiKey>[]>(
() => [
{
accessorKey: "label",
header: "Name",
cell: ({ row }) => (
<span className="text-primary text-sm font-medium">
{row.original.label}
</span>
),
},
{
id: "status",
header: "Status",
cell: ({ row }) =>
row.original.revokedAt === null ? (
<BadgeStatus variant="success">Active</BadgeStatus>
) : (
<BadgeStatus variant="dimmed">Disabled</BadgeStatus>
),
},
{
accessorKey: "createdAt",
header: "Created",
cell: ({ row }) => (
<span className="text-secondary text-sm">
{dateFmt.format(new Date(row.original.createdAt))}
</span>
),
},
{
accessorKey: "lastUsedAt",
header: "Last Used",
cell: ({ row }) => (
<span className="text-secondary text-sm">
{row.original.lastUsedAt
? formatRelativeTime(Date.parse(row.original.lastUsedAt) / 1000)
: "—"}
</span>
),
},
{
id: "options",
header: "",
cell: ({ row }) =>
row.original.revokedAt === null && (
<KeyOptions apiKey={row.original} onRevoke={onRevoke} />
),
meta: { columnClassName: "w-14" },
},
],
[onRevoke],
);

return (
<Table
columns={columns}
data={keys}
emptyTitle="No API keys yet"
emptyDescription="Create one to connect your AI agent."
/>
);
};
Loading
Loading