mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-06 08:16:03 +00:00
Use Better Auth desktop handoff
This commit is contained in:
parent
889f71ab40
commit
eff084a1f9
2 changed files with 202 additions and 13 deletions
|
|
@ -13,7 +13,7 @@ use std::{
|
|||
const KEYCHAIN_SERVICE: &str = "ai.supermemory.desktop";
|
||||
const KEYCHAIN_USER: &str = "supermemory-api-token";
|
||||
const KEYCHAIN_API_URL_USER: &str = "supermemory-api-url";
|
||||
const DEFAULT_WEB_URL: &str = "https://console.supermemory.ai";
|
||||
const DEFAULT_WEB_URL: &str = "https://app.supermemory.ai";
|
||||
const DEFAULT_BROWSER_API_URL: &str = "https://api.supermemory.ai";
|
||||
const BROWSER_AUTH_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
#[cfg(debug_assertions)]
|
||||
|
|
@ -320,7 +320,7 @@ fn build_browser_login_url(state: &str, callback_port: u16) -> Result<String, St
|
|||
let mut url = url::Url::parse(&base)
|
||||
.or_else(|_| url::Url::parse(&format!("{}/", base.trim_end_matches('/'))))
|
||||
.map_err(|error| format!("Invalid Supermemory web URL: {error}"))?;
|
||||
url.set_path("auth/agent-connect");
|
||||
url.set_path("auth/desktop");
|
||||
|
||||
let mut callback = url::Url::parse(&format!("http://127.0.0.1:{callback_port}/callback"))
|
||||
.map_err(|error| format!("Invalid desktop callback URL: {error}"))?;
|
||||
|
|
@ -339,8 +339,7 @@ fn build_browser_login_url(state: &str, callback_port: u16) -> Result<String, St
|
|||
.append_pair("hostname", "Supermemory Desktop")
|
||||
.append_pair("os", std::env::consts::OS)
|
||||
.append_pair("cwd", &cwd)
|
||||
.append_pair("cli_version", env!("CARGO_PKG_VERSION"))
|
||||
.append_pair("client", "desktop");
|
||||
.append_pair("version", env!("CARGO_PKG_VERSION"));
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
|
|
@ -590,14 +589,14 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn browser_auth_url_uses_console_agent_connect_flow() {
|
||||
fn browser_auth_url_uses_desktop_login_handoff_flow() {
|
||||
std::env::remove_var("SUPERMEMORY_DESKTOP_WEB_URL");
|
||||
std::env::remove_var("SUPERMEMORY_DESKTOP_API_URL");
|
||||
|
||||
let url = url::Url::parse(&build_browser_login_url("state-123", 49876).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
url.as_str().split('?').next().unwrap(),
|
||||
"https://console.supermemory.ai/auth/agent-connect"
|
||||
"https://app.supermemory.ai/auth/desktop"
|
||||
);
|
||||
assert_eq!(
|
||||
url.query_pairs()
|
||||
|
|
@ -606,13 +605,7 @@ mod tests {
|
|||
.1,
|
||||
"Supermemory Desktop"
|
||||
);
|
||||
assert_eq!(
|
||||
url.query_pairs()
|
||||
.find(|(key, _)| key == "client")
|
||||
.unwrap()
|
||||
.1,
|
||||
"desktop"
|
||||
);
|
||||
assert!(url.query_pairs().all(|(key, _)| key != "client"));
|
||||
|
||||
let callback = url
|
||||
.query_pairs()
|
||||
|
|
|
|||
196
apps/web/app/auth/desktop/page.tsx
Normal file
196
apps/web/app/auth/desktop/page.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"use client"
|
||||
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { useSession } from "@lib/auth"
|
||||
import { Loader2, XCircle } from "lucide-react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { Suspense, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { PENDING_CONNECT_URL_KEY } from "@/lib/constants"
|
||||
|
||||
const API_URL =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
type Status = "loading" | "creating" | "success" | "error"
|
||||
|
||||
function isValidDesktopCallback(callback: string): boolean {
|
||||
try {
|
||||
const url = new URL(callback)
|
||||
const isLoopback =
|
||||
(url.hostname === "localhost" || url.hostname === "127.0.0.1") &&
|
||||
url.protocol === "http:" &&
|
||||
url.pathname === "/callback" &&
|
||||
url.searchParams.has("state")
|
||||
if (isLoopback) return true
|
||||
|
||||
return (
|
||||
url.protocol === "supermemory:" &&
|
||||
url.hostname === "auth-callback" &&
|
||||
url.searchParams.has("state")
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function DesktopAuthContent() {
|
||||
const router = useRouter()
|
||||
const params = useSearchParams()
|
||||
const { data: session, isPending } = useSession()
|
||||
const { org, organizations, isRestoring } = useAuth()
|
||||
const [status, setStatus] = useState<Status>("loading")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const hasStarted = useRef(false)
|
||||
|
||||
const callback = params.get("callback")
|
||||
const desktopHostname = params.get("hostname") || "Supermemory Desktop"
|
||||
const desktopOs = params.get("os") || "desktop"
|
||||
const desktopCwd = params.get("cwd") || ""
|
||||
const desktopVersion = params.get("version") || "desktop"
|
||||
const callbackIsValid = useMemo(
|
||||
() => (callback ? isValidDesktopCallback(callback) : false),
|
||||
[callback],
|
||||
)
|
||||
|
||||
const shouldRedirectToOnboarding =
|
||||
!isPending &&
|
||||
!isRestoring &&
|
||||
!!session &&
|
||||
Array.isArray(organizations) &&
|
||||
organizations.length === 0
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending || isRestoring) return
|
||||
if (!session) return
|
||||
if (organizations === null) return
|
||||
if (organizations.length > 0) return
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(PENDING_CONNECT_URL_KEY, window.location.href)
|
||||
} catch (err) {
|
||||
console.warn("Failed to store pending desktop auth URL", err)
|
||||
}
|
||||
router.replace("/onboarding")
|
||||
}, [isPending, isRestoring, session, organizations, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending || isRestoring || shouldRedirectToOnboarding) return
|
||||
if (hasStarted.current) return
|
||||
|
||||
if (!callback) {
|
||||
setStatus("error")
|
||||
setError("Missing desktop callback URL.")
|
||||
return
|
||||
}
|
||||
if (!callbackIsValid) {
|
||||
setStatus("error")
|
||||
setError("Invalid desktop callback URL.")
|
||||
return
|
||||
}
|
||||
if (!session || !org) {
|
||||
setStatus("error")
|
||||
setError("Your account is not fully set up yet.")
|
||||
return
|
||||
}
|
||||
|
||||
hasStarted.current = true
|
||||
const callbackUrl = callback
|
||||
|
||||
async function finishDesktopAuth() {
|
||||
try {
|
||||
setStatus("creating")
|
||||
const res = await fetch(`${API_URL}/v3/auth/agent-key`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: "Supermemory Desktop",
|
||||
permission: "write",
|
||||
deviceInfo: {
|
||||
hostname: desktopHostname,
|
||||
os: desktopOs,
|
||||
cwd: desktopCwd,
|
||||
cliVersion: desktopVersion,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
message?: string
|
||||
}
|
||||
throw new Error(data.message ?? "Failed to create desktop key")
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { key: string }
|
||||
setStatus("success")
|
||||
|
||||
const redirectUrl = new URL(callbackUrl)
|
||||
redirectUrl.searchParams.set("apikey", data.key)
|
||||
redirectUrl.searchParams.set("api_url", API_URL)
|
||||
window.location.href = redirectUrl.toString()
|
||||
} catch (err) {
|
||||
setStatus("error")
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to finish desktop login",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
void finishDesktopAuth()
|
||||
}, [
|
||||
callback,
|
||||
callbackIsValid,
|
||||
desktopCwd,
|
||||
desktopHostname,
|
||||
desktopOs,
|
||||
desktopVersion,
|
||||
isPending,
|
||||
isRestoring,
|
||||
org,
|
||||
session,
|
||||
shouldRedirectToOnboarding,
|
||||
])
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
|
||||
<XCircle className="size-10 text-red-400" />
|
||||
<div>
|
||||
<h1 className="font-semibold text-[#FAFAFA] text-lg">
|
||||
Desktop login failed
|
||||
</h1>
|
||||
<p className="mt-1 text-[#737373] text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="size-6 animate-spin text-[#4BA0FA]" />
|
||||
<p className="text-[#737373] text-sm">
|
||||
{status === "success"
|
||||
? "Returning to Supermemory Desktop..."
|
||||
: "Finishing desktop login..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DesktopAuthPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<Loader2 className="size-6 animate-spin text-[#4BA0FA]" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DesktopAuthContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue