mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-04-29 07:16:11 +02:00
refactor: auth and wayfern
This commit is contained in:
Generated
+1
-1
@@ -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",
|
||||
|
||||
+69
-32
@@ -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<String, String> {
|
||||
let url = format!("{CLOUD_API_URL}/api/auth/otp/request");
|
||||
let response = self
|
||||
pub async fn exchange_device_code(&self, code: &str) -> Result<CloudAuthState, String> {
|
||||
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<CloudAuthState, String> {
|
||||
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<String> {
|
||||
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<String, String> {
|
||||
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<CloudAuthState, String> {
|
||||
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!(
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
url: Option<String>,
|
||||
cdp_port: Option<u16>,
|
||||
playwright_context: Option<playwright::api::BrowserContext>,
|
||||
playwright_runtime: Option<Playwright>,
|
||||
}
|
||||
|
||||
struct WayfernManagerInner {
|
||||
@@ -94,16 +91,6 @@ impl WayfernManager {
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_playwright(
|
||||
&self,
|
||||
) -> Result<Playwright, Box<dyn std::error::Error + Send + Sync>> {
|
||||
Playwright::initialize()
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
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<dyn std::error::Error + Send + Sync> {
|
||||
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<dyn std::error::Error + Send + Sync> {
|
||||
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<u32> = None;
|
||||
for (pid, process) in system.processes() {
|
||||
let cmd_str = process
|
||||
.cmd()
|
||||
.iter()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.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<dyn std::error::Error + Send + Sync>> {
|
||||
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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [isCaptchaLoading, setIsCaptchaLoading] = useState(false);
|
||||
const captchaContainerRef = useRef<HTMLDivElement>(null);
|
||||
const turnstileWidgetIdRef = useRef<string | null>(null);
|
||||
const turnstileScriptLoadedRef = useRef(false);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>("cloud");
|
||||
const [, setLiveProxyUsage] = useState<ProxyUsage | null>(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<void> => {
|
||||
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<ProxyUsage | null>("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) {
|
||||
<DialogDescription>{t("sync.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* If cloud is logged in, don't show tabs at all - just show cloud account */}
|
||||
{isLoggedIn && user ? (
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="flex gap-2 items-center text-sm">
|
||||
@@ -527,76 +366,46 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sync.cloud.deviceLinkInstructions")}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => void handleOpenLogin()}
|
||||
className="w-full"
|
||||
>
|
||||
{t("sync.cloud.openLogin")}
|
||||
</Button>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cloud-email">{t("sync.cloud.email")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="cloud-email"
|
||||
type="email"
|
||||
placeholder={t("sync.cloud.emailPlaceholder")}
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !codeSent && captchaToken) {
|
||||
void handleSendCode();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<LoadingButton
|
||||
onClick={() => void handleSendCode()}
|
||||
isLoading={isSendingCode}
|
||||
disabled={!email || codeSent || !captchaToken}
|
||||
variant="outline"
|
||||
>
|
||||
{t("sync.cloud.sendCode")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
|
||||
{!codeSent && isValidEmail(email) && TURNSTILE_SITE_KEY && (
|
||||
<div className="mt-2">
|
||||
{isCaptchaLoading && (
|
||||
<div className="flex items-center gap-2 py-3 text-sm text-muted-foreground">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-current animate-spin border-t-transparent" />
|
||||
{t("sync.cloud.loadingCaptcha")}
|
||||
</div>
|
||||
)}
|
||||
<div ref={captchaContainerRef} />
|
||||
</div>
|
||||
)}
|
||||
<Label htmlFor="cloud-link-code">
|
||||
{t("sync.cloud.linkCodeLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="cloud-link-code"
|
||||
placeholder={t("sync.cloud.linkCodePlaceholder")}
|
||||
value={linkCode}
|
||||
onChange={(e) => {
|
||||
setLinkCode(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && linkCode.trim()) {
|
||||
void handleVerifyCode();
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<LoadingButton
|
||||
onClick={() => void handleVerifyCode()}
|
||||
isLoading={isVerifying}
|
||||
disabled={!linkCode.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{isVerifying
|
||||
? t("sync.cloud.loggingIn")
|
||||
: t("sync.cloud.verifyAndLogin")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
|
||||
{codeSent && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cloud-otp">
|
||||
{t("sync.cloud.verificationCode")}
|
||||
</Label>
|
||||
<Input
|
||||
id="cloud-otp"
|
||||
placeholder={t("sync.cloud.codePlaceholder")}
|
||||
value={otpCode}
|
||||
onChange={(e) => {
|
||||
setOtpCode(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
void handleVerifyOtp();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<LoadingButton
|
||||
onClick={() => void handleVerifyOtp()}
|
||||
isLoading={isVerifying}
|
||||
disabled={!otpCode}
|
||||
className="w-full"
|
||||
>
|
||||
{isVerifying
|
||||
? t("sync.cloud.loggingIn")
|
||||
: t("sync.cloud.verifyAndLogin")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
@@ -7,8 +7,7 @@ interface UseCloudAuthReturn {
|
||||
user: CloudUser | null;
|
||||
isLoggedIn: boolean;
|
||||
isLoading: boolean;
|
||||
requestOtp: (email: string, captchaToken: string) => Promise<string>;
|
||||
verifyOtp: (email: string, code: string) => Promise<CloudAuthState>;
|
||||
exchangeDeviceCode: (code: string) => Promise<CloudAuthState>;
|
||||
logout: () => Promise<void>;
|
||||
refreshProfile: () => Promise<CloudUser>;
|
||||
}
|
||||
@@ -50,17 +49,9 @@ export function useCloudAuth(): UseCloudAuthReturn {
|
||||
};
|
||||
}, [loadUser]);
|
||||
|
||||
const requestOtp = useCallback(
|
||||
(email: string, captchaToken: string): Promise<string> => {
|
||||
return invoke<string>("cloud_request_otp", { email, captchaToken });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const verifyOtp = useCallback(
|
||||
async (email: string, code: string): Promise<CloudAuthState> => {
|
||||
const state = await invoke<CloudAuthState>("cloud_verify_otp", {
|
||||
email,
|
||||
const exchangeDeviceCode = useCallback(
|
||||
async (code: string): Promise<CloudAuthState> => {
|
||||
const state = await invoke<CloudAuthState>("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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "チーム",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Команда",
|
||||
|
||||
@@ -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": "团队",
|
||||
|
||||
Reference in New Issue
Block a user