diff --git a/.changeset/dashboard-api-keys-page.md b/.changeset/dashboard-api-keys-page.md new file mode 100644 index 000000000..052200001 --- /dev/null +++ b/.changeset/dashboard-api-keys-page.md @@ -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. diff --git a/apps/dashboard/app/api-keys/page.tsx b/apps/dashboard/app/api-keys/page.tsx new file mode 100644 index 000000000..f1f0af54a --- /dev/null +++ b/apps/dashboard/app/api-keys/page.tsx @@ -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 ( +
+ +
+
+ +
+
+
+ +
+
+
+
+ ); +} diff --git a/apps/dashboard/features/api-keys/ApiKeysManager.tsx b/apps/dashboard/features/api-keys/ApiKeysManager.tsx new file mode 100644 index 000000000..4e4c6bcf5 --- /dev/null +++ b/apps/dashboard/features/api-keys/ApiKeysManager.tsx @@ -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>( + {}, + ); + const [lastCreatedId, setLastCreatedId] = useState(null); + const [toRevoke, setToRevoke] = useState(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 ( +
+
+
+ } + title="API Keys" + description="Query Anticapture from Claude, Cursor, or Codex. Just ask in natural language." + /> + +
+ + {isLoading ? ( +

Loading…

+ ) : ( + + )} +
+ + + + + + + + {created && ( + !open && setCreated(null)} + token={created.token} + label={created.label} + /> + )} + + !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} + > +
+ +
+ ); +}; diff --git a/apps/dashboard/features/api-keys/components/ApiKeysTable.tsx b/apps/dashboard/features/api-keys/components/ApiKeysTable.tsx new file mode 100644 index 000000000..adc4aeb32 --- /dev/null +++ b/apps/dashboard/features/api-keys/components/ApiKeysTable.tsx @@ -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 ( + + + + + + + + + ); +}; + +export const ApiKeysTable = ({ + keys, + onRevoke, +}: { + keys: UserApiKey[]; + onRevoke: (key: UserApiKey) => void; +}) => { + const columns = useMemo[]>( + () => [ + { + accessorKey: "label", + header: "Name", + cell: ({ row }) => ( + + {row.original.label} + + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => + row.original.revokedAt === null ? ( + Active + ) : ( + Disabled + ), + }, + { + accessorKey: "createdAt", + header: "Created", + cell: ({ row }) => ( + + {dateFmt.format(new Date(row.original.createdAt))} + + ), + }, + { + accessorKey: "lastUsedAt", + header: "Last Used", + cell: ({ row }) => ( + + {row.original.lastUsedAt + ? formatRelativeTime(Date.parse(row.original.lastUsedAt) / 1000) + : "—"} + + ), + }, + { + id: "options", + header: "", + cell: ({ row }) => + row.original.revokedAt === null && ( + + ), + meta: { columnClassName: "w-14" }, + }, + ], + [onRevoke], + ); + + return ( + + ); +}; diff --git a/apps/dashboard/features/api-keys/components/ConnectAgentSection.tsx b/apps/dashboard/features/api-keys/components/ConnectAgentSection.tsx new file mode 100644 index 000000000..62ca86ca7 --- /dev/null +++ b/apps/dashboard/features/api-keys/components/ConnectAgentSection.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { ChevronDown, Settings } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { Button } from "@/shared/components"; +import { SectionTitle } from "@/shared/components/design-system/section/section-title/SectionTitle"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/shared/components/ui/popover"; +import type { UserApiKey } from "@/shared/services/user-api/apiKeysClient"; +import { cn } from "@/shared/utils/cn"; +import { formatRelativeTime } from "@/shared/utils/formatRelativeTime"; + +const MCP_URL = "https://mcp.anticapture.com/v1"; + +// Per-client install command. +const CLIENTS = { + "Claude Code": (key: string) => + `claude mcp add anticapture --transport http ${MCP_URL} --header "X-API-KEY: ${key}"`, + Cursor: (key: string) => + `cursor mcp add anticapture --transport http ${MCP_URL} --header "X-API-KEY: ${key}"`, + Codex: (key: string) => + `codex mcp add anticapture --url ${MCP_URL} --header "X-API-KEY: ${key}"`, +} as const; + +type ClientName = keyof typeof CLIENTS; + +// Shown when we don't hold the plaintext (keys created in a previous +// session) — the user pastes the key they saved at creation time. +const KEY_PLACEHOLDER = ""; + +/** + * "Connect your AI agent" — pick a client, pick a key, copy one command. + * Plaintext tokens only exist in memory for keys created this session + * (`sessionTokens`); for those the command embeds the real key (truncated on + * screen, full on copy). Otherwise the placeholder is used. + */ +export const ConnectAgentSection = ({ + keys, + sessionTokens, + lastCreatedId, +}: { + keys: UserApiKey[]; + sessionTokens: Record; + lastCreatedId: string | null; +}) => { + const [client, setClient] = useState("Claude Code"); + const [selectedId, setSelectedId] = useState(null); + const [selectorOpen, setSelectorOpen] = useState(false); + const [copied, setCopied] = useState(false); + + // A key created in this session becomes the selected one. + useEffect(() => { + if (lastCreatedId) setSelectedId(lastCreatedId); + }, [lastCreatedId]); + + const selected = + keys.find((k) => k.id === selectedId) ?? + keys.find((k) => sessionTokens[k.id]) ?? + keys[0] ?? + null; + + const token = selected ? sessionTokens[selected.id] : undefined; + // On-screen the key is truncated like the design; the copied command + // carries the full plaintext so it works as-is. + const shownKey = token ? `${token.slice(0, 12)}…` : KEY_PLACEHOLDER; + const copiedKey = token ?? KEY_PLACEHOLDER; + + const copy = async () => { + await navigator.clipboard.writeText(CLIENTS[client](copiedKey)); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+ } + title="Connect your AI agent" + description="Pick your tool and run one command in your terminal. Your key is already in it." + /> + +
+
+
+ {(Object.keys(CLIENTS) as ClientName[]).map((name) => ( + + ))} +
+ + {selected && ( + + + + + + {keys.map((key) => ( + + ))} + + + )} +
+ +
+ + {CLIENTS[client](shownKey)} + + +
+ + {selected && ( +
+ +

+ {selected.lastUsedAt + ? `Last call from your AI ${formatRelativeTime( + Date.parse(selected.lastUsedAt) / 1000, + ).toLowerCase()}` + : "Waiting for the first call from your AI…"} +

+
+ )} +
+
+ ); +}; diff --git a/apps/dashboard/features/api-keys/components/CreateApiKeyModal.tsx b/apps/dashboard/features/api-keys/components/CreateApiKeyModal.tsx new file mode 100644 index 000000000..11d0b1006 --- /dev/null +++ b/apps/dashboard/features/api-keys/components/CreateApiKeyModal.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { Modal } from "@/shared/components/design-system/modal/Modal"; +import { Input } from "@/shared/components/design-system/form/fields/input/Input"; + +/** + * Names a new key, then delegates the one-time plaintext reveal to the caller + * (which opens SaveApiKeyModal). Keeps its own submitting state so double + * confirms can't mint twice. + */ +export const CreateApiKeyModal = ({ + open, + onOpenChange, + onCreate, + isCreating, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onCreate: (label: string) => void; + isCreating: boolean; +}) => { + const [label, setLabel] = useState(""); + + // Reset the field whenever the modal (re)opens. + useEffect(() => { + if (open) setLabel(""); + }, [open]); + + const trimmed = label.trim(); + + return ( + onCreate(trimmed)} + > +
+ + setLabel(e.target.value)} + /> +
+
+ ); +}; diff --git a/apps/dashboard/features/api-keys/components/SaveApiKeyModal.tsx b/apps/dashboard/features/api-keys/components/SaveApiKeyModal.tsx new file mode 100644 index 000000000..c379fb7f7 --- /dev/null +++ b/apps/dashboard/features/api-keys/components/SaveApiKeyModal.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { Check, Copy } from "lucide-react"; +import { useState } from "react"; + +import { Modal } from "@/shared/components/design-system/modal/Modal"; +import { cn } from "@/shared/utils/cn"; + +/** + * Shown once, right after a key is created: the plaintext is never retrievable + * again, so the copy affordance is prominent and closing is a deliberate + * confirm ("I've saved it"). + */ +export const SaveApiKeyModal = ({ + open, + onOpenChange, + token, + label, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + token: string; + label: string; +}) => { + const [copied, setCopied] = useState(false); + + const copy = async () => { + await navigator.clipboard.writeText(token); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + onOpenChange(false)} + > +
+
+ + {token} + + +
+
+
+ ); +}; diff --git a/apps/dashboard/features/api-keys/hooks/useApiKeys.ts b/apps/dashboard/features/api-keys/hooks/useApiKeys.ts new file mode 100644 index 000000000..438e52142 --- /dev/null +++ b/apps/dashboard/features/api-keys/hooks/useApiKeys.ts @@ -0,0 +1,50 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + createApiKey, + listApiKeys, + revokeApiKey, + type CreatedApiKey, + type UserApiKey, +} from "@/shared/services/user-api/apiKeysClient"; + +const QUERY_KEY = ["user-api", "api-keys"] as const; + +/** + * API-key list + create/revoke against the User API (session-authenticated + * via the /api/user proxy). `enabled` gates the fetch on an authenticated + * session so we don't fire a guaranteed 401 before sign-in. + */ +export const useApiKeys = (enabled: boolean) => { + const queryClient = useQueryClient(); + + const query = useQuery({ + queryKey: QUERY_KEY, + queryFn: async () => (await listApiKeys()).items, + enabled, + }); + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + + const create = useMutation({ + mutationFn: (label: string) => createApiKey(label), + onSuccess: invalidate, + }); + + const revoke = useMutation({ + mutationFn: (id: string) => revokeApiKey(id), + onSuccess: invalidate, + }); + + return { + keys: (query.data ?? []) as UserApiKey[], + isLoading: query.isLoading, + isError: query.isError, + refetch: query.refetch, + create, + revoke, + }; +}; diff --git a/apps/dashboard/features/api-keys/index.ts b/apps/dashboard/features/api-keys/index.ts new file mode 100644 index 000000000..471c1a87d --- /dev/null +++ b/apps/dashboard/features/api-keys/index.ts @@ -0,0 +1 @@ +export { ApiKeysManager } from "./ApiKeysManager"; diff --git a/apps/dashboard/shared/services/user-api/apiKeysClient.ts b/apps/dashboard/shared/services/user-api/apiKeysClient.ts new file mode 100644 index 000000000..fdddbde37 --- /dev/null +++ b/apps/dashboard/shared/services/user-api/apiKeysClient.ts @@ -0,0 +1,49 @@ +// Hand-written client for the User API self-service API-keys surface, reached +// through the same-origin /api/user proxy with the better-auth session cookie. +// The User API is not part of Gateful's OpenAPI, so it has no generated SDK. + +const BASE = "/api/user/me/api-keys"; + +export type UserApiKey = { + id: string; + label: string; + createdAt: string; + revokedAt: string | null; + /** From Authful; null when never used or Authful was unreachable. */ + lastUsedAt: string | null; +}; + +/** Create returns the plaintext exactly once — never retrievable again. */ +export type CreatedApiKey = UserApiKey & { token: string }; + +export class ApiKeysRequestError extends Error { + constructor(readonly status: number) { + super(`api-keys request failed with status ${status}`); + this.name = "ApiKeysRequestError"; + } +} + +const request = async (url: string, init?: RequestInit): Promise => { + const res = await fetch(url, { + ...init, + credentials: "include", + headers: { + ...(init?.body ? { "content-type": "application/json" } : {}), + ...init?.headers, + }, + }); + if (!res.ok) throw new ApiKeysRequestError(res.status); + if (res.status === 204) return undefined as T; + return (await res.json()) as T; +}; + +export const listApiKeys = () => request<{ items: UserApiKey[] }>(BASE); + +export const createApiKey = (label: string) => + request(BASE, { + method: "POST", + body: JSON.stringify({ label }), + }); + +export const revokeApiKey = (id: string) => + request(`${BASE}/${id}`, { method: "DELETE" }); diff --git a/apps/dashboard/widgets/HeaderMobile.tsx b/apps/dashboard/widgets/HeaderMobile.tsx index 2360e3775..280d62597 100644 --- a/apps/dashboard/widgets/HeaderMobile.tsx +++ b/apps/dashboard/widgets/HeaderMobile.tsx @@ -5,6 +5,7 @@ import { Menu, BarChart4, BookOpen, + Code, Heart, HelpCircle, Bell, @@ -45,6 +46,12 @@ export const HeaderMobile = ({ label: "Alerts", icon: Bell, }, + { + page: "api-keys", + isGlobal: true, + label: "API", + icon: Code, + }, ], [], ); diff --git a/apps/dashboard/widgets/HeaderSidebar.tsx b/apps/dashboard/widgets/HeaderSidebar.tsx index a27be5c3e..514513cf2 100644 --- a/apps/dashboard/widgets/HeaderSidebar.tsx +++ b/apps/dashboard/widgets/HeaderSidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { BarChart4, Bell } from "lucide-react"; +import { BarChart4, Bell, Code } from "lucide-react"; import Link from "next/link"; import { useMemo } from "react"; @@ -26,6 +26,12 @@ export const HeaderSidebar = () => { icon: Bell, isGlobal: true, }, + { + page: "api-keys", + label: "API", + icon: Code, + isGlobal: true, + }, ], [], );