From 75158df4a6bac90b7d49cd35c991f7b1b3c895af Mon Sep 17 00:00:00 2001 From: Mahesh Sanikommu Date: Tue, 12 May 2026 17:12:21 -0700 Subject: [PATCH] feat(web): /connect page + plugin-aware OAuth consent with workspace picker --- apps/web/app/(app)/connect/page.tsx | 250 ++++++++++++++++++++++++++++ apps/web/app/oauth/consent/page.tsx | 155 +++++++++-------- apps/web/lib/oauth-plugins.ts | 57 +++++++ 3 files changed, 395 insertions(+), 67 deletions(-) create mode 100644 apps/web/app/(app)/connect/page.tsx create mode 100644 apps/web/lib/oauth-plugins.ts diff --git a/apps/web/app/(app)/connect/page.tsx b/apps/web/app/(app)/connect/page.tsx new file mode 100644 index 000000000..ff9692504 --- /dev/null +++ b/apps/web/app/(app)/connect/page.tsx @@ -0,0 +1,250 @@ +"use client" + +import { dmSans125ClassName } from "@/lib/fonts" +import { OAUTH_PLUGINS } from "@/lib/oauth-plugins" +import { cn } from "@lib/utils" +import { Building2, ExternalLink, LoaderIcon, Plug, Trash2 } from "lucide-react" +import Image from "next/image" +import { useCallback, useEffect, useState } from "react" + +const API_URL = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + +interface Connection { + clientId: string + name: string + icon: string | null + isFirstParty: boolean + workspaceId: string | null + workspaceName: string | null + scopes: string[] + connectedAt: string | null + lastUsedAt: string | null +} + +function relativeTime(iso: string | null): string | null { + if (!iso) return null + const then = new Date(iso).getTime() + if (Number.isNaN(then)) return null + const diff = Date.now() - then + const mins = Math.round(diff / 60000) + if (mins < 1) return "just now" + if (mins < 60) return `${mins}m ago` + const hrs = Math.round(mins / 60) + if (hrs < 24) return `${hrs}h ago` + const days = Math.round(hrs / 24) + if (days < 30) return `${days}d ago` + const months = Math.round(days / 30) + if (months < 12) return `${months}mo ago` + return `${Math.round(months / 12)}y ago` +} + +const cardClass = cn( + "rounded-[14px] bg-[#14161A] p-5", + "shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]", +) + +function PluginIcon({ src, alt }: { src: string | null; alt: string }) { + const [failed, setFailed] = useState(false) + if (!src || failed) { + return ( +
+ +
+ ) + } + return ( +
+ {alt} setFailed(true)} + src={src} + width={20} + /> +
+ ) +} + +export default function ConnectPage() { + const [connections, setConnections] = useState(null) + const [error, setError] = useState(null) + const [revoking, setRevoking] = useState(null) + + const load = useCallback(async () => { + try { + const res = await fetch(`${API_URL}/v3/oauth/grants`, { + credentials: "include", + }) + if (!res.ok) throw new Error(`Failed to load connections (${res.status})`) + const data = (await res.json()) as { grants: Connection[] } + setConnections(data.grants) + setError(null) + } catch (err) { + console.error("Failed to load connections:", err) + setError( + err instanceof Error ? err.message : "Failed to load connections", + ) + setConnections([]) + } + }, []) + + useEffect(() => { + load() + }, [load]) + + async function revoke(clientId: string) { + setRevoking(clientId) + try { + const res = await fetch( + `${API_URL}/v3/oauth/grants/${encodeURIComponent(clientId)}`, + { method: "DELETE", credentials: "include" }, + ) + if (!res.ok && res.status !== 204) + throw new Error(`Failed to revoke (${res.status})`) + setConnections((prev) => + prev ? prev.filter((c) => c.clientId !== clientId) : prev, + ) + } catch (err) { + console.error("Failed to revoke connection:", err) + setError(err instanceof Error ? err.message : "Failed to revoke") + } finally { + setRevoking(null) + } + } + + const connectedClientIds = new Set(connections?.map((c) => c.clientId) ?? []) + + return ( +
+

Connections

+

+ Apps and plugins you've connected to your Supermemory account. +

+ +
+

+ Connected apps +

+ + {connections === null ? ( +
+ + Loading… +
+ ) : connections.length === 0 ? ( +
+

No apps connected yet

+

+ Connect a plugin below — anything you authorize will show up here. +

+
+ ) : ( +
+ {connections.map((c) => { + const connectedRel = relativeTime(c.connectedAt) + const usedRel = relativeTime(c.lastUsedAt) + return ( +
+ +
+
+

+ {c.name} +

+ {!c.isFirstParty && ( + + external + + )} +
+
+ {c.workspaceName && ( + + + {c.workspaceName} + + )} + {connectedRel && Connected {connectedRel}} + {usedRel && Last used {usedRel}} +
+
+ +
+ ) + })} +
+ )} + + {error &&

{error}

} +
+ +
+

+ Available plugins +

+
+ {OAUTH_PLUGINS.map((p) => { + const isConnected = + p.oauthClientId != null && connectedClientIds.has(p.oauthClientId) + return ( +
+
+ +

+ {p.name} +

+ {isConnected && ( + + Connected + + )} +
+

+ {p.description} +

+ + Setup guide + + +
+ ) + })} +
+
+
+ ) +} diff --git a/apps/web/app/oauth/consent/page.tsx b/apps/web/app/oauth/consent/page.tsx index d0e5e284e..f25b12047 100644 --- a/apps/web/app/oauth/consent/page.tsx +++ b/apps/web/app/oauth/consent/page.tsx @@ -12,6 +12,14 @@ import { Suspense, useState } from "react" const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" +// Mirror of the OAuth plugins in mono's packages/lib/plugins.ts (oauthClientId → display name). +const OAUTH_PLUGIN_NAMES: Record = { + "supermemory-claude-code": "Claude Code", + "supermemory-opencode": "OpenCode", + "supermemory-openclaw": "OpenClaw", + "supermemory-codex": "OpenAI Codex", +} + // Phase 1 is one coarse grant — every approved client gets all of these. const DATA_CAPABILITIES = [ "Read and search your saved memories", @@ -53,6 +61,8 @@ function OAuthConsentContent() { organizations?.find((o) => o.id === activeOrgId)?.name ?? null const canSwitchOrg = (organizations?.length ?? 0) > 1 const clientId = params.get("client_id") ?? "" + const pluginName = clientId ? (OAUTH_PLUGIN_NAMES[clientId] ?? null) : null + const appLabel = pluginName ?? "An application" const scopes = (params.get("scope") ?? "").split(/\s+/).filter(Boolean) const accountAccess = accountAccessLabels(scopes) // A valid consent page is reached only via /oauth2/authorize, which appends a @@ -60,6 +70,7 @@ function OAuthConsentContent() { const expSeconds = Number(params.get("exp")) const requestExpired = expSeconds > 0 && expSeconds * 1000 < Date.now() const invalidRequest = !params.get("sig") || requestExpired + const busy = submitting !== null || switchingOrgId !== null async function changeOrg(orgId: string) { if (!orgId || orgId === activeOrgId) return @@ -166,6 +177,66 @@ function OAuthConsentContent() { ) } + const workspacePicker = + canSwitchOrg && session?.user ? ( + + + + + {activeOrgName ?? "Select workspace"} + + + + + {organizations?.map((o) => { + const isCurrent = o.id === activeOrgId + const isSwitching = switchingOrgId === o.id + return ( + + ) + })} + + + ) : activeOrgName ? ( + + + {activeOrgName} + + ) : null + return (
@@ -174,68 +245,9 @@ function OAuthConsentContent() {
{session?.user && ( -
-

- {session.user.email} -

- {canSwitchOrg ? ( - - - - {activeOrgName ?? "Select organization"} - - - - - {organizations?.map((o) => { - const isCurrent = o.id === activeOrgId - const isSwitching = switchingOrgId === o.id - return ( - - ) - })} - - - ) : activeOrgName ? ( -

- {activeOrgName} -

- ) : null} -
+

+ {session.user.email} +

)}
@@ -243,10 +255,10 @@ function OAuthConsentContent() {

- Authorize access + {pluginName ? `Connect ${pluginName}` : "Authorize access"}

- An application wants to connect to your Supermemory account. + {appLabel} wants to connect to your Supermemory account.

@@ -281,13 +293,22 @@ function OAuthConsentContent() { )}
+ {workspacePicker && ( +
+ + {pluginName ? `Connect ${pluginName} to` : "Connect to"} + + {workspacePicker} +
+ )} + {error &&

{error}

}
- {clientId && ( + {clientId && !pluginName && (

App ID · {shortClientId(clientId)}

diff --git a/apps/web/lib/oauth-plugins.ts b/apps/web/lib/oauth-plugins.ts new file mode 100644 index 000000000..2cc4e1da0 --- /dev/null +++ b/apps/web/lib/oauth-plugins.ts @@ -0,0 +1,57 @@ +// OAuth-connectable plugins, mirroring the `authMethod: "oauth"` entries in mono's packages/lib/plugins.ts. +// `oauthClientId` is the stable first-party client id (omitted for Cursor — it self-registers via DCR). +export interface OAuthPluginInfo { + id: string + oauthClientId?: string + name: string + description: string + icon: string + docsUrl: string +} + +export const OAUTH_PLUGINS: OAuthPluginInfo[] = [ + { + id: "claude_code", + oauthClientId: "supermemory-claude-code", + name: "Claude Code", + description: + "Persistent memory for Claude Code — recalls your coding context, patterns and decisions across sessions.", + icon: "/images/plugins/claude-code.svg", + docsUrl: "https://supermemory.ai/docs/integrations/claude-code", + }, + { + id: "opencode", + oauthClientId: "supermemory-opencode", + name: "OpenCode", + description: + "Memory layer for OpenCode — semantic search across sessions and automatic context injection.", + icon: "/images/plugins/opencode.svg", + docsUrl: "https://supermemory.ai/docs/integrations/opencode", + }, + { + id: "openclaw", + oauthClientId: "supermemory-openclaw", + name: "OpenClaw", + description: + "Multi-platform memory for OpenClaw — persistence across Telegram, WhatsApp, Discord, Slack and more.", + icon: "/images/plugins/openclaw.svg", + docsUrl: "https://supermemory.ai/docs/integrations/openclaw", + }, + { + id: "codex", + oauthClientId: "supermemory-codex", + name: "OpenAI Codex", + description: + "Persistent memory for the OpenAI Codex CLI — recalls coding context and decisions across projects.", + icon: "/images/plugins/codex.png", + docsUrl: "https://supermemory.ai/docs/integrations/codex", + }, + { + id: "cursor", + name: "Cursor", + description: + "Persistent AI memory for Cursor via the Supermemory MCP server. Connect from Cursor's MCP setup.", + icon: "/images/plugins/cursor.png", + docsUrl: "https://supermemory.ai/docs/supermemory-mcp/setup", + }, +]