Merge remote-tracking branch 'origin/main' into fix/openai-sdk-python-v4-api

This commit is contained in:
ved015 2026-08-21 21:24:40 +05:30
commit 7eca7c7e53
23 changed files with 2267 additions and 720 deletions

View file

@ -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": {

View file

@ -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()
})
})

View file

@ -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,

View file

@ -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)
}

View file

@ -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 = {

View file

@ -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>

View file

@ -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.

View file

@ -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
}

View 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,
},
})
}

View file

@ -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":

View 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>
)
}

View 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)]"

View file

@ -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&apos;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) => {

View file

@ -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

View 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>
)
}

View file

@ -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)
}

View file

@ -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"

View file

@ -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 {

View 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">
}

View 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"
]
}

View file

@ -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()
}

View file

@ -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
}