Fix settings organization flows (#1159)

## Summary
- Fix delete-organization dialog focus when switching orgs inside Settings.
- Restyle delete-organization modal to match the app modal theme and remove extra organization icons.
- Make the organization switcher list scrollable when many orgs exist.
- Send Create organization directly to onboarding instead of opening the create-org modal.
- Stop onboarding from completing when org creation fails, show an error toast, and return existing users to the dashboard.
This commit is contained in:
ved015 2026-06-27 14:27:36 +00:00
parent f1ff7beb0f
commit 3a9310778a
5 changed files with 217 additions and 196 deletions

View file

@ -39,6 +39,16 @@ const STORAGE_KEY = "supermemory-brain-onboarding-v1"
const countsAsConnectedSource = (state: unknown) =>
state === "connected" || state === "waitlist"
const getErrorMessage = (error: unknown, fallback: string) => {
if (error instanceof Error && error.message) return error.message
if (typeof error === "string" && error.trim()) return error
if (typeof error === "object" && error !== null && "message" in error) {
const message = (error as { message?: unknown }).message
if (typeof message === "string" && message.trim()) return message
}
return fallback
}
export default function BrainOnboardingPage() {
const router = useRouter()
const params = useSearchParams()
@ -240,7 +250,12 @@ export default function BrainOnboardingPage() {
slug,
metadata,
})
await setActiveOrg(result.data?.slug ?? slug)
if (result.error || !result.data?.slug) {
throw new Error(
getErrorMessage(result.error, "Organization was not created."),
)
}
await setActiveOrg(result.data.slug)
if (about.name.trim()) {
await authClient.updateUser({
name: about.name.trim(),
@ -283,16 +298,22 @@ export default function BrainOnboardingPage() {
await ensureOrg()
goNext()
} catch (e) {
const message = getErrorMessage(e, "Organization was not created.")
console.error("Failed to create organization:", e)
analytics.onboardingWorkspaceCreateFailed({
error: e instanceof Error ? e.message : String(e),
error: message,
})
toast.error("Couldn't create your workspace. Please try again.")
toast.error("Organization was not created", {
description: "Please try again from Settings.",
})
if (forceCreate && (organizations?.length ?? 0) > 0) {
router.replace("/")
}
} finally {
creatingOrgRef.current = false
setCreatingOrg(false)
}
}, [ensureOrg, goNext])
}, [ensureOrg, goNext, forceCreate, organizations, router])
const [sendingInvites, setSendingInvites] = useState(false)
const sendingInvitesRef = useRef(false)

View file

