From 3a9310778af85b7d2e28eb9a80c9da74ceb2048b Mon Sep 17 00:00:00 2001 From: ved015 <122012786+ved015@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:27:36 +0000 Subject: [PATCH] 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. --- apps/web/app/(app)/onboarding/page.tsx | 29 ++- .../components/settings/settings-content.tsx | 191 +++++++++++++----- .../components/settings/settings-modal.tsx | 5 + .../settings/settings-org-switcher.tsx | 181 +++++------------ packages/ui/components/dialog.tsx | 7 +- 5 files changed, 217 insertions(+), 196 deletions(-) diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index a8e0499e..c819d0f8 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -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) diff --git a/apps/web/components/settings/settings-content.tsx b/apps/web/components/settings/settings-content.tsx index a257c223..429274b6 100644 --- a/apps/web/components/settings/settings-content.tsx +++ b/apps/web/components/settings/settings-content.tsx @@ -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(null) + const deleteOrgDialogTimerRef = useRef | 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 */} { setIsDeleteOrgDialogOpen(open) @@ -617,33 +669,58 @@ export function SettingsContent({ }} > { event.preventDefault() deleteOrgInputRef.current?.focus() }} > -
-
-

+
+
+ Delete this organization? -

-

- Permanently deletes{" "} - - {org?.name || "this organization"} - {" "} - — its documents, spaces, connections, and members.{" "} - - This cannot be undone. - + +

+ This action permanently removes the selected workspace.

-
-

- Type {org?.name} to - confirm: -

+ + + +
+ +
+

+ Permanently deletes{" "} + + {org?.name || "this organization"} + {" "} + and all of its documents, spaces, connections, and members.{" "} + + This cannot be undone. + +

+
-
- - - + +
+ +
+ -
+ +
diff --git a/apps/web/components/settings/settings-modal.tsx b/apps/web/components/settings/settings-modal.tsx index 89c52a3f..f94b889d 100644 --- a/apps/web/components/settings/settings-modal.tsx +++ b/apps/web/components/settings/settings-modal.tsx @@ -5,6 +5,7 @@ import { useCallback, useContext, useMemo, + useState, type ReactNode, } from "react" import { useQueryState } from "nuqs" @@ -50,6 +51,8 @@ const SettingsModalContext = createContext( export function SettingsModalProvider({ children }: { children: ReactNode }) { const [param, setParam] = useQueryState(SETTINGS_PARAM) + const [settingsDialogContent, setSettingsDialogContent] = + useState(null) const open = param !== null const tab = parseTab(param) @@ -85,6 +88,7 @@ export function SettingsModalProvider({ children }: { children: ReactNode }) { }} > diff --git a/apps/web/components/settings/settings-org-switcher.tsx b/apps/web/components/settings/settings-org-switcher.tsx index 217f30b3..1e2dcd82 100644 --- a/apps/web/components/settings/settings-org-switcher.tsx +++ b/apps/web/components/settings/settings-org-switcher.tsx @@ -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(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 - // , 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() @@ -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 ( - <> - - - - - + + + + +
event.stopPropagation()} + onTouchMoveCapture={(event) => event.stopPropagation()} > {sortedOrgs.map((organization) => { const isCurrent = organization.id === org?.id @@ -156,92 +138,19 @@ export function SettingsOrgSwitcher() { ) })} +
-
+
- - - - - { - setCreateOpen(next) - if (!next) setCreateName("") - }} - > - -
-
- - Create organization - -

- A separate workspace with its own memories, connections, and - members. -

-
- 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" - /> -
- - -
-
-
-
- + + Create organization + + + ) } diff --git a/packages/ui/components/dialog.tsx b/packages/ui/components/dialog.tsx index 87d295c1..114a6306 100644 --- a/packages/ui/components/dialog.tsx +++ b/packages/ui/components/dialog.tsx @@ -48,13 +48,18 @@ function DialogOverlay({ function DialogContent({ className, children, + portalContainer, showCloseButton = true, ...props }: React.ComponentProps & { + portalContainer?: HTMLElement | null showCloseButton?: boolean }) { return ( - +