Start desktop auth from native login

This commit is contained in:
Sreeram Sreedhar 2026-06-23 13:03:31 -07:00
parent eff084a1f9
commit 33adc849aa
6 changed files with 501 additions and 266 deletions

View file

@ -23,18 +23,22 @@ import {
useState,
} from "react"
import {
beginBrowserAuth,
beginSocialAuth,
desktopDevAuthEnabled,
getSession,
onAuthChanged,
onAuthError,
sendMagicLink,
storeToken,
verifyMagicLinkToken,
} from "@/lib/auth"
export default function LoginPage() {
const router = useRouter()
const [token, setToken] = useState("")
const [email, setEmail] = useState("")
const [submittedEmail, setSubmittedEmail] = useState<string | null>(null)
const [loginCode, setLoginCode] = useState("")
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isBrowserAuthPending, setIsBrowserAuthPending] = useState(false)
@ -47,6 +51,8 @@ export default function LoginPage() {
if (!event.authenticated) return
setError(null)
setIsBrowserAuthPending(false)
setSubmittedEmail(null)
setIsSubmitting(false)
try {
await getSession()
router.replace("/")
@ -63,6 +69,7 @@ export default function LoginPage() {
onAuthError((message) => {
setIsBrowserAuthPending(false)
setIsSubmitting(false)
setError(message)
})
.then((handler) => {
@ -93,20 +100,46 @@ export default function LoginPage() {
}
}
async function startBrowserAuth() {
async function startSocialAuth(provider: "google" | "github") {
setError(null)
setSubmittedEmail(null)
setIsBrowserAuthPending(true)
try {
await beginBrowserAuth()
await beginSocialAuth(provider)
} catch (err) {
setError(formatError(err, "Could not open browser sign-in"))
setError(formatError(err, `Could not start ${provider} sign-in`))
setIsBrowserAuthPending(false)
}
}
function onBrowserAuthSubmit(event: FormEvent<HTMLFormElement>) {
async function onEmailAuthSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
void startBrowserAuth()
setError(null)
setSubmittedEmail(null)
setIsSubmitting(true)
try {
await sendMagicLink(email)
setSubmittedEmail(email)
} catch (err) {
setError(formatError(err, "Could not send login link"))
} finally {
setIsSubmitting(false)
}
}
async function onVerifyTokenSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setError(null)
setIsSubmitting(true)
try {
await verifyMagicLinkToken(loginCode)
setIsBrowserAuthPending(true)
} catch (err) {
setError(formatError(err, "Could not verify login code"))
setIsSubmitting(false)
}
}
const isAuthBusy = isBrowserAuthPending || isSubmitting
@ -168,48 +201,111 @@ export default function LoginPage() {
</div>
<div className="desktop-login-form flex flex-col">
<ExternalAuthButton
authIcon={<GoogleIcon />}
authProvider="Google"
className="w-full"
disabled={isAuthBusy}
onClick={startBrowserAuth}
type="button"
/>
{submittedEmail ? (
<div className="desktop-login-email-form flex flex-col">
<div className="space-y-2 text-center">
<h2 className="font-medium text-foreground text-lg">
Check your email
</h2>
<p className="text-muted-foreground/60 text-sm">
Click the magic link sent to{" "}
<span className="text-foreground">
{submittedEmail}
</span>{" "}
or enter the code below.
</p>
</div>
<ExternalAuthButton
authIcon={<GithubIcon />}
authProvider="Github"
className="w-full"
disabled={isAuthBusy}
onClick={startBrowserAuth}
type="button"
/>
<form
className="desktop-login-email-form flex flex-col"
onSubmit={onVerifyTokenSubmit}
>
<Input
value={loginCode}
onChange={(event) => setLoginCode(event.target.value)}
placeholder="temporary login code"
type="text"
autoComplete="one-time-code"
className="desktop-login-email-input rounded-xl border-[#17202e] bg-[#040a14]/70 px-6 text-base text-foreground placeholder:text-muted-foreground/45"
/>
<TextSeparator text="OR" />
<Button
type="submit"
className="desktop-login-primary-button rounded-xl bg-linear-to-r from-[#2935ff] to-[#2f78ff] text-lg text-white hover:from-[#3440ff] hover:to-[#3b83ff]"
disabled={!loginCode.trim() || isAuthBusy}
>
{isSubmitting ? (
<Loader2 className="size-5 animate-spin" />
) : (
<Logo className="size-5" />
)}
Verify code
</Button>
</form>
<form
className="desktop-login-email-form flex flex-col"
onSubmit={onBrowserAuthSubmit}
>
<Input
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="your@email.com"
type="email"
autoComplete="email"
className="desktop-login-email-input rounded-xl border-[#17202e] bg-[#040a14]/70 px-6 text-base text-foreground placeholder:text-muted-foreground/45"
/>
<Button
type="button"
variant="ghost"
className="text-muted-foreground/60 hover:text-foreground"
disabled={isAuthBusy}
onClick={() => {
setSubmittedEmail(null)
setLoginCode("")
}}
>
Use a different email
</Button>
</div>
) : (
<>
<ExternalAuthButton
authIcon={<GoogleIcon />}
authProvider="Google"
className="w-full"
disabled={isAuthBusy}
onClick={() => startSocialAuth("google")}
type="button"
/>
<Button
type="submit"
className="desktop-login-primary-button rounded-xl bg-linear-to-r from-[#2935ff] to-[#2f78ff] text-lg text-white hover:from-[#3440ff] hover:to-[#3b83ff]"
disabled={isAuthBusy}
>
<Logo className="size-5" />
Log in with Supermemory
</Button>
</form>
<ExternalAuthButton
authIcon={<GithubIcon />}
authProvider="Github"
className="w-full"
disabled={isAuthBusy}
onClick={() => startSocialAuth("github")}
type="button"
/>
<TextSeparator text="OR" />
<form
className="desktop-login-email-form flex flex-col"
onSubmit={onEmailAuthSubmit}
>
<Input
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="your@email.com"
type="email"
autoComplete="email"
className="desktop-login-email-input rounded-xl border-[#17202e] bg-[#040a14]/70 px-6 text-base text-foreground placeholder:text-muted-foreground/45"
/>
<Button
type="submit"
className="desktop-login-primary-button rounded-xl bg-linear-to-r from-[#2935ff] to-[#2f78ff] text-lg text-white hover:from-[#3440ff] hover:to-[#3b83ff]"
disabled={!email.trim() || isAuthBusy}
>
{isSubmitting ? (
<Loader2 className="size-5 animate-spin" />
) : (
<Logo className="size-5" />
)}
Send login link
</Button>
</form>
</>
)}
<p className="desktop-login-terms text-center text-muted-foreground/50">
By continuing, you agree to our{" "}

View file

@ -36,6 +36,18 @@ export async function beginBrowserAuth() {
return invoke<string>("auth_begin_browser")
}
export async function beginSocialAuth(provider: "google" | "github") {
return invoke<string>("auth_begin_social", { provider })
}
export async function sendMagicLink(email: string) {
return invoke("auth_send_magic_link", { email })
}
export async function verifyMagicLinkToken(token: string) {
return invoke<string>("auth_verify_magic_link_token", { token })
}
export async function clearSession() {
await invoke("auth_clear")
}

View file

@ -1,5 +1,5 @@
use keyring::{Entry, Error as KeyringError};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{
io::{BufRead, BufReader, Write},
@ -24,7 +24,22 @@ const DEFAULT_API_URL: &str = "https://api.supermemory.ai";
static TOKEN_CACHE: OnceLock<Mutex<Option<String>>> = OnceLock::new();
static API_URL_CACHE: OnceLock<Mutex<Option<String>>> = OnceLock::new();
static PENDING_BROWSER_STATE: OnceLock<Mutex<Option<String>>> = OnceLock::new();
static PENDING_BROWSER_AUTH: OnceLock<Mutex<Option<PendingBrowserAuth>>> = OnceLock::new();
#[derive(Clone)]
struct PendingBrowserAuth {
state: String,
finish_url: String,
}
struct DesktopAuthRequest {
finish_url: String,
}
#[derive(Deserialize)]
struct SocialSignInResponse {
url: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
@ -60,8 +75,8 @@ fn api_url_cache() -> &'static Mutex<Option<String>> {
API_URL_CACHE.get_or_init(|| Mutex::new(None))
}
fn pending_browser_state() -> &'static Mutex<Option<String>> {
PENDING_BROWSER_STATE.get_or_init(|| Mutex::new(None))
fn pending_browser_auth() -> &'static Mutex<Option<PendingBrowserAuth>> {
PENDING_BROWSER_AUTH.get_or_init(|| Mutex::new(None))
}
fn set_cached_token(token: Option<String>) -> Result<(), String> {
@ -207,20 +222,115 @@ pub fn begin_browser_auth<F>(on_complete: F) -> Result<String, String>
where
F: FnOnce(Result<AuthChangedEvent, String>) + Send + 'static,
{
let state = uuid::Uuid::new_v4().to_string();
{
let mut pending = pending_browser_state()
.lock()
.map_err(|_| "Could not lock browser auth state".to_string())?;
*pending = Some(state.clone());
}
let port = start_loopback_auth_server(on_complete)?;
let login_url = build_browser_login_url(&state, port)?;
let request = create_desktop_auth_request(on_complete)?;
let login_url = build_browser_login_url_from_finish_url(&request.finish_url)?;
open_system_browser(&login_url)?;
Ok(login_url)
}
pub async fn begin_social_auth<F>(provider: String, on_complete: F) -> Result<String, String>
where
F: FnOnce(Result<AuthChangedEvent, String>) + Send + 'static,
{
let provider = match provider.as_str() {
"google" | "github" => provider,
_ => return Err("Unsupported social sign-in provider".to_string()),
};
let request = create_desktop_auth_request(on_complete)?;
let api_url = browser_auth_api_url();
let sign_in_url = format!("{}/api/auth/sign-in/social", api_url.trim_end_matches('/'));
let response = reqwest::Client::new()
.post(&sign_in_url)
.header("Content-Type", "application/json")
.header("X-App-Source", "desktop")
.json(&serde_json::json!({
"provider": provider,
"callbackURL": request.finish_url,
"errorCallbackURL": request.finish_url,
}))
.send()
.await
.map_err(|error| format!("Could not start {provider} sign-in: {error}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!(
"Could not start {provider} sign-in ({status}): {body}"
));
}
let sign_in = response
.json::<SocialSignInResponse>()
.await
.map_err(|error| format!("Could not parse {provider} sign-in response: {error}"))?;
open_system_browser(&sign_in.url)?;
Ok(sign_in.url)
}
pub async fn send_magic_link<F>(email: String, on_complete: F) -> Result<(), String>
where
F: FnOnce(Result<AuthChangedEvent, String>) + Send + 'static,
{
let email = email.trim().to_string();
if email.is_empty() {
return Err("Email cannot be empty".to_string());
}
let request = create_desktop_auth_request(on_complete)?;
let api_url = browser_auth_api_url();
let magic_link_url = format!(
"{}/api/auth/sign-in/magic-link",
api_url.trim_end_matches('/')
);
let response = reqwest::Client::new()
.post(&magic_link_url)
.header("Content-Type", "application/json")
.header("X-App-Source", "desktop")
.json(&serde_json::json!({
"email": email,
"callbackURL": request.finish_url,
"errorCallbackURL": request.finish_url,
}))
.send()
.await
.map_err(|error| format!("Could not send login link: {error}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Could not send login link ({status}): {body}"));
}
Ok(())
}
pub fn verify_magic_link_token(token: String) -> Result<String, String> {
let token = token.trim();
if token.is_empty() {
return Err("Login code cannot be empty".to_string());
}
let finish_url = pending_finish_url()?;
let api_url = browser_auth_api_url();
let mut verify_url = url::Url::parse(&format!(
"{}/api/auth/magic-link/verify",
api_url.trim_end_matches('/')
))
.map_err(|error| format!("Invalid magic link verification URL: {error}"))?;
verify_url
.query_pairs_mut()
.append_pair("token", token)
.append_pair("callbackURL", &finish_url)
.append_pair("errorCallbackURL", &finish_url);
let verify_url = verify_url.to_string();
open_system_browser(&verify_url)?;
Ok(verify_url)
}
pub fn handle_deep_link(url: &str) -> Result<AuthChangedEvent, String> {
let parsed =
url::Url::parse(url).map_err(|error| format!("Invalid auth callback URL: {error}"))?;
@ -315,19 +425,44 @@ fn handle_loopback_callback(url: &str) -> Result<AuthChangedEvent, String> {
})
}
fn build_browser_login_url(state: &str, callback_port: u16) -> Result<String, String> {
let base = web_url();
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/desktop");
fn create_desktop_auth_request<F>(on_complete: F) -> Result<DesktopAuthRequest, String>
where
F: FnOnce(Result<AuthChangedEvent, String>) + Send + 'static,
{
let state = uuid::Uuid::new_v4().to_string();
let port = start_loopback_auth_server(on_complete)?;
let callback_url = build_loopback_callback_url(&state, port)?;
let finish_url = build_desktop_finish_url(&callback_url)?;
{
let mut pending = pending_browser_auth()
.lock()
.map_err(|_| "Could not lock browser auth state".to_string())?;
*pending = Some(PendingBrowserAuth {
state,
finish_url: finish_url.clone(),
});
}
Ok(DesktopAuthRequest { finish_url })
}
fn build_loopback_callback_url(state: &str, callback_port: u16) -> Result<String, String> {
let mut callback = url::Url::parse(&format!("http://127.0.0.1:{callback_port}/callback"))
.map_err(|error| format!("Invalid desktop callback URL: {error}"))?;
callback
.query_pairs_mut()
.append_pair("state", state)
.append_pair("api_url", &browser_auth_api_url());
Ok(callback.to_string())
}
fn build_desktop_finish_url(callback_url: &str) -> Result<String, String> {
let base = web_url();
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("api/auth/desktop/callback");
let cwd = std::env::current_dir()
.ok()
@ -335,7 +470,8 @@ fn build_browser_login_url(state: &str, callback_port: u16) -> Result<String, St
.unwrap_or_default();
url.query_pairs_mut()
.append_pair("callback", callback.as_str())
.append_pair("callback", callback_url)
.append_pair("api_url", &browser_auth_api_url())
.append_pair("hostname", "Supermemory Desktop")
.append_pair("os", std::env::consts::OS)
.append_pair("cwd", &cwd)
@ -343,6 +479,31 @@ fn build_browser_login_url(state: &str, callback_port: u16) -> Result<String, St
Ok(url.to_string())
}
fn build_browser_login_url_from_finish_url(finish_url: &str) -> Result<String, String> {
let base = web_url();
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("login");
url.query_pairs_mut().append_pair("redirect", finish_url);
Ok(url.to_string())
}
#[cfg(test)]
fn build_browser_login_url(state: &str, callback_port: u16) -> Result<String, String> {
let callback_url = build_loopback_callback_url(state, callback_port)?;
build_desktop_finish_url(&callback_url)
}
fn pending_finish_url() -> Result<String, String> {
pending_browser_auth()
.lock()
.map_err(|_| "Could not lock browser auth state".to_string())?
.as_ref()
.map(|pending| pending.finish_url.clone())
.ok_or_else(|| "No browser auth request is pending".to_string())
}
fn start_loopback_auth_server<F>(on_complete: F) -> Result<u16, String>
where
F: FnOnce(Result<AuthChangedEvent, String>) + Send + 'static,
@ -549,12 +710,12 @@ fn open_system_browser(url: &str) -> Result<(), String> {
}
fn verify_browser_state(state: &str) -> Result<(), String> {
let mut pending = pending_browser_state()
let mut pending = pending_browser_auth()
.lock()
.map_err(|_| "Could not lock browser auth state".to_string())?;
match pending.as_deref() {
Some(expected) if expected == state => {
match pending.as_ref() {
Some(expected) if expected.state == state => {
*pending = None;
Ok(())
}
@ -589,14 +750,14 @@ mod tests {
use super::*;
#[test]
fn browser_auth_url_uses_desktop_login_handoff_flow() {
fn browser_auth_url_uses_desktop_finish_callback_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://app.supermemory.ai/auth/desktop"
"https://app.supermemory.ai/api/auth/desktop/callback"
);
assert_eq!(
url.query_pairs()
@ -605,6 +766,13 @@ mod tests {
.1,
"Supermemory Desktop"
);
assert_eq!(
url.query_pairs()
.find(|(key, _)| key == "api_url")
.unwrap()
.1,
DEFAULT_BROWSER_API_URL
);
assert!(url.query_pairs().all(|(key, _)| key != "client"));
let callback = url

View file

@ -59,6 +59,43 @@ fn auth_begin_browser(app: tauri::AppHandle) -> Result<String, String> {
})
}
#[tauri::command]
async fn auth_begin_social(app: tauri::AppHandle, provider: String) -> Result<String, String> {
let app_for_callback = app.clone();
auth::begin_social_auth(provider, move |result| match result {
Ok(event) => {
let _ = app_for_callback.emit("auth:changed", event);
focus_main_window(&app_for_callback);
}
Err(error) => {
let _ = app_for_callback.emit("auth:error", error);
focus_main_window(&app_for_callback);
}
})
.await
}
#[tauri::command]
async fn auth_send_magic_link(app: tauri::AppHandle, email: String) -> Result<(), String> {
let app_for_callback = app.clone();
auth::send_magic_link(email, move |result| match result {
Ok(event) => {
let _ = app_for_callback.emit("auth:changed", event);
focus_main_window(&app_for_callback);
}
Err(error) => {
let _ = app_for_callback.emit("auth:error", error);
focus_main_window(&app_for_callback);
}
})
.await
}
#[tauri::command]
fn auth_verify_magic_link_token(token: String) -> Result<String, String> {
auth::verify_magic_link_token(token)
}
#[tauri::command]
async fn auth_whoami() -> Result<auth::AuthSession, String> {
auth::whoami().await
@ -191,6 +228,9 @@ pub fn run() {
auth_get_token,
auth_clear,
auth_begin_browser,
auth_begin_social,
auth_send_magic_link,
auth_verify_magic_link_token,
auth_whoami,
spotlight_show,
spotlight_hide,

View file

@ -0,0 +1,115 @@
import { NextResponse, type NextRequest } from "next/server"
const DEFAULT_API_URL =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
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 isValidApiUrl(apiUrl: string): boolean {
try {
const url = new URL(apiUrl)
return url.protocol === "http:" || url.protocol === "https:"
} catch {
return false
}
}
function redirectToCallback(callback: string, params: Record<string, string>) {
const redirectUrl = new URL(callback)
for (const [key, value] of Object.entries(params)) {
redirectUrl.searchParams.set(key, value)
}
return NextResponse.redirect(redirectUrl)
}
export async function GET(request: NextRequest) {
const url = new URL(request.url)
const callback = url.searchParams.get("callback")
if (!callback || !isValidDesktopCallback(callback)) {
return NextResponse.json(
{ message: "Invalid desktop callback URL" },
{ status: 400 },
)
}
if (url.searchParams.has("error")) {
return redirectToCallback(callback, {
error: url.searchParams.get("error") || "auth_failed",
})
}
const apiUrl = url.searchParams.get("api_url") || DEFAULT_API_URL
if (!isValidApiUrl(apiUrl)) {
return redirectToCallback(callback, { error: "invalid_api_url" })
}
const cookie = request.headers.get("cookie")
if (!cookie) {
return redirectToCallback(callback, { error: "missing_session" })
}
const deviceInfo = {
hostname: url.searchParams.get("hostname") || "Supermemory Desktop",
os: url.searchParams.get("os") || "desktop",
cwd: url.searchParams.get("cwd") || "",
cliVersion: url.searchParams.get("version") || "desktop",
}
try {
const res = await fetch(`${apiUrl.replace(/\/+$/, "")}/v3/auth/agent-key`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Cookie: cookie,
"X-App-Source": "desktop",
},
body: JSON.stringify({
name: "Supermemory Desktop",
permission: "write",
deviceInfo,
}),
})
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as {
message?: string
}
return redirectToCallback(callback, {
error: data.message ?? "desktop_key_failed",
})
}
const data = (await res.json()) as { key?: string }
if (!data.key) {
return redirectToCallback(callback, { error: "missing_desktop_key" })
}
return redirectToCallback(callback, {
apikey: data.key,
api_url: apiUrl,
})
} catch (err) {
return redirectToCallback(callback, {
error:
err instanceof Error ? err.message : "desktop_auth_callback_failed",
})
}
}

View file

@ -1,196 +0,0 @@
"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>
)
}