From 45585b4c0fc98a18d2ae652ad7cef3af1fe029ba Mon Sep 17 00:00:00 2001 From: Dhravya Shah Date: Fri, 7 Aug 2026 12:44:40 -0700 Subject: [PATCH] feat(web): add API Keys management in settings (#1426) Co-authored-by: Mahesh Sanikommu --- apps/web/components/settings/api-keys.tsx | 669 ++++++++++++++++++ .../components/settings/settings-content.tsx | 12 + apps/web/lib/analytics.ts | 8 +- packages/ui/components/alert-dialog.tsx | 7 +- 4 files changed, 693 insertions(+), 3 deletions(-) create mode 100644 apps/web/components/settings/api-keys.tsx diff --git a/apps/web/components/settings/api-keys.tsx b/apps/web/components/settings/api-keys.tsx new file mode 100644 index 000000000..30d7f0af0 --- /dev/null +++ b/apps/web/components/settings/api-keys.tsx @@ -0,0 +1,669 @@ +"use client" + +import { dmSans125ClassName } from "@/lib/fonts" +import { formatRelativeTime } from "@/components/settings/sync-utils" +import { cn } from "@lib/utils" +import { useAuth } from "@lib/auth-context" +import { authClient } from "@lib/auth" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@ui/components/alert-dialog" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@ui/components/dialog" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ui/components/select" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { PillButton } from "../integrations/install-steps" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { + Check, + Copy, + KeyRound, + Loader2, + Plus, + Trash2, + XIcon, +} from "lucide-react" +import { useCallback, useId, useState } from "react" +import { toast } from "sonner" + +type ListedApiKey = { + id: string + name: string | null + start: string | null + key?: string | null + createdAt: string + expiresAt: string | null + lastRequest: string | null + enabled: boolean + isScoped: boolean + containerTags: string[] | null + smType: string | null + smClient: string | null +} + +const EXPIRY_OPTIONS = [ + { label: "1 year", value: "365" }, + { label: "6 months", value: "180" }, + { label: "30 days", value: "30" }, + { label: "7 days", value: "7" }, + { label: "Never", value: "0" }, +] as const + +const API_URL = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + +const MODAL_SHADOW = + "0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset" + +const pillInputClass = + "h-9 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3.5 text-[13px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#334155]" + +function ModalClose() { + return ( + + + Close + + ) +} + +function SettingsCard({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function formatKeyPreview(start: string | null | undefined): string { + if (!start) return "sm_••••••" + return `${start}••••••` +} + +function isExpired(expiresAt: string | null): boolean { + if (!expiresAt) return false + return new Date(expiresAt).getTime() <= Date.now() +} + +function formatExpiresLabel(expiresAt: string | null): string { + if (!expiresAt) return "Never" + const date = new Date(expiresAt) + if (Number.isNaN(date.getTime())) return "—" + if (date.getTime() <= Date.now()) return "Expired" + return date.toLocaleDateString() +} + +function extractCreatedKey(result: unknown): string { + if (!result || typeof result !== "object") { + throw new Error("API key missing from response") + } + const r = result as { + key?: string + data?: { key?: string } + error?: { message?: string } + } + if (r.error?.message) throw new Error(r.error.message) + const key = r.key ?? r.data?.key + if (!key) throw new Error("API key missing from response") + return key +} + +export default function ApiKeys({ + dialogPortalContainer, +}: { + dialogPortalContainer?: HTMLElement | null +}) { + const { org } = useAuth() + const queryClient = useQueryClient() + const nameId = useId() + + const [createOpen, setCreateOpen] = useState(false) + const [keyName, setKeyName] = useState("") + const [expiryDays, setExpiryDays] = useState("365") + const [createdKey, setCreatedKey] = useState(null) + const [copied, setCopied] = useState(false) + const [revokeTarget, setRevokeTarget] = useState(null) + + const { + data: keys = [], + isLoading, + isError, + refetch, + } = useQuery({ + queryKey: ["api-keys", org?.id, "manage"], + queryFn: async () => { + if (!org?.id) return [] + const res = await fetch(`${API_URL}/v3/auth/keys?type=keys`, { + credentials: "include", + }) + if (!res.ok) { + throw new Error("Failed to load API keys") + } + const data = (await res.json()) as { keys?: ListedApiKey[] } + return data.keys ?? [] + }, + enabled: !!org?.id, + staleTime: 30 * 1000, + }) + + const invalidateKeys = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ["api-keys", org?.id] }) + queryClient.invalidateQueries({ queryKey: ["api-keys", org?.id, "manage"] }) + }, [org?.id, queryClient]) + + const createKeyMutation = useMutation({ + mutationFn: async () => { + if (!org?.id) throw new Error("Organization is required") + const days = Number(expiryDays) + const expiresIn = days > 0 ? days * 24 * 60 * 60 : undefined + const name = + keyName.trim() || `key-${new Date().toISOString().slice(0, 10)}` + + const res = await authClient.apiKey.create({ + name, + expiresIn, + metadata: { organizationId: org.id }, + prefix: `sm_${org.id}_`, + }) + return extractCreatedKey(res) + }, + onSuccess: (key) => { + setCreatedKey(key) + setKeyName("") + setExpiryDays("365") + setCopied(false) + invalidateKeys() + toast.success("API key created") + }, + onError: (error) => { + toast.error("Failed to create API key", { + description: error instanceof Error ? error.message : "Unknown error", + }) + }, + }) + + const revokeKeyMutation = useMutation({ + mutationFn: async (keyId: string) => { + const res = await authClient.apiKey.delete({ keyId }) + if (res && typeof res === "object" && "error" in res && res.error) { + const err = res.error as { message?: string } + throw new Error(err.message ?? "Failed to revoke API key") + } + }, + onSuccess: () => { + setRevokeTarget(null) + invalidateKeys() + toast.success("API key revoked") + }, + onError: (error) => { + toast.error("Failed to revoke API key", { + description: error instanceof Error ? error.message : "Unknown error", + }) + }, + }) + + const handleCopy = async (value: string) => { + try { + await navigator.clipboard.writeText(value) + setCopied(true) + toast.success("API key copied to clipboard") + setTimeout(() => setCopied(false), 2000) + } catch { + toast.error("Failed to copy API key") + } + } + + const resetCreateState = () => { + setCreateOpen(false) + setCreatedKey(null) + setKeyName("") + setExpiryDays("365") + setCopied(false) + } + + const handleCreateOpenChange = (open: boolean) => { + if (!open) { + resetCreateState() + return + } + setCreateOpen(true) + } + + return ( +
+
+
+
+

+ API Keys +

+

+ Create keys for the Supermemory API, SDKs, and custom + integrations. Keys are shown once at creation. +

+
+ { + setCreatedKey(null) + setCreateOpen(true) + }} + > + + Create key + +
+ + {isLoading ? ( + +
+ + + Loading keys… + +
+
+ ) : isError ? ( + +
+

+ Couldn't load API keys. +

+ +
+
+ ) : keys.length === 0 ? ( + +
+
+ +
+
+

+ No API keys yet +

+

+ Create your first key to use the API programmatically or + connect custom tools. +

+
+
+ { + setCreatedKey(null) + setCreateOpen(true) + }} + > + + Create key + +
+
+
+ ) : ( +
    + {keys.map((key) => { + const expired = isExpired(key.expiresAt) + const disabled = key.enabled === false || expired + return ( +
  • +
    + +
    +
    +
    +

    + {key.name?.trim() || "Unnamed key"} +

    + {key.isScoped && ( + + Scoped + + )} + {disabled && ( + + {expired ? "Expired" : "Disabled"} + + )} +
    +
    + + {formatKeyPreview(key.start ?? key.key)} + + · + Created {formatRelativeTime(key.createdAt)} + · + + Last used{" "} + {key.lastRequest + ? formatRelativeTime(key.lastRequest) + : "never"} + + · + Expires {formatExpiresLabel(key.expiresAt)} +
    +
    + +
  • + ) + })} +
+ )} + +

