Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions app/src/drive/cloud_action_confirmation_dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const CANCEL_TEXT: &str = "Cancel";
const DELETE_TEAM_TITLE_TEXT: &str = "Are you sure you want to delete this team?";
const LEAVE_TEAM_TITLE_TEXT: &str = "Are you sure you want to leave this team?";
const REMOVE_TEAM_MEMBER_TITLE_TEXT: &str = "Are you sure you want to remove this member?";
const REMOVE_NATIVE_WORKSPACE_TEAM_MEMBER_TITLE_TEXT: &str = "Remove from team?";
const REMOVE_WORKSPACE_MEMBER_TITLE_TEXT: &str = "Remove from workspace?";

const DELETE_TEAM_BODY_TEXT: &str = "Deleting this team will permanently delete it and all of its related content, including billing information or credits. You will not be able to restore them.";
const LEAVE_TEAM_BODY_TEXT: &str = "You will need to be reinvited in order to rejoin.";
Expand All @@ -32,6 +34,8 @@ const DELETE_TEAM_CONFIRM_TEXT: &str = "Yes, delete";
const LEAVE_TEAM_CONFIRM_TEXT: &str = "Yes, leave";
const LEAVE_TEAM_RELOAD_CREDITS_CONFIRM_TEXT: &str = "Leave Team";
const REMOVE_TEAM_MEMBER_RELOAD_CREDITS_CONFIRM_TEXT: &str = "Remove Member";
const REMOVE_NATIVE_WORKSPACE_TEAM_MEMBER_CONFIRM_TEXT: &str = "Remove from team";
const REMOVE_WORKSPACE_MEMBER_CONFIRM_TEXT: &str = "Remove from workspace";

pub enum CloudActionConfirmationDialogEvent {
Cancel,
Expand All @@ -53,6 +57,14 @@ pub enum CloudActionConfirmationDialogVariant {
DeleteTeam,
LeaveTeamReloadCredits,
RemoveTeamMemberReloadCredits,
RemoveNativeWorkspaceTeamMember {
member_email: String,
workspace_name: String,
},
RemoveWorkspaceMember {
member_email: String,
workspace_name: String,
},
#[default]
None,
}
Expand Down Expand Up @@ -97,6 +109,12 @@ impl CloudActionConfirmationDialog {
CloudActionConfirmationDialogVariant::RemoveTeamMemberReloadCredits => {
REMOVE_TEAM_MEMBER_TITLE_TEXT.to_string()
}
CloudActionConfirmationDialogVariant::RemoveNativeWorkspaceTeamMember { .. } => {
REMOVE_NATIVE_WORKSPACE_TEAM_MEMBER_TITLE_TEXT.to_string()
}
CloudActionConfirmationDialogVariant::RemoveWorkspaceMember { .. } => {
REMOVE_WORKSPACE_MEMBER_TITLE_TEXT.to_string()
}
CloudActionConfirmationDialogVariant::None => String::new(),
}
}
Expand All @@ -114,6 +132,22 @@ impl CloudActionConfirmationDialog {
CloudActionConfirmationDialogVariant::RemoveTeamMemberReloadCredits => {
REMOVE_TEAM_MEMBER_RELOAD_CREDITS_BODY_TEXT.to_string()
}
CloudActionConfirmationDialogVariant::RemoveNativeWorkspaceTeamMember {
member_email,
workspace_name,
} => {
format!(
"{member_email} will retain access to the {workspace_name} workspace and any other teams they’re a member of."
)
}
CloudActionConfirmationDialogVariant::RemoveWorkspaceMember {
member_email,
workspace_name,
} => {
format!(
"{member_email} will be removed from all teams in the {workspace_name} workspace and from the workspace itself."
)
}
CloudActionConfirmationDialogVariant::None => String::new(),
}
}
Expand All @@ -133,6 +167,12 @@ impl CloudActionConfirmationDialog {
CloudActionConfirmationDialogVariant::RemoveTeamMemberReloadCredits => {
REMOVE_TEAM_MEMBER_RELOAD_CREDITS_CONFIRM_TEXT.to_string()
}
CloudActionConfirmationDialogVariant::RemoveNativeWorkspaceTeamMember { .. } => {
REMOVE_NATIVE_WORKSPACE_TEAM_MEMBER_CONFIRM_TEXT.to_string()
}
CloudActionConfirmationDialogVariant::RemoveWorkspaceMember { .. } => {
REMOVE_WORKSPACE_MEMBER_CONFIRM_TEXT.to_string()
}
CloudActionConfirmationDialogVariant::None => String::new(),
}
}
Expand Down
47 changes: 47 additions & 0 deletions app/src/drive/cloud_action_confirmation_dialog_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,50 @@ fn native_workspace_leave_preserves_other_access() {
);
assert_eq!(dialog.confirm_button_text(), LEAVE_TEAM_CONFIRM_TEXT);
}

#[test]
fn native_workspace_team_removal_preserves_workspace_access() {
let mut dialog = CloudActionConfirmationDialog::new();
dialog.set_variant(
CloudActionConfirmationDialogVariant::RemoveNativeWorkspaceTeamMember {
member_email: "member@example.com".to_string(),
workspace_name: "Warp".to_string(),
},
);

assert_eq!(dialog.title_text(), "Remove from team?");
assert_eq!(
dialog.body_text(),
"member@example.com will retain access to the Warp workspace and any other teams they’re a member of."
);
assert_eq!(dialog.confirm_button_text(), "Remove from team");
}

#[test]
fn native_workspace_removal_describes_full_scope() {
let mut dialog = CloudActionConfirmationDialog::new();
dialog.set_variant(
CloudActionConfirmationDialogVariant::RemoveWorkspaceMember {
member_email: "member@example.com".to_string(),
workspace_name: "Warp".to_string(),
},
);

assert_eq!(dialog.title_text(), "Remove from workspace?");
assert_eq!(
dialog.body_text(),
"member@example.com will be removed from all teams in the Warp workspace and from the workspace itself."
);
assert_eq!(dialog.confirm_button_text(), "Remove from workspace");
}

#[test]
fn legacy_team_removal_keeps_reload_credit_warning() {
let mut dialog = CloudActionConfirmationDialog::new();
dialog.set_variant(CloudActionConfirmationDialogVariant::RemoveTeamMemberReloadCredits);

assert_eq!(
dialog.body_text(),
"This member will lose access to any remaining reload credits tied to this team. If they rejoin later, they’ll regain access to any unused, non-expired credits."
);
}
47 changes: 45 additions & 2 deletions app/src/server/server_api/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ use warp_graphql::mutations::purchase_addon_credits::{
PurchaseAddonCredits, PurchaseAddonCreditsInput, PurchaseAddonCreditsResult,
PurchaseAddonCreditsVariables,
};
use warp_graphql::mutations::remove_user_from_workspace::{
RemoveUserFromWorkspace, RemoveUserFromWorkspaceInput, RemoveUserFromWorkspaceResult,
RemoveUserFromWorkspaceVariables,
};
use warp_graphql::mutations::stripe_billing_portal::{
StripeBillingPortal, StripeBillingPortalInput, StripeBillingPortalResult,
StripeBillingPortalVariables,
Expand All @@ -23,10 +27,13 @@ use warp_graphql::queries::get_ai_overages_for_workspace::{

use super::ServerApi;
use super::team::TeamClient;
use crate::auth::UserUid;
use crate::server::graphql::{get_request_context, get_user_facing_error_message};
use crate::server::ids::ServerId;
use crate::workspaces::user_workspaces::WorkspacesMetadataResponse;
use crate::workspaces::workspace::AiOverages;
use crate::workspaces::user_workspaces::{
WorkspacesMetadataResponse, WorkspacesMetadataWithPricing,
};
use crate::workspaces::workspace::{AiOverages, WorkspaceUid};

/// Outcome of a successful `purchaseAddonCredits` mutation. Mirrors the
/// server's `PurchaseAddonCreditsResult` union members one-to-one.
Expand Down Expand Up @@ -68,11 +75,47 @@ pub trait WorkspaceClient: 'static + Send + Sync {
max_monthly_spend_cents: Option<i32>,
selected_auto_reload_credit_denomination: Option<i32>,
) -> Result<WorkspacesMetadataResponse>;

async fn remove_user_from_workspace(
&self,
user_uid: UserUid,
workspace_uid: WorkspaceUid,
) -> Result<WorkspacesMetadataWithPricing>;
}

