supermemory/apps/web/app/oauth/consent/page.tsx
2026-06-16 14:46:07 -07:00

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