diff --git a/apps/web/components/settings/api-keys.tsx b/apps/web/components/settings/api-keys.tsx new file mode 100644 index 00000000..3c591372 --- /dev/null +++ b/apps/web/components/settings/api-keys.tsx @@ -0,0 +1,672 @@ +"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, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@ui/components/dialog" +import { Input } from "@ui/components/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ui/components/select" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { Check, Copy, KeyRound, Loader2, Plus, Trash2 } 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" + +function SettingsCard({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function formatKeyPreview(start: string | null | undefined): string { + if (!start) return "sm_••••••••" + const TYPICAL_TOTAL_LEN = 48 + const TAIL_STARS_MIN = 8 + const tailStars = Math.max(TAIL_STARS_MIN, TYPICAL_TOTAL_LEN - start.length) + return `${start}${"•".repeat(Math.min(tailStars, 16))}` +} + +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() { + 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. +

+
+ +
+ + + {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. +

+
+ +
+ ) : ( +
    + {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. +

+
+ + + + + ) : ( + <> + + + 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="h-10 border-white/[0.08] bg-[#0D121A] text-[#FAFAFA] placeholder:text-[#6B6B6B]" + autoComplete="off" + onKeyDown={(e) => { + if (e.key === "Enter" && !createKeyMutation.isPending) { + createKeyMutation.mutate() + } + }} + /> +
+
+ + Expires + + +
+
+ + + + + + )} +
+
+ + {/* 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="rounded-full bg-[#C73B1B] 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 7694c03a..9b03bddd 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,7 @@ 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 f160e612..5ffda827 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"),