mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-21 06:23:59 +00:00
253 lines
7.6 KiB
TypeScript
253 lines
7.6 KiB
TypeScript
"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) {
|
|
const scopeRes = await fetch(`${API_URL}/v3/mcp/connect-scope`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
clientId,
|
|
permission: scope.permission,
|
|
containerTags: scope.scopeType === "scoped" ? scope.tags : [],
|
|
expiresDays: scope.expiresDays,
|
|
}),
|
|
})
|
|
if (!scopeRes.ok) {
|
|
const scopeData = (await scopeRes.json().catch(() => ({}))) as {
|
|
error?: string
|
|
message?: string
|
|
}
|
|
throw new Error(
|
|
scopeData.message ||
|
|
scopeData.error ||
|
|
"Could not save MCP access settings. Start the connection again.",
|
|
)
|
|
}
|
|
}
|
|
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.",
|
|
)
|
|
}
|
|
// Many clients use a loopback or custom-scheme redirect URI that hands
|
|
// off without replacing this tab, but the server still has to provide it.
|
|
const redirectUrl = data.url ?? data.redirectURI ?? data.redirect_uri
|
|
if (!redirectUrl) {
|
|
throw new Error(
|
|
"Authorization completed but no redirect URL was returned. Start the connection again from your app.",
|
|
)
|
|
}
|
|
const targetUrl = new URL(redirectUrl, window.location.href)
|
|
const isSignedInteractionRedirect =
|
|
targetUrl.origin === window.location.origin &&
|
|
(targetUrl.pathname === "/login" ||
|
|
targetUrl.pathname === "/oauth/consent") &&
|
|
targetUrl.searchParams.has("sig") &&
|
|
targetUrl.searchParams.has("exp")
|
|
console.log("isSignedInteractionRedirect", isSignedInteractionRedirect)
|
|
console.log("targetUrl", targetUrl)
|
|
console.log("accept", accept)
|
|
console.log("window.location.origin", window.location.origin)
|
|
console.log("pathname", targetUrl.pathname)
|
|
if (accept && isSignedInteractionRedirect) {
|
|
throw new Error(
|
|
"Authorization could not finish because your session changed. Start the connection again from your app.",
|
|
)
|
|
}
|
|
setDone(accept ? "approved" : "denied")
|
|
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>
|
|
)
|
|
}
|