Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 180 additions & 21 deletions apps/web/app/auth/connect/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

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 Image from "next/image"
Expand Down Expand Up @@ -107,6 +109,66 @@ 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, string>): 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 (
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
<ArrowRight className="size-5 text-[#4BA0FA]" />
</div>
)
}

return (
<div className="flex items-center justify-center">
{clients.map((id, index) => {
const plugin = PLUGIN_INFO[id]
return (
<div
className="-ml-2 flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F] p-2 first:ml-0"
key={`${id}-${index}`}
style={{ zIndex: clients.length - index }}
title={plugin?.name ?? id}
>
{plugin ? (
<Image
alt={plugin.name}
className="size-6 object-contain"
height={24}
src={plugin.icon}
width={24}
/>
) : (
<ArrowRight className="size-5 text-[#4BA0FA]" />
)}
</div>
)
})}
</div>
)
}

type Status = "loading" | "creating" | "success" | "error" | "upgrade"

const pageWrapperClass =
Expand All @@ -128,9 +190,30 @@ function AuthConnectContent() {

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] : []

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Invalid client IDs bypass validation for Pro users

When using the single client parameter (not clients), raw unvalidated values are included in requestedClients. The clients array properly filters to valid plugin IDs (value in PLUGIN_INFO), but this fallback uses the raw client value.

This causes a problem when a Pro user visits ?client=invalid_id:

  1. requestedClients = ["invalid_id"]
  2. eligibleClients filter passes because hasProProduct || isFreeTierPlugin(...) → Pro users pass everything
  3. API receives client=invalid_id which could cause server errors

The original code avoided this by using validClient (which is null for invalid clients).

Suggested change
const requestedClients = clients.length > 0 ? clients : client ? [client] : []
const requestedClients = clients.length > 0 ? clients : validClient ? [validClient] : []

const hasClientList = params.has("clients")
const validClient = client && client in PLUGIN_INFO ? client : null
const displayName = validClient ? getPluginName(validClient) : "External Tool"
const pluginInfo = validClient ? PLUGIN_INFO[validClient] : null
const displayName = formatPluginNames(requestedClients)
const pluginInfo =
requestedClients.length === 1
? PLUGIN_INFO[requestedClients[0] ?? ""]
: null
const hasProProduct = hasActivePlan(autumn.data?.subscriptions, "api_pro")
const eligibleClients = requestedClients.filter(
(requestedClient) => hasProProduct || isFreeTierPlugin(requestedClient),
)
const blockedClients = requestedClients.filter(
(requestedClient) => !eligibleClients.includes(requestedClient),
)
const needsPlanStatus = requestedClients.some(
(requestedClient) => !isFreeTierPlugin(requestedClient),
)
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.
Expand Down Expand Up @@ -176,8 +259,25 @@ 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),
]),
),
),
)
window.location.href = redirectUrl.toString()
return
}

const fetchParams = new URLSearchParams({ callback })
if (validClient) fetchParams.set("client", validClient)
fetchParams.set("client", eligibleClients[0] ?? validClient ?? "")

const res = await fetch(`${API_URL}/v3/auth/key?${fetchParams}`, {
credentials: "include",
Expand All @@ -198,7 +298,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) {
Expand All @@ -211,7 +338,7 @@ function AuthConnectContent() {
async function handleUpgrade() {
try {
setIsUpgrading(true)
const safeSuccessUrl = `${window.location.origin}${window.location.pathname}?callback=${encodeURIComponent(callback ?? "")}&client=${encodeURIComponent(validClient ?? "")}`
const safeSuccessUrl = `${window.location.origin}${window.location.pathname}${window.location.search}`
await autumn.attach({
planId: "api_pro",
successUrl: safeSuccessUrl,
Expand All @@ -224,7 +351,11 @@ 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)
if (isAuthLoading || shouldRedirectToOnboarding) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
Expand All @@ -238,19 +369,7 @@ function AuthConnectContent() {
<div className={pageWrapperClass}>
<div className={cardClass}>
<div className="flex flex-col items-center gap-5">
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
{pluginInfo ? (
<Image
alt={pluginInfo.name}
className="size-6"
height={24}
src={pluginInfo.icon}
width={24}
/>
) : (
<ArrowRight className="size-5 text-[#4BA0FA]" />
)}
</div>
<PluginLogoStack clients={requestedClients} />
<div className="text-center">
<h2
className={dmSans125ClassName(
Expand All @@ -265,11 +384,21 @@ function AuthConnectContent() {
)}
>
{pluginInfo?.description ??
`Allow ${displayName} to access your Supermemory account.`}
`Approve one Supermemory OAuth flow for ${displayName}.`}
</p>
</div>

{pluginInfo && (
{blockedClients.length > 0 && (
<div className="w-full rounded-[10px] border border-[#1E293B] bg-[#080B0F] p-3">
<p className={dmSans125ClassName("text-[13px] text-[#8B8B8B]")}>
{eligibleClients.length > 0
? `OAuth will connect ${eligibleDisplayName}. Upgrade to Pro to connect ${blockedDisplayName}.`
: `Upgrade to Pro to connect ${blockedDisplayName}.`}
</p>
</div>
)}

{pluginInfo ? (
<ul className="w-full space-y-2.5">
{pluginInfo.features.map((feature) => (
<li key={feature} className="flex items-start gap-2.5">
Expand All @@ -284,6 +413,36 @@ function AuthConnectContent() {
</li>
))}
</ul>
) : (
<ul className="w-full space-y-2.5">
<li className="flex items-start gap-2.5">
<ArrowRight className="mt-0.5 size-3.5 shrink-0 text-[#4BA0FA]" />
<span
className={dmSans125ClassName("text-[13px] text-[#8B8B8B]")}
>
Share one persistent memory layer across selected coding
agents.
</span>
</li>
<li className="flex items-start gap-2.5">
<ArrowRight className="mt-0.5 size-3.5 shrink-0 text-[#4BA0FA]" />
<span
className={dmSans125ClassName("text-[13px] text-[#8B8B8B]")}
>
Recall project context, coding decisions, and prior
sessions.
</span>
</li>
<li className="flex items-start gap-2.5">
<ArrowRight className="mt-0.5 size-3.5 shrink-0 text-[#4BA0FA]" />
<span
className={dmSans125ClassName("text-[13px] text-[#8B8B8B]")}
>
Keep each connected plugin ready without separate auth
steps.
</span>
</li>
</ul>
)}

<button
Expand Down
Loading