feat(web): Company Brain connections disconnect and Slack reconnect (#1157)

## Summary
- Add disconnect actions for org and personal GitHub/Linear connections on the Company Brain settings page
- Show connected Slack workspace name and an admin-only **Reconnect Slack** button (pairs with API admin gate in supermemoryai/mono#1912)
- Fix infinite loading skeleton when `/brain/connections` fails by falling back to empty state with a toast

## Test plan
- [ ] Open Settings → Company Brain connections as org admin
- [ ] Confirm Slack team name shows when workspace is connected
- [ ] Confirm **Reconnect Slack** is visible for admin/owner only
- [ ] Connect and disconnect GitHub/Linear for org (admin) and personal scopes
- [ ] Simulate failed connections fetch (e.g. offline) and confirm page renders instead of infinite skeleton
This commit is contained in:
MaheshtheDev 2026-06-24 01:16:16 +00:00
parent 1e1b0b1a37
commit 3769ffa41f

View file

@ -3,7 +3,7 @@
import { authClient } from "@lib/auth"
import { cn } from "@lib/utils"
import { useQuery } from "@tanstack/react-query"
import { Check, Loader2, Lock } from "lucide-react"
import { Loader2, Lock } from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { dmSans125ClassName } from "@/lib/fonts"
@ -14,6 +14,67 @@ const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
type ConnRow = { toolkit: string; org: boolean; user: boolean }
type SlackStatus = { connected: boolean; teamName: string | null }
function SecondaryButton({
children,
href,
}: {
children: React.ReactNode
href: string
}) {
return (
<a
href={href}
className={cn(
dmSans125ClassName(),
"inline-flex shrink-0 items-center justify-center gap-2 rounded-full border border-[#1E293B] bg-[#0D121A] px-4 h-9",
"text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#1E293B]",
)}
>
{children}
</a>
)
}
function SlackMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 122.8 122.8" className={className} aria-hidden="true">
<path
d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9z"
fill="#E01E5A"
/>
<path
d="M32.3 77.6c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z"
fill="#E01E5A"
/>
<path
d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2z"
fill="#36C5F0"
/>
<path
d="M45.2 32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z"
fill="#36C5F0"
/>
<path
d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2z"
fill="#2EB67D"
/>
<path
d="M90.5 45.2c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z"
fill="#2EB67D"
/>
<path
d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9z"
fill="#ECB22E"
/>
<path
d="M77.6 90.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z"
fill="#ECB22E"
/>
</svg>
)
}
function GithubMark({ className }: { className?: string }) {
return (
@ -73,16 +134,20 @@ function AppCard({
toolkit,
connected,
canConnect,
canDisconnect,
lockedHint,
busy,
onConnect,
onDisconnect,
}: {
toolkit: string
connected: boolean
canConnect: boolean
canDisconnect: boolean
lockedHint?: string
busy: boolean
onConnect: () => void
onDisconnect: () => void
}) {
const meta = TOOLKITS[toolkit] ?? {
label: toolkit,
@ -116,7 +181,13 @@ function AppCard({
</div>
<div className="flex shrink-0 items-center gap-3">
<StatusDot connected={connected} />
{!connected &&
{connected && canDisconnect ? (
<PillButton onClick={onDisconnect} disabled={busy}>
{busy && <Loader2 className="size-3.5 animate-spin" />}
Disconnect
</PillButton>
) : (
!connected &&
(canConnect ? (
<PillButton onClick={onConnect} disabled={busy}>
{busy && <Loader2 className="size-3.5 animate-spin" />}
@ -127,7 +198,8 @@ function AppCard({
<Lock className="size-3" />
{lockedHint}
</span>
) : null)}
) : null)
)}
</div>
</div>
)
@ -182,6 +254,7 @@ function CardSkeleton() {
export default function CompanyBrainConnections() {
const isCompanyBrain = useHasCompanyBrain()
const [rows, setRows] = useState<ConnRow[] | null>(null)
const [slackStatus, setSlackStatus] = useState<SlackStatus | null>(null)
const [busy, setBusy] = useState<string | null>(null)
const roleQuery = useQuery({
@ -195,11 +268,21 @@ export default function CompanyBrainConnections() {
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)
const [connRes, slackRes] = await Promise.all([
fetch(`${BACKEND}/brain/connections`, { credentials: "include" }),
fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
])
if (connRes.ok) {
setRows(((await connRes.json()) as { toolkits: ConnRow[] }).toolkits)
} else {
setRows([])
toast.error("Couldn't load connections.")
}
if (slackRes.ok) {
setSlackStatus((await slackRes.json()) as SlackStatus)
} else {
setSlackStatus({ connected: false, teamName: null })
}
}, [])
useEffect(() => {
@ -235,6 +318,37 @@ export default function CompanyBrainConnections() {
}
}
const disconnect = async (toolkit: string, scope: "user" | "org") => {
const label = TOOLKITS[toolkit]?.label ?? toolkit
if (
!window.confirm(
`Disconnect ${label} from ${scope === "org" ? "the shared org account" : "your personal account"}?`,
)
)
return
setBusy(`${toolkit}:${scope}`)
try {
const res = await fetch(
`${BACKEND}/brain/connections/${toolkit}?scope=${scope}`,
{ method: "DELETE", credentials: "include" },
)
if (res.status === 403) {
toast.error("Only admins can disconnect the shared org account.")
return
}
if (!res.ok) {
toast.error("Couldn't disconnect.")
return
}
toast.success(`${label} disconnected.`)
await load()
} catch {
toast.error("Couldn't disconnect.")
} finally {
setBusy(null)
}
}
if (!isCompanyBrain) {
return (
<p
@ -252,6 +366,24 @@ export default function CompanyBrainConnections() {
return (
<div className="space-y-7">
<div className="flex items-center justify-end gap-3">
{slackStatus?.connected && slackStatus.teamName ? (
<p
className={cn(
dmSans125ClassName(),
"mr-auto text-[13px] font-medium text-[#737373]",
)}
>
Slack · {slackStatus.teamName}
</p>
) : null}
{isAdmin ? (
<SecondaryButton href={`${BACKEND}/brain/slack/oauth/install`}>
<SlackMark className="size-4" />
Reconnect Slack
</SecondaryButton>
) : null}
</div>
<Section
title="Organization (shared)"
description="Connected by admins. Used for reads when you haven't connected your own."
@ -265,9 +397,11 @@ export default function CompanyBrainConnections() {
toolkit={row.toolkit}
connected={row.org}
canConnect={isAdmin}
canDisconnect={isAdmin}
lockedHint="Admin only"
busy={busy === `${row.toolkit}:org`}
onConnect={() => connect(row.toolkit, "org")}
onDisconnect={() => disconnect(row.toolkit, "org")}
/>
))
)}
@ -286,8 +420,10 @@ export default function CompanyBrainConnections() {
toolkit={row.toolkit}
connected={row.user}
canConnect
canDisconnect
busy={busy === `${row.toolkit}:user`}
onConnect={() => connect(row.toolkit, "user")}
onDisconnect={() => disconnect(row.toolkit, "user")}
/>
))
)}