mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
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:
parent
925be7c6c5
commit
651e304735
5 changed files with 1228 additions and 1 deletions
250
apps/web/app/(app)/connect/page.tsx
Normal file
250
apps/web/app/(app)/connect/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }) {
|
||||
|
|
|
|||
703
apps/web/app/oauth/consent/ConsentCard.tsx
Normal file
703
apps/web/app/oauth/consent/ConsentCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
217
apps/web/app/oauth/consent/page.tsx
Normal file
217
apps/web/app/oauth/consent/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
apps/web/lib/oauth-plugins.ts
Normal file
57
apps/web/lib/oauth-plugins.ts
Normal 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",
|
||||
},
|
||||
]
|
||||
Loading…
Add table
Reference in a new issue