feat(web): create org from settings launches onboarding (#1136)

Settings 'create organization' now routes to /onboarding?new=1&name=... (team/personal, invites) instead of a bare authClient.create. Adds the forceCreate path with name prefill, clears new=1 after a successful create to prevent duplicate orgs, and fixes the Radix popover-to-dialog pointer-events lock.
This commit is contained in:
MaheshtheDev 2026-06-22 18:12:18 +00:00
parent 504940414c
commit ab3dcd7a73
2 changed files with 38 additions and 36 deletions

View file

@ -44,6 +44,10 @@ export default function BrainOnboardingPage() {
const { user, org, organizations, setActiveOrg, refetchOrganizations } =
useAuth()
// `?new=1` forces creating an additional org even when the user already has one.
const forceCreate = params?.get("new") === "1"
const nameParam = params?.get("name")?.trim() || ""
const stepFromUrl = (params?.get("step") as BrainStep | null) ?? "about"
const initialStep: BrainStep = BRAIN_STEPS.includes(stepFromUrl)
? stepFromUrl
@ -68,7 +72,7 @@ export default function BrainOnboardingPage() {
const [about, setAbout] = useState<AboutValues>({
name: user?.name ?? "",
about: "",
workspaceName: suggestedWorkspaceName,
workspaceName: nameParam || suggestedWorkspaceName,
workspaceDomain: domain ?? "",
})
const [sources, setSources] = useState<SourcesValues>({
@ -82,6 +86,7 @@ export default function BrainOnboardingPage() {
})
useEffect(() => {
if (forceCreate) return
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return
@ -96,7 +101,7 @@ export default function BrainOnboardingPage() {
if (cached.sources) setSources((s) => ({ ...s, ...cached.sources }))
if (cached.team) setTeam((t) => ({ ...t, ...cached.team }))
} catch {}
}, [])
}, [forceCreate])
useEffect(() => {
try {
@ -175,8 +180,13 @@ export default function BrainOnboardingPage() {
try {
localStorage.removeItem(STORAGE_KEY)
} catch {}
// Extra org from settings: hard-reload so org-scoped caches don't show the previous org's data.
if (forceCreate) {
window.location.href = "/?onboarded=1"
return
}
router.push("/?onboarded=1")
}, [router, mode, sources, team])
}, [router, mode, sources, team, forceCreate])
const goNext = useCallback(() => {
const idx = BRAIN_STEPS.indexOf(step)
@ -193,7 +203,7 @@ export default function BrainOnboardingPage() {
const creatingOrgRef = useRef(false)
const ensureOrg = useCallback(async () => {
if (organizations && organizations.length > 0) return
if (!forceCreate && organizations && organizations.length > 0) return
const name = (about.workspaceName || suggestedWorkspaceName).trim()
const slug = generateOrgSlug(name)
const metadata: BrainMetadata & { signupSource: string } = {
@ -225,6 +235,13 @@ export default function BrainOnboardingPage() {
has_about: Boolean(about.about.trim()),
has_domain: Boolean(mode === "team" && (about.workspaceDomain || domain)),
})
// Drop new=1 so a reload or back+Continue reuses this org instead of creating a duplicate.
if (forceCreate) {
const url = new URL(window.location.href)
url.searchParams.delete("new")
url.searchParams.delete("name")
router.replace(url.pathname + url.search, { scroll: false })
}
}, [
organizations,
about,
@ -234,6 +251,8 @@ export default function BrainOnboardingPage() {
containerTag,
setActiveOrg,
refetchOrganizations,
forceCreate,
router,
])
const handleAboutContinue = useCallback(async () => {

View file

@ -1,6 +1,7 @@
"use client"
import { useMemo, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { useCustomer } from "autumn-js/react"
import { toast } from "sonner"
import {
@ -13,7 +14,6 @@ import {
import { cn } from "@lib/utils"
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
import { useAuth } from "@lib/auth-context"
import { authClient } from "@lib/auth"
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
@ -23,17 +23,9 @@ import { useTokenUsage, type PlanType } from "@/hooks/use-token-usage"
const SURFACE_SHADOW =
"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"
function generateOrgSlug(name: string): string {
const base =
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "") || "org"
return `${base}-${Math.floor(100000 + Math.random() * 900000)}`
}
export function SettingsOrgSwitcher() {
const { org, organizations, setActiveOrg } = useAuth()
const router = useRouter()
const autumn = useCustomer()
const { currentPlan } = useTokenUsage(autumn)
const { data: orgSummaries } = useOrgSummaries()
@ -44,6 +36,11 @@ export function SettingsOrgSwitcher() {
const [createName, setCreateName] = useState("")
const [creating, setCreating] = useState(false)
// Clear a stale Radix `pointer-events: none` left on <body> so the dialog accepts clicks.
useEffect(() => {
if (createOpen) document.body.style.pointerEvents = ""
}, [createOpen])
const planByOrgId = useMemo(() => {
const map = new Map<string, PlanType>()
for (const summary of orgSummaries ?? []) {
@ -78,29 +75,14 @@ export function SettingsOrgSwitcher() {
}
}
const handleCreate = async () => {
const handleCreate = () => {
const name = createName.trim()
if (!name || creating) return
setCreating(true)
try {
const result = await authClient.organization.create({
name,
slug: generateOrgSlug(name),
metadata: { signupSource: "consumer" },
})
if (result.error) {
throw new Error(result.error.message ?? "Failed to create organization")
}
await setActiveOrg(result.data?.slug ?? "")
window.location.reload()
} catch (error) {
setCreating(false)
toast.error(
error instanceof Error
? error.message
: "Failed to create organization",
)
}
// Org creation now happens through the onboarding flow (team/personal, name, invites).
setCreateOpen(false)
setOpen(false)
router.push(`/onboarding?new=1&name=${encodeURIComponent(name)}`)
}
return (
@ -179,8 +161,9 @@ export function SettingsOrgSwitcher() {
<button
type="button"
onClick={() => {
// Defer dialog open: same-tick handoff leaves Radix's pointer-events stuck on <body>.
setOpen(false)
setCreateOpen(true)
setTimeout(() => setCreateOpen(true), 120)
}}
className="w-full flex items-center gap-2.5 rounded-[10px] px-3 py-2 text-left text-[#A3A3A3] transition-colors hover:bg-white/5 hover:text-white cursor-pointer"
>