diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a2fd0cf..6572730 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4923,7 +4923,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "playwright" version = "0.0.23" -source = "git+https://github.com/zhom/playwright-rust?branch=master#95a6c94d87c88376502ce2f33d4c61c09fc008a6" +source = "git+https://github.com/zhom/playwright-rust?branch=master#0482f839aa24507268d9e3ee47c2be859eeef4f2" dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index e1f1c76..4cf3c16 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -7,6 +7,7 @@ use chrono::Utc; use lazy_static::lazy_static; use reqwest::Client; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::fs; use std::path::PathBuf; use std::sync::Arc; @@ -54,12 +55,15 @@ pub struct CloudAuthState { } #[derive(Debug, Deserialize)] -struct OtpRequestResponse { - message: String, +struct DeviceCodeChallengeResponse { + #[serde(rename = "challengeId")] + challenge_id: String, + prefix: String, + difficulty: u32, } #[derive(Debug, Deserialize)] -struct OtpVerifyResponse { +struct DeviceCodeExchangeResponse { #[serde(rename = "accessToken")] access_token: String, #[serde(rename = "refreshToken")] @@ -362,47 +366,49 @@ impl CloudAuthManager { // --- API methods --- - pub async fn request_otp(&self, email: &str, captcha_token: &str) -> Result { - let url = format!("{CLOUD_API_URL}/api/auth/otp/request"); - let response = self + pub async fn exchange_device_code(&self, code: &str) -> Result { + let challenge_url = format!("{CLOUD_API_URL}/api/auth/device-code/challenge"); + let challenge_response = self .client - .post(&url) - .json(&serde_json::json!({ "email": email, "captchaToken": captcha_token })) + .post(&challenge_url) .send() .await - .map_err(|e| format!("Failed to request OTP: {e}"))?; + .map_err(|e| format!("Failed to fetch challenge: {e}"))?; - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(format!("OTP request failed ({status}): {body}")); + if !challenge_response.status().is_success() { + let status = challenge_response.status(); + let body = challenge_response.text().await.unwrap_or_default(); + return Err(format!("Challenge request failed ({status}): {body}")); } - let result: OtpRequestResponse = response + let challenge: DeviceCodeChallengeResponse = challenge_response .json() .await - .map_err(|e| format!("Failed to parse response: {e}"))?; + .map_err(|e| format!("Failed to parse challenge: {e}"))?; - Ok(result.message) - } + let nonce = solve_pow(&challenge.prefix, challenge.difficulty) + .ok_or_else(|| "Failed to solve proof-of-work".to_string())?; - pub async fn verify_otp(&self, email: &str, code: &str) -> Result { - let url = format!("{CLOUD_API_URL}/api/auth/otp/verify"); + let exchange_url = format!("{CLOUD_API_URL}/api/auth/device-code/exchange"); let response = self .client - .post(&url) - .json(&serde_json::json!({ "email": email, "code": code })) + .post(&exchange_url) + .json(&serde_json::json!({ + "code": code, + "challengeId": challenge.challenge_id, + "nonce": nonce, + })) .send() .await - .map_err(|e| format!("Failed to verify OTP: {e}"))?; + .map_err(|e| format!("Failed to verify code: {e}"))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(format!("OTP verification failed ({status}): {body}")); + return Err(format!("Login failed ({status}): {body}")); } - let result: OtpVerifyResponse = response + let result: DeviceCodeExchangeResponse = response .json() .await .map_err(|e| format!("Failed to parse response: {e}"))?; @@ -1097,20 +1103,51 @@ impl CloudAuthManager { } } +fn solve_pow(prefix: &str, difficulty: u32) -> Option { + if difficulty == 0 || difficulty > 32 { + return None; + } + let prefix_bytes = prefix.as_bytes(); + let mut buf = Vec::with_capacity(prefix_bytes.len() + 24); + for nonce in 0u64..u64::MAX { + buf.clear(); + buf.extend_from_slice(prefix_bytes); + let nonce_str = nonce.to_string(); + buf.extend_from_slice(nonce_str.as_bytes()); + let digest = Sha256::digest(&buf); + if has_leading_zero_bits(&digest, difficulty) { + return Some(nonce_str); + } + } + None +} + +fn has_leading_zero_bits(digest: &[u8], bits: u32) -> bool { + let full_bytes = (bits / 8) as usize; + if digest.len() < full_bytes + 1 { + return false; + } + for &b in &digest[..full_bytes] { + if b != 0 { + return false; + } + } + let remainder = bits % 8; + if remainder == 0 { + return true; + } + let mask = 0xffu8 << (8 - remainder); + (digest[full_bytes] & mask) == 0 +} + // --- Tauri commands --- #[tauri::command] -pub async fn cloud_request_otp(email: String, captcha_token: String) -> Result { - CLOUD_AUTH.request_otp(&email, &captcha_token).await -} - -#[tauri::command] -pub async fn cloud_verify_otp( +pub async fn cloud_exchange_device_code( app_handle: tauri::AppHandle, - email: String, code: String, ) -> Result { - let state = CLOUD_AUTH.verify_otp(&email, &code).await?; + let state = CLOUD_AUTH.exchange_device_code(&code).await?; let has_subscription = CLOUD_AUTH.has_active_paid_subscription().await; log::info!( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 631a9ee..fae88e6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2087,9 +2087,9 @@ pub fn run() { disconnect_vpn, get_vpn_status, list_active_vpn_connections, + handle_url_open, // Cloud auth commands - cloud_auth::cloud_request_otp, - cloud_auth::cloud_verify_otp, + cloud_auth::cloud_exchange_device_code, cloud_auth::cloud_get_user, cloud_auth::cloud_refresh_profile, cloud_auth::cloud_logout, @@ -2278,7 +2278,7 @@ mod tests { // Remove trailing comma and whitespace let command = line.trim_end_matches(',').trim(); if !command.is_empty() { - // Strip module prefix (e.g., "cloud_auth::cloud_request_otp" -> "cloud_request_otp") + // Strip module prefix (e.g., "cloud_auth::cloud_get_user" -> "cloud_get_user") let command = command.rsplit("::").next().unwrap_or(command); commands.push(command.to_string()); } diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index fcdd248..b291c58 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -1,6 +1,5 @@ use crate::browser_runner::BrowserRunner; use crate::profile::BrowserProfile; -use playwright::api::Playwright; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -60,8 +59,6 @@ struct WayfernInstance { profile_path: Option, url: Option, cdp_port: Option, - playwright_context: Option, - playwright_runtime: Option, } struct WayfernManagerInner { @@ -94,16 +91,6 @@ impl WayfernManager { } } - async fn create_playwright( - &self, - ) -> Result> { - Playwright::initialize() - .await - .map_err(|e| -> Box { - format!("Failed to initialize Playwright: {e}").into() - }) - } - pub fn instance() -> &'static WayfernManager { &WAYFERN_MANAGER } @@ -514,7 +501,7 @@ impl WayfernManager { Some(p) => p, None => Self::find_free_port().await?, }; - log::info!("Launching Wayfern on CDP port {port}"); + log::info!("Launching Wayfern on CDP port {port} (detached)"); // Diagnostic: verify critical profile files and test cookie decryption { @@ -607,6 +594,7 @@ impl WayfernManager { let mut args = vec![ format!("--remote-debugging-port={port}"), "--remote-debugging-address=127.0.0.1".to_string(), + format!("--user-data-dir={profile_path}"), "--no-first-run".to_string(), "--no-default-browser-check".to_string(), "--disable-background-mode".to_string(), @@ -622,6 +610,10 @@ impl WayfernManager { "--password-store=basic".to_string(), ]; + if headless { + args.push("--headless=new".to_string()); + } + #[cfg(target_os = "linux")] { args.push("--no-sandbox".to_string()); @@ -670,51 +662,28 @@ impl WayfernManager { args.push("--dns-prefetch-disable".to_string()); } - let pw = self.create_playwright().await?; - let chromium = pw.chromium(); - let profile_path_ref = std::path::Path::new(profile_path); - let mut launcher = chromium.persistent_context_launcher(profile_path_ref); - launcher = launcher.executable(executable_path.as_ref()); - launcher = launcher.headless(headless); - launcher = launcher.chromium_sandbox(true); - launcher = launcher.args(&args); - launcher = launcher.timeout(0.0); + let mut command = TokioCommand::new(&executable_path); + command + .args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); - let pw_context = - launcher - .launch() - .await - .map_err(|e| -> Box { - let hint = if format!("{e}").contains("14001") { - ". This usually means the Visual C++ Redistributable is not installed. \ + let child = command + .spawn() + .map_err(|e| -> Box { + let hint = if e.raw_os_error() == Some(14001) { + ". This usually means the Visual C++ Redistributable is not installed. \ Download it from https://aka.ms/vs/17/release/vc_redist.x64.exe" - } else { - "" - }; - format!("Failed to launch Wayfern: {e}{hint}").into() - })?; + } else { + "" + }; + format!("Failed to spawn Wayfern: {e}{hint}").into() + })?; + let process_id = child.id(); + drop(child); - let process_id = { - use sysinfo::{ProcessRefreshKind, RefreshKind, System}; - let system = System::new_with_specifics( - RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()), - ); - let mut found: Option = None; - for (pid, process) in system.processes() { - let cmd_str = process - .cmd() - .iter() - .map(|s| s.to_string_lossy().to_string()) - .collect::>() - .join(" "); - if cmd_str.contains(&format!("--remote-debugging-port={port}")) { - found = Some(pid.as_u32()); - break; - } - } - found - }; - let pw_runtime = pw; + self.wait_for_cdp_ready(port).await?; let targets = self.get_cdp_targets(port).await?; log::info!("Found {} CDP targets", targets.len()); @@ -797,7 +766,6 @@ impl WayfernManager { for target in &page_targets { if let Some(ws_url) = &target.websocket_debugger_url { log::info!("Applying fingerprint to target via WebSocket: {}", ws_url); - // Wayfern.setFingerprint expects the fingerprint object directly, NOT wrapped match self .send_cdp_command(ws_url, "Wayfern.setFingerprint", fingerprint_params.clone()) .await @@ -816,23 +784,20 @@ impl WayfernManager { // Geolocation is handled internally by the browser binary. - // Navigate to URL via CDP - fingerprint will be applied at navigation commit time if let Some(url) = url { log::info!("Navigating to URL via CDP: {}", url); if let Some(target) = page_targets.first() { if let Some(ws_url) = &target.websocket_debugger_url { - match self + if let Err(e) = self .send_cdp_command(ws_url, "Page.navigate", json!({ "url": url })) .await { - Ok(_) => log::info!("Successfully navigated to URL: {}", url), - Err(e) => log::error!("Failed to navigate to URL: {e}"), + log::error!("Failed to navigate to URL: {e}"); } } } } - // Clear Playwright's emulation overrides that cause tampering detection for target in &page_targets { if let Some(ws_url) = &target.websocket_debugger_url { let _ = self @@ -862,8 +827,6 @@ impl WayfernManager { profile_path: Some(profile_path.to_string()), url: url.map(|s| s.to_string()), cdp_port: Some(port), - playwright_context: Some(pw_context), - playwright_runtime: Some(pw_runtime), }; let mut inner = self.inner.lock().await; @@ -886,8 +849,6 @@ impl WayfernManager { if let Some(instance) = inner.instances.remove(id) { log::info!("Cleaning up Wayfern instance {}", instance.id); - drop(instance.playwright_context); - drop(instance.playwright_runtime); if let Some(pid) = instance.process_id { #[cfg(unix)] { @@ -911,40 +872,49 @@ impl WayfernManager { Ok(()) } - /// Opens a URL in a new tab for an existing Wayfern instance using CDP. - /// Returns Ok(()) if successful, or an error if the instance is not found or CDP fails. + /// Opens a URL in a new tab for an existing Wayfern instance. pub async fn open_url_in_tab( &self, profile_path: &str, url: &str, ) -> Result<(), Box> { - let instance = self - .find_wayfern_by_profile(profile_path) + let inner = self.inner.lock().await; + let target_path = std::path::Path::new(profile_path) + .canonicalize() + .unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf()); + + let port = inner + .instances + .values() + .find(|i| { + i.profile_path + .as_deref() + .map(|p| { + std::path::Path::new(p) + .canonicalize() + .unwrap_or_else(|_| std::path::Path::new(p).to_path_buf()) + == target_path + }) + .unwrap_or(false) + }) + .and_then(|i| i.cdp_port) + .ok_or("Wayfern instance (with CDP port) not found for profile")?; + drop(inner); + + // Open the URL in a new tab via the CDP HTTP convenience endpoint. + let new_tab_url = format!( + "http://127.0.0.1:{port}/json/new?{}", + urlencoding::encode(url) + ); + let resp = self + .http_client + .put(&new_tab_url) + .send() .await - .ok_or("Wayfern instance not found for profile")?; - - let cdp_port = instance - .cdp_port - .ok_or("No CDP port available for Wayfern instance")?; - - // Get the browser target to create a new tab - let targets = self.get_cdp_targets(cdp_port).await?; - - // Find a page target to get the WebSocket URL (we need any target to send commands) - let page_target = targets - .iter() - .find(|t| t.target_type == "page" && t.websocket_debugger_url.is_some()) - .ok_or("No page target found for CDP")?; - - let ws_url = page_target - .websocket_debugger_url - .as_ref() - .ok_or("No WebSocket URL available")?; - - // Use Target.createTarget to open a new tab with the URL - self - .send_cdp_command(ws_url, "Target.createTarget", json!({ "url": url })) - .await?; + .map_err(|e| format!("Failed to open new tab: {e}"))?; + if !resp.status().is_success() { + return Err(format!("CDP /json/new returned HTTP {}", resp.status()).into()); + } log::info!("Opened URL in new tab via CDP: {}", url); Ok(()) @@ -1042,8 +1012,6 @@ impl WayfernManager { profile_path: Some(found_profile_path.clone()), url: None, cdp_port, - playwright_context: None, - playwright_runtime: None, }, ); diff --git a/src/components/sync-config-dialog.tsx b/src/components/sync-config-dialog.tsx index 281d0ed..3e9883e 100644 --- a/src/components/sync-config-dialog.tsx +++ b/src/components/sync-config-dialog.tsx @@ -1,7 +1,7 @@ "use client"; import { invoke } from "@tauri-apps/api/core"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { LuEye, LuEyeOff } from "react-icons/lu"; import { LoadingButton } from "@/components/loading-button"; @@ -27,32 +27,7 @@ import { useCloudAuth } from "@/hooks/use-cloud-auth"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import type { SyncSettings } from "@/types"; -const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE ?? ""; - -interface TurnstileWindow extends Window { - turnstile?: { - render: ( - container: string | HTMLElement, - options: { - sitekey: string; - callback: (token: string) => void; - "expired-callback": () => void; - "error-callback": () => void; - theme: "light" | "dark" | "auto"; - }, - ) => string; - remove: (widgetId: string) => void; - }; -} - -// RFC 5322 compliant email regex (emailregex.com) -// eslint-disable-next-line no-control-regex -const EMAIL_REGEX = - /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/; - -function isValidEmail(email: string): boolean { - return EMAIL_REGEX.test(email); -} +const DEVICE_LINK_URL = "https://donutbrowser.com/auth/link"; interface SyncConfigDialogProps { isOpen: boolean; @@ -83,23 +58,12 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { user, isLoggedIn, isLoading: isCloudLoading, - requestOtp, - verifyOtp, + exchangeDeviceCode, logout, } = useCloudAuth(); - const [email, setEmail] = useState(""); - const [otpCode, setOtpCode] = useState(""); - const [codeSent, setCodeSent] = useState(false); - const [isSendingCode, setIsSendingCode] = useState(false); + const [linkCode, setLinkCode] = useState(""); const [isVerifying, setIsVerifying] = useState(false); - // Turnstile captcha state - const [captchaToken, setCaptchaToken] = useState(null); - const [isCaptchaLoading, setIsCaptchaLoading] = useState(false); - const captchaContainerRef = useRef(null); - const turnstileWidgetIdRef = useRef(null); - const turnstileScriptLoadedRef = useRef(false); - const [activeTab, setActiveTab] = useState("cloud"); const [, setLiveProxyUsage] = useState(null); @@ -135,131 +99,18 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { } }, [testConnection]); - const removeTurnstileWidget = useCallback(() => { - const win = window as TurnstileWindow; - if (turnstileWidgetIdRef.current && win.turnstile) { - win.turnstile.remove(turnstileWidgetIdRef.current); - turnstileWidgetIdRef.current = null; - } - }, []); - - const renderTurnstile = useCallback(() => { - const win = window as TurnstileWindow; - if (!win.turnstile || !captchaContainerRef.current) return; - - removeTurnstileWidget(); - captchaContainerRef.current.innerHTML = ""; - - const widgetId = win.turnstile.render(captchaContainerRef.current, { - sitekey: TURNSTILE_SITE_KEY, - callback: (token: string) => { - setCaptchaToken(token); - setIsCaptchaLoading(false); - }, - "expired-callback": () => { - setCaptchaToken(null); - }, - "error-callback": () => { - setCaptchaToken(null); - setIsCaptchaLoading(false); - }, - theme: "auto", - }); - turnstileWidgetIdRef.current = widgetId; - }, [removeTurnstileWidget]); - - const loadTurnstileScript = useCallback((): Promise => { - return new Promise((resolve) => { - const win = window as TurnstileWindow; - if (win.turnstile) { - turnstileScriptLoadedRef.current = true; - resolve(); - return; - } - - if (turnstileScriptLoadedRef.current) { - const check = setInterval(() => { - if ((window as TurnstileWindow).turnstile) { - clearInterval(check); - resolve(); - } - }, 100); - return; - } - - const existing = document.querySelector( - 'script[src*="challenges.cloudflare.com/turnstile"]', - ); - if (existing) { - const check = setInterval(() => { - if ((window as TurnstileWindow).turnstile) { - clearInterval(check); - turnstileScriptLoadedRef.current = true; - resolve(); - } - }, 100); - return; - } - - turnstileScriptLoadedRef.current = true; - const script = document.createElement("script"); - script.src = - "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"; - script.async = true; - script.defer = true; - script.onload = () => { - const check = setInterval(() => { - if ((window as TurnstileWindow).turnstile) { - clearInterval(check); - resolve(); - } - }, 100); - }; - document.head.appendChild(script); - }); - }, []); - - useEffect(() => { - const emailValid = isValidEmail(email); - if (emailValid && !codeSent && TURNSTILE_SITE_KEY) { - setIsCaptchaLoading(true); - setCaptchaToken(null); - void loadTurnstileScript().then(() => { - renderTurnstile(); - }); - } else { - removeTurnstileWidget(); - setCaptchaToken(null); - setIsCaptchaLoading(false); - } - }, [ - email, - codeSent, - loadTurnstileScript, - renderTurnstile, - removeTurnstileWidget, - ]); - useEffect(() => { if (isOpen) { setConnectionStatus("unknown"); void loadSettings(); - setCodeSent(false); - setOtpCode(""); - setEmail(""); - setCaptchaToken(null); - setIsCaptchaLoading(false); - removeTurnstileWidget(); + setLinkCode(""); void invoke("cloud_get_proxy_usage") .then(setLiveProxyUsage) .catch(() => { setLiveProxyUsage(null); }); } - return () => { - removeTurnstileWidget(); - }; - }, [isOpen, loadSettings, removeTurnstileWidget]); + }, [isOpen, loadSettings]); // Auto-select the appropriate tab based on connection state useEffect(() => { @@ -345,44 +196,35 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { } }, []); - const handleSendCode = useCallback(async () => { - if (!email || !captchaToken) return; - setIsSendingCode(true); + const handleOpenLogin = useCallback(async () => { try { - await requestOtp(email, captchaToken); - setCodeSent(true); - removeTurnstileWidget(); - setCaptchaToken(null); - showSuccessToast(t("sync.cloud.codeSent")); + await invoke("handle_url_open", { url: DEVICE_LINK_URL }); } catch (error) { - console.error("Failed to send OTP:", error); + console.error("Failed to open login link:", error); showErrorToast(String(error)); - } finally { - setIsSendingCode(false); } - }, [email, captchaToken, requestOtp, removeTurnstileWidget, t]); + }, []); - const handleVerifyOtp = useCallback(async () => { - if (!email || !otpCode) return; + const handleVerifyCode = useCallback(async () => { + const trimmed = linkCode.trim(); + if (!trimmed) return; setIsVerifying(true); try { - await verifyOtp(email, otpCode); + await exchangeDeviceCode(trimmed); showSuccessToast(t("sync.cloud.loginSuccess")); - // Restart sync service with cloud credentials try { await invoke("restart_sync_service"); } catch (e) { console.error("Failed to restart sync service:", e); } - // Auto-close dialog after successful login, signal that login occurred onClose(true); } catch (error) { - console.error("OTP verification failed:", error); + console.error("Device-code exchange failed:", error); showErrorToast(String(error)); } finally { setIsVerifying(false); } - }, [email, otpCode, verifyOtp, t, onClose]); + }, [linkCode, exchangeDeviceCode, t, onClose]); const handleCloudLogout = useCallback(async () => { try { @@ -390,7 +232,6 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { showSuccessToast(t("sync.cloud.logoutSuccess")); setServerUrl(""); setToken(""); - // Restart sync service (will fall back to self-hosted or stop) try { await invoke("restart_sync_service"); } catch (e) { @@ -402,7 +243,6 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { } }, [logout, t]); - // Determine which tabs are available const cloudBlocked = !isLoggedIn && hasConfig; const selfHostedBlocked = isLoggedIn; @@ -414,7 +254,6 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { {t("sync.description")} - {/* If cloud is logged in, don't show tabs at all - just show cloud account */} {isLoggedIn && user ? (
@@ -527,76 +366,46 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
) : (
+

+ {t("sync.cloud.deviceLinkInstructions")} +

+ +
- -
- { - setEmail(e.target.value); - }} - onKeyDown={(e) => { - if (e.key === "Enter" && !codeSent && captchaToken) { - void handleSendCode(); - } - }} - /> - void handleSendCode()} - isLoading={isSendingCode} - disabled={!email || codeSent || !captchaToken} - variant="outline" - > - {t("sync.cloud.sendCode")} - -
- - {!codeSent && isValidEmail(email) && TURNSTILE_SITE_KEY && ( -
- {isCaptchaLoading && ( -
-
- {t("sync.cloud.loadingCaptcha")} -
- )} -
-
- )} + + { + setLinkCode(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && linkCode.trim()) { + void handleVerifyCode(); + } + }} + autoComplete="off" + spellCheck={false} + /> + void handleVerifyCode()} + isLoading={isVerifying} + disabled={!linkCode.trim()} + className="w-full" + > + {isVerifying + ? t("sync.cloud.loggingIn") + : t("sync.cloud.verifyAndLogin")} +
- - {codeSent && ( -
- - { - setOtpCode(e.target.value); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - void handleVerifyOtp(); - } - }} - /> - void handleVerifyOtp()} - isLoading={isVerifying} - disabled={!otpCode} - className="w-full" - > - {isVerifying - ? t("sync.cloud.loggingIn") - : t("sync.cloud.verifyAndLogin")} - -
- )}
)} diff --git a/src/hooks/use-cloud-auth.ts b/src/hooks/use-cloud-auth.ts index d77f7eb..3c93be4 100644 --- a/src/hooks/use-cloud-auth.ts +++ b/src/hooks/use-cloud-auth.ts @@ -7,8 +7,7 @@ interface UseCloudAuthReturn { user: CloudUser | null; isLoggedIn: boolean; isLoading: boolean; - requestOtp: (email: string, captchaToken: string) => Promise; - verifyOtp: (email: string, code: string) => Promise; + exchangeDeviceCode: (code: string) => Promise; logout: () => Promise; refreshProfile: () => Promise; } @@ -50,17 +49,9 @@ export function useCloudAuth(): UseCloudAuthReturn { }; }, [loadUser]); - const requestOtp = useCallback( - (email: string, captchaToken: string): Promise => { - return invoke("cloud_request_otp", { email, captchaToken }); - }, - [], - ); - - const verifyOtp = useCallback( - async (email: string, code: string): Promise => { - const state = await invoke("cloud_verify_otp", { - email, + const exchangeDeviceCode = useCallback( + async (code: string): Promise => { + const state = await invoke("cloud_exchange_device_code", { code, }); setAuthState(state); @@ -88,8 +79,7 @@ export function useCloudAuth(): UseCloudAuthReturn { user: authState?.user ?? null, isLoggedIn: authState !== null, isLoading, - requestOtp, - verifyOtp, + exchangeDeviceCode, logout, refreshProfile, }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4fd38d2..2dd82a7 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -382,11 +382,10 @@ "tabLabel": "Cloud", "selfHostedTabLabel": "Self-Hosted", "email": "Email", - "emailPlaceholder": "you@example.com", - "sendCode": "Send Code", - "codeSent": "Code sent! Check your email.", - "verificationCode": "Verification Code", - "codePlaceholder": "Enter 6-digit code", + "deviceLinkInstructions": "Click \"Login\" to open the login page in your browser. Once signed in there, copy the code shown and paste it below.", + "openLogin": "Login", + "linkCodeLabel": "Login code", + "linkCodePlaceholder": "Paste the code from the website", "verifyAndLogin": "Verify & Log In", "loggingIn": "Logging in...", "connected": "Connected", @@ -397,8 +396,7 @@ "logout": "Log Out", "logoutConfirm": "Are you sure you want to log out? Cloud sync will stop.", "loginSuccess": "Successfully logged in!", - "logoutSuccess": "Successfully logged out.", - "loadingCaptcha": "Loading captcha..." + "logoutSuccess": "Successfully logged out." }, "team": { "title": "Team", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 01a51ae..bfa3a25 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -382,11 +382,10 @@ "tabLabel": "Nube", "selfHostedTabLabel": "Autoalojado", "email": "Correo electrónico", - "emailPlaceholder": "tu@ejemplo.com", - "sendCode": "Enviar Código", - "codeSent": "¡Código enviado! Revisa tu correo electrónico.", - "verificationCode": "Código de Verificación", - "codePlaceholder": "Ingresa el código de 6 dígitos", + "deviceLinkInstructions": "Haz clic en \"Iniciar sesión\" para abrir la página de inicio de sesión en tu navegador. Después de iniciar sesión allí, copia el código que se muestra y pégalo abajo.", + "openLogin": "Iniciar sesión", + "linkCodeLabel": "Código de inicio de sesión", + "linkCodePlaceholder": "Pega el código del sitio web", "verifyAndLogin": "Verificar e Iniciar Sesión", "loggingIn": "Iniciando sesión...", "connected": "Conectado", @@ -397,8 +396,7 @@ "logout": "Cerrar Sesión", "logoutConfirm": "¿Estás seguro de que deseas cerrar sesión? La sincronización en la nube se detendrá.", "loginSuccess": "¡Sesión iniciada exitosamente!", - "logoutSuccess": "Sesión cerrada exitosamente.", - "loadingCaptcha": "Cargando captcha..." + "logoutSuccess": "Sesión cerrada exitosamente." }, "team": { "title": "Equipo", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 77ab7f3..fc42e18 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -382,11 +382,10 @@ "tabLabel": "Cloud", "selfHostedTabLabel": "Auto-hébergé", "email": "E-mail", - "emailPlaceholder": "vous@exemple.com", - "sendCode": "Envoyer le Code", - "codeSent": "Code envoyé ! Vérifiez votre e-mail.", - "verificationCode": "Code de Vérification", - "codePlaceholder": "Entrez le code à 6 chiffres", + "deviceLinkInstructions": "Cliquez sur « Se connecter » pour ouvrir la page de connexion dans votre navigateur. Une fois connecté, copiez le code affiché et collez-le ci-dessous.", + "openLogin": "Se connecter", + "linkCodeLabel": "Code de connexion", + "linkCodePlaceholder": "Collez le code du site web", "verifyAndLogin": "Vérifier et Se Connecter", "loggingIn": "Connexion en cours...", "connected": "Connecté", @@ -397,8 +396,7 @@ "logout": "Se Déconnecter", "logoutConfirm": "Êtes-vous sûr de vouloir vous déconnecter ? La synchronisation cloud sera arrêtée.", "loginSuccess": "Connexion réussie !", - "logoutSuccess": "Déconnexion réussie.", - "loadingCaptcha": "Chargement du captcha..." + "logoutSuccess": "Déconnexion réussie." }, "team": { "title": "Équipe", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 3e43148..2a67610 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -382,11 +382,10 @@ "tabLabel": "クラウド", "selfHostedTabLabel": "セルフホスト", "email": "メールアドレス", - "emailPlaceholder": "you@example.com", - "sendCode": "コードを送信", - "codeSent": "コードを送信しました!メールを確認してください。", - "verificationCode": "認証コード", - "codePlaceholder": "6桁のコードを入力", + "deviceLinkInstructions": "「ログイン」をクリックすると、ブラウザでログインページが開きます。サインイン後、表示されたコードをコピーして下に貼り付けてください。", + "openLogin": "ログイン", + "linkCodeLabel": "ログインコード", + "linkCodePlaceholder": "ウェブサイトのコードを貼り付け", "verifyAndLogin": "認証してログイン", "loggingIn": "ログイン中...", "connected": "接続済み", @@ -397,8 +396,7 @@ "logout": "ログアウト", "logoutConfirm": "ログアウトしてもよろしいですか?クラウド同期が停止します。", "loginSuccess": "ログインに成功しました!", - "logoutSuccess": "ログアウトしました。", - "loadingCaptcha": "キャプチャを読み込み中..." + "logoutSuccess": "ログアウトしました。" }, "team": { "title": "チーム", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index f76641a..7dba6ee 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -382,11 +382,10 @@ "tabLabel": "Nuvem", "selfHostedTabLabel": "Auto-hospedado", "email": "E-mail", - "emailPlaceholder": "voce@exemplo.com", - "sendCode": "Enviar Código", - "codeSent": "Código enviado! Verifique seu e-mail.", - "verificationCode": "Código de Verificação", - "codePlaceholder": "Digite o código de 6 dígitos", + "deviceLinkInstructions": "Clique em \"Entrar\" para abrir a página de login no seu navegador. Após entrar lá, copie o código exibido e cole-o abaixo.", + "openLogin": "Entrar", + "linkCodeLabel": "Código de login", + "linkCodePlaceholder": "Cole o código do site", "verifyAndLogin": "Verificar e Entrar", "loggingIn": "Entrando...", "connected": "Conectado", @@ -397,8 +396,7 @@ "logout": "Sair", "logoutConfirm": "Tem certeza de que deseja sair? A sincronização na nuvem será interrompida.", "loginSuccess": "Login realizado com sucesso!", - "logoutSuccess": "Logout realizado com sucesso.", - "loadingCaptcha": "Carregando captcha..." + "logoutSuccess": "Logout realizado com sucesso." }, "team": { "title": "Equipe", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 4f23b7a..ee17199 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -382,11 +382,10 @@ "tabLabel": "Облако", "selfHostedTabLabel": "Свой сервер", "email": "Электронная почта", - "emailPlaceholder": "вы@пример.com", - "sendCode": "Отправить код", - "codeSent": "Код отправлен! Проверьте вашу почту.", - "verificationCode": "Код подтверждения", - "codePlaceholder": "Введите 6-значный код", + "deviceLinkInstructions": "Нажмите «Войти», чтобы открыть страницу входа в браузере. После входа скопируйте показанный код и вставьте его ниже.", + "openLogin": "Войти", + "linkCodeLabel": "Код входа", + "linkCodePlaceholder": "Вставьте код с сайта", "verifyAndLogin": "Подтвердить и Войти", "loggingIn": "Вход...", "connected": "Подключено", @@ -397,8 +396,7 @@ "logout": "Выйти", "logoutConfirm": "Вы уверены, что хотите выйти? Облачная синхронизация будет остановлена.", "loginSuccess": "Вход выполнен успешно!", - "logoutSuccess": "Выход выполнен успешно.", - "loadingCaptcha": "Загрузка капчи..." + "logoutSuccess": "Выход выполнен успешно." }, "team": { "title": "Команда", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 1156d67..7531af7 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -382,11 +382,10 @@ "tabLabel": "云端", "selfHostedTabLabel": "自托管", "email": "电子邮件", - "emailPlaceholder": "you@example.com", - "sendCode": "发送验证码", - "codeSent": "验证码已发送!请检查您的邮箱。", - "verificationCode": "验证码", - "codePlaceholder": "输入6位验证码", + "deviceLinkInstructions": "点击\"登录\"以在浏览器中打开登录页面。登录后复制显示的代码并粘贴到下方。", + "openLogin": "登录", + "linkCodeLabel": "登录代码", + "linkCodePlaceholder": "粘贴网站的代码", "verifyAndLogin": "验证并登录", "loggingIn": "登录中...", "connected": "已连接", @@ -397,8 +396,7 @@ "logout": "退出登录", "logoutConfirm": "您确定要退出登录吗?云同步将会停止。", "loginSuccess": "登录成功!", - "logoutSuccess": "已成功退出登录。", - "loadingCaptcha": "正在加载验证码..." + "logoutSuccess": "已成功退出登录。" }, "team": { "title": "团队",