From a7ac0b8606095b9c9ab300f52ef3b07f71e3676c Mon Sep 17 00:00:00 2001 From: Bruno Date: Fri, 10 Jul 2026 11:39:06 -0300 Subject: [PATCH 1/5] feat(dashboard): add the API Keys page (create, list, revoke, connect AI agent) Signed-in users manage personal API keys for the MCP server / public API: create (name -> one-time plaintext reveal), list (name/status/created/last used), and revoke, plus a Connect-your-AI-agent section with copy-paste install commands for Claude Code, Cursor, and Codex. Talks to the User API via the /api/user proxy (session cookie); gated behind sign-in and reachable from the new API sidebar entry. Co-Authored-By: Claude Fable 5 --- .changeset/dashboard-api-keys-page.md | 5 + .../app/[daoId]/(main)/api-keys/page.tsx | 26 ++++ .../features/api-keys/ApiKeysManager.tsx | 138 ++++++++++++++++++ .../api-keys/components/ApiKeysTable.tsx | 82 +++++++++++ .../components/ConnectAgentSection.tsx | 83 +++++++++++ .../api-keys/components/CreateApiKeyModal.tsx | 62 ++++++++ .../api-keys/components/SaveApiKeyModal.tsx | 65 +++++++++ .../features/api-keys/hooks/useApiKeys.ts | 50 +++++++ apps/dashboard/features/api-keys/index.ts | 1 + .../shared/services/user-api/apiKeysClient.ts | 49 +++++++ apps/dashboard/widgets/HeaderDAOSidebar.tsx | 9 ++ 11 files changed, 570 insertions(+) create mode 100644 .changeset/dashboard-api-keys-page.md create mode 100644 apps/dashboard/app/[daoId]/(main)/api-keys/page.tsx create mode 100644 apps/dashboard/features/api-keys/ApiKeysManager.tsx create mode 100644 apps/dashboard/features/api-keys/components/ApiKeysTable.tsx create mode 100644 apps/dashboard/features/api-keys/components/ConnectAgentSection.tsx create mode 100644 apps/dashboard/features/api-keys/components/CreateApiKeyModal.tsx create mode 100644 apps/dashboard/features/api-keys/components/SaveApiKeyModal.tsx create mode 100644 apps/dashboard/features/api-keys/hooks/useApiKeys.ts create mode 100644 apps/dashboard/features/api-keys/index.ts create mode 100644 apps/dashboard/shared/services/user-api/apiKeysClient.ts 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/[daoId]/(main)/api-keys/page.tsx b/apps/dashboard/app/[daoId]/(main)/api-keys/page.tsx new file mode 100644 index 000000000..d26849fae --- /dev/null +++ b/apps/dashboard/app/[daoId]/(main)/api-keys/page.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from "next"; + +import { ApiKeysManager } from "@/features/api-keys"; +import type { DaoIdEnum } from "@/shared/types/daos"; + +type Props = { + params: Promise<{ daoId: string }>; +}; + +export async function generateMetadata(props: Props): Promise { + const params = await props.params; + const daoId = params.daoId.toUpperCase() as DaoIdEnum; + const canonicalPath = `/${params.daoId}/api-keys`; + + return { + title: `${daoId} API Keys — Anticapture`, + description: + "Create and manage API keys to query Anticapture from Claude, Cursor, or Codex.", + alternates: { canonical: canonicalPath }, + 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..d96c9e255 --- /dev/null +++ b/apps/dashboard/features/api-keys/ApiKeysManager.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { KeyRound, Plus } from "lucide-react"; +import { 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. + const [created, setCreated] = useState<{ + token: string; + label: string; + } | null>(null); + const [toRevoke, setToRevoke] = useState(null); + + const handleCreate = (label: string) => { + create.mutate(label, { + onSuccess: (key) => { + setCreateOpen(false); + setCreated({ token: key.token, label: key.label }); + }, + }); + }; + + const handleRevoke = () => { + if (!toRevoke) return; + revoke.mutate(toRevoke.id, { onSuccess: () => setToRevoke(null) }); + }; + + // Signed-out: the whole surface is gated behind sign-in. + if (!isAuthed) { + return ( +
+ +
+

API Keys

+

+ Sign in to create keys and query Anticapture from Claude, Cursor, or + Codex. +

+
+ +
+ ); + } + + return ( +
+
+
+ } + title="API Keys" + description="Query Anticapture from Claude, Cursor, or Codex. Just ask in natural language." + /> + +
+ + {isLoading ? ( +

Loading…

+ ) : keys.length === 0 ? ( +
+ No API keys yet. Create one to connect your AI agent. +
+ ) : ( + + )} +
+ + + + + + + + {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..8b646cbc3 --- /dev/null +++ b/apps/dashboard/features/api-keys/components/ApiKeysTable.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { Trash2 } from "lucide-react"; + +import { BadgeStatus } from "@/shared/components/design-system/badges/badge-status/BadgeStatus"; +import type { UserApiKey } from "@/shared/services/user-api/apiKeysClient"; + +const dateFmt = new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "short", + day: "numeric", +}); + +const formatDate = (iso: string | null): string => + iso ? dateFmt.format(new Date(iso)) : "—"; + +const HEADERS = ["Name", "Status", "Created", "Last used", ""] as const; + +export const ApiKeysTable = ({ + keys, + onRevoke, +}: { + keys: UserApiKey[]; + onRevoke: (key: UserApiKey) => void; +}) => { + return ( +
+ + + + {HEADERS.map((h) => ( + + ))} + + + + {keys.map((key) => { + const active = key.revokedAt === null; + return ( + + + + + + + + ); + })} + +
+ {h} +
+ {key.label} + + + {active ? "Active" : "Revoked"} + + + {formatDate(key.createdAt)} + + {formatDate(key.lastUsedAt)} + + {active && ( + + )} +
+
+ ); +}; 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..c85fda3be --- /dev/null +++ b/apps/dashboard/features/api-keys/components/ConnectAgentSection.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { Check, Copy, TerminalSquare } from "lucide-react"; +import { useState } from "react"; + +import { SectionTitle } from "@/shared/components/design-system/section/section-title/SectionTitle"; +import { cn } from "@/shared/utils/cn"; + +const MCP_URL = "https://mcp.anticapture.com/v1"; + +// Per-client install command. The key placeholder is shown verbatim — the +// user pastes their own key (revealed once at creation) in place of it. +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; + +const KEY_PLACEHOLDER = ""; + +export const ConnectAgentSection = () => { + const [client, setClient] = useState("Claude Code"); + const [copied, setCopied] = useState(false); + + const command = CLIENTS[client](KEY_PLACEHOLDER); + + const copy = async () => { + await navigator.clipboard.writeText(command); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+ } + title="Connect your AI agent" + description="Pick your tool and run one command in your terminal. Replace the placeholder with a key you created above." + /> + +
+ {(Object.keys(CLIENTS) as ClientName[]).map((name) => ( + + ))} +
+ +
+ + {command} + + +
+
+ ); +}; 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/HeaderDAOSidebar.tsx b/apps/dashboard/widgets/HeaderDAOSidebar.tsx index a0989e5bf..478183d9f 100644 --- a/apps/dashboard/widgets/HeaderDAOSidebar.tsx +++ b/apps/dashboard/widgets/HeaderDAOSidebar.tsx @@ -11,6 +11,7 @@ import { PieChart, Newspaper, Bomb, + Code2, } from "lucide-react"; import { usePathname } from "next/navigation"; import { useState } from "react"; @@ -86,6 +87,14 @@ export const HeaderDAOSidebar = () => { isCollapsed={isCollapsed} /> )} + {/* User feature (per-account, not per-DAO) — always available. */} + {daoConfig.attackProfitability && daoConfig.attackProfitability.supportsLiquidTreasuryCall && ( Date: Fri, 10 Jul 2026 16:53:39 -0300 Subject: [PATCH 2/5] fix(dashboard): make the sign-in modal actually open and reach better-auth - the three connect-wallet buttons called RainbowKit's openConnectModal directly, so the custom LoginModal never appeared; they now go through useLogin().openLogin - the better-auth client baseURL was missing the service's /api/auth base path, so every auth call (get-session, siwe/nonce, ...) 404'd through the /api/user proxy - the modal shows the ANTICAPTURE wordmark per the Figma (whitelabel keeps the neutral mark); the wordmark SVG now uses currentColor, with the watermark call-site compensating for its previously baked-in opacity Co-Authored-By: Claude Opus 4.8 --- .../shared/components/auth/LoginModal.tsx | 12 ++++++- .../components/icons/AnticaptureWatermark.tsx | 32 +++++++++++-------- .../components/wallet/ConnectWallet.tsx | 5 +-- .../components/wallet/ConnectWalletCustom.tsx | 5 +-- .../wallet/WhitelabelConnectWallet.tsx | 5 +-- apps/dashboard/shared/services/auth/client.ts | 11 ++++--- 6 files changed, 44 insertions(+), 26 deletions(-) diff --git a/apps/dashboard/shared/components/auth/LoginModal.tsx b/apps/dashboard/shared/components/auth/LoginModal.tsx index 68cd8e754..3f60fd2c6 100644 --- a/apps/dashboard/shared/components/auth/LoginModal.tsx +++ b/apps/dashboard/shared/components/auth/LoginModal.tsx @@ -10,6 +10,7 @@ import { Input } from "@/shared/components/design-system/form/fields/input/Input import { DefaultLink } from "@/shared/components/design-system/links/default-link/DefaultLink"; import { Logo } from "@/shared/components/design-system/logo/Logo"; import { Modal } from "@/shared/components/design-system/modal/Modal"; +import { AnticaptureLogo } from "@/shared/components/icons/AnticaptureWatermark"; import { GoogleIcon } from "@/shared/components/icons/GoogleIcon"; import { authClient } from "@/shared/services/auth/client"; import { useEmailLogin } from "@/shared/services/auth/useEmailLogin"; @@ -81,7 +82,16 @@ export const LoginModal = ({ className="max-w-100" bodyClassName="flex flex-col items-center gap-6 p-5" > - + {/* Whitelabel keeps the neutral mark; the main app shows the wordmark. */} + {isWhitelabel ? ( + + ) : ( + + )} {view === "sent" ? (
diff --git a/apps/dashboard/shared/components/icons/AnticaptureWatermark.tsx b/apps/dashboard/shared/components/icons/AnticaptureWatermark.tsx index 17bfdf732..0c0cf2859 100644 --- a/apps/dashboard/shared/components/icons/AnticaptureWatermark.tsx +++ b/apps/dashboard/shared/components/icons/AnticaptureWatermark.tsx @@ -12,54 +12,54 @@ export const AnticaptureLogo = (props: SVGProps) => { fill="none" {...props} > - + @@ -82,7 +82,11 @@ export const AnticaptureWatermark = ({ props.className, ])} > - + {/* opacity-[0.08] preserves the old 0.4 × 0.2 (baked-in group opacity) + compound now that the SVG uses currentColor with no own opacity. */} +
); }; diff --git a/apps/dashboard/shared/components/wallet/ConnectWallet.tsx b/apps/dashboard/shared/components/wallet/ConnectWallet.tsx index 445dbcd6d..934873372 100644 --- a/apps/dashboard/shared/components/wallet/ConnectWallet.tsx +++ b/apps/dashboard/shared/components/wallet/ConnectWallet.tsx @@ -7,6 +7,7 @@ import Image from "next/image"; import { Button } from "@/shared/components"; import { Tooltip } from "@/shared/components/design-system/tooltips"; +import { useLogin } from "@/shared/services/auth/LoginProvider"; import { cn } from "@/shared/utils"; const Jazzicon = dynamic( @@ -23,6 +24,7 @@ export const ConnectWallet = ({ label?: string; className?: string; }) => { + const { openLogin } = useLogin(); return ( {({ @@ -30,7 +32,6 @@ export const ConnectWallet = ({ chain, openAccountModal, openChainModal, - openConnectModal, authenticationStatus, mounted, }) => { @@ -56,7 +57,7 @@ export const ConnectWallet = ({ if (!connected) { return ( + + + + + + ); +}; export const ApiKeysTable = ({ keys, @@ -23,60 +66,66 @@ export const ApiKeysTable = ({ 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 ( -
- - - - {HEADERS.map((h) => ( - - ))} - - - - {keys.map((key) => { - const active = key.revokedAt === null; - return ( - - - - - - - - ); - })} - -
- {h} -
- {key.label} - - - {active ? "Active" : "Revoked"} - - - {formatDate(key.createdAt)} - - {formatDate(key.lastUsedAt)} - - {active && ( - - )} -
-
+ ); }; diff --git a/apps/dashboard/features/api-keys/components/ConnectAgentSection.tsx b/apps/dashboard/features/api-keys/components/ConnectAgentSection.tsx index c85fda3be..62ca86ca7 100644 --- a/apps/dashboard/features/api-keys/components/ConnectAgentSection.tsx +++ b/apps/dashboard/features/api-keys/components/ConnectAgentSection.tsx @@ -1,15 +1,22 @@ "use client"; -import { Check, Copy, TerminalSquare } from "lucide-react"; -import { useState } from "react"; +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. The key placeholder is shown verbatim — the -// user pastes their own key (revealed once at creation) in place of it. +// Per-client install command. const CLIENTS = { "Claude Code": (key: string) => `claude mcp add anticapture --transport http ${MCP_URL} --header "X-API-KEY: ${key}"`, @@ -21,62 +28,142 @@ const CLIENTS = { 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 = ""; -export const ConnectAgentSection = () => { +/** + * "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); - const command = CLIENTS[client](KEY_PLACEHOLDER); + // 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(command); + await navigator.clipboard.writeText(CLIENTS[client](copiedKey)); setCopied(true); setTimeout(() => setCopied(false), 2000); }; return ( -
+
} + icon={} title="Connect your AI agent" - description="Pick your tool and run one command in your terminal. Replace the placeholder with a key you created above." + description="Pick your tool and run one command in your terminal. Your key is already in it." /> -
- {(Object.keys(CLIENTS) as ClientName[]).map((name) => ( - - ))} -
+
+
+
+ {(Object.keys(CLIENTS) as ClientName[]).map((name) => ( + + ))} +
-
- - {command} - - + + + {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/widgets/HeaderDAOSidebar.tsx b/apps/dashboard/widgets/HeaderDAOSidebar.tsx index 478183d9f..a0989e5bf 100644 --- a/apps/dashboard/widgets/HeaderDAOSidebar.tsx +++ b/apps/dashboard/widgets/HeaderDAOSidebar.tsx @@ -11,7 +11,6 @@ import { PieChart, Newspaper, Bomb, - Code2, } from "lucide-react"; import { usePathname } from "next/navigation"; import { useState } from "react"; @@ -87,14 +86,6 @@ export const HeaderDAOSidebar = () => { isCollapsed={isCollapsed} /> )} - {/* User feature (per-account, not per-DAO) — always available. */} - {daoConfig.attackProfitability && daoConfig.attackProfitability.supportsLiquidTreasuryCall && ( { icon: Bell, isGlobal: true, }, + { + page: "api-keys", + label: "API", + icon: Code, + isGlobal: true, + }, ], [], ); From f427f56dda5d21e60918ed526513214c79e9d56d Mon Sep 17 00:00:00 2001 From: Bruno Date: Fri, 10 Jul 2026 17:19:23 -0300 Subject: [PATCH 4/5] feat(dashboard): open the sign-in modal directly on the API Keys page Signed-out visitors no longer see an interstitial sign-in screen: the login modal opens once over the page itself, and gated actions (Create key) re-open it if dismissed. Co-Authored-By: Claude Opus 4.8 --- .../features/api-keys/ApiKeysManager.tsx | 31 +++++++------------ 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/apps/dashboard/features/api-keys/ApiKeysManager.tsx b/apps/dashboard/features/api-keys/ApiKeysManager.tsx index d12e279a9..a84f5158d 100644 --- a/apps/dashboard/features/api-keys/ApiKeysManager.tsx +++ b/apps/dashboard/features/api-keys/ApiKeysManager.tsx @@ -1,7 +1,7 @@ "use client"; import { Code, Plus } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Button } from "@/shared/components"; import { DividerDefault } from "@/shared/components/design-system/divider/DividerDefault"; @@ -54,24 +54,15 @@ export const ApiKeysManager = () => { revoke.mutate(toRevoke.id, { onSuccess: () => setToRevoke(null) }); }; - // Signed-out: the whole surface is gated behind sign-in. - if (!isAuthed) { - return ( -
- -
-

API Keys

-

- Sign in to create keys and query Anticapture from Claude, Cursor, or - Codex. -

-
- -
- ); - } + // Signed-out visitors get the sign-in modal right away, over the page — + // no interstitial screen. Once only, so dismissing it isn't a fight; any + // gated action (Create key) re-opens it. + const autoOpenedLogin = useRef(false); + useEffect(() => { + if (isPending || session || autoOpenedLogin.current) return; + autoOpenedLogin.current = true; + openLogin(); + }, [isPending, session, openLogin]); return (
@@ -86,7 +77,7 @@ export const ApiKeysManager = () => { variant="primary" size="md" className="shrink-0" - onClick={() => setCreateOpen(true)} + onClick={() => (isAuthed ? setCreateOpen(true) : openLogin())} > Create key From 162a541ad72026847328541c8251a688702a999a Mon Sep 17 00:00:00 2001 From: Bruno Date: Sat, 11 Jul 2026 10:22:02 -0300 Subject: [PATCH 5/5] fix(dashboard): don't re-pop the sign-in modal when signing out on the API Keys page The auto-open gate is consumed on the first session resolution whether or not the visitor is signed in, so a later signed-in to signed-out transition on the page no longer re-opens the modal uninvited. Co-Authored-By: Claude Opus 4.8 --- apps/dashboard/features/api-keys/ApiKeysManager.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/dashboard/features/api-keys/ApiKeysManager.tsx b/apps/dashboard/features/api-keys/ApiKeysManager.tsx index a84f5158d..4e4c6bcf5 100644 --- a/apps/dashboard/features/api-keys/ApiKeysManager.tsx +++ b/apps/dashboard/features/api-keys/ApiKeysManager.tsx @@ -54,14 +54,16 @@ export const ApiKeysManager = () => { revoke.mutate(toRevoke.id, { onSuccess: () => setToRevoke(null) }); }; - // Signed-out visitors get the sign-in modal right away, over the page — - // no interstitial screen. Once only, so dismissing it isn't a fight; any - // gated action (Create key) re-opens it. + // 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 || session || autoOpenedLogin.current) return; + if (isPending || autoOpenedLogin.current) return; autoOpenedLogin.current = true; - openLogin(); + if (!session) openLogin(); }, [isPending, session, openLogin]); return (