@ -3,7 +3,7 @@
import { Logo } from "@ui/assets/Logo"
import { useAuth } from "@lib/auth-context"
import NovaOrb from "@/components/nova/nova-orb"
import { useRef, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { cn } from "@lib/utils"
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
import Account from "@/components/settings/account"
@ -31,9 +31,15 @@ import {
ChevronRight,
ArrowUpRight,
Building2,
X,
} from "lucide-react"
import { authClient } from "@lib/auth"
import { Dialog, DialogContent, DialogClose } from "@ui/components/dialog"
import {
Dialog,
DialogContent,
DialogClose,
DialogTitle,
} from "@ui/components/dialog"
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
import { useResetOrganization } from "@/hooks/use-reset-organization"
import { useDeleteUserAccount } from "@/hooks/use-account-settings"
@ -96,6 +102,11 @@ const NAV_ITEMS: NavItem[] = [
},
]
const MODAL_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"
const INSET_SHADOW = "inset 1.313px 1.313px 3.938px rgba(0,0,0,0.7)"
export function parseHashToTab(hash: string): SettingsTab {
const cleaned = hash.replace("#", "").toLowerCase()
return TABS.includes(cleaned as SettingsTab)
@ -132,11 +143,13 @@ function IdentityCard({ displayName }: { displayName: string }) {
export function SettingsContent({
activeTab,
onTabChange,
dialogPortalContainer,
className,
showIdentity = true,
}: {
activeTab: SettingsTab
onTabChange: (tab: SettingsTab) => void
dialogPortalContainer?: HTMLElement | null
className?: string
showIdentity?: boolean
}) {
@ -156,7 +169,12 @@ export function SettingsContent({
const [isDeleteOrgDialogOpen, setIsDeleteOrgDialogOpen] = useState(false)
const [deleteOrgConfirm, setDeleteOrgConfirm] = useState("")
const deleteOrgInputRef = useRef<HTMLInputElement>(null)
const deleteOrgDialogTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const deleteOrganization = useDeleteOrganization()
const activeOrgId = org?.id
const previousOrgIdRef = useRef(activeOrgId)
// Only owners can delete the organization.
const activeMemberRoleQuery = useQuery({
@ -175,11 +193,44 @@ export function SettingsContent({
const [dangerMenuOpen, setDangerMenuOpen] = useState(false)
useEffect(() => {
if (previousOrgIdRef.current === activeOrgId) return
previousOrgIdRef.current = activeOrgId
setDangerMenuOpen(false)
setIsDeleteOrgDialogOpen(false)
setDeleteOrgConfirm("")
}, [activeOrgId])
useEffect(() => {
if (!isDeleteOrgDialogOpen) return
document.body.style.pointerEvents = ""
const focusTimer = setTimeout(() => {
deleteOrgInputRef.current?.focus()
}, 0)
return () => clearTimeout(focusTimer)
}, [isDeleteOrgDialogOpen])
useEffect(() => {
return () => {
if (deleteOrgDialogTimerRef.current) {
clearTimeout(deleteOrgDialogTimerRef.current)
}
}
}, [])
const openDeleteOrganizationDialog = () => {
setDangerMenuOpen(false)
window.requestAnimationFrame(() => {
setDeleteOrgConfirm("")
if (deleteOrgDialogTimerRef.current) {
clearTimeout(deleteOrgDialogTimerRef.current)
}
deleteOrgDialogTimerRef.current = setTimeout(() => {
document.body.style.pointerEvents = ""
setIsDeleteOrgDialogOpen(true)
})
deleteOrgDialogTimerRef.current = null
}, 120)
}
const displayName =
@ -610,6 +661,7 @@ export function SettingsContent({
{/* Delete organization dialog */}
<Dialog
key={activeOrgId ?? "delete-organization"}
open={isDeleteOrgDialogOpen}
onOpenChange={(open) => {
setIsDeleteOrgDialogOpen(open)
@ -617,33 +669,58 @@ export function SettingsContent({
}}
>
<DialogContent
className="sm:max-w-md"
className={cn(
dmSans125ClassName(),
"sm:max-w-[560px] border border-white/[0.12] bg-[#1B1F24] p-0 px-4 pt-4 pb-5 gap-0 rounded-[22px] overflow-hidden text-[#FAFAFA]",
)}
portalContainer={dialogPortalContainer}
showCloseButton={false}
style={{ boxShadow: MODAL_SURFACE_SHADOW }}
onOpenAutoFocus={(event) => {
event.preventDefault()
deleteOrgInputRef.current?.focus()
}}
>
<div className={cn("flex flex-col gap-5 p-1", dmSans125ClassName())}>
<div className="flex flex-col gap-1.5">
<h2 className="text-[18px] font-semibold text-[#FAFAFA]">
<div className="flex shrink-0 items-center gap-3">
<div className="min-w-0 flex-1">
<DialogTitle className="text-[18px] font-semibold leading-tight text-[#FAFAFA]">
Delete this organization?
</h2>
<p className="text-sm text-[#8B8B8B]">
Permanently deletes{" "}
<strong className="text-[#FAFAFA]">
{org?.name || "this organization"}
</strong>{" "}
its documents, spaces, connections, and members.{" "}
<strong className="text-[#FAFAFA]">
This cannot be undone.
</strong>
</DialogTitle>
<p className="mt-0.5 truncate text-[13px] text-[#A1A1AA]">
This action permanently removes the selected workspace.
</p>
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-[#8B8B8B]">
Type <strong className="text-[#FAFAFA]">{org?.name}</strong> to
confirm:
</p>
<DialogClose asChild>
<button
type="button"
aria-label="Close"
className="flex size-9 shrink-0 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80 focus:outline-none"
style={{ boxShadow: INSET_SHADOW }}
>
<X className="size-5 text-[#737373]" />
</button>
</DialogClose>
</div>
<div className="mt-4 flex flex-col gap-4 rounded-[14px] bg-[#14161A] p-4 sm:p-5 shadow-[inset_1.313px_1.313px_3.938px_rgba(0,0,0,0.7)]">
<p className="text-[13.5px] leading-relaxed text-[#A1A1AA]">
Permanently deletes{" "}
<strong className="font-semibold text-[#FAFAFA]">
{org?.name || "this organization"}
</strong>{" "}
and all of its documents, spaces, connections, and members.{" "}
<strong className="font-semibold text-[#FAFAFA]">
This cannot be undone.
</strong>
</p>
<label className="flex flex-col gap-2">
<span className="text-[13px] text-[#A1A1AA]">
Type{" "}
<strong className="font-semibold text-[#FAFAFA]">
{org?.name}
</strong>{" "}
to confirm
</span>
<input
ref={deleteOrgInputRef}
type="text"
@ -651,43 +728,47 @@ export function SettingsContent({
onChange={(e) => setDeleteOrgConfirm(e.target.value)}
placeholder={org?.name ?? "Organization name"}
autoComplete="off"
className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C73B1B]/50 transition-colors"
className="h-11 w-full rounded-[12px] border border-white/[0.08] bg-[#0D121A] px-4 text-[14px] text-white placeholder:text-[#525D6E] transition-colors focus:border-[#C73B1B]/55 focus:outline-none focus:ring-2 focus:ring-[#C73B1B]/15"
style={{ boxShadow: INSET_SHADOW }}
/>
</div>
<div className="flex gap-3 justify-end">
<DialogClose asChild>
<button
type="button"
className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer"
>
Cancel
</button>
</DialogClose>
</label>
</div>
<div className="mt-4 flex shrink-0 flex-wrap items-center justify-end gap-2">
<DialogClose asChild>
<button
type="button"
disabled={
!org?.name ||
deleteOrgConfirm !== org.name ||
deleteOrganization.isPending
}
title={
!org?.name || deleteOrgConfirm !== org.name
? `Type "${org?.name ?? "the organization name"}" exactly to confirm`
: undefined
}
onClick={handleDeleteOrganization}
className="relative flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#290F0A] text-[#C73B1B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
>
{deleteOrganization.isPending ? (
<LoaderIcon className="size-[15px] animate-spin" />
) : (
<Building2 className="size-[15px]" />
className={cn(
"px-3 py-2 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#FAFAFA]",
dmSansClassName(),
)}
{deleteOrganization.isPending
? "Deleting…"
: "Delete organization"}
>
Cancel
</button>
</div>
</DialogClose>
<button
type="button"
disabled={
!org?.name ||
deleteOrgConfirm !== org.name ||
deleteOrganization.isPending
}
title={
!org?.name || deleteOrgConfirm !== org.name
? `Type "${org?.name ?? "the organization name"}" exactly to confirm`
: undefined
}
onClick={handleDeleteOrganization}
className="flex h-10 shrink-0 items-center justify-center gap-2 rounded-full bg-[#0D121A] px-5 text-[14px] font-semibold text-[#FAFAFA] transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-45"
style={{ boxShadow: INSET_SHADOW }}
>
{deleteOrganization.isPending ? (
<LoaderIcon className="size-4 animate-spin text-[#C73B1B]" />
) : null}
{deleteOrganization.isPending
? "Deleting..."
: "Delete organization"}
</button>
</div>
</DialogContent>
</Dialog>

View file

@ -5,6 +5,7 @@ import {
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react"
import { useQueryState } from "nuqs"
@ -50,6 +51,8 @@ const SettingsModalContext = createContext<SettingsModalContextValue | null>(
export function SettingsModalProvider({ children }: { children: ReactNode }) {
const [param, setParam] = useQueryState(SETTINGS_PARAM)
const [settingsDialogContent, setSettingsDialogContent] =
useState<HTMLDivElement | null>(null)
const open = param !== null
const tab = parseTab(param)
@ -85,6 +88,7 @@ export function SettingsModalProvider({ children }: { children: ReactNode }) {
}}
>
<DialogContent
ref={setSettingsDialogContent}
showCloseButton={false}
style={{
display: "flex",
@ -122,6 +126,7 @@ export function SettingsModalProvider({ children }: { children: ReactNode }) {
<SettingsContent
activeTab={tab}
onTabChange={handleTabChange}
dialogPortalContainer={settingsDialogContent}
showIdentity={false}
className="flex-1 min-h-0 w-full overflow-y-auto md:overflow-hidden px-5 md:px-4 pt-4 pb-6"
/>

View file

@ -1,6 +1,6 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { useCustomer } from "autumn-js/react"
import { toast } from "sonner"
@ -12,17 +12,13 @@ import {
Plus,
} from "lucide-react"
import { cn } from "@lib/utils"
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
import { dmSansClassName } from "@/lib/fonts"
import { useAuth } from "@lib/auth-context"
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
import { useOrgSummaries } from "@/hooks/use-org-summaries"
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"
export function SettingsOrgSwitcher() {
const { org, organizations, setActiveOrg } = useAuth()
const router = useRouter()
@ -32,15 +28,6 @@ export function SettingsOrgSwitcher() {
const [open, setOpen] = useState(false)
const [switchingId, setSwitchingId] = useState<string | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [createName, setCreateName] = useState("")
const [creating, setCreating] = useState(false)
// Safety net: a prior Radix overlay can leave `pointer-events: none` stuck on
// <body>, making the dialog appear but ignore clicks. Clear it when it opens.
useEffect(() => {
if (createOpen) document.body.style.pointerEvents = ""
}, [createOpen])
const planByOrgId = useMemo(() => {
const map = new Map<string, PlanType>()
@ -77,50 +64,45 @@ export function SettingsOrgSwitcher() {
}
const handleCreate = () => {
const name = createName.trim()
if (!name || creating) return
setCreating(true)
// Org creation now happens through the onboarding flow (team/personal, name, invites).
setCreateOpen(false)
setOpen(false)
router.push(`/onboarding?new=1&name=${encodeURIComponent(name)}`)
router.push("/onboarding?new=1")
}
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
dmSansClassName(),
"flex w-full items-center gap-2 rounded-[12px] border border-white/[0.06] bg-white/[0.03] px-2.5 py-2 transition-colors cursor-pointer hover:bg-white/[0.06]",
)}
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/[0.05] text-white/55">
<Building2 className="size-[13px]" />
</span>
<span className="min-w-0 flex-1 truncate text-left text-[13px] font-medium text-white">
{org?.name ?? "Personal"}
</span>
<OrgPlanBadge plan={activeOrgPlan} />
<ChevronsUpDown className="size-3.5 shrink-0 text-white/40" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
sideOffset={6}
style={{
maxHeight:
"min(360px, var(--radix-popover-content-available-height))",
}}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"w-[var(--radix-popover-trigger-width)] min-w-[220px] overflow-y-auto p-1.5",
"bg-[#14161A] border-white/10 rounded-[14px]",
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
dmSansClassName(),
"flex w-full items-center gap-2 rounded-[12px] border border-white/[0.06] bg-white/[0.03] px-2.5 py-2 transition-colors cursor-pointer hover:bg-white/[0.06]",
)}
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/[0.05] text-white/55">
<Building2 className="size-[13px]" />
</span>
<span className="min-w-0 flex-1 truncate text-left text-[13px] font-medium text-white">
{org?.name ?? "Personal"}
</span>
<OrgPlanBadge plan={activeOrgPlan} />
<ChevronsUpDown className="size-3.5 shrink-0 text-white/40" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
sideOffset={6}
className={cn(
"w-[var(--radix-popover-trigger-width)] min-w-[220px] p-1.5",
"bg-[#14161A] border-white/10 rounded-[14px]",
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
dmSansClassName(),
)}
>
<div
className="max-h-[360px] overflow-y-auto overscroll-contain pr-1 -mr-1 scrollbar-thin [scrollbar-gutter:stable]"
onWheelCapture={(event) => event.stopPropagation()}
onTouchMoveCapture={(event) => event.stopPropagation()}
>
{sortedOrgs.map((organization) => {
const isCurrent = organization.id === org?.id
@ -156,92 +138,19 @@ export function SettingsOrgSwitcher() {
</button>
)
})}
</div>
<div className="my-1 h-px bg-white/[0.06]" />
<div className="my-1 h-px bg-white/[0.06]" />
<button
type="button"
onClick={() => {
// Open the dialog only after the popover has closed — opening both in
// the same tick can leave Radix's `pointer-events: none` stuck on <body>.
setOpen(false)
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"
>
<Plus className="size-4 shrink-0" />
<span className="text-[13.5px] font-medium">
Create organization
</span>
</button>
</PopoverContent>
</Popover>
<Dialog
open={createOpen}
onOpenChange={(next) => {
setCreateOpen(next)
if (!next) setCreateName("")
}}
>
<DialogContent
showCloseButton={false}
style={{ boxShadow: SURFACE_SHADOW }}
className={cn(
"sm:max-w-[420px] border border-white/[0.12] bg-[#1B1F24] p-5 gap-0 rounded-[22px]",
dmSansClassName(),
)}
<button
type="button"
onClick={handleCreate}
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"
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<DialogTitle
className={cn(
dmSans125ClassName(),
"text-[18px] font-semibold tracking-[-0.18px] text-[#FAFAFA]",
)}
>
Create organization
</DialogTitle>
<p className="text-[13px] tracking-[-0.13px] leading-relaxed text-[#737373]">
A separate workspace with its own memories, connections, and
members.
</p>
</div>
<input
value={createName}
onChange={(e) => setCreateName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate()
}}
placeholder="Organization name"
maxLength={80}
className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#4BA0FA]/50 transition-colors"
/>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setCreateOpen(false)}
className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer"
>
Cancel
</button>
<button
type="button"
disabled={!createName.trim() || creating}
onClick={handleCreate}
className="flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#0D121A] text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
>
{creating ? (
<LoaderIcon className="size-[15px] animate-spin" />
) : (
<Plus className="size-[15px]" />
)}
{creating ? "Creating…" : "Create"}
</button>
</div>
</div>
</DialogContent>
</Dialog>
</>
<Plus className="size-4 shrink-0" />
<span className="text-[13.5px] font-medium">Create organization</span>
</button>
</PopoverContent>
</Popover>
)
}

View file

@ -48,13 +48,18 @@ function DialogOverlay({
function DialogContent({
className,
children,
portalContainer,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
portalContainer?: HTMLElement | null
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogPortal
container={portalContainer ?? undefined}
data-slot="dialog-portal"
>
<DialogOverlay />
<DialogPrimitive.Content
className={cn(