mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-15 04:21:38 +00:00
Add multi-plugin OAuth connect UI
This commit is contained in:
parent
886ee692f9
commit
d247ff69a6
1 changed files with 180 additions and 21 deletions
|
|
@ -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"
|
||||
|
|
@ -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 =
|
||||
|
|
@ -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] : []
|
||||
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.
|
||||
|
|
@ -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",
|
||||
|
|
@ -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) {
|
||||
|
|
@ -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,
|
||||
|
|
@ -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">
|
||||
|
|
@ -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(
|
||||
|
|
@ -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">
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue