diff --git a/apps/web/app/auth/connect/page.tsx b/apps/web/app/auth/connect/page.tsx index febd2760..f4e52289 100644 --- a/apps/web/app/auth/connect/page.tsx +++ b/apps/web/app/auth/connect/page.tsx @@ -3,11 +3,19 @@ import { useAuth } from "@lib/auth-context" import { useSession } from "@lib/auth" import { cn } from "@lib/utils" +import { Logo } from "@ui/assets/Logo" import { dmSans125ClassName } from "@/lib/fonts" -import { ArrowRight, XCircle } from "lucide-react" +import { ArrowLeft, ArrowRight, LoaderIcon, XCircle } from "lucide-react" import Image from "next/image" import { useRouter, useSearchParams } from "next/navigation" -import { Suspense, useEffect, useMemo, useState } from "react" +import { + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react" import { PENDING_CONNECT_URL_KEY } from "@/lib/constants" @@ -172,7 +180,7 @@ function PluginLogoStack({ clients }: { clients: string[] }) { ) } -type Status = "loading" | "creating" | "success" | "error" +type Status = "loading" | "selection" | "approval" | "creating" | "success" const pageWrapperClass = "flex items-center justify-center min-h-screen bg-background p-4" @@ -185,9 +193,16 @@ function AuthConnectContent() { const params = useSearchParams() const router = useRouter() const { data: session, isPending } = useSession() - const { org, organizations, isRestoring } = useAuth() + const { organizations, isRestoring, setActiveOrg } = useAuth() const [status, setStatus] = useState("loading") const [error, setError] = useState(null) + const [selectedOrgId, setSelectedOrgId] = useState(null) + const [switchingOrgId, setSwitchingOrgId] = useState(null) + const switchingOrgIdRef = useRef(null) + const autoAttemptedOrgId = useRef(null) + const listRef = useRef(null) + const [canScrollUp, setCanScrollUp] = useState(false) + const [canScrollDown, setCanScrollDown] = useState(false) const callback = params.get("callback") const client = params.get("client") @@ -214,10 +229,22 @@ function AuthConnectContent() { requestedClients.length === 1 && validClient ? PLUGIN_INFO[validClient] : null + const requestError = useMemo(() => { + if (!callback) return "Missing callback parameter." + if (!isValidLocalhostCallback(callback)) return "Invalid callback URL." + if (invalidClients.length > 0) { + return `Unsupported plugin requested: ${invalidClients.join(", ")}.` + } + if (requestedClients.length === 0) return "Invalid or missing client." + return null + }, [callback, invalidClients, requestedClients.length]) + const selectedOrg = + organizations?.find((organization) => organization.id === selectedOrgId) ?? + null + const multiOrg = (organizations?.length ?? 0) > 1 - // Redirect new users (logged in but no organization) to onboarding. - // Store the current connect URL so onboarding can redirect back here. const shouldRedirectToOnboarding = + !requestError && !isPending && !isRestoring && !!session && @@ -225,6 +252,7 @@ function AuthConnectContent() { organizations.length === 0 useEffect(() => { + if (requestError) return if (isPending || isRestoring) return if (!session) return if (organizations === null) return // orgs query still pending @@ -236,41 +264,103 @@ function AuthConnectContent() { console.warn("Failed to access sessionStorage for pending connect URL", e) } router.replace("/onboarding") - }, [isPending, isRestoring, session, organizations, router]) + }, [isPending, isRestoring, session, organizations, router, requestError]) + + const selectOrganization = useCallback( + async (organization: NonNullable[number]) => { + if (switchingOrgIdRef.current) return + + setError(null) + switchingOrgIdRef.current = organization.id + setSwitchingOrgId(organization.id) + try { + await setActiveOrg(organization.slug) + setSelectedOrgId(organization.id) + setStatus("approval") + } catch (err) { + console.error("Failed to switch organization:", err) + setError("Couldn't switch to that organization. Try again.") + setStatus("selection") + } finally { + switchingOrgIdRef.current = null + setSwitchingOrgId(null) + } + }, + [setActiveOrg], + ) + + useEffect(() => { + if (requestError || isPending || isRestoring || organizations === null) + return + if (!session || organizations.length === 0 || status !== "loading") return + if (organizations.length > 1) { + setStatus("selection") + return + } + const onlyOrganization = organizations[0] + if ( + !onlyOrganization || + autoAttemptedOrgId.current === onlyOrganization.id + ) { + return + } + autoAttemptedOrgId.current = onlyOrganization.id + setStatus("selection") + void selectOrganization(onlyOrganization) + }, [ + requestError, + isPending, + isRestoring, + organizations, + session, + status, + selectOrganization, + ]) + + useEffect(() => { + if (status !== "approval" || !selectedOrgId || organizations === null) + return + if ( + organizations.some((organization) => organization.id === selectedOrgId) + ) { + return + } + setSelectedOrgId(null) + setError("That organization is no longer available. Choose another one.") + setStatus("selection") + }, [organizations, selectedOrgId, status]) + + const measureFades = useCallback((element: HTMLDivElement | null) => { + if (!element) return + setCanScrollUp(element.scrollTop > 8) + setCanScrollDown( + element.scrollTop + element.clientHeight < element.scrollHeight - 8, + ) + }, []) + + useEffect(() => { + if (status !== "selection") return + measureFades(listRef.current) + }, [measureFades, status]) async function handleConnect() { - if (!callback) { - setStatus("error") - setError("Missing callback parameter.") - return - } - if (!isValidLocalhostCallback(callback)) { - setStatus("error") - setError("Invalid callback URL.") - return - } - if (invalidClients.length > 0) { - setStatus("error") - setError(`Unsupported plugin requested: ${invalidClients.join(", ")}.`) - return - } - if (requestedClients.length === 0) { - setStatus("error") - setError("Invalid or missing client.") - return - } - if (!session || !org) { - setStatus("error") + if (requestError || !callback) return + if (!session || !selectedOrg) { setError( - "Your account is not fully set up yet. Please complete onboarding first.", + selectedOrgId + ? "That organization is no longer available. Choose another one." + : "Select an organization before approving the connection.", ) + setStatus(multiOrg ? "selection" : "approval") return } try { + setError(null) setStatus("creating") const fetchParams = new URLSearchParams({ callback }) fetchParams.set("client", requestedClients[0] ?? "") + fetchParams.set("orgId", selectedOrg.id) const res = await fetch(`${API_URL}/v3/auth/key?${fetchParams}`, { credentials: "include", @@ -306,105 +396,14 @@ function AuthConnectContent() { window.location.href = redirectUrl.toString() } catch (err) { console.error("Failed to get API key:", err) - setStatus("error") setError(err instanceof Error ? err.message : "Failed to get API key") + setStatus("approval") } } - // Show a spinner while session/org data is loading or while we're about - // to redirect to onboarding (prevents a brief flash of the connect card). const isAuthLoading = isPending || isRestoring || organizations === null - useEffect(() => { - if (status !== "loading") return - if (rawRequestedClients.length === 0) { - setStatus("error") - setError("Invalid or missing client.") - return - } - if (invalidClients.length > 0) { - setStatus("error") - setError(`Unsupported plugin requested: ${invalidClients.join(", ")}.`) - } - }, [invalidClients, rawRequestedClients.length, status]) - - if (isAuthLoading || shouldRedirectToOnboarding) { - return ( -
-
-
- ) - } - - if (status === "loading") { - return ( -
-
-
- -
-

- Connect {displayName} -

-

- {pluginInfo?.description ?? - (requestedClients.length > 1 - ? "Use one Supermemory account across these plugins." - : `Use your Supermemory account with ${displayName}.`)} -

-
- -
    - {(pluginInfo?.features ?? MULTI_PLUGIN_FEATURES).map( - (feature) => ( -
  • - - - {feature} - -
  • - ), - )} -
- - -
-
-
- ) - } - if (status === "error") { + if (requestError) { return (
@@ -413,42 +412,223 @@ function AuthConnectContent() {

Connection failed

- {error} + {requestError}

+ + Go to app + +
+
+
+ ) + } -
- - - Go to app - + if (isAuthLoading || shouldRedirectToOnboarding || status === "loading") { + return ( +
+
+
+ ) + } + + if (status === "selection") { + return ( +
+
+
+
+ +

+ Select an organization +

+

+ Choose which organization to connect {displayName} to. +

+
+
+
measureFades(event.currentTarget)} + ref={listRef} + > + {organizations?.map((organization) => ( + + ))}
+
+
+
+ {error && ( +

+ {error} +

+ )} +
+
+ ) + } + + if (status === "approval" || status === "creating") { + const creating = status === "creating" + return ( +
+
+
+
+ +

+ Connect {displayName} +

+

+ {pluginInfo?.description ?? + (requestedClients.length > 1 + ? "Use one Supermemory account across these plugins." + : `Use your Supermemory account with ${displayName}.`)} +

+
+
    + {(pluginInfo?.features ?? MULTI_PLUGIN_FEATURES).map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+
+
+ + Connecting to + +

+ {selectedOrg?.name ?? "Organization unavailable"} +

+
+ {multiOrg && ( + + )} +
+
+ {error && ( +

+ {error} +

+ )} +
+
@@ -460,7 +640,6 @@ function AuthConnectContent() {

- {status === "creating" && `Connecting ${displayName}…`} {status === "success" && `Success! Redirecting back to ${displayName}…`}

diff --git a/packages/lib/auth-context.tsx b/packages/lib/auth-context.tsx index acd15e88..7234975e 100644 --- a/packages/lib/auth-context.tsx +++ b/packages/lib/auth-context.tsx @@ -75,8 +75,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { const res = await authClient.organization.setActive({ organizationSlug: slug, }) - setOrg(res?.data ?? null) - localStorage.setItem(STORAGE_KEY, slug) + if (res.error || !res.data) { + throw new Error(res.error?.message ?? "Failed to switch organization") + } + try { + localStorage.setItem(STORAGE_KEY, slug) + } catch {} + setOrg(res.data) }, []) const clearActiveOrg = useCallback(async () => {