supermemory/apps/web/app/(auth)/login/page.tsx
Prasanna721 651e304735 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.
2026-06-16 17:33:09 +00:00

545 lines
18 KiB
TypeScript

"use client"
import { signIn, useSession } from "@lib/auth"
import { usePostHog } from "@lib/posthog"
import { TextSeparator } from "@ui/components/text-separator"
import { ExternalAuthButton } from "@ui/button/external-auth"
import { Button } from "@ui/components/button"
import { Badge } from "@ui/components/badge"
import { LabeledInput } from "@ui/input/labeled-input"
import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium"
import { Label1Regular } from "@ui/text/label/label-1-regular"
import { Title1Bold } from "@ui/text/title/title-1-bold"
import { InitialHeader } from "@/components/initial-header"
import { LoginToolsPanel } from "@/components/login-tools-panel"
import { useRouter, useSearchParams } from "next/navigation"
import { useState, useEffect } from "react"
import { motion } from "motion/react"
import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { Logo } from "@ui/assets/Logo"
import { resolveAuthRedirectUrl } from "@/lib/url-helpers"
import { Loader2 } from "lucide-react"
function isMcpOAuthAuthorizeContext(sp: Pick<URLSearchParams, "get">): boolean {
return sp.get("response_type") === "code" && Boolean(sp.get("client_id"))
}
function buildMcpAuthorizeResumeUrl(
sp: Pick<URLSearchParams, "toString">,
): string {
const backend =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const p = new URLSearchParams(sp.toString())
p.delete("redirect")
p.delete("error")
return `${backend}/api/auth/oauth2/authorize?${p.toString()}`
}
function LoginHeadline({ className }: { className?: string }) {
return (
<div className={cn("max-w-sm text-center", className)}>
<h1 className="text-balance text-lg font-medium leading-tight tracking-tight sm:text-xl lg:text-[1.375rem] xl:text-2xl">
Never forget anything, anywhere
<span className="block text-brand-accent">with supermemory</span>
</h1>
<p
className={cn(
"mt-1.5 text-xs leading-snug text-muted-foreground/50 lg:mt-2",
dmSansClassName(),
)}
>
Save from Chrome, Notion, X search it all in one place.
</p>
</div>
)
}
function LastUsedBadge({ show }: { show: boolean }) {
if (!show) return null
return (
<div className="mb-1 flex justify-end">
<Badge variant="default" className="h-5 px-1.5 text-[10px]">
Last used
</Badge>
</div>
)
}
function LoginCard({ children }: { children: React.ReactNode }) {
return (
<motion.div
className="flex w-full max-w-[400px] shrink-0 flex-col items-center gap-2 rounded-[22px] py-5 px-5 sm:w-auto sm:max-w-none sm:items-start sm:py-8 sm:px-11 bg-linear-to-b from-[#06101F] to-[#030912] shadow-[1.5px_1.5px_20px_0_rgba(0,0,0,0.65),1px_1.5px_2px_0_rgba(128,189,255,0.07)_inset,-0.5px_-1.5px_4px_0_rgba(0,35,73,0.40)_inset]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.75, ease: "easeOut" }}
>
{children}
</motion.div>
)
}
function LoginCardBody({
children,
loadingMessage,
}: {
children: React.ReactNode
loadingMessage?: string | null
}) {
return (
<div className="relative w-full max-w-[360px] sm:w-[360px]">
<div className={loadingMessage ? "invisible" : undefined}>{children}</div>
{loadingMessage ? (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 rounded-[14px] bg-[#030912]/90 backdrop-blur-sm">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">{loadingMessage}</p>
</div>
) : null}
</div>
)
}
export default function LoginPage() {
const [email, setEmail] = useState("")
const [submittedEmail, setSubmittedEmail] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isLoadingEmail, setIsLoadingEmail] = useState(false)
const [error, setError] = useState<string | null>(null)
const [lastUsedMethod, setLastUsedMethod] = useState<string | null>(null)
const router = useRouter()
const posthog = usePostHog()
const params = useSearchParams()
const { data: sessionData, isPending: sessionPending } = useSession()
const oauthQueryForResume = params.toString()
const isRedirecting = !sessionPending && Boolean(sessionData?.session)
const isAuthResolving = sessionPending || isRedirecting
const loadingMessage = isAuthResolving
? sessionPending
? "Checking session…"
: "Redirecting…"
: isLoading
? isLoadingEmail
? "Sending login link…"
: "Redirecting…"
: null
useEffect(() => {
if (sessionPending) return
if (!sessionData?.session) return
const sp = new URLSearchParams(oauthQueryForResume)
if (isMcpOAuthAuthorizeContext(sp)) {
window.location.assign(buildMcpAuthorizeResumeUrl(sp))
return
}
const redirectUrl = params.get("redirect")
if (redirectUrl) {
window.location.assign(
resolveAuthRedirectUrl(redirectUrl, window.location.origin).toString(),
)
return
}
// Carry the flag so the dashboard posts the session token to the extension (else: sign-in loop).
const dest = new URL("/", window.location.origin)
dest.searchParams.set("extension-auth-success", "true")
window.location.assign(dest.toString())
}, [sessionPending, sessionData?.session, oauthQueryForResume, params])
// Get redirect URL from query params
const redirectUrl = params.get("redirect")
// Create callback URL that includes redirect parameter if provided
const getCallbackURL = () => {
const origin = window.location.origin
if (isMcpOAuthAuthorizeContext(params)) {
return buildMcpAuthorizeResumeUrl(params)
}
const finalUrl = resolveAuthRedirectUrl(redirectUrl, origin)
finalUrl.searchParams.set("extension-auth-success", "true")
return finalUrl.toString()
}
// Load last used method from localStorage on mount
useEffect(() => {
const savedMethod = localStorage.getItem("supermemory-last-login-method")
setLastUsedMethod(savedMethod)
}, [])
// Record the pending login method (will be committed after successful auth)
function setPendingLoginMethod(method: string) {
try {
localStorage.setItem("supermemory-pending-login-method", method)
localStorage.setItem(
"supermemory-pending-login-timestamp",
String(Date.now()),
)
} catch {}
}
function isNetworkError(error: unknown): boolean {
if (!(error instanceof Error)) return false
const message = error.message.toLowerCase()
return (
message.includes("load failed") ||
message.includes("networkerror") ||
message.includes("failed to fetch") ||
message.includes("network request failed")
)
}
function getErrorMessage(error: unknown): string {
if (isNetworkError(error)) {
return "Network error. Please check your connection and try again."
}
if (error instanceof Error) {
return error.message
}
return "An unexpected error occurred. Please try again."
}
// If we land back on this page with an error, clear any pending marker
useEffect(() => {
if (params.get("error")) {
try {
localStorage.removeItem("supermemory-pending-login-method")
localStorage.removeItem("supermemory-pending-login-timestamp")
} catch {}
}
}, [params])
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsLoading(true)
setIsLoadingEmail(true)
setError(null)
// Track login attempt
posthog.capture("login_attempt", {
method: "magic_link",
email_domain: email.split("@")[1] || "unknown",
})
const { error } = await signIn.magicLink({
callbackURL: getCallbackURL(),
email,
})
if (error) {
console.error(error)
// Track login failure
posthog.capture("login_failed", {
method: "magic_link",
error: error instanceof Error ? error.message : "Unknown error",
email_domain: email.split("@")[1] || "unknown",
is_network_error: isNetworkError(error),
})
setError(getErrorMessage(error))
setIsLoading(false)
setIsLoadingEmail(false)
return
}
setSubmittedEmail(email)
setPendingLoginMethod("magic_link")
posthog.capture("login_magic_link_sent", {
email_domain: email.split("@")[1] || "unknown",
})
setIsLoading(false)
setIsLoadingEmail(false)
}
const handleSubmitToken = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
setIsLoading(true)
const formData = new FormData(event.currentTarget)
const token = formData.get("token") as string
const callbackURL = getCallbackURL()
router.push(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`,
)
}
return (
<main className="login-page-grid relative bg-[#030912]">
<div className="login-page-mobile-bg" aria-hidden>
<div className="login-panel-orb h-full" />
<div className="login-panel-orb-image h-full" />
</div>
<div className="relative z-20 lg:hidden">
<InitialHeader />
</div>
<LoginToolsPanel />
<div className="relative z-10 flex min-h-0 min-w-0 flex-col lg:col-start-2 lg:row-start-1 lg:h-full">
<div className="hidden lg:block">
<InitialHeader />
</div>
<section className="flex min-h-0 flex-1 flex-col items-center px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pt-1 lg:justify-center lg:px-10 lg:py-10">
<div className="mt-auto flex w-full flex-col items-center lg:mt-0">
<LoginHeadline className="mb-3 sm:mb-4 lg:mb-8" />
<LoginCard>
<LoginCardBody loadingMessage={loadingMessage}>
{submittedEmail ? (
<div className="flex flex-col gap-4 lg:gap-6">
<div className="flex flex-col gap-2 text-center lg:text-left">
<Title1Bold className="text-foreground">
Almost there!
</Title1Bold>
<HeadingH3Medium className="text-muted-foreground">
Click the magic link we've sent to{" "}
<span className="text-foreground">
{submittedEmail}
</span>
.
</HeadingH3Medium>
</div>
<TextSeparator
text="OR"
className={cn(dmSansClassName())}
/>
<form
className="flex flex-col gap-4 lg:gap-6"
onSubmit={handleSubmitToken}
>
<LabeledInput
inputPlaceholder="your temporary login code"
inputProps={{
name: "token",
required: true,
disabled: isLoading,
"aria-invalid": error ? "true" : "false",
}}
inputType="text"
label="Enter code"
/>
<Button
disabled={isLoading}
id="verify-token"
type="submit"
>
Verify Token
</Button>
</form>
</div>
) : (
<div className="flex flex-col" style={{ gap: "12px" }}>
{params.get("error") && (
<div className="text-red-500">
Error: {params.get("error")}. Please try again!
</div>
)}
<div className="flex flex-col gap-3">
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
!process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ? (
<div className="w-full">
<LastUsedBadge show={lastUsedMethod === "google"} />
<ExternalAuthButton
authIcon={
<svg
className="size-4 sm:size-5"
fill="none"
height="25"
viewBox="0 0 24 25"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Google</title>
<path
d="M21.81 10.26H21V10.21H12V14.21H17.65C16.83 16.54 14.61 18.21 12 18.21C8.69 18.21 6 15.53 6 12.21C6 8.9 8.69 6.21 12 6.21C13.53 6.21 14.92 6.79 15.98 7.73L18.81 4.91C17.02 3.24 14.63 2.21 12 2.21C6.48 2.21 2 6.69 2 12.21C2 17.74 6.48 22.21 12 22.21C17.52 22.21 22 17.74 22 12.21C22 11.54 21.93 10.89 21.81 10.26Z"
fill="#FFC107"
/>
<path
d="M3.15 7.56L6.44 9.97C7.33 7.77 9.48 6.21 12 6.21C13.53 6.21 14.92 6.79 15.98 7.73L18.81 4.91C17.02 3.24 14.63 2.21 12 2.21C8.16 2.21 4.83 4.38 3.15 7.56Z"
fill="#FF3D00"
/>
<path
d="M12 22.22C14.58 22.22 16.93 21.23 18.7 19.62L15.61 17C14.57 17.79 13.3 18.22 12 18.22C9.4 18.22 7.19 16.56 6.36 14.24L3.1 16.75C4.75 19.99 8.11 22.22 12 22.22Z"
fill="#4CAF50"
/>
<path
d="M21.81 10.26H21V10.21H12V14.21H17.65C17.26 15.32 16.55 16.29 15.61 17L15.61 17L18.7 19.62C18.49 19.82 22 17.21 22 12.21C22 11.54 21.93 10.89 21.81 10.26Z"
fill="#1976D2"
/>
</svg>
}
authProvider="Google"
className="w-full"
disabled={Boolean(loadingMessage)}
onClick={() => {
if (loadingMessage) return
setIsLoading(true)
posthog.capture("login_attempt", {
method: "social",
provider: "google",
})
setPendingLoginMethod("google")
signIn
.social({
callbackURL: getCallbackURL(),
provider: "google",
})
.catch((err: unknown) => {
setError(getErrorMessage(err))
setIsLoading(false)
})
}}
/>
</div>
) : null}
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
!process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? (
<div className="w-full">
<LastUsedBadge show={lastUsedMethod === "github"} />
<ExternalAuthButton
authIcon={
<svg
className="size-4 sm:size-5 text-foreground"
fill="none"
height="25"
viewBox="0 0 26 25"
width="26"
xmlns="http://www.w3.org/2000/svg"
>
<title>Github</title>
<g clipPath="url(#clip0_2579_3356)">
<path
clipRule="evenodd"
d="M12.96 0.21C6.21 0.21 0.75 5.71 0.75 12.52C0.75 17.96 4.25 22.56 9.1 24.19C9.71 24.31 9.93 23.93 9.93 23.6C9.93 23.32 9.91 22.34 9.91 21.32C6.51 22.05 5.81 19.85 5.81 19.85C5.26 18.43 4.45 18.06 4.45 18.06C3.34 17.31 4.53 17.31 4.53 17.31C5.76 17.39 6.41 18.57 6.41 18.57C7.5 20.44 9.26 19.91 9.97 19.59C10.07 18.79 10.4 18.24 10.74 17.94C8.03 17.65 5.18 16.59 5.18 11.87C5.18 10.52 5.66 9.42 6.43 8.57C6.31 8.26 5.89 7 6.55 5.31C6.55 5.31 7.58 4.98 9.91 6.57C10.91 6.3 11.93 6.16 12.96 6.16C13.99 6.16 15.05 6.31 16.02 6.57C18.34 4.98 19.37 5.31 19.37 5.31C20.04 7 19.62 8.26 19.49 8.57C20.28 9.42 20.75 10.52 20.75 11.87C20.75 16.59 17.9 17.63 15.17 17.94C15.61 18.32 16 19.06 16 20.22C16 21.87 15.98 23.19 15.98 23.6C15.98 23.93 16.2 24.31 16.81 24.19C21.66 22.56 25.16 17.96 25.16 12.52C25.18 5.71 19.7 0.21 12.96 0.21Z"
fill="currentColor"
fillRule="evenodd"
/>
</g>
<defs>
<clipPath id="clip0_2579_3356">
<rect
fill="currentColor"
height="24"
transform="translate(0.75 0.21)"
width="24.5"
/>
</clipPath>
</defs>
</svg>
}
authProvider="Github"
className="w-full"
disabled={Boolean(loadingMessage)}
onClick={() => {
if (loadingMessage) return
setIsLoading(true)
posthog.capture("login_attempt", {
method: "social",
provider: "github",
})
setPendingLoginMethod("github")
signIn
.social({
callbackURL: getCallbackURL(),
provider: "github",
})
.catch((err: unknown) => {
setError(getErrorMessage(err))
setIsLoading(false)
})
}}
/>
</div>
) : null}
</div>
<TextSeparator
text="OR"
className={cn(dmSansClassName())}
/>
<div className="flex flex-col gap-6">
<form
onSubmit={handleSubmit}
className="flex flex-col gap-6"
>
<LabeledInput
error={error}
inputPlaceholder="your@email.com"
inputProps={{
"aria-invalid": error ? "true" : "false",
disabled: Boolean(loadingMessage),
id: "email",
onChange: (
e: React.ChangeEvent<HTMLInputElement>,
) => {
setEmail(e.target.value)
error && setError(null)
},
required: true,
value: email,
}}
inputType="email"
/>
<div className="w-full">
<LastUsedBadge
show={lastUsedMethod === "magic_link"}
/>
<Button
className="flex justify-center items-center w-full h-[44px] relative gap-3 p-2 rounded-xl"
style={{
background:
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
boxShadow:
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
}}
disabled={Boolean(loadingMessage)}
type="submit"
>
<Logo className="size-4" />
Log in with Supermemory
</Button>
</div>
</form>
<Label1Regular
className={cn(
"text-center text-xs! text-[#737373B2]",
dmSansClassName(),
)}
>
By continuing, you agree to our{" "}
<span className="inline-block">
<a
className="underline"
href="https://supermemory.ai/terms/"
>
Terms
</a>{" "}
and{" "}
<a
className="underline"
href="https://supermemory.ai/privacy/"
>
Privacy Policy
</a>
.
</span>
</Label1Regular>
</div>
</div>
)}
</LoginCardBody>
</LoginCard>
</div>
</section>
</div>
</main>
)
}