#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
impl WorkspaceClient for ServerApi {
async fn remove_user_from_workspace(
&self,
user_uid: UserUid,
workspace_uid: WorkspaceUid,
) -> Result<WorkspacesMetadataWithPricing> {
let variables = RemoveUserFromWorkspaceVariables {
input: RemoveUserFromWorkspaceInput {
user_uid: user_uid.as_str().into(),
workspace_uid: String::from(workspace_uid).into(),
},
request_context: get_request_context(),
};
let operation = RemoveUserFromWorkspace::build(variables);
let response = self.send_graphql_request(operation, None).await?;

match response.remove_user_from_workspace {
RemoveUserFromWorkspaceResult::RemoveUserFromWorkspaceOutput(output)
if output.success =>
{
TeamClient::workspaces_metadata(self).await
}
RemoveUserFromWorkspaceResult::RemoveUserFromWorkspaceOutput(_) => {
Err(anyhow!("Failed to remove workspace member"))
}
RemoveUserFromWorkspaceResult::UserFacingError(error) => {
Err(anyhow!(get_user_facing_error_message(error)))
}
RemoveUserFromWorkspaceResult::Unknown => Err(anyhow!("Unknown error")),
}
}
async fn generate_stripe_billing_portal_link(&self, team_uid: ServerId) -> Result<String> {
let variables = StripeBillingPortalVariables {
input: StripeBillingPortalInput {
Expand Down
105 changes: 102 additions & 3 deletions app/src/settings_view/teams_page.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ use crate::workspaces::team::{DiscoverableTeam, MembershipRole, Team, TeamDelete
use crate::workspaces::update_manager::{TeamUpdateManager, TeamUpdateManagerEvent};
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
use crate::workspaces::workspace::{
BillingMetadata, CustomerType, DelinquencyStatus, Workspace, WorkspaceSizePolicy,
BillingMetadata, CustomerType, DelinquencyStatus, Workspace, WorkspaceSizePolicy, WorkspaceUid,
};

const TEAM_MEMBERS_HEADER_POSITION_ID: &str = "team_settings:team_members_header";
Expand Down Expand Up @@ -190,6 +190,13 @@ pub enum TeamsPageAction {
RemoveUserFromTeam {
user_uid: UserUid,
team_uid: ServerId,
member_email: String,
},
RemoveUserFromWorkspace {
user_uid: UserUid,
workspace_uid: WorkspaceUid,
member_email: String,
workspace_name: String,
},
ToggleIsInviteLinkEnabled {
team_uid: ServerId,
Expand Down Expand Up @@ -259,6 +266,7 @@ impl TeamsPageAction {
| CreateTeam
| DeletePendingEmailInvitation { .. }
| RemoveUserFromTeam { .. }
| RemoveUserFromWorkspace { .. }
| AddDomainRestrictions { .. }
| DeleteDomainRestriction { .. }
| SendEmailInvites { .. }
Expand All @@ -284,6 +292,7 @@ impl From<&TeamsPageAction> for LoginGatedFeature {
CreateTeam => "Create Team",
DeletePendingEmailInvitation { .. } => "Delete Pending Email Invitation",
RemoveUserFromTeam { .. } => "Remove User From Team",
RemoveUserFromWorkspace { .. } => "Remove User From Workspace",
AddDomainRestrictions { .. } => "Add Domain Restrictions",
DeleteDomainRestriction { .. } => "Delete Domain Restriction",
SendEmailInvites { .. } => "Send Email Invites",
Expand Down Expand Up @@ -516,6 +525,10 @@ enum TeamActionConfirmationTarget {
user_uid: UserUid,
team_uid: ServerId,
},
RemoveUserFromWorkspace {
user_uid: UserUid,
workspace_uid: WorkspaceUid,
},
}

pub struct TeamsPageView {
Expand Down Expand Up @@ -578,8 +591,27 @@ impl TypedActionView for TeamsPageView {
TeamsPageAction::CopyLink(link) => self.copy_invite_link(link, ctx),
TeamsPageAction::LeaveTeam => self.leave_team(ctx),
TeamsPageAction::CreateTeam => self.create_team(ctx),
TeamsPageAction::RemoveUserFromTeam { user_uid, team_uid } => {
if FeatureFlag::BillingAndUsagePageV2.is_enabled() {
TeamsPageAction::RemoveUserFromTeam {
user_uid,
team_uid,
member_email,
} => {
let workspace = self.user_workspaces.as_ref(ctx).current_workspace();
if let Some(workspace) =
workspace.filter(|workspace| workspace.is_native_workspaces_enabled())
{
self.show_team_action_confirmation(
CloudActionConfirmationDialogVariant::RemoveNativeWorkspaceTeamMember {
member_email: member_email.clone(),
workspace_name: workspace.name.clone(),
},
TeamActionConfirmationTarget::RemoveUser {
user_uid: *user_uid,
team_uid: *team_uid,
},
ctx,
);
} else if FeatureFlag::BillingAndUsagePageV2.is_enabled() {
self.show_team_action_confirmation(
CloudActionConfirmationDialogVariant::RemoveTeamMemberReloadCredits,
TeamActionConfirmationTarget::RemoveUser {
Expand All @@ -592,6 +624,24 @@ impl TypedActionView for TeamsPageView {
self.remove_user_from_team(*user_uid, *team_uid, ctx);
}
}
TeamsPageAction::RemoveUserFromWorkspace {
user_uid,
workspace_uid,
member_email,
workspace_name,
} => {
self.show_team_action_confirmation(
CloudActionConfirmationDialogVariant::RemoveWorkspaceMember {
member_email: member_email.clone(),
workspace_name: workspace_name.clone(),
},
TeamActionConfirmationTarget::RemoveUserFromWorkspace {
user_uid: *user_uid,
workspace_uid: *workspace_uid,
},
ctx,
);
}
TeamsPageAction::ChangeInviteViewOption(view_option) => {
self.change_invite_view_option(view_option, ctx);
}
Expand Down Expand Up @@ -1187,6 +1237,17 @@ impl TeamsPageView {
ctx,
);
}
UserWorkspacesEvent::RemoveUserFromWorkspaceSuccess => {
self.update_team_members_state(ctx);
self.show_success("Removed workspace member", ctx);
}
UserWorkspacesEvent::RemoveUserFromWorkspaceRejected(err) => {
self.show_error(
format!("Failed to remove workspace member: {err}"),
Some(err),
ctx,
);
}
UserWorkspacesEvent::UpdateWorkspaceSettingsSuccess => {
// as of right now, this is only emitted on the billing & usage page
}
Expand Down Expand Up @@ -1293,6 +1354,12 @@ impl TeamsPageView {
TeamActionConfirmationTarget::RemoveUser { user_uid, team_uid } => {
self.remove_user_from_team(user_uid, team_uid, ctx);
}
TeamActionConfirmationTarget::RemoveUserFromWorkspace {
user_uid,
workspace_uid,
} => {
self.remove_user_from_workspace(user_uid, workspace_uid, ctx);
}
}
ctx.notify();
}
Expand Down Expand Up @@ -1725,6 +1792,18 @@ impl TeamsPageView {
});
}

fn remove_user_from_workspace(
&mut self,
user_uid: UserUid,
workspace_uid: WorkspaceUid,
ctx: &mut ViewContext<Self>,
) {
self.user_workspaces
.update(ctx, move |user_workspaces, ctx| {
user_workspaces.remove_user_from_workspace(user_uid, workspace_uid, ctx);
});
}

fn leave_team(&mut self, ctx: &mut ViewContext<Self>) {
let team_uid = self
.user_workspaces
Expand Down Expand Up @@ -2020,6 +2099,8 @@ impl TeamsPageView {
let current_user_has_admin_permissions =
Self::has_admin_permissions(team, workspace, current_user_email);
let current_user_has_owner_permissions = team.has_owner_permissions(current_user_email);
let current_user_is_workspace_admin = workspace.is_workspace_admin(current_user_email);
let native_workspaces_enabled = workspace.is_native_workspaces_enabled();

// pending email invites
team.pending_email_invites.iter().for_each(|email_invite| {
Expand Down Expand Up @@ -2124,6 +2205,24 @@ impl TeamsPageView {
action: TeamsPageAction::RemoveUserFromTeam {
user_uid: member.uid,
team_uid: team.uid,
member_email: member.email.clone(),
},
});
}

if native_workspaces_enabled
&& current_user_is_workspace_admin
&& !team_member_has_owner_permissions
&& member_workspace_role != Some(MembershipRole::Owner)
{
actions.push(ItemAction {
icon: Icon::X,
label: "Remove from workspace".to_string(),
action: TeamsPageAction::RemoveUserFromWorkspace {
user_uid: member.uid,
workspace_uid: workspace.uid,
member_email: member.email.clone(),
workspace_name: workspace.name.clone(),
},
});
}
Expand Down
Loading
Loading