mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Refine Codex OAuth and integration status
This commit is contained in:
parent
f5383b6671
commit
c7d191e787
9 changed files with 363 additions and 214 deletions
1
apps/web/app/auth/agent-connect/page.tsx
Normal file
1
apps/web/app/auth/agent-connect/page.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../connect/page"
|
||||
|
|
@ -99,7 +99,7 @@ const PLUGIN_INFO: Record<string, PluginInfo> = {
|
|||
"Captures coding decisions and patterns automatically",
|
||||
"Builds persistent user profile across projects",
|
||||
],
|
||||
icon: "/images/plugins/codex.svg",
|
||||
icon: "/images/plugins/codex.png",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -199,6 +199,7 @@ function AuthConnectContent() {
|
|||
|
||||
const redirectUrl = new URL(callback)
|
||||
redirectUrl.searchParams.set("apikey", data.key)
|
||||
redirectUrl.searchParams.set("api_url", API_URL)
|
||||
window.location.href = redirectUrl.toString()
|
||||
} catch (err) {
|
||||
console.error("Failed to get API key:", err)
|
||||
|
|
|
|||
|
|
@ -28,15 +28,7 @@ export function PluginPreview({ parsed }: { parsed: ParsedPluginDocument }) {
|
|||
)}
|
||||
{parsed.pluginLabel}
|
||||
</span>
|
||||
<p className="text-[11px] text-[#737373] truncate">
|
||||
{parsed.formatLabel}
|
||||
</p>
|
||||
</div>
|
||||
{parsed.identifierValue && (
|
||||
<p className="text-[10px] text-[#737373] truncate max-w-[45%]">
|
||||
{parsed.identifierValue}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-[6px]">
|
||||
<p
|
||||
|
|
|
|||
|
|
@ -47,24 +47,6 @@ function PluginHeader({ parsed }: { parsed: ParsedPluginDocument }) {
|
|||
>
|
||||
{parsed.pluginLabel}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex items-center rounded-full border border-[#1F242C] bg-[#0F1318] px-2.5 py-1 text-[11px] font-medium text-[#B7BDC7]",
|
||||
)}
|
||||
>
|
||||
{parsed.formatLabel}
|
||||
</span>
|
||||
{parsed.identifierLabel && parsed.identifierValue && (
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex items-center rounded-full border border-[#1F242C] bg-[#0F1318] px-2.5 py-1 text-[11px] font-medium text-[#8E97A3]",
|
||||
)}
|
||||
>
|
||||
{parsed.identifierLabel}: {parsed.identifierValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import type { UseMutationResult } from "@tanstack/react-query"
|
|||
import { toast } from "sonner"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { parsePluginDocument } from "@/lib/plugin-document"
|
||||
import { PluginDetails } from "./plugin-details"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
|
@ -270,13 +269,8 @@ export function DocumentModal({
|
|||
],
|
||||
)
|
||||
|
||||
const hasPluginInsights =
|
||||
pluginDocument &&
|
||||
pluginDocument.kind !== "claude-code-doc" &&
|
||||
pluginDocument.kind !== "openclaw-session"
|
||||
const hasDocumentInsights = Boolean(
|
||||
hasPluginInsights ||
|
||||
_document?.summary ||
|
||||
_document?.summary ||
|
||||
pluginDocument?.summary ||
|
||||
(_document?.memoryEntries && _document.memoryEntries.length > 0),
|
||||
)
|
||||
|
|
@ -303,7 +297,6 @@ export function DocumentModal({
|
|||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{hasPluginInsights && <PluginDetails parsed={pluginDocument} />}
|
||||
{_document && (_document.summary || pluginDocument?.summary) && (
|
||||
<DocumentSummary
|
||||
memoryEntries={_document.memoryEntries}
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
import Image from "next/image"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import type { ParsedPluginDocument } from "@/lib/plugin-document"
|
||||
|
||||
function DetailPill({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-[12px] border border-[#1E232B] bg-[#0F1318] px-3 py-2">
|
||||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] uppercase tracking-[0.08em] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1 text-[13px] text-[#F3F4F6] break-all">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PluginDetails({ parsed }: { parsed: ParsedPluginDocument }) {
|
||||
return (
|
||||
<div className="bg-[#14161A] p-3 rounded-[14px] space-y-3 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[16px] font-semibold text-[#FAFAFA] leading-[125%]">
|
||||
Details
|
||||
</p>
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex items-center gap-1.5 text-[12px] font-medium text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{parsed.pluginIconSrc && (
|
||||
<Image
|
||||
src={parsed.pluginIconSrc}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="rounded-[3px]"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{parsed.pluginLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<DetailPill label="Format" value={parsed.formatLabel} />
|
||||
{parsed.identifierLabel && parsed.identifierValue && (
|
||||
<DetailPill
|
||||
label={parsed.identifierLabel}
|
||||
value={parsed.identifierValue}
|
||||
/>
|
||||
)}
|
||||
{parsed.clientLabel && parsed.clientValue && (
|
||||
<DetailPill label={parsed.clientLabel} value={parsed.clientValue} />
|
||||
)}
|
||||
</div>
|
||||
{parsed.artifacts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[11px] font-medium uppercase tracking-[0.08em] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Outputs
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{parsed.artifacts.map((artifact, index) => (
|
||||
<DetailPill
|
||||
key={`${artifact.label}-${artifact.value}-${index}`}
|
||||
label={artifact.label}
|
||||
value={artifact.value}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -23,7 +23,6 @@ import {
|
|||
ArrowRight,
|
||||
BookOpen,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Loader,
|
||||
Search,
|
||||
X,
|
||||
|
|
@ -46,11 +45,11 @@ import {
|
|||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { toast } from "sonner"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
|
||||
import {
|
||||
PLUGIN_CATALOG,
|
||||
FREE_TIER_PLUGIN_IDS,
|
||||
isFreeTierPlugin,
|
||||
normalizePluginClientId,
|
||||
type InstallStep,
|
||||
} from "@/lib/plugin-catalog"
|
||||
import { INSET, InstallSteps, PillButton } from "./integrations/install-steps"
|
||||
|
|
@ -66,6 +65,16 @@ interface ConnectedKey {
|
|||
pluginId: string
|
||||
}
|
||||
|
||||
type ListedApiKey = {
|
||||
id: string
|
||||
name?: string | null
|
||||
createdAt?: string
|
||||
enabled?: boolean
|
||||
lastRequest?: string | null
|
||||
metadata: string | Record<string, unknown> | null
|
||||
start?: string | null
|
||||
}
|
||||
|
||||
type ItemKind = "plugin" | "connector" | "client" | "mcp-client" | "import"
|
||||
|
||||
type MCPClientKey =
|
||||
|
|
@ -487,56 +496,20 @@ function DisconnectButton({ onConfirm }: { onConfirm: () => void }) {
|
|||
)
|
||||
}
|
||||
|
||||
function ConnectedPill({
|
||||
keys,
|
||||
onRevoke,
|
||||
}: {
|
||||
keys: ConnectedKey[]
|
||||
onRevoke: (keyId: string) => void
|
||||
}) {
|
||||
function ConnectedButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex h-8 min-w-[104px] shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-medium text-[#00AC3F] sm:h-9 sm:min-w-[116px] sm:gap-2 sm:px-4 sm:text-[13px]",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80",
|
||||
)}
|
||||
>
|
||||
<span className="size-[7px] rounded-full bg-[#00AC3F]" />
|
||||
Connected
|
||||
<ChevronDown className="size-3 text-[#737373]" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"w-[260px] rounded-xl border border-white/10 bg-[#1B1F24] p-2 text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<p className="px-2 pb-1.5 pt-1 text-[11px] font-medium uppercase tracking-wide text-[#737373]">
|
||||
{keys.length > 1 ? `${keys.length} connections` : "Connection"}
|
||||
</p>
|
||||
<div className="flex flex-col">
|
||||
{keys.map((k) => (
|
||||
<div
|
||||
key={k.keyId}
|
||||
className="flex items-center justify-between gap-2 rounded-lg px-2 py-1.5"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[12px] text-[#A1A1AA]">
|
||||
{k.keyStart ? `${k.keyStart}…` : "API key"}
|
||||
</span>
|
||||
<DisconnectButton onConfirm={() => onRevoke(k.keyId)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex h-8 min-w-[104px] shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-medium text-[#00AC3F] sm:h-9 sm:min-w-[116px] sm:gap-2 sm:px-4 sm:text-[13px]",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80",
|
||||
)}
|
||||
>
|
||||
<span className="size-[7px] rounded-full bg-[#00AC3F]" />
|
||||
Connected
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1002,6 +975,9 @@ export function IntegrationsView() {
|
|||
key: string
|
||||
pluginId: string | null
|
||||
}>({ open: false, key: "", pluginId: null })
|
||||
const [connectedPluginId, setConnectedPluginId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
const { data: pluginsData } = useQuery({
|
||||
queryFn: async () => {
|
||||
|
|
@ -1030,27 +1006,34 @@ export function IntegrationsView() {
|
|||
enabled: hasProProduct,
|
||||
})
|
||||
|
||||
type ApiKey = {
|
||||
id: string
|
||||
metadata: Record<string, unknown> | null
|
||||
start: string | null
|
||||
}
|
||||
const { data: apiKeys = [], refetch: refetchKeys } = useQuery({
|
||||
queryKey: ["api-keys", org?.id],
|
||||
queryFn: async () => {
|
||||
if (!org?.id) return []
|
||||
const data = (await authClient.apiKey.list({
|
||||
fetchOptions: { query: { metadata: { organizationId: org.id } } },
|
||||
})) as unknown as ApiKey[]
|
||||
return data.filter((key) => key.metadata?.organizationId === org.id)
|
||||
const { data: apiKeys = [], refetch: refetchKeys } = useQuery<ListedApiKey[]>(
|
||||
{
|
||||
queryKey: ["api-keys", org?.id],
|
||||
queryFn: async () => {
|
||||
if (!org?.id) return []
|
||||
const API_URL =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const res = await fetch(`${API_URL}/v3/auth/keys`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data = (await res.json()) as { keys?: ListedApiKey[] }
|
||||
return data.keys ?? []
|
||||
},
|
||||
enabled: !!org?.id,
|
||||
staleTime: 30 * 1000,
|
||||
},
|
||||
enabled: !!org?.id,
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
)
|
||||
|
||||
const keyPrefix = useCallback((key: ListedApiKey): string | null => {
|
||||
return key.start ?? (key.name?.startsWith("sm_") ? key.name : null)
|
||||
}, [])
|
||||
|
||||
const connectedPlugins = useMemo<ConnectedKey[]>(() => {
|
||||
const out: ConnectedKey[] = []
|
||||
for (const key of apiKeys) {
|
||||
if (key.enabled === false) continue
|
||||
if (!key.lastRequest) continue
|
||||
if (!key.metadata) continue
|
||||
try {
|
||||
const metadata =
|
||||
|
|
@ -1063,14 +1046,14 @@ export function IntegrationsView() {
|
|||
if (metadata.sm_type === "plugin_auth" && metadata.sm_client) {
|
||||
out.push({
|
||||
keyId: key.id,
|
||||
keyStart: key.start ?? null,
|
||||
pluginId: metadata.sm_client,
|
||||
keyStart: keyPrefix(key),
|
||||
pluginId: normalizePluginClientId(metadata.sm_client),
|
||||
})
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return out
|
||||
}, [apiKeys])
|
||||
}, [apiKeys, keyPrefix])
|
||||
|
||||
const connectionsByProvider = useMemo(() => {
|
||||
const out: Record<ConnectorProvider, Connection[]> = {
|
||||
|
|
@ -1368,7 +1351,14 @@ export function IntegrationsView() {
|
|||
const needsProUpgrade =
|
||||
!isAutumnLoading && !hasProProduct && !isFreeTierPlugin(item.pluginId)
|
||||
if (keys.length > 0) {
|
||||
return <ConnectedPill keys={keys} onRevoke={handleRevokePluginKey} />
|
||||
return (
|
||||
<ConnectedButton
|
||||
onClick={() => {
|
||||
trackCard(item)
|
||||
setConnectedPluginId(item.pluginId)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (needsProUpgrade) {
|
||||
return (
|
||||
|
|
@ -1500,12 +1490,7 @@ export function IntegrationsView() {
|
|||
|
||||
const renderLeftIndicator = (item: Item): ReactNode => {
|
||||
if (item.kind === "plugin") {
|
||||
const isConnected = connectedPlugins.some(
|
||||
(k) => k.pluginId === item.pluginId,
|
||||
)
|
||||
return isConnected ? (
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-[#00AC3F]" />
|
||||
) : null
|
||||
return null
|
||||
}
|
||||
if (item.kind === "connector") {
|
||||
const count = connectionsByProvider[item.provider].length
|
||||
|
|
@ -1519,6 +1504,12 @@ export function IntegrationsView() {
|
|||
const dialogPlugin = newKey.pluginId
|
||||
? PLUGIN_CATALOG[newKey.pluginId]
|
||||
: undefined
|
||||
const connectedDialogPlugin = connectedPluginId
|
||||
? PLUGIN_CATALOG[connectedPluginId]
|
||||
: undefined
|
||||
const connectedDialogKeys = connectedPluginId
|
||||
? connectedPlugins.filter((key) => key.pluginId === connectedPluginId)
|
||||
: []
|
||||
const pluginSteps = dialogPlugin?.installSteps ?? []
|
||||
const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_..."))
|
||||
const setupSteps: InstallStep[] = stepsEmbedKey
|
||||
|
|
@ -1699,6 +1690,123 @@ export function IntegrationsView() {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={!!connectedPluginId}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConnectedPluginId(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
style={{
|
||||
boxShadow:
|
||||
"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",
|
||||
}}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex max-h-[88dvh] flex-col gap-3 overflow-hidden border border-white/[0.12] bg-[#1B1F24] p-0 px-3 pt-3 pb-4 rounded-2xl md:px-4 sm:max-w-[520px] sm:rounded-[22px]",
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
{connectedDialogPlugin?.name ?? "Plugin"} connection
|
||||
</DialogTitle>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{connectedDialogPlugin && (
|
||||
<IconBox>
|
||||
<Image
|
||||
src={connectedDialogPlugin.icon}
|
||||
alt={connectedDialogPlugin.name}
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</IconBox>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[16px] font-semibold leading-tight text-[#FAFAFA]">
|
||||
{connectedDialogPlugin?.name ?? "Plugin"} connected
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1.5 text-[12px] text-[#00AC3F]">
|
||||
<span className="size-[7px] rounded-full bg-[#00AC3F]" />
|
||||
Active
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{connectedDialogPlugin?.docsUrl && (
|
||||
<a
|
||||
href={connectedDialogPlugin.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex h-7 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] text-[#A1A1AA] transition-colors hover:text-white",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<BookOpen className="size-3.5" /> Docs
|
||||
</a>
|
||||
)}
|
||||
<DialogPrimitive.Close
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
className={cn(
|
||||
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80 focus:outline-none",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<X className="size-4 text-[#737373]" />
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-col gap-2 rounded-[14px] bg-[#14161A] p-4",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-[#737373]">
|
||||
{connectedDialogKeys.length > 1
|
||||
? `${connectedDialogKeys.length} connections`
|
||||
: "Connection"}
|
||||
</p>
|
||||
{connectedDialogKeys.length > 0 ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{connectedDialogKeys.map((key) => (
|
||||
<div
|
||||
key={key.keyId}
|
||||
className="flex min-w-0 items-center justify-between gap-3 rounded-lg bg-[#0D121A]/70 px-3 py-2"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[12px] text-[#A1A1AA]">
|
||||
{key.keyStart ? `${key.keyStart}...` : "API key"}
|
||||
</span>
|
||||
<DisconnectButton
|
||||
onConfirm={() => void handleRevokePluginKey(key.keyId)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[12px] text-[#A1A1AA]">
|
||||
No active connection was found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-end">
|
||||
<DialogPrimitive.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex h-9 items-center gap-1.5 rounded-full bg-[#0D121A] px-5 text-[13px] font-medium text-[#FAFAFA] transition-opacity hover:opacity-80",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<Check className="size-3.5 text-[#4BA0FA]" /> Done
|
||||
</button>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={mcpModalOpen}
|
||||
onOpenChange={(open) => {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,15 @@ import { hasActivePlan } from "@lib/queries"
|
|||
import { useCustomer } from "autumn-js/react"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { BookOpen, Check, ChevronDown, Loader, X, Zap } from "lucide-react"
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
Loader,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { type ReactNode, useEffect, useMemo, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
|
@ -17,6 +25,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
|
|||
import {
|
||||
PLUGIN_CATALOG,
|
||||
isFreeTierPlugin,
|
||||
normalizePluginClientId,
|
||||
type InstallStep,
|
||||
type PluginInfo,
|
||||
} from "@/lib/plugin-catalog"
|
||||
|
|
@ -27,9 +36,20 @@ interface ConnectedPlugin {
|
|||
keyId: string
|
||||
pluginId: string
|
||||
createdAt: string
|
||||
lastRequest?: string | null
|
||||
keyStart?: string | null
|
||||
}
|
||||
|
||||
type ListedApiKey = {
|
||||
id: string
|
||||
name?: string | null
|
||||
createdAt: string
|
||||
enabled?: boolean
|
||||
lastRequest: string | null
|
||||
metadata: string | Record<string, unknown> | null
|
||||
start?: string | null
|
||||
}
|
||||
|
||||
function SectionHeader({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
|
|
@ -121,13 +141,77 @@ function DisconnectButton({ onConfirm }: { onConfirm: () => void }) {
|
|||
)
|
||||
}
|
||||
|
||||
function ConnectedPill({
|
||||
function toDate(value: string | Date | null | undefined): Date | null {
|
||||
if (!value) return null
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function formatRelativeTime(value: string | Date | null | undefined): string {
|
||||
const date = toDate(value)
|
||||
if (!date) return "Never"
|
||||
const absMs = Math.abs(Date.now() - date.getTime())
|
||||
const minute = 60 * 1000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
if (absMs < minute) return "Just now"
|
||||
if (absMs < hour) return `${Math.round(absMs / minute)}m ago`
|
||||
if (absMs < day) return `${Math.round(absMs / hour)}h ago`
|
||||
return `${Math.round(absMs / day)}d ago`
|
||||
}
|
||||
|
||||
function formatDate(value: string | Date | null | undefined): string {
|
||||
const date = toDate(value)
|
||||
if (!date) return "Unknown"
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
function maskKey(start: string | null | undefined): string {
|
||||
if (!start) return "sm_********"
|
||||
return `${start}********`
|
||||
}
|
||||
|
||||
function keyPrefix(key: ListedApiKey): string | null {
|
||||
return key.start ?? (key.name?.startsWith("sm_") ? key.name : null)
|
||||
}
|
||||
|
||||
function DetailStat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[10px] font-medium uppercase tracking-wide text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-1 truncate text-[13px] font-medium text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivePill({
|
||||
plugin,
|
||||
connectedKeys,
|
||||
onRevoke,
|
||||
}: {
|
||||
plugin: PluginInfo
|
||||
connectedKeys: ConnectedPlugin[]
|
||||
onRevoke: (keyId: string) => void
|
||||
}) {
|
||||
const primaryKey = connectedKeys[0]
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
|
|
@ -140,7 +224,7 @@ function ConnectedPill({
|
|||
)}
|
||||
>
|
||||
<span className="size-[7px] rounded-full bg-[#00AC3F]" />
|
||||
Connected
|
||||
ACTIVE
|
||||
<ChevronDown className="size-3 text-[#737373]" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
|
@ -148,13 +232,67 @@ function ConnectedPill({
|
|||
align="end"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"w-[260px] rounded-xl border border-white/10 bg-[#1B1F24] p-2 text-[#FAFAFA]",
|
||||
"w-[min(380px,calc(100vw-32px))] rounded-xl border border-white/10 bg-[#1B1F24] p-4 text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<PluginIconBox src={plugin.icon} alt={plugin.name} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate text-[15px] font-semibold text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{plugin.name}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] text-[#A1A1AA]">Connected</p>
|
||||
</div>
|
||||
<span className="size-3 rounded-full bg-[#37D67A]" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 border-y border-white/[0.08] py-4 sm:grid-cols-3">
|
||||
<DetailStat label="API key" value={maskKey(primaryKey?.keyStart)} />
|
||||
<DetailStat
|
||||
label="Last active"
|
||||
value={formatRelativeTime(primaryKey?.lastRequest)}
|
||||
/>
|
||||
<DetailStat
|
||||
label="Connected"
|
||||
value={formatRelativeTime(primaryKey?.createdAt)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
{plugin.docsUrl && (
|
||||
<a
|
||||
href={plugin.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-[13px] text-[#4BA0FA] transition-colors hover:text-[#86C5FF]"
|
||||
>
|
||||
Docs <ExternalLink className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
{plugin.githubUrl && (
|
||||
<a
|
||||
href={plugin.githubUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-[13px] text-[#4BA0FA] transition-colors hover:text-[#86C5FF]"
|
||||
>
|
||||
GitHub <ExternalLink className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
<span className="ml-auto text-[11px] text-[#737373]">
|
||||
Connected {formatDate(primaryKey?.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"px-2 pb-1.5 pt-1 text-[11px] font-medium uppercase tracking-wide text-[#737373]",
|
||||
"px-2 pb-1.5 pt-4 text-[11px] font-medium uppercase tracking-wide text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{connectedKeys.length > 1
|
||||
|
|
@ -240,7 +378,11 @@ function PluginRow({
|
|||
<div className="col-start-2 flex min-w-0 shrink-0 items-center gap-2 sm:col-start-auto sm:gap-4">
|
||||
{plugin.docsUrl && <DocsLink href={plugin.docsUrl} />}
|
||||
{isConnected ? (
|
||||
<ConnectedPill connectedKeys={connectedKeys} onRevoke={onRevoke} />
|
||||
<ActivePill
|
||||
plugin={plugin}
|
||||
connectedKeys={connectedKeys}
|
||||
onRevoke={onRevoke}
|
||||
/>
|
||||
) : needsProUpgrade ? (
|
||||
<PillButton onClick={onUpgrade}>
|
||||
<Zap className="size-3.5 text-[#4BA0FA]" /> Upgrade
|
||||
|
|
@ -332,21 +474,29 @@ export function PluginsDetail() {
|
|||
queryKey: ["plugins"],
|
||||
})
|
||||
|
||||
const { data: apiKeys = [], refetch: refetchKeys } = useQuery({
|
||||
enabled: !!org?.id,
|
||||
queryFn: async () => {
|
||||
if (!org?.id) return []
|
||||
const data = await authClient.apiKey.list({
|
||||
fetchOptions: { query: { metadata: { organizationId: org.id } } },
|
||||
})
|
||||
return data.filter((key) => key.metadata?.organizationId === org.id)
|
||||
const { data: apiKeys = [], refetch: refetchKeys } = useQuery<ListedApiKey[]>(
|
||||
{
|
||||
enabled: !!org?.id,
|
||||
queryFn: async () => {
|
||||
if (!org?.id) return []
|
||||
const API_URL =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const res = await fetch(`${API_URL}/v3/auth/keys`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data = (await res.json()) as { keys?: ListedApiKey[] }
|
||||
return data.keys ?? []
|
||||
},
|
||||
queryKey: ["api-keys", org?.id],
|
||||
},
|
||||
queryKey: ["api-keys", org?.id],
|
||||
})
|
||||
)
|
||||
|
||||
const connectedPlugins = useMemo<ConnectedPlugin[]>(() => {
|
||||
const plugins: ConnectedPlugin[] = []
|
||||
for (const key of apiKeys) {
|
||||
if (key.enabled === false) continue
|
||||
if (!key.lastRequest) continue
|
||||
if (!key.metadata) continue
|
||||
try {
|
||||
const metadata =
|
||||
|
|
@ -358,12 +508,15 @@ export function PluginsDetail() {
|
|||
: (key.metadata as { sm_type?: string; sm_client?: string })
|
||||
|
||||
if (metadata.sm_type === "plugin_auth" && metadata.sm_client) {
|
||||
const createdAt = toDate(key.createdAt)?.toISOString()
|
||||
const lastRequest = toDate(key.lastRequest)?.toISOString()
|
||||
plugins.push({
|
||||
id: key.id,
|
||||
keyId: key.id,
|
||||
pluginId: metadata.sm_client,
|
||||
createdAt: key.createdAt.toISOString(),
|
||||
keyStart: key.start ?? null,
|
||||
pluginId: normalizePluginClientId(metadata.sm_client),
|
||||
createdAt: createdAt ?? new Date().toISOString(),
|
||||
lastRequest: lastRequest ?? null,
|
||||
keyStart: keyPrefix(key),
|
||||
})
|
||||
}
|
||||
} catch {}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export interface PluginInfo {
|
|||
tagline: string
|
||||
icon: string
|
||||
docsUrl?: string
|
||||
githubUrl?: string
|
||||
/** Steps shown after a key is minted. The literal `sm_...` is replaced
|
||||
* with the freshly generated key when rendered. */
|
||||
installSteps?: InstallStep[]
|
||||
|
|
@ -55,6 +56,7 @@ export const PLUGIN_CATALOG: Record<string, PluginInfo> = {
|
|||
tagline: "Persistent memory for the Codex CLI — free on every plan",
|
||||
icon: "/images/plugins/codex.png",
|
||||
docsUrl: "https://docs.supermemory.ai/integrations/codex",
|
||||
githubUrl: "https://github.com/supermemoryai/codex-supermemory",
|
||||
installSteps: [
|
||||
{
|
||||
title: "Save your API key",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue