mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-07-09 17:18:51 +00:00
feat(web): add staff custom MCP connection cards (#1199)
--- **Session Details** - Session: [View Session](https://supermemory.us1.vorflux.com/agent-sessions/cd042b68-db46-431f-982c-3070096a37bd) - Requested by: Unknown - Address comments on this PR. Add `(aside)` to your comment to have me ignore it.
This commit is contained in:
parent
d7050ed332
commit
af61880da1
2 changed files with 206 additions and 31 deletions
|
|
@ -15,14 +15,16 @@ export function PillButton({
|
|||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
type = "button",
|
||||
}: {
|
||||
children: ReactNode
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
type?: "button" | "submit"
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { useCallback, useEffect, useState } from "react"
|
|||
import { toast } from "sonner"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { brainConnectorIcon, SlackMark } from "../brain-connector-icons"
|
||||
import { PillButton } from "../integrations/install-steps"
|
||||
|
||||
|
|
@ -26,6 +27,7 @@ type CatalogEntry = {
|
|||
}
|
||||
type ConnRow = {
|
||||
serverSlug: string
|
||||
serverUrl?: string
|
||||
authType: AuthType
|
||||
status: "active" | "pending" | "error"
|
||||
userId: string | null
|
||||
|
|
@ -37,6 +39,15 @@ function titleCase(s: string) {
|
|||
return s.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function slugifyMcpName(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 63)
|
||||
}
|
||||
|
||||
function SecondaryButton({
|
||||
children,
|
||||
href,
|
||||
|
|
@ -63,7 +74,7 @@ function StatusDot({ connected }: { connected: boolean }) {
|
|||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex items-center gap-1.5 text-[13px] font-medium",
|
||||
"flex shrink-0 items-center gap-1.5 whitespace-nowrap text-[12px] font-medium",
|
||||
connected ? "text-[#FAFAFA]" : "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
|
|
@ -78,7 +89,7 @@ function StatusDot({ connected }: { connected: boolean }) {
|
|||
)
|
||||
}
|
||||
|
||||
function AppRow({
|
||||
function AppCard({
|
||||
name,
|
||||
subtitle,
|
||||
icon,
|
||||
|
|
@ -102,16 +113,16 @@ function AppRow({
|
|||
onDisconnect: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-xl bg-[#14161A] px-4 py-3 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<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)]">
|
||||
<div className="flex min-h-[152px] min-w-0 flex-col justify-between gap-4 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">
|
||||
<div className="min-w-0 pt-0.5">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[14px] tracking-[-0.15px] text-[#FAFAFA]",
|
||||
"truncate font-semibold text-[14px] tracking-[-0.15px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
|
|
@ -119,14 +130,14 @@ function AppRow({
|
|||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate text-[12px] font-medium text-[#737373]",
|
||||
"mt-1 line-clamp-2 break-words text-[12px] font-medium leading-5 text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<div className="flex min-h-9 items-center justify-between gap-3 border-[#1E293B]/50 border-t pt-3">
|
||||
<StatusDot connected={connected} />
|
||||
{connected && canDisconnect ? (
|
||||
<PillButton onClick={onDisconnect} disabled={busy}>
|
||||
|
|
@ -187,23 +198,30 @@ function ScopeToggle({
|
|||
|
||||
function RowSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl bg-[#14161A] px-4 py-3 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<div className="size-9 animate-pulse rounded-[9px] bg-[#1c1f24]" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 w-24 animate-pulse rounded bg-[#1c1f24]" />
|
||||
<div className="h-2.5 w-36 animate-pulse rounded bg-[#1c1f24]" />
|
||||
<div className="min-h-[152px] rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 animate-pulse rounded-[10px] bg-[#1c1f24]" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 w-24 animate-pulse rounded bg-[#1c1f24]" />
|
||||
<div className="h-2.5 w-32 animate-pulse rounded bg-[#1c1f24]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 h-8 w-28 animate-pulse rounded-full bg-[#1c1f24] ml-auto" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CompanyBrainConnections() {
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const { user } = useAuth()
|
||||
const [catalog, setCatalog] = useState<CatalogEntry[] | null>(null)
|
||||
const [catalogLoaded, setCatalogLoaded] = useState(false)
|
||||
const [rows, setRows] = useState<ConnRow[]>([])
|
||||
const [slackStatus, setSlackStatus] = useState<SlackStatus | null>(null)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [scope, setScope] = useState<Scope>("user")
|
||||
const [customName, setCustomName] = useState("")
|
||||
const [customServerUrl, setCustomServerUrl] = useState("")
|
||||
|
||||
const roleQuery = useQuery({
|
||||
queryKey: ["company-brain-connections", "role"],
|
||||
|
|
@ -224,8 +242,10 @@ export default function CompanyBrainConnections() {
|
|||
if (catRes.ok) {
|
||||
const data = (await catRes.json()) as { catalog?: CatalogEntry[] }
|
||||
setCatalog(Array.isArray(data.catalog) ? data.catalog : [])
|
||||
setCatalogLoaded(true)
|
||||
} else {
|
||||
setCatalog([])
|
||||
setCatalogLoaded(false)
|
||||
toast.error("Couldn't load the app catalog.")
|
||||
}
|
||||
if (connRes.ok) {
|
||||
|
|
@ -259,6 +279,9 @@ export default function CompanyBrainConnections() {
|
|||
(shared ? r.userId === null : r.userId !== null),
|
||||
)
|
||||
|
||||
const isStaff =
|
||||
user?.email?.toLowerCase().endsWith("@supermemory.com") ?? false
|
||||
|
||||
const connect = async (entry: CatalogEntry, shared: boolean) => {
|
||||
const key = `${entry.slug}:${shared ? "org" : "user"}`
|
||||
setBusy(key)
|
||||
|
|
@ -320,6 +343,66 @@ export default function CompanyBrainConnections() {
|
|||
}
|
||||
}
|
||||
|
||||
const connectCustom = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
const slug = slugifyMcpName(customName)
|
||||
const serverUrl = customServerUrl.trim()
|
||||
if (!slug) {
|
||||
toast.error("Enter a custom MCP name.")
|
||||
return
|
||||
}
|
||||
if (!serverUrl) {
|
||||
toast.error("Enter an OAuth MCP URL.")
|
||||
return
|
||||
}
|
||||
if (apps.some((entry) => entry.slug === slug)) {
|
||||
toast.error("That name is already used by a catalog app.")
|
||||
return
|
||||
}
|
||||
|
||||
const key = `custom:${slug}`
|
||||
setBusy(key)
|
||||
try {
|
||||
const res = await fetch(`${MCP_BASE}/${slug}/connect`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
serverUrl,
|
||||
shared: false,
|
||||
redirectUrl: window.location.href,
|
||||
}),
|
||||
})
|
||||
if (res.status === 403) {
|
||||
toast.error("Custom MCP URLs are staff-only.")
|
||||
return
|
||||
}
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
authUrl?: string
|
||||
ok?: boolean
|
||||
error?: string
|
||||
}
|
||||
if (!res.ok) {
|
||||
toast.error(data.error ?? "Couldn't start the custom connection.")
|
||||
return
|
||||
}
|
||||
if (data.authUrl) {
|
||||
window.open(data.authUrl, "_blank", "noopener")
|
||||
} else if (data.ok) {
|
||||
toast.success(`${slug} connected.`)
|
||||
setCustomName("")
|
||||
setCustomServerUrl("")
|
||||
await load()
|
||||
} else {
|
||||
toast.error(data.error ?? "Couldn't start the custom connection.")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Couldn't start the custom connection.")
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const disconnect = async (entry: CatalogEntry, shared: boolean) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
|
|
@ -366,6 +449,18 @@ export default function CompanyBrainConnections() {
|
|||
|
||||
const loading = catalog === null
|
||||
const apps = catalog ?? []
|
||||
const catalogSlugs = new Set(apps.map((entry) => entry.slug))
|
||||
const canClassifyCustomRows = catalogLoaded && apps.length > 0
|
||||
const customRows = canClassifyCustomRows
|
||||
? rows.filter(
|
||||
(row) =>
|
||||
row.userId !== null &&
|
||||
row.status === "active" &&
|
||||
typeof row.serverUrl === "string" &&
|
||||
row.serverUrl.length > 0 &&
|
||||
!catalogSlugs.has(row.serverSlug),
|
||||
)
|
||||
: []
|
||||
const shared = scope === "org"
|
||||
const description = shared
|
||||
? "Connected by admins. Used for reads when you haven't connected your own."
|
||||
|
|
@ -402,7 +497,7 @@ export default function CompanyBrainConnections() {
|
|||
{description}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{loading ? (
|
||||
<>
|
||||
<RowSkeleton />
|
||||
|
|
@ -410,21 +505,99 @@ export default function CompanyBrainConnections() {
|
|||
<RowSkeleton />
|
||||
</>
|
||||
) : (
|
||||
apps.map((entry) => (
|
||||
<AppRow
|
||||
key={`${scope}-${entry.slug}`}
|
||||
name={entry.name}
|
||||
subtitle={titleCase(entry.category)}
|
||||
icon={brainConnectorIcon(entry.slug, entry.name)}
|
||||
connected={isConnected(entry.slug, shared)}
|
||||
canConnect={shared ? isAdmin : true}
|
||||
canDisconnect={shared ? isAdmin : true}
|
||||
lockedHint={shared ? "Admin only" : undefined}
|
||||
busy={busy === `${entry.slug}:${scope}`}
|
||||
onConnect={() => connect(entry, shared)}
|
||||
onDisconnect={() => disconnect(entry, shared)}
|
||||
/>
|
||||
))
|
||||
<>
|
||||
{!shared && isStaff ? (
|
||||
<form
|
||||
onSubmit={connectCustom}
|
||||
className="flex min-h-[152px] 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>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[14px] font-semibold text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Custom MCP server
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-1 line-clamp-2 text-[12px] font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Add a personal OAuth MCP server by URL.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={customName}
|
||||
onChange={(event) => setCustomName(event.target.value)}
|
||||
placeholder="Name"
|
||||
className="h-8 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3 text-[12px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#334155]"
|
||||
/>
|
||||
<input
|
||||
value={customServerUrl}
|
||||
onChange={(event) => setCustomServerUrl(event.target.value)}
|
||||
placeholder="https://example.com/mcp"
|
||||
className="h-8 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3 text-[12px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#334155]"
|
||||
/>
|
||||
<div className="flex justify-end border-[#1E293B]/50 border-t pt-3">
|
||||
<PillButton
|
||||
type="submit"
|
||||
disabled={busy?.startsWith("custom:") ?? false}
|
||||
>
|
||||
{busy?.startsWith("custom:") && (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
)}
|
||||
Connect
|
||||
</PillButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{apps.map((entry) => (
|
||||
<AppCard
|
||||
key={`${scope}-${entry.slug}`}
|
||||
name={entry.name}
|
||||
subtitle={titleCase(entry.category)}
|
||||
icon={brainConnectorIcon(entry.slug, entry.name)}
|
||||
connected={isConnected(entry.slug, shared)}
|
||||
canConnect={shared ? isAdmin : true}
|
||||
canDisconnect={shared ? isAdmin : true}
|
||||
lockedHint={shared ? "Admin only" : undefined}
|
||||
busy={busy === `${entry.slug}:${scope}`}
|
||||
onConnect={() => connect(entry, shared)}
|
||||
onDisconnect={() => disconnect(entry, shared)}
|
||||
/>
|
||||
))}
|
||||
{!shared &&
|
||||
customRows.map((row) => (
|
||||
<AppCard
|
||||
key={`custom-${row.serverSlug}`}
|
||||
name={titleCase(row.serverSlug.replace(/-/g, " "))}
|
||||
subtitle={row.serverUrl ?? "Custom OAuth MCP"}
|
||||
icon={brainConnectorIcon(row.serverSlug, row.serverSlug)}
|
||||
connected
|
||||
canConnect={false}
|
||||
canDisconnect
|
||||
busy={busy === `${row.serverSlug}:user`}
|
||||
onConnect={() => {}}
|
||||
onDisconnect={() =>
|
||||
disconnect(
|
||||
{
|
||||
slug: row.serverSlug,
|
||||
name: titleCase(row.serverSlug.replace(/-/g, " ")),
|
||||
category: "Custom OAuth MCP",
|
||||
authType: "oauth",
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue