diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index 5fa70049..4ad8e215 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -1,12 +1,13 @@ "use client" +import { EnsureWorkspace } from "@/components/ensure-workspace" import { MobileBanner } from "@/components/mobile-banner" export default function AppLayout({ children }: { children: React.ReactNode }) { return ( <> - {children} + {children} ) } diff --git a/apps/web/app/(app)/onboarding/welcome/page.tsx b/apps/web/app/(app)/onboarding/welcome/page.tsx index a43c65d6..6101b070 100644 --- a/apps/web/app/(app)/onboarding/welcome/page.tsx +++ b/apps/web/app/(app)/onboarding/welcome/page.tsx @@ -19,7 +19,30 @@ import { } from "./layout" import { gapVariants, orbVariants } from "@/lib/variants" import { authClient } from "@lib/auth" +import { useAuth } from "@lib/auth-context" import { analytics } from "@/lib/analytics" +import { toast } from "sonner" + +function generateSlugFromName(value: string) { + return ( + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") || "org" + ) +} + +function generateOrgSlug(name: string) { + const base = generateSlugFromName(name.trim()) + const randomNum = Math.floor(100000 + Math.random() * 900000) + return `${base}-${randomNum}` +} + +function generateUsername(name: string) { + const base = generateSlugFromName(name.trim()) + const randomNum = Math.floor(100000 + Math.random() * 900000) + return `${base}${randomNum}` +} function UserSupermemory({ name }: { name: string }) { return ( @@ -78,20 +101,67 @@ export default function WelcomePage() { goToStep, } = useWelcomeContext() + const { refetchOrganizations, setActiveOrg } = useAuth() + const handleSubmit = async () => { localStorage.setItem("username", name) if (name.trim()) { setIsSubmitting(true) try { - await authClient.updateUser({ displayUsername: name.trim() }) - } catch (error) { - console.error("Failed to update displayUsername:", error) - } + await authClient.updateUser({ + displayUsername: name.trim(), + username: generateUsername(name.trim()), + }) - analytics.onboardingNameSubmitted({ name_length: name.trim().length }) - goToStep("greeting") - setIsSubmitting(false) + const refetchResult = await refetchOrganizations() + const refetchData = ( + refetchResult as { data?: unknown[] | null | undefined } + )?.data + const existingOrgs = Array.isArray(refetchData) ? refetchData : [] + + if (existingOrgs.length > 0) { + analytics.onboardingNameSubmitted({ + name_length: name.trim().length, + }) + goToStep("greeting") + return + } + + const uniqueSlug = generateOrgSlug(name.trim()) + const completedAt = new Date().toISOString() + const newOrg = await authClient.organization.create({ + name: name.trim(), + slug: uniqueSlug, + metadata: { + signupSource: "consumer", + webOnboarding: { + completedAt: null, + steps: { + welcomeInput: { + startedAt: completedAt, + completedAt, + data: {}, + }, + }, + }, + }, + }) + + await setActiveOrg(newOrg.slug) + + analytics.onboardingNameSubmitted({ name_length: name.trim().length }) + goToStep("greeting") + } catch (error) { + console.error("Onboarding submit failed:", error) + toast.error( + error instanceof Error + ? error.message + : "Could not set up your workspace. Please try again.", + ) + } finally { + setIsSubmitting(false) + } } } diff --git a/apps/web/components/ensure-workspace.tsx b/apps/web/components/ensure-workspace.tsx new file mode 100644 index 00000000..a9bd8823 --- /dev/null +++ b/apps/web/components/ensure-workspace.tsx @@ -0,0 +1,21 @@ +"use client" + +import { useEffect } from "react" +import { usePathname, useRouter } from "next/navigation" +import { useAuth } from "@lib/auth-context" + +export function EnsureWorkspace({ children }: { children: React.ReactNode }) { + const pathname = usePathname() + const router = useRouter() + const { session, organizations, isRestoring } = useAuth() + + useEffect(() => { + if (!session) return + if (isRestoring || organizations === null) return + if (organizations.length > 0) return + if (pathname.startsWith("/onboarding")) return + router.replace("/onboarding/welcome?step=input") + }, [session, organizations, isRestoring, pathname, router]) + + return children +} diff --git a/packages/lib/auth-context.tsx b/packages/lib/auth-context.tsx index caf7b6d5..45ffc5b4 100644 --- a/packages/lib/auth-context.tsx +++ b/packages/lib/auth-context.tsx @@ -12,13 +12,21 @@ import { authClient, useSession } from "./auth" type Organization = typeof authClient.$Infer.ActiveOrganization type SessionData = NonNullable["data"]> +type OrganizationListItem = NonNullable< + ReturnType["data"] +>[number] + +const STORAGE_KEY = "supermemory-consumer-last-org-slug" interface AuthContextType { session: SessionData["session"] | null user: SessionData["user"] | null org: Organization | null + organizations: OrganizationListItem[] | null + isRestoring: boolean setActiveOrg: (orgSlug: string) => Promise updateOrgMetadata: (partial: Record) => void + refetchOrganizations: () => Promise } const AuthContext = createContext(undefined) @@ -26,17 +34,30 @@ const AuthContext = createContext(undefined) export function AuthProvider({ children }: { children: ReactNode }) { const { data: session } = useSession() const [org, setOrg] = useState(null) - const { data: orgs } = authClient.useListOrganizations() + const [isRestoring, setIsRestoring] = useState(true) + const { + data: orgsData, + refetch: refetchOrgsQuery, + isPending: orgsPending, + } = authClient.useListOrganizations() - const setActiveOrg = async (slug: string) => { + const organizations: OrganizationListItem[] | null = + session?.session == null ? null : orgsPending ? null : (orgsData ?? []) + + const refetchOrganizations = useCallback( + () => Promise.resolve(refetchOrgsQuery()), + [refetchOrgsQuery], + ) + + const setActiveOrg = useCallback(async (slug: string) => { if (!slug) return const activeOrg = await authClient.organization.setActive({ organizationSlug: slug, }) setOrg(activeOrg) - localStorage.setItem("supermemory-consumer-last-org-slug", slug) - } + localStorage.setItem(STORAGE_KEY, slug) + }, []) const updateOrgMetadata = useCallback((partial: Record) => { setOrg((prev) => { @@ -51,23 +72,81 @@ export function AuthProvider({ children }: { children: ReactNode }) { }) }, []) - // biome-ignore lint/correctness/useExhaustiveDependencies: ignoring the setActiveOrg dependency useEffect(() => { - if (!session?.session.activeOrganizationId || !orgs) return - - const savedSlug = localStorage.getItem("supermemory-consumer-last-org-slug") - - if (savedSlug && orgs.find((o) => o.slug === savedSlug)) { - setActiveOrg(savedSlug) + if (!session?.session) { + setIsRestoring(false) + setOrg(null) return } - if (savedSlug) localStorage.removeItem("supermemory-consumer-last-org-slug") - authClient.organization.getFullOrganization().then(setOrg) - }, [session?.session.activeOrganizationId, orgs]) + if (orgsPending || orgsData === undefined) { + setIsRestoring(true) + return + } + + const orgs = orgsData ?? [] + let cancelled = false + + const run = async () => { + try { + if (orgs.length === 0) { + if (!cancelled) setOrg(null) + return + } + + const activeOrgId = session.session.activeOrganizationId + + if (orgs.length === 1) { + const one = orgs[0] + if (!one) return + if (activeOrgId === one.id) { + const full = await authClient.organization.getFullOrganization() + if (!cancelled) setOrg(full) + } else { + await setActiveOrg(one.slug) + } + return + } + + const savedSlug = localStorage.getItem(STORAGE_KEY) + if (savedSlug) { + const match = orgs.find((o) => o.slug === savedSlug) + if (match) { + if (activeOrgId === match.id) { + const full = await authClient.organization.getFullOrganization() + if (!cancelled) setOrg(full) + } else { + await setActiveOrg(savedSlug) + } + return + } + localStorage.removeItem(STORAGE_KEY) + } + + if (activeOrgId) { + const fromList = orgs.find((o) => o.id === activeOrgId) + if (fromList) { + const full = await authClient.organization.getFullOrganization() + if (!cancelled) setOrg(full) + return + } + } + + const full = await authClient.organization.getFullOrganization() + if (!cancelled) setOrg(full) + } catch (error) { + console.error("Failed to restore organization:", error) + } finally { + if (!cancelled) setIsRestoring(false) + } + } + + void run() + return () => { + cancelled = true + } + }, [session, orgsData, orgsPending, setActiveOrg]) - // When a session exists and there is a pending login method recorded, - // promote it to the last-used method (successful login) and clear pending. useEffect(() => { if (typeof window === "undefined") return if (!session?.session) return @@ -83,14 +162,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (pendingMethod) { const now = Date.now() const ts = pendingTsRaw ? Number.parseInt(pendingTsRaw, 10) : Number.NaN - const isFresh = Number.isFinite(ts) && now - ts < 10 * 60 * 1000 // 10 minutes TTL + const isFresh = Number.isFinite(ts) && now - ts < 10 * 60 * 1000 if (isFresh) { localStorage.setItem("supermemory-last-login-method", pendingMethod) } } } catch {} - // Always clear pending markers once a session is present try { localStorage.removeItem("supermemory-pending-login-method") localStorage.removeItem("supermemory-pending-login-timestamp") @@ -101,10 +179,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { {children}