mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-09 03:18:04 +00:00
feat(web): Company Brain connections settings page
Org-shared vs personal GitHub/Linear connection status; connect via /brain/connections.
This commit is contained in:
parent
f5022c2691
commit
293a774363
2 changed files with 300 additions and 0 deletions
291
apps/web/components/settings/company-brain-connections.tsx
Normal file
291
apps/web/components/settings/company-brain-connections.tsx
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { authClient } from "@lib/auth"
|
||||||
|
import { cn } from "@lib/utils"
|
||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { Check, Loader2, Lock } from "lucide-react"
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { dmSans125ClassName } from "@/lib/fonts"
|
||||||
|
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||||
|
import { PillButton } from "../integrations/install-steps"
|
||||||
|
|
||||||
|
const BACKEND =
|
||||||
|
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||||
|
|
||||||
|
type ConnRow = { toolkit: string; org: boolean; user: boolean }
|
||||||
|
|
||||||
|
function GithubMark({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||||
|
<title>GitHub</title>
|
||||||
|
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinearMark({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||||
|
<title>Linear</title>
|
||||||
|
<path d="M3.084 12.866a8.916 8.916 0 0 0 8.05 8.05.27.27 0 0 0 .222-.46l-7.812-7.812a.27.27 0 0 0-.46.222Zm-.044-1.955a.27.27 0 0 0 .078.21l9.76 9.76c.06.06.142.087.21.078a8.87 8.87 0 0 0 1.273-.218.27.27 0 0 0 .127-.453L3.712 9.51a.27.27 0 0 0-.453.127 8.87 8.87 0 0 0-.218 1.273Zm.69-2.706a.27.27 0 0 0 .06.29l11.715 11.716a.27.27 0 0 0 .29.06 8.96 8.96 0 0 0 .837-.384.27.27 0 0 0 .066-.439L4.553 7.302a.27.27 0 0 0-.44.066 8.96 8.96 0 0 0-.383.837Zm1.11-1.798a.27.27 0 0 1-.017-.366A8.948 8.948 0 0 1 18.07 18.69a.27.27 0 0 1-.366-.017L4.94 6.407Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOLKITS: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; subtitle: string; icon: React.ReactNode }
|
||||||
|
> = {
|
||||||
|
github: {
|
||||||
|
label: "GitHub",
|
||||||
|
subtitle: "Repos, pull requests and issues",
|
||||||
|
icon: <GithubMark className="size-5 text-[#FAFAFA]" />,
|
||||||
|
},
|
||||||
|
linear: {
|
||||||
|
label: "Linear",
|
||||||
|
subtitle: "Issues, projects and cycles",
|
||||||
|
icon: <LinearMark className="size-5 text-[#5E6AD2]" />,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusDot({ connected }: { connected: boolean }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
dmSans125ClassName(),
|
||||||
|
"flex items-center gap-1.5 text-[13px] font-medium",
|
||||||
|
connected ? "text-[#FAFAFA]" : "text-[#737373]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"size-[7px] shrink-0 rounded-full",
|
||||||
|
connected ? "bg-[#00AC3F]" : "bg-[#3A4150]",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{connected ? "Connected" : "Not connected"}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppCard({
|
||||||
|
toolkit,
|
||||||
|
connected,
|
||||||
|
canConnect,
|
||||||
|
lockedHint,
|
||||||
|
busy,
|
||||||
|
onConnect,
|
||||||
|
}: {
|
||||||
|
toolkit: string
|
||||||
|
connected: boolean
|
||||||
|
canConnect: boolean
|
||||||
|
lockedHint?: string
|
||||||
|
busy: boolean
|
||||||
|
onConnect: () => void
|
||||||
|
}) {
|
||||||
|
const meta = TOOLKITS[toolkit] ?? {
|
||||||
|
label: toolkit,
|
||||||
|
subtitle: "",
|
||||||
|
icon: null,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-[14px] bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] sm:p-5">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
|
||||||
|
{meta.icon}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
dmSans125ClassName(),
|
||||||
|
"font-semibold text-[15px] tracking-[-0.15px] text-[#FAFAFA]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{meta.label}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
dmSans125ClassName(),
|
||||||
|
"truncate text-[12px] font-medium text-[#737373]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{meta.subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-3">
|
||||||
|
<StatusDot connected={connected} />
|
||||||
|
{!connected &&
|
||||||
|
(canConnect ? (
|
||||||
|
<PillButton onClick={onConnect} disabled={busy}>
|
||||||
|
{busy && <Loader2 className="size-3.5 animate-spin" />}
|
||||||
|
Connect
|
||||||
|
</PillButton>
|
||||||
|
) : lockedHint ? (
|
||||||
|
<span className="flex items-center gap-1 text-[12px] font-medium text-[#737373]">
|
||||||
|
<Lock className="size-3" />
|
||||||
|
{lockedHint}
|
||||||
|
</span>
|
||||||
|
) : null)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="px-1">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
dmSans125ClassName(),
|
||||||
|
"font-semibold text-[15px] tracking-[-0.15px] text-[#FAFAFA]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
dmSans125ClassName(),
|
||||||
|
"mt-0.5 text-[13px] font-medium text-[#737373]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 rounded-[14px] bg-[#14161A] p-5 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||||
|
<div className="size-10 animate-pulse rounded-[10px] bg-[#1c1f24]" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-3.5 w-24 animate-pulse rounded bg-[#1c1f24]" />
|
||||||
|
<div className="h-3 w-40 animate-pulse rounded bg-[#1c1f24]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CompanyBrainConnections() {
|
||||||
|
const isCompanyBrain = useHasCompanyBrain()
|
||||||
|
const [rows, setRows] = useState<ConnRow[] | null>(null)
|
||||||
|
const [busy, setBusy] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const roleQuery = useQuery({
|
||||||
|
queryKey: ["company-brain-connections", "role"],
|
||||||
|
queryFn: async () =>
|
||||||
|
(await authClient.organization.getActiveMember()).data?.role ?? null,
|
||||||
|
staleTime: 60_000,
|
||||||
|
enabled: isCompanyBrain,
|
||||||
|
})
|
||||||
|
const role = (roleQuery.data ?? "").toLowerCase()
|
||||||
|
const isAdmin = role === "owner" || role === "admin"
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const res = await fetch(`${BACKEND}/brain/connections`, {
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
if (res.ok)
|
||||||
|
setRows(((await res.json()) as { toolkits: ConnRow[] }).toolkits)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCompanyBrain) return
|
||||||
|
void load()
|
||||||
|
const onFocus = () => void load()
|
||||||
|
window.addEventListener("focus", onFocus)
|
||||||
|
return () => window.removeEventListener("focus", onFocus)
|
||||||
|
}, [isCompanyBrain, load])
|
||||||
|
|
||||||
|
const connect = async (toolkit: string, scope: "user" | "org") => {
|
||||||
|
setBusy(`${toolkit}:${scope}`)
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${BACKEND}/brain/connections/${toolkit}/link?scope=${scope}`,
|
||||||
|
{ method: "POST", credentials: "include" },
|
||||||
|
)
|
||||||
|
if (res.status === 403) {
|
||||||
|
toast.error("Only admins can connect the shared org account.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { url?: string; error?: string }
|
||||||
|
if (data.url) window.open(data.url, "_blank", "noopener")
|
||||||
|
else toast.error(data.error ?? "Couldn't start the connection.")
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCompanyBrain) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
dmSans125ClassName(),
|
||||||
|
"text-[14px] font-medium text-[#737373]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Company Brain isn't enabled for this organization.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = rows === null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-7">
|
||||||
|
<Section
|
||||||
|
title="Organization (shared)"
|
||||||
|
description="Connected by admins. Used for reads when you haven't connected your own."
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<CardSkeleton />
|
||||||
|
) : (
|
||||||
|
rows.map((row) => (
|
||||||
|
<AppCard
|
||||||
|
key={`org-${row.toolkit}`}
|
||||||
|
toolkit={row.toolkit}
|
||||||
|
connected={row.org}
|
||||||
|
canConnect={isAdmin}
|
||||||
|
lockedHint="Admin only"
|
||||||
|
busy={busy === `${row.toolkit}:org`}
|
||||||
|
onConnect={() => connect(row.toolkit, "org")}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="Your connections"
|
||||||
|
description="Your personal accounts — used for your actions and your reads."
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<CardSkeleton />
|
||||||
|
) : (
|
||||||
|
rows.map((row) => (
|
||||||
|
<AppCard
|
||||||
|
key={`user-${row.toolkit}`}
|
||||||
|
toolkit={row.toolkit}
|
||||||
|
connected={row.user}
|
||||||
|
canConnect
|
||||||
|
busy={busy === `${row.toolkit}:user`}
|
||||||
|
onConnect={() => connect(row.toolkit, "user")}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import Account from "@/components/settings/account"
|
||||||
import Billing from "@/components/settings/billing"
|
import Billing from "@/components/settings/billing"
|
||||||
import Integrations from "@/components/settings/integrations"
|
import Integrations from "@/components/settings/integrations"
|
||||||
import ConnectionsMCP from "@/components/settings/connections-mcp"
|
import ConnectionsMCP from "@/components/settings/connections-mcp"
|
||||||
|
import CompanyBrainConnections from "@/components/settings/company-brain-connections"
|
||||||
import Support from "@/components/settings/support"
|
import Support from "@/components/settings/support"
|
||||||
import { ErrorBoundary } from "@/components/error-boundary"
|
import { ErrorBoundary } from "@/components/error-boundary"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
|
@ -44,6 +45,7 @@ export const TABS = [
|
||||||
"billing",
|
"billing",
|
||||||
"integrations",
|
"integrations",
|
||||||
"connections",
|
"connections",
|
||||||
|
"company-brain",
|
||||||
"support",
|
"support",
|
||||||
] as const
|
] as const
|
||||||
export type SettingsTab = (typeof TABS)[number]
|
export type SettingsTab = (typeof TABS)[number]
|
||||||
|
|
@ -80,6 +82,12 @@ const NAV_ITEMS: NavItem[] = [
|
||||||
description: "Drive, Notion, OneDrive, MCP",
|
description: "Drive, Notion, OneDrive, MCP",
|
||||||
icon: <Zap className="size-[18px]" />,
|
icon: <Zap className="size-[18px]" />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "company-brain",
|
||||||
|
label: "Company Brain",
|
||||||
|
description: "GitHub & Linear — org and personal",
|
||||||
|
icon: <Building2 className="size-[18px]" />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "support",
|
id: "support",
|
||||||
label: "Support & Help",
|
label: "Support & Help",
|
||||||
|
|
@ -416,6 +424,7 @@ export function SettingsContent({
|
||||||
{activeTab === "billing" && <Billing />}
|
{activeTab === "billing" && <Billing />}
|
||||||
{activeTab === "integrations" && <Integrations />}
|
{activeTab === "integrations" && <Integrations />}
|
||||||
{activeTab === "connections" && <ConnectionsMCP />}
|
{activeTab === "connections" && <ConnectionsMCP />}
|
||||||
|
{activeTab === "company-brain" && <CompanyBrainConnections />}
|
||||||
{activeTab === "support" && <Support />}
|
{activeTab === "support" && <Support />}
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue