From 651e3047351cfe5cc6ce9d4975b145ab1601d700 Mon Sep 17 00:00:00 2001 From: Prasanna721 <106952318+Prasanna721@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:33:09 +0000 Subject: [PATCH] feat(web): MCP OAuth consent page (#1118) Consent + connect UI for the new OAuth 2.1 provider. The API side lives in mono#1812 (stacked on the Enterprise MCP PR). When an MCP client starts OAuth, this is the page where you pick the org and approve access. What's here: - `/oauth/consent`: the consent screen. Pick an organization (cards), then set access: permission (read / read+write) and scope (full, or scoped to specific container-tag spaces with a searchable picker). Approving hands the code back to the client. - `/connect`: plugin-aware entry for known clients (Claude Code, etc.). - `ConsentCard.tsx`: shared card component (org list with fade, dual-icon connecting header, scoped-spaces picker), built to reuse across plugins. - plus a fix to the mcp resource metadata. Pairs with mono#1812 (the API OAuth provider) and the Enterprise MCP PR. Draft until the end-to-end flow is verified. --- apps/web/app/(app)/connect/page.tsx | 250 ++++++++ apps/web/app/(auth)/login/page.tsx | 2 +- apps/web/app/oauth/consent/ConsentCard.tsx | 703 +++++++++++++++++++++ apps/web/app/oauth/consent/page.tsx | 217 +++++++ apps/web/lib/oauth-plugins.ts | 57 ++ 5 files changed, 1228 insertions(+), 1 deletion(-) create mode 100644 apps/web/app/(app)/connect/page.tsx create mode 100644 apps/web/app/oauth/consent/ConsentCard.tsx create mode 100644 apps/web/app/oauth/consent/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 00000000..ff969250 --- /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/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index dbdacdfc..e994d133 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -33,7 +33,7 @@ function buildMcpAuthorizeResumeUrl( const p = new URLSearchParams(sp.toString()) p.delete("redirect") p.delete("error") - return `${backend}/api/auth/mcp/authorize?${p.toString()}` + return `${backend}/api/auth/oauth2/authorize?${p.toString()}` } function LoginHeadline({ className }: { className?: string }) { diff --git a/apps/web/app/oauth/consent/ConsentCard.tsx b/apps/web/app/oauth/consent/ConsentCard.tsx new file mode 100644 index 00000000..4ce7d1d5 --- /dev/null +++ b/apps/web/app/oauth/consent/ConsentCard.tsx @@ -0,0 +1,703 @@ +"use client" + +import { dmSans125ClassName } from "@/lib/fonts" +import { cn } from "@lib/utils" +import { ClaudeDesktopIcon, MCPIcon } from "@ui/assets/icons" +import { Logo, LogoFull } from "@ui/assets/Logo" +import { Popover, PopoverAnchor, PopoverContent } from "@ui/components/popover" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ui/components/select" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@ui/components/tooltip" +import { ArrowLeft, BadgeCheck, Check, LoaderIcon, X } from "lucide-react" +import { AnimatePresence, motion } from "motion/react" +import { + type ComponentType, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react" + +type IconComponent = ComponentType<{ className?: string }> + +// Mirror of the OAuth plugins in mono's packages/lib/plugins.ts. An entry here +// makes a client "verified" — we show its bundled icon + real name. Unknown DCR +// clients still connect; they just render as a generic MCP client. +export const OAUTH_PLUGINS: Record< + string, + { name: string; icon?: IconComponent } +> = { + "supermemory-claude-code": { name: "Claude Code", icon: ClaudeDesktopIcon }, + "supermemory-opencode": { name: "OpenCode" }, + "supermemory-openclaw": { name: "OpenClaw" }, + "supermemory-codex": { name: "OpenAI Codex" }, +} + +const GRADIENT_BG = + "linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)" +const GRADIENT_SHADOW = + "1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)" + +const EXPIRY_OPTIONS = [ + { label: "1 year", value: "365" }, + { label: "6 months", value: "180" }, + { label: "90 days", value: "90" }, + { label: "30 days", value: "30" }, + { label: "7 days", value: "7" }, + { label: "Never", value: "0" }, +] + +export function shortClientId(id: string): string { + return id.length > 12 ? `${id.slice(0, 4)}…${id.slice(-4)}` : id +} + +export type ConsentOrg = { + id: string + name: string +} +export type ConsentPermission = "read" | "write" +export type ConsentScopeType = "full" | "scoped" +export type ConsentScope = { + permission: ConsentPermission + scopeType: ConsentScopeType + tags: string[] + expiresDays: number +} + +function SectionLabel({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +function ConnectingDots() { + return ( +
+ {["a", "b", "c"].map((k, i) => ( + + ))} +
+ ) +} + +function ConnectingHeader({ + clientIcon: ClientIcon, +}: { + clientIcon: IconComponent +}) { + return ( +
+
+ +
+ +
+ +
+
+ ) +} + +function SpacesPicker({ + options, + selected, + setSelected, + loading, + disabled, +}: { + options: string[] + selected: string[] + setSelected: (next: string[]) => void + loading: boolean + disabled?: boolean +}) { + const [query, setQuery] = useState("") + const [open, setOpen] = useState(false) + const fieldRef = useRef(null) + const filtered = useMemo(() => { + const q = query.trim().toLowerCase() + return options + .filter( + (o) => !selected.includes(o) && (!q || o.toLowerCase().includes(q)), + ) + .slice(0, 50) + }, [options, selected, query]) + + const add = (t: string) => { + if (!selected.includes(t)) setSelected([...selected, t]) + setQuery("") + } + const remove = (t: string) => setSelected(selected.filter((x) => x !== t)) + + return ( + + +
+ {selected.length > 0 && ( +
+ {selected.map((t) => ( + + {t} + + + ))} +
+ )} + setQuery(e.target.value)} + onFocus={() => setOpen(true)} + placeholder={loading ? "Loading spaces…" : "Search spaces to add…"} + value={query} + /> +
+
+ { + if (fieldRef.current?.contains(e.target as Node)) e.preventDefault() + }} + onInteractOutside={(e) => { + if (fieldRef.current?.contains(e.target as Node)) e.preventDefault() + }} + onOpenAutoFocus={(e) => e.preventDefault()} + sideOffset={6} + > + {filtered.length === 0 ? ( +

+ {loading + ? "Loading spaces…" + : query.trim() + ? `No spaces match “${query.trim()}”.` + : "No spaces available."} +

+ ) : ( + filtered.map((t) => ( + + )) + )} +
+
+ ) +} + +export function CardShell({ children }: { children: ReactNode }) { + return ( +
+
+
+ {children} +
+
+ ) +} + +export function FullScreenMessage({ + title, + subtitle, +}: { + title: string + subtitle: string +}) { + return ( + +
+ +

+ {title} +

+

{subtitle}

+
+
+ ) +} + +export interface ConsentCardProps { + appLabel: string + verified: boolean + clientId: string + userEmail?: string + orgs: ConsentOrg[] + availableTags: string[] + tagsLoading: boolean + submitting: "approve" | "deny" | null + error: string | null + onEnterOrg: (orgId: string) => Promise + onScopedOpen: () => void + onSubmit: (accept: boolean, scope: ConsentScope) => void + onSignOut: () => void +} + +export function ConsentCard({ + appLabel, + verified, + clientId, + userEmail, + orgs, + availableTags, + tagsLoading, + submitting, + error, + onEnterOrg, + onScopedOpen, + onSubmit, + onSignOut, +}: ConsentCardProps) { + const [step, setStep] = useState<1 | 2>(1) + const [selectedOrgId, setSelectedOrgId] = useState(null) + const [switchingOrgId, setSwitchingOrgId] = useState(null) + const [autoTried, setAutoTried] = useState(false) + const [permission, setPermission] = useState("write") + const [scopeType, setScopeType] = useState("full") + const [tags, setTags] = useState([]) + const [expiresDays, setExpiresDays] = useState("365") + + const multiOrg = orgs.length > 1 + const busy = submitting !== null || switchingOrgId !== null + const selectedOrg = orgs.find((o) => o.id === selectedOrgId) ?? null + const ClientIcon = (clientId && OAUTH_PLUGINS[clientId]?.icon) || MCPIcon + const title = verified ? `Authorize ${appLabel}` : "Authorize MCP" + const connectLabel = verified ? appLabel : "this MCP client" + + const enterOrg = useCallback( + async (orgId: string) => { + if (!orgId) return + setSelectedOrgId(orgId) + setSwitchingOrgId(orgId) + try { + await onEnterOrg(orgId) + } catch { + setSwitchingOrgId(null) + return + } + setSwitchingOrgId(null) + setTags([]) + setStep(2) + }, + [onEnterOrg], + ) + + useEffect(() => { + if (autoTried || step !== 1 || orgs.length !== 1) return + setAutoTried(true) + void enterOrg(orgs[0]?.id ?? "") + }, [orgs, step, autoTried, enterOrg]) + + useEffect(() => { + if (step === 2 && scopeType === "scoped") onScopedOpen() + }, [step, scopeType, onScopedOpen]) + + const listRef = useRef(null) + const [canScrollUp, setCanScrollUp] = useState(false) + const [canScrollDown, setCanScrollDown] = useState(false) + const measureFades = useCallback((el: HTMLDivElement | null) => { + if (!el) return + setCanScrollUp(el.scrollTop > 8) + setCanScrollDown(el.scrollTop + el.clientHeight < el.scrollHeight - 8) + }, []) + useEffect(() => { + if (step !== 1 || orgs.length === 0) { + setCanScrollUp(false) + setCanScrollDown(false) + return + } + measureFades(listRef.current) + }, [orgs, step, measureFades]) + + const submit = (accept: boolean) => + onSubmit(accept, { + permission, + scopeType, + tags, + expiresDays: Number(expiresDays), + }) + + return ( +
+
+
+ + {step === 1 ? ( + +
+ +

+ Select an organization +

+

+ Choose which organization to connect {connectLabel} to. +

+
+ +
+
measureFades(e.currentTarget)} + ref={listRef} + > + {orgs.length === 0 ? ( +

+ No organizations found on your account. +

+ ) : ( + orgs.map((o) => { + const switching = switchingOrgId === o.id + return ( + + ) + }) + )} +
+
+
+
+ + {error && ( +

+ {error} +

+ )} + +
+ {userEmail && ( +

+ Signed in as {userEmail} +

+ )} + +
+ + ) : ( + +
+ +
+

+ {title} +

+ {verified && ( + + + + + + + + Verified app + + + )} +
+

+ {verified ? appLabel : "An MCP client"} wants to connect to + your supermemory. +

+
+ +
+
+
+
+ Connecting to +

+ {selectedOrg?.name ?? "Workspace"} +

+
+ {multiOrg && ( + + )} +
+
+ +
+
+ Permission + +
+ +
+ Access + + + {scopeType === "scoped" && ( + +
+ +

+ {tags.length > 0 + ? `${tags.length} space${tags.length === 1 ? "" : "s"} selected` + : "Type to search and add spaces."} +

+
+
+ )} +
+
+ +
+ Expires + +
+ + {error &&

{error}

} +
+
+ +
+
+ + +
+ + {clientId && !verified && ( +

+ App ID · {shortClientId(clientId)} +

+ )} + + )} + +
+
+ ) +} + +function Choice({ + options, + value, + onChange, + disabled, +}: { + options: { value: T; label: string }[] + value: T + onChange: (v: T) => void + disabled?: boolean +}) { + return ( +
+ {options.map((o) => { + const active = o.value === value + return ( + + ) + })} +
+ ) +} diff --git a/apps/web/app/oauth/consent/page.tsx b/apps/web/app/oauth/consent/page.tsx new file mode 100644 index 00000000..209a69f4 --- /dev/null +++ b/apps/web/app/oauth/consent/page.tsx @@ -0,0 +1,217 @@ +"use client" + +import { authClient, useSession } from "@lib/auth" +import { useSearchParams } from "next/navigation" +import { Suspense, useCallback, useMemo, useState } from "react" +import { + CardShell, + ConsentCard, + type ConsentScope, + FullScreenMessage, + OAUTH_PLUGINS, +} from "./ConsentCard" + +const API_URL = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + +function OAuthConsentContent() { + const params = useSearchParams() + const { data: session } = useSession() + const { data: organizations } = authClient.useListOrganizations() + + const [submitting, setSubmitting] = useState<"approve" | "deny" | null>(null) + const [done, setDone] = useState<"approved" | "denied" | null>(null) + const [error, setError] = useState(null) + const [availableTags, setAvailableTags] = useState([]) + const [tagsLoading, setTagsLoading] = useState(false) + + const orgs = useMemo( + () => (organizations ?? []).map((o) => ({ id: o.id, name: o.name })), + [organizations], + ) + const activeOrgId = session?.session.activeOrganizationId ?? null + const clientId = params.get("client_id") ?? "" + const plugin = clientId ? (OAUTH_PLUGINS[clientId] ?? null) : null + const appLabel = plugin?.name ?? "An application" + + // A valid consent page is reached only via /oauth2/authorize, which appends a + // signed (`sig`) + short-lived (`exp`) query. Without that it can't succeed. + const expSeconds = Number(params.get("exp")) + const requestExpired = expSeconds > 0 && expSeconds * 1000 < Date.now() + const invalidRequest = !params.get("sig") || requestExpired + + const onEnterOrg = useCallback( + async (orgId: string) => { + setError(null) + if (orgId !== activeOrgId) { + try { + await authClient.organization.setActive({ organizationId: orgId }) + } catch (err) { + setError("Couldn't switch to that organization. Try again.") + throw err + } + } + setAvailableTags([]) + }, + [activeOrgId], + ) + + const onScopedOpen = useCallback(() => { + if (tagsLoading || availableTags.length > 0) return + setTagsLoading(true) + fetch(`${API_URL}/v3/container-tags/list`, { credentials: "include" }) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + const list = (d?.containerTags ?? d?.tags ?? d ?? []) as unknown[] + const names = (Array.isArray(list) ? list : []) + .map((t) => + typeof t === "string" + ? t + : ((t as { containerTag?: string; tag?: string })?.containerTag ?? + (t as { tag?: string })?.tag ?? + null), + ) + .filter((t): t is string => typeof t === "string" && t.length > 0) + setAvailableTags(Array.from(new Set(names))) + }) + .catch(() => {}) + .finally(() => setTagsLoading(false)) + }, [tagsLoading, availableTags.length]) + + const onSubmit = useCallback( + async (accept: boolean, scope: ConsentScope) => { + // Send the raw, unmodified query string — better-auth re-verifies its HMAC, + // so it must be byte-for-byte what we were redirected with. + const oauthQuery = window.location.search.replace(/^\?/, "") + if (!oauthQuery) { + setError( + "Missing authorization request. Start the flow again from your app.", + ) + return + } + setSubmitting(accept ? "approve" : "deny") + setError(null) + try { + if (accept && clientId) { + await fetch(`${API_URL}/v3/mcp/connect-scope`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientId, + permission: scope.permission, + containerTags: scope.scopeType === "scoped" ? scope.tags : [], + expiresDays: scope.expiresDays, + }), + }).catch(() => {}) + } + const res = await fetch(`${API_URL}/api/auth/oauth2/consent`, { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ accept, oauth_query: oauthQuery }), + }) + const data = (await res.json().catch(() => ({}))) as { + url?: string + redirectURI?: string + redirect_uri?: string + message?: string + error?: string + error_description?: string + } + if (!res.ok) { + if ( + data.error === "invalid_signature" || + data.error === "invalid_request" + ) { + throw new Error( + "This authorization request has expired. Start the connection again from your app.", + ) + } + throw new Error( + data.error_description || + data.message || + data.error || + "Authorization failed.", + ) + } + // Show the final state regardless: many clients use a loopback or custom + // scheme redirect_uri that hands off without replacing this tab. + setDone(accept ? "approved" : "denied") + const redirectUrl = data.url ?? data.redirectURI ?? data.redirect_uri + if (redirectUrl) window.location.href = redirectUrl + } catch (err) { + console.error("OAuth consent failed:", err) + setError(err instanceof Error ? err.message : "Authorization failed.") + setSubmitting(null) + } + }, + [clientId], + ) + + const onSignOut = useCallback(async () => { + try { + await authClient.signOut() + } catch {} + window.location.href = "/login" + }, []) + + if (invalidRequest && !done) { + return ( + + ) + } + + if (done) { + return ( + + ) + } + + return ( + + ) +} + +export default function OAuthConsentPage() { + return ( + +
+
+
+ + } + > + + + ) +} diff --git a/apps/web/lib/oauth-plugins.ts b/apps/web/lib/oauth-plugins.ts new file mode 100644 index 00000000..2cc4e1da --- /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", + }, +]