mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-10 22:41:17 +00:00
Start desktop auth from native login
This commit is contained in:
parent
eff084a1f9
commit
33adc849aa
6 changed files with 501 additions and 266 deletions
|
|
@ -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{" "}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
115
apps/web/app/api/auth/desktop/callback/route.ts
Normal file
115
apps/web/app/api/auth/desktop/callback/route.ts
Normal 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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue