mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-07-09 17:18:51 +00:00
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:
parent
1e1b0b1a37
commit
3769ffa41f
1 changed files with 144 additions and 8 deletions
|
|
@ -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")}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue