1644 lines
59 KiB
Rust
1644 lines
59 KiB
Rust
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<DiscoverableTeam>),
|
|
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<Option<WorkspaceUid>>,
|
|
workspaces: Tracked<Vec<Workspace>>,
|
|
joinable_teams: Vec<DiscoverableTeam>,
|
|
team_client: Arc<dyn TeamClient>,
|
|
workspace_client: Arc<dyn WorkspaceClient>,
|
|
}
|
|
|
|
/// 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<Workspace>,
|
|
/// The list of discoverable teams that the user can join.
|
|
pub joinable_teams: Vec<DiscoverableTeam>,
|
|
/// The list of experiments applicable to the user.
|
|
pub experiments: Option<Vec<ServerExperiment>>,
|
|
/// 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<FeatureModelChoice>,
|
|
}
|
|
|
|
// 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<galaxy_graphql::billing::PricingInfo>,
|
|
}
|
|
|
|
pub struct CreateTeamResponse {
|
|
pub workspace: Workspace,
|
|
pub team: Team,
|
|
}
|
|
|
|
impl UserWorkspaces {
|
|
#[cfg(test)]
|
|
pub fn mock(
|
|
team_client: Arc<dyn TeamClient>,
|
|
workspace_client: Arc<dyn WorkspaceClient>,
|
|
cached_workspaces: Vec<Workspace>,
|
|
_ctx: &mut ModelContext<Self>,
|
|
) -> 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 {
|
|
Self::mock(
|
|
Arc::new(MockTeamClient::new()),
|
|
Arc::new(MockWorkspaceClient::new()),
|
|
vec![],
|
|
ctx,
|
|
)
|
|
}
|
|
|
|
pub fn new(
|
|
team_client: Arc<dyn TeamClient>,
|
|
workspace_client: Arc<dyn WorkspaceClient>,
|
|
cached_workspaces: Vec<Workspace>,
|
|
current_workspace_uid: Option<WorkspaceUid>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> 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<ServerId> {
|
|
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<Workspace> {
|
|
&self.workspaces
|
|
}
|
|
|
|
pub fn set_current_workspace_uid(
|
|
&mut self,
|
|
workspace_uid: WorkspaceUid,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
*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<SandboxedAgentSettings> {
|
|
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<Space> {
|
|
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<Space> {
|
|
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<Owner> {
|
|
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<Owner> {
|
|
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<Workspace>, ctx: &mut ModelContext<Self>) {
|
|
// 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<Self>) {
|
|
// 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<DiscoverableTeam>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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<WorkspacesMetadataWithPricing>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<WorkspacesMetadataWithPricing>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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<String>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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<WorkspacesMetadataWithPricing>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<WorkspacesMetadataWithPricing>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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<String>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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<WorkspacesMetadataWithPricing>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<WorkspacesMetadataWithPricing>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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<Self>) {
|
|
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<WorkspacesMetadataWithPricing>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<WorkspacesMetadataWithPricing>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<Vec<DiscoverableTeam>, anyhow::Error>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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<Self>) {
|
|
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<WorkspacesMetadataWithPricing>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<WorkspacesMetadataWithPricing>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<WorkspacesMetadataWithPricing>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<String>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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>) {
|
|
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<String>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<u32>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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<WorkspacesMetadataResponse>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<WorkspacesMetadataResponse>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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<Self>) {
|
|
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<bool>,
|
|
max_monthly_spend_cents: Option<i32>,
|
|
selected_auto_reload_credit_denomination: Option<i32>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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<AiOverages>, ctx: &mut ModelContext<Self>) {
|
|
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<EnterpriseSecretRegex> {
|
|
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<Regex> {
|
|
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<Self>) {
|
|
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<F>(&mut self, f: F, ctx: &mut ModelContext<Self>)
|
|
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<F>(&mut self, f: F, ctx: &mut ModelContext<Self>)
|
|
where
|
|
F: FnOnce(&mut Option<SandboxedAgentSettings>),
|
|
{
|
|
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<F>(&mut self, f: F, ctx: &mut ModelContext<Self>)
|
|
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>) {
|
|
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;
|