+ Need docs?{" "} + + API quickstart + + {" · "} + + Developer console + +

+
+ + {/* Create / reveal dialog */} + + + {createdKey ? ( + <> +
+ + + API key created + +

+ Copy this key now. You won't be able to see it again. +

+
+ +
+
+ + {createdKey} + +

+ Store it somewhere safe. For security, the full key is only + shown once. +

+
+
+ + handleCopy(createdKey)}> + {copied ? ( + + ) : ( + + )} + {copied ? "Copied" : "Copy key"} + +
+ + ) : ( + <> +
+ + + Create API key + +

+ This key has full access to your organization's + Supermemory data via the API. +

+
+ +
+
+
+ + setKeyName(e.target.value)} + placeholder="e.g. production, local-dev" + className={pillInputClass} + autoComplete="off" + onKeyDown={(e) => { + if (e.key === "Enter" && !createKeyMutation.isPending) { + createKeyMutation.mutate() + } + }} + /> +
+
+ + Expires + + +
+
+
+ + createKeyMutation.mutate()} + disabled={createKeyMutation.isPending || !org?.id} + > + {createKeyMutation.isPending && ( + + )} + {createKeyMutation.isPending ? "Creating…" : "Create"} + +
+ + )} +
+
+ + {/* Revoke confirmation */} + { + if (!open && !revokeKeyMutation.isPending) setRevokeTarget(null) + }} + > + + + + Revoke API key? + + + {revokeTarget?.name?.trim() + ? `"${revokeTarget.name}" will stop working immediately.` + : "This key will stop working immediately."}{" "} + Any apps or scripts still using it will fail. This cannot be + undone. + + + + + Cancel + + { + e.preventDefault() + if (revokeTarget) revokeKeyMutation.mutate(revokeTarget.id) + }} + className="h-9 rounded-full bg-[#C73B1B] px-4 text-[13px] font-semibold text-white hover:bg-[#A83217]" + > + {revokeKeyMutation.isPending ? ( + + + Revoking… + + ) : ( + "Revoke key" + )} + + + + +
+ ) +} diff --git a/apps/web/components/settings/settings-content.tsx b/apps/web/components/settings/settings-content.tsx index 7694c03ac..d5147ec7f 100644 --- a/apps/web/components/settings/settings-content.tsx +++ b/apps/web/components/settings/settings-content.tsx @@ -11,6 +11,7 @@ import Billing from "@/components/settings/billing" import Integrations from "@/components/settings/integrations" import ConnectionsMCP from "@/components/settings/connections-mcp" import Support from "@/components/settings/support" +import ApiKeys from "@/components/settings/api-keys" import { ErrorBoundary } from "@/components/error-boundary" import { useRouter } from "next/navigation" import { useQuery } from "@tanstack/react-query" @@ -26,6 +27,7 @@ import { User as UserIcon, Zap, HelpCircle, + KeyRound, CreditCard, ShieldAlert, ChevronRight, @@ -49,6 +51,7 @@ import { SettingsOrgSwitcher } from "@/components/settings/settings-org-switcher export const TABS = [ "account", "billing", + "api-keys", "integrations", "connections", "support", @@ -75,6 +78,12 @@ const NAV_ITEMS: NavItem[] = [ description: "Plan, usage and payments", icon: , }, + { + id: "api-keys", + label: "API Keys", + description: "Create and manage API keys", + icon: , + }, { id: "integrations", label: "Integrations", @@ -481,6 +490,9 @@ export function SettingsContent({ )} {activeTab === "billing" && } + {activeTab === "api-keys" && ( + + )} {activeTab === "integrations" && } {activeTab === "connections" && } {activeTab === "support" && } diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts index f160e6123..5ffda827c 100644 --- a/apps/web/lib/analytics.ts +++ b/apps/web/lib/analytics.ts @@ -222,7 +222,13 @@ export const analytics = { // settings / spaces / docs analytics settingsTabChanged: (props: { - tab: "account" | "billing" | "integrations" | "connections" | "support" + tab: + | "account" + | "billing" + | "api-keys" + | "integrations" + | "connections" + | "support" }) => safeCapture("settings_tab_changed", props), spaceCreated: () => safeCapture("space_created"), diff --git a/packages/ui/components/alert-dialog.tsx b/packages/ui/components/alert-dialog.tsx index 619f85164..e97d8ed19 100644 --- a/packages/ui/components/alert-dialog.tsx +++ b/packages/ui/components/alert-dialog.tsx @@ -45,10 +45,13 @@ function AlertDialogOverlay({ function AlertDialogContent({ className, + portalContainer, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + portalContainer?: HTMLElement | null +}) { return ( - +