diff --git a/apps/web/app/auth/connect/page.tsx b/apps/web/app/auth/connect/page.tsx index febd27600..c3fd18ee5 100644 --- a/apps/web/app/auth/connect/page.tsx +++ b/apps/web/app/auth/connect/page.tsx @@ -1,13 +1,21 @@ "use client" import { useAuth } from "@lib/auth-context" -import { useSession } from "@lib/auth" +import { authClient, 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,14 +193,19 @@ function AuthConnectContent() { const params = useSearchParams() const router = useRouter() const { data: session, isPending } = useSession() - const { org, organizations, isRestoring } = useAuth() + const { organizations, isRestoring } = useAuth() const [status, setStatus] = useState("loading") const [error, setError] = useState(null) + const [selectedOrgId, setSelectedOrgId] = useState(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") const clientsParam = params.get("clients") const hasClientList = params.has("clients") + const isSwitchMode = params.get("mode") === "switch_organization" const rawRequestedClients = useMemo( () => (clientsParam !== null ? clientsParam.split(",") : client ? [client] : []) @@ -214,10 +227,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 +250,14 @@ function AuthConnectContent() { organizations.length === 0 useEffect(() => { + if (requestError || isPending || isRestoring || session) return + router.replace( + `/login?redirect=${encodeURIComponent(window.location.href)}`, + ) + }, [isPending, isRestoring, requestError, router, session]) + + useEffect(() => { + if (requestError) return if (isPending || isRestoring) return if (!session) return if (organizations === null) return // orgs query still pending @@ -236,219 +269,407 @@ 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]) - async function handleConnect() { - if (!callback) { - setStatus("error") - setError("Missing callback parameter.") - return - } - if (!isValidLocalhostCallback(callback)) { - setStatus("error") - setError("Invalid callback URL.") + useEffect(() => { + if (requestError || isPending || isRestoring || organizations === null) return - } - if (invalidClients.length > 0) { - setStatus("error") - setError(`Unsupported plugin requested: ${invalidClients.join(", ")}.`) + if (!session || organizations.length === 0 || status !== "loading") return + setStatus("selection") + }, [requestError, isPending, isRestoring, organizations, session, status]) + + useEffect(() => { + if (status !== "approval" || !selectedOrgId || organizations === null) return - } - if (requestedClients.length === 0) { - setStatus("error") - setError("Invalid or missing client.") + if ( + organizations.some((organization) => organization.id === selectedOrgId) + ) { return } - if (!session || !org) { - setStatus("error") + 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]) + + const handleSignOut = useCallback(async () => { + await authClient.signOut().catch(() => undefined) + router.replace( + `/login?redirect=${encodeURIComponent(window.location.href)}`, + ) + }, [router]) + + async function handleConnect(organization = selectedOrg): Promise { + if (requestError || !callback) return + if (!session || !organization) { 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] ?? "") - - const res = await fetch(`${API_URL}/v3/auth/key?${fetchParams}`, { - credentials: "include", - }) - - if (!res.ok) { - const errorData = (await res.json().catch(() => ({}))) as { - message?: string + const keyResults = await Promise.allSettled( + requestedClients.map(async (requestedClient) => { + const fetchParams = new URLSearchParams({ + callback, + client: requestedClient, + orgId: organization.id, + }) + const res = await fetch(`${API_URL}/v3/auth/key?${fetchParams}`, { + credentials: "include", + }) + + if (!res.ok) { + const errorData = (await res.json().catch(() => ({}))) as { + message?: string + } + throw new Error(errorData.message || "Failed to get API key") + } + + const data = (await res.json()) as { + key: string + organization?: { id: string } + } + const expectedKeyPrefix = `sm_${organization.id}_` + if ( + (data.organization && data.organization.id !== organization.id) || + !data.key.startsWith(expectedKeyPrefix) + ) { + throw new Error( + "The server did not create a key for the selected organization. Try again shortly.", + ) + } + + return [requestedClient, data.key] as const + }), + ) + const keys: Record = {} + const errors: Record = {} + for (const [index, result] of keyResults.entries()) { + const requestedClient = requestedClients[index] + if (!requestedClient) continue + if (result.status === "fulfilled") { + keys[result.value[0]] = result.value[1] + } else { + errors[requestedClient] = + result.reason instanceof Error + ? result.reason.message + : "Failed to get API key" } - throw new Error(errorData.message || "Failed to get API key") } - const data = (await res.json()) as { key: string } + if (!hasClientList && Object.keys(errors).length > 0) { + throw new Error(errors[requestedClients[0] ?? ""]) + } + if (Object.keys(keys).length === 0) { + throw new Error( + Object.values(errors)[0] ?? "Failed to get plugin API keys", + ) + } setStatus("success") const redirectUrl = new URL(callback) if (hasClientList) { + redirectUrl.searchParams.set("keys", encodeBase64UrlJson(keys)) + if (Object.keys(errors).length > 0) { + redirectUrl.searchParams.set("errors", encodeBase64UrlJson(errors)) + } + } else { redirectUrl.searchParams.set( - "keys", - encodeBase64UrlJson( - Object.fromEntries( - requestedClients.map((requestedClient) => [ - requestedClient, - data.key, - ]), - ), - ), + "apikey", + keys[requestedClients[0] ?? ""] ?? "", ) - } else { - redirectUrl.searchParams.set("apikey", data.key) } redirectUrl.searchParams.set("api_url", API_URL) 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(isSwitchMode ? "selection" : "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.") + function selectOrganization( + organization: NonNullable[number], + ): void { + setError(null) + setSelectedOrgId(organization.id) + if (isSwitchMode) { + void handleConnect(organization) return } - if (invalidClients.length > 0) { - setStatus("error") - setError(`Unsupported plugin requested: ${invalidClients.join(", ")}.`) - } - }, [invalidClients, rawRequestedClients.length, status]) - - if (isAuthLoading || shouldRedirectToOnboarding) { - return ( -
-
-
- ) + setStatus("approval") } - if (status === "loading") { + const isAuthLoading = isPending || isRestoring || organizations === null + + if (requestError) { return (
-
- -
+
+ +

- Connect {displayName} + Connection failed

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

- -
+
+
+ ) + } - + ))} +
+
+
+
+ {error && ( +

+ {error} +

+ )} +
+ {session?.user.email && ( +

+ Signed in as {session.user.email} +

+ )} +
) } - if (status === "error") { + + if (isSwitchMode && status === "creating") { return ( -
-
-
- -
-

- Connection failed -

-

- {error} +

+
+
+

+ Switching organization… +

+
+
+ ) + } + + 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 && ( - - Go to app - -
+ )} +
+
+ {error && ( +

+ {error} +

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

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