use super::{ team::{DiscoverableTeam, MembershipRole, Team}, workspace::{ AdminEnablementSetting, CustomerType, EnterpriseSecretRegex, HostEnablementSetting, UgcCollectionEnablementSetting, Workspace, WorkspaceUid, }, }; use crate::{ ai::llms::LLMModelHost, auth::{AuthStateProvider, UserUid}, channel::ChannelState, cloud_object::{ model::persistence::CloudModel, CloudObjectEventEntrypoint, ObjectType, Owner, Space, }, pricing::PricingInfoModel, report_error, server::{ experiments::{ServerExperiment, ServerExperiments, ServerExperimentsEvent}, ids::ServerId, server_api::{team::TeamClient, workspace::WorkspaceClient}, }, settings::{ AISettings, AISettingsChangedEvent, CodeSettings, CodeSettingsChangedEvent, PrivacySettings, }, workspaces::workspace::{ AiAutonomySettings, AiOverages, SandboxedAgentSettings, UsageBasedPricingSettings, }, }; use anyhow::Result; use galaxy_core::{ features::FeatureFlag, settings::{ChangeEventReason, Setting}, }; use galaxy_graphql::workspace::FeatureModelChoice; use galaxyui::{AppContext, Entity, ModelContext, SingletonEntity, Tracked}; use regex::Regex; use std::sync::Arc; #[cfg(test)] use crate::server::server_api::{team::MockTeamClient, workspace::MockWorkspaceClient}; #[cfg(test)] use crate::workspaces::workspace::{ AIAutonomyPolicy, BillingMetadata, WorkspaceMember, WorkspaceSettings, }; #[cfg(test)] use super::workspace::WorkspaceMemberUsageInfo; const STRIPE_SUBSCRIPTION_INTERVAL_PAGE_PREFIX: &str = "/upgrade"; #[derive(Debug)] pub enum UserWorkspacesEvent { AddDomainRestrictionsSuccess, AddDomainRestrictionsRejected(anyhow::Error), DeleteDomainRestrictionSuccess, DeleteDomainRestrictionRejected(anyhow::Error), EmailInviteSent, EmailInviteRejected(anyhow::Error), ToggleInviteLinksSuccess, ToggleInviteLinksRejected(anyhow::Error), ResetInviteLinks, ResetInviteLinksRejected(anyhow::Error), DeleteTeamInvite, DeleteTeamInviteRejected(anyhow::Error), GenerateUpgradeLink(String), GenerateUpgradeLinkRejected(anyhow::Error), GenerateStripeBillingPortalLink(String), GenerateStripeBillingPortalLinkRejected(anyhow::Error), ToggleTeamDiscoverabilitySuccess, ToggleTeamDiscoverabilityRejected(anyhow::Error), JoinTeamWithTeamDiscoverySuccess, JoinTeamWithTeamDiscoveryRejected(anyhow::Error), FetchDiscoverableTeamsSuccess(Vec), FetchDiscoverableTeamsRejected(anyhow::Error), TransferTeamOwnershipSuccess, TransferTeamOwnershipRejected(anyhow::Error), SetTeamMemberRoleSuccess, SetTeamMemberRoleRejected(anyhow::Error), UpdateWorkspaceSettingsSuccess, UpdateWorkspaceSettingsRejected(anyhow::Error), AiOveragesUpdated, PurchaseAddonCreditsSuccess, PurchaseAddonCreditsRejected(anyhow::Error), /// Fired whenever the set of teams the user is on changes. TeamsChanged, CodebaseContextEnablementChanged, /// Fired when a service agreement's sunsetted_to_build_ts field is updated. SunsettedToBuildDataUpdated, } /// UserWorkspaces is a singleton model that holds workspace metadata (name, members, etc). /// It should be used for getting information about the workspaces, teams, current teams, /// and all other things related to operating on workspace and team data. /// TODO: move other server_api calls to update_manager to correctly update sqlite. pub struct UserWorkspaces { current_workspace_uid: Tracked>, workspaces: Tracked>, joinable_teams: Vec, team_client: Arc, workspace_client: Arc, } /// Represents the workspaces a user potentially has access to. #[derive(Clone)] pub struct WorkspacesMetadataResponse { /// The list of workspaces the user is currently on. pub workspaces: Vec, /// The list of discoverable teams that the user can join. pub joinable_teams: Vec, /// The list of experiments applicable to the user. pub experiments: Option>, /// TODO(Tyler): Post-workspaces, move this into the workspace object. /// Feature model choices may change from user to user and while the app is open, so we need to periodically update this list. /// It makes most sense to fetch this in workspaces which is queried every 10 minutes. /// This is list of available LLM models for the user. pub feature_model_choices: Option, } // A representation of all data we fetch at a single time via our 10 minute poll. // Prefer adding to this struct if you need relatively fresh data vs making // independent queries. pub struct WorkspacesMetadataWithPricing { pub metadata: WorkspacesMetadataResponse, pub pricing_info: Option, } pub struct CreateTeamResponse { pub workspace: Workspace, pub team: Team, } impl UserWorkspaces { #[cfg(test)] pub fn mock( team_client: Arc, workspace_client: Arc, cached_workspaces: Vec, _ctx: &mut ModelContext, ) -> Self { // In tests, avoid subscribing to [`ServerExperiments`] because it // requires us to register that singleton along with _its_ dependencies // for all tests that use [`UserWorkspaces`] (a lot of them do). Self { current_workspace_uid: cached_workspaces.first().map(|w| w.uid).into(), workspaces: cached_workspaces.into(), joinable_teams: Default::default(), team_client, workspace_client, } } #[cfg(test)] pub fn default_mock(ctx: &mut ModelContext) -> Self { Self::mock( Arc::new(MockTeamClient::new()), Arc::new(MockWorkspaceClient::new()), vec![], ctx, ) } pub fn new( team_client: Arc, workspace_client: Arc, cached_workspaces: Vec, current_workspace_uid: Option, ctx: &mut ModelContext, ) -> Self { ctx.subscribe_to_model(&ServerExperiments::handle(ctx), |me, event, ctx| { let ServerExperimentsEvent::ExperimentsUpdated = event; me.update_session_sharing_enablement(ctx); }); ctx.subscribe_to_model(&CodeSettings::handle(ctx), |_, code_settings_event, ctx| { match code_settings_event { CodeSettingsChangedEvent::CodebaseContextEnabled { .. } | CodeSettingsChangedEvent::AutoIndexingEnabled { .. } => { ctx.emit(UserWorkspacesEvent::CodebaseContextEnablementChanged); } _ => {} } }); ctx.subscribe_to_model(&AISettings::handle(ctx), |_, ai_settings_event, ctx| { if let AISettingsChangedEvent::IsAnyAIEnabled { .. } = ai_settings_event { ctx.emit(UserWorkspacesEvent::CodebaseContextEnablementChanged); } }); Self { current_workspace_uid: current_workspace_uid.into(), workspaces: cached_workspaces.into(), joinable_teams: Default::default(), team_client, workspace_client, } } pub fn upgrade_link(user_id: UserUid) -> String { format!( "{}{}/{}/{}", ChannelState::server_root_url(), STRIPE_SUBSCRIPTION_INTERVAL_PAGE_PREFIX, "user", user_id.as_str() ) } pub fn upgrade_link_for_team(team_uid: ServerId) -> String { format!( "{}{}/{}", ChannelState::server_root_url(), STRIPE_SUBSCRIPTION_INTERVAL_PAGE_PREFIX, team_uid ) } pub fn team_from_uid(&self, team_uid: ServerId) -> Option<&Team> { self.current_workspace() .and_then(|w| w.teams.iter().find(|t| t.uid == team_uid)) } pub fn team_from_uid_across_all_workspaces(&self, team_uid: ServerId) -> Option<&Team> { self.workspaces .iter() .flat_map(|w| w.teams.iter()) .find(|t| t.uid == team_uid) } pub fn workspace_from_uid(&self, workspace_uid: WorkspaceUid) -> Option<&Workspace> { self.workspaces.iter().find(|w| w.uid == workspace_uid) } pub fn workspace_from_uid_mut( &mut self, workspace_uid: WorkspaceUid, ) -> Option<&mut Workspace> { self.workspaces.iter_mut().find(|w| w.uid == workspace_uid) } pub fn is_at_tier_limit_for_object_type( team_uid: ServerId, object_type: ObjectType, ctx: &AppContext, ) -> bool { match object_type { ObjectType::Notebook => { !UserWorkspaces::has_capacity_for_shared_notebooks(team_uid, ctx, 1) } ObjectType::Workflow => { !UserWorkspaces::has_capacity_for_shared_workflows(team_uid, ctx, 1) } ObjectType::Folder => false, ObjectType::GenericStringObject(_) => false, } } pub fn is_at_tier_limit_for_some_warp_drive_objects( team_uid: ServerId, ctx: &AppContext, ) -> bool { UserWorkspaces::is_at_tier_limit_for_object_type(team_uid, ObjectType::Notebook, ctx) || UserWorkspaces::is_at_tier_limit_for_object_type(team_uid, ObjectType::Workflow, ctx) } // Checks if the team has capacity for another shared notebook for their current // billing tier, given their current notebook count and delinquency status. pub fn has_capacity_for_shared_notebooks( team_uid: ServerId, ctx: &AppContext, new_shared_notebooks: usize, ) -> bool { let current_shared_notebooks = CloudModel::as_ref(ctx) .active_notebooks_in_space(Space::Team { team_uid }, ctx) .count(); let team = UserWorkspaces::as_ref(ctx).team_from_uid(team_uid); if let Some(team) = team { // If the team is past due or unpaid, then don't allow new notebooks. if team.billing_metadata.is_delinquent_due_to_payment_issue() { return false; } if let Some(policy) = team.billing_metadata.tier.shared_notebooks_policy { // Allow new notebooks if policy is unlimited or if the number of notebooks // is less than the limit. policy.is_unlimited || current_shared_notebooks + new_shared_notebooks <= policy .limit .try_into() .expect("shared notebooks limit should be within max i64 range") } else { // If no policy is set, then allow it to go through by default (should still be enforced server-side) true } } else { // If the team is not found, then allow it to go through by default (should still be enforced server-side) true } } // Checks if the team has capacity for another shared workflow for their current // billing tier, given their current workflow count and delinquency status. pub fn has_capacity_for_shared_workflows( team_uid: ServerId, ctx: &AppContext, new_shared_workflows: usize, ) -> bool { let current_shared_workflows = CloudModel::as_ref(ctx) .active_workflows_in_space(Space::Team { team_uid }, ctx) .count(); let team = UserWorkspaces::as_ref(ctx).team_from_uid(team_uid); if let Some(team) = team { // If the team is past due or unpaid, then don't allow new workflows. if team.billing_metadata.is_delinquent_due_to_payment_issue() { return false; } if let Some(policy) = team.billing_metadata.tier.shared_workflows_policy { // Allow new workflows if policy is unlimited or if the number of workflows // is less than the limit. policy.is_unlimited || current_shared_workflows + new_shared_workflows <= policy .limit .try_into() .expect("shared workflows limit should be within max i64 range") } else { // If no policy is set, then allow it to go through by default (should still be enforced server-side) true } } else { // If the team is not found, then allow it to go through by default (should still be enforced server-side) true } } /// Return the uid of user's current team (if any) without refreshing. pub fn current_team_uid(&self) -> Option { self.current_team().map(|t| t.uid) } pub fn current_team_mut(&mut self) -> Option<&mut Team> { self.current_workspace_mut() .and_then(|w| w.teams.first_mut()) } /// Note that the team is populated with dummy data until /// the initial fetch completes (only team name and ID are cached in sqlite locally). /// Consider whether you need to wait for the results of the fetch before checking the /// values of other fields. pub fn current_team(&self) -> Option<&Team> { self.current_workspace().and_then(|w| w.teams.first()) } /// Note that the workspace is populated with dummy data until the initial fetch /// completes (only workspace name/ID and workspace team's name/ID are cached in /// sqlite locally). /// Consider whether you need to wait for the results of the fetch before checking the /// values of other fields. pub fn current_workspace(&self) -> Option<&Workspace> { self.current_workspace_uid .and_then(|workspace_uid| self.workspace_from_uid(workspace_uid)) } pub fn current_workspace_mut(&mut self) -> Option<&mut Workspace> { self.current_workspace_uid .and_then(|workspace_uid| self.workspace_from_uid_mut(workspace_uid)) } pub fn workspaces(&self) -> &Vec { &self.workspaces } pub fn set_current_workspace_uid( &mut self, workspace_uid: WorkspaceUid, ctx: &mut ModelContext, ) { *self.current_workspace_uid = Some(workspace_uid); self.notify_and_emit_teams_changed(ctx); } /// Returns `true` if active AI is allowed for the current workspace, based on billing config. /// /// In the future, we should store active AI enablement on the policy directly. For now, we /// proxy whether active AI by checking if prompt suggestions, next command, or code suggestions are enabled. pub fn is_active_ai_allowed(&self) -> bool { self.current_team().is_none_or(|team| { team.billing_metadata .tier .warp_ai_policy .is_none_or(|policy| { policy.is_prompt_suggestions_toggleable || policy.is_next_command_enabled || policy.is_code_suggestions_toggleable }) }) } /// Returns `true` if the current team's enterprise status allows AI features that have an /// enterprise gate. Non-enterprise teams always pass; enterprise teams pass only if they /// are on the Warp Plan or the build is dogfood (both our internal Warp team and dogfood /// team are billed as enterprise). pub fn ai_allowed_for_current_team(&self) -> bool { !self .current_team() .is_some_and(|team| team.billing_metadata.customer_type == CustomerType::Enterprise) || self .current_team() .is_some_and(|team| team.billing_metadata.is_warp_plan()) || ChannelState::channel().is_dogfood() } /// Whether Prompt Suggestions should be toggleable for the current user, based on the active policies. /// Note that the value may be incorrect if called before the team's billing metadata has been fetched. pub fn is_prompt_suggestions_toggleable(&self) -> bool { self.current_team() // If the user has no team, they can toggle prompt suggestions (no restrictions). .is_none_or(|team| { team.billing_metadata .tier .warp_ai_policy .is_some_and(|policy| policy.is_prompt_suggestions_toggleable) }) } /// Whether Code Suggestions should be toggleable for the current user, based on the active policies. /// Note that the value may be incorrect if called before the team's billing metadata has been fetched. pub fn is_code_suggestions_toggleable(&self) -> bool { self.current_team() // If the user has no team, they can toggle code suggestions (no restrictions). .is_none_or(|team| { team.billing_metadata .tier .warp_ai_policy .is_some_and(|policy| policy.is_code_suggestions_toggleable) }) } /// Whether Next Command should be toggleable for the current user, based on the active policies. /// Note that the value may be incorrect if called before the team's billing metadata has been fetched. pub fn is_next_command_enabled(&self) -> bool { self.current_team() // If the user has no team, they can toggle Next Command (no restrictions). .is_none_or(|team| { team.billing_metadata .tier .warp_ai_policy .is_some_and(|policy| policy.is_next_command_enabled) }) } /// Whether voice input should be toggleable for the current user, based on the active policies. /// Note that the value may be incorrect if called before the team's billing metadata has been fetched. /// If voice input support is not compiled into this build, always returns `false`. pub fn is_voice_enabled(&self) -> bool { cfg!(feature = "voice_input") && self .current_team() // If the user has no team, they can toggle Voice (no restrictions). .is_none_or(|team| { team.billing_metadata .tier .warp_ai_policy .is_some_and(|policy| policy.is_voice_enabled) }) } /// Whether BYO API key is enabled for the current user, based on the active policies. /// Note that the value may be incorrect if called before the team's billing metadata has been fetched. /// For solo users (no workspace), this is controlled by the `SoloUserByok` feature flag. pub fn is_byo_api_key_enabled(&self) -> bool { self.current_workspace() .map(|workspace| workspace.is_byo_api_key_enabled()) .unwrap_or(FeatureFlag::SoloUserByok.is_enabled()) } pub fn aws_bedrock_host_settings(&self) -> Option<&super::workspace::LlmHostSettings> { self.current_workspace().and_then(|workspace| { workspace .settings .llm_settings .host_configs .get(&LLMModelHost::AwsBedrock) }) } /// Did the admin enable AWS Bedrock for the current workspace? pub fn is_aws_bedrock_available_from_workspace(&self) -> bool { self.current_workspace().is_some_and(|workspace| { workspace.settings.llm_settings.enabled && self .aws_bedrock_host_settings() .is_some_and(|settings| settings.enabled) }) } pub fn aws_bedrock_host_enablement_setting(&self) -> HostEnablementSetting { self.aws_bedrock_host_settings() .map(|settings| settings.enablement_setting.clone()) .unwrap_or_default() } pub fn is_aws_bedrock_credentials_toggleable(&self) -> bool { matches!( self.aws_bedrock_host_enablement_setting(), HostEnablementSetting::RespectUserSetting ) } pub fn is_bedrock_enabled(&self, app: &AppContext) -> bool { // i.e. did the admin go and toggle on aws bedrock in the admin panel? if !self.is_aws_bedrock_available_from_workspace() { return false; } match self.aws_bedrock_host_enablement_setting() { HostEnablementSetting::Enforce => true, HostEnablementSetting::RespectUserSetting => { *AISettings::as_ref(app).bedrock_enabled.value() } } } /// Returns the AI autonomy settings that are enforced by the workspace for all its members. /// If a setting is `None`, the workspace doesn't enforce a particular setting. pub fn ai_autonomy_settings(&self) -> AiAutonomySettings { self.current_team() .map(|team| team.organization_settings.ai_autonomy_settings.clone()) .unwrap_or_default() } /// Returns the sandboxed agent settings enforced by the workspace, if any. pub fn sandboxed_agent_settings(&self) -> Option { self.current_team() .and_then(|team| team.organization_settings.sandboxed_agent_settings.clone()) } /// Returns true iff AI autonomy features are allowed for this client. /// TODO: This should be deleted soon. AI autonomy settings have been moved into organization /// settings (see `ai_autonomy_settings` above), but there could be an interim time where we /// have not set up the org settings yet for an enterprise that previously had the entire /// feature set disabled. To capture that case, we'll see if all the settings are `None`; /// if so, we'll fall back to their billing metadata's value. Once we've migrated everyone /// into org settings, we should remove `is_enabled` from the policy and delete this function. pub fn is_ai_autonomy_allowed(&self) -> bool { self.current_team().is_none_or(|team| { let settings = &team.organization_settings.ai_autonomy_settings; let all_settings_none = settings.apply_code_diffs_setting.is_none() && settings.read_files_setting.is_none() && settings.read_files_allowlist.is_none() && settings.execute_commands_setting.is_none() && settings.execute_commands_allowlist.is_none() && settings.execute_commands_denylist.is_none(); if all_settings_none { team.billing_metadata .tier .ai_autonomy_policy .is_some_and(|policy| policy.is_enabled) } else { true } }) } // Returns a Vec of the user's active spaces, based on their // team membership. pub fn team_spaces(&self) -> Vec { if let Some(workspace) = self.current_workspace() { workspace .teams .iter() .map(|team| Space::Team { team_uid: team.uid }) .collect() } else { // If the user has no workspace, they have no team spaces. vec![] } } pub fn total_teammates_in_joinable_teams(&self) -> i64 { self.joinable_teams .iter() .map(|team| team.num_members) .sum() } pub fn num_joinable_teams(&self) -> usize { self.joinable_teams.len() } // Returns a Vec of the user's active spaces, based on their // team membership. Includes the "Personal Space" by default. pub fn all_user_spaces(&self, ctx: &AppContext) -> Vec { if AuthStateProvider::as_ref(ctx) .get() .is_user_web_anonymous_user() .unwrap_or_default() { return vec![Space::Shared]; } let mut spaces = Vec::new(); spaces.extend(self.team_spaces().iter()); if FeatureFlag::SharedWithMe.is_enabled() && CloudModel::as_ref(ctx).has_directly_shared_objects(self, ctx) { spaces.push(Space::Shared); } spaces.push(Space::Personal); spaces } // Returns the [`Owner`] for the user's personal drive. If the user is not authenticated, this // returns `None`. pub fn personal_drive(&self, ctx: &AppContext) -> Option { AuthStateProvider::as_ref(ctx) .get() .user_id() .map(|user_uid| Owner::User { user_uid }) } // Maps a [`Space`] into an [`Owner`], based on the user's team memberships. If the space // does not directly identify an owner (it's the space for shared objects), returns `None`. pub fn space_to_owner(&self, space: Space, ctx: &AppContext) -> Option { match space { Space::Team { team_uid } => Some(Owner::Team { team_uid }), Space::Personal => self.personal_drive(ctx), Space::Shared => None, } } // Maps an [`Owner`] into a [`Space`], based on the user's team memberships. // This is always possible, as unknown owners imply the shared space. pub fn owner_to_space(&self, owner: Owner, ctx: &AppContext) -> Space { match owner { Owner::User { user_uid } => { if !FeatureFlag::SharedWithMe.is_enabled() { return Space::Personal; } let current_user = AuthStateProvider::as_ref(ctx).get().user_id(); if Some(user_uid) == current_user { Space::Personal } else { Space::Shared } } Owner::Team { team_uid } => { if !FeatureFlag::SharedWithMe.is_enabled() || self.team_from_uid_across_all_workspaces(team_uid).is_some() { Space::Team { team_uid } } else { Space::Shared } } } } pub fn has_teams(&self) -> bool { if let Some(workspace) = self.current_workspace() { !workspace.teams.is_empty() } else { false } } pub fn has_workspaces(&self) -> bool { !self.workspaces.is_empty() } pub fn update_workspaces(&mut self, workspaces: Vec, ctx: &mut ModelContext) { // Check if sunsetted_to_build_ts changed for any workspace let sunsetted_to_build_changed = self.has_sunsetted_to_build_data_changed(&workspaces); *self.workspaces = workspaces; self.notify_and_emit_teams_changed(ctx); if sunsetted_to_build_changed { ctx.emit(UserWorkspacesEvent::SunsettedToBuildDataUpdated); } } /// Checks if any workspace's service agreement sunsetted_to_build_ts field has changed. fn has_sunsetted_to_build_data_changed(&self, new_workspaces: &[Workspace]) -> bool { for new_workspace in new_workspaces { // Find the corresponding old workspace let old_workspace = self.workspaces.iter().find(|w| w.uid == new_workspace.uid); if let Some(old_workspace) = old_workspace { // Check if any team's service agreement sunsetted_to_build_ts changed for new_team in &new_workspace.teams { let old_team = old_workspace.teams.iter().find(|t| t.uid == new_team.uid); if let Some(old_team) = old_team { let old_sunsetted = old_team .billing_metadata .service_agreements .first() .and_then(|sa| sa.sunsetted_to_build_ts); let new_sunsetted = new_team .billing_metadata .service_agreements .first() .and_then(|sa| sa.sunsetted_to_build_ts); // Detect if it changed from None to Some or changed value if old_sunsetted != new_sunsetted { return true; } } } } } false } fn notify_and_emit_teams_changed(&self, ctx: &mut ModelContext) { // Update session-sharing enablement since it depends on what teams the user // is part of. self.update_session_sharing_enablement(ctx); // PrivacySettings can't observe UserWorkspaces for updates, as it's initialized too early in // the app initialization flow. So, we update it manually whenever teams data changes. PrivacySettings::handle(ctx).update(ctx, |settings, ctx| { settings.set_is_telemetry_force_enabled(self.is_telemetry_force_enabled()); settings.set_enterprise_secret_redaction_settings( self.is_enterprise_secret_redaction_enabled(), self.get_enterprise_secret_redaction_regex_list(), ChangeEventReason::CloudSync, ctx, ); }); ctx.emit(UserWorkspacesEvent::TeamsChanged); ctx.emit(UserWorkspacesEvent::CodebaseContextEnablementChanged); ctx.notify(); } pub fn update_joinable_teams( &mut self, joinable_teams: Vec, ctx: &mut ModelContext, ) { self.joinable_teams.clone_from(&joinable_teams); ctx.emit(UserWorkspacesEvent::FetchDiscoverableTeamsSuccess( joinable_teams, )); ctx.notify(); } // TODO follow up with moving other modifying calls out of UserWorkspaces to TeamUpdateManager fn on_workspaces_updated( &mut self, result: Result, ctx: &mut ModelContext, ) { match result { Ok(response) => { if let Some(pricing_info) = response.pricing_info { PricingInfoModel::handle(ctx).update(ctx, |model, ctx| { model.update_pricing_info(pricing_info, ctx); }); } let workspaces = response.metadata.workspaces; let joinable_teams = response.metadata.joinable_teams; self.update_workspaces(workspaces.clone(), ctx); self.update_joinable_teams(joinable_teams, ctx); // Check if the current workspace is still in the list of workspaces. // If it's not, then set the current workspace to the first workspace in the list. if let Some(current_workspace) = self.current_workspace() { if !self .workspaces .iter() .any(|w| w.uid == current_workspace.uid) { if let Some(workspace_uid) = workspaces.first().map(|w| w.uid) { self.set_current_workspace_uid(workspace_uid, ctx); } } } else if let Some(workspace_uid) = workspaces.first().map(|w| w.uid) { self.set_current_workspace_uid(workspace_uid, ctx); } } Err(e) => { report_error!(e.context("Failed to load user workspaces")); } } } pub fn team_created( &mut self, create_team_response: &CreateTeamResponse, ctx: &mut ModelContext, ) { self.workspaces.push(create_team_response.workspace.clone()); self.set_current_workspace_uid(create_team_response.workspace.uid, ctx); self.notify_and_emit_teams_changed(ctx); } pub fn remove_user_from_team( &mut self, user_uid: UserUid, team_uid: ServerId, entrypoint: CloudObjectEventEntrypoint, ctx: &mut ModelContext, ) { let team_client = self.team_client.clone(); let _ = ctx.spawn( async move { team_client .remove_user_from_team(user_uid, team_uid, entrypoint) .await }, Self::on_workspaces_updated, ); } fn on_add_invite_link_domain_restrictions( &mut self, result: Result, ctx: &mut ModelContext, ) { match result { Err(err) => ctx.emit(UserWorkspacesEvent::AddDomainRestrictionsRejected(err)), Ok(result) => { self.on_workspaces_updated(Ok(result), ctx); ctx.emit(UserWorkspacesEvent::AddDomainRestrictionsSuccess); } }; ctx.notify(); } pub fn add_invite_link_domain_restrictions( &mut self, team_uid: ServerId, domains: Vec, ctx: &mut ModelContext, ) { for domain in domains { let team_client = self.team_client.clone(); let _ = ctx.spawn( async move { team_client .add_invite_link_domain_restriction(team_uid, domain) .await }, Self::on_add_invite_link_domain_restrictions, ); } } fn on_delete_invite_link_domain_restriction( &mut self, result: Result, ctx: &mut ModelContext, ) { match result { Err(err) => ctx.emit(UserWorkspacesEvent::DeleteDomainRestrictionRejected(err)), Ok(result) => { self.on_workspaces_updated(Ok(result), ctx); ctx.emit(UserWorkspacesEvent::DeleteDomainRestrictionSuccess); } }; ctx.notify(); } pub fn delete_invite_link_domain_restriction( &mut self, team_uid: ServerId, domain_uid: ServerId, ctx: &mut ModelContext, ) { let team_client = self.team_client.clone(); let _ = ctx.spawn( async move { team_client .delete_invite_link_domain_restriction(team_uid, domain_uid) .await }, Self::on_delete_invite_link_domain_restriction, ); } fn on_email_invite_sent( &mut self, result: Result, ctx: &mut ModelContext, ) { match result { Err(err) => ctx.emit(UserWorkspacesEvent::EmailInviteRejected(err)), Ok(result) => { self.on_workspaces_updated(Ok(result), ctx); ctx.emit(UserWorkspacesEvent::EmailInviteSent); } }; ctx.notify(); } pub fn send_email_invites( &mut self, team_uid: ServerId, emails: Vec, ctx: &mut ModelContext, ) { for email in emails { let team_client = self.team_client.clone(); let _ = ctx.spawn( async move { team_client.send_team_invite_email(team_uid, email).await }, Self::on_email_invite_sent, ); } } pub fn on_is_invite_link_enabled_set( &mut self, result: Result, ctx: &mut ModelContext, ) { match result { Err(err) => ctx.emit(UserWorkspacesEvent::ToggleInviteLinksRejected(err)), Ok(result) => { self.on_workspaces_updated(Ok(result), ctx); ctx.emit(UserWorkspacesEvent::ToggleInviteLinksSuccess); } }; ctx.notify(); } pub fn set_is_invite_link_enabled( &mut self, team_uid: ServerId, new_value: bool, ctx: &mut ModelContext, ) { let team_client = self.team_client.clone(); let _ = ctx.spawn( async move { team_client .set_is_invite_link_enabled(team_uid, new_value) .await }, Self::on_is_invite_link_enabled_set, ); } pub fn on_invite_links_reset( &mut self, result: Result, ctx: &mut ModelContext, ) { match result { Err(err) => ctx.emit(UserWorkspacesEvent::ResetInviteLinksRejected(err)), Ok(result) => { self.on_workspaces_updated(Ok(result), ctx); ctx.emit(UserWorkspacesEvent::ResetInviteLinks); } }; ctx.notify(); } pub fn reset_invite_links(&mut self, team_uid: ServerId, ctx: &mut ModelContext) { let team_client = self.team_client.clone(); let _ = ctx.spawn( async move { team_client.reset_invite_links(team_uid).await }, Self::on_invite_links_reset, ); } pub fn on_team_discoverability_set( &mut self, result: Result, ctx: &mut ModelContext, ) { match result { Err(err) => ctx.emit(UserWorkspacesEvent::ToggleTeamDiscoverabilityRejected(err)), Ok(result) => { self.on_workspaces_updated(Ok(result), ctx); ctx.emit(UserWorkspacesEvent::ToggleTeamDiscoverabilitySuccess); } }; ctx.notify(); } pub fn set_team_discoverability( &mut self, team_uid: ServerId, discoverable: bool, ctx: &mut ModelContext, ) { let team_client = self.team_client.clone(); let _ = ctx.spawn( async move { team_client .set_team_discoverability(team_uid, discoverable) .await }, Self::on_team_discoverability_set, ); } pub fn on_join_team_with_team_discovery( &mut self, result: Result, ctx: &mut ModelContext, ) { match result { Err(err) => ctx.emit(UserWorkspacesEvent::JoinTeamWithTeamDiscoveryRejected(err)), Ok(result) => { self.on_workspaces_updated(Ok(result), ctx); ctx.emit(UserWorkspacesEvent::JoinTeamWithTeamDiscoverySuccess); } }; ctx.notify(); } pub fn join_team_with_team_discovery( &mut self, team_uid: ServerId, ctx: &mut ModelContext, ) { let team_client = self.team_client.clone(); let _ = ctx.spawn( async move { team_client.join_team_with_team_discovery(team_uid).await }, Self::on_join_team_with_team_discovery, ); } fn on_fetch_discoverable_teams( &mut self, teams: Result, anyhow::Error>, ctx: &mut ModelContext, ) { match teams { Err(e) => ctx.emit(UserWorkspacesEvent::FetchDiscoverableTeamsRejected(e)), Ok(teams) => { self.update_joinable_teams(teams, ctx); } } } /// Make request to get list of discoverable teams for a user pub fn fetch_discoverable_teams(&mut self, ctx: &mut ModelContext) { let team_client = self.team_client.clone(); let _ = ctx.spawn( async move { team_client.get_discoverable_teams().await }, Self::on_fetch_discoverable_teams, ); } fn on_team_ownership_transferred( &mut self, result: Result, ctx: &mut ModelContext, ) { match result { Err(err) => ctx.emit(UserWorkspacesEvent::TransferTeamOwnershipRejected(err)), Ok(result) => { self.on_workspaces_updated(Ok(result), ctx); ctx.emit(UserWorkspacesEvent::TransferTeamOwnershipSuccess); } }; ctx.notify(); } pub fn transfer_team_ownership( &mut self, new_owner_email: String, ctx: &mut ModelContext, ) { let team_client = self.team_client.clone(); let _ = ctx.spawn( async move { team_client.transfer_team_ownership(new_owner_email).await }, Self::on_team_ownership_transferred, ); } fn on_team_member_role_set( &mut self, result: Result, ctx: &mut ModelContext, ) { match result { Err(err) => ctx.emit(UserWorkspacesEvent::SetTeamMemberRoleRejected(err)), Ok(result) => { self.on_workspaces_updated(Ok(result), ctx); ctx.emit(UserWorkspacesEvent::SetTeamMemberRoleSuccess); } }; ctx.notify(); } pub fn set_team_member_role( &mut self, user_uid: UserUid, team_uid: ServerId, role: MembershipRole, ctx: &mut ModelContext, ) { let team_client = self.team_client.clone(); let _ = ctx.spawn( async move { team_client .set_team_member_role(user_uid, team_uid, role) .await }, Self::on_team_member_role_set, ); } pub fn on_delete_team_invite( &mut self, result: Result, ctx: &mut ModelContext, ) { match result { Err(err) => ctx.emit(UserWorkspacesEvent::DeleteTeamInviteRejected(err)), Ok(result) => { self.on_workspaces_updated(Ok(result), ctx); ctx.emit(UserWorkspacesEvent::DeleteTeamInvite); } }; ctx.notify(); } pub fn delete_team_invite( &mut self, team_uid: ServerId, invitee_email: String, ctx: &mut ModelContext, ) { let team_client = self.team_client.clone(); let _ = ctx.spawn( async move { team_client .delete_team_invite(team_uid, invitee_email) .await }, Self::on_delete_team_invite, ); } pub fn on_generate_upgrade_link( &mut self, result: Result, ctx: &mut ModelContext, ) { match result { Err(err) => ctx.emit(UserWorkspacesEvent::GenerateUpgradeLinkRejected(err)), Ok(upgrade_link) => { ctx.emit(UserWorkspacesEvent::GenerateUpgradeLink(upgrade_link)); } }; ctx.notify(); } pub fn generate_upgrade_link(&mut self, team_uid: ServerId, ctx: &mut ModelContext) { Self::on_generate_upgrade_link( self, Ok(UserWorkspaces::upgrade_link_for_team(team_uid)), ctx, ); } pub fn on_generate_stripe_billing_portal_link( &mut self, result: Result, ctx: &mut ModelContext, ) { match result { Err(err) => ctx.emit(UserWorkspacesEvent::GenerateStripeBillingPortalLinkRejected(err)), Ok(billing_session_link) => { ctx.emit(UserWorkspacesEvent::GenerateStripeBillingPortalLink( billing_session_link, )); } }; ctx.notify(); } pub fn generate_stripe_billing_portal_link( &mut self, team_uid: ServerId, ctx: &mut ModelContext, ) { let workspace_client = self.workspace_client.clone(); let _ = ctx.spawn( async move { workspace_client .generate_stripe_billing_portal_link(team_uid) .await }, Self::on_generate_stripe_billing_portal_link, ); } pub fn update_usage_based_pricing_settings( &mut self, team_uid: ServerId, usage_based_pricing_enabled: bool, max_monthly_spend_cents: Option, ctx: &mut ModelContext, ) { let workspace_client = self.workspace_client.clone(); let _ = ctx.spawn( async move { workspace_client .update_usage_based_pricing_settings( team_uid, usage_based_pricing_enabled, max_monthly_spend_cents, ) .await }, Self::on_update_workspace_metadata, ); } fn on_update_workspace_metadata( &mut self, result: Result, ctx: &mut ModelContext, ) { match result { Ok(result) => { let wrapped = WorkspacesMetadataWithPricing { metadata: result, pricing_info: None, }; self.on_workspaces_updated(Ok(wrapped), ctx); ctx.emit(UserWorkspacesEvent::UpdateWorkspaceSettingsSuccess); } Err(err) => { let err_for_event = anyhow::anyhow!("{}", err); self.on_workspaces_updated(Err(err), ctx); ctx.emit(UserWorkspacesEvent::UpdateWorkspaceSettingsRejected( err_for_event, )); } }; ctx.notify(); } pub fn purchase_addon_credits( &mut self, team_uid: ServerId, credits: i32, ctx: &mut ModelContext, ) { let workspace_client = self.workspace_client.clone(); let _ = ctx.spawn( async move { workspace_client .purchase_addon_credits(team_uid, credits) .await }, Self::on_purchase_addon_credits, ); } fn on_purchase_addon_credits( &mut self, result: Result, ctx: &mut ModelContext, ) { match result { Ok(result) => { let wrapped = WorkspacesMetadataWithPricing { metadata: result, pricing_info: None, }; self.on_workspaces_updated(Ok(wrapped), ctx); ctx.emit(UserWorkspacesEvent::PurchaseAddonCreditsSuccess); } Err(err) => { ctx.emit(UserWorkspacesEvent::PurchaseAddonCreditsRejected( anyhow::anyhow!(err), )); } }; ctx.notify(); } pub fn refresh_ai_overages(&mut self, ctx: &mut ModelContext) { let workspace_client = self.workspace_client.clone(); let _ = ctx.spawn( async move { workspace_client.refresh_ai_overages().await }, Self::on_refresh_ai_overages, ); } pub fn update_addon_credits_settings( &mut self, team_uid: ServerId, auto_reload_enabled: Option, max_monthly_spend_cents: Option, selected_auto_reload_credit_denomination: Option, ctx: &mut ModelContext, ) { let workspace_client = self.workspace_client.clone(); let _ = ctx.spawn( async move { workspace_client .update_addon_credits_settings( team_uid, auto_reload_enabled, max_monthly_spend_cents, selected_auto_reload_credit_denomination, ) .await }, Self::on_update_workspace_metadata, ); } fn on_refresh_ai_overages(&mut self, result: Result, ctx: &mut ModelContext) { match result { Ok(fresh_ai_overages) => { // TODO: We really need to stop having duplicate billing metadata... if let Some(workspace) = self.current_workspace_mut() { workspace.billing_metadata.ai_overages = Some(fresh_ai_overages.clone()); } if let Some(team) = self.current_team_mut() { team.billing_metadata.ai_overages = Some(fresh_ai_overages); } ctx.emit(UserWorkspacesEvent::AiOveragesUpdated); ctx.notify(); } Err(e) => { log::warn!("Failed to refresh AI overages for workspace: {e:?}"); } } } pub fn usage_based_pricing_settings(&self) -> UsageBasedPricingSettings { self.current_workspace() .map(|workspace| workspace.settings.usage_based_pricing_settings.clone()) .unwrap_or_default() } pub fn is_telemetry_force_enabled(&self) -> bool { self.current_team() .map(|team| team.organization_settings.telemetry_settings.force_enabled) .unwrap_or(false) } pub fn is_enterprise_secret_redaction_enabled(&self) -> bool { self.current_team() .map(|team| team.organization_settings.secret_redaction_settings.enabled) .unwrap_or(false) } pub fn get_enterprise_secret_redaction_regex_list(&self) -> Vec { self.current_team() .map(|team| { team.organization_settings .secret_redaction_settings .regexes .clone() }) .unwrap_or_default() } pub fn get_ugc_collection_enablement_setting(&self) -> UgcCollectionEnablementSetting { self.current_team() .map(|team| { team.organization_settings .ugc_collection_settings .setting .clone() }) .unwrap_or_default() } pub fn get_cloud_conversation_storage_enablement_setting(&self) -> AdminEnablementSetting { self.current_team() .map(|team| { team.organization_settings .cloud_conversation_storage_settings .setting .clone() }) .unwrap_or_default() } pub fn is_ai_allowed_in_remote_sessions(&self) -> bool { self.current_team() .map(|team| { team.organization_settings .ai_permissions_settings .allow_ai_in_remote_sessions }) .unwrap_or(true) } pub fn get_remote_session_regex_list(&self) -> Vec { self.current_team() .map(|team| { team.organization_settings .ai_permissions_settings .remote_session_regex_list .clone() }) .unwrap_or_default() } pub fn is_anyone_with_link_sharing_enabled(&self) -> bool { self.current_team() .map(|team| { team.organization_settings .link_sharing_settings .anyone_with_link_sharing_enabled }) .unwrap_or(true) } pub fn is_direct_link_sharing_enabled(&self) -> bool { self.current_team() .map(|team| { team.organization_settings .link_sharing_settings .direct_link_sharing_enabled }) .unwrap_or(true) } /// Returns the codebase context settings, taking into account the organization, /// global AI settings, and codebase-specific settings. /// Prefer this function to determine whether to show indexing-related functionality. pub fn is_codebase_context_enabled(&self, app: &AppContext) -> bool { // If the organization has an explicit setting, respect it and make user toggle irrelevant. // - Enable: forced ON by org, regardless of user preference. // - Disable: forced OFF by org. // - RespectUserSetting: respect the user setting. let org_setting = self.team_allows_codebase_context(); let ai_globally_enabled = AISettings::as_ref(app).is_any_ai_enabled(app); match org_setting { AdminEnablementSetting::Enable => ai_globally_enabled, AdminEnablementSetting::Disable => false, AdminEnablementSetting::RespectUserSetting => { ai_globally_enabled && *CodeSettings::as_ref(app).codebase_context_enabled.value() } } } /// Returns the team-level agent attribution setting. /// /// Use this to decide whether the user's attribution toggle should be locked /// (`Enable`/`Disable`) or editable (`RespectUserSetting`). pub fn get_agent_attribution_setting(&self) -> AdminEnablementSetting { self.current_team() .map(|team| team.organization_settings.enable_warp_attribution.clone()) .unwrap_or_default() } /// Returns only the organization-specific codebase context enablement setting. /// Do not use this function to determine whether codebase context is generally enabled -- /// use `is_codebase_context_enabled` instead. pub fn team_allows_codebase_context(&self) -> AdminEnablementSetting { self.current_team() .map(|team| { team.organization_settings .codebase_context_settings .setting .clone() }) .unwrap_or_default() } /// Updates whether or not session sharing is enabled based on the current team's tier policy. fn update_session_sharing_enablement(&self, ctx: &AppContext) { if cfg!(any(test, feature = "integration_tests")) { return; } // If we have experiment state to unconditionally enable / disable the feature, // then we defer to that. let server_experiments = ServerExperiments::as_ref(ctx); if server_experiments.is_experiment_enabled(&ServerExperiment::SessionSharingControl) || server_experiments.is_experiment_enabled(&ServerExperiment::SessionSharingExperiment) { return; } let is_session_sharing_enabled_via_tier_policy = self .current_team() .and_then(|t| t.billing_metadata.tier.session_sharing_policy) .map(|policy| policy.is_enabled) .unwrap_or(true); FeatureFlag::CreatingSharedSessions.set_enabled(is_session_sharing_enabled_via_tier_policy); } } #[cfg(test)] impl UserWorkspaces { /// Creates a test workspace with a team and sets it as the current workspace. /// Returns the workspace UID and admin UID for use in tests. pub fn setup_test_workspace(&mut self, ctx: &mut ModelContext) { let workspace_uid = WorkspaceUid::from(ServerId::from(1)); let owner_uid = UserUid::new("test_owner"); let workspace_settings = WorkspaceSettings::default(); let workspace = Workspace { uid: workspace_uid, name: "Test Workspace".to_string(), stripe_customer_id: None, teams: vec![Team { uid: ServerId::from(2), name: "Test Team".to_string(), organization_settings: workspace_settings.clone(), billing_metadata: BillingMetadata::default(), members: vec![], invite_code: None, pending_email_invites: vec![], invite_link_domain_restrictions: vec![], stripe_customer_id: None, is_eligible_for_discovery: false, has_billing_history: false, }], members: vec![WorkspaceMember { uid: owner_uid, email: "test@example.com".to_string(), role: MembershipRole::Owner, usage_info: WorkspaceMemberUsageInfo { requests_used_since_last_refresh: 0, request_limit: 1000, is_unlimited: false, is_request_limit_prorated: false, }, }], billing_metadata: BillingMetadata::default(), bonus_grants_purchased_this_month: Default::default(), has_billing_history: false, settings: workspace_settings, invite_code: None, invite_link_domain_restrictions: vec![], pending_email_invites: vec![], is_eligible_for_discovery: false, total_requests_used_since_last_refresh: 0, }; self.update_workspaces(vec![workspace], ctx); self.set_current_workspace_uid(workspace_uid, ctx); } /// Updates the current workspace by applying a mutation function. pub fn update_current_workspace(&mut self, f: F, ctx: &mut ModelContext) where F: FnOnce(&mut Workspace), { if let Some(workspace) = self.current_workspace() { if workspace.teams.is_empty() { panic!("No team found in current workspace. Did you call setup_test_workspace()?"); } let mut new_workspace = workspace.clone(); f(&mut new_workspace); self.update_workspaces(vec![new_workspace], ctx); } else { panic!("No workspace found. Did you call setup_test_workspace()?"); } } pub fn update_sandboxed_agent_settings(&mut self, f: F, ctx: &mut ModelContext) where F: FnOnce(&mut Option), { self.update_current_workspace( |workspace| { if let Some(team) = workspace.teams.first_mut() { f(&mut team.organization_settings.sandboxed_agent_settings); } else { panic!( "No team found in current workspace. Did you call setup_test_workspace()?" ); } }, ctx, ); } pub fn update_ai_autonomy_settings(&mut self, f: F, ctx: &mut ModelContext) where F: FnOnce(&mut AiAutonomySettings), { self.update_current_workspace( |workspace| { if let Some(team) = workspace.teams.first_mut() { f(&mut team.organization_settings.ai_autonomy_settings); } else { panic!( "No team found in current workspace. Did you call setup_test_workspace()?" ); } }, ctx, ); } pub fn update_ai_autonomy_policy_flag(&mut self, enabled: bool, ctx: &mut ModelContext) { self.update_current_workspace( |workspace| { if let Some(team) = workspace.teams.first_mut() { team.billing_metadata.tier.ai_autonomy_policy = Some(AIAutonomyPolicy { is_enabled: enabled, toggleable: true, }); } else { panic!( "No team found in current workspace. Did you call setup_test_workspace()?" ); } }, ctx, ); } } impl Entity for UserWorkspaces { type Event = UserWorkspacesEvent; } /// Mark UserWorkspaces as global application state. impl SingletonEntity for UserWorkspaces {} #[cfg(test)] #[path = "user_workspaces_tests.rs"] mod user_workspaces_tests;