mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Merge remote-tracking branch 'origin/main' into fix/openai-sdk-python-v4-api
This commit is contained in:
commit
7eca7c7e53
23 changed files with 2267 additions and 720 deletions
|
|
@ -9,10 +9,9 @@
|
|||
"dev:firefox": "wxt -b firefox",
|
||||
"build": "wxt build",
|
||||
"build:firefox": "wxt build -b firefox",
|
||||
"check-types": "bun run compile",
|
||||
"check-types": "wxt prepare && tsc --noEmit",
|
||||
"zip": "wxt zip",
|
||||
"zip:firefox": "wxt zip -b firefox",
|
||||
"compile": "tsc --noEmit",
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT } from "jose"
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"
|
||||
import { fetchSession, validateOAuthToken } from "./index"
|
||||
import { fetchSession, validateApiKey, validateOAuthToken } from "./index"
|
||||
|
||||
const API_URL = "https://api.example.com"
|
||||
const ISSUER = `${API_URL}/api/auth`
|
||||
|
|
@ -120,4 +120,64 @@ describe("MCP authentication", () => {
|
|||
status: 403,
|
||||
})
|
||||
})
|
||||
|
||||
function sessionResponse() {
|
||||
return Response.json({
|
||||
user: { id: "user_test", email: "test@example.com" },
|
||||
org: { id: "org_test" },
|
||||
role: "owner",
|
||||
accessType: "full",
|
||||
scope: { type: "full", permission: "write" },
|
||||
})
|
||||
}
|
||||
|
||||
it("validates an sm_ API key via the session endpoint", async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(sessionResponse())
|
||||
vi.stubGlobal("fetch", fetchSpy)
|
||||
const key = "sm_valid_key_0123456789abcdef"
|
||||
|
||||
await expect(validateApiKey(key, API_URL)).resolves.toEqual({
|
||||
userId: "user_test",
|
||||
organizationId: "org_test",
|
||||
bearerToken: key,
|
||||
scopes: [],
|
||||
})
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
`${API_URL}/v3/session`,
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("caches a validated API key within the TTL", async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(sessionResponse())
|
||||
vi.stubGlobal("fetch", fetchSpy)
|
||||
const key = "sm_cached_key_0123456789abcdef"
|
||||
|
||||
await validateApiKey(key, API_URL)
|
||||
await validateApiKey(key, API_URL)
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("rejects an API key the session endpoint refuses", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {})
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response(null, { status: 401 })),
|
||||
)
|
||||
|
||||
await expect(
|
||||
validateApiKey("sm_revoked_key_0123456789abcdef", API_URL),
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it("rejects malformed API keys without an API request", async () => {
|
||||
const fetchSpy = vi.fn()
|
||||
vi.stubGlobal("fetch", fetchSpy)
|
||||
|
||||
await expect(validateApiKey("sm_short", API_URL)).resolves.toBeNull()
|
||||
await expect(validateApiKey("not_a_key", API_URL)).resolves.toBeNull()
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -52,6 +52,51 @@ export async function fetchSession(
|
|||
return result.data
|
||||
}
|
||||
|
||||
// Opaque Supermemory API keys (sm_...) authenticate via the session endpoint
|
||||
// instead of JWT verification. Successful lookups are cached per isolate so a
|
||||
// busy MCP session doesn't re-validate on every JSON-RPC message.
|
||||
const API_KEY_PATTERN = /^sm_\S{17,}$/
|
||||
const API_KEY_CACHE_TTL_MS = 60_000
|
||||
const API_KEY_CACHE_MAX_ENTRIES = 1000
|
||||
|
||||
const apiKeyCache = new Map<string, { user: AuthUser; expiresAt: number }>()
|
||||
|
||||
export function isApiKey(token: string): boolean {
|
||||
return API_KEY_PATTERN.test(token)
|
||||
}
|
||||
|
||||
export async function validateApiKey(
|
||||
token: string,
|
||||
apiUrl: string,
|
||||
): Promise<AuthUser | null> {
|
||||
if (!isApiKey(token)) return null
|
||||
|
||||
const cached = apiKeyCache.get(token)
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.user
|
||||
|
||||
try {
|
||||
const session = await fetchSession(token, apiUrl)
|
||||
const organizationId = session.org?.id
|
||||
if (!organizationId) return null
|
||||
|
||||
const user: AuthUser = {
|
||||
userId: session.user.id,
|
||||
organizationId,
|
||||
bearerToken: token,
|
||||
scopes: [],
|
||||
}
|
||||
if (apiKeyCache.size >= API_KEY_CACHE_MAX_ENTRIES) apiKeyCache.clear()
|
||||
apiKeyCache.set(token, {
|
||||
user,
|
||||
expiresAt: Date.now() + API_KEY_CACHE_TTL_MS,
|
||||
})
|
||||
return user
|
||||
} catch (error) {
|
||||
console.error("API key validation error:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateOAuthToken(
|
||||
token: string,
|
||||
apiUrl: string,
|
||||
|
|
|
|||
|
|
@ -7,11 +7,14 @@ import { z } from "zod"
|
|||
import {
|
||||
containerTagSchema,
|
||||
documentsApiResponseSchema,
|
||||
paginationSchema,
|
||||
memoriesListSchema,
|
||||
type ContainerTag,
|
||||
type DocumentMemoryEntry,
|
||||
type DocumentsApiResponse,
|
||||
type DocumentWithMemories,
|
||||
type MemoriesList,
|
||||
type MemoryEntry,
|
||||
type MemoryEntryHistory,
|
||||
} from "../../shared/types"
|
||||
|
||||
const MAX_CHARS = 200000
|
||||
|
|
@ -34,43 +37,10 @@ export interface DocumentsListResponse {
|
|||
pagination: SdkDocumentListResponse["pagination"]
|
||||
}
|
||||
|
||||
const memoryEntryHistorySchema = z.looseObject({
|
||||
id: z.string(),
|
||||
memory: z.string(),
|
||||
version: z.number(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
parentMemoryId: z.string().nullish(),
|
||||
rootMemoryId: z.string().nullish(),
|
||||
isLatest: z.boolean().optional(),
|
||||
isForgotten: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type MemoryEntryHistory = z.infer<typeof memoryEntryHistorySchema>
|
||||
|
||||
const memoryEntrySchema = z.looseObject({
|
||||
id: z.string(),
|
||||
memory: z.string(),
|
||||
version: z.number(),
|
||||
isLatest: z.boolean(),
|
||||
isForgotten: z.boolean(),
|
||||
isStatic: z.boolean().optional(),
|
||||
isInference: z.boolean().optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
sourceCount: z.number().optional(),
|
||||
documentIds: z.array(z.string()).optional(),
|
||||
history: z.array(memoryEntryHistorySchema).optional(),
|
||||
})
|
||||
|
||||
export type MemoryEntry = z.infer<typeof memoryEntrySchema>
|
||||
|
||||
const memoryEntriesResponseSchema = z.object({
|
||||
memoryEntries: z.array(memoryEntrySchema),
|
||||
pagination: paginationSchema,
|
||||
})
|
||||
|
||||
export type MemoryEntriesResponse = z.infer<typeof memoryEntriesResponseSchema>
|
||||
// Memory-entry shapes live in shared/types so the client parser and the
|
||||
// listMemories output schema share one definition and can't drift.
|
||||
export type { MemoryEntry, MemoryEntryHistory }
|
||||
export type MemoryEntriesResponse = MemoriesList
|
||||
|
||||
export type Memory =
|
||||
| {
|
||||
|
|
@ -452,7 +422,7 @@ export class SupermemoryClient {
|
|||
})
|
||||
}
|
||||
|
||||
return memoryEntriesResponseSchema.parse(await response.json())
|
||||
return memoriesListSchema.parse(await response.json())
|
||||
} catch (error) {
|
||||
this.handleError(error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ import type { AuthInfo } from "@modelcontextprotocol/server"
|
|||
import { createMcpHandler } from "agents/mcp/server"
|
||||
import { Hono, type Context } from "hono"
|
||||
import { cors } from "hono/cors"
|
||||
import { validateOAuthToken, type AuthUser } from "./auth"
|
||||
import {
|
||||
isApiKey,
|
||||
validateApiKey,
|
||||
validateOAuthToken,
|
||||
type AuthUser,
|
||||
} from "./auth"
|
||||
import { SupermemoryMCP } from "./legacy-protocol-state"
|
||||
import { createSupermemoryServer } from "./server"
|
||||
import type { ActorContext, ServerEnv } from "./types"
|
||||
|
|
@ -176,7 +181,9 @@ async function handleMcpRequest(
|
|||
|
||||
if (!token) return unauthorizedResponse(resourceMetadataUrl)
|
||||
|
||||
const authUser = await validateOAuthToken(token, apiUrl, mcpResource)
|
||||
const authUser = isApiKey(token)
|
||||
? await validateApiKey(token, apiUrl)
|
||||
: await validateOAuthToken(token, apiUrl, mcpResource)
|
||||
if (!authUser) return unauthorizedResponse(resourceMetadataUrl, true)
|
||||
|
||||
const actor: ActorContext = {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from "zod"
|
||||
import {
|
||||
containerTagAccessSchema,
|
||||
memoriesListSchema,
|
||||
paginationSchema,
|
||||
sessionScopeSchema,
|
||||
} from "../../shared/types"
|
||||
|
|
@ -42,33 +43,6 @@ const documentSummarySchema = z.object({
|
|||
summary: z.string().nullable(),
|
||||
})
|
||||
|
||||
const memoryHistorySchema = z.object({
|
||||
id: z.string(),
|
||||
memory: z.string(),
|
||||
version: z.number(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
parentMemoryId: z.string().nullish(),
|
||||
rootMemoryId: z.string().nullish(),
|
||||
isLatest: z.boolean().optional(),
|
||||
isForgotten: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const memoryEntryOutputSchema = z.object({
|
||||
id: z.string(),
|
||||
memory: z.string(),
|
||||
version: z.number(),
|
||||
isLatest: z.boolean(),
|
||||
isForgotten: z.boolean(),
|
||||
isStatic: z.boolean().optional(),
|
||||
isInference: z.boolean().optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
sourceCount: z.number().optional(),
|
||||
documentIds: z.array(z.string()).optional(),
|
||||
history: z.array(memoryHistorySchema).optional(),
|
||||
})
|
||||
|
||||
export const addMemoryOutputSchema = z.object({
|
||||
action: z.enum(["save", "forget"]),
|
||||
success: z.boolean(),
|
||||
|
|
@ -104,10 +78,9 @@ export const listDocumentsOutputSchema = z.object({
|
|||
|
||||
export type ListDocumentsOutput = z.infer<typeof listDocumentsOutputSchema>
|
||||
|
||||
export const listMemoriesOutputSchema = z.object({
|
||||
memoryEntries: z.array(memoryEntryOutputSchema),
|
||||
pagination: paginationSchema,
|
||||
})
|
||||
// Reuse the shared schema so the tool's output contract stays identical to what
|
||||
// the client parses — the two can't drift.
|
||||
export const listMemoriesOutputSchema = memoriesListSchema
|
||||
|
||||
export type ListMemoriesOutput = z.infer<typeof listMemoriesOutputSchema>
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export const sessionInfoSchema = z.looseObject({
|
|||
email: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
}),
|
||||
org: z.looseObject({ id: z.string().min(1) }).optional(),
|
||||
role: z.string().optional(),
|
||||
accessType: z.enum(["full", "restricted"]).optional(),
|
||||
containerTags: z.array(containerTagAccessSchema).nullable().optional(),
|
||||
|
|
@ -116,6 +117,49 @@ export const documentsApiResponseSchema = z.object({
|
|||
|
||||
export type DocumentsApiResponse = z.infer<typeof documentsApiResponseSchema>
|
||||
|
||||
// Extracted memory entries from /v4/memories/list. Single source of truth for
|
||||
// both the client parser and the listMemories tool output schema, so the two
|
||||
// can't drift (a mismatch previously produced Ajv "must NOT have additional
|
||||
// properties"). z.object strips unknown API fields on parse, keeping parsed data
|
||||
// matched to the strict MCP output contract while tolerating new API fields.
|
||||
export const memoryEntryHistorySchema = z.object({
|
||||
id: z.string(),
|
||||
memory: z.string(),
|
||||
version: z.number(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
parentMemoryId: z.string().nullish(),
|
||||
rootMemoryId: z.string().nullish(),
|
||||
isLatest: z.boolean().optional(),
|
||||
isForgotten: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type MemoryEntryHistory = z.infer<typeof memoryEntryHistorySchema>
|
||||
|
||||
export const memoryEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
memory: z.string(),
|
||||
version: z.number(),
|
||||
isLatest: z.boolean(),
|
||||
isForgotten: z.boolean(),
|
||||
isStatic: z.boolean().optional(),
|
||||
isInference: z.boolean().optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
sourceCount: z.number().optional(),
|
||||
documentIds: z.array(z.string()).optional(),
|
||||
history: z.array(memoryEntryHistorySchema).optional(),
|
||||
})
|
||||
|
||||
export type MemoryEntry = z.infer<typeof memoryEntrySchema>
|
||||
|
||||
export const memoriesListSchema = z.object({
|
||||
memoryEntries: z.array(memoryEntrySchema),
|
||||
pagination: paginationSchema,
|
||||
})
|
||||
|
||||
export type MemoriesList = z.infer<typeof memoriesListSchema>
|
||||
|
||||
// ViewMessage — discriminated union returned by app tools as `structuredContent`.
|
||||
// The widget uses an exhaustive switch on `view` to dispatch to the correct view component.
|
||||
// Adding a new view here is a compile error in App.tsx until the case is handled.
|
||||
|
|
|
|||
|
|
@ -6,12 +6,22 @@ import {
|
|||
|
||||
export default async function ConfigureSectionPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ section: string }>
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const { section } = await params
|
||||
// Default section is canonical at /configure.
|
||||
if (section === DEFAULT_CONFIGURE_SECTION) redirect("/configure")
|
||||
// Carry the query across, else deep links like ?mcpSetup= are dropped here.
|
||||
if (section === DEFAULT_CONFIGURE_SECTION) {
|
||||
const query = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(await searchParams)) {
|
||||
if (typeof value === "string") query.set(key, value)
|
||||
else if (Array.isArray(value)) for (const v of value) query.append(key, v)
|
||||
}
|
||||
const search = query.toString()
|
||||
redirect(search ? `/configure?${search}` : "/configure")
|
||||
}
|
||||
if (!isConfigureSection(section)) notFound()
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
58
apps/web/app/api/mcp-icon/route.ts
Normal file
58
apps/web/app/api/mcp-icon/route.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { type NextRequest, NextResponse } from "next/server"
|
||||
import iconDomains from "@/lib/mcp-icon-domains.json"
|
||||
|
||||
const DOMAIN_RE =
|
||||
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i
|
||||
const MAX_ICON_BYTES = 256 * 1024
|
||||
|
||||
const ALLOWED_DOMAINS = new Set(iconDomains.domains)
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const domain = request.nextUrl.searchParams
|
||||
.get("domain")
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
if (!domain || !DOMAIN_RE.test(domain) || !ALLOWED_DOMAINS.has(domain)) {
|
||||
return new NextResponse(null, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=128`,
|
||||
{ next: { revalidate: 60 * 60 * 24 * 7 } },
|
||||
)
|
||||
const contentType = response.headers.get("content-type") ?? ""
|
||||
if (!response.ok || !contentType.startsWith("image/")) {
|
||||
return new NextResponse(null, { status: 404 })
|
||||
}
|
||||
const contentLength = Number(response.headers.get("content-length") ?? 0)
|
||||
if (contentLength > MAX_ICON_BYTES) {
|
||||
return new NextResponse(null, { status: 413 })
|
||||
}
|
||||
if (!response.body) return new NextResponse(null, { status: 404 })
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let bytes = 0
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
bytes += value.byteLength
|
||||
if (bytes > MAX_ICON_BYTES) {
|
||||
await reader.cancel()
|
||||
return new NextResponse(null, { status: 413 })
|
||||
}
|
||||
chunks.push(value)
|
||||
}
|
||||
const body = new Uint8Array(bytes)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
body.set(chunk, offset)
|
||||
offset += chunk.byteLength
|
||||
}
|
||||
return new NextResponse(body, {
|
||||
headers: {
|
||||
"cache-control":
|
||||
"public, max-age=86400, s-maxage=604800, stale-while-revalidate=2592000",
|
||||
"content-type": contentType,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { cn } from "@lib/utils"
|
||||
import { Gmail, Granola, Notion } from "@ui/assets/icons"
|
||||
import { Gmail, GoogleDrive, Granola, Notion } from "@ui/assets/icons"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
export function SlackMark({ className }: { className?: string }) {
|
||||
|
|
@ -99,6 +99,8 @@ export function brainConnectorIcon(
|
|||
className = "size-[18px]",
|
||||
): React.ReactNode {
|
||||
switch (slug) {
|
||||
case "google-drive":
|
||||
return <GoogleDrive className={className} />
|
||||
case "gmail":
|
||||
return <Gmail className={className} />
|
||||
case "github":
|
||||
|
|
|
|||
82
apps/web/components/directory/connector-card.tsx
Normal file
82
apps/web/components/directory/connector-card.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import type { ReactNode } from "react"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
// Shared connector/integration card shell: icon, name, subtitle, optional
|
||||
// top-right slot, and a footer split into a status side and an action side.
|
||||
export function ConnectorCard({
|
||||
icon,
|
||||
name,
|
||||
subtitle,
|
||||
topRight,
|
||||
footerLeft,
|
||||
footerRight,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
name: string
|
||||
subtitle: string
|
||||
topRight?: ReactNode
|
||||
footerLeft: ReactNode
|
||||
footerRight?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-col justify-between gap-3 rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate font-semibold text-[14px] tracking-[-0.15px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-1 line-clamp-2 break-words text-[12px] font-medium leading-5 text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
{topRight}
|
||||
</div>
|
||||
<div className="flex min-h-9 items-center justify-between gap-3 border-[#1E293B]/50 border-t pt-3">
|
||||
<div className="flex min-w-0 items-center gap-3">{footerLeft}</div>
|
||||
{footerRight}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ScopeChip({
|
||||
label,
|
||||
connected,
|
||||
}: {
|
||||
label: string
|
||||
connected: boolean
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex shrink-0 items-center gap-1.5 whitespace-nowrap text-[12px] font-medium",
|
||||
connected ? "text-[#FAFAFA]" : "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"size-[7px] shrink-0 rounded-full",
|
||||
connected ? "bg-[#00AC3F]" : "bg-[#3A4150]",
|
||||
)}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
117
apps/web/components/directory/section-rail.tsx
Normal file
117
apps/web/components/directory/section-rail.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
export const sectionLabelClass = cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] font-semibold tracking-[-0.01em] text-[#A1A1AA]",
|
||||
)
|
||||
|
||||
// Horizontally scrollable card rail with a section heading — shared by the
|
||||
// main integrations directory and the Company Brain connections directory.
|
||||
// Arrows appear only when the content actually overflows.
|
||||
export function SectionRail({
|
||||
label,
|
||||
children,
|
||||
headerSlot,
|
||||
labelSlot,
|
||||
scrollbar = "hidden",
|
||||
}: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
headerSlot?: ReactNode
|
||||
labelSlot?: ReactNode
|
||||
scrollbar?: "hidden" | "visible"
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false)
|
||||
const [canScrollRight, setCanScrollRight] = useState(false)
|
||||
const [hasOverflow, setHasOverflow] = useState(false)
|
||||
|
||||
const update = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
setHasOverflow(el.scrollWidth > el.clientWidth + 4)
|
||||
setCanScrollLeft(el.scrollLeft > 4)
|
||||
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
update()
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
el.addEventListener("scroll", update, { passive: true })
|
||||
el.addEventListener("scrollend", update)
|
||||
const ro = new ResizeObserver(update)
|
||||
ro.observe(el)
|
||||
return () => {
|
||||
el.removeEventListener("scroll", update)
|
||||
el.removeEventListener("scrollend", update)
|
||||
ro.disconnect()
|
||||
}
|
||||
}, [update])
|
||||
|
||||
const scrollBy = (dir: 1 | -1) => {
|
||||
scrollRef.current?.scrollBy({ left: 292 * dir, behavior: "smooth" })
|
||||
setTimeout(update, 450)
|
||||
}
|
||||
|
||||
const arrowClass = cn(
|
||||
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] text-[#FAFAFA] transition-opacity",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]",
|
||||
"hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-30",
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className={sectionLabelClass}>{label}</h3>
|
||||
{labelSlot}
|
||||
</div>
|
||||
<div className="hidden items-center gap-1.5 sm:flex">
|
||||
{headerSlot}
|
||||
{hasOverflow ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Show previous"
|
||||
disabled={!canScrollLeft}
|
||||
onClick={() => scrollBy(-1)}
|
||||
className={arrowClass}
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Show more"
|
||||
disabled={!canScrollRight}
|
||||
onClick={() => scrollBy(1)}
|
||||
className={arrowClass}
|
||||
>
|
||||
<ArrowRight className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
"flex flex-col gap-1.5 sm:-mx-1 sm:flex-row sm:gap-3 sm:overflow-x-auto sm:px-1",
|
||||
scrollbar === "visible" ? "scrollbar-thin sm:pb-2" : "scrollbar-none",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Standard card width inside a rail: full-width stacked on mobile, 2-up on
|
||||
// small screens, 3-up on large.
|
||||
export const railItemClass =
|
||||
"w-full sm:shrink-0 sm:grow-0 sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)]"
|
||||
|
|
@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|||
import { useCustomer } from "autumn-js/react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
|
||||
import { SectionRail } from "@/components/directory/section-rail"
|
||||
import { $fetch } from "@lib/api"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
|
@ -71,8 +72,14 @@ import {
|
|||
isFreeTierPlugin,
|
||||
normalizePluginClientId,
|
||||
type InstallStep,
|
||||
type PluginInfo,
|
||||
} from "@/lib/plugin-catalog"
|
||||
import { INSET, InstallSteps, PillButton } from "./integrations/install-steps"
|
||||
import {
|
||||
CopyButton,
|
||||
INSET,
|
||||
InstallSteps,
|
||||
PillButton,
|
||||
} from "./integrations/install-steps"
|
||||
import {
|
||||
ShortcutsConnectButtons,
|
||||
useShortcutsConnect,
|
||||
|
|
@ -638,6 +645,206 @@ function IconBox({
|
|||
)
|
||||
}
|
||||
|
||||
const PLUGIN_COMMANDS: InstallStep[] = [
|
||||
{
|
||||
code: "npx supermemory plugin",
|
||||
copyLabel: "Install plugins",
|
||||
title: "Install plugins",
|
||||
description:
|
||||
"Detect Claude Code, Cursor, OpenCode, and Codex, install your selections, then approve OAuth once in the browser.",
|
||||
},
|
||||
{
|
||||
code: "npx supermemory plugin login",
|
||||
copyLabel: "Reconnect plugins",
|
||||
title: "Reconnect plugins",
|
||||
description:
|
||||
"Run browser OAuth again for plugins that are already installed, without reinstalling them.",
|
||||
},
|
||||
{
|
||||
code: "npx supermemory plugin uninstall",
|
||||
copyLabel: "Uninstall plugins",
|
||||
title: "Uninstall plugins",
|
||||
description:
|
||||
"Remove selected plugin integrations while keeping your credentials and memories.",
|
||||
},
|
||||
]
|
||||
|
||||
const PLUGIN_COMMAND_CLIENTS = [
|
||||
"claude_code",
|
||||
"cursor",
|
||||
"codex",
|
||||
"opencode",
|
||||
] as const
|
||||
|
||||
type PluginSetupTab = "agent" | "manual"
|
||||
|
||||
const PLUGIN_CLI_TARGETS: Partial<Record<string, string>> = {
|
||||
claude_code: "claude",
|
||||
codex: "codex",
|
||||
cursor: "cursor",
|
||||
opencode: "opencode",
|
||||
}
|
||||
|
||||
function pluginAgentPrompt(plugin: PluginInfo): string {
|
||||
const cliTarget = PLUGIN_CLI_TARGETS[plugin.id]
|
||||
if (cliTarget) {
|
||||
return `Install and connect the Supermemory plugin for ${plugin.name} on this machine. Run \`npx supermemory plugin --only ${cliTarget}\`, complete the browser OAuth flow when it opens, then verify the plugin is installed and authenticated.`
|
||||
}
|
||||
|
||||
const docsInstruction = plugin.docsUrl
|
||||
? ` Follow the official setup instructions at ${plugin.docsUrl}.`
|
||||
: " Follow its official setup instructions."
|
||||
return `Install and connect the Supermemory integration for ${plugin.name} on this machine.${docsInstruction} Complete authentication securely, then verify the integration is working.`
|
||||
}
|
||||
|
||||
function PluginSetupMethodTabs({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: PluginSetupTab
|
||||
onChange: (value: PluginSetupTab) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-row gap-0.5 rounded-full bg-[#0D121A] p-0.5",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)]",
|
||||
)}
|
||||
role="tablist"
|
||||
aria-label="Setup method"
|
||||
>
|
||||
{(["agent", "manual"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={cn(
|
||||
"min-h-8 flex-1 rounded-full px-3 text-center text-[12px] font-medium transition-colors",
|
||||
value === tab
|
||||
? "bg-white/[0.10] text-[#FAFAFA]"
|
||||
: "text-[#A1A1AA] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
onClick={() => onChange(tab)}
|
||||
role="tab"
|
||||
type="button"
|
||||
aria-selected={value === tab}
|
||||
>
|
||||
{tab === "agent" ? "Agent instructions" : "Manual instructions"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PluginAgentInstructions({ plugin }: { plugin: PluginInfo }) {
|
||||
const prompt = pluginAgentPrompt(plugin)
|
||||
return (
|
||||
<div className="flex min-w-0 items-start gap-2 rounded-[10px] border border-white/[0.07] bg-[#0B0E13] px-3 py-2.5">
|
||||
<p className="min-w-0 flex-1 whitespace-pre-wrap break-words font-mono text-[12px] leading-[1.6] text-[#E4E4E7]">
|
||||
{prompt}
|
||||
</p>
|
||||
<CopyButton text={prompt} label="Agent instructions" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PluginCommandsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<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 text-[#FAFAFA] rounded-2xl md:px-4 sm:max-w-[620px] sm:rounded-[22px]",
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
Supermemory plugin commands
|
||||
</DialogTitle>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Claude Code, Cursor, Codex, and OpenCode"
|
||||
className="flex shrink-0 -space-x-2"
|
||||
>
|
||||
{PLUGIN_COMMAND_CLIENTS.map((pluginId) => {
|
||||
const plugin = PLUGIN_CATALOG[pluginId]
|
||||
if (!plugin) return null
|
||||
return (
|
||||
<span
|
||||
key={pluginId}
|
||||
className="flex size-8 items-center justify-center rounded-[9px] border border-white/[0.12] bg-[#0D121A] p-1.5 shadow-sm"
|
||||
>
|
||||
<Image
|
||||
src={plugin.icon}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[16px] font-semibold leading-tight text-[#FAFAFA]">
|
||||
Plugin commands
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] text-[#A1A1AA]">
|
||||
Install, reconnect, or remove integrations from one CLI.
|
||||
</p>
|
||||
</div>
|
||||
<DialogPrimitive.Close
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 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 className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 rounded-[14px] bg-[#14161A] p-3 sm:p-4",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<InstallSteps steps={PLUGIN_COMMANDS} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 pt-1">
|
||||
<p className="text-[11px] text-[#737373]">
|
||||
Run these commands from your terminal.
|
||||
</p>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
type InfoUseCase = {
|
||||
title: string
|
||||
description: string
|
||||
|
|
@ -2474,95 +2681,6 @@ function CategoryFilterToggle({
|
|||
)
|
||||
}
|
||||
|
||||
function SectionRail({
|
||||
label,
|
||||
children,
|
||||
headerSlot,
|
||||
}: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
headerSlot?: ReactNode
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false)
|
||||
const [canScrollRight, setCanScrollRight] = useState(false)
|
||||
|
||||
const update = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
setCanScrollLeft(el.scrollLeft > 4)
|
||||
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
update()
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
el.addEventListener("scroll", update, { passive: true })
|
||||
el.addEventListener("scrollend", update)
|
||||
const ro = new ResizeObserver(update)
|
||||
ro.observe(el)
|
||||
return () => {
|
||||
el.removeEventListener("scroll", update)
|
||||
el.removeEventListener("scrollend", update)
|
||||
ro.disconnect()
|
||||
}
|
||||
}, [update])
|
||||
|
||||
const scrollBy = (dir: 1 | -1) => {
|
||||
scrollRef.current?.scrollBy({ left: 292 * dir, behavior: "smooth" })
|
||||
setTimeout(update, 450)
|
||||
}
|
||||
|
||||
const arrowClass = cn(
|
||||
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] text-[#FAFAFA] transition-opacity",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]",
|
||||
"hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-30",
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] font-semibold tracking-[-0.01em] text-[#A1A1AA]",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</h3>
|
||||
<div className="hidden items-center gap-1.5 sm:flex">
|
||||
{headerSlot}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Show previous"
|
||||
disabled={!canScrollLeft}
|
||||
onClick={() => scrollBy(-1)}
|
||||
className={arrowClass}
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Show more"
|
||||
disabled={!canScrollRight}
|
||||
onClick={() => scrollBy(1)}
|
||||
className={arrowClass}
|
||||
>
|
||||
<ArrowRight className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="scrollbar-none flex flex-col gap-1.5 sm:-mx-1 sm:flex-row sm:gap-3 sm:overflow-x-auto sm:px-1"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function IntegrationsView({
|
||||
publicMode = false,
|
||||
onOpenDocument,
|
||||
|
|
@ -2588,18 +2706,21 @@ export function IntegrationsView({
|
|||
const [connectingProvider, setConnectingProvider] =
|
||||
useState<ConnectorProvider | null>(null)
|
||||
const [granolaModalOpen, setGranolaModalOpen] = useState(false)
|
||||
const [pluginCommandsOpen, setPluginCommandsOpen] = useState(false)
|
||||
const [newKey, setNewKey] = useState<{
|
||||
open: boolean
|
||||
key: string
|
||||
pluginId: string | null
|
||||
loading: boolean
|
||||
}>({ open: false, key: "", pluginId: null, loading: false })
|
||||
const [pluginSetupTab, setPluginSetupTab] = useState<PluginSetupTab>("agent")
|
||||
const openPluginSetup = useCallback((pluginId: string) => {
|
||||
setPluginSetupTab("agent")
|
||||
setNewKey({ open: true, key: "", pluginId, loading: false })
|
||||
}, [])
|
||||
const [connectedPluginId, setConnectedPluginId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const [finishSetupPluginId, setFinishSetupPluginId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
const { data: pluginsData } = useQuery({
|
||||
queryFn: async () => {
|
||||
|
|
@ -2778,12 +2899,7 @@ export function IntegrationsView({
|
|||
},
|
||||
onMutate: (pluginId) => setConnectingPlugin(pluginId),
|
||||
onError: (err) => {
|
||||
// Tear down a pre-opened (loading) modal so a failed mint doesn't hang on a spinner.
|
||||
setNewKey((s) =>
|
||||
s.loading
|
||||
? { open: false, key: "", pluginId: null, loading: false }
|
||||
: s,
|
||||
)
|
||||
setNewKey((s) => ({ ...s, loading: false }))
|
||||
toast.error("Failed to connect plugin", {
|
||||
description: err instanceof Error ? err.message : "Unknown error",
|
||||
})
|
||||
|
|
@ -2793,10 +2909,32 @@ export function IntegrationsView({
|
|||
queryClient.invalidateQueries({ queryKey: ["api-keys", org?.id] })
|
||||
},
|
||||
onSuccess: (data, pluginId) => {
|
||||
setNewKey({ open: true, key: data.key, pluginId, loading: false })
|
||||
setNewKey((s) =>
|
||||
s.open && s.pluginId === pluginId
|
||||
? { ...s, key: data.key, loading: false }
|
||||
: s,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const generatePluginKey = () => {
|
||||
const pluginId = newKey.pluginId
|
||||
if (
|
||||
!pluginId ||
|
||||
newKey.key ||
|
||||
newKey.loading ||
|
||||
createPluginKeyMutation.isPending
|
||||
)
|
||||
return
|
||||
setNewKey((s) => ({ ...s, loading: true }))
|
||||
createPluginKeyMutation.mutate(pluginId)
|
||||
}
|
||||
|
||||
const selectPluginSetupTab = (tab: PluginSetupTab) => {
|
||||
setPluginSetupTab(tab)
|
||||
if (tab === "manual") generatePluginKey()
|
||||
}
|
||||
|
||||
const addConnectionMutation = useMutation({
|
||||
mutationFn: async (provider: ConnectorProvider) => {
|
||||
const response = await $fetch("@post/connections/:provider", {
|
||||
|
|
@ -2928,10 +3066,7 @@ export function IntegrationsView({
|
|||
void setConnectTarget(null)
|
||||
handleUpgrade("api_pro")
|
||||
} else {
|
||||
// Open instantly; the key fills in on mint. The ?connect param stays the source
|
||||
// of truth until the modal closes.
|
||||
setNewKey({ open: true, key: "", pluginId: target, loading: true })
|
||||
createPluginKeyMutation.mutate(target)
|
||||
openPluginSetup(target)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -2966,8 +3101,8 @@ export function IntegrationsView({
|
|||
redirectToLogin,
|
||||
setConnectTarget,
|
||||
setAddDoc,
|
||||
createPluginKeyMutation,
|
||||
handleUpgrade,
|
||||
openPluginSetup,
|
||||
])
|
||||
|
||||
const closeMcpModal = () => {
|
||||
|
|
@ -3282,7 +3417,7 @@ export function IntegrationsView({
|
|||
handleUpgrade("api_pro")
|
||||
return
|
||||
}
|
||||
createPluginKeyMutation.mutate("claude_code")
|
||||
openPluginSetup("claude_code")
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -3360,7 +3495,7 @@ export function IntegrationsView({
|
|||
return
|
||||
}
|
||||
trackCard(item)
|
||||
createPluginKeyMutation.mutate(item.pluginId)
|
||||
openPluginSetup(item.pluginId)
|
||||
}}
|
||||
disabled={!!connectingPlugin}
|
||||
className={cn(
|
||||
|
|
@ -3381,12 +3516,7 @@ export function IntegrationsView({
|
|||
<FinishSetupButton
|
||||
onClick={() => {
|
||||
trackCard(item)
|
||||
if (!PLUGIN_CATALOG[item.pluginId]?.usesOAuth) {
|
||||
if (connectingPlugin) return
|
||||
createPluginKeyMutation.mutate(item.pluginId)
|
||||
return
|
||||
}
|
||||
setFinishSetupPluginId(item.pluginId)
|
||||
openPluginSetup(item.pluginId)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
|
@ -3403,7 +3533,7 @@ export function IntegrationsView({
|
|||
<PillButton
|
||||
onClick={() => {
|
||||
trackCard(item)
|
||||
createPluginKeyMutation.mutate(item.pluginId)
|
||||
openPluginSetup(item.pluginId)
|
||||
}}
|
||||
disabled={!!connectingPlugin}
|
||||
>
|
||||
|
|
@ -3573,7 +3703,7 @@ export function IntegrationsView({
|
|||
return
|
||||
}
|
||||
trackCard(item)
|
||||
createPluginKeyMutation.mutate(item.pluginId)
|
||||
openPluginSetup(item.pluginId)
|
||||
}}
|
||||
disabled={!!connectingPlugin}
|
||||
>
|
||||
|
|
@ -3686,10 +3816,6 @@ export function IntegrationsView({
|
|||
!isAutumnLoading &&
|
||||
!hasProProduct &&
|
||||
!isFreeTierPlugin(connectedPluginId)
|
||||
const finishSetupPlugin = finishSetupPluginId
|
||||
? PLUGIN_CATALOG[finishSetupPluginId]
|
||||
: undefined
|
||||
const finishSetupSteps = finishSetupPlugin?.installSteps ?? []
|
||||
const pluginSteps = dialogPlugin?.installSteps ?? []
|
||||
const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_..."))
|
||||
const skipGeneratedKeyStep = stepsEmbedKey || !!dialogPlugin?.usesOAuth
|
||||
|
|
@ -3794,6 +3920,23 @@ export function IntegrationsView({
|
|||
<SectionRail
|
||||
key={cat}
|
||||
label={CATEGORY_LABEL[cat]}
|
||||
labelSlot={
|
||||
cat === "plugins" ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={pluginCommandsOpen}
|
||||
onClick={() => setPluginCommandsOpen(true)}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex items-center gap-1.5 rounded-full text-[10px] font-medium text-[#737373] transition-colors hover:text-[#FAFAFA] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#4BA0FA]/60 sm:text-[11px]",
|
||||
)}
|
||||
>
|
||||
<span>Install plugins with one command</span>
|
||||
<NewChip />
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
headerSlot={
|
||||
cat === "ai-clients" && activeMcpKey ? (
|
||||
<McpConnectedPill
|
||||
|
|
@ -3839,6 +3982,11 @@ export function IntegrationsView({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<PluginCommandsDialog
|
||||
open={pluginCommandsOpen}
|
||||
onOpenChange={setPluginCommandsOpen}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={newKey.open}
|
||||
onOpenChange={(open) => {
|
||||
|
|
@ -3848,7 +3996,10 @@ export function IntegrationsView({
|
|||
pluginId: open ? s.pluginId : null,
|
||||
loading: open ? s.loading : false,
|
||||
}))
|
||||
if (!open) void setConnectTarget(null)
|
||||
if (!open) {
|
||||
setPluginSetupTab("agent")
|
||||
void setConnectTarget(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
|
|
@ -3881,9 +4032,11 @@ export function IntegrationsView({
|
|||
Set up {dialogPlugin?.name ?? "your plugin"}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[12px] text-[#A1A1AA]">
|
||||
{newKey.loading
|
||||
? "Generating your key…"
|
||||
: "Copy your key and run these steps to finish."}
|
||||
{pluginSetupTab === "agent"
|
||||
? "Copy this prompt into your coding agent."
|
||||
: newKey.loading
|
||||
? "Generating your key…"
|
||||
: "Follow these steps to finish manually."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
|
|
@ -3916,17 +4069,30 @@ export function IntegrationsView({
|
|||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 rounded-[14px] bg-[#14161A] p-4 sm:p-5",
|
||||
"min-w-0 space-y-4 rounded-[14px] bg-[#14161A] p-4 sm:p-5",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
{newKey.loading ? (
|
||||
<PluginSetupMethodTabs
|
||||
value={pluginSetupTab}
|
||||
onChange={selectPluginSetupTab}
|
||||
/>
|
||||
{pluginSetupTab === "agent" && dialogPlugin ? (
|
||||
<PluginAgentInstructions plugin={dialogPlugin} />
|
||||
) : newKey.loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-10 text-[13px] text-[#A1A1AA]">
|
||||
<Loader className="size-4 animate-spin" />
|
||||
Generating your key…
|
||||
</div>
|
||||
) : (
|
||||
) : newKey.key ? (
|
||||
<InstallSteps steps={setupSteps} apiKey={newKey.key} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<p className="text-[13px] text-[#A1A1AA]">
|
||||
We couldn't generate the key for the manual setup.
|
||||
</p>
|
||||
<PillButton onClick={generatePluginKey}>Try again</PillButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -3940,6 +4106,7 @@ export function IntegrationsView({
|
|||
pluginId: null,
|
||||
loading: false,
|
||||
})
|
||||
setPluginSetupTab("agent")
|
||||
void setConnectTarget(null)
|
||||
}}
|
||||
className={cn(
|
||||
|
|
@ -4078,7 +4245,7 @@ export function IntegrationsView({
|
|||
if (!connectedPluginId) return
|
||||
const pluginId = connectedPluginId
|
||||
setConnectedPluginId(null)
|
||||
createPluginKeyMutation.mutate(pluginId)
|
||||
openPluginSetup(pluginId)
|
||||
}}
|
||||
disabled={!!connectingPlugin}
|
||||
>
|
||||
|
|
@ -4109,91 +4276,6 @@ export function IntegrationsView({
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={!!finishSetupPluginId}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setFinishSetupPluginId(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-[560px] sm:rounded-[22px]",
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
Finish setup {finishSetupPlugin?.name ?? "plugin"}
|
||||
</DialogTitle>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{finishSetupPlugin && (
|
||||
<IconBox>
|
||||
<Image
|
||||
src={finishSetupPlugin.icon}
|
||||
alt={finishSetupPlugin.name}
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</IconBox>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[16px] font-semibold leading-tight text-[#FAFAFA]">
|
||||
Finish setup {finishSetupPlugin?.name ?? "plugin"}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[12px] text-[#A1A1AA]">
|
||||
Complete install in the tool — this card turns active after the
|
||||
first API call.
|
||||
</p>
|
||||
</div>
|
||||
<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 className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 rounded-[14px] bg-[#14161A] p-4 sm:p-5",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
{finishSetupSteps.length > 0 ? (
|
||||
<InstallSteps steps={finishSetupSteps} />
|
||||
) : (
|
||||
<p className="text-[13px] text-[#A1A1AA]">
|
||||
Open {finishSetupPlugin?.name ?? "the plugin"} and finish
|
||||
authentication, then send a test memory.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</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) => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { LogoFull } from "@ui/assets/Logo"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { Input } from "@ui/components/input"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { cn } from "@lib/utils"
|
||||
import {
|
||||
ArrowRight,
|
||||
|
|
@ -15,6 +16,7 @@ import {
|
|||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react"
|
||||
import { getBrainWorkspaceDomain } from "@/lib/billing-utils"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import {
|
||||
type ResearchEvent,
|
||||
|
|
@ -102,13 +104,19 @@ export function CompanyBrainOnboarding({
|
|||
setPhase("trial")
|
||||
analytics.brainTrialCardViewed()
|
||||
}, [needsSetup, phase])
|
||||
const { org } = useAuth()
|
||||
const [domain, setDomain] = useState(initialDomain)
|
||||
const [organizationChoices, setOrganizationChoices] = useState<
|
||||
CompanyBrainOrganizationChoice[] | null
|
||||
>(null)
|
||||
const [serverSchedulesResearch, setServerSchedulesResearch] = useState(false)
|
||||
const firstName = name.trim().split(/\s+/)[0] ?? ""
|
||||
const clean = normalizeDomain(domain)
|
||||
// Returning from checkout remounts and reseeds local state from the email domain,
|
||||
// so past the confirm step the org's stored domain is the one to trust.
|
||||
const confirmedDomain = getBrainWorkspaceDomain(org?.metadata)
|
||||
const clean = normalizeDomain(
|
||||
phase === "confirm" ? domain : confirmedDomain || domain,
|
||||
)
|
||||
const queryClient = useQueryClient()
|
||||
const { status: researchStatus } = useResearchStatus(phase === "research")
|
||||
const researchDone = researchStatus === "done"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
329
apps/web/components/settings/mcp-directory-browser.tsx
Normal file
329
apps/web/components/settings/mcp-directory-browser.tsx
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
"use client"
|
||||
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import type { McpDirectoryEntry } from "@/lib/mcp-directory"
|
||||
import { brainConnectorIcon } from "../brain-connector-icons"
|
||||
import { ConnectorCard, ScopeChip } from "../directory/connector-card"
|
||||
import { PillButton } from "../integrations/install-steps"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
let directoryCache: McpDirectoryEntry[] | null = null
|
||||
|
||||
function isDirectoryEntry(value: unknown): value is McpDirectoryEntry {
|
||||
if (!value || typeof value !== "object") return false
|
||||
const entry = value as Partial<McpDirectoryEntry>
|
||||
return (
|
||||
typeof entry.id === "string" &&
|
||||
typeof entry.name === "string" &&
|
||||
(entry.type === "remote" || entry.type === "local") &&
|
||||
(entry.url === null || typeof entry.url === "string") &&
|
||||
typeof entry.auth === "string" &&
|
||||
(entry.note === null || typeof entry.note === "string") &&
|
||||
Array.isArray(entry.categories) &&
|
||||
entry.categories.every((category) => typeof category === "string") &&
|
||||
typeof entry.popularity === "number" &&
|
||||
(entry.iconDomain === null || typeof entry.iconDomain === "string") &&
|
||||
["custom", "unsupported"].includes(entry.setup ?? "") &&
|
||||
(entry.oauthCapability === null ||
|
||||
["dcr", "preregistered"].includes(entry.oauthCapability ?? "")) &&
|
||||
Array.isArray(entry.authMethods) &&
|
||||
entry.authMethods.every((method) =>
|
||||
["oauth", "api-key"].includes(method),
|
||||
) &&
|
||||
["fixed", "tenant", "unavailable", "local"].includes(
|
||||
entry.availability ?? "",
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function parseDirectory(value: unknown) {
|
||||
if (!value || typeof value !== "object") throw new Error("invalid catalog")
|
||||
const entries = (value as { entries?: unknown }).entries
|
||||
if (!Array.isArray(entries) || !entries.every(isDirectoryEntry)) {
|
||||
throw new Error("invalid catalog")
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
async function loadDirectory(signal: AbortSignal) {
|
||||
if (directoryCache) return directoryCache
|
||||
const response = await fetch(`${BACKEND}/brain/mcp-connections/directory`, {
|
||||
signal,
|
||||
cache: "default",
|
||||
credentials: "include",
|
||||
})
|
||||
if (!response.ok) throw new Error("catalog request failed")
|
||||
directoryCache = parseDirectory(await response.json())
|
||||
return directoryCache
|
||||
}
|
||||
|
||||
export function useMcpDirectory() {
|
||||
const [entries, setEntries] = useState<McpDirectoryEntry[]>(
|
||||
() => directoryCache ?? [],
|
||||
)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void loadDirectory(controller.signal)
|
||||
.then((data) => {
|
||||
setEntries(data)
|
||||
setError(false)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return
|
||||
setError(true)
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
return { entries, error }
|
||||
}
|
||||
|
||||
export function categoryLabel(value: string) {
|
||||
return value
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
export function entrySlug(entry: McpDirectoryEntry) {
|
||||
return entry.name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 63)
|
||||
}
|
||||
|
||||
// Mirrors the backend's URL normalization so connection rows match entries.
|
||||
export function normalizeServerUrl(value: string) {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`.toLowerCase()
|
||||
} catch {
|
||||
return value.toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
// An entry we can actually take the user through connecting.
|
||||
export function isEntrySetUppable(entry: McpDirectoryEntry) {
|
||||
return (
|
||||
entry.setup !== "unsupported" &&
|
||||
entry.authMethods.length > 0 &&
|
||||
(entry.availability === "fixed" || entry.availability === "tenant")
|
||||
)
|
||||
}
|
||||
|
||||
// Entries worth listing at all — servers with no reachable URL are dropped.
|
||||
export function listableDirectoryEntries(entries: McpDirectoryEntry[]) {
|
||||
return entries.filter((entry) => entry.availability !== "unavailable")
|
||||
}
|
||||
|
||||
export function entryMatchesQuery(entry: McpDirectoryEntry, needle: string) {
|
||||
return [entry.name, entry.url, entry.note, ...entry.categories]
|
||||
.filter(Boolean)
|
||||
.some((value) => value?.toLowerCase().includes(needle))
|
||||
}
|
||||
|
||||
function DirectoryIcon({ entry }: { entry: McpDirectoryEntry }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
if (!entry.iconDomain || failed) {
|
||||
return brainConnectorIcon(entrySlug(entry), entry.name, "size-4")
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={`/api/mcp-icon?domain=${encodeURIComponent(entry.iconDomain)}`}
|
||||
alt=""
|
||||
className="size-5 object-contain"
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DirectoryEntryCard({
|
||||
entry,
|
||||
connected,
|
||||
onSetUp,
|
||||
}: {
|
||||
entry: McpDirectoryEntry
|
||||
connected: boolean
|
||||
onSetUp: (entry: McpDirectoryEntry) => void
|
||||
}) {
|
||||
const canSetUp = !connected && isEntrySetUppable(entry)
|
||||
const status = connected
|
||||
? "Connected"
|
||||
: canSetUp
|
||||
? "Not connected"
|
||||
: entry.availability === "local"
|
||||
? "Desktop only"
|
||||
: "Coming soon"
|
||||
return (
|
||||
<ConnectorCard
|
||||
icon={<DirectoryIcon entry={entry} />}
|
||||
name={entry.name}
|
||||
subtitle={entrySubtitle(entry)}
|
||||
footerLeft={<ScopeChip label={status} connected={connected} />}
|
||||
footerRight={
|
||||
canSetUp ? (
|
||||
<PillButton onClick={() => onSetUp(entry)}>Set up</PillButton>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function entrySubtitle(entry: McpDirectoryEntry) {
|
||||
if (entry.categories.length > 0) {
|
||||
return entry.categories.slice(0, 2).map(categoryLabel).join(" · ")
|
||||
}
|
||||
return entry.type === "local" ? "Desktop extension" : "MCP server"
|
||||
}
|
||||
|
||||
// One directory listing: a dense single-line row. The default state carries no
|
||||
// status text — in a marketplace, "not connected" is implied. Only connection,
|
||||
// or the reason there's no button, earns words.
|
||||
export function DirectoryEntryRow({
|
||||
entry,
|
||||
connected,
|
||||
onSetUp,
|
||||
}: {
|
||||
entry: McpDirectoryEntry
|
||||
connected: boolean
|
||||
onSetUp: (entry: McpDirectoryEntry) => void
|
||||
}) {
|
||||
const canSetUp = !connected && isEntrySetUppable(entry)
|
||||
return (
|
||||
<div className="group flex min-w-0 items-center gap-3 rounded-xl px-2.5 py-2 transition-colors hover:bg-[#14161A]">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-[9px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
|
||||
<DirectoryIcon entry={entry} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[13px] font-semibold text-[#FAFAFA]">
|
||||
{entry.name}
|
||||
</p>
|
||||
<p className="mt-px truncate text-[11px] font-medium text-[#616875]">
|
||||
{entrySubtitle(entry)}
|
||||
</p>
|
||||
</div>
|
||||
{connected ? (
|
||||
<span className="flex shrink-0 items-center gap-1.5 pr-1 text-[11px] font-medium text-[#FAFAFA]">
|
||||
<span className="size-[6px] rounded-full bg-[#00AC3F]" />
|
||||
Connected
|
||||
</span>
|
||||
) : canSetUp ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSetUp(entry)}
|
||||
className="h-7 shrink-0 cursor-pointer rounded-full bg-[#1B2028] px-3 text-[12px] font-medium text-[#FAFAFA]/70 transition-colors group-hover:bg-[#252C37] group-hover:text-[#FAFAFA] hover:bg-[#2B3340]"
|
||||
>
|
||||
Set up
|
||||
</button>
|
||||
) : (
|
||||
<span className="shrink-0 pr-1 text-[11px] font-medium text-[#4E5560]">
|
||||
{entry.availability === "local" ? "Desktop only" : "Coming soon"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const GRID_PAGE_SIZE = 24
|
||||
|
||||
// Paged card grid over the MCP directory. With a query it renders matching
|
||||
// servers; without one it renders the whole marketplace.
|
||||
export function McpDirectoryGrid({
|
||||
query = "",
|
||||
entries,
|
||||
loadError,
|
||||
excludeSlugs,
|
||||
isEntryConnected,
|
||||
onSetUp,
|
||||
suppressEmpty,
|
||||
}: {
|
||||
query?: string
|
||||
entries: McpDirectoryEntry[]
|
||||
loadError: boolean
|
||||
// entries already rendered elsewhere (e.g. the built-in app catalog)
|
||||
excludeSlugs?: Set<string>
|
||||
isEntryConnected: (entry: McpDirectoryEntry) => boolean
|
||||
onSetUp: (entry: McpDirectoryEntry) => void
|
||||
// the caller rendered its own matches, so an empty grid isn't "no results"
|
||||
suppressEmpty?: boolean
|
||||
}) {
|
||||
const [visibleCount, setVisibleCount] = useState(GRID_PAGE_SIZE)
|
||||
const needle = query.trim().toLowerCase()
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset paging per query
|
||||
useEffect(() => {
|
||||
setVisibleCount(GRID_PAGE_SIZE)
|
||||
}, [needle])
|
||||
|
||||
// Connected first, then connectable, then "coming soon"/desktop-only.
|
||||
const matches = useMemo(() => {
|
||||
const found = entries.filter(
|
||||
(entry) =>
|
||||
!excludeSlugs?.has(entrySlug(entry)) &&
|
||||
(!needle || entryMatchesQuery(entry, needle)),
|
||||
)
|
||||
return found.sort(
|
||||
(a, b) =>
|
||||
Number(isEntryConnected(b)) - Number(isEntryConnected(a)) ||
|
||||
Number(isEntrySetUppable(b)) - Number(isEntrySetUppable(a)),
|
||||
)
|
||||
}, [entries, excludeSlugs, isEntryConnected, needle])
|
||||
|
||||
if (loadError) {
|
||||
if (suppressEmpty) return null
|
||||
return (
|
||||
<div className="rounded-xl border border-[#252B34] border-dashed px-4 py-10 text-center text-[13px] font-medium text-[#737373]">
|
||||
The MCP directory couldn't be loaded. Refresh to try again.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
if (suppressEmpty) return null
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 rounded-xl border border-[#252B34] border-dashed px-4 py-10 text-[13px] font-medium text-[#737373]">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading MCP directory
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (matches.length === 0) {
|
||||
if (suppressEmpty) return null
|
||||
return (
|
||||
<div className="rounded-xl border border-[#252B34] border-dashed px-4 py-10 text-center text-[13px] font-medium text-[#737373]">
|
||||
No integrations match “{query.trim()}”.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-x-3 gap-y-0.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{matches.slice(0, visibleCount).map((entry) => (
|
||||
<DirectoryEntryRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
connected={isEntryConnected(entry)}
|
||||
onSetUp={onSetUp}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{visibleCount < matches.length ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisibleCount((count) => count + GRID_PAGE_SIZE)}
|
||||
className="mx-auto flex h-9 cursor-pointer items-center rounded-full border border-[#2A313C] px-5 text-[12px] font-semibold text-[#D4D4D8] transition-colors hover:border-[#3A4150] hover:text-[#FAFAFA]"
|
||||
>
|
||||
Show {Math.min(GRID_PAGE_SIZE, matches.length - visibleCount)} more ·{" "}
|
||||
{visibleCount} of {matches.length.toLocaleString()}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,16 +1,8 @@
|
|||
import { useAuth } from "@lib/auth-context"
|
||||
import {
|
||||
getBrainMode,
|
||||
getCompanyBrainOverride,
|
||||
hasCompanyBrain,
|
||||
} from "@/lib/billing-utils"
|
||||
import { isCompanyBrainOrg } from "@/lib/billing-utils"
|
||||
|
||||
export function useHasCompanyBrain(): boolean {
|
||||
const { org } = useAuth()
|
||||
const metadata = org?.metadata as Record<string, unknown> | string | undefined
|
||||
// An explicit concierge override wins over the team-onboarding fallback.
|
||||
const override = getCompanyBrainOverride(metadata)
|
||||
if (override !== undefined) return override
|
||||
// Team-brain orgs use brain spaces even before the add-on webhook lands.
|
||||
return hasCompanyBrain(metadata) || getBrainMode(metadata) === "team"
|
||||
return isCompanyBrainOrg(metadata)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,15 @@ export function getBrainMode(
|
|||
: null
|
||||
}
|
||||
|
||||
export function isCompanyBrainOrg(
|
||||
metadataRaw: Record<string, unknown> | string | null | undefined,
|
||||
): boolean {
|
||||
const override = getCompanyBrainOverride(metadataRaw)
|
||||
if (override !== undefined) return override
|
||||
if (hasCompanyBrain(metadataRaw)) return true
|
||||
return getBrainMode(metadataRaw) === "team"
|
||||
}
|
||||
|
||||
export type BrainTrialStatus =
|
||||
| "active"
|
||||
| "exhausted"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
import {
|
||||
getBrainMode,
|
||||
getBrainWorkspaceDomain,
|
||||
getCompanyBrainOverride,
|
||||
hasCompanyBrain,
|
||||
} from "./billing-utils"
|
||||
import { getBrainWorkspaceDomain, isCompanyBrainOrg } from "./billing-utils"
|
||||
|
||||
export type BrainEntryOrganization = {
|
||||
id: string
|
||||
|
|
@ -18,21 +13,12 @@ export type CompanyBrainEntryDecision =
|
|||
| { action: "choose"; organizations: BrainEntryOrganization[] }
|
||||
| { action: "create" }
|
||||
|
||||
export function isCompanyBrainOrganization(
|
||||
organization: BrainEntryOrganization,
|
||||
): boolean {
|
||||
const override = getCompanyBrainOverride(organization.metadata)
|
||||
if (override !== undefined) return override
|
||||
return (
|
||||
hasCompanyBrain(organization.metadata) ||
|
||||
getBrainMode(organization.metadata) === "team"
|
||||
)
|
||||
}
|
||||
|
||||
export function getCompanyBrainOrganizations(
|
||||
organizations: BrainEntryOrganization[],
|
||||
): BrainEntryOrganization[] {
|
||||
return organizations.filter(isCompanyBrainOrganization)
|
||||
return organizations.filter((organization) =>
|
||||
isCompanyBrainOrg(organization.metadata),
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeDomain(domain: string): string {
|
||||
|
|
|
|||
21
apps/web/lib/mcp-directory.ts
Normal file
21
apps/web/lib/mcp-directory.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export type McpDirectoryAvailability =
|
||||
| "fixed"
|
||||
| "tenant"
|
||||
| "unavailable"
|
||||
| "local"
|
||||
|
||||
export type McpDirectoryEntry = {
|
||||
id: string
|
||||
name: string
|
||||
type: "remote" | "local"
|
||||
url: string | null
|
||||
auth: string
|
||||
note: string | null
|
||||
categories: string[]
|
||||
popularity: number
|
||||
availability: McpDirectoryAvailability
|
||||
iconDomain: string | null
|
||||
setup: "custom" | "unsupported"
|
||||
oauthCapability: "dcr" | "preregistered" | null
|
||||
authMethods: Array<"oauth" | "api-key">
|
||||
}
|
||||
518
apps/web/lib/mcp-icon-domains.json
Normal file
518
apps/web/lib/mcp-icon-domains.json
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
{
|
||||
"domains": [
|
||||
"10xgenomics.com",
|
||||
"activecampaign.com",
|
||||
"actively.ai",
|
||||
"adisinsight-mcp.springer.com",
|
||||
"adobe-creativity.adobe.io",
|
||||
"adobeaemcloud.com",
|
||||
"aep-ai-ama.adobe.io",
|
||||
"affinity.co",
|
||||
"aftership.com",
|
||||
"agent.thoughtspot.app",
|
||||
"agentmail.to",
|
||||
"agents.riskanalytics.dnb.com",
|
||||
"agenttools.wolfram.com",
|
||||
"ahrefs.com",
|
||||
"ai-connect.norton.com",
|
||||
"ai-inc.mailchimp.com",
|
||||
"ai-inc.quickbooks.intuit.com",
|
||||
"ai-inc.turbotax.intuit.com",
|
||||
"ai-tools.tillermoney.com",
|
||||
"ai.chronograph.pe",
|
||||
"ai.consilio.com",
|
||||
"ai.thirdbridge.com",
|
||||
"ai.todoist.net",
|
||||
"ai.veltra.com",
|
||||
"airbnb.com",
|
||||
"airtable.com",
|
||||
"ajo-mcp.adobe.io",
|
||||
"alltrails.com",
|
||||
"alma.food",
|
||||
"alphavantage.co",
|
||||
"alphaxiv.org",
|
||||
"alpic.ai",
|
||||
"amplitude.com",
|
||||
"analytics.credit.morningstar.com",
|
||||
"analytics.lseg.com",
|
||||
"android.com",
|
||||
"angellist.com",
|
||||
"anthropic.mcp.creditkarma.com",
|
||||
"api-ssl.bitly.com",
|
||||
"apify.com",
|
||||
"apigw.americanexpress.com",
|
||||
"apollo.io",
|
||||
"apollographql.com",
|
||||
"app.airops.com",
|
||||
"app.base44.com",
|
||||
"app.brighthire.ai",
|
||||
"app.carta.com",
|
||||
"app.definely.com",
|
||||
"app.eraser.io",
|
||||
"app.files.com",
|
||||
"app.flourish.studio",
|
||||
"app.fyxer.com",
|
||||
"app.grasp-ai.com",
|
||||
"app.hanoverpark.com",
|
||||
"app.ketryx.com",
|
||||
"app.magicschool.ai",
|
||||
"app.midpage.ai",
|
||||
"app.synthesize.bio",
|
||||
"app.tropicapp.io",
|
||||
"app.unthread.io",
|
||||
"appfolio.com",
|
||||
"asana.com",
|
||||
"ashbyhq.com",
|
||||
"asset-management.mcp.cloudinary.com",
|
||||
"atlassian.com",
|
||||
"attention.tech",
|
||||
"attio.com",
|
||||
"audible.com",
|
||||
"auraintelligence.com",
|
||||
"autodesk.com",
|
||||
"autorfp.ai",
|
||||
"benchling.com",
|
||||
"benevity.org",
|
||||
"bigdata.com",
|
||||
"bigquery.googleapis.com",
|
||||
"bindings.mcp.cloudflare.com",
|
||||
"blockscout.com",
|
||||
"blueconic.com",
|
||||
"boltz.bio",
|
||||
"box.com",
|
||||
"brandfetch.io",
|
||||
"brave.com",
|
||||
"braze.com",
|
||||
"brevo.com",
|
||||
"brex.com",
|
||||
"briskteaching.com",
|
||||
"calendar.google.com",
|
||||
"calendly.com",
|
||||
"callbacks.omniapp.co",
|
||||
"canary-data.com",
|
||||
"candid.org",
|
||||
"canva.com",
|
||||
"cargoai.co",
|
||||
"cbinsights.com",
|
||||
"chargebee.com",
|
||||
"chartmogul.com",
|
||||
"chatgpt.mermaid.ai",
|
||||
"checkatrade.com",
|
||||
"circleback.ai",
|
||||
"civitatis-claude-app.civitatis.com",
|
||||
"cja-mcp.adobe.io",
|
||||
"clapi.guidepoint.io",
|
||||
"clarify.ai",
|
||||
"clarity-sfdr20-mcp.pro.clarity.ai",
|
||||
"claude-mcp-api.ml.goodnotes.com",
|
||||
"claude.mcp.kpler.com",
|
||||
"claude.slidesgpt.com",
|
||||
"claudecompanion.gateway.api.mcafee.com",
|
||||
"clay.com",
|
||||
"clerk.com",
|
||||
"clickhouse.cloud",
|
||||
"clickup.com",
|
||||
"close.com",
|
||||
"cloud.cdata.com",
|
||||
"cloudimanage.com",
|
||||
"cloze.com",
|
||||
"cognitoforms.com",
|
||||
"coindesk.com",
|
||||
"columnapi.com",
|
||||
"cometchat.com",
|
||||
"commonroom.io",
|
||||
"compute.googleapis.com",
|
||||
"connect.squareup.com",
|
||||
"connector.scholargateway.ai",
|
||||
"consensus.app",
|
||||
"contentsquare.com",
|
||||
"context.era.app",
|
||||
"context7.com",
|
||||
"coralogix.com",
|
||||
"coteach.ai",
|
||||
"coupler.io",
|
||||
"coursera.com",
|
||||
"courtlistener.com",
|
||||
"courtroom5.com",
|
||||
"craft.do",
|
||||
"crossbeam.com",
|
||||
"crypto.com",
|
||||
"customer.io",
|
||||
"daloopa.com",
|
||||
"dashboard.plaid.com",
|
||||
"data-search.apigw.feverup.com",
|
||||
"databricks.com",
|
||||
"datacamp.com",
|
||||
"datadoghq.com",
|
||||
"datagrail.io",
|
||||
"datahub.com",
|
||||
"day.ai",
|
||||
"deepl.com",
|
||||
"demandapi-mcp.booking.com",
|
||||
"descript.com",
|
||||
"descrybe.com",
|
||||
"developer.api.autodesk.com",
|
||||
"developer.mcp.mastercard.com",
|
||||
"devrev.ai",
|
||||
"dhsprogram.com",
|
||||
"dice.com",
|
||||
"diffit.me",
|
||||
"digits.com",
|
||||
"directbooker.ai",
|
||||
"docs.superhuman.com",
|
||||
"docuseal.com",
|
||||
"docusign.com",
|
||||
"dovetail.com",
|
||||
"dremio.com",
|
||||
"drive.google.com",
|
||||
"dropbox.com",
|
||||
"dynatrace.com",
|
||||
"econ-index.mcp.claude.com",
|
||||
"elevenlabs.io",
|
||||
"elicit.com",
|
||||
"entendre.finance",
|
||||
"eulerapp.com",
|
||||
"everlaw.com",
|
||||
"exa.ai",
|
||||
"example-server.modelcontextprotocol.io",
|
||||
"excalidraw.com",
|
||||
"exp-app-mcp.prod.ep.viator.com",
|
||||
"expedia.com",
|
||||
"expo.dev",
|
||||
"factset.com",
|
||||
"fathom.ai",
|
||||
"fellow.app",
|
||||
"felt.com",
|
||||
"fids-mcp.ice.com",
|
||||
"fig-mcp.instacart.com",
|
||||
"figma.com",
|
||||
"financeanalytics.dnb.com",
|
||||
"financialmodelingprep.com",
|
||||
"fireflies.ai",
|
||||
"firefox.com",
|
||||
"fiscal.ai",
|
||||
"fitch.group",
|
||||
"floot.com",
|
||||
"frontify-integrations.com",
|
||||
"fullstory.com",
|
||||
"funnel.io",
|
||||
"g.runorion.com",
|
||||
"g2.com",
|
||||
"gainsight.com",
|
||||
"gamma.app",
|
||||
"gatewaymcp.verisk.com",
|
||||
"genai-prod-ext.dominos.co.in",
|
||||
"getaugust.ai",
|
||||
"getguru.com",
|
||||
"getmontecarlo.com",
|
||||
"getunblocked.com",
|
||||
"glean.com",
|
||||
"global.datasite.com",
|
||||
"glovoapp.com",
|
||||
"gmail.com",
|
||||
"gocardless.com",
|
||||
"godaddy.com",
|
||||
"gopigment.com",
|
||||
"govcon.dev",
|
||||
"govtribe.com",
|
||||
"grain.com",
|
||||
"granola.ai",
|
||||
"grantedai.com",
|
||||
"grasshopper-mcp.prd.narmitech.com",
|
||||
"grounding.kensho.com",
|
||||
"gusto.com",
|
||||
"harmonic.ai",
|
||||
"harness.io",
|
||||
"harvey.ai",
|
||||
"haveibeenpwned.com",
|
||||
"hcls.mcp.claude.com",
|
||||
"healthex.io",
|
||||
"helium10.com",
|
||||
"heygen.com",
|
||||
"highspot.com",
|
||||
"honeycomb.io",
|
||||
"hrn-production.helix.com",
|
||||
"hubspot.com",
|
||||
"huggingface.co",
|
||||
"ibisworld.com",
|
||||
"ibkr.com",
|
||||
"idiolect.app",
|
||||
"ifttt.com",
|
||||
"imedidata.com",
|
||||
"incident.io",
|
||||
"indeed.com",
|
||||
"inductive.bio",
|
||||
"inkbox.ai",
|
||||
"insiderone.com",
|
||||
"instrumentl.com",
|
||||
"intapp.com",
|
||||
"integrators.prod.api.tabsplatform.com",
|
||||
"intercom.com",
|
||||
"ipone.clarivate.com",
|
||||
"ironcladapp.com",
|
||||
"isometric.com",
|
||||
"item.app",
|
||||
"jam.dev",
|
||||
"jentic.com",
|
||||
"jotform.com",
|
||||
"jupiterone.com",
|
||||
"jusmundi.com",
|
||||
"k.owkin.com",
|
||||
"kfinance.kensho.com",
|
||||
"kg.mcp.learningcommons.org",
|
||||
"kindora-mcp.azurewebsites.net",
|
||||
"kiwi.com",
|
||||
"klaviyo.com",
|
||||
"krisp.ai",
|
||||
"kubernetes.io",
|
||||
"lastminute.com",
|
||||
"latch.bio",
|
||||
"latticehq.com",
|
||||
"lawve.ai",
|
||||
"learn.microsoft.com",
|
||||
"leaveadot.com",
|
||||
"legal-mcp.thomsonreuters.com",
|
||||
"legaldatahunter.com",
|
||||
"legalzoom.com",
|
||||
"letsbot.net",
|
||||
"letsdeel.com",
|
||||
"light.inc",
|
||||
"lightfield.app",
|
||||
"lilt.com",
|
||||
"linear.app",
|
||||
"listenlabs.ai",
|
||||
"litmus.com",
|
||||
"livestorm.co",
|
||||
"localfalcon.com",
|
||||
"lorikeetcx.ai",
|
||||
"lovable.dev",
|
||||
"lucid.app",
|
||||
"luminpdf.com",
|
||||
"lumonic.com",
|
||||
"lunarcrush.ai",
|
||||
"lusha.com",
|
||||
"macaly.com",
|
||||
"magicpatterns.com",
|
||||
"mail.superhuman.com",
|
||||
"mailerlite.com",
|
||||
"make.com",
|
||||
"manufact.com",
|
||||
"marketplace-mcp.us-east-1.api.aws",
|
||||
"matrixmcp.virtuoso.ai",
|
||||
"mcp-app.turkishtechlab.com",
|
||||
"mcp-demo.airwallex.com",
|
||||
"mcp-gateway-external-pilot.spotify.net",
|
||||
"mcp-pub.aiera.com",
|
||||
"mcp-public.basecamp-research.com",
|
||||
"mcp-server.egnyte.com",
|
||||
"mcp-server.signnow.com",
|
||||
"mcp-server.zomato.com",
|
||||
"mcp-v1.tixel.com",
|
||||
"mcp2.readwise.io",
|
||||
"meetcampfire.com",
|
||||
"melon.com",
|
||||
"meltwater.com",
|
||||
"mem.ai",
|
||||
"mem0.ai",
|
||||
"mercadolibre.com",
|
||||
"mercury.com",
|
||||
"metabase.com",
|
||||
"metal.ai",
|
||||
"metaview.ai",
|
||||
"microsoft.com",
|
||||
"mintlify.com",
|
||||
"miro.com",
|
||||
"mixpanel.com",
|
||||
"monday.com",
|
||||
"mongodb.com",
|
||||
"moodys.com",
|
||||
"morningstar.com",
|
||||
"mospi.gov.in",
|
||||
"motherduck.com",
|
||||
"msci.com",
|
||||
"mtnewswires.com",
|
||||
"myisolved.com",
|
||||
"n8n.io",
|
||||
"netlify-mcp.netlify.app",
|
||||
"netsuite.com",
|
||||
"nimbleway.com",
|
||||
"nlp.api.production.unwrap.ai",
|
||||
"nooks.in",
|
||||
"notion.com",
|
||||
"omni.mulesoft.com",
|
||||
"onesignal.com",
|
||||
"ontra.ai",
|
||||
"open-ai-app.stubhub.net",
|
||||
"oreilly.com",
|
||||
"otter.ai",
|
||||
"ottotheagent.com",
|
||||
"outreach.io",
|
||||
"pagerduty.com",
|
||||
"pandadoc.com",
|
||||
"partner-mcp.ticketmaster.com",
|
||||
"patlytics.ai",
|
||||
"paypal.com",
|
||||
"paytmpayments.com",
|
||||
"peec.ai",
|
||||
"pga.com",
|
||||
"phished.io",
|
||||
"phoenix.hginsights.com",
|
||||
"pi.security",
|
||||
"pinegap.ai",
|
||||
"platform.opentargets.org",
|
||||
"plaud.ai",
|
||||
"playmcp.kakao.com",
|
||||
"polaranalytics.com",
|
||||
"pophive.org",
|
||||
"posthog.com",
|
||||
"postman.com",
|
||||
"premium.mcp.pitchbook.com",
|
||||
"privacy.com",
|
||||
"process.st",
|
||||
"prod.originhq.com",
|
||||
"production.ai-mcp-extensibility-prd.tamg.cloud",
|
||||
"projects.motionapp.com",
|
||||
"pscale.dev",
|
||||
"public-api.wordpress.com",
|
||||
"pubmed.mcp.claude.com",
|
||||
"qbo-connector.meridian.pilot.com",
|
||||
"qonto.com",
|
||||
"quartr.com",
|
||||
"quicknode.com",
|
||||
"quo.com",
|
||||
"railway.com",
|
||||
"rallyuxr.com",
|
||||
"ramp-mcp-remote.ramp.com",
|
||||
"ramp.com",
|
||||
"rapid7.com",
|
||||
"razorpay.com",
|
||||
"react.dev",
|
||||
"read.ai",
|
||||
"reclaim.ai",
|
||||
"reddit.com",
|
||||
"relativity.com",
|
||||
"remote.com",
|
||||
"render.com",
|
||||
"replit-mcp.com",
|
||||
"resend.com",
|
||||
"retool.com",
|
||||
"revolut.com",
|
||||
"rillet.com",
|
||||
"roamresearch.com",
|
||||
"roboflow.com",
|
||||
"salesflare.com",
|
||||
"salesloft.com",
|
||||
"sanity.io",
|
||||
"sap.com",
|
||||
"scamguard.malwarebytes.com",
|
||||
"scite.ai",
|
||||
"seismic.com",
|
||||
"semrush.com",
|
||||
"send.co",
|
||||
"sentry.dev",
|
||||
"servicenow.com",
|
||||
"services.biorender.com",
|
||||
"services.functionhealth.com",
|
||||
"services.oxfordeconomics.com",
|
||||
"setup.shopify.com",
|
||||
"shapes.co",
|
||||
"shipbob.com",
|
||||
"shippo.com",
|
||||
"shutterstock.com",
|
||||
"sigmacomputing.com",
|
||||
"signeasy.com",
|
||||
"similarweb.com",
|
||||
"sketch.com",
|
||||
"sketchup.com",
|
||||
"slack.com",
|
||||
"smartbear.com",
|
||||
"smartling.com",
|
||||
"smartsheet.com",
|
||||
"snowflake.com",
|
||||
"snowstorm-mcp.snomedtools.org",
|
||||
"snyk.io",
|
||||
"solveintelligence.com",
|
||||
"sourcegraph.com",
|
||||
"spinach.ai",
|
||||
"splice.com",
|
||||
"sprouts-mcp-server.kartikay-dhar.workers.dev",
|
||||
"squareup.com",
|
||||
"stackoverflow.com",
|
||||
"staircase.ai",
|
||||
"starburst.io",
|
||||
"strava.com",
|
||||
"stripe.com",
|
||||
"stytch.dev",
|
||||
"sumble.com",
|
||||
"sumsub.com",
|
||||
"supabase.com",
|
||||
"super.com",
|
||||
"supermetrics.com",
|
||||
"surveymonkey.com",
|
||||
"swagger.mcp.smartbear.com",
|
||||
"sybill.ai",
|
||||
"synapse.org",
|
||||
"tableau.com",
|
||||
"taskrabbit.com",
|
||||
"tavily.com",
|
||||
"taxact.com",
|
||||
"teacher-tools.eedi.ai",
|
||||
"teamtailor.com",
|
||||
"techgc.co",
|
||||
"tellme.embat.io",
|
||||
"thumbtack.com",
|
||||
"tickettailor.ai",
|
||||
"ticktick.com",
|
||||
"tigerdata.com",
|
||||
"tines.com",
|
||||
"tldraw-mcp-app.tldraw.workers.dev",
|
||||
"tldv.io",
|
||||
"tomtom.com",
|
||||
"tray.io",
|
||||
"trellis.law",
|
||||
"trello.com",
|
||||
"trivago.com",
|
||||
"tryprofound.com",
|
||||
"turquoise.health",
|
||||
"twilio.com",
|
||||
"uakozrqrztgrgwoywxkx.supabase.co",
|
||||
"uber.com",
|
||||
"ubereats.com",
|
||||
"udemy.com",
|
||||
"unsplash.com",
|
||||
"use.kick.co",
|
||||
"usepylon.com",
|
||||
"v0.app",
|
||||
"vast.blueskyapi.com",
|
||||
"vendr.com",
|
||||
"vercel.com",
|
||||
"vibe.com",
|
||||
"virtuoso.ai",
|
||||
"voluum.com",
|
||||
"webexapis.com",
|
||||
"webflow.com",
|
||||
"webull.com",
|
||||
"whimsical.com",
|
||||
"windsor.ai",
|
||||
"wisdom-api.enterpret.com",
|
||||
"wisprflow.ai",
|
||||
"within.ai",
|
||||
"wix.com",
|
||||
"workable.com",
|
||||
"workato.com",
|
||||
"workfront.adobe.com",
|
||||
"workos.com",
|
||||
"wrike.com",
|
||||
"wyndhamhotels.com",
|
||||
"xactrestore-xactremodelserver-usw2-prod.propsol.io",
|
||||
"xero.com",
|
||||
"xweather.com",
|
||||
"zapier.com",
|
||||
"ziprecruiter.com",
|
||||
"zocks.io",
|
||||
"zoho.com",
|
||||
"zoom.us",
|
||||
"zoominfo.com",
|
||||
"zscaler.com"
|
||||
]
|
||||
}
|
||||
|
|
@ -41,13 +41,14 @@ export default async function proxy(request: Request) {
|
|||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// MCP setup page is public — no auth required
|
||||
if (url.searchParams.get("view") === "mcp") {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// Integrations index is public in guest mode; actions still require login.
|
||||
if (url.pathname === "/" && url.searchParams.get("view") === "integrations") {
|
||||
// Integrations index and MCP setup are public in guest mode; actions still
|
||||
// require login. The ?view param is only meaningful at "/" (see
|
||||
// lib/view-mode-context, which ignores it elsewhere), so scope it there —
|
||||
// unscoped, ?view=mcp would let any path skip the /api/ gate below.
|
||||
if (
|
||||
url.pathname === "/" &&
|
||||
["integrations", "mcp"].includes(url.searchParams.get("view") ?? "")
|
||||
) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
export const AnonymousAuth = ({
|
||||
dashboardPath = "/dashboard",
|
||||
loginPath = "/login",
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const createAnonymousSession = async () => {
|
||||
const session = await authClient.getSession()
|
||||
|
||||
if (!session?.session) {
|
||||
console.debug(
|
||||
"[ANONYMOUS_AUTH] No session found, creating anonymous session...",
|
||||
)
|
||||
|
||||
try {
|
||||
// Create anonymous session
|
||||
console.debug("[ANONYMOUS_AUTH] Calling signIn.anonymous()...")
|
||||
const res = await authClient.signIn.anonymous()
|
||||
|
||||
if (!res.token) {
|
||||
throw new Error("Failed to get anonymous token")
|
||||
}
|
||||
|
||||
// Get the new session
|
||||
console.debug(
|
||||
"[ANONYMOUS_AUTH] Getting new session with anonymous token...",
|
||||
)
|
||||
const newSession = await authClient.getSession()
|
||||
|
||||
console.debug("[ANONYMOUS_AUTH] New session retrieved:", newSession)
|
||||
|
||||
if (!newSession?.session || !newSession?.user) {
|
||||
console.error(
|
||||
"[ANONYMOUS_AUTH] Failed to create anonymous session - missing session or user",
|
||||
)
|
||||
throw new Error("Failed to create anonymous session")
|
||||
}
|
||||
|
||||
// Get the user's organization
|
||||
console.debug(
|
||||
"[ANONYMOUS_AUTH] Fetching organizations for anonymous user...",
|
||||
)
|
||||
const orgs = await authClient.organization.list()
|
||||
|
||||
console.debug("[ANONYMOUS_AUTH] Organizations retrieved:", {
|
||||
count: orgs?.length || 0,
|
||||
orgs: orgs?.map((o) => ({
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
slug: o.slug,
|
||||
})),
|
||||
})
|
||||
|
||||
const org = orgs?.[0]
|
||||
if (!org) {
|
||||
console.error(
|
||||
"[ANONYMOUS_AUTH] No organization found for anonymous user",
|
||||
)
|
||||
throw new Error("Failed to get organization for anonymous user")
|
||||
}
|
||||
|
||||
// Redirect to the organization dashboard
|
||||
console.debug(
|
||||
`[ANONYMOUS_AUTH] Redirecting anonymous user to /${org.slug}${dashboardPath}`,
|
||||
)
|
||||
router.push(dashboardPath)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[ANONYMOUS_AUTH] Anonymous session creation error:",
|
||||
error,
|
||||
)
|
||||
console.error("[ANONYMOUS_AUTH] Error details:", {
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
})
|
||||
router.push(loginPath)
|
||||
}
|
||||
} else if (session.session) {
|
||||
// Session exists, handle organization routing
|
||||
console.debug(
|
||||
"[ANONYMOUS_AUTH] Session exists, checking organization...",
|
||||
)
|
||||
|
||||
if (!session.session.activeOrganizationId) {
|
||||
console.debug(
|
||||
"[ANONYMOUS_AUTH] No active organization ID, fetching organizations...",
|
||||
)
|
||||
const orgs = await authClient.organization.list()
|
||||
|
||||
console.debug("[ANONYMOUS_AUTH] Organizations for existing user:", {
|
||||
count: orgs?.length || 0,
|
||||
orgs: orgs?.map((o) => ({
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
slug: o.slug,
|
||||
})),
|
||||
})
|
||||
|
||||
if (orgs?.[0]) {
|
||||
console.debug(
|
||||
`[ANONYMOUS_AUTH] Setting active organization to ${orgs[0].id}`,
|
||||
)
|
||||
await authClient.organization.setActive({
|
||||
organizationId: orgs[0].id,
|
||||
})
|
||||
console.debug(
|
||||
`[ANONYMOUS_AUTH] Redirecting to /${orgs[0].slug}${dashboardPath}`,
|
||||
)
|
||||
router.push(dashboardPath)
|
||||
}
|
||||
} else {
|
||||
console.debug(
|
||||
`[ANONYMOUS_AUTH] Active organization ID: ${session.session.activeOrganizationId}`,
|
||||
)
|
||||
console.debug(
|
||||
"[ANONYMOUS_AUTH] Fetching full organization details...",
|
||||
)
|
||||
const org = await authClient.organization.getFullOrganization({
|
||||
query: {
|
||||
organizationId: session.session.activeOrganizationId,
|
||||
},
|
||||
})
|
||||
|
||||
console.debug("[ANONYMOUS_AUTH] Full organization retrieved:", {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
})
|
||||
|
||||
console.debug(
|
||||
`[ANONYMOUS_AUTH] Redirecting to /${org.slug}${dashboardPath}`,
|
||||
)
|
||||
router.push(dashboardPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createAnonymousSession()
|
||||
}, [router.push])
|
||||
|
||||
// Return null as this component only handles the redirect logic
|
||||
return null
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue