feat: better proxy management

This commit is contained in:
zhom
2026-02-16 10:03:27 +04:00
parent 777be9b9dc
commit bb8356eeef
19 changed files with 1066 additions and 324 deletions
+1 -1
View File
@@ -628,7 +628,7 @@ export class SyncService implements OnModuleInit {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"x-internal-key": this.backendInternalKey!, "x-internal-key": this.backendInternalKey ?? "undefined",
}, },
body: JSON.stringify({ userId, count }), body: JSON.stringify({ userId, count }),
}); });
+4 -4
View File
@@ -334,7 +334,7 @@ pub struct BrowserRelease {
pub is_prerelease: bool, pub is_prerelease: bool,
} }
/// Wayfern version info from https://download.wayfern.com/version.json /// Wayfern version info from https://donutbrowser.com/wayfern.json
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct WayfernVersionInfo { pub struct WayfernVersionInfo {
pub version: String, pub version: String,
@@ -1115,7 +1115,7 @@ impl ApiClient {
Ok(()) Ok(())
} }
/// Fetch Wayfern version info from https://download.wayfern.com/version.json /// Fetch Wayfern version info from https://donutbrowser.com/wayfern.json
pub async fn fetch_wayfern_version_with_caching( pub async fn fetch_wayfern_version_with_caching(
&self, &self,
no_caching: bool, no_caching: bool,
@@ -1128,8 +1128,8 @@ impl ApiClient {
} }
} }
log::info!("Fetching Wayfern version from https://download.wayfern.com/version.json"); log::info!("Fetching Wayfern version from https://donutbrowser.com/wayfern.json");
let url = "https://download.wayfern.com/version.json"; let url = "https://donutbrowser.com/wayfern.json";
let response = self let response = self
.client .client
+140 -1
View File
@@ -73,6 +73,12 @@ struct SyncTokenResponse {
sync_token: String, sync_token: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocationItem {
pub code: String,
pub name: String,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
struct CloudProxyConfigResponse { struct CloudProxyConfigResponse {
@@ -650,7 +656,11 @@ impl CloudAuthManager {
password: config.password, password: config.password,
}; };
match PROXY_MANAGER.upsert_cloud_proxy(settings) { match PROXY_MANAGER.upsert_cloud_proxy(settings) {
Ok(_) => log::debug!("Cloud proxy synced successfully"), Ok(_) => {
log::debug!("Cloud proxy synced successfully");
// Propagate credential changes to derived location proxies
PROXY_MANAGER.update_cloud_derived_proxies();
}
Err(e) => log::warn!("Failed to upsert cloud proxy: {e}"), Err(e) => log::warn!("Failed to upsert cloud proxy: {e}"),
} }
} }
@@ -663,6 +673,106 @@ impl CloudAuthManager {
} }
} }
/// Fetch country list from the cloud backend
pub async fn fetch_countries(&self) -> Result<Vec<LocationItem>, String> {
self
.api_call_with_retry(|access_token| {
let url = format!("{CLOUD_API_URL}/api/proxy/locations/countries");
let client = self.client.clone();
async move {
let response = client
.get(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to fetch countries: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Countries fetch failed ({status}): {body}"));
}
response
.json::<Vec<LocationItem>>()
.await
.map_err(|e| format!("Failed to parse countries: {e}"))
}
})
.await
}
/// Fetch state list for a country from the cloud backend
pub async fn fetch_states(&self, country: &str) -> Result<Vec<LocationItem>, String> {
let country = country.to_string();
self
.api_call_with_retry(move |access_token| {
let url = format!(
"{CLOUD_API_URL}/api/proxy/locations/states?country={}",
country
);
let client = reqwest::Client::new();
async move {
let response = client
.get(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to fetch states: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("States fetch failed ({status}): {body}"));
}
response
.json::<Vec<LocationItem>>()
.await
.map_err(|e| format!("Failed to parse states: {e}"))
}
})
.await
}
/// Fetch city list for a country+state from the cloud backend
pub async fn fetch_cities(
&self,
country: &str,
state: &str,
) -> Result<Vec<LocationItem>, String> {
let country = country.to_string();
let state = state.to_string();
self
.api_call_with_retry(move |access_token| {
let url = format!(
"{CLOUD_API_URL}/api/proxy/locations/cities?country={}&state={}",
country, state
);
let client = reqwest::Client::new();
async move {
let response = client
.get(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to fetch cities: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Cities fetch failed ({status}): {body}"));
}
response
.json::<Vec<LocationItem>>()
.await
.map_err(|e| format!("Failed to parse cities: {e}"))
}
})
.await
}
/// Background loop that refreshes the sync token periodically /// Background loop that refreshes the sync token periodically
pub async fn start_sync_token_refresh_loop(app_handle: tauri::AppHandle) { pub async fn start_sync_token_refresh_loop(app_handle: tauri::AppHandle) {
loop { loop {
@@ -755,6 +865,35 @@ pub async fn cloud_has_active_subscription() -> Result<bool, String> {
Ok(CLOUD_AUTH.has_active_paid_subscription().await) Ok(CLOUD_AUTH.has_active_paid_subscription().await)
} }
#[tauri::command]
pub async fn cloud_get_countries() -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_countries().await
}
#[tauri::command]
pub async fn cloud_get_states(country: String) -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_states(&country).await
}
#[tauri::command]
pub async fn cloud_get_cities(country: String, state: String) -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_cities(&country, &state).await
}
#[tauri::command]
pub async fn create_cloud_location_proxy(
name: String,
country: String,
state: Option<String>,
city: Option<String>,
) -> Result<crate::proxy_manager::StoredProxy, String> {
// If no cloud proxy exists yet, attempt to sync it first
if !PROXY_MANAGER.has_cloud_proxy() {
CLOUD_AUTH.sync_cloud_proxy().await;
}
PROXY_MANAGER.create_cloud_location_proxy(name, country, state, city)
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct CloudProxyUsage { pub struct CloudProxyUsage {
pub used_mb: i64, pub used_mb: i64,
+4
View File
@@ -1221,6 +1221,10 @@ pub fn run() {
cloud_auth::cloud_refresh_profile, cloud_auth::cloud_refresh_profile,
cloud_auth::cloud_logout, cloud_auth::cloud_logout,
cloud_auth::cloud_get_proxy_usage, cloud_auth::cloud_get_proxy_usage,
cloud_auth::cloud_get_countries,
cloud_auth::cloud_get_states,
cloud_auth::cloud_get_cities,
cloud_auth::create_cloud_location_proxy,
cloud_auth::restart_sync_service cloud_auth::restart_sync_service
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
+173 -2
View File
@@ -105,6 +105,14 @@ pub struct StoredProxy {
pub last_sync: Option<u64>, pub last_sync: Option<u64>,
#[serde(default)] #[serde(default)]
pub is_cloud_managed: bool, pub is_cloud_managed: bool,
#[serde(default)]
pub is_cloud_derived: bool,
#[serde(default)]
pub geo_country: Option<String>,
#[serde(default)]
pub geo_state: Option<String>,
#[serde(default)]
pub geo_city: Option<String>,
} }
impl StoredProxy { impl StoredProxy {
@@ -116,6 +124,10 @@ impl StoredProxy {
sync_enabled: false, sync_enabled: false,
last_sync: None, last_sync: None,
is_cloud_managed: false, is_cloud_managed: false,
is_cloud_derived: false,
geo_country: None,
geo_state: None,
geo_city: None,
} }
} }
@@ -400,6 +412,12 @@ impl ProxyManager {
Ok(stored_proxy) Ok(stored_proxy)
} }
// Check if a cloud-managed proxy exists
pub fn has_cloud_proxy(&self) -> bool {
let stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies.contains_key(CLOUD_PROXY_ID)
}
// Upsert the cloud-managed proxy (create or update) // Upsert the cloud-managed proxy (create or update)
pub fn upsert_cloud_proxy(&self, proxy_settings: ProxySettings) -> Result<StoredProxy, String> { pub fn upsert_cloud_proxy(&self, proxy_settings: ProxySettings) -> Result<StoredProxy, String> {
let mut stored_proxies = self.stored_proxies.lock().unwrap(); let mut stored_proxies = self.stored_proxies.lock().unwrap();
@@ -424,6 +442,10 @@ impl ProxyManager {
sync_enabled: false, sync_enabled: false,
last_sync: None, last_sync: None,
is_cloud_managed: true, is_cloud_managed: true,
is_cloud_derived: false,
geo_country: None,
geo_state: None,
geo_city: None,
}; };
stored_proxies.insert(CLOUD_PROXY_ID.to_string(), cloud_proxy.clone()); stored_proxies.insert(CLOUD_PROXY_ID.to_string(), cloud_proxy.clone());
drop(stored_proxies); drop(stored_proxies);
@@ -455,6 +477,155 @@ impl ProxyManager {
} }
} }
// Build a geo-targeted username from base username and location parts
fn build_geo_username(
base_username: &str,
country: &str,
state: &Option<String>,
city: &Option<String>,
) -> String {
let mut username = format!("{}-country-{}", base_username, country);
if let Some(state) = state {
username = format!("{}-state-{}", username, state);
}
if let Some(city) = city {
username = format!("{}-city-{}", username, city);
}
username
}
// Create a cloud-derived location proxy from the base cloud proxy credentials
pub fn create_cloud_location_proxy(
&self,
name: String,
country: String,
state: Option<String>,
city: Option<String>,
) -> Result<StoredProxy, String> {
// Get base cloud proxy credentials
let base_proxy = {
let stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies
.get(CLOUD_PROXY_ID)
.cloned()
.ok_or_else(|| "No cloud proxy available. Please log in first.".to_string())?
};
let base_username = base_proxy
.proxy_settings
.username
.as_ref()
.ok_or_else(|| "Cloud proxy has no username".to_string())?;
let geo_username = Self::build_geo_username(base_username, &country, &state, &city);
let proxy_settings = ProxySettings {
proxy_type: base_proxy.proxy_settings.proxy_type.clone(),
host: base_proxy.proxy_settings.host.clone(),
port: base_proxy.proxy_settings.port,
username: Some(geo_username),
password: base_proxy.proxy_settings.password.clone(),
};
// Check if name already exists
{
let stored_proxies = self.stored_proxies.lock().unwrap();
if stored_proxies.values().any(|p| p.name == name) {
return Err(format!("Proxy with name '{}' already exists", name));
}
}
let stored_proxy = StoredProxy {
id: uuid::Uuid::new_v4().to_string(),
name,
proxy_settings,
sync_enabled: false,
last_sync: None,
is_cloud_managed: false,
is_cloud_derived: true,
geo_country: Some(country),
geo_state: state,
geo_city: city,
};
{
let mut stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies.insert(stored_proxy.id.clone(), stored_proxy.clone());
}
if let Err(e) = self.save_proxy(&stored_proxy) {
log::warn!("Failed to save location proxy: {e}");
}
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
}
Ok(stored_proxy)
}
// Update all cloud-derived proxies when base cloud proxy credentials change
pub fn update_cloud_derived_proxies(&self) {
let base_proxy = {
let stored_proxies = self.stored_proxies.lock().unwrap();
match stored_proxies.get(CLOUD_PROXY_ID) {
Some(p) => p.clone(),
None => return, // No cloud proxy, nothing to update
}
};
let base_username = match &base_proxy.proxy_settings.username {
Some(u) => u.clone(),
None => return,
};
let mut updated = false;
let mut stored_proxies = self.stored_proxies.lock().unwrap();
for proxy in stored_proxies.values_mut() {
if !proxy.is_cloud_derived {
continue;
}
let country = match &proxy.geo_country {
Some(c) => c.clone(),
None => continue,
};
let geo_username =
Self::build_geo_username(&base_username, &country, &proxy.geo_state, &proxy.geo_city);
proxy.proxy_settings.username = Some(geo_username);
proxy.proxy_settings.password = base_proxy.proxy_settings.password.clone();
proxy.proxy_settings.host = base_proxy.proxy_settings.host.clone();
proxy.proxy_settings.port = base_proxy.proxy_settings.port;
updated = true;
}
if updated {
// Save all updated proxies
let proxies_to_save: Vec<StoredProxy> = stored_proxies
.values()
.filter(|p| p.is_cloud_derived)
.cloned()
.collect();
drop(stored_proxies);
for proxy in &proxies_to_save {
if let Err(e) = self.save_proxy(proxy) {
log::warn!("Failed to save updated derived proxy {}: {e}", proxy.id);
}
}
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
}
log::debug!("Updated {} cloud-derived proxies", proxies_to_save.len());
}
}
// Get all stored proxies // Get all stored proxies
pub fn get_stored_proxies(&self) -> Vec<StoredProxy> { pub fn get_stored_proxies(&self) -> Vec<StoredProxy> {
let stored_proxies = self.stored_proxies.lock().unwrap(); let stored_proxies = self.stored_proxies.lock().unwrap();
@@ -678,7 +849,7 @@ impl ProxyManager {
let stored_proxies = self.stored_proxies.lock().unwrap(); let stored_proxies = self.stored_proxies.lock().unwrap();
let proxies: Vec<ExportedProxy> = stored_proxies let proxies: Vec<ExportedProxy> = stored_proxies
.values() .values()
.filter(|p| !p.is_cloud_managed) .filter(|p| !p.is_cloud_managed && !p.is_cloud_derived)
.map(|p| ExportedProxy { .map(|p| ExportedProxy {
name: p.name.clone(), name: p.name.clone(),
proxy_type: p.proxy_settings.proxy_type.clone(), proxy_type: p.proxy_settings.proxy_type.clone(),
@@ -704,7 +875,7 @@ impl ProxyManager {
let stored_proxies = self.stored_proxies.lock().unwrap(); let stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies stored_proxies
.values() .values()
.filter(|p| !p.is_cloud_managed) .filter(|p| !p.is_cloud_managed && !p.is_cloud_derived)
.map(|p| Self::build_proxy_url(&p.proxy_settings)) .map(|p| Self::build_proxy_url(&p.proxy_settings))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n") .join("\n")
+65 -46
View File
@@ -1050,34 +1050,41 @@ pub async fn set_profile_sync_enabled(
// If enabling, first check that sync settings are configured // If enabling, first check that sync settings are configured
if enabled { if enabled {
let manager = SettingsManager::instance(); // Cloud auth provides sync settings dynamically — skip local checks
let settings = manager let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if settings.sync_server_url.is_none() { if !cloud_logged_in {
let _ = events::emit( let manager = SettingsManager::instance();
"profile-sync-status", let settings = manager
serde_json::json!({ .load_settings()
"profile_id": profile_id, .map_err(|e| format!("Failed to load settings: {e}"))?;
"status": "error",
"error": "Sync server not configured. Please configure sync settings first."
}),
);
return Err("Sync server not configured. Please configure sync settings first.".to_string());
}
let token = manager.get_sync_token(&app_handle).await.ok().flatten(); if settings.sync_server_url.is_none() {
if token.is_none() { let _ = events::emit(
let _ = events::emit( "profile-sync-status",
"profile-sync-status", serde_json::json!({
serde_json::json!({ "profile_id": profile_id,
"profile_id": profile_id, "status": "error",
"status": "error", "error": "Sync server not configured. Please configure sync settings first."
"error": "Sync token not configured. Please configure sync settings first." }),
}), );
); return Err(
return Err("Sync token not configured. Please configure sync settings first.".to_string()); "Sync server not configured. Please configure sync settings first.".to_string(),
);
}
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
if token.is_none() {
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"status": "error",
"error": "Sync token not configured. Please configure sync settings first."
}),
);
return Err("Sync token not configured. Please configure sync settings first.".to_string());
}
} }
} }
@@ -1240,18 +1247,24 @@ pub async fn set_proxy_sync_enabled(
// If enabling, check that sync settings are configured // If enabling, check that sync settings are configured
if enabled { if enabled {
let manager = SettingsManager::instance(); let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if settings.sync_server_url.is_none() { if !cloud_logged_in {
return Err("Sync server not configured. Please configure sync settings first.".to_string()); let manager = SettingsManager::instance();
} let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
let token = manager.get_sync_token(&app_handle).await.ok().flatten(); if settings.sync_server_url.is_none() {
if token.is_none() { return Err(
return Err("Sync token not configured. Please configure sync settings first.".to_string()); "Sync server not configured. Please configure sync settings first.".to_string(),
);
}
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
if token.is_none() {
return Err("Sync token not configured. Please configure sync settings first.".to_string());
}
} }
} }
@@ -1318,18 +1331,24 @@ pub async fn set_group_sync_enabled(
// If enabling, check that sync settings are configured // If enabling, check that sync settings are configured
if enabled { if enabled {
let manager = SettingsManager::instance(); let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if settings.sync_server_url.is_none() { if !cloud_logged_in {
return Err("Sync server not configured. Please configure sync settings first.".to_string()); let manager = SettingsManager::instance();
} let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
let token = manager.get_sync_token(&app_handle).await.ok().flatten(); if settings.sync_server_url.is_none() {
if token.is_none() { return Err(
return Err("Sync token not configured. Please configure sync settings first.".to_string()); "Sync server not configured. Please configure sync settings first.".to_string(),
);
}
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
if token.is_none() {
return Err("Sync token not configured. Please configure sync settings first.".to_string());
}
} }
} }
+1
View File
@@ -919,6 +919,7 @@ export default function Home() {
onBulkCopyCookies={handleBulkCopyCookies} onBulkCopyCookies={handleBulkCopyCookies}
onOpenProfileSyncDialog={handleOpenProfileSyncDialog} onOpenProfileSyncDialog={handleOpenProfileSyncDialog}
onToggleProfileSync={handleToggleProfileSync} onToggleProfileSync={handleToggleProfileSync}
crossOsUnlocked={crossOsUnlocked}
/> />
</div> </div>
</main> </main>
+217
View File
@@ -0,0 +1,217 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { LocationItem } from "@/types";
import { RippleButton } from "./ui/ripple";
interface LocationProxyDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function LocationProxyDialog({
isOpen,
onClose,
}: LocationProxyDialogProps) {
const [countries, setCountries] = useState<LocationItem[]>([]);
const [states, setStates] = useState<LocationItem[]>([]);
const [cities, setCities] = useState<LocationItem[]>([]);
const [selectedCountry, setSelectedCountry] = useState("");
const [selectedState, setSelectedState] = useState("");
const [selectedCity, setSelectedCity] = useState("");
const [proxyName, setProxyName] = useState("");
const [isLoadingCountries, setIsLoadingCountries] = useState(false);
const [isLoadingStates, setIsLoadingStates] = useState(false);
const [isLoadingCities, setIsLoadingCities] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const handleClose = useCallback(() => {
setSelectedCountry("");
setSelectedState("");
setSelectedCity("");
setProxyName("");
setStates([]);
setCities([]);
onClose();
}, [onClose]);
// Fetch countries on mount
useEffect(() => {
if (!isOpen) return;
setIsLoadingCountries(true);
invoke<LocationItem[]>("cloud_get_countries")
.then((data) => setCountries(data))
.catch((err) => {
console.error("Failed to fetch countries:", err);
toast.error("Failed to load countries");
})
.finally(() => setIsLoadingCountries(false));
}, [isOpen]);
// Fetch states when country changes
useEffect(() => {
if (!selectedCountry) {
setStates([]);
return;
}
setIsLoadingStates(true);
setSelectedState("");
setSelectedCity("");
setCities([]);
invoke<LocationItem[]>("cloud_get_states", { country: selectedCountry })
.then((data) => setStates(data))
.catch((err) => console.error("Failed to fetch states:", err))
.finally(() => setIsLoadingStates(false));
}, [selectedCountry]);
// Fetch cities when state changes
useEffect(() => {
if (!selectedCountry || !selectedState) {
setCities([]);
return;
}
setIsLoadingCities(true);
setSelectedCity("");
invoke<LocationItem[]>("cloud_get_cities", {
country: selectedCountry,
state: selectedState,
})
.then((data) => setCities(data))
.catch((err) => console.error("Failed to fetch cities:", err))
.finally(() => setIsLoadingCities(false));
}, [selectedCountry, selectedState]);
// Auto-generate name from selections
useEffect(() => {
const parts: string[] = [];
const countryItem = countries.find((c) => c.code === selectedCountry);
if (countryItem) parts.push(countryItem.name);
const stateItem = states.find((s) => s.code === selectedState);
if (stateItem) parts.push(stateItem.name);
const cityItem = cities.find((c) => c.code === selectedCity);
if (cityItem) parts.push(cityItem.name);
if (parts.length > 0) {
setProxyName(parts.join(" - "));
}
}, [selectedCountry, selectedState, selectedCity, countries, states, cities]);
const handleCreate = useCallback(async () => {
if (!selectedCountry || !proxyName.trim()) return;
setIsCreating(true);
try {
await invoke("create_cloud_location_proxy", {
name: proxyName.trim(),
country: selectedCountry,
state: selectedState || null,
city: selectedCity || null,
});
toast.success("Location proxy created");
await emit("stored-proxies-changed");
handleClose();
} catch (error) {
console.error("Failed to create location proxy:", error);
toast.error(
typeof error === "string" ? error : "Failed to create location proxy",
);
} finally {
setIsCreating(false);
}
}, [selectedCountry, selectedState, selectedCity, proxyName, handleClose]);
const countryOptions = countries.map((c) => ({
value: c.code,
label: c.name,
}));
const stateOptions = states.map((s) => ({ value: s.code, label: s.name }));
const cityOptions = cities.map((c) => ({ value: c.code, label: c.name }));
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Create Location Proxy</DialogTitle>
<DialogDescription>
Create a geo-targeted proxy from your cloud credentials
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Country (required)</Label>
<Combobox
options={countryOptions}
value={selectedCountry}
onValueChange={setSelectedCountry}
placeholder={isLoadingCountries ? "Loading..." : "Select country"}
searchPlaceholder="Search countries..."
/>
</div>
{selectedCountry && stateOptions.length > 0 && (
<div className="space-y-2">
<Label>State (optional)</Label>
<Combobox
options={stateOptions}
value={selectedState}
onValueChange={setSelectedState}
placeholder={isLoadingStates ? "Loading..." : "Select state"}
searchPlaceholder="Search states..."
/>
</div>
)}
{selectedState && cityOptions.length > 0 && (
<div className="space-y-2">
<Label>City (optional)</Label>
<Combobox
options={cityOptions}
value={selectedCity}
onValueChange={setSelectedCity}
placeholder={isLoadingCities ? "Loading..." : "Select city"}
searchPlaceholder="Search cities..."
/>
</div>
)}
<div className="space-y-2">
<Label>Name</Label>
<Input
value={proxyName}
onChange={(e) => setProxyName(e.target.value)}
placeholder="Proxy name"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<RippleButton
onClick={handleCreate}
disabled={!selectedCountry || !proxyName.trim() || isCreating}
>
{isCreating ? "Creating..." : "Create"}
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+127 -1
View File
@@ -20,6 +20,7 @@ import {
LuChevronDown, LuChevronDown,
LuChevronUp, LuChevronUp,
LuCookie, LuCookie,
LuLock,
LuTrash2, LuTrash2,
LuUsers, LuUsers,
} from "react-icons/lu"; } from "react-icons/lu";
@@ -73,6 +74,7 @@ import { trimName } from "@/lib/name-utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { import type {
BrowserProfile, BrowserProfile,
LocationItem,
ProxyCheckResult, ProxyCheckResult,
StoredProxy, StoredProxy,
TrafficSnapshot, TrafficSnapshot,
@@ -170,6 +172,16 @@ type TableMeta = {
syncStatuses: Record<string, string>; syncStatuses: Record<string, string>;
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void; onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
onToggleProfileSync?: (profile: BrowserProfile) => void; onToggleProfileSync?: (profile: BrowserProfile) => void;
crossOsUnlocked?: boolean;
// Country proxy creation (inline in proxy dropdown)
countries: LocationItem[];
canCreateLocationProxy: boolean;
loadCountries: () => Promise<void>;
handleCreateCountryProxy: (
profileId: string,
country: LocationItem,
) => Promise<void>;
}; };
const TagsCell = React.memo<{ const TagsCell = React.memo<{
@@ -691,6 +703,7 @@ interface ProfilesDataTableProps {
onBulkCopyCookies?: () => void; onBulkCopyCookies?: () => void;
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void; onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
onToggleProfileSync?: (profile: BrowserProfile) => void; onToggleProfileSync?: (profile: BrowserProfile) => void;
crossOsUnlocked?: boolean;
} }
export function ProfilesDataTable({ export function ProfilesDataTable({
@@ -713,6 +726,7 @@ export function ProfilesDataTable({
onBulkCopyCookies, onBulkCopyCookies,
onOpenProfileSyncDialog, onOpenProfileSyncDialog,
onToggleProfileSync, onToggleProfileSync,
crossOsUnlocked = false,
}: ProfilesDataTableProps) { }: ProfilesDataTableProps) {
const { getTableSorting, updateSorting, isLoaded } = useTableSorting(); const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
const [sorting, setSorting] = React.useState<SortingState>([]); const [sorting, setSorting] = React.useState<SortingState>([]);
@@ -822,6 +836,23 @@ export function ProfilesDataTable({
Record<string, string> Record<string, string>
>({}); >({});
// Country proxy creation state (for inline proxy creation in dropdown)
const [countries, setCountries] = React.useState<LocationItem[]>([]);
const [countriesLoaded, setCountriesLoaded] = React.useState(false);
const hasCloudProxy = storedProxies.some((p) => p.is_cloud_managed);
const canCreateLocationProxy = hasCloudProxy || crossOsUnlocked;
const loadCountries = React.useCallback(async () => {
if (countriesLoaded || !canCreateLocationProxy) return;
try {
const data = await invoke<LocationItem[]>("cloud_get_countries");
setCountries(data);
setCountriesLoaded(true);
} catch (e) {
console.error("Failed to load countries:", e);
}
}, [countriesLoaded, canCreateLocationProxy]);
// Load cached check results for proxies // Load cached check results for proxies
React.useEffect(() => { React.useEffect(() => {
const loadCachedResults = async () => { const loadCachedResults = async () => {
@@ -880,6 +911,35 @@ export function ProfilesDataTable({
[], [],
); );
const handleCreateCountryProxy = React.useCallback(
async (profileId: string, country: LocationItem) => {
try {
await invoke("create_cloud_location_proxy", {
name: country.name,
country: country.code,
state: null,
city: null,
});
await emit("stored-proxies-changed");
// Wait briefly for proxy list to update, then find and assign the new proxy
await new Promise((r) => setTimeout(r, 200));
const updatedProxies =
await invoke<StoredProxy[]>("get_stored_proxies");
const newProxy = updatedProxies.find(
(p: StoredProxy) =>
p.is_cloud_derived && p.geo_country === country.code,
);
if (newProxy) {
await handleProxySelection(profileId, newProxy.id);
}
setOpenProxySelectorFor(null);
} catch (error) {
console.error("Failed to create country proxy:", error);
}
},
[handleProxySelection],
);
// Use shared browser state hook // Use shared browser state hook
const browserState = useBrowserState( const browserState = useBrowserState(
profiles, profiles,
@@ -1323,6 +1383,13 @@ export function ProfilesDataTable({
syncStatuses, syncStatuses,
onOpenProfileSyncDialog, onOpenProfileSyncDialog,
onToggleProfileSync, onToggleProfileSync,
crossOsUnlocked,
// Country proxy creation
countries,
canCreateLocationProxy,
loadCountries,
handleCreateCountryProxy,
}), }),
[ [
selectedProfiles, selectedProfiles,
@@ -1364,6 +1431,11 @@ export function ProfilesDataTable({
syncStatuses, syncStatuses,
onOpenProfileSyncDialog, onOpenProfileSyncDialog,
onToggleProfileSync, onToggleProfileSync,
crossOsUnlocked,
countries,
canCreateLocationProxy,
loadCountries,
handleCreateCountryProxy,
], ],
); );
@@ -1835,7 +1907,17 @@ export function ProfilesDataTable({
sideOffset={8} sideOffset={8}
> >
<Command> <Command>
<CommandInput placeholder="Search proxies..." /> <CommandInput
placeholder={
meta.canCreateLocationProxy
? "Search proxies or countries..."
: "Search proxies..."
}
onFocus={() => {
if (meta.canCreateLocationProxy)
void meta.loadCountries();
}}
/>
<CommandList> <CommandList>
<CommandEmpty>No proxies found.</CommandEmpty> <CommandEmpty>No proxies found.</CommandEmpty>
<CommandGroup> <CommandGroup>
@@ -1878,6 +1960,35 @@ export function ProfilesDataTable({
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>
{meta.canCreateLocationProxy &&
meta.countries.length > 0 && (
<CommandGroup heading="Create by country">
{meta.countries
.filter(
(c) =>
!meta.storedProxies.some(
(p) =>
p.is_cloud_derived &&
p.geo_country === c.code,
),
)
.map((country) => (
<CommandItem
key={`country-${country.code}`}
value={`create-${country.name}`}
onSelect={() =>
void meta.handleCreateCountryProxy(
profile.id,
country,
)
}
>
<span className="mr-2 h-4 w-4" />+{" "}
{country.name}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList> </CommandList>
</Command> </Command>
</PopoverContent> </PopoverContent>
@@ -1969,6 +2080,21 @@ export function ProfilesDataTable({
> >
View Network View Network
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
if (meta.crossOsUnlocked) {
meta.onToggleProfileSync?.(profile);
}
}}
disabled={!meta.crossOsUnlocked}
>
<span className="flex items-center gap-2">
{profile.sync_enabled ? "Disable Sync" : "Enable Sync"}
{!meta.crossOsUnlocked && (
<LuLock className="w-3 h-3 text-muted-foreground" />
)}
</span>
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
meta.onAssignProfilesToGroup?.([profile.id]); meta.onAssignProfilesToGroup?.([profile.id]);
+77 -52
View File
@@ -3,7 +3,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { emit, listen } from "@tauri-apps/api/event"; import { emit, listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { GoPlus } from "react-icons/go"; import { GoGlobe, GoPlus } from "react-icons/go";
import { LuDownload, LuPencil, LuTrash2, LuUpload } from "react-icons/lu"; import { LuDownload, LuPencil, LuTrash2, LuUpload } from "react-icons/lu";
import { toast } from "sonner"; import { toast } from "sonner";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"; import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
@@ -38,6 +38,8 @@ import {
import { useProxyEvents } from "@/hooks/use-proxy-events"; import { useProxyEvents } from "@/hooks/use-proxy-events";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { ProxyCheckResult, StoredProxy } from "@/types"; import type { ProxyCheckResult, StoredProxy } from "@/types";
import { FlagIcon } from "./flag-icon";
import { LocationProxyDialog } from "./location-proxy-dialog";
import { ProxyCheckButton } from "./proxy-check-button"; import { ProxyCheckButton } from "./proxy-check-button";
import { RippleButton } from "./ui/ripple"; import { RippleButton } from "./ui/ripple";
@@ -85,6 +87,7 @@ export function ProxyManagementDialog({
const [showProxyForm, setShowProxyForm] = useState(false); const [showProxyForm, setShowProxyForm] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false); const [showImportDialog, setShowImportDialog] = useState(false);
const [showExportDialog, setShowExportDialog] = useState(false); const [showExportDialog, setShowExportDialog] = useState(false);
const [showLocationDialog, setShowLocationDialog] = useState(false);
const [editingProxy, setEditingProxy] = useState<StoredProxy | null>(null); const [editingProxy, setEditingProxy] = useState<StoredProxy | null>(null);
const [proxyToDelete, setProxyToDelete] = useState<StoredProxy | null>(null); const [proxyToDelete, setProxyToDelete] = useState<StoredProxy | null>(null);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
@@ -277,14 +280,27 @@ export function ProxyManagementDialog({
Export Export
</RippleButton> </RippleButton>
</div> </div>
<RippleButton <div className="flex gap-2">
size="sm" {storedProxies.some((p) => p.is_cloud_managed) && (
onClick={handleCreateProxy} <RippleButton
className="flex gap-2 items-center" size="sm"
> variant="outline"
<GoPlus className="w-4 h-4" /> onClick={() => setShowLocationDialog(true)}
Create className="flex gap-2 items-center"
</RippleButton> >
<GoGlobe className="w-4 h-4" />
Location
</RippleButton>
)}
<RippleButton
size="sm"
onClick={handleCreateProxy}
className="flex gap-2 items-center"
>
<GoPlus className="w-4 h-4" />
Create
</RippleButton>
</div>
</div> </div>
{/* Proxies list */} {/* Proxies list */}
@@ -316,12 +332,19 @@ export function ProxyManagementDialog({
proxy, proxy,
proxySyncStatus[proxy.id], proxySyncStatus[proxy.id],
); );
const isDerived = proxy.is_cloud_derived === true;
return ( return (
<TableRow key={proxy.id}> <TableRow key={proxy.id}>
<TableCell className="font-medium"> <TableCell className="font-medium">
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!isCloud && ( {isDerived && proxy.geo_country && (
<FlagIcon
countryCode={proxy.geo_country}
className="shrink-0"
/>
)}
{!isCloud && !isDerived && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div <div
@@ -409,54 +432,52 @@ export function ProxyManagementDialog({
})); }));
}} }}
/> />
{!isCloud && !isDerived && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditProxy(proxy)}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit proxy</p>
</TooltipContent>
</Tooltip>
)}
{!isCloud && ( {!isCloud && (
<> <Tooltip>
<Tooltip> <TooltipTrigger asChild>
<TooltipTrigger asChild> <span>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleEditProxy(proxy)} onClick={() =>
handleDeleteProxy(proxy)
}
disabled={
(proxyUsage[proxy.id] ?? 0) > 0
}
> >
<LuPencil className="w-4 h-4" /> <LuTrash2 className="w-4 h-4" />
</Button> </Button>
</TooltipTrigger> </span>
<TooltipContent> </TooltipTrigger>
<p>Edit proxy</p> <TooltipContent>
</TooltipContent> {(proxyUsage[proxy.id] ?? 0) > 0 ? (
</Tooltip> <p>
<Tooltip> Cannot delete: in use by{" "}
<TooltipTrigger asChild> {proxyUsage[proxy.id]} profile
<span> {proxyUsage[proxy.id] > 1 ? "s" : ""}
<Button </p>
variant="ghost" ) : (
size="sm" <p>Delete proxy</p>
onClick={() => )}
handleDeleteProxy(proxy) </TooltipContent>
} </Tooltip>
disabled={
(proxyUsage[proxy.id] ?? 0) > 0
}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
<p>
Cannot delete: in use by{" "}
{proxyUsage[proxy.id]} profile
{proxyUsage[proxy.id] > 1
? "s"
: ""}
</p>
) : (
<p>Delete proxy</p>
)}
</TooltipContent>
</Tooltip>
</>
)} )}
</div> </div>
</TableCell> </TableCell>
@@ -500,6 +521,10 @@ export function ProxyManagementDialog({
isOpen={showExportDialog} isOpen={showExportDialog}
onClose={() => setShowExportDialog(false)} onClose={() => setShowExportDialog(false)}
/> />
<LocationProxyDialog
isOpen={showLocationDialog}
onClose={() => setShowLocationDialog(false)}
/>
</> </>
); );
} }
+240 -209
View File
@@ -3,7 +3,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LuEye, LuEyeOff } from "react-icons/lu"; import { LuEye, LuEyeOff, LuLock } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button"; import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -57,9 +57,10 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
const [isSendingCode, setIsSendingCode] = useState(false); const [isSendingCode, setIsSendingCode] = useState(false);
const [isVerifying, setIsVerifying] = useState(false); const [isVerifying, setIsVerifying] = useState(false);
// Default to self-hosted tab if self-hosted is configured and not cloud-logged-in
const [activeTab, setActiveTab] = useState<string>("cloud"); const [activeTab, setActiveTab] = useState<string>("cloud");
const isConnected = Boolean(serverUrl && token);
const loadSettings = useCallback(async () => { const loadSettings = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
try { try {
@@ -82,10 +83,15 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
} }
}, [isOpen, loadSettings]); }, [isOpen, loadSettings]);
// If self-hosted is configured and not cloud-logged-in, default to self-hosted tab // Auto-select the appropriate tab based on connection state
useEffect(() => { useEffect(() => {
if (!isCloudLoading && !isLoggedIn && serverUrl && token) { if (isCloudLoading) return;
if (isLoggedIn) {
setActiveTab("cloud");
} else if (serverUrl && token) {
setActiveTab("self-hosted"); setActiveTab("self-hosted");
} else {
setActiveTab("cloud");
} }
}, [isCloudLoading, isLoggedIn, serverUrl, token]); }, [isCloudLoading, isLoggedIn, serverUrl, token]);
@@ -173,13 +179,15 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
} catch (e) { } catch (e) {
console.error("Failed to restart sync service:", e); console.error("Failed to restart sync service:", e);
} }
// Auto-close dialog after successful login
onClose();
} catch (error) { } catch (error) {
console.error("OTP verification failed:", error); console.error("OTP verification failed:", error);
showErrorToast(String(error)); showErrorToast(String(error));
} finally { } finally {
setIsVerifying(false); setIsVerifying(false);
} }
}, [email, otpCode, verifyOtp, t]); }, [email, otpCode, verifyOtp, t, onClose]);
const handleCloudLogout = useCallback(async () => { const handleCloudLogout = useCallback(async () => {
try { try {
@@ -197,7 +205,9 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
} }
}, [logout, t]); }, [logout, t]);
const isConnected = Boolean(serverUrl && token); // Determine which tabs are available
const cloudBlocked = !isLoggedIn && isConnected;
const selfHostedBlocked = isLoggedIn;
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
@@ -207,233 +217,254 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
<DialogDescription>{t("sync.description")}</DialogDescription> <DialogDescription>{t("sync.description")}</DialogDescription>
</DialogHeader> </DialogHeader>
<Tabs value={activeTab} onValueChange={setActiveTab}> {/* If cloud is logged in, don't show tabs at all - just show cloud account */}
<TabsList className="w-full"> {isLoggedIn && user ? (
<TabsTrigger value="cloud" className="flex-1"> <div className="grid gap-4 py-4">
{t("sync.cloud.tabLabel")} <div className="flex gap-2 items-center text-sm">
</TabsTrigger> <div className="w-2 h-2 rounded-full bg-green-500" />
<TabsTrigger value="self-hosted" className="flex-1"> {t("sync.cloud.connected")}
{t("sync.cloud.selfHostedTabLabel")} </div>
</TabsTrigger>
</TabsList>
<TabsContent value="cloud"> <div className="space-y-2 text-sm">
{isCloudLoading ? ( <div className="flex justify-between">
<div className="flex justify-center py-8"> <span className="text-muted-foreground">
<div className="w-6 h-6 rounded-full border-2 border-current animate-spin border-t-transparent" /> {t("sync.cloud.email")}
</span>
<span>{user.email}</span>
</div> </div>
) : isLoggedIn && user ? ( <div className="flex justify-between">
<div className="grid gap-4 py-4"> <span className="text-muted-foreground">
<div className="flex gap-2 items-center text-sm"> {t("sync.cloud.plan")}
<div className="w-2 h-2 rounded-full bg-green-500" /> </span>
{t("sync.cloud.connected")} <span className="capitalize">
{user.plan}
{user.planPeriod ? ` (${user.planPeriod})` : ""}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
{t("sync.cloud.profiles")}
</span>
<span>
{t("sync.cloud.profileUsage", {
used: user.cloudProfilesUsed,
limit: user.profileLimit,
})}
</span>
</div>
{user.proxyBandwidthLimitMb > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Proxy Bandwidth</span>
<span>
{user.proxyBandwidthUsedMb} / {user.proxyBandwidthLimitMb}{" "}
MB
</span>
</div> </div>
)}
</div>
<div className="space-y-2 text-sm"> <div className="flex gap-2 pt-2">
<div className="flex justify-between"> <Button variant="outline" className="flex-1" asChild>
<span className="text-muted-foreground"> <a
{t("sync.cloud.email")} href="https://donutbrowser.com/account"
</span> target="_blank"
<span>{user.email}</span> rel="noopener noreferrer"
>
{t("sync.cloud.manageAccount")}
</a>
</Button>
<Button
variant="outline"
className="flex-1"
onClick={handleCloudLogout}
>
{t("sync.cloud.logout")}
</Button>
</div>
</div>
) : (
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="w-full">
<TabsTrigger
value="cloud"
className="flex-1"
disabled={cloudBlocked}
>
<span className="flex items-center gap-2">
{t("sync.cloud.tabLabel")}
{cloudBlocked && (
<LuLock className="w-3 h-3 text-muted-foreground" />
)}
</span>
</TabsTrigger>
<TabsTrigger
value="self-hosted"
className="flex-1"
disabled={selfHostedBlocked}
>
<span className="flex items-center gap-2">
{t("sync.cloud.selfHostedTabLabel")}
{selfHostedBlocked && (
<LuLock className="w-3 h-3 text-muted-foreground" />
)}
</span>
</TabsTrigger>
</TabsList>
<TabsContent value="cloud">
{isCloudLoading ? (
<div className="flex justify-center py-8">
<div className="w-6 h-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
</div>
) : (
<div className="grid gap-4 py-4">
<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) {
void handleSendCode();
}
}}
/>
<LoadingButton
onClick={handleSendCode}
isLoading={isSendingCode}
disabled={!email || codeSent}
variant="outline"
>
{t("sync.cloud.sendCode")}
</LoadingButton>
</div>
</div> </div>
<div className="flex justify-between">
<span className="text-muted-foreground"> {codeSent && (
{t("sync.cloud.plan")} <div className="space-y-2">
</span> <Label htmlFor="cloud-otp">
<span className="capitalize"> {t("sync.cloud.verificationCode")}
{user.plan} </Label>
{user.planPeriod ? ` (${user.planPeriod})` : ""} <Input
</span> id="cloud-otp"
</div> placeholder={t("sync.cloud.codePlaceholder")}
<div className="flex justify-between"> value={otpCode}
<span className="text-muted-foreground"> onChange={(e) => setOtpCode(e.target.value)}
{t("sync.cloud.profiles")} onKeyDown={(e) => {
</span> if (e.key === "Enter") {
<span> void handleVerifyOtp();
{t("sync.cloud.profileUsage", { }
used: user.cloudProfilesUsed, }}
limit: user.profileLimit, />
})} <LoadingButton
</span> onClick={handleVerifyOtp}
</div> isLoading={isVerifying}
{user.proxyBandwidthLimitMb > 0 && ( disabled={!otpCode}
<div className="flex justify-between"> className="w-full"
<span className="text-muted-foreground"> >
Proxy Bandwidth {isVerifying
</span> ? t("sync.cloud.loggingIn")
<span> : t("sync.cloud.verifyAndLogin")}
{user.proxyBandwidthUsedMb} /{" "} </LoadingButton>
{user.proxyBandwidthLimitMb} MB
</span>
</div> </div>
)} )}
</div> </div>
)}
</TabsContent>
<div className="flex gap-2 pt-2"> <TabsContent value="self-hosted">
<Button variant="outline" className="flex-1" asChild> {isLoading ? (
<a <div className="flex justify-center py-8">
href="https://donutbrowser.com/account" <div className="w-6 h-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
target="_blank"
rel="noopener noreferrer"
>
{t("sync.cloud.manageAccount")}
</a>
</Button>
<Button
variant="outline"
className="flex-1"
onClick={handleCloudLogout}
>
{t("sync.cloud.logout")}
</Button>
</div> </div>
</div> ) : (
) : ( <div className="grid gap-4 py-4">
<div className="grid gap-4 py-4">
<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) {
void handleSendCode();
}
}}
/>
<LoadingButton
onClick={handleSendCode}
isLoading={isSendingCode}
disabled={!email || codeSent}
variant="outline"
>
{t("sync.cloud.sendCode")}
</LoadingButton>
</div>
</div>
{codeSent && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="cloud-otp"> <Label htmlFor="sync-server-url">
{t("sync.cloud.verificationCode")} {t("sync.serverUrl")}
</Label> </Label>
<Input <Input
id="cloud-otp" id="sync-server-url"
placeholder={t("sync.cloud.codePlaceholder")} placeholder={t("sync.serverUrlPlaceholder")}
value={otpCode} value={serverUrl}
onChange={(e) => setOtpCode(e.target.value)} onChange={(e) => setServerUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
void handleVerifyOtp();
}
}}
/> />
<LoadingButton
onClick={handleVerifyOtp}
isLoading={isVerifying}
disabled={!otpCode}
className="w-full"
>
{isVerifying
? t("sync.cloud.loggingIn")
: t("sync.cloud.verifyAndLogin")}
</LoadingButton>
</div> </div>
)}
</div>
)}
</TabsContent>
<TabsContent value="self-hosted"> <div className="space-y-2">
{isLoading ? ( <Label htmlFor="sync-token">{t("sync.token")}</Label>
<div className="flex justify-center py-8"> <div className="relative">
<div className="w-6 h-6 rounded-full border-2 border-current animate-spin border-t-transparent" /> <Input
</div> id="sync-token"
) : ( type={showToken ? "text" : "password"}
<div className="grid gap-4 py-4"> placeholder={t("sync.tokenPlaceholder")}
<div className="space-y-2"> value={token}
<Label htmlFor="sync-server-url">{t("sync.serverUrl")}</Label> onChange={(e) => setToken(e.target.value)}
<Input className="pr-10"
id="sync-server-url" />
placeholder={t("sync.serverUrlPlaceholder")} <Tooltip>
value={serverUrl} <TooltipTrigger asChild>
onChange={(e) => setServerUrl(e.target.value)} <button
/> type="button"
</div> onClick={() => setShowToken(!showToken)}
className="absolute right-3 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
<div className="space-y-2"> aria-label={showToken ? "Hide token" : "Show token"}
<Label htmlFor="sync-token">{t("sync.token")}</Label> >
<div className="relative"> {showToken ? (
<Input <LuEyeOff className="w-4 h-4 text-muted-foreground hover:text-foreground" />
id="sync-token" ) : (
type={showToken ? "text" : "password"} <LuEye className="w-4 h-4 text-muted-foreground hover:text-foreground" />
placeholder={t("sync.tokenPlaceholder")} )}
value={token} </button>
onChange={(e) => setToken(e.target.value)} </TooltipTrigger>
className="pr-10" <TooltipContent>
/> {showToken ? "Hide token" : "Show token"}
<Tooltip> </TooltipContent>
<TooltipTrigger asChild> </Tooltip>
<button </div>
type="button"
onClick={() => setShowToken(!showToken)}
className="absolute right-3 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
aria-label={showToken ? "Hide token" : "Show token"}
>
{showToken ? (
<LuEyeOff className="w-4 h-4 text-muted-foreground hover:text-foreground" />
) : (
<LuEye className="w-4 h-4 text-muted-foreground hover:text-foreground" />
)}
</button>
</TooltipTrigger>
<TooltipContent>
{showToken ? "Hide token" : "Show token"}
</TooltipContent>
</Tooltip>
</div> </div>
</div>
{isConnected && (
<div className="flex gap-2 items-center text-sm text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-green-500" />
{t("sync.status.connected")}
</div>
)}
</div>
)}
<DialogFooter className="flex gap-2">
{isConnected && ( {isConnected && (
<div className="flex gap-2 items-center text-sm text-muted-foreground"> <Button
<div className="w-2 h-2 rounded-full bg-green-500" /> variant="outline"
{t("sync.status.connected")} onClick={handleDisconnect}
</div> disabled={isSaving}
>
Disconnect
</Button>
)} )}
</div>
)}
<DialogFooter className="flex gap-2">
{isConnected && (
<Button <Button
variant="outline" variant="outline"
onClick={handleDisconnect} onClick={handleTestConnection}
disabled={isSaving} disabled={isTesting || !serverUrl}
> >
Disconnect {isTesting ? "Testing..." : "Test Connection"}
</Button> </Button>
)} <LoadingButton
<Button onClick={handleSave}
variant="outline" isLoading={isSaving}
onClick={handleTestConnection} disabled={!serverUrl || !token}
disabled={isTesting || !serverUrl} >
> Save
{isTesting ? "Testing..." : "Test Connection"} </LoadingButton>
</Button> </DialogFooter>
<LoadingButton </TabsContent>
onClick={handleSave} </Tabs>
isLoading={isSaving} )}
disabled={!serverUrl || !token}
>
Save
</LoadingButton>
</DialogFooter>
</TabsContent>
</Tabs>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
+2 -2
View File
@@ -128,7 +128,7 @@
"settings": "Settings", "settings": "Settings",
"proxies": "Proxies", "proxies": "Proxies",
"groups": "Groups", "groups": "Groups",
"syncService": "Sync Service", "syncService": "Account",
"integrations": "Integrations", "integrations": "Integrations",
"importProfile": "Import Profile" "importProfile": "Import Profile"
} }
@@ -262,7 +262,7 @@
} }
}, },
"sync": { "sync": {
"title": "Sync Service", "title": "Account",
"config": "Sync Configuration", "config": "Sync Configuration",
"serverUrl": "Server URL", "serverUrl": "Server URL",
"serverUrlPlaceholder": "https://sync.example.com", "serverUrlPlaceholder": "https://sync.example.com",
+1 -1
View File
@@ -128,7 +128,7 @@
"settings": "Configuración", "settings": "Configuración",
"proxies": "Proxies", "proxies": "Proxies",
"groups": "Grupos", "groups": "Grupos",
"syncService": "Servicio de Sincronización", "syncService": "Cuenta",
"integrations": "Integraciones", "integrations": "Integraciones",
"importProfile": "Importar Perfil" "importProfile": "Importar Perfil"
} }
+1 -1
View File
@@ -128,7 +128,7 @@
"settings": "Paramètres", "settings": "Paramètres",
"proxies": "Proxies", "proxies": "Proxies",
"groups": "Groupes", "groups": "Groupes",
"syncService": "Service de synchronisation", "syncService": "Compte",
"integrations": "Intégrations", "integrations": "Intégrations",
"importProfile": "Importer un profil" "importProfile": "Importer un profil"
} }
+1 -1
View File
@@ -128,7 +128,7 @@
"settings": "設定", "settings": "設定",
"proxies": "プロキシ", "proxies": "プロキシ",
"groups": "グループ", "groups": "グループ",
"syncService": "同期サービス", "syncService": "アカウント",
"integrations": "統合", "integrations": "統合",
"importProfile": "プロファイルをインポート" "importProfile": "プロファイルをインポート"
} }
+1 -1
View File
@@ -128,7 +128,7 @@
"settings": "Configurações", "settings": "Configurações",
"proxies": "Proxies", "proxies": "Proxies",
"groups": "Grupos", "groups": "Grupos",
"syncService": "Serviço de Sincronização", "syncService": "Conta",
"integrations": "Integrações", "integrations": "Integrações",
"importProfile": "Importar Perfil" "importProfile": "Importar Perfil"
} }
+1 -1
View File
@@ -128,7 +128,7 @@
"settings": "Настройки", "settings": "Настройки",
"proxies": "Прокси", "proxies": "Прокси",
"groups": "Группы", "groups": "Группы",
"syncService": "Служба синхронизации", "syncService": "Аккаунт",
"integrations": "Интеграции", "integrations": "Интеграции",
"importProfile": "Импорт профиля" "importProfile": "Импорт профиля"
} }
+1 -1
View File
@@ -128,7 +128,7 @@
"settings": "设置", "settings": "设置",
"proxies": "代理", "proxies": "代理",
"groups": "分组", "groups": "分组",
"syncService": "同步服务", "syncService": "账户",
"integrations": "集成", "integrations": "集成",
"importProfile": "导入配置文件" "importProfile": "导入配置文件"
} }
+9
View File
@@ -76,6 +76,15 @@ export interface StoredProxy {
sync_enabled?: boolean; sync_enabled?: boolean;
last_sync?: number; last_sync?: number;
is_cloud_managed?: boolean; is_cloud_managed?: boolean;
is_cloud_derived?: boolean;
geo_country?: string;
geo_state?: string;
geo_city?: string;
}
export interface LocationItem {
code: string;
name: string;
} }
export interface ProfileGroup { export interface ProfileGroup {