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.
This commit is contained in:
Prasanna721 2026-06-16 17:33:09 +00:00
parent 925be7c6c5
commit 651e304735
5 changed files with 1228 additions and 1 deletions

View file

@ -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 (
<div className="flex size-9 shrink-0 items-center justify-center rounded-[8px] border border-[#1E293B] bg-[#080B0F]">
<Plug className="size-4 text-[#737373]" />
</div>
)
}
return (
<div className="flex size-9 shrink-0 items-center justify-center rounded-[8px] border border-[#1E293B] bg-[#080B0F]">
<Image
alt={alt}
className="size-5"
height={20}
onError={() => setFailed(true)}
src={src}
width={20}
/>
</div>
)
}
export default function ConnectPage() {
const [connections, setConnections] = useState<Connection[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [revoking, setRevoking] = useState<string | null>(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 (
<div
className={cn(
"mx-auto w-full max-w-[760px] px-4 py-10",
dmSans125ClassName(),
)}
>
<h1 className="font-semibold text-[22px] text-[#FAFAFA]">Connections</h1>
<p className="mt-1 text-[14px] text-[#737373]">
Apps and plugins you've connected to your Supermemory account.
</p>
<section className="mt-8">
<h2 className="mb-3 text-[13px] text-[#737373] uppercase tracking-[0.06em]">
Connected apps
</h2>
{connections === null ? (
<div className={cn(cardClass, "flex items-center gap-3")}>
<LoaderIcon className="size-4 animate-spin text-[#4BA0FA]" />
<span className="text-[14px] text-[#737373]">Loading</span>
</div>
) : connections.length === 0 ? (
<div className={cn(cardClass, "text-center")}>
<p className="text-[14px] text-[#FAFAFA]">No apps connected yet</p>
<p className="mt-1 text-[13px] text-[#737373]">
Connect a plugin below anything you authorize will show up here.
</p>
</div>
) : (
<div className="flex flex-col gap-2">
{connections.map((c) => {
const connectedRel = relativeTime(c.connectedAt)
const usedRel = relativeTime(c.lastUsedAt)
return (
<div
key={c.clientId}
className={cn(cardClass, "flex items-center gap-4")}
>
<PluginIcon src={c.icon} alt={c.name} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-[15px] text-[#FAFAFA]">
{c.name}
</p>
{!c.isFirstParty && (
<span className="rounded-full border border-[#1E293B] px-1.5 py-0.5 text-[10px] text-[#737373] uppercase tracking-[0.04em]">
external
</span>
)}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[12px] text-[#737373]">
{c.workspaceName && (
<span className="flex items-center gap-1">
<Building2 className="size-3" />
{c.workspaceName}
</span>
)}
{connectedRel && <span>Connected {connectedRel}</span>}
{usedRel && <span>Last used {usedRel}</span>}
</div>
</div>
<button
type="button"
onClick={() => revoke(c.clientId)}
disabled={revoking === c.clientId}
className={cn(
"flex items-center gap-1.5 rounded-[8px] border border-[#1E293B] bg-[#0D121A] px-3 py-1.5",
"text-[13px] text-[#FAFAFA] transition-colors hover:bg-[#1E293B]",
"cursor-pointer disabled:cursor-not-allowed disabled:opacity-60",
)}
>
{revoking === c.clientId ? (
<LoaderIcon className="size-3.5 animate-spin" />
) : (
<Trash2 className="size-3.5" />
)}
Revoke
</button>
</div>
)
})}
</div>
)}
{error && <p className="mt-2 text-[13px] text-red-400">{error}</p>}
</section>
<section className="mt-10">
<h2 className="mb-3 text-[13px] text-[#737373] uppercase tracking-[0.06em]">
Available plugins
</h2>
<div className="grid gap-2 sm:grid-cols-2">
{OAUTH_PLUGINS.map((p) => {
const isConnected =
p.oauthClientId != null && connectedClientIds.has(p.oauthClientId)
return (
<div
key={p.id}
className={cn(cardClass, "flex flex-col gap-3 p-4")}
>
<div className="flex items-center gap-3">
<PluginIcon src={p.icon} alt={p.name} />
<p className="flex-1 truncate text-[15px] text-[#FAFAFA]">
{p.name}
</p>
{isConnected && (
<span className="rounded-full border border-[#1f4d44] bg-[#0c1c19] px-2 py-0.5 text-[10px] text-[#7fd9c4] uppercase tracking-[0.04em]">
Connected
</span>
)}
</div>
<p className="text-[13px] text-[#8B8B8B] leading-snug">
{p.description}
</p>
<a
href={p.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex w-fit items-center gap-1.5 text-[13px] text-[#4BA0FA] transition-opacity hover:opacity-80"
>
Setup guide
<ExternalLink className="size-3.5" />
</a>
</div>
)
})}
</div>
</section>
</div>
)
}

View file

@ -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 }) {

View file

@ -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 (
<span className="text-[11px] font-medium uppercase tracking-[0.08em] text-[#737373]">
{children}
</span>
)
}
function ConnectingDots() {
return (
<div className="flex items-center gap-1">
{["a", "b", "c"].map((k, i) => (
<span
className="size-1.5 animate-pulse rounded-full bg-[#525660]"
key={k}
style={{
animationDelay: `${i * 220}ms`,
animationDuration: "1100ms",
}}
/>
))}
</div>
)
}
function ConnectingHeader({
clientIcon: ClientIcon,
}: {
clientIcon: IconComponent
}) {
return (
<div className="flex items-center justify-center gap-3">
<div className="flex size-12 items-center justify-center rounded-[13px] bg-[#0B0D11] text-[#FAFAFA] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.07)]">
<Logo className="h-6 w-auto text-white" />
</div>
<ConnectingDots />
<div className="flex size-12 items-center justify-center rounded-[13px] bg-[#0B0D11] text-[#FAFAFA] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.07)]">
<ClientIcon className="size-6" />
</div>
</div>
)
}
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<HTMLDivElement>(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 (
<Popover onOpenChange={setOpen} open={open && !disabled}>
<PopoverAnchor asChild>
<div
className="rounded-[12px] border border-white/[0.06] bg-[#0B0D11] p-2.5"
ref={fieldRef}
>
{selected.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{selected.map((t) => (
<span
className="inline-flex items-center gap-1.5 rounded-[7px] bg-[#1B1E25] py-1 pr-1.5 pl-2.5 text-[12.5px] text-[#FAFAFA]"
key={t}
>
{t}
<button
className="text-[#9AA0A6] transition-colors hover:text-[#FAFAFA]"
disabled={disabled}
onClick={() => remove(t)}
type="button"
>
<X className="size-3" />
</button>
</span>
))}
</div>
)}
<input
className="w-full bg-transparent px-1 py-1 text-[14px] text-[#FAFAFA] placeholder:text-[#5C5C5C] focus:outline-none"
disabled={disabled}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setOpen(true)}
placeholder={loading ? "Loading spaces…" : "Search spaces to add…"}
value={query}
/>
</div>
</PopoverAnchor>
<PopoverContent
align="start"
className="max-h-[210px] w-[var(--radix-popover-trigger-width)] overflow-y-auto rounded-[10px] border-white/10 bg-[#14161A] p-1 shadow-[0px_8px_28px_rgba(0,0,0,0.5)] [scrollbar-width:thin]"
onFocusOutside={(e) => {
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 ? (
<p className="px-2 py-2 text-[12.5px] text-[#737373]">
{loading
? "Loading spaces…"
: query.trim()
? `No spaces match “${query.trim()}”.`
: "No spaces available."}
</p>
) : (
filtered.map((t) => (
<button
className="flex w-full items-center rounded-[7px] px-2 py-1.5 text-left text-[13px] text-[#E8E8E8] transition-colors hover:bg-white/[0.05]"
key={t}
onClick={() => add(t)}
onMouseDown={(e) => e.preventDefault()}
type="button"
>
{t}
</button>
))
)}
</PopoverContent>
</Popover>
)
}
export function CardShell({ children }: { children: ReactNode }) {
return (
<div className="relative flex min-h-screen items-center justify-center bg-[#08090C] p-4">
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(60% 50% at 50% 0%, rgba(75,160,250,0.06), transparent 70%)",
}}
/>
<div
className={cn(
"relative flex max-h-[92vh] w-full max-w-[460px] flex-col overflow-hidden rounded-[14px] bg-[#14161A] shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
dmSans125ClassName(),
)}
>
{children}
</div>
</div>
)
}
export function FullScreenMessage({
title,
subtitle,
}: {
title: string
subtitle: string
}) {
return (
<CardShell>
<div className="p-6 text-center">
<LogoFull className="mx-auto h-5 w-auto text-[#FAFAFA]" />
<h2 className="mt-5 text-[18px] font-semibold text-[#FAFAFA]">
{title}
</h2>
<p className="mt-1.5 text-[13px] text-[#737373]">{subtitle}</p>
</div>
</CardShell>
)
}
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<void>
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<string | null>(null)
const [switchingOrgId, setSwitchingOrgId] = useState<string | null>(null)
const [autoTried, setAutoTried] = useState(false)
const [permission, setPermission] = useState<ConsentPermission>("write")
const [scopeType, setScopeType] = useState<ConsentScopeType>("full")
const [tags, setTags] = useState<string[]>([])
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<HTMLDivElement>(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 (
<div className="relative flex min-h-screen items-center justify-center bg-[#08090C] p-4">
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(60% 50% at 50% 0%, rgba(75,160,250,0.05), transparent 70%)",
}}
/>
<div
className={cn(
"relative flex w-full max-w-[440px] flex-col",
dmSans125ClassName(),
)}
>
<AnimatePresence mode="wait">
{step === 1 ? (
<motion.div
animate={{ opacity: 1 }}
className="flex flex-col"
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
key="step1"
transition={{ duration: 0.18 }}
>
<div className="pt-2 pb-6 text-center">
<Logo className="mx-auto h-8 w-auto text-white" />
<h1 className="mt-6 text-[20px] font-semibold tracking-[-0.2px] text-[#FAFAFA]">
Select an organization
</h1>
<p className="mt-3 text-[13px] text-[#737373]">
Choose which organization to connect {connectLabel} to.
</p>
</div>
<div className="relative">
<div
className="flex max-h-[360px] flex-col gap-2 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
onScroll={(e) => measureFades(e.currentTarget)}
ref={listRef}
>
{orgs.length === 0 ? (
<p className="py-6 text-center text-[13px] text-[#737373]">
No organizations found on your account.
</p>
) : (
orgs.map((o) => {
const switching = switchingOrgId === o.id
return (
<button
className={cn(
"flex w-full items-center gap-3 rounded-[12px] bg-[#14161A] px-4 py-3.5 text-left transition-colors",
"hover:bg-[#1B1E25]",
"disabled:cursor-not-allowed disabled:opacity-60",
)}
disabled={busy}
key={o.id}
onClick={() => enterOrg(o.id)}
type="button"
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-[9px] bg-white/[0.06] text-[14px] font-semibold text-[#FAFAFA]">
{o.name?.charAt(0)?.toUpperCase() ?? "?"}
</div>
<span className="min-w-0 flex-1 truncate text-[15px] font-medium text-[#FAFAFA]">
{o.name}
</span>
{switching && (
<LoaderIcon className="size-4 shrink-0 animate-spin text-[#9AA0A6]" />
)}
</button>
)
})
)}
</div>
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-0 top-0 h-14 bg-gradient-to-b from-[#08090C] to-transparent transition-opacity duration-300",
canScrollUp ? "opacity-100" : "opacity-0",
)}
/>
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-[#08090C] to-transparent transition-opacity duration-300",
canScrollDown ? "opacity-100" : "opacity-0",
)}
/>
</div>
{error && (
<p className="pt-3 text-center text-[13px] text-red-400">
{error}
</p>
)}
<div className="flex flex-col items-center gap-1 pt-6">
{userEmail && (
<p className="text-[12px] text-[#737373]">
Signed in as {userEmail}
</p>
)}
<button
className="text-[12px] text-[#9AA0A6] transition-colors hover:text-[#FAFAFA]"
onClick={onSignOut}
type="button"
>
Sign out
</button>
</div>
</motion.div>
) : (
<motion.div
animate={{ opacity: 1 }}
className="flex max-h-[92vh] min-h-0 flex-col overflow-hidden rounded-[14px] bg-[#14161A] shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
key="step2"
transition={{ duration: 0.18 }}
>
<div className="px-6 pt-7 pb-5 text-center">
<ConnectingHeader clientIcon={ClientIcon} />
<div className="mt-5 flex items-center justify-center gap-1.5">
<h1 className="text-[19px] font-semibold tracking-[-0.2px] text-[#FAFAFA]">
{title}
</h1>
{verified && (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default text-[#FAFAFA]">
<BadgeCheck className="size-[18px]" />
</span>
</TooltipTrigger>
<TooltipContent>Verified app</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<p className="mt-1 text-[13px] text-[#737373]">
{verified ? appLabel : "An MCP client"} wants to connect to
your supermemory.
</p>
</div>
<div className="min-h-0 overflow-y-auto">
<div className="mx-6 h-px bg-white/[0.06]" />
<div className="flex items-center justify-between gap-3 px-6 py-3.5">
<div className="min-w-0">
<SectionLabel>Connecting to</SectionLabel>
<p className="truncate text-[14px] font-medium text-[#FAFAFA]">
{selectedOrg?.name ?? "Workspace"}
</p>
</div>
{multiOrg && (
<button
className="flex shrink-0 items-center gap-1 rounded-[7px] px-2 py-1.5 text-[12px] text-[#9AA0A6] transition-colors hover:bg-white/[0.04] hover:text-[#FAFAFA] disabled:opacity-50"
disabled={busy}
onClick={() => setStep(1)}
type="button"
>
<ArrowLeft className="size-3.5" />
Change
</button>
)}
</div>
<div className="mx-6 h-px bg-white/[0.06]" />
<div className="flex flex-col gap-4 px-6 py-5">
<div className="flex flex-col gap-2">
<SectionLabel>Permission</SectionLabel>
<Choice
disabled={busy}
onChange={setPermission}
options={[
{ value: "write", label: "Read + Write" },
{ value: "read", label: "Read only" },
]}
value={permission}
/>
</div>
<div className="flex flex-col gap-2">
<SectionLabel>Access</SectionLabel>
<Choice
disabled={busy}
onChange={setScopeType}
options={[
{ value: "full", label: "Full access" },
{ value: "scoped", label: "Scoped" },
]}
value={scopeType}
/>
<AnimatePresence initial={false}>
{scopeType === "scoped" && (
<motion.div
animate={{ opacity: 1, height: "auto" }}
className="overflow-hidden"
exit={{ opacity: 0, height: 0 }}
initial={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
>
<div className="mt-1.5">
<SpacesPicker
disabled={busy}
loading={tagsLoading}
options={availableTags}
selected={tags}
setSelected={setTags}
/>
<p className="mt-2 text-[12px] text-[#5C6470]">
{tags.length > 0
? `${tags.length} space${tags.length === 1 ? "" : "s"} selected`
: "Type to search and add spaces."}
</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="flex flex-col gap-2">
<SectionLabel>Expires</SectionLabel>
<Select
disabled={busy}
onValueChange={setExpiresDays}
value={expiresDays}
>
<SelectTrigger className="h-10 w-full rounded-[10px] border-white/[0.08] bg-[#0B0D11] text-[14px] text-[#FAFAFA]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EXPIRY_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error && <p className="text-[13px] text-red-400">{error}</p>}
</div>
</div>
<div className="mx-6 h-px bg-white/[0.06]" />
<div className="flex items-center justify-between gap-3 px-6 py-4">
<button
className="rounded-[10px] px-3 py-2.5 text-[13px] font-medium text-[#9AA0A6] transition-colors hover:text-[#FAFAFA] disabled:opacity-50"
disabled={busy}
onClick={() => submit(false)}
type="button"
>
{submitting === "deny" ? "Cancelling…" : "Cancel"}
</button>
<button
className={cn(
"relative flex h-11 min-w-[150px] items-center justify-center gap-2 rounded-[10px] px-6",
"text-[14px] font-medium tracking-[-0.14px] text-[#FAFAFA]",
"cursor-pointer transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40",
)}
disabled={
busy || (scopeType === "scoped" && tags.length === 0)
}
onClick={() => submit(true)}
style={{
background: GRADIENT_BG,
boxShadow: GRADIENT_SHADOW,
}}
type="button"
>
{submitting === "approve" ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<>
<Check className="size-4" />
Approve
</>
)}
<div className="pointer-events-none absolute inset-0 rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
</button>
</div>
{clientId && !verified && (
<p className="px-6 pb-4 text-center text-[11px] text-[#5C5C5C]">
App ID · <code>{shortClientId(clientId)}</code>
</p>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)
}
function Choice<T extends string>({
options,
value,
onChange,
disabled,
}: {
options: { value: T; label: string }[]
value: T
onChange: (v: T) => void
disabled?: boolean
}) {
return (
<div className={cn("grid grid-cols-2 gap-2", dmSans125ClassName())}>
{options.map((o) => {
const active = o.value === value
return (
<button
className={cn(
"h-10 rounded-[10px] border px-3 text-[13px] font-medium transition-colors",
"disabled:cursor-not-allowed disabled:opacity-60",
active
? "border-white/15 bg-white/[0.07] text-[#FAFAFA]"
: "cursor-pointer border-white/[0.07] bg-transparent text-[#9AA0A6] hover:border-white/15 hover:text-[#FAFAFA]",
)}
disabled={disabled}
key={o.value}
onClick={() => onChange(o.value)}
type="button"
>
{o.label}
</button>
)
})}
</div>
)
}

View file

@ -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<string | null>(null)
const [availableTags, setAvailableTags] = useState<string[]>([])
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 (
<FullScreenMessage
subtitle="Start the connection again from your app — this page only works as part of that flow."
title={
requestExpired
? "This request has expired"
: "No authorization request"
}
/>
)
}
if (done) {
return (
<FullScreenMessage
subtitle="You can return to your app — it's safe to close this tab."
title={done === "approved" ? "Access authorized" : "Access denied"}
/>
)
}
return (
<ConsentCard
appLabel={appLabel}
availableTags={availableTags}
clientId={clientId}
error={error}
onEnterOrg={onEnterOrg}
onScopedOpen={onScopedOpen}
onSignOut={onSignOut}
onSubmit={onSubmit}
orgs={orgs}
submitting={submitting}
tagsLoading={tagsLoading}
userEmail={session?.user?.email}
verified={!!plugin}
/>
)
}
export default function OAuthConsentPage() {
return (
<Suspense
fallback={
<CardShell>
<div className="flex h-40 items-center justify-center">
<div className="size-6 animate-spin rounded-full border-2 border-[#4BA0FA] border-t-transparent" />
</div>
</CardShell>
}
>
<OAuthConsentContent />
</Suspense>
)
}

View file

@ -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",
},
]