diff --git a/apps/web/app/auth/connect/page.tsx b/apps/web/app/auth/connect/page.tsx index 0f96e52c0..5e50a44c0 100644 --- a/apps/web/app/auth/connect/page.tsx +++ b/apps/web/app/auth/connect/page.tsx @@ -2,13 +2,22 @@ import { useAuth } from "@lib/auth-context" import { useSession } from "@lib/auth" +import { hasActivePlan } from "@lib/queries" import { cn } from "@lib/utils" import { dmSans125ClassName } from "@/lib/fonts" +import { isFreeTierPlugin } from "@/lib/plugin-catalog" import { useCustomer } from "autumn-js/react" -import { ArrowRight, Loader, XCircle } from "lucide-react" +import { ArrowRight, Check, Loader, XCircle } from "lucide-react" import Image from "next/image" import { useRouter, useSearchParams } from "next/navigation" -import { Suspense, useEffect, useState } from "react" +import { + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react" import { PENDING_CONNECT_URL_KEY } from "@/lib/constants" @@ -107,6 +116,139 @@ function getPluginName(client: string): string { return PLUGIN_INFO[client]?.name ?? "External Tool" } +function formatPluginNames(clients: string[]): string { + const names = clients.map((id) => getPluginName(id)) + if (names.length === 0) return "External Tool" + if (names.length === 1) return names[0] ?? "External Tool" + if (names.length === 2) { + return `${names[0] ?? "External Tool"} and ${names[1] ?? "External Tool"}` + } + + return `${names.slice(0, -1).join(", ")}, and ${names.at(-1) ?? "External Tool"}` +} + +function encodeBase64UrlJson(value: Record): string { + return btoa(JSON.stringify(value)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, "") +} + +function pluginAccessError(client: string): string { + return `${getPluginName(client)} requires a Pro plan or higher.` +} + +function PluginLogoStack({ clients }: { clients: string[] }) { + if (clients.length === 0) { + return ( +
+ +
+ ) + } + + return ( +
+ {clients.map((id, index) => { + const plugin = PLUGIN_INFO[id] + return ( +
+ {plugin ? ( + {plugin.name} + ) : ( + + )} +
+ ) + })} +
+ ) +} + +function PluginAccessList({ + blockedClients, + eligibleClients, +}: { + blockedClients: string[] + eligibleClients: string[] +}) { + const rows = [ + ...eligibleClients.map((id) => ({ id, state: "eligible" as const })), + ...blockedClients.map((id) => ({ id, state: "blocked" as const })), + ] + + if (rows.length === 0) return null + + return ( +
+

+ Connection summary +

+
+ {rows.map(({ id, state }) => { + const plugin = PLUGIN_INFO[id] + const eligible = state === "eligible" + return ( +
+ {plugin && ( + + )} +
+
+

+ {getPluginName(id)} +

+ {eligible && ( + + + + )} + {!eligible && ( + + PRO + + )} +
+

+ {eligible + ? "Available on your current plan" + : "Upgrade required"} +

+
+
+ ) + })} +
+
+ ) +} type Status = "loading" | "creating" | "success" | "error" | "upgrade" const pageWrapperClass = @@ -125,12 +267,58 @@ function AuthConnectContent() { const [status, setStatus] = useState("loading") const [error, setError] = useState(null) const [isUpgrading, setIsUpgrading] = useState(false) + const hasAutoConnectedAfterUpgrade = useRef(false) const callback = params.get("callback") const client = params.get("client") - const validClient = client && client in PLUGIN_INFO ? client : null - const displayName = validClient ? getPluginName(validClient) : "External Tool" - const pluginInfo = validClient ? PLUGIN_INFO[validClient] : null + const clientsParam = params.get("clients") + const hasClientList = params.has("clients") + const rawRequestedClients = useMemo( + () => + (clientsParam !== null ? clientsParam.split(",") : client ? [client] : []) + .map((value) => value.trim()) + .filter(Boolean), + [client, clientsParam], + ) + const requestedClients = useMemo( + () => + Array.from( + new Set(rawRequestedClients.filter((value) => value in PLUGIN_INFO)), + ), + [rawRequestedClients], + ) + const invalidClients = useMemo( + () => rawRequestedClients.filter((value) => !(value in PLUGIN_INFO)), + [rawRequestedClients], + ) + const validClient = requestedClients[0] ?? null + const displayName = formatPluginNames(requestedClients) + const pluginInfo = + requestedClients.length === 1 && validClient + ? PLUGIN_INFO[validClient] + : null + const hasProProduct = hasActivePlan(autumn.data?.subscriptions, "api_pro") + const eligibleClients = useMemo( + () => + requestedClients.filter( + (requestedClient) => hasProProduct || isFreeTierPlugin(requestedClient), + ), + [hasProProduct, requestedClients], + ) + const blockedClients = useMemo( + () => + requestedClients.filter( + (requestedClient) => !eligibleClients.includes(requestedClient), + ), + [eligibleClients, requestedClients], + ) + const needsPlanStatus = requestedClients.some( + (requestedClient) => !isFreeTierPlugin(requestedClient), + ) + const shouldAutoConnectAfterUpgrade = + params.get("upgrade_complete") === "true" + const eligibleDisplayName = formatPluginNames(eligibleClients) + const blockedDisplayName = formatPluginNames(blockedClients) // Redirect new users (logged in but no organization) to onboarding. // Store the current connect URL so onboarding can redirect back here. @@ -155,7 +343,7 @@ function AuthConnectContent() { router.replace("/onboarding") }, [isPending, isRestoring, session, organizations, router]) - async function handleConnect() { + const handleConnect = useCallback(async () => { if (!callback) { setStatus("error") setError("Missing callback parameter.") @@ -166,6 +354,16 @@ function AuthConnectContent() { 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") setError( @@ -176,21 +374,38 @@ function AuthConnectContent() { try { setStatus("creating") + if (eligibleClients.length === 0) { + setStatus("upgrade") + setError(`Upgrade to Pro to connect ${blockedDisplayName}.`) + return + } + if (shouldAutoConnectAfterUpgrade && blockedClients.length > 0) { + setStatus("upgrade") + setError( + "Your plan update is still processing. Please try again in a moment.", + ) + return + } + const fetchParams = new URLSearchParams({ callback }) - if (validClient) fetchParams.set("client", validClient) + fetchParams.set("client", eligibleClients[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 + } if (res.status === 403) { setStatus("upgrade") + setError( + errorData.message || + `Upgrade to Pro to connect ${eligibleDisplayName}.`, + ) return } - const errorData = (await res.json().catch(() => ({}))) as { - message?: string - } throw new Error(errorData.message || "Failed to get API key") } @@ -198,7 +413,34 @@ function AuthConnectContent() { setStatus("success") const redirectUrl = new URL(callback) - redirectUrl.searchParams.set("apikey", data.key) + if (hasClientList) { + redirectUrl.searchParams.set( + "keys", + encodeBase64UrlJson( + Object.fromEntries( + eligibleClients.map((eligibleClient) => [ + eligibleClient, + data.key, + ]), + ), + ), + ) + if (blockedClients.length > 0) { + redirectUrl.searchParams.set( + "errors", + encodeBase64UrlJson( + Object.fromEntries( + blockedClients.map((blockedClient) => [ + blockedClient, + pluginAccessError(blockedClient), + ]), + ), + ), + ) + } + } else { + redirectUrl.searchParams.set("apikey", data.key) + } redirectUrl.searchParams.set("api_url", API_URL) window.location.href = redirectUrl.toString() } catch (err) { @@ -206,12 +448,26 @@ function AuthConnectContent() { setStatus("error") setError(err instanceof Error ? err.message : "Failed to get API key") } - } + }, [ + blockedClients, + blockedDisplayName, + callback, + eligibleClients, + eligibleDisplayName, + hasClientList, + invalidClients, + org, + requestedClients.length, + session, + shouldAutoConnectAfterUpgrade, + ]) async function handleUpgrade() { try { setIsUpgrading(true) - const safeSuccessUrl = `${window.location.origin}${window.location.pathname}?callback=${encodeURIComponent(callback ?? "")}&client=${encodeURIComponent(validClient ?? "")}` + const successParams = new URLSearchParams(params.toString()) + successParams.set("upgrade_complete", "true") + const safeSuccessUrl = `${window.location.origin}${window.location.pathname}?${successParams.toString()}` await autumn.attach({ planId: "api_pro", successUrl: safeSuccessUrl, @@ -224,7 +480,42 @@ function AuthConnectContent() { // 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 + const isAuthLoading = + isPending || + isRestoring || + organizations === null || + (needsPlanStatus && autumn.isLoading) + useEffect(() => { + if (!shouldAutoConnectAfterUpgrade) return + if (hasAutoConnectedAfterUpgrade.current) return + if (status !== "loading") return + if (isAuthLoading || shouldRedirectToOnboarding || !session || !org) return + + hasAutoConnectedAfterUpgrade.current = true + void handleConnect() + }, [ + shouldAutoConnectAfterUpgrade, + status, + isAuthLoading, + shouldRedirectToOnboarding, + session, + org, + handleConnect, + ]) + + 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 (
@@ -238,19 +529,7 @@ function AuthConnectContent() {
-
- {pluginInfo ? ( - {pluginInfo.name} - ) : ( - - )} -
+

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

- {pluginInfo && ( + {(requestedClients.length > 1 || blockedClients.length > 0) && ( + + )} + + {requestedClients.length <= 1 && + blockedClients.length === 0 && + pluginInfo ? (
    {pluginInfo.features.map((feature) => (
  • @@ -284,28 +574,121 @@ function AuthConnectContent() {
  • ))}
- )} + ) : requestedClients.length <= 1 && blockedClients.length === 0 ? ( +
    +
  • + + + Share one persistent memory layer across selected coding + agents. + +
  • +
  • + + + Recall project context, coding decisions, and prior + sessions. + +
  • +
  • + + + Keep each connected plugin ready without separate auth + steps. + +
  • +
+ ) : null} + +
+ {eligibleClients.length > 0 ? ( + + ) : ( + + )} - )} - style={{ - background: - "linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)", - boxShadow: - "1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)", - }} - > - Approve Connection -
- +
@@ -317,19 +700,11 @@ function AuthConnectContent() {
-
- {pluginInfo ? ( - {pluginInfo.name} - ) : ( - - )} -
+ 0 ? blockedClients : requestedClients + } + />

- {pluginInfo?.description ?? + {error ?? + pluginInfo?.description ?? `A paid plan is required to use ${displayName} with Supermemory.`}