feat: ensure workspace created on nova new account (#798)

Added workspace management to the onboarding flow with automatic organization creation and workspace validation throughout the app.

### What changed?

- Wrapped the app layout with an `EnsureWorkspace` component that redirects users without organizations to onboarding
- Enhanced the welcome page to automatically create an organization with a generated slug when users complete their name input
- Expanded the auth context to track organizations list, restoration state, and provide organization refetching capabilities
- Added utility functions for generating unique organization slugs and usernames from display names
- Implemented proper organization restoration logic that handles saved preferences and multiple organization scenarios
This commit is contained in:
MaheshtheDev 2026-03-24 18:17:21 +00:00
parent fc94dd8308
commit fe5d16509a
4 changed files with 199 additions and 26 deletions

View file

@ -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 (
<>
<MobileBanner />
{children}
<EnsureWorkspace>{children}</EnsureWorkspace>
</>
)
}

View file

@ -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)
}
}
}

View file

@ -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
}

View file

@ -12,13 +12,21 @@ import { authClient, useSession } from "./auth"
type Organization = typeof authClient.$Infer.ActiveOrganization
type SessionData = NonNullable<ReturnType<typeof useSession>["data"]>
type OrganizationListItem = NonNullable<
ReturnType<typeof authClient.useListOrganizations>["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<void>
updateOrgMetadata: (partial: Record<string, unknown>) => void
refetchOrganizations: () => Promise<unknown>
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
@ -26,17 +34,30 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const { data: session } = useSession()
const [org, setOrg] = useState<Organization | null>(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<string, unknown>) => {
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 }) {
<AuthContext.Provider
value={{
org,
organizations,
isRestoring,
session: session?.session ?? null,
user: session?.user ?? null,
setActiveOrg,
updateOrgMetadata,
refetchOrganizations,
}}
>
{children}