"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) { 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 ( ) } if (done) { return ( ) } return ( ) } export default function OAuthConsentPage() { return (
} > ) }