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 */}
+
+
+ {/* 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 (
-
+