mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-09 03:18:04 +00:00
## What changed
- Make `/brain` reuse the active Company Brain, switch to a single existing Company Brain, show a picker for multiple choices, or create one only when none exists.
- Wait for active-organization restoration before making that decision.
- Make Company Brain onboarding match organizations by the confirmed company domain and never offer unrelated-domain workspaces.
- Create a new Company Brain when no matching workspace exists and show an actionable workspace-limit toast when creation is blocked.
- Improve the organization picker, loading, and error states.
## Why
The previous flows could use or mutate the currently active normal organization, start research against stale Company Brain metadata, or offer unrelated Company Brain workspaces after a quota failure.
## Impact
Normal organizations are no longer silently converted. Research is scoped to the Company Brain for the confirmed domain, and users receive a clear recovery path when they reach their workspace limit.
This keeps the existing `{ domain }` research API contract; no organization-reconfiguration API is required.
## Validation
- Biome checks passed for all five changed web files
- Targeted web TypeScript diagnostics reported no errors for the changed files
- React Doctor against `origin/main`: no issues found
- `git diff --check`
No test files were added.
315 lines
10 KiB
TypeScript
315 lines
10 KiB
TypeScript
"use client"
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react"
|
|
import { useRouter } from "next/navigation"
|
|
import { LogoFull } from "@ui/assets/Logo"
|
|
import { Button } from "@ui/components/button"
|
|
import { AlertTriangle, ChevronRight, Loader2, RotateCw } from "lucide-react"
|
|
import { authClient } from "@lib/auth"
|
|
import { useAuth } from "@lib/auth-context"
|
|
import { SHARED_TEAM_BRAIN_TAG } from "@lib/constants"
|
|
import { cn } from "@lib/utils"
|
|
import { analytics } from "@/lib/analytics"
|
|
import {
|
|
type BrainEntryOrganization,
|
|
resolveCompanyBrainEntry,
|
|
} from "@/lib/company-brain-entry"
|
|
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
|
import {
|
|
detectModeFromEmail,
|
|
generateOrgSlug,
|
|
workspaceDomainFromEmail,
|
|
workspaceNameFromDomain,
|
|
workspaceNameFromEmail,
|
|
type BrainMetadata,
|
|
} from "@/components/onboarding-brain/types"
|
|
|
|
const BACKEND =
|
|
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
|
|
|
const modalCardStyle = {
|
|
boxShadow:
|
|
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
|
|
}
|
|
|
|
const inputBevelStyle = {
|
|
boxShadow:
|
|
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
|
|
}
|
|
|
|
// No forms: sign up → org auto-created → Slack install.
|
|
// After OAuth, mono attaches api_scale (14d trial) + company_brain (200 credits).
|
|
export default function BrainEntryPage() {
|
|
const router = useRouter()
|
|
const {
|
|
user,
|
|
org,
|
|
organizations,
|
|
isRestoring,
|
|
setActiveOrg,
|
|
refetchOrganizations,
|
|
} = useAuth()
|
|
const { email = null } = user ?? {}
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [choices, setChoices] = useState<BrainEntryOrganization[] | null>(null)
|
|
const [attempt, setAttempt] = useState(0)
|
|
const startedRef = useRef(false)
|
|
|
|
const continueWithOrganization = useCallback(
|
|
async (organization: BrainEntryOrganization) => {
|
|
if (org?.id !== organization.id) {
|
|
await setActiveOrg(organization.slug)
|
|
}
|
|
const status = await fetch(`${BACKEND}/brain/slack/status`, {
|
|
credentials: "include",
|
|
headers: { "X-App-Source": "nova" },
|
|
})
|
|
.then((res) => (res.ok ? res.json() : null))
|
|
.catch(() => null)
|
|
if (status?.connected) {
|
|
router.replace("/")
|
|
return
|
|
}
|
|
window.location.href = `${BACKEND}/brain/slack/oauth/install`
|
|
},
|
|
[org?.id, router, setActiveOrg],
|
|
)
|
|
|
|
const createCompanyBrain = useCallback(async () => {
|
|
// Personal email → shell org; the Slack workspace resolves identity later.
|
|
const domain =
|
|
detectModeFromEmail(email) === "team"
|
|
? workspaceDomainFromEmail(email)
|
|
: null
|
|
const name =
|
|
(domain
|
|
? workspaceNameFromDomain(domain)
|
|
: workspaceNameFromEmail(email)) || "Company Brain"
|
|
const metadata: BrainMetadata & { signupSource: string } = {
|
|
signupSource: "consumer",
|
|
brainOnboardingVersion: "v1",
|
|
brainMode: "team",
|
|
brainWorkspaceName: name,
|
|
brainWorkspaceDomain: domain,
|
|
// Always the shared Team Brain; the CB UI never selects a slug space.
|
|
brainContainerTag: SHARED_TEAM_BRAIN_TAG,
|
|
}
|
|
const result = await authClient.organization.create({
|
|
name,
|
|
slug: generateOrgSlug(name),
|
|
metadata,
|
|
})
|
|
if (result.error || !result.data?.slug) {
|
|
throw new Error(result.error?.message || "Could not create workspace.")
|
|
}
|
|
await setActiveOrg(result.data.slug)
|
|
await refetchOrganizations()
|
|
analytics.onboardingWorkspaceCreated({
|
|
mode: "team",
|
|
has_about: false,
|
|
has_domain: Boolean(domain),
|
|
})
|
|
window.location.href = `${BACKEND}/brain/slack/oauth/install`
|
|
}, [email, refetchOrganizations, setActiveOrg])
|
|
|
|
const run = useCallback(async () => {
|
|
const organizationsWithActiveMetadata = (organizations ?? []).map(
|
|
(organization) =>
|
|
organization.id === org?.id
|
|
? { ...organization, metadata: org.metadata }
|
|
: organization,
|
|
)
|
|
const decision = resolveCompanyBrainEntry(
|
|
org?.id,
|
|
organizationsWithActiveMetadata,
|
|
)
|
|
|
|
if (decision.action === "use" || decision.action === "switch") {
|
|
await continueWithOrganization(decision.organization)
|
|
return
|
|
}
|
|
if (decision.action === "choose") {
|
|
setChoices(decision.organizations)
|
|
return
|
|
}
|
|
await createCompanyBrain()
|
|
}, [continueWithOrganization, createCompanyBrain, org, organizations])
|
|
|
|
const handleChoice = useCallback(
|
|
(organization: BrainEntryOrganization) => {
|
|
setChoices(null)
|
|
setError(null)
|
|
continueWithOrganization(organization).catch((e) => {
|
|
startedRef.current = false
|
|
console.error("Company Brain organization selection failed:", e)
|
|
setError(e instanceof Error ? e.message : "Something went wrong.")
|
|
})
|
|
},
|
|
[continueWithOrganization],
|
|
)
|
|
|
|
// Sole caller of run(): the guard is only released on failure, so a dep change
|
|
// mid-flight can't kick off a second org creation.
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: attempt retriggers the retry
|
|
useEffect(() => {
|
|
if (!user || organizations === null || isRestoring || startedRef.current)
|
|
return
|
|
startedRef.current = true
|
|
run().catch((e) => {
|
|
startedRef.current = false
|
|
console.error("Brain entry failed:", e)
|
|
setError(e instanceof Error ? e.message : "Something went wrong.")
|
|
})
|
|
}, [user, organizations, isRestoring, run, attempt])
|
|
|
|
return (
|
|
<EntryShell>
|
|
{choices ? (
|
|
<section
|
|
className="w-full max-w-md rounded-[22px] bg-[#1B1F24] p-6 text-left md:p-8"
|
|
style={modalCardStyle}
|
|
>
|
|
<p
|
|
className={cn(
|
|
"text-[20px] font-semibold text-[#fafafa]",
|
|
dmSans125ClassName(),
|
|
)}
|
|
>
|
|
Choose your Company Brain
|
|
</p>
|
|
<p className="mt-1.5 text-[14px] font-medium leading-[1.5] text-[#737373]">
|
|
You're a member of more than one workspace. Pick the one to open.
|
|
</p>
|
|
|
|
<div className="mt-6 flex flex-col gap-2">
|
|
{choices.map((organization) => (
|
|
<button
|
|
key={organization.id}
|
|
type="button"
|
|
onClick={() => handleChoice(organization)}
|
|
style={inputBevelStyle}
|
|
className="group flex w-full items-center gap-3 rounded-[14px] border border-[rgba(82,89,102,0.2)] bg-[#14161A] px-3 py-3 text-left transition-colors hover:bg-[#1E2228] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/60"
|
|
>
|
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-full border border-[rgba(115,115,115,0.15)] bg-[#0D121A] text-[13px] font-semibold uppercase text-[#A1A1AA]">
|
|
{(organization.name.trim()[0] ?? "?").toUpperCase()}
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-[14px] font-medium text-[#fafafa]">
|
|
{organization.name}
|
|
</span>
|
|
{generateOrgSlug(organization.name) !== organization.slug && (
|
|
<span className="mt-0.5 block truncate text-[12px] font-medium text-[#525D6E]">
|
|
{organization.slug}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<ChevronRight className="size-4 shrink-0 text-[#525D6E] transition-[color,transform] group-hover:translate-x-0.5 group-hover:text-[#A1A1AA]" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{email && (
|
|
<p className="mt-5 border-t border-white/[0.06] pt-4 text-[12px] font-medium text-[#525D6E]">
|
|
Signed in as {email}
|
|
</p>
|
|
)}
|
|
</section>
|
|
) : error ? (
|
|
<section
|
|
className="w-full max-w-md rounded-[22px] bg-[#1B1F24] p-8 text-center"
|
|
style={modalCardStyle}
|
|
>
|
|
<div
|
|
className="mx-auto flex size-12 items-center justify-center rounded-[14px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
|
|
style={inputBevelStyle}
|
|
>
|
|
<AlertTriangle className="size-5 text-[#E5A94B]" />
|
|
</div>
|
|
<p
|
|
className={cn(
|
|
"mt-5 text-[20px] font-semibold text-[#fafafa]",
|
|
dmSans125ClassName(),
|
|
)}
|
|
>
|
|
Couldn't set up your Company Brain
|
|
</p>
|
|
<p className="mt-2 text-[14px] font-medium leading-[1.5] text-[#737373]">
|
|
{error}
|
|
</p>
|
|
<Button
|
|
variant="insideOut"
|
|
onClick={() => {
|
|
setError(null)
|
|
setAttempt((a) => a + 1)
|
|
}}
|
|
className="mt-6 rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
|
|
>
|
|
<RotateCw className="size-3.5" />
|
|
Try again
|
|
</Button>
|
|
</section>
|
|
) : (
|
|
<div className="flex flex-col items-center text-center">
|
|
<div className="relative flex size-14 items-center justify-center">
|
|
<span className="absolute inset-0 animate-ping rounded-[18px] bg-[#4BA0FA]/10" />
|
|
<span
|
|
className="absolute inset-0 rounded-[18px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
|
|
style={inputBevelStyle}
|
|
/>
|
|
<Loader2 className="relative size-5 animate-spin text-[#4BA0FA]" />
|
|
</div>
|
|
<p
|
|
className={cn(
|
|
"mt-6 text-[20px] font-semibold text-[#fafafa]",
|
|
dmSans125ClassName(),
|
|
)}
|
|
>
|
|
Setting up your Company Brain
|
|
</p>
|
|
<p className="mt-2 max-w-sm text-[14px] font-medium leading-[1.5] text-[#737373]">
|
|
Preparing your workspace, then we'll connect it to Slack.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</EntryShell>
|
|
)
|
|
}
|
|
|
|
function EntryShell({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"relative min-h-dvh overflow-hidden bg-[#05080D] text-[#fafafa]",
|
|
dmSansClassName(),
|
|
)}
|
|
>
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none absolute inset-0 select-none"
|
|
style={{
|
|
background:
|
|
"radial-gradient(ellipse 80% 60% at 50% 40%, rgba(75,160,250,0.08) 0%, rgba(34,97,202,0.04) 35%, transparent 70%)",
|
|
}}
|
|
/>
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none absolute inset-0 select-none"
|
|
style={{
|
|
backgroundImage:
|
|
"radial-gradient(circle at center, rgba(105,167,240,0.22) 1px, transparent 1px)",
|
|
backgroundSize: "28px 28px",
|
|
maskImage:
|
|
"radial-gradient(ellipse at center, black 0%, black 40%, transparent 90%)",
|
|
WebkitMaskImage:
|
|
"radial-gradient(ellipse at center, black 0%, black 40%, transparent 90%)",
|
|
}}
|
|
/>
|
|
<header className="pointer-events-none absolute inset-x-0 top-0 z-20 px-4 py-4 md:px-10">
|
|
<LogoFull className="h-5 text-[#fafafa] md:h-6" />
|
|
</header>
|
|
<main className="relative z-10 flex min-h-dvh flex-col items-center justify-center px-4 py-20 md:px-10">
|
|
{children}
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|