feat(web): company brain onboarding redesign + connector entitlement (#1178)

- onboarding: unified About step, mode-aware Sources, Slack-focused Flows step, connect feedback (toast + connected state), auto-draft company description from domain
- brain-home: Active members stat + invite, real OneDrive icon
- useConnectorAccess hook so company_brain unlocks pro-tier connectors across onboarding + integrations
- fix company-brain-connections crash on empty connections

---
**Session Details**
- Session: [View Session](https://supermemory.us1.vorflux.com/agent-sessions/64a15b29-0848-42d1-af4c-138c5a27136f)
- Requested by: Unknown
- Address comments on this PR. Add `(aside)` to your comment to have me ignore it.
This commit is contained in:
MaheshtheDev 2026-06-30 22:01:20 +00:00
parent e706a13877
commit 42f3aec885
12 changed files with 859 additions and 617 deletions

View file

@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation"
import { toast } from "sonner"
import { useAuth } from "@lib/auth-context"
import { authClient } from "@lib/auth"
import { SHARED_TEAM_BRAIN_TAG } from "@lib/constants"
import { analytics } from "@/lib/analytics"
import { BrainShell } from "@/components/onboarding-brain/shell"
import {
@ -167,13 +168,17 @@ export default function BrainOnboardingPage() {
[router],
)
// Team/company-brain routes everything into the shared Team Brain that
// provisioning creates (sm_org_shared) — not a workspace-name slug space.
const containerTag = useMemo(
() =>
containerTagFromWorkspace(
about.workspaceName || suggestedWorkspaceName,
mode,
),
[about.workspaceName, suggestedWorkspaceName, mode],
allowTeam && mode === "team"
? SHARED_TEAM_BRAIN_TAG
: containerTagFromWorkspace(
about.workspaceName || suggestedWorkspaceName,
mode,
),
[allowTeam, about.workspaceName, suggestedWorkspaceName, mode],
)
const isScale = useMemo(() => {
@ -397,7 +402,6 @@ export default function BrainOnboardingPage() {
{step === "sources" && (
<StepSources
containerTag={containerTag}
workspaceName={about.workspaceName || suggestedWorkspaceName}
mode={mode}
values={sources}
onChange={setSources}

View file

@ -1,7 +1,7 @@
"use client"
import { $fetch } from "@lib/api"
import { hasActivePlan } from "@lib/queries"
import { useConnectorAccess } from "@/hooks/use-connector-access"
import type { ConnectionResponseSchema } from "@repo/validation/api"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { GoogleDrive, Granola, Notion, OneDrive } from "@ui/assets/icons"
@ -309,7 +309,7 @@ interface ConnectContentProps {
export function ConnectContent({ selectedProject }: ConnectContentProps) {
const queryClient = useQueryClient()
const autumn = useCustomer()
const isProUser = hasActivePlan(autumn.data?.subscriptions, "api_pro")
const { connectorAccess } = useConnectorAccess()
const [connectingProvider, setConnectingProvider] =
useState<ConnectorProvider | null>(null)
const [granolaModalOpen, setGranolaModalOpen] = useState(false)
@ -398,7 +398,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
provider: ConnectorProvider
syncScope?: GDriveSyncScope
}) => {
if (!canAddConnection && !isProUser) {
if (!canAddConnection && !connectorAccess) {
throw new Error(
"Free plan doesn't include connections. Upgrade to Pro for unlimited connections.",
)
@ -557,7 +557,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
type="button"
onClick={() => handleConnect("google-drive")}
disabled={
!isProUser ||
!connectorAccess ||
isConnecting ||
addConnectionMutation.isPending
}
@ -605,14 +605,14 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
</div>
) : provider === "granola" ? (
<>
{!isProUser && (
{!connectorAccess && (
<span className="bg-[#0054AD] text-[#FAFAFA] text-[10px] font-bold px-1.5 py-[2px] rounded-[3px] uppercase tracking-wide">
Pro
</span>
)}
<Button
onClick={() => {
if (!isProUser) {
if (!connectorAccess) {
handleUpgrade("api_pro")
return
}
@ -621,7 +621,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
disabled={isUpgrading || autumn.isLoading}
className="bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 text-[14px] font-medium px-3 py-1.5 h-8"
>
{!isProUser
{!connectorAccess
? isUpgrading || autumn.isLoading
? "Upgrading..."
: "Upgrade"
@ -634,7 +634,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
handleConnect(provider as ConnectorProvider)
}
disabled={
!isProUser ||
!connectorAccess ||
isConnecting ||
addConnectionMutation.isPending
}
@ -681,7 +681,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={!isProUser || isAnyConnecting}
disabled={!connectorAccess || isAnyConnecting}
className="flex shrink-0 items-center gap-1.5 bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 disabled:opacity-50 disabled:cursor-not-allowed text-[13px] font-medium rounded-full h-8 px-3 transition-colors"
>
{isAnyConnecting ? (
@ -792,7 +792,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
<DropdownMenuItem
disabled={isUpgrading || autumn.isLoading}
onClick={() => {
if (!isProUser) {
if (!connectorAccess) {
handleUpgrade("api_pro")
return
}
@ -804,14 +804,14 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
<div className="flex flex-col gap-0.5 min-w-0">
<span className="flex items-center gap-1.5 text-[14px] font-medium text-[#FAFAFA] leading-tight">
Granola
{!isProUser && (
{!connectorAccess && (
<span className="bg-[#0054AD] text-[#FAFAFA] text-[9px] font-bold px-1 py-px rounded-[3px] uppercase tracking-wide">
Pro
</span>
)}
</span>
<span className="text-[11px] text-[#737373] leading-tight">
{isProUser
{connectorAccess
? "Meeting notes & transcripts"
: "Upgrade to Pro"}
</span>
@ -866,7 +866,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
className="bg-[#14161A] shadow-inside-out rounded-[12px] px-4 py-6 h-full mb-4 flex flex-col justify-center items-center"
>
<Zap className="size-6 text-[#737373] mb-3" />
{!isProUser ? (
{!connectorAccess ? (
<>
<p className="text-[14px] text-[#737373] mb-4 text-center">
{isUpgrading || autumn.isLoading ? (
@ -945,8 +945,8 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
/>
<GranolaConnectModal
open={isProUser && granolaModalOpen}
onOpenChange={(open) => setGranolaModalOpen(open && isProUser)}
open={connectorAccess && granolaModalOpen}
onOpenChange={(open) => setGranolaModalOpen(open && connectorAccess)}
containerTags={[selectedProject]}
/>
</div>

View file

@ -4,8 +4,10 @@ import { $fetch } from "@lib/api"
import { useAuth } from "@lib/auth-context"
import { cn } from "@lib/utils"
import { useQuery } from "@tanstack/react-query"
import { ArrowRight, Check, FileText, Loader2 } from "lucide-react"
import { ArrowRight, Check, FileText, Loader2, UserPlus } from "lucide-react"
import Link from "next/link"
import { useQueryState } from "nuqs"
import { useSettingsModal } from "@/components/settings/settings-modal"
import { dmSans125ClassName } from "@/lib/fonts"
import { ConnectionsBoard } from "./connections-board"
@ -103,11 +105,17 @@ function useBrainOverview() {
(brain.data?.slack ? 1 : 0) +
(connectors.data?.length ?? 0)
const currentRole = org?.members
?.find((m) => m.userId === user?.id)
?.role?.toLowerCase()
return {
loading: docs.isPending,
recentDocs: docs.data?.documents ?? [],
memoriesCount,
connectedCount,
membersCount: org?.members?.length ?? 0,
canInvite: currentRole === "owner" || currentRole === "admin",
hasSource: connectedCount > 0,
hasAgent: mcp.data ?? false,
hasMemory: memoriesCount > 0,
@ -125,6 +133,8 @@ export function BrainHomeView() {
<StatsRow
memories={o.memoriesCount}
connected={o.connectedCount}
members={o.membersCount}
canInvite={o.canInvite}
setupDone={stepsDone}
/>
<ConnectionsBoard />
@ -143,27 +153,60 @@ export function BrainHomeView() {
function StatsRow({
memories,
connected,
members,
canInvite,
setupDone,
}: {
memories: number
connected: number
members: number
canInvite: boolean
setupDone: number
}) {
const tiles = [
const { openSettings } = useSettingsModal()
const [, setInvite] = useQueryState("invite")
const onInvite = () => {
setInvite("1")
openSettings("account")
}
const tiles: {
label: string
value: string
action?: React.ReactNode
}[] = [
{ label: "Memories", value: memories.toLocaleString() },
{ label: "Connected sources", value: String(connected) },
{
label: "Active members",
value: String(members),
action: canInvite ? (
<button
type="button"
onClick={onInvite}
className="inline-flex items-center gap-1 text-[11px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
>
<UserPlus className="size-3" />
Invite
</button>
) : undefined,
},
{ label: "Setup", value: `${setupDone}/3` },
]
return (
<section
className="grid grid-cols-3 divide-x divide-white/[0.04] rounded-[16px] bg-[#1B1F24]"
className="grid grid-cols-2 divide-white/[0.04] rounded-[16px] bg-[#1B1F24] sm:grid-cols-4 sm:divide-x"
style={cardStyle}
>
{tiles.map((t) => (
<div key={t.label} className="px-5 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[#737373]">
{t.label}
</p>
<div className="flex items-start justify-between gap-2">
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[#737373]">
{t.label}
</p>
{t.action}
</div>
<p
className={cn(
"mt-1.5 text-[22px] font-semibold leading-none tabular-nums text-[#fafafa]",

View file

@ -3,8 +3,8 @@
import { $fetch } from "@lib/api"
import { cn } from "@lib/utils"
import { useQuery } from "@tanstack/react-query"
import { GoogleDrive, Notion } from "@ui/assets/icons"
import { Cloud, ExternalLink, Loader2 } from "lucide-react"
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
import { ExternalLink, Loader2 } from "lucide-react"
import Link from "next/link"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
@ -167,7 +167,7 @@ export function ConnectionsBoard() {
onConnect={() => connectConnector("notion")}
/>
<AppCard
icon={<Cloud className="size-5 text-[#0F6CBD]" />}
icon={<OneDrive className="size-5" />}
name="OneDrive"
subtitle="Files from Microsoft 365."
connected={connectorConnected("onedrive")}

View file

@ -4,7 +4,6 @@ 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 { hasActivePlan } from "@lib/queries"
import { $fetch } from "@lib/api"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
@ -44,6 +43,7 @@ import {
Zap,
} from "lucide-react"
import { formatRelativeTime } from "@/components/settings/sync-utils"
import { useConnectorAccess } from "@/hooks/use-connector-access"
import { useConnectionHealth } from "@/hooks/use-connection-health"
import { useContainerTags } from "@/hooks/use-container-tags"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
@ -2555,8 +2555,11 @@ export function IntegrationsView({
const { allProjects } = useContainerTags()
const shortcutsConnect = useShortcutsConnect()
const autumn = useCustomer({ queryOptions: { enabled: !publicMode } })
const hasProProduct =
!publicMode && hasActivePlan(autumn.data?.subscriptions, "api_pro")
// connectorAccess covers pro-tier connectors (incl. company_brain orgs); plugins
// stay on hasProProduct. See useConnectorAccess.
const { hasPro: hasProProduct, connectorAccess } = useConnectorAccess({
enabled: !publicMode,
})
const isAutumnLoading = !publicMode && autumn.isLoading
const [connectingPlugin, setConnectingPlugin] = useState<string | null>(null)
@ -2601,7 +2604,7 @@ export function IntegrationsView({
return response.data as Connection[]
},
staleTime: 30 * 1000,
enabled: !publicMode && hasProProduct,
enabled: !publicMode && connectorAccess,
})
const {
@ -2915,7 +2918,7 @@ export function IntegrationsView({
}
if (target === "granola") {
if (!hasProProduct) {
if (!connectorAccess) {
void setConnectTarget(null)
handleUpgrade("api_pro")
} else {
@ -2939,6 +2942,7 @@ export function IntegrationsView({
connectTarget,
isAutumnLoading,
hasProProduct,
connectorAccess,
publicMode,
redirectToLogin,
setConnectTarget,
@ -3397,7 +3401,7 @@ export function IntegrationsView({
case "connector": {
const count = connectionsByProvider[item.provider].length
const isGranola = item.provider === "granola"
const needsPlanUpgrade = !isAutumnLoading && !hasProProduct
const needsPlanUpgrade = !isAutumnLoading && !connectorAccess
if (count > 0) {
return (
<div className="flex w-full items-center justify-between gap-2">
@ -3408,7 +3412,7 @@ export function IntegrationsView({
onClick={() => {
trackCard(item)
if (isGranola) {
if (!hasProProduct) {
if (!connectorAccess) {
handleUpgrade("api_pro")
return
}
@ -3440,7 +3444,7 @@ export function IntegrationsView({
onClick={() => {
trackCard(item)
if (isGranola) {
if (!hasProProduct) {
if (!connectorAccess) {
handleUpgrade("api_pro")
return
}
@ -4260,9 +4264,9 @@ export function IntegrationsView({
</Dialog>
<GranolaConnectModal
open={hasProProduct && granolaModalOpen}
open={connectorAccess && granolaModalOpen}
onOpenChange={(open) => {
setGranolaModalOpen(open && hasProProduct)
setGranolaModalOpen(open && connectorAccess)
if (!open) void setConnectTarget(null)
}}
/>

View file

@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { motion } from "motion/react"
import { useEffect, useRef, useState } from "react"
import { AnimatePresence, motion } from "motion/react"
import { Button } from "@ui/components/button"
import { Input } from "@ui/components/input"
import { Textarea } from "@ui/components/textarea"
@ -14,14 +14,17 @@ import {
Mail,
Plug,
Terminal,
User2,
UserPlus,
Users2,
Wand2,
} from "lucide-react"
import { cn } from "@lib/utils"
import { dmSans125ClassName } from "@/lib/fonts"
import type { BrainMode } from "./types"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
export interface AboutValues {
name: string
about: string
@ -84,36 +87,115 @@ export function StepAbout({
}, [defaultName, suggestedWorkspaceName, domain])
const teamGated = mode === "team" && !allowTeam
const isTeam = mode === "team" && !teamGated
// Default the workspace name per mode: name-derived for Personal (field hidden),
// email-derived for Team unless the user has typed their own.
const workspaceTouched = useRef(false)
// biome-ignore lint/correctness/useExhaustiveDependencies: derive on mode/name changes only
useEffect(() => {
if (mode === "personal") {
const first = values.name.trim().split(/\s+/)[0]
const derived = first
? `${first}'s Brain`
: suggestedWorkspaceName || "My brain"
if (values.workspaceName !== derived) {
onChange({ ...values, workspaceName: derived })
}
} else if (!workspaceTouched.current) {
const derived = suggestedWorkspaceName || ""
if (values.workspaceName !== derived) {
onChange({ ...values, workspaceName: derived })
}
}
}, [mode, values.name, suggestedWorkspaceName])
// Auto-draft the "what does your company do" blurb from the domain.
const valuesRef = useRef(values)
valuesRef.current = values
const aboutTouched = useRef(false)
const summarizedDomain = useRef<string | null>(null)
const latestDraftDomain = useRef<string | null>(null)
const [drafting, setDrafting] = useState(false)
const [drafted, setDrafted] = useState(false)
const draftCompany = async (rawDomain: string) => {
const d = rawDomain.trim().toLowerCase()
if (!d || summarizedDomain.current === d) return
if (aboutTouched.current && valuesRef.current.about.trim()) return
summarizedDomain.current = d
latestDraftDomain.current = d
setDrafting(true)
try {
const res = await fetch(`${BACKEND}/brain/company-summary`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ domain: d }),
})
// Ignore a stale response if the domain changed mid-flight.
if (latestDraftDomain.current !== d) return
if (!res.ok) return
const data = (await res.json()) as { summary?: string | null }
if (
data.summary &&
!aboutTouched.current &&
latestDraftDomain.current === d
) {
onChange({ ...valuesRef.current, about: data.summary })
setDrafted(true)
}
} catch {
if (latestDraftDomain.current === d) summarizedDomain.current = null
} finally {
if (latestDraftDomain.current === d) setDrafting(false)
}
}
// Draft once when entering Team with a domain already filled (e.g. from email).
// biome-ignore lint/correctness/useExhaustiveDependencies: run on team-entry only
useEffect(() => {
if (!isTeam) return
const d = (values.workspaceDomain || domain || "").trim()
if (d && !values.about.trim()) draftCompany(d)
}, [isTeam])
const canContinue =
!teamGated &&
values.name.trim().length > 0 &&
values.workspaceName.trim().length > 0
return (
<div className="space-y-5">
<div className="grid md:grid-cols-[1.6fr_1fr] gap-4 items-stretch">
<section
className="rounded-[22px] bg-[#1B1F24] p-6 md:p-7"
style={cardSurfaceStyle}
>
<div className="max-w-xl mx-auto space-y-5">
<section
className="rounded-[22px] bg-[#1B1F24] p-6 md:p-8"
style={cardSurfaceStyle}
>
<ModeToggle mode={mode} onChange={onModeChange} />
<div className="mt-7 flex items-center gap-4">
<UserAvatar
url={avatarUrl}
name={values.name || defaultName}
className="size-14 mb-5"
className="size-12 shrink-0"
/>
<p
className={cn(
"font-semibold text-[#fafafa] text-[20px]",
dmSans125ClassName(),
)}
>
Tell us about you
</p>
<p className="text-[#737373] font-medium text-[15px] leading-[1.4] mt-1.5">
So your brain sounds like yours, not the docs.
</p>
<div className="min-w-0">
<p
className={cn(
"font-semibold text-[#fafafa] text-[20px]",
dmSans125ClassName(),
)}
>
Tell us about you
</p>
<p className="text-[#737373] font-medium text-[14px] leading-[1.4] mt-0.5">
So your brain sounds like yours, not the docs.
</p>
</div>
</div>
<div className="mt-6 space-y-5">
<div className="mt-6 space-y-4">
<div className={cn("grid gap-4", isTeam && "sm:grid-cols-2")}>
<div>
<p className={fieldLabel}>Your name</p>
<Input
@ -125,54 +207,147 @@ export function StepAbout({
/>
</div>
{isTeam && (
<div>
<p className={fieldLabel}>Workspace name</p>
<Input
value={values.workspaceName}
onChange={(e) => {
workspaceTouched.current = true
onChange({ ...values, workspaceName: e.target.value })
}}
placeholder={suggestedWorkspaceName || "Acme"}
className={inputClass}
style={inputBevelStyle}
/>
</div>
)}
</div>
{/* Company domain slides in only for Team mode. */}
<AnimatePresence initial={false}>
{isTeam && (
<motion.div
key="domain"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.22, ease: "easeOut" }}
className="overflow-hidden"
>
<p className={fieldLabel}>Company domain</p>
<div className="relative">
<div
className="absolute left-1.5 top-1/2 -translate-y-1/2 size-9 rounded-[8px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center overflow-hidden"
style={inputBevelStyle}
>
{values.workspaceDomain || domain ? (
<DomainLogo
domain={values.workspaceDomain || domain || ""}
/>
) : (
<Building2 className="size-4 text-[#737373]" />
)}
</div>
<Input
value={values.workspaceDomain}
onChange={(e) =>
onChange({ ...values, workspaceDomain: e.target.value })
}
onBlur={(e) => {
const d = e.target.value.trim()
if (d !== values.workspaceDomain) {
onChange({ ...values, workspaceDomain: d })
}
draftCompany(d)
}}
placeholder="your-team.com"
className={cn(inputClass, "pl-14")}
style={inputBevelStyle}
/>
</div>
</motion.div>
)}
</AnimatePresence>
{!teamGated && (
<div>
<p className={fieldLabel}>
What are you here for?{" "}
<span className="text-[#525D6E] font-medium">(optional)</span>
</p>
<div className="mb-2 flex items-center justify-between gap-2 pl-2">
<p className="font-semibold text-[14px] text-[#737373]">
{isTeam ? (
"What does your company do?"
) : (
<>
What are you here for?{" "}
<span className="text-[#525D6E] font-medium">
(optional)
</span>
</>
)}
</p>
{isTeam &&
(drafting ? (
<span className="inline-flex shrink-0 items-center gap-1 text-[11px] font-medium text-[#737373]">
<Loader2 className="size-3 animate-spin" />
Drafting from your site
</span>
) : drafted ? (
<span className="inline-flex shrink-0 items-center gap-1 text-[11px] font-medium text-[#525D6E]">
<Wand2 className="size-3" />
Drafted from your site · edit anything
</span>
) : null)}
</div>
<Textarea
value={values.about}
onChange={(e) => onChange({ ...values, about: e.target.value })}
placeholder="A sentence or two — what you do, what you're hoping the brain helps with."
rows={4}
value={drafting ? "" : values.about}
onChange={(e) => {
aboutTouched.current = true
setDrafted(false)
onChange({ ...values, about: e.target.value })
}}
placeholder={
drafting
? ""
: isTeam
? "A sentence or two — what your company builds, who you serve, how the team works."
: "A sentence or two — what you do, what you're hoping the brain helps with."
}
rows={3}
disabled={drafting}
className={cn(
inputClass,
"h-auto resize-none py-3 leading-[1.5]",
drafting && "opacity-60",
)}
style={inputBevelStyle}
/>
</div>
</div>
</section>
)}
</div>
<section
className="rounded-[22px] bg-[#1B1F24] p-6 md:p-7 flex flex-col"
style={cardSurfaceStyle}
>
<ModeToggle mode={mode} onChange={onModeChange} />
<div className="mt-6">
{teamGated ? (
<TeamBetaGate onUsePersonal={() => onModeChange("personal")} />
) : mode === "team" ? (
<TeamWorkspaceCard
domain={values.workspaceDomain || domain || ""}
onDomainChange={(d) =>
onChange({ ...values, workspaceDomain: d })
}
value={values.workspaceName}
onChange={(w) => onChange({ ...values, workspaceName: w })}
suggested={suggestedWorkspaceName}
/>
) : (
<PersonalWorkspaceCard
value={values.workspaceName}
onChange={(w) => onChange({ ...values, workspaceName: w })}
/>
)}
</div>
</section>
</div>
{teamGated ? (
<motion.div
key="gate"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, ease: "easeOut" }}
className="mt-6"
>
<TeamBetaGate onUsePersonal={() => onModeChange("personal")} />
</motion.div>
) : (
<motion.div
key={`perks-${mode}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
>
<PerksFooter
perks={mode === "team" ? TEAM_PERKS : PERSONAL_PERKS}
/>
</motion.div>
)}
</section>
<div className="flex items-center justify-end gap-[22px] px-1">
<Button
@ -198,88 +373,41 @@ export function StepAbout({
)
}
function TeamWorkspaceCard({
domain,
onDomainChange,
value,
onChange,
suggested,
}: {
domain: string
onDomainChange: (d: string) => void
value: string
onChange: (v: string) => void
suggested: string
}) {
return (
<>
<div className="flex items-center gap-3">
<div
className="size-12 rounded-[12px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center overflow-hidden shrink-0"
style={inputBevelStyle}
>
{domain ? (
<DomainLogo domain={domain} />
) : (
<Building2 className="size-5 text-[#737373]" />
)}
</div>
<div className="min-w-0 flex-1">
<input
value={domain}
onChange={(e) => onDomainChange(e.target.value.trim())}
placeholder="your-team.com"
className="w-full bg-transparent text-[18px] text-[#fafafa] font-semibold leading-tight outline-none border-b border-transparent hover:border-[rgba(115,115,115,0.2)] focus:border-[rgba(115,115,115,0.4)] transition-colors px-0 py-0.5"
/>
<div className="text-[12px] text-[#737373] font-medium mt-0.5">
Team workspace
</div>
</div>
</div>
const PERSONAL_PERKS: Perk[] = [
{
icon: <Brain className="size-4 text-[#8B8B8B]" />,
title: "Your own brain",
blurb: "Notes, docs, bookmarks — all searchable in one place.",
},
{
icon: <Plug className="size-4 text-[#8B8B8B]" />,
title: "Plug into your AI tools",
blurb: "Claude, Cursor, ChatGPT — your context, everywhere.",
},
{
icon: <Users2 className="size-4 text-[#8B8B8B]" />,
title: "Switch to a team anytime",
blurb: "Invite teammates whenever you're ready.",
},
]
<div className="mt-6">
<p className={fieldLabel}>
Workspace name{" "}
<span className="text-[#525D6E] font-medium">
(rename if you'd like)
</span>
</p>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={suggested || "Acme"}
className={inputClass}
style={inputBevelStyle}
/>
</div>
<PerksList
heading="What this unlocks"
perks={[
{
icon: <UserPlus className="size-4 text-[#8B8B8B]" />,
title: "Invite teammates",
blurb: "Everyone contributes to the same brain.",
},
{
icon: <Terminal className="size-4 text-[#8B8B8B]" />,
title: "Shared coding agent context",
blurb: "Claude, Cursor, MCP — same brain across the team.",
},
{
icon: <LayoutGrid className="size-4 text-[#8B8B8B]" />,
title: "Org-wide spaces",
blurb: "Carve out sales, eng, design with their own access.",
},
]}
/>
<p className="text-[12px] text-[#525D6E] mt-auto pt-5 leading-[1.5] font-medium">
Not your team? Switch above.
</p>
</>
)
}
const TEAM_PERKS: Perk[] = [
{
icon: <UserPlus className="size-4 text-[#8B8B8B]" />,
title: "Invite teammates",
blurb: "Everyone contributes to the same brain.",
},
{
icon: <Terminal className="size-4 text-[#8B8B8B]" />,
title: "Shared coding agent context",
blurb: "Claude, Cursor, MCP — same brain across the team.",
},
{
icon: <LayoutGrid className="size-4 text-[#8B8B8B]" />,
title: "Org-wide spaces",
blurb: "Carve out sales, eng, design with their own access.",
},
]
function DomainLogo({ domain }: { domain: string }) {
const sources = [
@ -301,71 +429,6 @@ function DomainLogo({ domain }: { domain: string }) {
)
}
function PersonalWorkspaceCard({
value,
onChange,
}: {
value: string
onChange: (v: string) => void
}) {
return (
<>
<div className="flex items-center gap-3">
<div
className="size-12 rounded-[12px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center"
style={inputBevelStyle}
>
<User2 className="size-5 text-[#737373]" />
</div>
<div>
<div className="text-[18px] text-[#fafafa] font-semibold">
Just you, for now
</div>
<div className="text-[12px] text-[#737373] font-medium mt-0.5">
Personal workspace
</div>
</div>
</div>
<div className="mt-6">
<p className={fieldLabel}>Workspace nickname</p>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="My brain"
className={inputClass}
style={inputBevelStyle}
/>
</div>
<PerksList
heading="What this unlocks"
perks={[
{
icon: <Brain className="size-4 text-[#8B8B8B]" />,
title: "Your own brain",
blurb: "Notes, docs, bookmarks — all searchable in one place.",
},
{
icon: <Plug className="size-4 text-[#8B8B8B]" />,
title: "Plug into your AI tools",
blurb: "Claude, Cursor, ChatGPT — your context, everywhere.",
},
{
icon: <Users2 className="size-4 text-[#8B8B8B]" />,
title: "Switch to a team anytime",
blurb: "Invite teammates whenever you're ready.",
},
]}
/>
<p className="text-[12px] text-[#525D6E] mt-auto pt-5 leading-[1.5] font-medium">
Working with a team? Switch above.
</p>
</>
)
}
function TeamBetaGate({ onUsePersonal }: { onUsePersonal: () => void }) {
return (
<div
@ -435,6 +498,13 @@ function ModeToggle({
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
<motion.span
aria-hidden
className="absolute inset-y-1 left-1 w-[calc(50%-4px)] rounded-full"
style={{ background: "#00173C", border: "1px solid #2261CA66" }}
animate={{ x: mode === "team" ? "100%" : "0%" }}
transition={{ type: "spring", stiffness: 420, damping: 38, mass: 0.8 }}
/>
{items.map((item) => {
const isActive = mode === item.id
return (
@ -443,28 +513,13 @@ function ModeToggle({
type="button"
onClick={() => onChange(item.id)}
className={cn(
"relative h-8 rounded-full text-center transition-colors",
"relative z-10 h-8 rounded-full text-center transition-colors duration-200",
isActive
? "text-[#fafafa]"
: "text-[#737373] hover:text-[#fafafa]",
)}
>
{isActive && (
<motion.span
layoutId="brain-mode-pill"
className="absolute inset-0 rounded-full"
style={{
background: "#00173C",
border: "1px solid #2261CA66",
}}
transition={{
type: "spring",
stiffness: 380,
damping: 34,
}}
/>
)}
<span className="relative z-10">{item.label}</span>
{item.label}
</button>
)
})}
@ -511,22 +566,18 @@ function UserAvatar({
type Perk = { icon: React.ReactNode; title: string; blurb: string }
function PerksList({ heading, perks }: { heading: string; perks: Perk[] }) {
function PerksFooter({ perks }: { perks: Perk[] }) {
return (
<div className="mt-6">
<p className="text-[10px] uppercase tracking-[0.08em] text-[#737373] font-semibold mb-3 pl-1">
{heading}
</p>
<ul className="space-y-2.5">
{perks.map((p) => (
<li key={p.title} className="flex items-center gap-2.5 pl-1">
<span className="shrink-0">{p.icon}</span>
<span className="text-[13px] text-[#fafafa] font-medium">
{p.title}
</span>
</li>
))}
</ul>
<div className="mt-7 flex flex-wrap items-center justify-center gap-x-4 gap-y-2">
{perks.map((p) => (
<span
key={p.title}
className="inline-flex items-center gap-1.5 text-[12px] text-[#A1A1AA] font-medium"
>
<span className="shrink-0">{p.icon}</span>
{p.title}
</span>
))}
</div>
)
}

View file

@ -38,6 +38,8 @@ type FlowTool = {
}
const TOOL_OPTIONS: Record<BrainMode, [FlowTool, ...FlowTool[]]> = {
// Team/company-brain onboarding is focused: just get Supermemory into Slack.
// Coding agents live under "More tools (full catalog)".
team: [
{
id: "slack",
@ -46,20 +48,6 @@ const TOOL_OPTIONS: Record<BrainMode, [FlowTool, ...FlowTool[]]> = {
kind: "slack",
recommended: true,
},
{
id: "claude-code",
label: "Claude Code",
blurb: "Shared context in your terminal.",
kind: "plugin",
pluginId: "claude_code",
},
{
id: "codex",
label: "Codex",
blurb: "OpenAI's coding agent.",
kind: "plugin",
pluginId: "codex",
},
],
personal: [
{
@ -126,8 +114,8 @@ export function StepIngest({ mode, mcpUrl, onContinue }: Props) {
}
return (
<div className="mx-auto w-full max-w-[900px] pb-10">
<section className="relative py-4">
<div className="mx-auto w-full max-w-[680px] pb-10">
<section className="py-4">
<div className="mb-6 px-1">
<p
className={cn(
@ -139,80 +127,76 @@ export function StepIngest({ mode, mcpUrl, onContinue }: Props) {
</p>
<p className="mt-1.5 text-[15px] font-medium leading-[1.4] text-[#737373]">
{mode === "team"
? "Pick where your team asks questions, then set it up."
? "Add Supermemory to Slack so your team can ask in any channel."
: "Pick the tool you open every day — about 60 seconds."}
</p>
</div>
<div className="grid items-start gap-4 lg:grid-cols-[250px_minmax(0,1fr)]">
{/* Left rail: pick a tool */}
<div className="flex flex-col gap-2">
{tools.map((tool) => (
<FlowToolRow
key={tool.id}
tool={tool}
active={selected === tool.id}
onSelect={() => {
analytics.onboardingAgentSelected({ agent: tool.id })
setSelected(tool.id)
<div
className="rounded-[22px] bg-[#1B1F24] p-6 md:p-7"
style={modalCardStyle}
>
{tools.length > 1 ? (
<>
<FlowTabs
tools={tools}
selected={selected}
onSelect={(id) => {
analytics.onboardingAgentSelected({ agent: id })
setSelected(id)
}}
/>
))}
<Link
href="/settings/integrations"
className="mt-1 inline-flex items-center gap-1.5 px-2 py-1.5 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
>
More tools
<span className="text-[#525D6E]">(full catalog)</span>
<ExternalLink className="size-3.5" aria-hidden />
</Link>
</div>
{/* Right pane: setup detail */}
<div
className="relative flex min-h-[300px] flex-col overflow-hidden rounded-[22px] bg-[#1B1F24] p-6 md:p-7"
style={modalCardStyle}
>
<div
aria-hidden
className="absolute -top-px right-10 left-10 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.4), transparent)",
}}
/>
<div className="flex flex-1 flex-col">
<div className="mb-5 flex items-center gap-3">
<div
className="flex size-10 shrink-0 items-center justify-center rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
style={inputBevelStyle}
>
<ToolIcon id={activeTool.id} />
</div>
<div>
<p className="text-[18px] font-semibold text-[#fafafa]">
Set up {activeTool.label}
</p>
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
{activeTool.blurb}
</p>
</div>
<p className="mt-5 mb-4 px-1 text-[13px] font-medium text-[#737373]">
{activeTool.blurb}
</p>
</>
) : (
<div className="mb-5 flex items-center gap-3 px-1">
<div
className="flex size-10 shrink-0 items-center justify-center rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
style={inputBevelStyle}
>
<ToolIcon id={activeTool.id} />
</div>
<div>
<p className="text-[16px] font-semibold text-[#fafafa]">
Set up {activeTool.label}
</p>
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
{activeTool.blurb}
</p>
</div>
{activeTool.kind === "slack" ? (
<SlackSetupPanel />
) : activeTool.kind === "mcp" ? (
<McpGenericSetup mcpUrl={mcpUrl} />
) : activeTool.pluginId ? (
<PluginSetup
key={activeTool.pluginId}
pluginId={activeTool.pluginId}
/>
) : null}
</div>
)}
<div className="mt-6 flex items-center justify-end gap-[22px] border-t border-white/[0.06] pt-5">
{activeTool.kind === "slack" ? (
<SlackSetupPanel />
) : activeTool.kind === "mcp" ? (
<McpGenericSetup mcpUrl={mcpUrl} />
) : activeTool.pluginId ? (
<PluginSetup
key={activeTool.pluginId}
pluginId={activeTool.pluginId}
/>
) : null}
<div
className={cn(
"mt-6 flex items-center gap-3 border-t border-white/[0.06] pt-5",
tools.length > 1 ? "justify-between" : "justify-end",
)}
>
{tools.length > 1 && (
<Link
href="/settings/integrations"
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
>
More tools
<span className="text-[#525D6E]">(full catalog)</span>
<ExternalLink className="size-3.5" aria-hidden />
</Link>
)}
<div className="flex items-center gap-[22px]">
<button
type="button"
onClick={handleSkip}
@ -253,77 +237,47 @@ function ToolIcon({ id, className }: { id: FlowToolId; className?: string }) {
)
}
function FlowToolRow({
tool,
active,
function FlowTabs({
tools,
selected,
onSelect,
}: {
tool: FlowTool
active: boolean
onSelect: () => void
tools: readonly FlowTool[]
selected: FlowToolId
onSelect: (id: FlowToolId) => void
}) {
return (
<button
type="button"
onClick={onSelect}
aria-pressed={active}
className={cn(
"group relative flex w-full items-center gap-3 overflow-hidden rounded-[14px] p-3 text-left transition-all duration-150",
active
? "bg-[#10151D] ring-2 ring-[#4BA0FA]/45"
: "bg-[#1B1F24] ring-1 ring-white/[0.05] hover:ring-white/[0.12]",
)}
style={modalCardStyle}
<div
className="grid gap-1 rounded-[14px] border border-[rgba(115,115,115,0.2)] bg-[#0D121A] p-1"
style={{
gridTemplateColumns: `repeat(${tools.length}, minmax(0,1fr))`,
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
{active && (
<div
aria-hidden
className="absolute -top-px right-5 left-5 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.55), transparent)",
}}
/>
)}
<div
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-[10px] border bg-[#14161A] transition-colors",
active ? "border-[#2261CA]/45" : "border-[rgba(82,89,102,0.2)]",
)}
style={inputBevelStyle}
>
<ToolIcon id={tool.id} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-[14px] font-semibold leading-tight text-[#fafafa]">
{tool.label}
</p>
{tool.recommended && (
<span className="shrink-0 rounded-full bg-[#4BA0FA]/12 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.08em] text-[#4BA0FA]">
Recommended
</span>
)}
</div>
<p className="mt-0.5 truncate text-[12px] font-medium text-[#737373]">
{tool.blurb}
</p>
</div>
<span
aria-hidden
className={cn(
"flex size-[18px] shrink-0 items-center justify-center rounded-full border transition-colors",
active
? "border-[#4BA0FA] bg-[#4BA0FA]"
: "border-[rgba(82,89,102,0.4)] group-hover:border-[rgba(115,115,115,0.5)]",
)}
>
{active && <Check className="size-3 text-white" />}
</span>
</button>
{tools.map((tool) => {
const active = tool.id === selected
return (
<button
key={tool.id}
type="button"
onClick={() => onSelect(tool.id)}
aria-pressed={active}
className={cn(
"relative flex h-10 items-center justify-center gap-2 rounded-[10px] border border-transparent px-2 text-[13px] font-medium transition-colors",
active ? "text-[#fafafa]" : "text-[#737373] hover:text-[#fafafa]",
)}
style={
active
? { background: "#00173C", borderColor: "#2261CA66" }
: undefined
}
>
<ToolIcon id={tool.id} className="size-4 shrink-0" />
<span className="truncate">{tool.label}</span>
</button>
)
})}
</div>
)
}
@ -332,26 +286,36 @@ function StepRow({
title,
done,
children,
last,
}: {
index: number
title: ReactNode
done?: boolean
children?: ReactNode
last?: boolean
}) {
return (
<div className="flex gap-3">
<span
aria-hidden
className={cn(
"mt-0.5 flex size-[22px] shrink-0 items-center justify-center rounded-full text-[12px] font-semibold transition-colors",
done
? "bg-[#4BA0FA] text-white"
: "border border-[rgba(82,89,102,0.3)] bg-[#14161A] text-[#737373]",
<div className="flex flex-col items-center">
<span
aria-hidden
className={cn(
"flex size-[22px] shrink-0 items-center justify-center rounded-full text-[12px] font-semibold transition-colors",
done
? "bg-[#4BA0FA] text-white"
: "border border-[rgba(82,89,102,0.3)] bg-[#14161A] text-[#737373]",
)}
>
{done ? <Check className="size-3" /> : index}
</span>
{!last && (
<span
aria-hidden
className="mt-1.5 w-px flex-1 bg-[rgba(82,89,102,0.3)]"
/>
)}
>
{done ? <Check className="size-3" /> : index}
</span>
<div className="min-w-0 flex-1 pt-0.5">
</div>
<div className={cn("min-w-0 flex-1 pt-0.5", last ? "pb-0" : "pb-6")}>
<div className="text-[13px] font-medium leading-[1.5] text-[#fafafa]">
{title}
</div>
@ -370,7 +334,7 @@ function PluginSetup({ pluginId }: { pluginId: string }) {
)
return (
<div className="space-y-4">
<div>
{steps.map((step, i) => (
<StepRow key={step.title} index={i + 1} title={step.title}>
{step.description ? (
@ -381,7 +345,7 @@ function PluginSetup({ pluginId }: { pluginId: string }) {
{step.code ? <CopyCodeBlock code={step.code} /> : null}
</StepRow>
))}
<StepRow index={steps.length + 1} title="Ask your brain to test it">
<StepRow index={steps.length + 1} last title="Ask your brain to test it">
<CopyCodeBlock code={TEST_PROMPT} />
</StepRow>
</div>
@ -390,7 +354,7 @@ function PluginSetup({ pluginId }: { pluginId: string }) {
function McpGenericSetup({ mcpUrl }: { mcpUrl: string }) {
return (
<div className="space-y-4">
<div>
<StepRow index={1} title="Copy your universal MCP URL">
<McpUrlRow url={mcpUrl} />
</StepRow>
@ -403,7 +367,7 @@ function McpGenericSetup({ mcpUrl }: { mcpUrl: string }) {
<ExternalLink className="size-3.5" aria-hidden />
</Link>
</StepRow>
<StepRow index={3} title="Ask your brain to test it">
<StepRow index={3} last title="Ask your brain to test it">
<CopyCodeBlock code={TEST_PROMPT} />
</StepRow>
</div>
@ -444,35 +408,36 @@ function SlackSetupPanel() {
const connected = status?.connected ?? false
return (
<div className="space-y-4">
<div>
<StepRow
index={1}
done={connected}
title={
connected
? `Connected to ${status?.teamName ?? "your workspace"}`
: "Add Supermemory to your Slack workspace"
}
>
{!connected &&
(loading ? (
<span className="inline-flex items-center gap-2 text-[12px] font-medium text-[#737373]">
<Loader2 className="size-3.5 animate-spin" />
Checking
</span>
connected ? (
`Connected to ${status?.teamName ?? "your workspace"}`
) : (
<Button
variant="insideOut"
asChild
className="h-9 gap-2 rounded-full px-4 text-[13px] font-medium text-[#fafafa]"
>
<a href={`${BACKEND}/brain/slack/oauth/install`}>
<SlackMark className="size-4" />
Add to Slack
</a>
</Button>
))}
</StepRow>
<div className="flex items-start justify-between gap-3">
<span className="pt-1">
Add Supermemory to your Slack workspace
</span>
{loading ? (
<span className="inline-flex shrink-0 items-center gap-2 text-[12px] font-medium text-[#737373]">
<Loader2 className="size-3.5 animate-spin" />
Checking
</span>
) : (
<a
href={`${BACKEND}/brain/slack/oauth/install`}
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-white px-3.5 py-2 text-[13px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02]"
>
<SlackMark className="size-4" />
Add to Slack
</a>
)}
</div>
)
}
/>
<StepRow
index={2}
title={
@ -482,7 +447,7 @@ function SlackSetupPanel() {
</>
}
/>
<StepRow index={3} title="Ask your brain to test it">
<StepRow index={3} last title="Ask your brain to test it">
<CopyCodeBlock code="@supermemory what do we know about [topic]?" />
</StepRow>
</div>

View file

@ -1,11 +1,12 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { useQueryState } from "nuqs"
import Image from "next/image"
import { Button } from "@ui/components/button"
import { Dialog, DialogClose, DialogContent } from "@ui/components/dialog"
import { GoogleDrive, Granola, Notion, OneDrive } from "@ui/assets/icons"
import { Logo } from "@ui/assets/Logo"
import {
Select,
SelectContent,
@ -98,9 +99,11 @@ import {
type PlanType,
useTokenUsage,
} from "@/hooks/use-token-usage"
import { GranolaConnectModal } from "@/components/granola-connect-modal"
import { dmSans125ClassName } from "@/lib/fonts"
import { $fetch } from "@lib/api"
import { hasActivePlan } from "@lib/queries"
import { useAuth } from "@lib/auth-context"
import { useConnectorAccess } from "@/hooks/use-connector-access"
import {
ADD_MEMORY_SHORTCUT_URL,
CHROME_EXTENSION_URL,
@ -117,6 +120,7 @@ type SourceId =
| "gmail"
| "github"
| "onedrive"
| "granola"
| "bookmarks"
| "chatapps"
| "chrome"
@ -126,6 +130,20 @@ type SourceState = "idle" | "connecting" | "connected" | "waitlist"
type DriveScope = "selective" | "full"
type RequiredPlan = "pro" | "max"
const PROVIDER_TO_SOURCE: Record<string, SourceId> = {
"google-drive": "drive",
notion: "notion",
onedrive: "onedrive",
granola: "granola",
}
const SOURCE_LABEL: Partial<Record<SourceId, string>> = {
drive: "Google Drive",
notion: "Notion",
onedrive: "OneDrive",
granola: "Granola",
}
const PLAN_LABELS: Record<RequiredPlan, string> = {
pro: "Pro",
max: "Max",
@ -224,7 +242,6 @@ export interface SourcesValues {
interface Props {
containerTag: string
workspaceName: string
mode: BrainMode
values: SourcesValues
onChange: (next: SourcesValues) => void
@ -281,7 +298,6 @@ function ChatAppsIconCluster() {
export function StepSources({
containerTag,
workspaceName,
mode,
values,
onChange,
@ -289,20 +305,99 @@ export function StepSources({
}: Props) {
const [moreOpen, setMoreOpen] = useState(false)
const [plansOpen, setPlansOpen] = useState(false)
const [granolaOpen, setGranolaOpen] = useState(false)
const [requestedPlan, setRequestedPlan] = useState<RequiredPlan>("pro")
const [requestedConnector, setRequestedConnector] = useState("This connector")
const autumn = useCustomer()
const hasPro = hasActivePlan(autumn.data?.subscriptions, "api_pro")
const hasMax = hasActivePlan(autumn.data?.subscriptions, "api_max")
const planLoading = autumn.isLoading
const { hasMax, connectorAccess, loading: planLoading } = useConnectorAccess()
const { org, isRestoring } = useAuth()
useEffect(() => {
setMoreOpen(false)
}, [])
const valuesRef = useRef(values)
valuesRef.current = values
// dedupe toasts across the param + reconcile paths
const announced = useRef(new Set<SourceId>())
const seeded = useRef(false)
const markConnected = (ids: SourceId[]) => {
const current = valuesRef.current
const updates: Partial<Record<SourceId, SourceState>> = {}
for (const id of ids) {
if (current.connected[id] !== "connected") updates[id] = "connected"
}
if (Object.keys(updates).length > 0) {
onChange({
...current,
connected: { ...current.connected, ...updates },
})
}
}
// keyed by org so it re-runs once the active org restores on reload
const { data: liveConnections, refetch: refetchConnections } = useQuery({
queryKey: ["onboarding-connections", org?.id],
queryFn: async () => {
const res = await $fetch("@post/connections/list", {
body: { containerTags: [] },
})
if (res.error) return [] as Array<{ provider?: string }>
return (res.data ?? []) as Array<{ provider?: string }>
},
enabled: !isRestoring && !!org?.id,
staleTime: 10_000,
refetchOnWindowFocus: true,
})
// biome-ignore lint/correctness/useExhaustiveDependencies: reconcile on fetched connections only
useEffect(() => {
if (!liveConnections) return
const ids = liveConnections
.map((c) => (c.provider ? PROVIDER_TO_SOURCE[c.provider] : undefined))
.filter((id): id is SourceId => Boolean(id))
markConnected(ids)
// first load: seed without toasting pre-existing connections
if (!seeded.current) {
seeded.current = true
for (const id of ids) announced.current.add(id)
return
}
for (const id of ids) {
if (!announced.current.has(id)) {
announced.current.add(id)
toast.success(`${SOURCE_LABEL[id] ?? "Source"} connected`)
}
}
}, [liveConnections])
// Post-OAuth redirect lands with ?connected=<provider> — confirm it instantly.
const [connectedParam, setConnectedParam] = useQueryState("connected")
// biome-ignore lint/correctness/useExhaustiveDependencies: run when the param arrives
useEffect(() => {
if (!connectedParam) return
const id = PROVIDER_TO_SOURCE[connectedParam]
if (id) {
markConnected([id])
if (!announced.current.has(id)) {
announced.current.add(id)
toast.success(`${SOURCE_LABEL[id] ?? "Source"} connected`)
}
}
setConnectedParam(null)
const t1 = setTimeout(() => void refetchConnections(), 1500)
const t2 = setTimeout(() => void refetchConnections(), 4000)
return () => {
clearTimeout(t1)
clearTimeout(t2)
}
}, [connectedParam])
// company_brain unlocks pro connectors; max stays gated
const isLocked = (plan?: RequiredPlan) => {
if (!plan || planLoading) return false
return plan === "max" ? !hasMax : !hasPro
if (plan === "max") return !hasMax
return !connectorAccess
}
const setState = (id: SourceId, state: SourceState) => {
@ -382,71 +477,91 @@ export function StepSources({
<div className="mx-auto w-full max-w-[1400px] pb-10">
<section className="relative min-h-[calc(100dvh-136px)] py-4">
<div className="absolute inset-x-0 top-[46%] -translate-y-1/2">
<div className="flex flex-wrap items-end justify-between gap-3 mb-6 px-1">
<div>
<p
className={cn(
"font-semibold text-[#fafafa] text-[22px]",
dmSans125ClassName(),
)}
>
{mode === "personal"
? "Bring your context together"
: "Connect your team's signals"}
</p>
<p className="text-[#737373] font-medium text-[15px] leading-[1.4] mt-1.5">
Start with the sources that carry the most context. Add more
anytime.
</p>
</div>
<RoutingChip workspaceName={workspaceName} />
<div className="mb-6 px-1">
<p
className={cn(
"font-semibold text-[#fafafa] text-[22px]",
dmSans125ClassName(),
)}
>
{mode === "personal"
? "Bring your context together"
: "Connect your team's signals"}
</p>
<p className="text-[#737373] font-medium text-[15px] leading-[1.4] mt-1.5">
Start with the sources that carry the most context. Add more
anytime.
</p>
</div>
<div className="grid md:grid-cols-3 gap-4">
<SourceCard
title="Import bookmarks"
blurb="One-shot import of your saved tweets."
icon={<XBookmarksIcon className="size-6 text-[#fafafa]" />}
state={values.connected.bookmarks ?? "idle"}
ctaLabel="Connect"
doneLabel="Opened"
perks={[
"Bookmarks become searchable memories",
"One-click import from the X bookmarks tab",
"Works via the Chrome extension",
]}
onConnect={() => openExternal("bookmarks", CHROME_EXTENSION_URL)}
/>
<SourceCard
title="Import from AI chat apps"
blurb="Bring memories from ChatGPT, Claude, Grok & more."
icon={<ChatAppsIconCluster />}
bareIconFrame
state={values.connected.chatapps ?? "idle"}
ctaLabel="Connect"
doneLabel="Opened"
perks={[
"Sync your ChatGPT memories",
"Carry context across every assistant",
"Import once, recall anywhere",
]}
onConnect={() => openExternal("chatapps", CHROME_EXTENSION_URL)}
/>
{mode === "personal" ? (
<NotionSourceCard
values={values}
isLocked={isLocked}
guard={guard}
connectRealProvider={connectRealProvider}
/>
<>
<SourceCard
title="Import bookmarks"
blurb="One-shot import of your saved tweets."
icon={<XBookmarksIcon className="size-6 text-[#fafafa]" />}
state={values.connected.bookmarks ?? "idle"}
ctaLabel="Connect"
doneLabel="Opened"
perks={[
"Bookmarks become searchable memories",
"One-click import from the X bookmarks tab",
"Works via the Chrome extension",
]}
onConnect={() =>
openExternal("bookmarks", CHROME_EXTENSION_URL)
}
/>
<SourceCard
title="Import from AI chat apps"
blurb="Bring memories from ChatGPT, Claude, Grok & more."
icon={<ChatAppsIconCluster />}
bareIconFrame
state={values.connected.chatapps ?? "idle"}
ctaLabel="Connect"
doneLabel="Opened"
perks={[
"Sync your ChatGPT memories",
"Carry context across every assistant",
"Import once, recall anywhere",
]}
onConnect={() =>
openExternal("chatapps", CHROME_EXTENSION_URL)
}
/>
<NotionSourceCard
mode={mode}
values={values}
isLocked={isLocked}
guard={guard}
connectRealProvider={connectRealProvider}
/>
</>
) : (
<GoogleDriveSourceCard
values={values}
onChange={onChange}
isLocked={isLocked}
guard={guard}
connectRealProvider={connectRealProvider}
/>
<>
<NotionSourceCard
mode={mode}
values={values}
isLocked={isLocked}
guard={guard}
connectRealProvider={connectRealProvider}
/>
<GranolaSourceCard
state={values.connected.granola ?? "idle"}
isLocked={isLocked}
guard={guard}
onOpen={() => setGranolaOpen(true)}
/>
<GoogleDriveSourceCard
mode={mode}
values={values}
onChange={onChange}
isLocked={isLocked}
guard={guard}
connectRealProvider={connectRealProvider}
/>
</>
)}
</div>
@ -465,7 +580,7 @@ export function StepSources({
/>
More integrations
<span className="text-[#525D6E]">
(Notion, Gmail, GitHub, OneDrive)
(Gmail, GitHub, OneDrive)
</span>
</button>
<SourceActions
@ -486,6 +601,7 @@ export function StepSources({
openExternal={openExternal}
requestWaitlist={requestWaitlist}
connectRealProvider={connectRealProvider}
onOpenGranola={() => setGranolaOpen(true)}
/>
</div>
) : null}
@ -500,6 +616,15 @@ export function StepSources({
requestedConnector={requestedConnector}
requestedPlan={requestedPlan}
/>
<GranolaConnectModal
open={granolaOpen}
onOpenChange={setGranolaOpen}
containerTags={[containerTag]}
onSuccess={() => {
announced.current.add("granola")
markConnected(["granola"])
}}
/>
</div>
)
}
@ -832,12 +957,14 @@ function SourceActions({
}
function GoogleDriveSourceCard({
mode,
values,
onChange,
isLocked,
guard,
connectRealProvider,
}: {
mode: BrainMode
values: SourcesValues
onChange: (next: SourcesValues) => void
isLocked: (plan?: RequiredPlan) => boolean
@ -882,17 +1009,21 @@ function GoogleDriveSourceCard({
onChange={(s) => onChange({ ...values, driveScope: s })}
/>
}
footerRight={<SpaceChip name="My Drive" />}
footerRight={
<SpaceChip name={mode === "team" ? "Team Brain" : "My Brain"} />
}
/>
)
}
function NotionSourceCard({
mode,
values,
isLocked,
guard,
connectRealProvider,
}: {
mode: BrainMode
values: SourcesValues
isLocked: (plan?: RequiredPlan) => boolean
guard: (
@ -922,7 +1053,46 @@ function NotionSourceCard({
onConnect={guard("pro", "Notion", () =>
connectRealProvider("notion", "notion"),
)}
footerRight={<SpaceChip name="Company Notion" />}
footerRight={
<SpaceChip name={mode === "team" ? "Team Brain" : "My Brain"} />
}
/>
)
}
function GranolaSourceCard({
state,
isLocked,
guard,
onOpen,
}: {
state: SourceState
isLocked: (plan?: RequiredPlan) => boolean
guard: (
plan: RequiredPlan | undefined,
title: string,
fn: () => void,
) => () => void
onOpen: () => void
}) {
return (
<SourceCard
title="Granola"
blurb="Meeting notes into searchable decisions."
icon={<Granola className="size-6" />}
state={state}
ctaLabel="Connect"
locked={isLocked("pro")}
requiredPlan="pro"
perks={[
"Meeting notes auto-captured",
"Decisions and action items extracted",
"Synced after every meeting",
]}
onConnect={guard("pro", "Granola", () => {
analytics.onboardingIntegrationClicked({ integration: "granola" })
onOpen()
})}
/>
)
}
@ -936,6 +1106,7 @@ function MoreSourcesGrid({
openExternal,
requestWaitlist,
connectRealProvider,
onOpenGranola,
}: {
mode: BrainMode
values: SourcesValues
@ -952,6 +1123,7 @@ function MoreSourcesGrid({
provider: "google-drive" | "notion" | "onedrive",
id: SourceId,
) => void
onOpenGranola: () => void
}) {
return (
<>
@ -999,20 +1171,14 @@ function MoreSourcesGrid({
/>
{mode === "personal" ? (
<GoogleDriveSourceCard
mode={mode}
values={values}
onChange={onChange}
isLocked={isLocked}
guard={guard}
connectRealProvider={connectRealProvider}
/>
) : (
<NotionSourceCard
values={values}
isLocked={isLocked}
guard={guard}
connectRealProvider={connectRealProvider}
/>
)}
) : null}
<SourceCard
title="OneDrive"
blurb="Office docs from OneDrive."
@ -1060,46 +1226,18 @@ function MoreSourcesGrid({
]}
onConnect={guard("max", "GitHub", () => requestWaitlist("github"))}
/>
<SourceCard
title="Granola"
blurb="Meeting notes into searchable decisions."
icon={<Granola className="size-6" />}
state="idle"
ctaLabel="Connect"
locked={isLocked("max")}
requiredPlan="max"
perks={[
"Meeting notes auto-captured",
"Decisions and action items extracted",
"Synced after every meeting",
]}
onConnect={guard("max", "Granola", () => {
analytics.onboardingIntegrationClicked({ integration: "granola" })
toast.info("Granola is coming soon.")
})}
/>
{mode === "personal" ? (
<GranolaSourceCard
state={values.connected.granola ?? "idle"}
isLocked={isLocked}
guard={guard}
onOpen={onOpenGranola}
/>
) : null}
</>
)
}
function RoutingChip({ workspaceName }: { workspaceName: string }) {
return (
<div
className="inline-flex items-center gap-2 px-3 h-9 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.2)] text-[12px]"
style={{
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
title="All sources route to your brain. You can carve out spaces after setup."
>
<Logo className="size-3.5 text-[#8B8B8B]" />
<span className="text-[#737373] font-medium">Routing to</span>
<span className="text-[#fafafa] font-semibold">
{workspaceName || "your brain"}
</span>
</div>
)
}
function SourceCard({
title,
blurb,
@ -1167,8 +1305,8 @@ function SourceCard({
{headerNote}
</div>
{isDone ? (
<span className="inline-flex items-center gap-1 text-[11px] text-[#4BA0FA] font-semibold uppercase tracking-[0.08em] shrink-0 mt-1">
<Check className="size-3" />
<span className="inline-flex items-center gap-1.5 rounded-full border border-[#2261CA55] bg-[#2261CA1A] px-2.5 py-1 text-[12px] font-semibold text-[#4BA0FA] shrink-0 mt-0.5">
<Check className="size-3.5" />
{state === "waitlist" ? "Requested" : (doneLabel ?? "Connected")}
</span>
) : (

View file

@ -144,7 +144,7 @@ export function StepTeam({
const count = values.invites.length
return (
<div className="max-w-2xl mx-auto space-y-5">
<div className="max-w-2xl mx-auto">
<section
className="rounded-[22px] bg-[#1B1F24] p-7 md:p-8"
style={modalCardStyle}
@ -294,38 +294,38 @@ export function StepTeam({
</div>
</>
)}
</section>
<div className="flex items-center justify-end gap-[22px]">
<button
type="button"
onClick={onSkip ?? onContinue}
disabled={submitting}
className="text-[#737373] font-medium text-[14px] hover:text-[#999] transition-colors disabled:opacity-50"
>
Skip for now
</button>
<Button
variant="insideOut"
onClick={onContinue}
disabled={submitting}
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
>
{submitting ? (
<>
Sending
<Loader2 className="size-3.5 animate-spin" />
</>
) : (
<>
{count > 0
? `Send ${count} invite${count === 1 ? "" : "s"}`
: "Continue"}
<ArrowRight className="size-3.5" />
</>
)}
</Button>
</div>
<div className="mt-6 flex items-center justify-end gap-[22px] border-t border-white/[0.06] pt-5">
<button
type="button"
onClick={onSkip ?? onContinue}
disabled={submitting}
className="text-[#737373] font-medium text-[14px] hover:text-[#999] transition-colors disabled:opacity-50"
>
Skip for now
</button>
<Button
variant="insideOut"
onClick={onContinue}
disabled={submitting}
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
>
{submitting ? (
<>
Sending
<Loader2 className="size-3.5 animate-spin" />
</>
) : (
<>
{count > 0
? `Send ${count} invite${count === 1 ? "" : "s"}`
: "Continue"}
<ArrowRight className="size-3.5" />
</>
)}
</Button>
</div>
</section>
</div>
)
}

View file

@ -35,6 +35,7 @@ import {
Tag,
Plus,
} from "lucide-react"
import { useQueryState } from "nuqs"
import { useEffect, useMemo, useRef, useState } from "react"
import { toast } from "sonner"
import { useContainerTags } from "@/hooks/use-container-tags"
@ -174,6 +175,10 @@ export default function Account() {
setIsEditingOrgName(false)
}, [org?.name])
// Deep link: ?invite=1 (e.g. from the dashboard) opens the invite dialog.
// Consumed below, once the role is known and only for admins/owners.
const [inviteParam, setInviteParam] = useQueryState("invite")
const activeMemberRoleQuery = useQuery({
queryKey: ["organization", org?.id, "active-member-role"],
queryFn: async () => {
@ -209,6 +214,18 @@ export default function Account() {
const canManageTeam = currentRole === "owner" || currentRole === "admin"
const isOwner = currentRole === "owner"
// Consume ?invite=1 only after the role resolves, and only for managers.
useEffect(() => {
if (inviteParam !== "1" || activeMemberRoleQuery.isLoading) return
if (canManageTeam) setInviteDialogOpen(true)
setInviteParam(null)
}, [
inviteParam,
activeMemberRoleQuery.isLoading,
canManageTeam,
setInviteParam,
])
const pendingInvitations = useMemo(
() => (org?.invitations ?? []).filter(isPendingInvitation),
[org?.invitations],

View file

@ -273,7 +273,8 @@ export default function CompanyBrainConnections() {
fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
])
if (connRes.ok) {
setRows(((await connRes.json()) as { toolkits: ConnRow[] }).toolkits)
const data = (await connRes.json()) as { toolkits?: ConnRow[] }
setRows(Array.isArray(data.toolkits) ? data.toolkits : [])
} else {
setRows([])
toast.error("Couldn't load connections.")

View file

@ -0,0 +1,19 @@
import { useCustomer } from "autumn-js/react"
import { hasActivePlan } from "@lib/queries"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
// Connector entitlement (pro tier or company_brain) — mirrors backend canAccessConnector. Not for plugins.
export function useConnectorAccess(opts?: { enabled?: boolean }) {
const enabled = opts?.enabled ?? true
const autumn = useCustomer({ queryOptions: { enabled } })
const hasCompanyBrain = useHasCompanyBrain()
const hasPro = enabled && hasActivePlan(autumn.data?.subscriptions, "api_pro")
const hasMax = enabled && hasActivePlan(autumn.data?.subscriptions, "api_max")
return {
hasPro,
hasMax,
hasCompanyBrain,
connectorAccess: hasPro || hasCompanyBrain,
loading: enabled && autumn.isLoading,
}
}