mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-06 03:08:42 +02:00
feat: better proxy management
This commit is contained in:
@@ -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 }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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!())
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"proxies": "プロキシ",
|
"proxies": "プロキシ",
|
||||||
"groups": "グループ",
|
"groups": "グループ",
|
||||||
"syncService": "同期サービス",
|
"syncService": "アカウント",
|
||||||
"integrations": "統合",
|
"integrations": "統合",
|
||||||
"importProfile": "プロファイルをインポート"
|
"importProfile": "プロファイルをインポート"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"proxies": "Прокси",
|
"proxies": "Прокси",
|
||||||
"groups": "Группы",
|
"groups": "Группы",
|
||||||
"syncService": "Служба синхронизации",
|
"syncService": "Аккаунт",
|
||||||
"integrations": "Интеграции",
|
"integrations": "Интеграции",
|
||||||
"importProfile": "Импорт профиля"
|
"importProfile": "Импорт профиля"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"proxies": "代理",
|
"proxies": "代理",
|
||||||
"groups": "分组",
|
"groups": "分组",
|
||||||
"syncService": "同步服务",
|
"syncService": "账户",
|
||||||
"integrations": "集成",
|
"integrations": "集成",
|
||||||
"importProfile": "导入配置文件"
|
"importProfile": "导入配置文件"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user