diff --git a/apps/web/app/auth/connect/page.tsx b/apps/web/app/auth/connect/page.tsx index d103e320..7523f695 100644 --- a/apps/web/app/auth/connect/page.tsx +++ b/apps/web/app/auth/connect/page.tsx @@ -7,10 +7,17 @@ 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" @@ -169,6 +176,81 @@ function PluginLogoStack({ clients }: { clients: string[] }) { ) } +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 ( +
+
+ {eligible ? ( + + ) : ( + + PRO + + )} +
+ {plugin && ( + + )} +
+

+ {getPluginName(id)} +

+

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

+
+
+ ) + })} +
+
+ ) +} type Status = "loading" | "creating" | "success" | "error" | "upgrade" const pageWrapperClass = @@ -187,31 +269,56 @@ 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 clients = (params.get("clients") ?? "") - .split(",") - .map((value) => value.trim()) - .filter((value) => value in PLUGIN_INFO) - const requestedClients = clients.length > 0 ? clients : client ? [client] : [] + const clientsParam = params.get("clients") const hasClientList = params.has("clients") - const validClient = client && client in PLUGIN_INFO ? client : null + 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 - ? PLUGIN_INFO[requestedClients[0] ?? ""] + requestedClients.length === 1 && validClient + ? PLUGIN_INFO[validClient] : null const hasProProduct = hasActivePlan(autumn.data?.subscriptions, "api_pro") - const eligibleClients = requestedClients.filter( - (requestedClient) => hasProProduct || isFreeTierPlugin(requestedClient), + const eligibleClients = useMemo( + () => + requestedClients.filter( + (requestedClient) => hasProProduct || isFreeTierPlugin(requestedClient), + ), + [hasProProduct, requestedClients], ) - const blockedClients = requestedClients.filter( - (requestedClient) => !eligibleClients.includes(requestedClient), + 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) @@ -238,7 +345,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.") @@ -249,6 +356,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( @@ -260,37 +377,37 @@ function AuthConnectContent() { try { setStatus("creating") if (eligibleClients.length === 0) { - const redirectUrl = new URL(callback) - redirectUrl.searchParams.set( - "errors", - encodeBase64UrlJson( - Object.fromEntries( - blockedClients.map((blockedClient) => [ - blockedClient, - pluginAccessError(blockedClient), - ]), - ), - ), + 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.", ) - window.location.href = redirectUrl.toString() return } const fetchParams = new URLSearchParams({ callback }) - fetchParams.set("client", eligibleClients[0] ?? validClient ?? "") + fetchParams.set("client", eligibleClients[0] ?? "") const res = await fetch(`${API_URL}/v3/auth/key?${fetchParams}`, { credentials: "include", }) if (!res.ok) { - if (res.status === 403) { - setStatus("upgrade") - return - } 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 + } throw new Error(errorData.message || "Failed to get API key") } @@ -333,12 +450,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}${window.location.search}` + 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, @@ -356,6 +487,37 @@ function AuthConnectContent() { 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 (
@@ -384,21 +546,22 @@ function AuthConnectContent() { )} > {pluginInfo?.description ?? - `Approve one Supermemory OAuth flow for ${displayName}.`} + (requestedClients.length > 1 + ? "Use one Supermemory account across these plugins." + : `Use your Supermemory account with ${displayName}.`)}

- {blockedClients.length > 0 && ( -
-

- {eligibleClients.length > 0 - ? `OAuth will connect ${eligibleDisplayName}. Upgrade to Pro to connect ${blockedDisplayName}.` - : `Upgrade to Pro to connect ${blockedDisplayName}.`} -

-
+ {(requestedClients.length > 1 || blockedClients.length > 0) && ( + )} - {pluginInfo ? ( + {requestedClients.length <= 1 && + blockedClients.length === 0 && + pluginInfo ? (
    {pluginInfo.features.map((feature) => (
  • @@ -413,7 +576,7 @@ function AuthConnectContent() {
  • ))}
- ) : ( + ) : requestedClients.length <= 1 && blockedClients.length === 0 ? (
  • @@ -443,28 +606,91 @@ function AuthConnectContent() {
- )} + ) : null} - + ) : ( + )} - 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 -
- + + {eligibleClients.length > 0 && blockedClients.length > 0 && ( + + )} +
@@ -476,19 +702,11 @@ function AuthConnectContent() {
-
- {pluginInfo ? ( - {pluginInfo.name} - ) : ( - - )} -
+ 0 ? blockedClients : requestedClients + } + />

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