mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-10 03:28:14 +00:00
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:
parent
fc94dd8308
commit
fe5d16509a
4 changed files with 199 additions and 26 deletions
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
21
apps/web/components/ensure-workspace.tsx
Normal file
21
apps/web/components/ensure-workspace.tsx
Normal 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
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue