mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-15 19:20:14 +02:00
feat: teams plan
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<SubscribeEventDto> {
|
||||
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<string, string>();
|
||||
@@ -554,16 +561,33 @@ export class SyncService implements OnModuleInit {
|
||||
private async checkProfileLimit(ctx: UserContext): Promise<void> {
|
||||
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<number> {
|
||||
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}`),
|
||||
);
|
||||
|
||||
@@ -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::<u16>().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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(rename = "teamName", default)]
|
||||
pub team_name: Option<String>,
|
||||
#[serde(rename = "teamRole", default)]
|
||||
pub team_role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -249,7 +255,7 @@ impl CloudAuthManager {
|
||||
Self::encrypt_and_store(&path, b"DBCAT", token)
|
||||
}
|
||||
|
||||
fn load_access_token() -> Result<Option<String>, String> {
|
||||
pub(crate) fn load_access_token() -> Result<Option<String>, 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<CloudAuthState> {
|
||||
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;
|
||||
|
||||
@@ -275,6 +275,8 @@ mod tests {
|
||||
ephemeral,
|
||||
extension_group_id: None,
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<serde_json::Value, McpError> {
|
||||
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<serde_json::Value, McpError> {
|
||||
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]
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -61,6 +61,10 @@ pub struct BrowserProfile {
|
||||
pub extension_group_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub proxy_bypass_rules: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub created_by_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_by_email: Option<String>,
|
||||
}
|
||||
|
||||
pub fn default_release_type() -> String {
|
||||
|
||||
@@ -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
|
||||
|
||||
+149
-28
@@ -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<String>)> = 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<String> = 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<bool> {
|
||||
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<Vec<String>> {
|
||||
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<String> = Vec::new();
|
||||
|
||||
// Extract unique profile IDs from the list
|
||||
let mut profile_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
// Extract unique profile IDs with their key prefix
|
||||
let mut profiles_to_check: HashMap<String, String> = 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<String>)> = 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 ({:?} -> {:?})",
|
||||
|
||||
@@ -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<SyncWorkItem>) {
|
||||
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/")
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct AcquireLockResponse {
|
||||
success: bool,
|
||||
#[serde(rename = "lockedBy")]
|
||||
locked_by: Option<String>,
|
||||
#[serde(rename = "lockedByEmail")]
|
||||
locked_by_email: Option<String>,
|
||||
}
|
||||
|
||||
pub struct TeamLockManager {
|
||||
locks: RwLock<HashMap<String, ProfileLockInfo>>,
|
||||
heartbeat_handle: Mutex<Option<JoinHandle<()>>>,
|
||||
connected_team_id: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
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<ProfileLockInfo> {
|
||||
let locks = self.locks.read().await;
|
||||
locks.values().cloned().collect()
|
||||
}
|
||||
|
||||
pub async fn get_lock_status(&self, profile_id: &str) -> Option<ProfileLockInfo> {
|
||||
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<ProfileLockInfo> = 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<String> = {
|
||||
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<String, String> {
|
||||
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<Vec<ProfileLockInfo>, String> {
|
||||
Ok(TEAM_LOCK.get_locks().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_team_lock_status(profile_id: String) -> Result<Option<ProfileLockInfo>, String> {
|
||||
Ok(TEAM_LOCK.get_lock_status(&profile_id).await)
|
||||
}
|
||||
+27
-14
@@ -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<Set<string>>(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]);
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
|
||||
// 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<string, string | null>
|
||||
@@ -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<string>) =>
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-2 py-1 mr-auto text-left bg-transparent rounded border-none w-30 h-6",
|
||||
isDisabled
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
: "cursor-pointer hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isDisabled) return;
|
||||
meta.setProfileToRename(profile);
|
||||
meta.setNewProfileName(profile.name);
|
||||
meta.setRenameError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (isDisabled) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-2 py-1 mr-auto text-left bg-transparent rounded border-none w-30 h-6",
|
||||
isDisabled
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
: "cursor-pointer hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isDisabled) return;
|
||||
meta.setProfileToRename(profile);
|
||||
meta.setNewProfileName(profile.name);
|
||||
meta.setRenameError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{display}
|
||||
</button>
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (isDisabled) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
meta.setProfileToRename(profile);
|
||||
meta.setNewProfileName(profile.name);
|
||||
meta.setRenameError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{display}
|
||||
</button>
|
||||
{isLocked && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<LuLock className="w-3 h-3 text-muted-foreground" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{meta.t("sync.team.profileLocked", { email: lockedEmail })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
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: <LuShieldCheck className="w-4 h-4" />,
|
||||
label: t("profileInfo.network.bypassRulesTitle"),
|
||||
onClick: () => setBypassRulesDialogOpen(true),
|
||||
},
|
||||
{
|
||||
icon: <LuTrash2 className="w-4 h-4" />,
|
||||
label: t("profiles.actions.delete"),
|
||||
@@ -395,124 +411,166 @@ export function ProfileInfoDialog({
|
||||
const visibleActions = actions.filter((a) => !a.hidden);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("profileInfo.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="info">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="info" className="flex-1">
|
||||
{t("profileInfo.tabs.info")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="flex-1">
|
||||
{t("profileInfo.tabs.settings")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="info">
|
||||
<div className="flex flex-col items-center gap-1 py-3">
|
||||
<ProfileIcon className="w-12 h-12 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold">{profile.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{getBrowserDisplayName(profile.browser)} {profile.version}
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-3 py-2">
|
||||
{infoFields.map((field) => (
|
||||
<React.Fragment key={field.label}>
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{field.label}
|
||||
</span>
|
||||
<span className="text-sm">{field.value}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="settings">
|
||||
<div className="flex flex-col py-1">
|
||||
{visibleActions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
type="button"
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors text-left w-full",
|
||||
"hover:bg-accent disabled:opacity-50 disabled:pointer-events-none",
|
||||
action.destructive &&
|
||||
"text-destructive hover:bg-destructive/10",
|
||||
)}
|
||||
>
|
||||
{action.icon}
|
||||
<span className="flex-1 flex items-center gap-2">
|
||||
{action.label}
|
||||
{action.proBadge && <ProBadge />}
|
||||
</span>
|
||||
<LuChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t my-2" />
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">
|
||||
{t("profileInfo.network.bypassRules")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("profileInfo.network.bypassRulesDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newRule}
|
||||
onChange={(e) => setNewRule(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAddRule();
|
||||
}}
|
||||
placeholder={t("profileInfo.network.rulePlaceholder")}
|
||||
className="flex-1 text-sm"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAddRule}
|
||||
disabled={!newRule.trim()}
|
||||
>
|
||||
<LuPlus className="w-4 h-4 mr-1" />
|
||||
{t("profileInfo.network.addRule")}
|
||||
</Button>
|
||||
</div>
|
||||
{bypassRules.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
{t("profileInfo.network.noRules")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5 max-h-48 overflow-y-auto">
|
||||
{bypassRules.map((rule) => (
|
||||
<div
|
||||
key={rule}
|
||||
className="flex items-center justify-between gap-2 px-3 py-1.5 rounded-md bg-muted text-sm"
|
||||
>
|
||||
<span className="font-mono text-xs truncate">{rule}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveRule(rule)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
|
||||
>
|
||||
<LuX className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("profileInfo.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="info" className="flex-1 min-h-0 flex flex-col">
|
||||
<TabsList className="w-full shrink-0">
|
||||
<TabsTrigger value="info" className="flex-1">
|
||||
{t("profileInfo.tabs.info")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="flex-1">
|
||||
{t("profileInfo.tabs.settings")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="info" className="flex-1 min-h-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="flex flex-col items-center gap-1 py-3">
|
||||
<ProfileIcon className="w-12 h-12 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold">{profile.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{getBrowserDisplayName(profile.browser)} {profile.version}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-3 py-2">
|
||||
{infoFields.map((field) => (
|
||||
<React.Fragment key={field.label}>
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{field.label}
|
||||
</span>
|
||||
<span className="text-sm">{field.value}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("profileInfo.network.ruleTypes")}
|
||||
</p>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
<TabsContent value="settings" className="flex-1 min-h-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="flex flex-col py-1">
|
||||
{visibleActions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
type="button"
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors text-left w-full",
|
||||
"hover:bg-accent disabled:opacity-50 disabled:pointer-events-none",
|
||||
action.destructive &&
|
||||
"text-destructive hover:bg-destructive/10",
|
||||
)}
|
||||
>
|
||||
{action.icon}
|
||||
<span className="flex-1 flex items-center gap-2">
|
||||
{action.label}
|
||||
{action.proBadge && <ProBadge />}
|
||||
</span>
|
||||
<LuChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("common.buttons.close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ProfileBypassRulesDialog
|
||||
isOpen={bypassRulesDialogOpen}
|
||||
onClose={() => 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 (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-lg max-h-[80vh] flex flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("profileInfo.network.bypassRulesTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("profileInfo.network.bypassRulesDescription")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newRule}
|
||||
onChange={(e) => onNewRuleChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onAddRule();
|
||||
}}
|
||||
placeholder={t("profileInfo.network.rulePlaceholder")}
|
||||
className="flex-1 text-sm"
|
||||
/>
|
||||
<Button size="sm" onClick={onAddRule} disabled={!newRule.trim()}>
|
||||
<LuPlus className="w-4 h-4 mr-1" />
|
||||
{t("profileInfo.network.addRule")}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<DialogFooter>
|
||||
{bypassRules.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
{t("profileInfo.network.noRules")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{bypassRules.map((rule) => (
|
||||
<div
|
||||
key={rule}
|
||||
className="flex items-center justify-between gap-2 px-3 py-1.5 rounded-md bg-muted text-sm"
|
||||
>
|
||||
<span className="font-mono text-xs truncate">{rule}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveRule(rule)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
|
||||
>
|
||||
<LuX className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("profileInfo.network.ruleTypes")}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("common.buttons.close")}
|
||||
</Button>
|
||||
|
||||
@@ -309,6 +309,31 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{user.teamName && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("sync.team.name")}
|
||||
</span>
|
||||
<span>{user.teamName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("sync.team.role")}
|
||||
</span>
|
||||
<span className="capitalize">
|
||||
{user.teamRole === "owner"
|
||||
? t("sync.team.roleOwner")
|
||||
: user.teamRole === "admin"
|
||||
? t("sync.team.roleAdmin")
|
||||
: t("sync.team.roleMember")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground pt-1">
|
||||
{t("sync.team.manageOnWeb")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
|
||||
@@ -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<ProfileLockInfo[]>([]);
|
||||
|
||||
const fetchLocks = useCallback(async () => {
|
||||
try {
|
||||
const result = await invoke<ProfileLockInfo[]>("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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user