From acd572ed232219eba3e7016bf6cb59a46bb04d73 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:49:26 +0400 Subject: [PATCH] feat: teams plan --- donut-sync/src/auth/auth.guard.ts | 2 + donut-sync/src/auth/user-context.interface.ts | 1 + donut-sync/src/sync/sync.service.ts | 87 ++++- src-tauri/src/api_server.rs | 7 + src-tauri/src/auto_updater.rs | 2 + src-tauri/src/browser_runner.rs | 6 + src-tauri/src/cloud_auth.rs | 32 +- src-tauri/src/ephemeral_dirs.rs | 2 + src-tauri/src/lib.rs | 7 +- src-tauri/src/mcp_server.rs | 88 ++++- src-tauri/src/profile/manager.rs | 8 + src-tauri/src/profile/types.rs | 4 + src-tauri/src/profile_importer.rs | 2 + src-tauri/src/sync/engine.rs | 177 +++++++-- src-tauri/src/sync/subscription.rs | 17 +- src-tauri/src/team_lock.rs | 335 ++++++++++++++++++ src/app/page.tsx | 41 ++- src/components/profile-data-table.tsx | 88 +++-- src/components/profile-info-dialog.tsx | 288 +++++++++------ src/components/sync-config-dialog.tsx | 25 ++ src/hooks/use-team-locks.ts | 54 +++ src/i18n/locales/en.json | 15 + src/i18n/locales/es.json | 15 + src/i18n/locales/fr.json | 15 + src/i18n/locales/ja.json | 15 + src/i18n/locales/pt.json | 15 + src/i18n/locales/ru.json | 15 + src/i18n/locales/zh.json | 15 + src/lib/toast-utils.ts | 32 ++ src/types.ts | 13 + 30 files changed, 1223 insertions(+), 200 deletions(-) create mode 100644 src-tauri/src/team_lock.rs create mode 100644 src/hooks/use-team-locks.ts diff --git a/donut-sync/src/auth/auth.guard.ts b/donut-sync/src/auth/auth.guard.ts index 06976e5..facc737 100644 --- a/donut-sync/src/auth/auth.guard.ts +++ b/donut-sync/src/auth/auth.guard.ts @@ -43,6 +43,7 @@ export class AuthGuard implements CanActivate { prefix: "", teamPrefix: null, profileLimit: 0, + teamProfileLimit: 0, } satisfies UserContext; return true; } @@ -59,6 +60,7 @@ export class AuthGuard implements CanActivate { prefix: decoded.prefix || `users/${decoded.sub}/`, teamPrefix: decoded.teamPrefix || null, profileLimit: decoded.profileLimit || 0, + teamProfileLimit: decoded.teamProfileLimit || 0, } satisfies UserContext; return true; } catch (err) { diff --git a/donut-sync/src/auth/user-context.interface.ts b/donut-sync/src/auth/user-context.interface.ts index 56015b2..ec93d6a 100644 --- a/donut-sync/src/auth/user-context.interface.ts +++ b/donut-sync/src/auth/user-context.interface.ts @@ -3,4 +3,5 @@ export interface UserContext { prefix: string; // '' for self-hosted, 'users/{id}/' for cloud teamPrefix: string | null; // 'teams/{id}/' or null profileLimit: number; // 0 for unlimited (self-hosted) + teamProfileLimit: number; // 0 for unlimited or non-team users } diff --git a/donut-sync/src/sync/sync.service.ts b/donut-sync/src/sync/sync.service.ts index f973af9..55179d3 100644 --- a/donut-sync/src/sync/sync.service.ts +++ b/donut-sync/src/sync/sync.service.ts @@ -145,6 +145,7 @@ export class SyncService implements OnModuleInit { */ private scopeKey(ctx: UserContext, key: string): string { if (ctx.mode === "self-hosted") return key; + if (ctx.teamPrefix && key.startsWith(ctx.teamPrefix)) return key; return `${ctx.prefix}${key}`; } @@ -309,10 +310,12 @@ export class SyncService implements OnModuleInit { ); const userPrefix = ctx?.prefix || ""; + const teamPrefix = ctx?.teamPrefix || ""; const objects = (response.Contents || []).map((obj) => { - // Strip user prefix from returned keys so client sees relative keys let key = obj.Key || ""; - if (userPrefix && key.startsWith(userPrefix)) { + if (teamPrefix && key.startsWith(teamPrefix)) { + key = key.substring(teamPrefix.length); + } else if (userPrefix && key.startsWith(userPrefix)) { key = key.substring(userPrefix.length); } return { @@ -481,11 +484,15 @@ export class SyncService implements OnModuleInit { ): Observable { const basePrefixes = ["profiles/", "proxies/", "groups/", "tombstones/"]; - // Scope prefixes for cloud users; self-hosted gets root prefixes - const prefixes = - ctx.mode === "self-hosted" - ? basePrefixes - : basePrefixes.map((p) => `${ctx.prefix}${p}`); + let prefixes: string[]; + if (ctx.mode === "self-hosted") { + prefixes = basePrefixes; + } else { + prefixes = basePrefixes.map((p) => `${ctx.prefix}${p}`); + if (ctx.teamPrefix) { + prefixes.push(...basePrefixes.map((p) => `${ctx.teamPrefix}${p}`)); + } + } // Per-connection state (not shared across subscribers) let lastKnownState = new Map(); @@ -554,16 +561,33 @@ export class SyncService implements OnModuleInit { private async checkProfileLimit(ctx: UserContext): Promise { if (ctx.profileLimit <= 0) return; // 0 = unlimited - const profilePrefix = `${ctx.prefix}profiles/`; - const result = await this.s3Client.send( + let count = 0; + + const userResult = await this.s3Client.send( new ListObjectsV2Command({ Bucket: this.bucket, - Prefix: profilePrefix, + Prefix: `${ctx.prefix}profiles/`, Delimiter: "/", }), ); + count += userResult.CommonPrefixes?.length || 0; + + if (ctx.teamPrefix && ctx.teamProfileLimit && ctx.teamProfileLimit > 0) { + const teamResult = await this.s3Client.send( + new ListObjectsV2Command({ + Bucket: this.bucket, + Prefix: `${ctx.teamPrefix}profiles/`, + Delimiter: "/", + }), + ); + const teamCount = teamResult.CommonPrefixes?.length || 0; + if (teamCount >= ctx.teamProfileLimit) { + throw new ForbiddenException( + `Team profile limit reached (${ctx.teamProfileLimit}). Ask the team owner to upgrade.`, + ); + } + } - const count = result.CommonPrefixes?.length || 0; if (count >= ctx.profileLimit) { throw new ForbiddenException( `Profile limit reached (${ctx.profileLimit}). Upgrade your plan for more profiles.`, @@ -604,6 +628,35 @@ export class SyncService implements OnModuleInit { return match ? match[1] : null; } + private async countTeamProfiles(ctx: UserContext): Promise { + if (!ctx.teamPrefix) return 0; + const profilePrefix = `${ctx.teamPrefix}profiles/`; + let count = 0; + let continuationToken: string | undefined; + + do { + const result = await this.s3Client.send( + new ListObjectsV2Command({ + Bucket: this.bucket, + Prefix: profilePrefix, + Delimiter: "/", + MaxKeys: 1000, + ContinuationToken: continuationToken, + }), + ); + count += result.CommonPrefixes?.length || 0; + continuationToken = result.NextContinuationToken; + } while (continuationToken); + + return count; + } + + private extractTeamId(ctx: UserContext): string | null { + if (!ctx.teamPrefix) return null; + const match = ctx.teamPrefix.match(/^teams\/([^/]+)\/$/); + return match ? match[1] : null; + } + /** * Fire-and-forget: count profiles and report to backend. */ @@ -614,7 +667,17 @@ export class SyncService implements OnModuleInit { if (!userId) return; this.countProfiles(ctx) - .then((count) => this.reportProfileUsage(userId, count)) + .then(async (count) => { + await this.reportProfileUsage(userId, count); + + if (ctx.teamPrefix) { + const teamCount = await this.countTeamProfiles(ctx); + const teamId = this.extractTeamId(ctx); + if (teamId) { + await this.reportProfileUsage(teamId, teamCount); + } + } + }) .catch((err) => this.logger.warn(`Failed to report profile usage: ${err.message}`), ); diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index c8cc1e2..5e63ae0 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -1305,6 +1305,11 @@ async fn run_profile( return Err(StatusCode::BAD_REQUEST); } + // Team lock check + crate::team_lock::acquire_team_lock_if_needed(profile) + .await + .map_err(|_| StatusCode::CONFLICT)?; + // Generate a random port for remote debugging let remote_debugging_port = rand::random::().saturating_add(9000).max(9000); @@ -1399,6 +1404,8 @@ async fn kill_profile( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + crate::team_lock::release_team_lock_if_needed(profile).await; + Ok(StatusCode::NO_CONTENT) } diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs index 2a85e06..7fd91ac 100644 --- a/src-tauri/src/auto_updater.rs +++ b/src-tauri/src/auto_updater.rs @@ -525,6 +525,8 @@ mod tests { ephemeral: false, extension_group_id: None, proxy_bypass_rules: Vec::new(), + created_by_id: None, + created_by_email: None, } } diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index 17c2017..fedf061 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -2536,6 +2536,9 @@ pub async fn launch_browser_profile( )); } + // Team lock check: if profile is sync-enabled and user is on a team, acquire lock + crate::team_lock::acquire_team_lock_if_needed(&profile).await?; + let browser_runner = BrowserRunner::instance(); // Store the internal proxy settings for passing to launch_browser @@ -2740,6 +2743,9 @@ pub async fn kill_browser_profile( profile.id ); + // Release team lock if applicable + crate::team_lock::release_team_lock_if_needed(&profile).await; + // Auto-update non-running profiles and cleanup unused binaries let browser_for_update = profile.browser.clone(); let app_handle_for_update = app_handle.clone(); diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index 9e0f1f6..8677cc1 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -39,6 +39,12 @@ pub struct CloudUser { pub proxy_bandwidth_used_mb: i64, #[serde(rename = "proxyBandwidthExtraMb", default)] pub proxy_bandwidth_extra_mb: i64, + #[serde(rename = "teamId", default)] + pub team_id: Option, + #[serde(rename = "teamName", default)] + pub team_name: Option, + #[serde(rename = "teamRole", default)] + pub team_role: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -249,7 +255,7 @@ impl CloudAuthManager { Self::encrypt_and_store(&path, b"DBCAT", token) } - fn load_access_token() -> Result, String> { + pub(crate) fn load_access_token() -> Result, String> { let path = Self::get_settings_dir().join("cloud_access_token.dat"); Self::decrypt_from_file(&path, b"DBCAT") } @@ -572,6 +578,9 @@ impl CloudAuthManager { } pub async fn logout(&self) -> Result<(), String> { + // Disconnect team lock manager + crate::team_lock::TEAM_LOCK.disconnect().await; + // Try to call the logout API (best-effort) if let Ok(Some(access_token)) = Self::load_access_token() { let refresh_token = Self::load_refresh_token().ok().flatten(); @@ -637,6 +646,13 @@ impl CloudAuthManager { } } + pub async fn is_on_team_plan(&self) -> bool { + if let Some(state) = self.get_user().await { + return state.user.team_id.is_some(); + } + false + } + pub async fn get_user(&self) -> Option { let state = self.state.lock().await; state.clone() @@ -935,6 +951,13 @@ impl CloudAuthManager { log::debug!("Failed to refresh cloud profile: {e}"); } + // Reconnect team lock manager if needed + if let Some(auth_state) = CLOUD_AUTH.get_user().await { + if let Some(tid) = &auth_state.user.team_id { + crate::team_lock::TEAM_LOCK.connect(tid).await; + } + } + // Sync cloud proxy credentials CLOUD_AUTH.sync_cloud_proxy().await; @@ -978,6 +1001,13 @@ pub async fn cloud_verify_otp( // Sync cloud proxy after login CLOUD_AUTH.sync_cloud_proxy().await; + // Connect team lock manager if on a team plan + if state.user.team_id.is_some() { + if let Some(tid) = &state.user.team_id { + crate::team_lock::TEAM_LOCK.connect(tid).await; + } + } + let _ = crate::events::emit_empty("cloud-auth-changed"); let _ = &app_handle; diff --git a/src-tauri/src/ephemeral_dirs.rs b/src-tauri/src/ephemeral_dirs.rs index ff65db3..861c324 100644 --- a/src-tauri/src/ephemeral_dirs.rs +++ b/src-tauri/src/ephemeral_dirs.rs @@ -275,6 +275,8 @@ mod tests { ephemeral, extension_group_id: None, proxy_bypass_rules: Vec::new(), + created_by_id: None, + created_by_email: None, } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9715d9d..b268c5f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -50,6 +50,7 @@ pub mod daemon_ws; pub mod events; mod mcp_server; mod tag_manager; +mod team_lock; mod version_updater; pub mod vpn; pub mod vpn_worker_runner; @@ -1466,7 +1467,10 @@ pub fn run() { 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, + // Team lock commands + team_lock::get_team_locks, + team_lock::get_team_lock_status, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") @@ -1509,6 +1513,7 @@ mod tests { "update_extension", "set_extension_sync_enabled", "set_extension_group_sync_enabled", + "get_team_lock_status", ]; // Extract command names from the generate_handler! macro in this file diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index d802e9d..c2e9d0d 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -809,6 +809,30 @@ impl McpServer { "required": ["profile_id"] }), }, + // Team lock tools + McpTool { + name: "get_team_locks".to_string(), + description: "List all active team profile locks. Requires team plan.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + McpTool { + name: "get_team_lock_status".to_string(), + description: "Check if a profile is locked by a team member. Requires team plan.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "profile_id": { + "type": "string", + "description": "The UUID of the profile to check" + } + }, + "required": ["profile_id"] + }), + }, ] } @@ -926,6 +950,9 @@ impl McpServer { .handle_assign_extension_group_to_profile(&arguments) .await } + // Team lock tools + "get_team_locks" => self.handle_get_team_locks().await, + "get_team_lock_status" => self.handle_get_team_lock_status(&arguments).await, _ => Err(McpError { code: -32602, message: format!("Unknown tool: {tool_name}"), @@ -1040,6 +1067,14 @@ impl McpServer { }); } + // Team lock check + crate::team_lock::acquire_team_lock_if_needed(profile) + .await + .map_err(|e| McpError { + code: -32000, + message: e, + })?; + // Get app handle to launch let inner = self.inner.lock().await; let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { @@ -1121,6 +1156,8 @@ impl McpServer { message: format!("Failed to kill browser: {e}"), })?; + crate::team_lock::release_team_lock_if_needed(profile).await; + Ok(serde_json::json!({ "content": [{ "type": "text", @@ -2388,6 +2425,50 @@ impl McpServer { })?; Ok(serde_json::to_value(profile).unwrap()) } + + async fn handle_get_team_locks(&self) -> Result { + if !CLOUD_AUTH.is_on_team_plan().await { + return Err(McpError { + code: -32000, + message: "Team features require an active team plan".to_string(), + }); + } + let locks = crate::team_lock::TEAM_LOCK.get_locks().await; + Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": serde_json::to_string_pretty(&locks).unwrap_or_default() + }] + })) + } + + async fn handle_get_team_lock_status( + &self, + arguments: &serde_json::Value, + ) -> Result { + if !CLOUD_AUTH.is_on_team_plan().await { + return Err(McpError { + code: -32000, + message: "Team features require an active team plan".to_string(), + }); + } + let profile_id = arguments + .get("profile_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing profile_id".to_string(), + })?; + let lock_status = crate::team_lock::TEAM_LOCK + .get_lock_status(profile_id) + .await; + Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": serde_json::to_string_pretty(&lock_status).unwrap_or_default() + }] + })) + } } lazy_static::lazy_static! { @@ -2403,8 +2484,8 @@ mod tests { let server = McpServer::new(); let tools = server.get_tools(); - // Should have at least 32 tools (26 + 6 extension tools) - assert!(tools.len() >= 32); + // Should have at least 34 tools (26 + 6 extension tools + 2 team lock tools) + assert!(tools.len() >= 34); // Check tool names let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect(); @@ -2448,6 +2529,9 @@ mod tests { assert!(tool_names.contains(&"delete_extension")); assert!(tool_names.contains(&"delete_extension_group")); assert!(tool_names.contains(&"assign_extension_group_to_profile")); + // Team lock tools + assert!(tool_names.contains(&"get_team_locks")); + assert!(tool_names.contains(&"get_team_lock_status")); } #[test] diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 26783dc..3f28619 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -179,6 +179,8 @@ impl ProfileManager { ephemeral: false, extension_group_id: None, proxy_bypass_rules: Vec::new(), + created_by_id: None, + created_by_email: None, }; match self @@ -298,6 +300,8 @@ impl ProfileManager { ephemeral: false, extension_group_id: None, proxy_bypass_rules: Vec::new(), + created_by_id: None, + created_by_email: None, }; match self @@ -349,6 +353,8 @@ impl ProfileManager { ephemeral, extension_group_id: None, proxy_bypass_rules: Vec::new(), + created_by_id: None, + created_by_email: None, }; // Save profile info @@ -903,6 +909,8 @@ impl ProfileManager { ephemeral: false, extension_group_id: source.extension_group_id, proxy_bypass_rules: source.proxy_bypass_rules, + created_by_id: None, + created_by_email: None, }; self.save_profile(&new_profile)?; diff --git a/src-tauri/src/profile/types.rs b/src-tauri/src/profile/types.rs index 323f2dc..6c01392 100644 --- a/src-tauri/src/profile/types.rs +++ b/src-tauri/src/profile/types.rs @@ -61,6 +61,10 @@ pub struct BrowserProfile { pub extension_group_id: Option, #[serde(default)] pub proxy_bypass_rules: Vec, + #[serde(default)] + pub created_by_id: Option, + #[serde(default)] + pub created_by_email: Option, } pub fn default_release_type() -> String { diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs index f2ae048..7e14782 100644 --- a/src-tauri/src/profile_importer.rs +++ b/src-tauri/src/profile_importer.rs @@ -561,6 +561,8 @@ impl ProfileImporter { ephemeral: false, extension_group_id: None, proxy_bypass_rules: Vec::new(), + created_by_id: None, + created_by_email: None, }; // Save the profile metadata diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index abee13b..acdeb96 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -67,6 +67,23 @@ impl SyncEngine { Ok(Self::new(server_url, token)) } + /// Get the key prefix for team profiles. Returns empty string for personal profiles. + async fn get_team_key_prefix(profile: &BrowserProfile) -> String { + if profile.created_by_id.is_some() { + if let Some(auth) = crate::cloud_auth::CLOUD_AUTH.get_user().await { + if let Some(team_id) = &auth.user.team_id { + return format!("teams/{}/", team_id); + } + } + } + String::new() + } + + /// Check if this is a self-hosted sync (no cloud login). + async fn is_self_hosted_sync() -> bool { + !crate::cloud_auth::CLOUD_AUTH.is_logged_in().await + } + pub async fn sync_profile( &self, app_handle: &tauri::AppHandle, @@ -81,6 +98,16 @@ impl SyncEngine { return Ok(()); } + // Skip team profiles for self-hosted sync + if Self::is_self_hosted_sync().await && profile.created_by_id.is_some() { + log::info!( + "Skipping team profile for self-hosted sync: {} ({})", + profile.name, + profile.id + ); + return Ok(()); + } + // Derive encryption key if encrypted sync let encryption_key = if profile.is_encrypted_sync() { let password = encryption::load_e2e_password() @@ -104,10 +131,18 @@ impl SyncEngine { let profile_dir = profiles_dir.join(profile.id.to_string()); let profile_id = profile.id.to_string(); + // Determine team key prefix for team profiles + let key_prefix = Self::get_team_key_prefix(profile).await; + log::info!( - "Starting delta sync for profile: {} ({})", + "Starting delta sync for profile: {} ({}){}", profile.name, - profile_id + profile_id, + if key_prefix.is_empty() { + String::new() + } else { + format!(" [team prefix: {}]", key_prefix) + } ); let _ = events::emit( @@ -155,7 +190,7 @@ impl SyncEngine { hash_cache.save(&cache_path)?; // Try to download remote manifest - let remote_manifest_key = format!("profiles/{}/manifest.json", profile_id); + let remote_manifest_key = format!("{}profiles/{}/manifest.json", key_prefix, profile_id); let remote_manifest = self.download_manifest(&remote_manifest_key).await?; // Compute diff @@ -173,6 +208,13 @@ impl SyncEngine { return Ok(()); } + let upload_bytes: u64 = diff.files_to_upload.iter().map(|f| f.size).sum(); + let download_bytes: u64 = diff.files_to_download.iter().map(|f| f.size).sum(); + let total_files = diff.files_to_upload.len() + + diff.files_to_download.len() + + diff.files_to_delete_local.len() + + diff.files_to_delete_remote.len(); + log::info!( "Profile {} diff: {} to upload, {} to download, {} to delete local, {} to delete remote", profile_id, @@ -182,6 +224,16 @@ impl SyncEngine { diff.files_to_delete_remote.len() ); + let _ = events::emit( + "profile-sync-progress", + serde_json::json!({ + "profile_id": profile_id, + "phase": "started", + "total_files": total_files, + "total_bytes": upload_bytes + download_bytes + }), + ); + // Perform uploads if !diff.files_to_upload.is_empty() { self @@ -191,6 +243,7 @@ impl SyncEngine { &profile_dir, &diff.files_to_upload, encryption_key.as_ref(), + &key_prefix, ) .await?; } @@ -204,6 +257,7 @@ impl SyncEngine { &profile_dir, &diff.files_to_download, encryption_key.as_ref(), + &key_prefix, ) .await?; } @@ -219,18 +273,22 @@ impl SyncEngine { // Delete remote files that don't exist locally (when local is newer) for path in &diff.files_to_delete_remote { - let remote_key = format!("profiles/{}/files/{}", profile_id, path); + let remote_key = format!("{}profiles/{}/files/{}", key_prefix, profile_id, path); let _ = self.client.delete(&remote_key, None).await; log::debug!("Deleted remote file: {}", path); } // Upload metadata.json (sanitized profile) - self.upload_profile_metadata(&profile_id, profile).await?; + self + .upload_profile_metadata(&profile_id, profile, &key_prefix) + .await?; // Upload manifest.json last for atomicity let mut final_manifest = local_manifest; final_manifest.encrypted = encryption_key.is_some(); - self.upload_manifest(&profile_id, &final_manifest).await?; + self + .upload_manifest(&profile_id, &final_manifest, &key_prefix) + .await?; // Sync associated proxy, group, and VPN if let Some(proxy_id) = &profile.proxy_id { @@ -281,11 +339,16 @@ impl SyncEngine { Ok(Some(manifest)) } - async fn upload_manifest(&self, profile_id: &str, manifest: &SyncManifest) -> SyncResult<()> { + async fn upload_manifest( + &self, + profile_id: &str, + manifest: &SyncManifest, + key_prefix: &str, + ) -> SyncResult<()> { let json = serde_json::to_string_pretty(manifest) .map_err(|e| SyncError::SerializationError(format!("Failed to serialize manifest: {e}")))?; - let remote_key = format!("profiles/{}/manifest.json", profile_id); + let remote_key = format!("{}profiles/{}/manifest.json", key_prefix, profile_id); let presign = self .client .presign_upload(&remote_key, Some("application/json")) @@ -303,6 +366,7 @@ impl SyncEngine { &self, profile_id: &str, profile: &BrowserProfile, + key_prefix: &str, ) -> SyncResult<()> { let mut sanitized = profile.clone(); sanitized.process_id = None; @@ -311,7 +375,7 @@ impl SyncEngine { let json = serde_json::to_string_pretty(&sanitized) .map_err(|e| SyncError::SerializationError(format!("Failed to serialize profile: {e}")))?; - let remote_key = format!("profiles/{}/metadata.json", profile_id); + let remote_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id); let presign = self .client .presign_upload(&remote_key, Some("application/json")) @@ -332,6 +396,7 @@ impl SyncEngine { profile_dir: &Path, files: &[super::manifest::ManifestFileEntry], encryption_key: Option<&[u8; 32]>, + key_prefix: &str, ) -> SyncResult<()> { if files.is_empty() { return Ok(()); @@ -343,7 +408,7 @@ impl SyncEngine { let items: Vec<(String, Option)> = files .iter() .map(|f| { - let key = format!("profiles/{}/files/{}", profile_id, f.path); + let key = format!("{}profiles/{}/files/{}", key_prefix, profile_id, f.path); let content_type = mime_guess::from_path(&f.path) .first() .map(|m| m.to_string()); @@ -372,7 +437,7 @@ impl SyncEngine { for file in files { let sem = semaphore.clone(); let file_path = profile_dir.join(&file.path); - let remote_key = format!("profiles/{}/files/{}", profile_id, file.path); + let remote_key = format!("{}profiles/{}/files/{}", key_prefix, profile_id, file.path); let url = url_map.get(&remote_key).cloned(); if url.is_none() { @@ -442,6 +507,7 @@ impl SyncEngine { profile_dir: &Path, files: &[super::manifest::ManifestFileEntry], encryption_key: Option<&[u8; 32]>, + key_prefix: &str, ) -> SyncResult<()> { if files.is_empty() { return Ok(()); @@ -456,7 +522,7 @@ impl SyncEngine { // Get batch presigned URLs let keys: Vec = files .iter() - .map(|f| format!("profiles/{}/files/{}", profile_id, f.path)) + .map(|f| format!("{}profiles/{}/files/{}", key_prefix, profile_id, f.path)) .collect(); let batch_response = self.client.presign_download_batch(keys).await?; @@ -480,7 +546,7 @@ impl SyncEngine { for file in files { let sem = semaphore.clone(); let file_path = profile_dir.join(&file.path); - let remote_key = format!("profiles/{}/files/{}", profile_id, file.path); + let remote_key = format!("{}profiles/{}/files/{}", key_prefix, profile_id, file.path); let url = url_map.get(&remote_key).cloned(); if url.is_none() { @@ -845,6 +911,26 @@ impl SyncEngine { profile_id, result.deleted_count ); + + // Also delete from team path if user is on a team + if let Some(auth) = crate::cloud_auth::CLOUD_AUTH.get_user().await { + if let Some(team_id) = &auth.user.team_id { + let team_prefix = format!("teams/{}/profiles/{}/", team_id, profile_id); + let team_tombstone = format!("teams/{}/tombstones/profiles/{}.json", team_id, profile_id); + let team_result = self + .client + .delete_prefix(&team_prefix, Some(&team_tombstone)) + .await?; + if team_result.deleted_count > 0 { + log::info!( + "Profile {} deleted from team sync ({} objects removed)", + profile_id, + team_result.deleted_count + ); + } + } + } + Ok(()) } @@ -1359,6 +1445,7 @@ impl SyncEngine { &self, app_handle: &tauri::AppHandle, profile_id: &str, + key_prefix: &str, ) -> SyncResult { let profile_manager = ProfileManager::instance(); let profiles_dir = profile_manager.get_profiles_dir(); @@ -1380,7 +1467,7 @@ impl SyncEngine { } // Check if profile exists remotely - let manifest_key = format!("profiles/{}/manifest.json", profile_id); + let manifest_key = format!("{}profiles/{}/manifest.json", key_prefix, profile_id); let stat = self.client.stat(&manifest_key).await?; if !stat.exists { @@ -1394,7 +1481,7 @@ impl SyncEngine { ); // Download metadata.json first to get profile info - let metadata_key = format!("profiles/{}/metadata.json", profile_id); + let metadata_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id); let metadata_stat = self.client.stat(&metadata_key).await?; if !metadata_stat.exists { @@ -1515,6 +1602,7 @@ impl SyncEngine { &profile_dir, &manifest.files, encryption_key.as_ref(), + key_prefix, ) .await?; } @@ -1558,13 +1646,13 @@ impl SyncEngine { ) -> SyncResult> { log::info!("Checking for missing synced profiles..."); - // List all profiles from S3 + // List personal profiles from S3 let list_response = self.client.list("profiles/").await?; let mut downloaded: Vec = Vec::new(); - // Extract unique profile IDs from the list - let mut profile_ids: std::collections::HashSet = std::collections::HashSet::new(); + // Extract unique profile IDs with their key prefix + let mut profiles_to_check: HashMap = HashMap::new(); for obj in list_response.objects { if obj.key.starts_with("profiles/") && obj.key.ends_with("/manifest.json") { if let Some(profile_id) = obj @@ -1572,24 +1660,45 @@ impl SyncEngine { .strip_prefix("profiles/") .and_then(|s| s.strip_suffix("/manifest.json")) { - profile_ids.insert(profile_id.to_string()); + profiles_to_check.insert(profile_id.to_string(), String::new()); + } + } + } + + // Also list team profiles if user is on a team + if let Some(auth) = crate::cloud_auth::CLOUD_AUTH.get_user().await { + if let Some(team_id) = &auth.user.team_id { + let team_prefix = format!("teams/{}/", team_id); + let team_list_key = format!("{}profiles/", team_prefix); + if let Ok(team_list) = self.client.list(&team_list_key).await { + for obj in team_list.objects { + if obj.key.starts_with("profiles/") && obj.key.ends_with("/manifest.json") { + if let Some(profile_id) = obj + .key + .strip_prefix("profiles/") + .and_then(|s| s.strip_suffix("/manifest.json")) + { + profiles_to_check.insert(profile_id.to_string(), team_prefix.clone()); + } + } + } } } } log::info!( "Found {} profiles in remote storage, checking for missing ones...", - profile_ids.len() + profiles_to_check.len() ); // For each remote profile, check if it exists locally and download if missing - for profile_id in profile_ids { + for (profile_id, key_prefix) in &profiles_to_check { match self - .download_profile_if_missing(app_handle, &profile_id) + .download_profile_if_missing(app_handle, profile_id, key_prefix) .await { Ok(true) => { - downloaded.push(profile_id); + downloaded.push(profile_id.clone()); } Ok(false) => { // Profile exists locally or doesn't exist remotely, skip @@ -1613,17 +1722,28 @@ impl SyncEngine { // Refresh metadata for local cross-OS profiles (propagate renames, tags, notes from originating device) let profile_manager = ProfileManager::instance(); // Collect cross-OS profiles before async operations to avoid holding non-Send Result across await - let cross_os_profiles: Vec<(String, SyncMode)> = profile_manager + let cross_os_profiles: Vec<(String, SyncMode, Option)> = profile_manager .list_profiles() .unwrap_or_default() .iter() .filter(|p| p.is_cross_os() && p.is_sync_enabled()) - .map(|p| (p.id.to_string(), p.sync_mode)) + .map(|p| (p.id.to_string(), p.sync_mode, p.created_by_id.clone())) .collect(); if !cross_os_profiles.is_empty() { - for (pid, sync_mode) in &cross_os_profiles { - let metadata_key = format!("profiles/{}/metadata.json", pid); + let team_prefix = if let Some(auth) = crate::cloud_auth::CLOUD_AUTH.get_user().await { + auth.user.team_id.map(|tid| format!("teams/{}/", tid)) + } else { + None + }; + + for (pid, sync_mode, created_by_id) in &cross_os_profiles { + let kp = if created_by_id.is_some() { + team_prefix.as_deref().unwrap_or("") + } else { + "" + }; + let metadata_key = format!("{}profiles/{}/metadata.json", kp, pid); match self.client.stat(&metadata_key).await { Ok(stat) if stat.exists => match self.client.presign_download(&metadata_key).await { Ok(presign) => match self.client.download_bytes(&presign.url).await { @@ -1981,7 +2101,8 @@ pub async fn set_profile_sync_mode( let mode_switched = old_mode != SyncMode::Disabled && enabling && old_mode != new_mode; if mode_switched { if let Ok(engine) = SyncEngine::create_from_settings(&app_handle).await { - let manifest_key = format!("profiles/{}/manifest.json", profile_id); + let key_prefix = SyncEngine::get_team_key_prefix(&profile).await; + let manifest_key = format!("{}profiles/{}/manifest.json", key_prefix, profile_id); let _ = engine.client.delete(&manifest_key, None).await; log::info!( "Deleted remote manifest for profile {} due to sync mode change ({:?} -> {:?})", diff --git a/src-tauri/src/sync/subscription.rs b/src-tauri/src/sync/subscription.rs index 8ea9f19..b1711bb 100644 --- a/src-tauri/src/sync/subscription.rs +++ b/src-tauri/src/sync/subscription.rs @@ -208,8 +208,21 @@ impl SyncSubscription { data_line.and_then(|data| serde_json::from_str(data).ok()) } + fn strip_team_prefix(key: &str) -> &str { + if key.starts_with("teams/") { + if let Some(rest) = key.find('/').and_then(|first_slash| { + key[first_slash + 1..] + .find('/') + .map(|second_slash| first_slash + 1 + second_slash + 1) + }) { + return &key[rest..]; + } + } + key + } + fn handle_event(event: &SubscribeEvent, work_tx: &mpsc::UnboundedSender) { - let Some(key) = &event.key else { + let Some(raw_key) = &event.key else { return; }; @@ -217,6 +230,8 @@ impl SyncSubscription { return; } + let key = Self::strip_team_prefix(raw_key); + let work_item = if key.starts_with("profiles/") { key .strip_prefix("profiles/") diff --git a/src-tauri/src/team_lock.rs b/src-tauri/src/team_lock.rs new file mode 100644 index 0000000..1ffb101 --- /dev/null +++ b/src-tauri/src/team_lock.rs @@ -0,0 +1,335 @@ +use lazy_static::lazy_static; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use tokio::sync::{Mutex, RwLock}; +use tokio::task::JoinHandle; + +use crate::cloud_auth::{CloudAuthManager, CLOUD_API_URL, CLOUD_AUTH}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProfileLockInfo { + #[serde(rename = "profileId")] + pub profile_id: String, + #[serde(rename = "lockedBy")] + pub locked_by: String, + #[serde(rename = "lockedByEmail")] + pub locked_by_email: String, + #[serde(rename = "lockedAt")] + pub locked_at: String, + #[serde(rename = "expiresAt", default)] + pub expires_at: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct AcquireLockResponse { + success: bool, + #[serde(rename = "lockedBy")] + locked_by: Option, + #[serde(rename = "lockedByEmail")] + locked_by_email: Option, +} + +pub struct TeamLockManager { + locks: RwLock>, + heartbeat_handle: Mutex>>, + connected_team_id: Mutex>, +} + +lazy_static! { + pub static ref TEAM_LOCK: TeamLockManager = TeamLockManager::new(); +} + +impl TeamLockManager { + fn new() -> Self { + Self { + locks: RwLock::new(HashMap::new()), + heartbeat_handle: Mutex::new(None), + connected_team_id: Mutex::new(None), + } + } + + pub async fn connect(&self, team_id: &str) { + log::info!("Connecting team lock manager for team: {team_id}"); + + { + let mut tid = self.connected_team_id.lock().await; + *tid = Some(team_id.to_string()); + } + + if let Err(e) = self.fetch_initial_locks(team_id).await { + log::warn!("Failed to fetch initial locks: {e}"); + } + + self.start_heartbeat_loop().await; + } + + pub async fn disconnect(&self) { + log::info!("Disconnecting team lock manager"); + + { + let mut handle = self.heartbeat_handle.lock().await; + if let Some(h) = handle.take() { + h.abort(); + } + } + + { + let mut locks = self.locks.write().await; + locks.clear(); + } + + { + let mut tid = self.connected_team_id.lock().await; + *tid = None; + } + } + + pub async fn acquire_lock(&self, profile_id: &str) -> Result<(), String> { + let team_id = self.get_team_id().await?; + let client = Client::new(); + + let access_token = + CloudAuthManager::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?; + + let url = format!("{CLOUD_API_URL}/api/teams/{team_id}/locks"); + let response = client + .post(&url) + .header("Authorization", format!("Bearer {access_token}")) + .json(&serde_json::json!({ "profileId": profile_id })) + .send() + .await + .map_err(|e| format!("Failed to acquire lock: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("Lock acquisition failed ({status}): {body}")); + } + + let result: AcquireLockResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse lock response: {e}"))?; + + if !result.success { + let email = result + .locked_by_email + .unwrap_or_else(|| "another user".to_string()); + return Err(format!("Profile is in use by {email}")); + } + + // Update local cache + if let Some(user) = CLOUD_AUTH.get_user().await { + let mut locks = self.locks.write().await; + locks.insert( + profile_id.to_string(), + ProfileLockInfo { + profile_id: profile_id.to_string(), + locked_by: user.user.id.clone(), + locked_by_email: user.user.email.clone(), + locked_at: chrono::Utc::now().to_rfc3339(), + expires_at: None, + }, + ); + } + + let _ = crate::events::emit( + "team-lock-acquired", + serde_json::json!({ "profileId": profile_id }), + ); + + Ok(()) + } + + pub async fn release_lock(&self, profile_id: &str) -> Result<(), String> { + let team_id = self.get_team_id().await?; + let client = Client::new(); + + let access_token = + CloudAuthManager::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?; + + let url = format!("{CLOUD_API_URL}/api/teams/{team_id}/locks/{profile_id}"); + let _ = client + .delete(&url) + .header("Authorization", format!("Bearer {access_token}")) + .send() + .await; + + { + let mut locks = self.locks.write().await; + locks.remove(profile_id); + } + + let _ = crate::events::emit( + "team-lock-released", + serde_json::json!({ "profileId": profile_id }), + ); + + Ok(()) + } + + pub async fn get_locks(&self) -> Vec { + let locks = self.locks.read().await; + locks.values().cloned().collect() + } + + pub async fn get_lock_status(&self, profile_id: &str) -> Option { + let locks = self.locks.read().await; + locks.get(profile_id).cloned() + } + + pub async fn is_locked_by_another(&self, profile_id: &str) -> bool { + let locks = self.locks.read().await; + if let Some(lock) = locks.get(profile_id) { + if let Some(user) = CLOUD_AUTH.get_user().await { + return lock.locked_by != user.user.id; + } + } + false + } + + async fn fetch_initial_locks(&self, team_id: &str) -> Result<(), String> { + let client = Client::new(); + let access_token = + CloudAuthManager::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?; + + let url = format!("{CLOUD_API_URL}/api/teams/{team_id}/locks"); + let response = client + .get(&url) + .header("Authorization", format!("Bearer {access_token}")) + .send() + .await + .map_err(|e| format!("Failed to fetch locks: {e}"))?; + + if !response.status().is_success() { + return Err("Failed to fetch locks".to_string()); + } + + let lock_list: Vec = response + .json() + .await + .map_err(|e| format!("Failed to parse locks: {e}"))?; + + let mut locks = self.locks.write().await; + locks.clear(); + for lock in lock_list { + locks.insert(lock.profile_id.clone(), lock); + } + + Ok(()) + } + + async fn start_heartbeat_loop(&self) { + let mut handle = self.heartbeat_handle.lock().await; + if let Some(h) = handle.take() { + h.abort(); + } + + let h = tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs(30)).await; + + let team_id = match TEAM_LOCK.get_team_id().await { + Ok(id) => id, + Err(_) => break, + }; + + let held_locks: Vec = { + let locks = TEAM_LOCK.locks.read().await; + if let Some(user) = CLOUD_AUTH.get_user().await { + locks + .values() + .filter(|l| l.locked_by == user.user.id) + .map(|l| l.profile_id.clone()) + .collect() + } else { + vec![] + } + }; + + for profile_id in held_locks { + let client = Client::new(); + if let Ok(Some(token)) = CloudAuthManager::load_access_token() { + let url = format!("{CLOUD_API_URL}/api/teams/{team_id}/locks/{profile_id}/heartbeat"); + let _ = client + .post(&url) + .header("Authorization", format!("Bearer {token}")) + .send() + .await; + } + } + + // Refresh lock state from server + if let Err(e) = TEAM_LOCK.fetch_initial_locks(&team_id).await { + log::debug!("Failed to refresh locks: {e}"); + } + } + }); + + *handle = Some(h); + } + + async fn get_team_id(&self) -> Result { + let tid = self.connected_team_id.lock().await; + tid + .clone() + .ok_or_else(|| "Not connected to a team".to_string()) + } +} + +/// Acquire team lock if profile is sync-enabled and user is on a team. +/// Returns Ok(()) if lock acquired or not applicable, Err with message if locked by another. +pub async fn acquire_team_lock_if_needed( + profile: &crate::profile::BrowserProfile, +) -> Result<(), String> { + if !profile.is_sync_enabled() { + return Ok(()); + } + if !CLOUD_AUTH.is_on_team_plan().await { + return Ok(()); + } + + if TEAM_LOCK + .is_locked_by_another(&profile.id.to_string()) + .await + { + if let Some(lock) = TEAM_LOCK.get_lock_status(&profile.id.to_string()).await { + return Err(format!("Profile is in use by {}", lock.locked_by_email)); + } + return Err("Profile is in use by another team member".to_string()); + } + + TEAM_LOCK.acquire_lock(&profile.id.to_string()).await +} + +/// Release team lock if profile is sync-enabled and user is on a team. +/// Logs warnings on failure but does not return errors. +pub async fn release_team_lock_if_needed(profile: &crate::profile::BrowserProfile) { + if !profile.is_sync_enabled() { + return; + } + if !CLOUD_AUTH.is_on_team_plan().await { + return; + } + + if let Err(e) = TEAM_LOCK.release_lock(&profile.id.to_string()).await { + log::warn!( + "Failed to release team lock for profile {}: {e}", + profile.id + ); + } +} + +// --- Tauri commands --- + +#[tauri::command] +pub async fn get_team_locks() -> Result, String> { + Ok(TEAM_LOCK.get_locks().await) +} + +#[tauri::command] +pub async fn get_team_lock_status(profile_id: String) -> Result, String> { + Ok(TEAM_LOCK.get_lock_status(&profile_id).await) +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 2fd4a2c..fc8bb7b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -46,6 +46,7 @@ import { dismissToast, showErrorToast, showSuccessToast, + showSyncProgressToast, showToast, } from "@/lib/toast-utils"; import type { @@ -192,8 +193,6 @@ export default function Home() { const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } = usePermissions(); - const userInitiatedSyncIds = useRef>(new Set()); - const handleSelectGroup = useCallback((groupId: string) => { setSelectedGroupId(groupId); setSelectedProfiles([]); @@ -769,9 +768,6 @@ export default function Home() { profileId: profile.id, syncMode: enabling ? "Regular" : "Disabled", }); - if (enabling) { - userInitiatedSyncIds.current.add(profile.id); - } showSuccessToast(enabling ? "Sync enabled" : "Sync disabled", { description: enabling ? "Profile sync has been enabled" @@ -786,17 +782,16 @@ export default function Home() { ); useEffect(() => { - let unlisten: (() => void) | undefined; + let unlistenStatus: (() => void) | undefined; + let unlistenProgress: (() => void) | undefined; (async () => { try { - unlisten = await listen<{ + unlistenStatus = await listen<{ profile_id: string; status: string; error?: string; }>("profile-sync-status", (event) => { const { profile_id, status, error } = event.payload; - if (!userInitiatedSyncIds.current.has(profile_id)) return; - const toastId = `sync-${profile_id}`; const profile = profiles.find((p) => p.id === profile_id); const name = profile?.name ?? "Unknown"; @@ -806,26 +801,44 @@ export default function Home() { type: "loading", title: `Syncing profile '${name}'...`, id: toastId, - duration: 30000, + duration: Number.POSITIVE_INFINITY, + onCancel: () => dismissToast(toastId), }); } else if (status === "synced") { dismissToast(toastId); showSuccessToast(`Profile '${name}' synced successfully`); - userInitiatedSyncIds.current.delete(profile_id); } else if (status === "error") { dismissToast(toastId); showErrorToast( `Failed to sync profile '${name}'${error ? `: ${error}` : ""}`, ); - userInitiatedSyncIds.current.delete(profile_id); } }); + + unlistenProgress = await listen<{ + profile_id: string; + phase: string; + total_files?: number; + total_bytes?: number; + }>("profile-sync-progress", (event) => { + const { profile_id, phase, total_files, total_bytes } = event.payload; + if (phase !== "started") return; + + const toastId = `sync-${profile_id}`; + const profile = profiles.find((p) => p.id === profile_id); + const name = profile?.name ?? "Unknown"; + + showSyncProgressToast(name, total_files ?? 0, total_bytes ?? 0, { + id: toastId, + }); + }); } catch (error) { - console.error("Failed to listen for sync status events:", error); + console.error("Failed to listen for sync events:", error); } })(); return () => { - if (unlisten) unlisten(); + if (unlistenStatus) unlistenStatus(); + if (unlistenProgress) unlistenProgress(); }; }, [profiles]); diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 1ce3f3e..e7aa451 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -22,6 +22,7 @@ import { LuChevronUp, LuCookie, LuInfo, + LuLock, LuTrash2, LuUsers, } from "react-icons/lu"; @@ -58,8 +59,10 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { useBrowserState } from "@/hooks/use-browser-state"; +import { useCloudAuth } from "@/hooks/use-cloud-auth"; import { useProxyEvents } from "@/hooks/use-proxy-events"; import { useTableSorting } from "@/hooks/use-table-sorting"; +import { useTeamLocks } from "@/hooks/use-team-locks"; import { useVpnEvents } from "@/hooks/use-vpn-events"; import { getBrowserDisplayName, @@ -193,6 +196,10 @@ type TableMeta = { profileId: string, country: LocationItem, ) => Promise; + + // Team locks + isProfileLockedByAnother: (profileId: string) => boolean; + getProfileLockEmail: (profileId: string) => string | undefined; }; type SyncStatusDot = { color: string; tooltip: string; animate: boolean }; @@ -873,6 +880,8 @@ export function ProfilesDataTable({ const { storedProxies } = useProxyEvents(); const { vpnConfigs } = useVpnEvents(); + const { user } = useCloudAuth(); + const { isProfileLocked, getLockInfo } = useTeamLocks(user?.id); const [proxyOverrides, setProxyOverrides] = React.useState< Record @@ -1488,6 +1497,11 @@ export function ProfilesDataTable({ canCreateLocationProxy, loadCountries, handleCreateCountryProxy, + + // Team locks + isProfileLockedByAnother: isProfileLocked, + getProfileLockEmail: (profileId: string) => + getLockInfo(profileId)?.lockedByEmail, }), [ t, @@ -1540,6 +1554,8 @@ export function ProfilesDataTable({ canCreateLocationProxy, loadCountries, handleCreateCountryProxy, + isProfileLocked, + getLockInfo, ], ); @@ -1724,9 +1740,13 @@ export function ProfilesDataTable({ meta.isClient && meta.runningProfiles.has(profile.id); const isLaunching = meta.launchingProfiles.has(profile.id); const isStopping = meta.stoppingProfiles.has(profile.id); - const canLaunch = meta.browserState.canLaunchProfile(profile); - const tooltipContent = - meta.browserState.getLaunchTooltipContent(profile); + const isLockedByAnother = meta.isProfileLockedByAnother(profile.id); + const canLaunch = + meta.browserState.canLaunchProfile(profile) && !isLockedByAnother; + const lockEmail = meta.getProfileLockEmail(profile.id); + const tooltipContent = isLockedByAnother + ? meta.t("sync.team.cannotLaunchLocked", { email: lockEmail }) + : meta.browserState.getLaunchTooltipContent(profile); const handleProfileStop = async (profile: BrowserProfile) => { meta.setStoppingProfiles((prev: Set) => @@ -1890,34 +1910,50 @@ export function ProfilesDataTable({ const isStopping = meta.stoppingProfiles.has(profile.id); const isDisabled = isRunning || isLaunching || isStopping || isCrossOs; + const lockedEmail = meta.getProfileLockEmail(profile.id); + const isLocked = meta.isProfileLockedByAnother(profile.id); return ( - + }} + onKeyDown={(e) => { + if (isDisabled) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + meta.setProfileToRename(profile); + meta.setNewProfileName(profile.name); + meta.setRenameError(null); + } + }} + > + {display} + + {isLocked && ( + + + + + + + + {meta.t("sync.team.profileLocked", { email: lockedEmail })} + + + )} + ); }, }, diff --git a/src/components/profile-info-dialog.tsx b/src/components/profile-info-dialog.tsx index ee57623..9bb7670 100644 --- a/src/components/profile-info-dialog.tsx +++ b/src/components/profile-info-dialog.tsx @@ -16,6 +16,7 @@ import { LuPlus, LuRefreshCw, LuSettings, + LuShieldCheck, LuTrash2, LuX, } from "react-icons/lu"; @@ -30,6 +31,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { ProBadge } from "@/components/ui/pro-badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { getBrowserDisplayName, @@ -108,6 +110,8 @@ export function ProfileInfoDialog({ >(null); const [bypassRules, setBypassRules] = React.useState([]); const [newRule, setNewRule] = React.useState(""); + const [bypassRulesDialogOpen, setBypassRulesDialogOpen] = + React.useState(false); React.useEffect(() => { if (!isOpen || !profile?.group_id) { @@ -305,6 +309,13 @@ export function ProfileInfoDialog({ }); } + if (profile.created_by_email) { + infoFields.push({ + label: t("sync.team.title"), + value: t("sync.team.createdBy", { email: profile.created_by_email }), + }); + } + if (profile.ephemeral) { infoFields.push({ label: t("profileInfo.fields.ephemeral"), @@ -383,6 +394,11 @@ export function ProfileInfoDialog({ disabled: isDisabled, hidden: profile.ephemeral === true, }, + { + icon: , + label: t("profileInfo.network.bypassRulesTitle"), + onClick: () => setBypassRulesDialogOpen(true), + }, { icon: , label: t("profiles.actions.delete"), @@ -395,124 +411,166 @@ export function ProfileInfoDialog({ const visibleActions = actions.filter((a) => !a.hidden); return ( - !open && onClose()}> - - - {t("profileInfo.title")} - - - - - {t("profileInfo.tabs.info")} - - - {t("profileInfo.tabs.settings")} - - - -
- -

{profile.name}

-

- {getBrowserDisplayName(profile.browser)} {profile.version} -

-
-
-
- {infoFields.map((field) => ( - - - {field.label} - - {field.value} - - ))} -
-
-
- -
- {visibleActions.map((action) => ( - - ))} -
-
-
-
-

- {t("profileInfo.network.bypassRules")} -

-

- {t("profileInfo.network.bypassRulesDescription")} -

-
-
- setNewRule(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") handleAddRule(); - }} - placeholder={t("profileInfo.network.rulePlaceholder")} - className="flex-1 text-sm" - /> - -
- {bypassRules.length === 0 ? ( -

- {t("profileInfo.network.noRules")} -

- ) : ( -
- {bypassRules.map((rule) => ( -
- {rule} - -
+ <> + !open && onClose()}> + + + {t("profileInfo.title")} + + + + + {t("profileInfo.tabs.info")} + + + {t("profileInfo.tabs.settings")} + + + + +
+ +

{profile.name}

+

+ {getBrowserDisplayName(profile.browser)} {profile.version} +

+
+
+ {infoFields.map((field) => ( + + + {field.label} + + {field.value} + ))}
- )} -

- {t("profileInfo.network.ruleTypes")} -

+
+
+ + +
+ {visibleActions.map((action) => ( + + ))} +
+
+
+
+ + + +
+
+ setBypassRulesDialogOpen(false)} + bypassRules={bypassRules} + newRule={newRule} + onNewRuleChange={setNewRule} + onAddRule={handleAddRule} + onRemoveRule={handleRemoveRule} + /> + + ); +} + +interface ProfileBypassRulesDialogProps { + isOpen: boolean; + onClose: () => void; + bypassRules: string[]; + newRule: string; + onNewRuleChange: (value: string) => void; + onAddRule: () => void; + onRemoveRule: (rule: string) => void; +} + +function ProfileBypassRulesDialog({ + isOpen, + onClose, + bypassRules, + newRule, + onNewRuleChange, + onAddRule, + onRemoveRule, +}: ProfileBypassRulesDialogProps) { + const { t } = useTranslation(); + + return ( + !open && onClose()}> + + + {t("profileInfo.network.bypassRulesTitle")} + + +
+

+ {t("profileInfo.network.bypassRulesDescription")} +

+
+ onNewRuleChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") onAddRule(); + }} + placeholder={t("profileInfo.network.rulePlaceholder")} + className="flex-1 text-sm" + /> +
- - - + {bypassRules.length === 0 ? ( +

+ {t("profileInfo.network.noRules")} +

+ ) : ( +
+ {bypassRules.map((rule) => ( +
+ {rule} + +
+ ))} +
+ )} +

+ {t("profileInfo.network.ruleTypes")} +

+
+
+ diff --git a/src/components/sync-config-dialog.tsx b/src/components/sync-config-dialog.tsx index f3fdbb1..460c9c1 100644 --- a/src/components/sync-config-dialog.tsx +++ b/src/components/sync-config-dialog.tsx @@ -309,6 +309,31 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
)} + {user.teamName && ( + <> +
+ + {t("sync.team.name")} + + {user.teamName} +
+
+ + {t("sync.team.role")} + + + {user.teamRole === "owner" + ? t("sync.team.roleOwner") + : user.teamRole === "admin" + ? t("sync.team.roleAdmin") + : t("sync.team.roleMember")} + +
+

+ {t("sync.team.manageOnWeb")} +

+ + )}
diff --git a/src/hooks/use-team-locks.ts b/src/hooks/use-team-locks.ts new file mode 100644 index 0000000..7d46668 --- /dev/null +++ b/src/hooks/use-team-locks.ts @@ -0,0 +1,54 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { useCallback, useEffect, useState } from "react"; +import type { ProfileLockInfo } from "@/types"; + +export function useTeamLocks(currentUserId?: string) { + const [locks, setLocks] = useState([]); + + const fetchLocks = useCallback(async () => { + try { + const result = await invoke("get_team_locks"); + setLocks(result); + } catch { + // Not connected to a team or not logged in + } + }, []); + + useEffect(() => { + fetchLocks(); + + const unlistenAcquired = listen<{ profileId: string }>( + "team-lock-acquired", + () => fetchLocks(), + ); + const unlistenReleased = listen<{ profileId: string }>( + "team-lock-released", + () => fetchLocks(), + ); + + return () => { + unlistenAcquired.then((fn) => fn()); + unlistenReleased.then((fn) => fn()); + }; + }, [fetchLocks]); + + const isProfileLocked = useCallback( + (profileId: string): boolean => { + const lock = locks.find((l) => l.profileId === profileId); + if (!lock) return false; + if (currentUserId && lock.lockedBy === currentUserId) return false; + return true; + }, + [locks, currentUserId], + ); + + const getLockInfo = useCallback( + (profileId: string): ProfileLockInfo | undefined => { + return locks.find((l) => l.profileId === profileId); + }, + [locks], + ); + + return { locks, isProfileLocked, getLockInfo, refetchLocks: fetchLocks }; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9058c88..db1d8d2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -340,6 +340,19 @@ "logoutConfirm": "Are you sure you want to log out? Cloud sync will stop.", "loginSuccess": "Successfully logged in!", "logoutSuccess": "Successfully logged out." + }, + "team": { + "title": "Team", + "name": "Team Name", + "role": "Role", + "roleOwner": "Owner", + "roleAdmin": "Admin", + "roleMember": "Member", + "manageOnWeb": "Manage team on the web dashboard", + "profileLocked": "In use by {{email}}", + "profileLockedShort": "In use", + "cannotLaunchLocked": "Cannot launch — profile is in use by {{email}}", + "createdBy": "Created by {{email}}" } }, "integrations": { @@ -504,6 +517,7 @@ "verifying": "Verifying {{browser}} {{version}}", "syncing": "Syncing...", "syncingProfile": "Syncing profile '{{name}}'...", + "syncingProfileWithProgress": "{{count}} files ({{size}})", "updatingVersions": "Updating browser versions..." } }, @@ -707,6 +721,7 @@ }, "network": { "bypassRules": "Proxy Bypass Rules", + "bypassRulesTitle": "Proxy Bypass Rules", "bypassRulesDescription": "Requests matching these rules will connect directly, bypassing the proxy.", "addRule": "Add Rule", "rulePlaceholder": "e.g. example.com, 192.168.1.*, .*\\.local", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3fef96e..06bb68f 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -340,6 +340,19 @@ "logoutConfirm": "¿Estás seguro de que deseas cerrar sesión? La sincronización en la nube se detendrá.", "loginSuccess": "¡Sesión iniciada exitosamente!", "logoutSuccess": "Sesión cerrada exitosamente." + }, + "team": { + "title": "Equipo", + "name": "Nombre del Equipo", + "role": "Rol", + "roleOwner": "Propietario", + "roleAdmin": "Administrador", + "roleMember": "Miembro", + "manageOnWeb": "Gestionar equipo en el panel web", + "profileLocked": "En uso por {{email}}", + "profileLockedShort": "En uso", + "cannotLaunchLocked": "No se puede iniciar — el perfil está en uso por {{email}}", + "createdBy": "Creado por {{email}}" } }, "integrations": { @@ -504,6 +517,7 @@ "verifying": "Verificando {{browser}} {{version}}", "syncing": "Sincronizando...", "syncingProfile": "Sincronizando perfil '{{name}}'...", + "syncingProfileWithProgress": "{{count}} archivos ({{size}})", "updatingVersions": "Actualizando versiones de navegadores..." } }, @@ -707,6 +721,7 @@ }, "network": { "bypassRules": "Reglas de Omisión de Proxy", + "bypassRulesTitle": "Reglas de Omisión de Proxy", "bypassRulesDescription": "Las solicitudes que coincidan con estas reglas se conectarán directamente, omitiendo el proxy.", "addRule": "Agregar Regla", "rulePlaceholder": "ej. example.com, 192.168.1.*, .*\\.local", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index b2e1784..ee80cbd 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -340,6 +340,19 @@ "logoutConfirm": "Êtes-vous sûr de vouloir vous déconnecter ? La synchronisation cloud sera arrêtée.", "loginSuccess": "Connexion réussie !", "logoutSuccess": "Déconnexion réussie." + }, + "team": { + "title": "Équipe", + "name": "Nom de l'équipe", + "role": "Rôle", + "roleOwner": "Propriétaire", + "roleAdmin": "Administrateur", + "roleMember": "Membre", + "manageOnWeb": "Gérer l'équipe sur le tableau de bord web", + "profileLocked": "Utilisé par {{email}}", + "profileLockedShort": "En cours d'utilisation", + "cannotLaunchLocked": "Impossible de lancer — le profil est utilisé par {{email}}", + "createdBy": "Créé par {{email}}" } }, "integrations": { @@ -504,6 +517,7 @@ "verifying": "Vérification de {{browser}} {{version}}", "syncing": "Synchronisation...", "syncingProfile": "Synchronisation du profil '{{name}}'...", + "syncingProfileWithProgress": "{{count}} fichiers ({{size}})", "updatingVersions": "Mise à jour des versions de navigateurs..." } }, @@ -707,6 +721,7 @@ }, "network": { "bypassRules": "Règles de Contournement du Proxy", + "bypassRulesTitle": "Règles de Contournement du Proxy", "bypassRulesDescription": "Les requêtes correspondant à ces règles se connecteront directement, contournant le proxy.", "addRule": "Ajouter une Règle", "rulePlaceholder": "ex. example.com, 192.168.1.*, .*\\.local", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 40d14b4..5c08d6e 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -340,6 +340,19 @@ "logoutConfirm": "ログアウトしてもよろしいですか?クラウド同期が停止します。", "loginSuccess": "ログインに成功しました!", "logoutSuccess": "ログアウトしました。" + }, + "team": { + "title": "チーム", + "name": "チーム名", + "role": "役割", + "roleOwner": "オーナー", + "roleAdmin": "管理者", + "roleMember": "メンバー", + "manageOnWeb": "Webダッシュボードでチームを管理", + "profileLocked": "{{email}} が使用中", + "profileLockedShort": "使用中", + "cannotLaunchLocked": "起動できません — {{email}} がプロファイルを使用中です", + "createdBy": "{{email}} が作成" } }, "integrations": { @@ -504,6 +517,7 @@ "verifying": "{{browser}} {{version}} を確認中", "syncing": "同期中...", "syncingProfile": "プロファイル '{{name}}' を同期中...", + "syncingProfileWithProgress": "{{count}} ファイル ({{size}})", "updatingVersions": "ブラウザバージョンを更新中..." } }, @@ -707,6 +721,7 @@ }, "network": { "bypassRules": "プロキシバイパスルール", + "bypassRulesTitle": "プロキシバイパスルール", "bypassRulesDescription": "これらのルールに一致するリクエストは、プロキシをバイパスして直接接続します。", "addRule": "ルールを追加", "rulePlaceholder": "例: example.com, 192.168.1.*, .*\\.local", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 40060df..3318234 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -340,6 +340,19 @@ "logoutConfirm": "Tem certeza de que deseja sair? A sincronização na nuvem será interrompida.", "loginSuccess": "Login realizado com sucesso!", "logoutSuccess": "Logout realizado com sucesso." + }, + "team": { + "title": "Equipe", + "name": "Nome da Equipe", + "role": "Função", + "roleOwner": "Proprietário", + "roleAdmin": "Administrador", + "roleMember": "Membro", + "manageOnWeb": "Gerenciar equipe no painel web", + "profileLocked": "Em uso por {{email}}", + "profileLockedShort": "Em uso", + "cannotLaunchLocked": "Não é possível iniciar — o perfil está em uso por {{email}}", + "createdBy": "Criado por {{email}}" } }, "integrations": { @@ -504,6 +517,7 @@ "verifying": "Verificando {{browser}} {{version}}", "syncing": "Sincronizando...", "syncingProfile": "Sincronizando perfil '{{name}}'...", + "syncingProfileWithProgress": "{{count}} arquivos ({{size}})", "updatingVersions": "Atualizando versões de navegadores..." } }, @@ -707,6 +721,7 @@ }, "network": { "bypassRules": "Regras de Bypass de Proxy", + "bypassRulesTitle": "Regras de Bypass de Proxy", "bypassRulesDescription": "Solicitações que correspondam a estas regras se conectarão diretamente, ignorando o proxy.", "addRule": "Adicionar Regra", "rulePlaceholder": "ex. example.com, 192.168.1.*, .*\\.local", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index bf6f1e5..98585fb 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -340,6 +340,19 @@ "logoutConfirm": "Вы уверены, что хотите выйти? Облачная синхронизация будет остановлена.", "loginSuccess": "Вход выполнен успешно!", "logoutSuccess": "Выход выполнен успешно." + }, + "team": { + "title": "Команда", + "name": "Название команды", + "role": "Роль", + "roleOwner": "Владелец", + "roleAdmin": "Администратор", + "roleMember": "Участник", + "manageOnWeb": "Управление командой в веб-панели", + "profileLocked": "Используется {{email}}", + "profileLockedShort": "Используется", + "cannotLaunchLocked": "Невозможно запустить — профиль используется {{email}}", + "createdBy": "Создано {{email}}" } }, "integrations": { @@ -504,6 +517,7 @@ "verifying": "Проверка {{browser}} {{version}}", "syncing": "Синхронизация...", "syncingProfile": "Синхронизация профиля '{{name}}'...", + "syncingProfileWithProgress": "{{count}} файлов ({{size}})", "updatingVersions": "Обновление версий браузеров..." } }, @@ -707,6 +721,7 @@ }, "network": { "bypassRules": "Правила обхода прокси", + "bypassRulesTitle": "Правила обхода прокси", "bypassRulesDescription": "Запросы, соответствующие этим правилам, будут подключаться напрямую, минуя прокси.", "addRule": "Добавить правило", "rulePlaceholder": "напр. example.com, 192.168.1.*, .*\\.local", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 52e6c81..a1420b1 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -340,6 +340,19 @@ "logoutConfirm": "您确定要退出登录吗?云同步将会停止。", "loginSuccess": "登录成功!", "logoutSuccess": "已成功退出登录。" + }, + "team": { + "title": "团队", + "name": "团队名称", + "role": "角色", + "roleOwner": "所有者", + "roleAdmin": "管理员", + "roleMember": "成员", + "manageOnWeb": "在网页控制台管理团队", + "profileLocked": "{{email}} 正在使用中", + "profileLockedShort": "使用中", + "cannotLaunchLocked": "无法启动 — 配置文件正被 {{email}} 使用", + "createdBy": "由 {{email}} 创建" } }, "integrations": { @@ -504,6 +517,7 @@ "verifying": "正在验证 {{browser}} {{version}}", "syncing": "同步中...", "syncingProfile": "正在同步配置文件 '{{name}}'...", + "syncingProfileWithProgress": "{{count}} 个文件 ({{size}})", "updatingVersions": "正在更新浏览器版本..." } }, @@ -707,6 +721,7 @@ }, "network": { "bypassRules": "代理绕过规则", + "bypassRulesTitle": "代理绕过规则", "bypassRulesDescription": "匹配这些规则的请求将直接连接,绕过代理。", "addRule": "添加规则", "rulePlaceholder": "例如 example.com, 192.168.1.*, .*\\.local", diff --git a/src/lib/toast-utils.ts b/src/lib/toast-utils.ts index 8cd78c8..a45ee83 100644 --- a/src/lib/toast-utils.ts +++ b/src/lib/toast-utils.ts @@ -232,6 +232,38 @@ export function dismissToast(id: string) { sonnerToast.dismiss(id); } +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + const i = Math.min( + Math.floor(Math.log(bytes) / Math.log(1024)), + units.length - 1, + ); + const value = bytes / 1024 ** i; + return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`; +} + +export function showSyncProgressToast( + profileName: string, + totalFiles: number, + totalBytes: number, + options?: { id?: string }, +) { + const description = `${totalFiles} files (${formatBytes(totalBytes)})`; + return showToast({ + type: "loading", + title: `Syncing profile '${profileName}'...`, + description, + id: options?.id, + duration: Number.POSITIVE_INFINITY, + onCancel: () => { + if (options?.id) { + dismissToast(options.id); + } + }, + }); +} + export function showUnifiedVersionUpdateToast( title: string, options?: { diff --git a/src/types.ts b/src/types.ts index 7e4267c..9005a5d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -33,6 +33,8 @@ export interface BrowserProfile { ephemeral?: boolean; extension_group_id?: string; proxy_bypass_rules?: string[]; + created_by_id?: string; + created_by_email?: string; } export interface Extension { @@ -77,6 +79,17 @@ export interface CloudUser { proxyBandwidthLimitMb: number; proxyBandwidthUsedMb: number; proxyBandwidthExtraMb: number; + teamId?: string; + teamName?: string; + teamRole?: string; +} + +export interface ProfileLockInfo { + profileId: string; + lockedBy: string; + lockedByEmail: string; + lockedAt: string; + expiresAt?: string; } export interface CloudAuthState {